Compare commits

..

20 Commits

Author SHA1 Message Date
uberwald 53f9c33419 chore: gitignore LevelDB internal files, stop tracking auto-generated LDB bookkeeping
Release Creation / build (release) Successful in 46s
2026-06-11 23:00:45 +02:00
uberwald 06eba5f835 fix: show spell tier dialog on character sheet cast; duplicate rollTarget to prevent Item mutation 2026-06-11 22:56:54 +02:00
uberwald 46fa2d15a3 fix: allow defender to react when attacker boosts past defense via cross-client socket 2026-06-11 22:41:54 +02:00
uberwald 8aae7bada0 fix: pre-check 'Save against spell' checkbox when defense originates from spell attack 2026-06-11 22:17:37 +02:00
uberwald ceb62bca3f fix: add missing class lethal-luck-grit-hud to template so JS selector matches 2026-06-11 22:03:56 +02:00
uberwald 110ac65ba5 fix: replace hardcoded French bleeding notifications with i18n keys 2026-06-11 21:50:19 +02:00
uberwald 9b75fd4d96 feat: combat-tracker-driven bleeding (HP loss per wound per round) 2026-06-11 21:49:35 +02:00
uberwald 141d6048e0 Fix triple damage issue 2026-06-11 21:32:26 +02:00
uberwald ea7acf6bf8 Fix hp < 0 and D30 with D20 bonus roll 2026-06-11 20:48:46 +02:00
uberwald c20750caa7 Minor fixes regarding rolls and chat messages
Release Creation / build (release) Successful in 48s
2026-06-10 20:17:45 +02:00
uberwald ce630feb51 feat: D30 combat effects, spell tiers, small damage removal, token HUD luck/grit
- Replace Knockback with Internal Injury on D30 (5, 10, 15); remove Shield Bash from D30 counter-attacks
- Eliminate small weapon damage: keep only medium damage labelled Damage in sheets, rolls, and chat
- D30 bonus dice (20, 27, 30) auto-resolved before grit/luck/shield decisions; choice dialogs for special strikes
- D30 combat effects: bleeding wounds, damage ×2/×3 before DR, DR ×2/×3 with component picker dialog
- Add hp.wounds to monster schema for bleeding support
- Show Save against spell? checkbox for all save rolls (not just magic users)
- Fix mulligan restart: persistent D30 process flags prevent double-application and allow both sides to react
- For Dice So Nice, show main roll animation before explosion dice for correct ordering
- Spell tier selection: force Standard/Overpowered choice at cast time, tier-specific aether cost, only chosen damage button shown
- Add +1/−1 luck and grit controls to Token HUD
- Fix inconsistent indentation, remove duplicate i18n key, remove unused includesShield return
2026-06-10 07:53:51 +02:00
uberwald b35b684d50 NEgative values for HP and weapon bonuses 2026-06-06 16:11:36 +02:00
uberwald f6fb0b68b8 Fix spell rolls again
Release Creation / build (release) Successful in 47s
2026-05-25 20:41:00 +02:00
uberwald e45edd60c4 FIx spell order and dual rollll for spell damages
Release Creation / build (release) Successful in 1m6s
2026-05-25 12:29:39 +02:00
uberwald d389a85a9f Fix ranged attacks again
Release Creation / build (release) Successful in 43s
2026-05-24 09:42:07 +02:00
uberwald c217490a5b Fix ranged attacks again
Release Creation / build (release) Has been cancelled
2026-05-24 09:41:06 +02:00
uberwald 38eb1a8d3d Add ranged actions for monsters
Release Creation / build (release) Successful in 54s
2026-05-23 19:10:10 +02:00
uberwald 4724cdf2bb VArious fixes for rolls and ranged attacks
Release Creation / build (release) Successful in 44s
2026-05-23 09:08:16 +02:00
uberwald 6d06c8ddad Various fixes for spell and ranged attacks 2026-05-23 00:21:05 +02:00
uberwald 2770774aa3 Various fixes for spell and ranged attacks 2026-05-23 00:11:58 +02:00
59 changed files with 1866 additions and 594 deletions
+1
View File
@@ -0,0 +1 @@
packs/** filter=lfs diff=lfs merge=lfs -text
+1 -1
View File
@@ -59,5 +59,5 @@ jobs:
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/${{gitea.repository}}/releases/download/${{github.event.release.tag_name}}/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: "13" compatibility-minimum: "14"
compatibility-verified: "14" compatibility-verified: "14"
+8
View File
@@ -9,3 +9,11 @@ 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-*
+6
View File
@@ -0,0 +1,6 @@
{
"cSpell.words": [
"biodata",
"LETHALFANTASY"
]
}
+62
View File
@@ -0,0 +1,62 @@
# 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 */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
+11 -3
View File
@@ -471,6 +471,8 @@
"range": "Range", "range": "Range",
"rangeDefenseDialog": "Ranged defense dialog", "rangeDefenseDialog": "Ranged defense dialog",
"rangeDefenseRoll": "Ranged defense roll", "rangeDefenseRoll": "Ranged defense roll",
"rangeAttackDialog": "Ranged attack dialog",
"rangeAttackRoll": "Ranged attack roll",
"rangedAttackDefense": "Ranged attack defense", "rangedAttackDefense": "Ranged attack defense",
"resource": "Resource", "resource": "Resource",
"resources": "Resources", "resources": "Resources",
@@ -503,8 +505,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",
@@ -624,7 +624,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": {}
@@ -826,6 +828,12 @@
"cost": { "cost": {
"label": "Cost" "label": "Cost"
}, },
"costOverpowered": {
"label": "Cost (Overpowered)"
},
"costOverpowered2": {
"label": "Cost (Overpowered 2)"
},
"description": { "description": {
"label": "Description" "label": "Description"
}, },
+636 -289
View File
File diff suppressed because it is too large Load Diff
+3 -13
View File
@@ -268,12 +268,12 @@ export default class LethalFantasyCharacterSheet extends LethalFantasyActorSheet
{ {
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",
@@ -286,17 +286,7 @@ 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
await roll.toMessage()
} }
static #onCreateEquipment(event, target) { static #onCreateEquipment(event, target) {
+68 -25
View File
@@ -108,6 +108,10 @@
} }
], ],
"description": "Possible Flawless or Legendary Defense or Add D20E to Defense" "description": "Possible Flawless or Legendary Defense or Add D20E to Defense"
},
"saving_throws": {
"type": "save_auto_success",
"description": "Saving Throw Succeeds Regardless of Opposing Roll"
} }
}, },
"29": { "29": {
@@ -145,6 +149,11 @@
"type": "gain_grit", "type": "gain_grit",
"amount": 1, "amount": 1,
"description": "Gain 1 Grit" "description": "Gain 1 Grit"
},
"saving_throws": {
"type": "gain_grit",
"amount": 1,
"description": "Gain 1 Grit"
} }
}, },
"28": { "28": {
@@ -199,6 +208,12 @@
"amount": 1, "amount": 1,
"target": "skill", "target": "skill",
"description": "Add 1 to Skill Roll" "description": "Add 1 to Skill Roll"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 1,
"target": "save",
"description": "Add 1 to Saving Throw"
} }
}, },
"21": { "21": {
@@ -239,6 +254,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"
},
"saving_throws": {
"type": "bonus_dice",
"dice": "D6",
"target": "save",
"description": "Granted D6 (1-6) Saving Throw Modifier for this Saving Throw Attempt"
} }
}, },
"20": { "20": {
@@ -325,7 +346,7 @@
"target": "spell_defense" "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" "description": "Possible 20/20 Spell defense that Saves Against Any Magical Attack Except a Lethal Magical Strike or add D12 to spell defense"
}, },
"skill_rolls": { "skill_rolls": {
"type": "bonus_flat", "type": "bonus_flat",
@@ -349,6 +370,12 @@
} }
], ],
"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"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 20,
"target": "save",
"description": "20 Added to Saving Throw"
} }
}, },
"15": { "15": {
@@ -359,10 +386,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 +399,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 +413,15 @@
"type": "counter_attack", "type": "counter_attack",
"options": [ "options": [
"kick", "kick",
"punch", "punch"
"shield_bash"
], ],
"description": "Kick, Punch or Shield Bash" "description": "Kick or Punch"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 1,
"target": "save",
"description": "Add 1 to Saving Throw"
} }
}, },
"13": {}, "13": {},
@@ -413,10 +444,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 +457,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 +471,15 @@
"type": "counter_attack", "type": "counter_attack",
"options": [ "options": [
"kick", "kick",
"punch", "punch"
"shield_bash"
], ],
"description": "Kick, Punch or Shield Bash" "description": "Kick or Punch"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 1,
"target": "save",
"description": "Add 1 to Saving Throw"
} }
}, },
"8": { "8": {
@@ -475,6 +510,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"
},
"saving_throws": {
"type": "mulligan",
"description": "Mulligan, Can Re-Roll This Saving Throw"
} }
}, },
"7": { "7": {
@@ -496,10 +535,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 +548,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 +562,15 @@
"type": "counter_attack", "type": "counter_attack",
"options": [ "options": [
"kick", "kick",
"punch", "punch"
"shield_bash"
], ],
"description": "Kick, Punch, or Shield Bash" "description": "Kick or Punch"
},
"saving_throws": {
"type": "bonus_flat",
"amount": 1,
"target": "save",
"description": "Add 1 to Saving Throw"
} }
}, },
"3": { "3": {
+9 -1
View File
@@ -85,11 +85,18 @@ export const RANGE_CHOICES = {
"pointblank": { label: "Point Blank (Special)", value: "pointblank" }, "pointblank": { label: "Point Blank (Special)", value: "pointblank" },
"short": { label: "Short (+0)", value: "0" }, "short": { label: "Short (+0)", value: "0" },
"medium": { label: "Medium (Red +5)", value: "+5" }, "medium": { label: "Medium (Red +5)", value: "+5" },
"long": { label: "Long (Purle +7)", value: "+7" }, "long": { label: "Long (Purple +7)", value: "+7" },
"extreme": { label: "Extreme (Grey +9)", value: "+9" }, "extreme": { label: "Extreme (Grey +9)", value: "+9" },
"beyondskill": { label: "Beyond Skill (Blue +11)", value: "beyondskill" } "beyondskill": { label: "Beyond Skill (Blue +11)", value: "beyondskill" }
} }
export const ATTACKER_MOVEMENT_CHOICES = {
"none": { label: "None / Stationary (D20E Favor)", favor: true, value: "2D20kh" },
"walk": { label: "Walk (D20E)", value: "D20" },
"incombat": { label: "In Combat (D20E)", value: "D20" },
"run": { label: "Jog/Run/Sprint (D20E Disfavor)", disfavor: true, value: "2D20kl" }
}
export const ATTACKER_AIM_CHOICES = { export const ATTACKER_AIM_CHOICES = {
"simple": { label: "Simple (+0)", value: "0" }, "simple": { label: "Simple (+0)", value: "0" },
"careful": { label: "Careful (Red +5)", value: "+4" }, "careful": { label: "Careful (Red +5)", value: "+4" },
@@ -321,6 +328,7 @@ export const SYSTEM = {
RANGE_CHOICES, RANGE_CHOICES,
FAVOR_CHOICES, FAVOR_CHOICES,
ATTACKER_AIM_CHOICES, ATTACKER_AIM_CHOICES,
ATTACKER_MOVEMENT_CHOICES,
MORTAL_CHOICES, MORTAL_CHOICES,
SPELL_CRITICAL, SPELL_CRITICAL,
MIRACLE_TYPES, MIRACLE_TYPES,
+54 -17
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) {
hp = 0
}
this.update({ "system.hp.value": hp }) this.update({ "system.hp.value": hp })
} }
/* *************************************************/ /* *************************************************/
computeDamageReduction() { getNaturalDR() {
// 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 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() {
if (this.type === "monster") {
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,7 +162,7 @@ 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) console.log("Preparing roll", rollType, rollKey, rollDice, defenderId)
let rollTarget let rollTarget
switch (rollType) { switch (rollType) {
@@ -196,10 +205,38 @@ 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,
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 +259,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)
@@ -261,9 +297,10 @@ export default class LethalFantasyActor extends Actor {
weapon: weapon, weapon: weapon,
weaponSkillModifier: skill.weaponSkillModifier, weaponSkillModifier: skill.weaponSkillModifier,
rollKey: rollKey, rollKey: rollKey,
combat: foundry.utils.duplicate(this.system.combat) combat: foundry.utils.duplicate(this.system.combat),
isRangedAttack: weapon.system.weaponType === "ranged"
} }
if (rollType === "weapon-damage-small" || rollType === "weapon-damage-medium") { if (rollType === "weapon-damage") {
rollTarget.grantedDice = this.system.granted.damageDice rollTarget.grantedDice = this.system.granted.damageDice
} }
if (rollType === "weapon-attack") { if (rollType === "weapon-attack") {
@@ -286,7 +323,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 -8
View File
@@ -25,7 +25,8 @@ export default class D30Roll {
RANGED_DEFENSE: "ranged_defense", RANGED_DEFENSE: "ranged_defense",
ARCANE_SPELL_ATTACK: "arcane_spell_attack", ARCANE_SPELL_ATTACK: "arcane_spell_attack",
ARCANE_SPELL_DEFENSE: "arcane_spell_defense", ARCANE_SPELL_DEFENSE: "arcane_spell_defense",
SKILL_ROLLS: "skill_rolls" SKILL_ROLLS: "skill_rolls",
SAVING_THROWS: "saving_throws"
} }
/** /**
@@ -111,17 +112,16 @@ export default class D30Roll {
if (externalType === "weapon-attack") { if (externalType === "weapon-attack") {
if (!weapon) { if (!weapon) {
console.warn("D30Roll | Weapon object required for weapon-attack type") console.warn("D30Roll | Weapon object required for weapon-attack type")
return this.ROLL_TYPES.MELEE_ATTACK // Default to melee // Fall through to use options.isRanged if available, otherwise default melee
} }
return weapon.system?.weaponType === "ranged" return (options.isRanged || weapon?.system?.weaponType === "ranged")
? this.ROLL_TYPES.RANGED_ATTACK ? this.ROLL_TYPES.RANGED_ATTACK
: this.ROLL_TYPES.MELEE_ATTACK : this.ROLL_TYPES.MELEE_ATTACK
} }
// Monster attacks - default to melee // Monster attacks - check options.isRanged (set from rollTarget.attackMode) or weapon type
if (externalType === "monster-attack") { if (externalType === "monster-attack") {
// Check if weapon object has range information if (options.isRanged || weapon?.system?.weaponType === "ranged") {
if (weapon?.system?.weaponType === "ranged") {
return this.ROLL_TYPES.RANGED_ATTACK return this.ROLL_TYPES.RANGED_ATTACK
} }
return this.ROLL_TYPES.MELEE_ATTACK return this.ROLL_TYPES.MELEE_ATTACK
@@ -138,11 +138,15 @@ export default class D30Roll {
} }
// 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 options.isSpellSave ? this.ROLL_TYPES.ARCANE_SPELL_DEFENSE : this.ROLL_TYPES.SAVING_THROWS
}
// 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
+254 -24
View File
@@ -237,6 +237,7 @@ export default class LethalFantasyRoll extends Roll {
baseFormula = "D20" baseFormula = "D20"
hasModifier = true hasModifier = true
hasChangeDice = false hasChangeDice = false
hasFavor = true
options.rollTarget.value = options.rollTarget.actorModifiers.levelSpellModifier + options.rollTarget.actorModifiers.intSpellModifier options.rollTarget.value = options.rollTarget.actorModifiers.levelSpellModifier + options.rollTarget.actorModifiers.intSpellModifier
options.rollTarget.charModifier = options.rollTarget.actorModifiers.intSpellModifier options.rollTarget.charModifier = options.rollTarget.actorModifiers.intSpellModifier
hasStaticModifier = options.rollType === "spell-power" hasStaticModifier = options.rollType === "spell-power"
@@ -253,6 +254,7 @@ export default class LethalFantasyRoll extends Roll {
dice = "1D20" dice = "1D20"
baseFormula = "D20" baseFormula = "D20"
hasChangeDice = false hasChangeDice = false
hasFavor = true
options.rollTarget.value = options.rollTarget.actorModifiers.levelMiracleModifier + options.rollTarget.actorModifiers.chaMiracleModifier options.rollTarget.value = options.rollTarget.actorModifiers.levelMiracleModifier + options.rollTarget.actorModifiers.chaMiracleModifier
options.rollTarget.charModifier = options.rollTarget.actorModifiers.chaMiracleModifier options.rollTarget.charModifier = options.rollTarget.actorModifiers.chaMiracleModifier
hasStaticModifier = options.rollType === "miracle-power" hasStaticModifier = options.rollType === "miracle-power"
@@ -283,13 +285,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")) { dice = options.rollTarget.weapon.system.damage.damageM
options.damageSmall = true
dice = options.rollTarget.weapon.system.damage.damageS
} else {
options.damageMedium = true
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
@@ -336,6 +332,10 @@ export default class LethalFantasyRoll extends Roll {
let modifier = "+0" let modifier = "+0"
let targetName let targetName
// True for any ranged attack: PC weapon (ranged type) or monster attack (ranged mode)
const isRangedAttack = (options.rollType === "weapon-attack" && options.rollTarget?.weapon?.system?.weaponType === "ranged")
|| (options.rollType === "monster-attack" && options.rollTarget?.attackMode === "ranged")
let dialogContext = { let dialogContext = {
rollType: options.rollType, rollType: options.rollType,
rollTarget: options.rollTarget, rollTarget: options.rollTarget,
@@ -358,9 +358,10 @@ export default class LethalFantasyRoll extends Roll {
dice, dice,
hasTarget: options.hasTarget, hasTarget: options.hasTarget,
modifier, modifier,
saveSpell: false, saveSpell: game.lethalFantasy?.spellDefense ?? false,
favor: "none", favor: "none",
targetName targetName,
isRangedAttack
} }
let rollContext let rollContext
if (options.rollContext) { if (options.rollContext) {
@@ -522,8 +523,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,
@@ -592,12 +591,17 @@ export default class LethalFantasyRoll extends Roll {
} }
options.D30result = rollD30.total options.D30result = rollD30.total
// Récupérer le message D30 correspondant // Compute isRanged for D30: covers defense (isRangedDefense), monster ranged attacks (attackMode),
// and PC weapon attacks (isRangedAttack or weaponType)
const isRangedForD30 = options.isRangedDefense
|| options.rollTarget?.attackMode === "ranged"
|| options.rollTarget?.isRangedAttack === true
|| options.rollTarget?.weapon?.system?.weaponType === "ranged"
const d30Message = D30Roll.getResult( const d30Message = D30Roll.getResult(
rollD30.total, rollD30.total,
options.rollType, options.rollType,
options.rollTarget?.weapon, options.rollTarget?.weapon,
{ isRanged: options.isRangedDefense } { isRanged: isRangedForD30, isSpellSave: saveSpell }
) )
options.D30message = d30Message options.D30message = d30Message
} }
@@ -616,9 +620,6 @@ export default class LethalFantasyRoll extends Roll {
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)
@@ -661,6 +662,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.
@@ -775,9 +780,18 @@ 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", 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)
@@ -882,6 +896,29 @@ 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,
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
@@ -903,7 +940,8 @@ export default class LethalFantasyRoll extends Roll {
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)
let message = `Casting time : ${currentAction.name}, count : ${time}/${time} — ready to cast next second !`
ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) }) ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.castingTime = 1 currentAction.castingTime = 1
currentAction.spellStatus = "toBeCasted" currentAction.spellStatus = "toBeCasted"
@@ -980,7 +1018,7 @@ export default class LethalFantasyRoll extends Roll {
} }
// 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 }) }) ChatMessage.create({ content: message, speaker: ChatMessage.getSpeaker({ actor: combatant.actor }) })
currentAction.progressionCount += 1 currentAction.progressionCount += 1
@@ -1126,6 +1164,8 @@ export default class LethalFantasyRoll extends Roll {
options = { ...options, ...rollContext } options = { ...options, ...rollContext }
options.rollName = "Ranged Defense" options.rollName = "Ranged Defense"
options.rollType = "weapon-defense" options.rollType = "weapon-defense"
options.type = options.rollType // Required: this.type reads options.type
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
const rollBase = new this(rollContext.movement, options.data, rollData) const rollBase = new this(rollContext.movement, options.data, rollData)
const rollModifier = new Roll(modifierFormula, options.data, rollData) const rollModifier = new Roll(modifierFormula, options.data, rollData)
@@ -1193,6 +1233,161 @@ export default class LethalFantasyRoll extends Roll {
return rollBase return rollBase
} }
/**
* Prompts the GM for ranged attack context (movement, range, target size, aim) when a monster
* attacks with a ranged weapon, then evaluates an exploding D20 attack roll with the resulting modifiers.
*
* @param {Object} options Options for the roll.
* @param {string} options.actorId The attacker actor ID.
* @param {string} options.actorName The attacker actor name.
* @param {Object} options.rollTarget The rollTarget containing attackModifier and related data.
* @returns {Promise<LethalFantasyRoll|null>} The resulting roll, or null if cancelled.
*/
static async promptRangedAttack(options = {}) {
const rollModes = foundry.utils.duplicate(CONFIG.ChatMessage.modes)
const fieldRollMode = new foundry.data.fields.StringField({
choices: rollModes,
blank: false,
default: "public",
})
let dialogContext = {
attackerMovementChoices: SYSTEM.ATTACKER_MOVEMENT_CHOICES,
rangeChoices: SYSTEM.RANGE_CHOICES,
sizeChoices: SYSTEM.SIZE_CHOICES,
attackerAimChoices: SYSTEM.ATTACKER_AIM_CHOICES,
movement: "none",
range: "short",
size: "+5",
attackerAim: "simple",
fieldRollMode,
rollModes
}
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/range-attack-dialog.hbs", dialogContext)
const label = game.i18n.localize("LETHALFANTASY.Label.rangeAttackRoll")
const rollContext = await foundry.applications.api.DialogV2.wait({
window: { title: "Ranged Attack" },
classes: ["lethalfantasy"],
content,
buttons: [
{
label,
callback: (event, button) => {
const output = Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
return output
},
},
],
rejectClose: false
})
if (rollContext === null) return null
// Handle pointblank: attacker at point blank gets favor (standing still easier to aim)
if (rollContext.range === "pointblank") {
rollContext.movement = rollContext.movement.replace("kh", "")
rollContext.movement = rollContext.movement.replace("kl", "")
rollContext.movement += "kh" // Favor for attacker at point blank
rollContext.range = "0"
}
// Handle beyondskill: extreme range gives disfavor to attacker
if (rollContext.range === "beyondskill") {
rollContext.movement = rollContext.movement.replace("kh", "")
rollContext.movement = rollContext.movement.replace("kl", "")
rollContext.movement += "kl" // Disfavor for attacker beyond skill range
rollContext.range = "+11"
}
// Compute contextual penalty: range + target_size, reduced by aim bonus and attack modifier
const attackModifier = options.rollTarget?.attackModifier ?? 0
const contextualPenalty = Number(rollContext.range) + Number(rollContext.size)
const aimBonus = Number(rollContext.attackerAim || 0)
const fullModifier = contextualPenalty - aimBonus - attackModifier
let modifierFormula
if (fullModifier === 0) {
modifierFormula = "0"
} else {
const modAbs = Math.abs(fullModifier)
modifierFormula = `D${modAbs + 1} -1`
}
const rollData = { ...rollContext }
options = { ...options, ...rollContext }
options.rollName = "Ranged Attack"
options.rollType = options.rollType || "monster-attack"
options.type = options.rollType // Required: this.type reads options.type, used to build weaponDamageOptions in toHTML
options.rollMode = rollContext.visibility // Required: callers pass roll.options.rollMode to toMessage
options.isRangedAttack = true
const rollBase = new this(rollContext.movement, options.data, rollData)
const rollModifier = new Roll(modifierFormula, options.data, rollData)
rollModifier.evaluate()
await rollBase.evaluate()
const rollD30 = await new Roll("1D30").evaluate()
options.D30result = rollD30.total
options.D30message = D30Roll.getResult(rollD30.total, options.rollType, undefined, { isRanged: true })
// Determine favor from dice formula
let badResult = 0
if (rollContext.movement.includes("kh")) {
rollData.favor = "favor"
badResult = Math.min(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 20)
}
if (rollContext.movement.includes("kl")) {
rollData.favor = "disfavor"
badResult = Math.max(rollBase.terms[0].results[0].result, rollBase.terms[0].results[1]?.result || 1)
}
const dice = rollContext.movement
const maxValue = 20
let rollTotal = -1
let diceResults = []
let diceResult = rollBase.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}`, value: diceResult })
let diceSum = diceResult
// Exploding dice
while (diceResult === maxValue) {
const r = await new Roll(dice).evaluate()
diceResult = r.dice[0].results[0].result
diceResults.push({ dice: `${dice.toUpperCase()}-1`, value: diceResult - 1 })
diceSum += (diceResult - 1)
}
if (fullModifier !== 0) {
diceResults.push({ dice: `${rollModifier.formula.toUpperCase()}`, value: rollModifier.total })
if (fullModifier > 0) {
// Net penalty: subtract from roll
rollTotal = Math.max(diceSum - rollModifier.total, 0)
} else {
// Net bonus: add to roll
rollTotal = diceSum + rollModifier.total
}
} else {
rollTotal = diceSum
}
rollBase.options = { ...rollBase.options, ...options }
rollBase.options.resultType = undefined
rollBase.options.rollTotal = rollTotal
rollBase.options.diceResults = diceResults
rollBase.options.rollTarget = options.rollTarget
rollBase.options.titleFormula = `1D20E + ${modifierFormula}`
rollBase.options.D30result = options.D30result
rollBase.options.D30message = options.D30message
rollBase.options.rollName = "Ranged Attack"
rollBase.options.badResult = badResult
rollBase.options.rollData = foundry.utils.duplicate(rollData)
return rollBase
}
/** /**
* Creates a title based on the given type. * Creates a title based on the given type.
* *
@@ -1213,10 +1408,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":
@@ -1277,7 +1470,6 @@ export default class LethalFantasyRoll extends Roll {
weaponDamageOptions = { weaponDamageOptions = {
weaponId: weapon._id || weapon.id, weaponId: weapon._id || 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) console.log("Weapon damage options:", weaponDamageOptions)
@@ -1360,4 +1552,42 @@ 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}`
for (const r of term.results) {
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)
}
}
}
}
roll.options.diceResults = diceResults
roll.options.rollTotal = diceSum
return roll.toMessage()
}
} }
+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
+44 -7
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,8 +182,24 @@ export default class LethalFantasyMonster extends foundry.abstract.TypeDataModel
* @param {"="|"+"|"++"|"-"|"--"} rollAdvantage If there is an avantage (+), a disadvantage (-), a double advantage (++), a double disadvantage (--) or a normal roll (=). * @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)
if (rollType === "monster-defense" && rollTarget?.isRangedDefense === true) {
let roll = await LethalFantasyRoll.promptRangedDefense({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
rollTarget,
defenderId,
defenderTokenId,
})
if (!roll) return null
await roll.toMessage({}, { messageMode: roll.options.rollMode })
return
}
let roll = await LethalFantasyRoll.prompt({ let roll = await LethalFantasyRoll.prompt({
rollType, rollType,
rollTarget, rollTarget,
@@ -176,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":
@@ -236,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)
@@ -284,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)
} }
} }
+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({
+3 -3
View File
@@ -45,9 +45,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 })
+451 -16
View File
@@ -105,6 +105,35 @@ 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');
} }
}) })
// 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((event) => {
event.preventDefault();
if (token) {
let tokenFull = canvas.tokens.placeables.find(t => t.id === token._id);
let actor = tokenFull.actor;
const resource = event.currentTarget.dataset.resource;
const amount = Number(event.currentTarget.dataset.amount);
const current = Number(foundry.utils.getProperty(actor.system, `${resource}.current`)) || 0;
const newValue = Math.max(0, current + amount);
actor.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');
}
})
}) })
} }
@@ -145,6 +174,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" }
actor.update({ "system.hp.wounds": wounds })
}
}
}
break
case "attackBoosted":
if (msg.userId === game.user.id) {
LethalFantasyUtils.handleAttackBoosted(msg)
}
break
} }
} }
@@ -182,6 +231,180 @@ export default class LethalFantasyUtils {
}) })
} }
/* -------------------------------------------- */
static async handleAttackBoosted(msg) {
const {
attackerName, attackerId, defenderName, defenderId, defenderTokenId,
attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
shieldDamageReduction: initialShieldDR,
d30Bleed, d30DamageMultiplier, d30DrMultiplier,
damageTier, attackD30message,
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
// Show the defense reaction dialog
if (defender && 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",
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
icon: "fa-solid fa-fist-raised",
callback: () => "grit"
})
}
if (currentLuck > 0) {
buttons.push({
action: "luck",
label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
icon: "fa-solid fa-clover",
callback: () => "luck"
})
}
buttons.push({
action: "bonusDie",
label: "Add bonus die",
icon: "fa-solid fa-dice",
callback: () => "bonusDie"
})
if (canShieldReact) {
buttons.push({
action: "shieldReact",
label: `Roll shield (${shieldLabel})`,
icon: "fa-solid fa-shield",
callback: () => "shieldReact"
})
} else if (canAdHoc) {
buttons.push({
action: "adHocShield",
label: "Roll ad-hoc shield (choose dice + DR)",
icon: "fa-solid fa-shield-halved",
callback: () => "adHocShield"
})
}
buttons.push({
action: "continue",
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 === "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 || 1,
damageTier: damageTier || "standard",
attackD30message
})
}
/* -------------------------------------------- */ /* -------------------------------------------- */
static async showDefenseRequest(msg) { static async showDefenseRequest(msg) {
const attackerName = msg.attackerName const attackerName = msg.attackerName
@@ -279,6 +502,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,6 +514,8 @@ export default class LethalFantasyUtils {
attackD30result, attackD30result,
attackD30message, attackD30message,
attackRerollContext, attackRerollContext,
attackNaturalRoll: msg.attackNaturalRoll,
damageTier: msg.damageTier,
defenderId: defender.id, defenderId: defender.id,
defenderTokenId defenderTokenId
} }
@@ -365,6 +591,8 @@ 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
@@ -397,6 +625,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
@@ -467,9 +696,11 @@ export default class LethalFantasyUtils {
attackRollType, attackRollType,
attackRollKey, attackRollKey,
attackD30result, attackD30result,
attackD30message, attackD30message,
attackRerollContext, attackRerollContext,
defenderId: defender.id, attackNaturalRoll: msg.attackNaturalRoll,
damageTier: msg.damageTier,
defenderId: defender.id,
defenderTokenId, defenderTokenId,
isRanged: msg.isRanged isRanged: msg.isRanged
} }
@@ -485,6 +716,196 @@ 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") {
// Try to find a bonus_dice option matching this side
const autoBonus = d30Message.choices.find(c => c.type === "bonus_dice" && validTargets.includes(c.target))
// If we can't show dialogs (wrong client), auto-roll bonus dice if available
if (!canDialog) {
if (autoBonus) {
const modifier = await this._rollD30BonusDie(autoBonus.dice, actor, true)
return { modifier, specialEffect: null, specialName: null }
}
// No bonus dice available on this side — just report as flag
const first = d30Message.choices[0]
return { modifier: 0, specialEffect: "flag", specialName: first?.type || "choice" }
}
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,
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"]
@@ -833,6 +1254,18 @@ export default class LethalFantasyUtils {
/* -------------------------------------------- */ /* -------------------------------------------- */
static async compareAttackDefense(data) { static async compareAttackDefense(data) {
console.log("compareAttackDefense called with:", data) console.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) console.log("isAttackWin:", isAttackWin, "attackRoll:", data.attackRoll, "defenseRoll:", data.defenseRoll)
@@ -843,19 +1276,16 @@ export default class LethalFantasyUtils {
// 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 +1293,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 +1309,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("")
-1
View File
@@ -1 +0,0 @@
MANIFEST-000611
View File
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.153266 7f179e7ed6c0 Recovering log #609
2026/05/20-23:09:33.205459 7f179e7ed6c0 Delete type=3 #607
2026/05/20-23:09:33.205507 7f179e7ed6c0 Delete type=0 #609
2026/05/20-23:16:53.517842 7f179d7eb6c0 Level-0 table #614: started
2026/05/20-23:16:53.517893 7f179d7eb6c0 Level-0 table #614: 0 bytes OK
2026/05/20-23:16:53.523991 7f179d7eb6c0 Delete type=0 #612
2026/05/20-23:16:53.536989 7f179d7eb6c0 Manual compaction at level-0 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.537026 7f179d7eb6c0 Manual compaction at level-1 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.079181 7f0b1e7ee6c0 Recovering log #605
2026/05/20-00:10:37.089613 7f0b1e7ee6c0 Delete type=3 #603
2026/05/20-00:10:37.089671 7f0b1e7ee6c0 Delete type=0 #605
2026/05/20-10:53:40.566713 7f0b1cfeb6c0 Level-0 table #610: started
2026/05/20-10:53:40.566740 7f0b1cfeb6c0 Level-0 table #610: 0 bytes OK
2026/05/20-10:53:40.572741 7f0b1cfeb6c0 Delete type=0 #608
2026/05/20-10:53:40.587356 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.587409 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!ATr9wZhg5uTVTksM' @ 72057594037927935 : 1 .. '!items!zw9RQocTdz3HRjZK' @ 0 : 0; will stop at (end)
Binary file not shown.
View File
-1
View File
@@ -1 +0,0 @@
MANIFEST-000608
View File
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.234730 7f179e7ed6c0 Recovering log #606
2026/05/20-23:09:33.288868 7f179e7ed6c0 Delete type=3 #604
2026/05/20-23:09:33.288920 7f179e7ed6c0 Delete type=0 #606
2026/05/20-23:16:53.524172 7f179d7eb6c0 Level-0 table #611: started
2026/05/20-23:16:53.524219 7f179d7eb6c0 Level-0 table #611: 0 bytes OK
2026/05/20-23:16:53.530312 7f179d7eb6c0 Delete type=0 #609
2026/05/20-23:16:53.537003 7f179d7eb6c0 Manual compaction at level-0 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.537059 7f179d7eb6c0 Manual compaction at level-1 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.107483 7f0b1efef6c0 Recovering log #602
2026/05/20-00:10:37.117907 7f0b1efef6c0 Delete type=3 #600
2026/05/20-00:10:37.117968 7f0b1efef6c0 Delete type=0 #602
2026/05/20-10:53:40.572909 7f0b1cfeb6c0 Level-0 table #607: started
2026/05/20-10:53:40.572948 7f0b1cfeb6c0 Level-0 table #607: 0 bytes OK
2026/05/20-10:53:40.579001 7f0b1cfeb6c0 Delete type=0 #605
2026/05/20-10:53:40.587370 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.587430 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!yPWGvxHJbDNHVSnY' @ 72057594037927935 : 1 .. '!items!x5gLtqlW4sdDmHTd' @ 0 : 0; will stop at (end)
Binary file not shown.
View File
-1
View File
@@ -1 +0,0 @@
MANIFEST-000613
View File
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.082007 7f179dfec6c0 Recovering log #611
2026/05/20-23:09:33.140910 7f179dfec6c0 Delete type=3 #609
2026/05/20-23:09:33.140962 7f179dfec6c0 Delete type=0 #611
2026/05/20-23:16:53.510592 7f179d7eb6c0 Level-0 table #616: started
2026/05/20-23:16:53.510664 7f179d7eb6c0 Level-0 table #616: 0 bytes OK
2026/05/20-23:16:53.517661 7f179d7eb6c0 Delete type=0 #614
2026/05/20-23:16:53.536973 7f179d7eb6c0 Manual compaction at level-0 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.537037 7f179d7eb6c0 Manual compaction at level-1 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.061485 7f0b1efef6c0 Recovering log #607
2026/05/20-00:10:37.071572 7f0b1efef6c0 Delete type=3 #605
2026/05/20-00:10:37.071684 7f0b1efef6c0 Delete type=0 #607
2026/05/20-10:53:40.579189 7f0b1cfeb6c0 Level-0 table #612: started
2026/05/20-10:53:40.579229 7f0b1cfeb6c0 Level-0 table #612: 0 bytes OK
2026/05/20-10:53:40.587176 7f0b1cfeb6c0 Delete type=0 #610
2026/05/20-10:53:40.587386 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.587419 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!7j8H7DbmBb9Uza2X' @ 72057594037927935 : 1 .. '!items!zt8s7564ep1La4XQ' @ 0 : 0; will stop at (end)
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
MANIFEST-000308
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.368596 7f179e7ed6c0 Recovering log #306
2026/05/20-23:09:33.422152 7f179e7ed6c0 Delete type=3 #304
2026/05/20-23:09:33.422210 7f179e7ed6c0 Delete type=0 #306
2026/05/20-23:16:53.560903 7f179d7eb6c0 Level-0 table #311: started
2026/05/20-23:16:53.560969 7f179d7eb6c0 Level-0 table #311: 0 bytes OK
2026/05/20-23:16:53.568455 7f179d7eb6c0 Delete type=0 #309
2026/05/20-23:16:53.568616 7f179d7eb6c0 Manual compaction at level-0 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.578679 7f179d7eb6c0 Manual compaction at level-1 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.136939 7f0b1efef6c0 Recovering log #302
2026/05/20-00:10:37.146957 7f0b1efef6c0 Delete type=3 #300
2026/05/20-00:10:37.147030 7f0b1efef6c0 Delete type=0 #302
2026/05/20-10:53:40.615430 7f0b1cfeb6c0 Level-0 table #307: started
2026/05/20-10:53:40.615467 7f0b1cfeb6c0 Level-0 table #307: 0 bytes OK
2026/05/20-10:53:40.621586 7f0b1cfeb6c0 Delete type=0 #305
2026/05/20-10:53:40.621713 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.633723 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!37mu4dxsSuftlnmP' @ 72057594037927935 : 1 .. '!items!zKOpU34oLziGJW6y' @ 0 : 0; will stop at (end)
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
MANIFEST-000607
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-23:09:33.302872 7f179efee6c0 Recovering log #605
2026/05/20-23:09:33.362229 7f179efee6c0 Delete type=3 #603
2026/05/20-23:09:33.362319 7f179efee6c0 Delete type=0 #605
2026/05/20-23:16:53.530498 7f179d7eb6c0 Level-0 table #610: started
2026/05/20-23:16:53.530547 7f179d7eb6c0 Level-0 table #610: 0 bytes OK
2026/05/20-23:16:53.536854 7f179d7eb6c0 Delete type=0 #608
2026/05/20-23:16:53.537015 7f179d7eb6c0 Manual compaction at level-0 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
2026/05/20-23:16:53.537048 7f179d7eb6c0 Manual compaction at level-1 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
-8
View File
@@ -1,8 +0,0 @@
2026/05/20-00:10:37.121340 7f0b1e7ee6c0 Recovering log #601
2026/05/20-00:10:37.132435 7f0b1e7ee6c0 Delete type=3 #599
2026/05/20-00:10:37.132491 7f0b1e7ee6c0 Delete type=0 #601
2026/05/20-10:53:40.559937 7f0b1cfeb6c0 Level-0 table #606: started
2026/05/20-10:53:40.560028 7f0b1cfeb6c0 Level-0 table #606: 0 bytes OK
2026/05/20-10:53:40.566605 7f0b1cfeb6c0 Delete type=0 #604
2026/05/20-10:53:40.587338 7f0b1cfeb6c0 Manual compaction at level-0 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
2026/05/20-10:53:40.587398 7f0b1cfeb6c0 Manual compaction at level-1 from '!folders!mnO9OzE7BEE2KDfh' @ 72057594037927935 : 1 .. '!items!zkK6ixtCsCw3RH9X' @ 0 : 0; will stop at (end)
Binary file not shown.
+4 -4
View File
@@ -387,11 +387,11 @@
min-width: 6rem; 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 */
/* -------------------------------------------------- */ /* -------------------------------------------------- */
+4 -7
View File
@@ -647,15 +647,11 @@
.damage-buttons-grid { .damage-buttons-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: 1fr;
max-width: 280px;
margin: 0 auto;
gap: 8px; gap: 8px;
&.monster-damage {
grid-template-columns: 1fr;
max-width: 280px;
margin: 0 auto;
}
.damage-roll-btn { .damage-roll-btn {
padding: 10px 14px; padding: 10px 14px;
background: linear-gradient(to bottom, #8b4513 0%, #6b3410 100%); background: linear-gradient(to bottom, #8b4513 0%, #6b3410 100%);
@@ -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>
+45
View File
@@ -0,0 +1,45 @@
<div class="lethalfantasy-range-defense-dialog">
<fieldSet class="">
<legend>{{localize "LETHALFANTASY.Label.rangeAttackDialog"}}</legend>
<div class="field-section">
<span class="field-name">Attacker Movement :</span>
<select name="movement" data-tooltip-direction="UP">
{{selectOptions attackerMovementChoices selected=movement}}
</select>
</div>
<div class="field-section">
<span class="field-name">Range :</span>
<select name="range" data-tooltip-direction="UP">
{{selectOptions rangeChoices selected=range}}
</select>
</div>
<div class="field-section">
<span class="field-name">Target Size :</span>
<select name="size" data-tooltip-direction="UP">
{{selectOptions sizeChoices selected=size}}
</select>
</div>
<div class="field-section">
<span class="field-name">Aim :</span>
<select name="attackerAim" data-tooltip-direction="UP">
{{selectOptions attackerAimChoices selected=attackerAim}}
</select>
</div>
</fieldSet>
<fieldSet>
<legend>{{localize "LETHALFANTASY.Roll.visibility"}}</legend>
<span class="fieldset-centered">
<select name="visibility">
{{selectOptions rollModes selected=visibility localize=true}}
</select>
</span>
</fieldSet>
</div>
+36 -40
View File
@@ -55,35 +55,33 @@
<div class="dialog-save">Add Granted Attack Dice <div class="dialog-save">Add Granted Attack Dice
<input type="checkbox" data-action="selectGranted" name="granted" /> <input type="checkbox" data-action="selectGranted" name="granted" />
</div> </div>
{{#if rollTarget.weapon}} {{#if isRangedAttack}}
{{#if (eq rollTarget.weapon.system.weaponType "melee")}}{{else}} <div class="dialog-save">Point Blank Range Attack
<div class="dialog-save">Point Blank Range Attack <input
<input type="checkbox"
type="checkbox" data-action="selectPointBlank"
data-action="selectPointBlank" name="pointBlankV"
name="pointBlankV" />
/> </div>
</div> <div class="dialog-save">Beyond Skill Range Attack
<div class="dialog-save">Beyond Skill Range Attack <input
<input type="checkbox"
type="checkbox" data-action="selectBeyondSkill"
data-action="selectBeyondSkill" name="beyondSkillV"
name="beyondSkillV" />
/> </div>
</div> <div class="dialog-save">Let it Fly (Pure D20E)
<div class="dialog-save">Let it Fly (Pure D20E) <input
<input type="checkbox"
type="checkbox" data-action="selectLetItFly"
data-action="selectLetItFly" name="letItFlyV"
name="letItFlyV" />
/> </div>
</div> <div class="dialog-save">Aiming
<div class="dialog-save">Aiming <select name="attackerAim" data-tooltip-direction="UP">
<select name="attackerAim" data-tooltip-direction="UP"> {{selectOptions attackerAimChoices selected=attackerAim}}
{{selectOptions attackerAimChoices selected=attackerAim}} </select>
</select> </div>
</div>
{{/if}}
{{/if}} {{/if}}
{{/if}} {{/if}}
@@ -125,17 +123,15 @@
</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> <input
<input type="checkbox"
type="checkbox" name="saveSpellCheck"
name="saveSpellCheck" data-action="saveSpellCheck"
data-action="saveSpellCheck" />
/> </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="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}}