Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ea1a4b4b7 | |||
| 25648aa2a3 | |||
| 41b1199704 |
@@ -1,105 +1,87 @@
|
|||||||
# Lethal Fantasy FoundryVTT System — Session Context
|
# Lethal Fantasy FoundryVTT System — Session Context
|
||||||
|
|
||||||
## Current Goal
|
## Current Goal
|
||||||
Fix Grit/Luck defense reaction dialog UX (stacking dialogs, multiple clicks, revert on close) and cross-client sync of defense bonuses.
|
Fix Grit/Luck defense reaction dialog UX (stacking dialogs, multiple clicks, revert on close) and cross-client sync of defense bonuses. Fix monster defense mulligan reroll button missing cross-client.
|
||||||
|
|
||||||
## Accomplished
|
## Accomplished
|
||||||
|
|
||||||
### Pass 1 — Critical Issues
|
### Pass 1 — Critical Issues
|
||||||
- **Telemetry removed**: `ClassCounter`, `registerWorldCount`, orphaned `worldKey` setting deleted from system.json
|
- **Telemetry removed** from system.json
|
||||||
- **globalThis side effects**: `globalThis.SYSTEM`, `globalThis.pendingDefenses` moved from top-level to `init` hook
|
- **globalThis side effects** moved from top-level to `init` hook
|
||||||
- **console.log → log()**: All runtime console.log replaced with `log()` helper guarded by `lethalFantasy.debug` setting
|
- **console.log → log()** helper guarded by setting
|
||||||
- **Stale Tenebris refs**: `macros.mjs` — `TENEBRIS.Label.jet` → `LETHALFANTASY.Label.jet`, `TENEBRIS.Manager.*` → `LETHALFANTASY.Label.*`, `tenebris.macro` flag → `lethalFantasy.macro`
|
- **Stale Tenebris refs** → LETHALFANTASY
|
||||||
|
|
||||||
### Pass 2 — V1/V2 Mixing, Fire-and-Forget
|
### Pass 2 — V1/V2 Mixing, Fire-and-Forget
|
||||||
- **V1 sheet registrations removed**: `foundry.appv1.sheets.*` in system.json
|
- V1 sheet registrations, activateListeners/jQuery, FilePicker paths fixed
|
||||||
- **V1 `activateListeners`/jQuery**: removed dead `defaultOptions`, V1 tab code from `combat.mjs`
|
- Fire-and-forget Promises now awaited
|
||||||
- **V2 API paths**: `FilePicker` → V2, `TextEditor.getDragEventData` → V2, `item.sheet.render(true)` → `render({force:true})`, `super._onRender()` → `super._onRender(context, options)`, `token._id` → `token.id`
|
- Misnamed `LethalFantasySkill` → `LethalFantasyWeapon`
|
||||||
- **Fire-and-forget Promises**: All `actor.update()`, `ChatMessage.create()`, `prepareRoll()`, `prepareMonsterRoll()`, socket handler calls now awaited
|
|
||||||
- **Misnamed class**: `LethalFantasySkill` → `LethalFantasyWeapon`; added missing `WEAPON_TYPE` import; fixed `weaponCategory`
|
|
||||||
|
|
||||||
### Pass 3 — Code Review Fixes
|
### Pass 3 — Code Review Fixes
|
||||||
- **Duplicated dialogs**: Per-element `.rollable`/`.wound-data` bindings moved to `_onRender` (V2 destroys/recreates DOM each render); `_activateListeners` reverted
|
- Duplicated dialogs fixed via `_onRender` bindings
|
||||||
- **renderChatMessage reverted**: V2 hook `renderChatMessage` passes jQuery html, `querySelectorAll` fails; kept `renderChatMessageHTML`
|
- renderChatMessage reverted to HTML hook
|
||||||
- **Roll actions broken**: Fixed `async` base-actor-sheet methods; `_onRender` bindings for rollable elements restored
|
- All review awaits confirmed
|
||||||
- **Token HUD guard**: `html.querySelector()` → `html.find().length` (html is jQuery object)
|
|
||||||
- **All review awaits confirmed**: `showDefenseRequest`/`socket` handlers all awaited
|
|
||||||
|
|
||||||
## Defense Dialog Investigation — Status
|
### Pass 4 — D30 Dialog Removal & Dead Code Audit
|
||||||
|
- **D30 choice dialog removed** — auto-rolls bonus dice; special strike/defense reported as `specialEffect: "flag"` (informational)
|
||||||
|
- **Spell calamity choice restored** — catch-all for non-standard choices uses `specialEffect: "flag"`
|
||||||
|
- **Dead `specialEffect === "auto"` branches removed** from `chat-reaction.mjs` (×2), `combat.mjs` (×1), `reaction-message.hbs`
|
||||||
|
- **Deleted `d30-special-choice.hbs` and `_buildSpecialLabel()`**
|
||||||
|
- **Dead code audit** — 2 runtime bugs fixed, ~20 dead exports/methods, 33 unused i18n keys, 2 unused templates
|
||||||
|
- **3 critical bugs fixed**: `SYSTEM.ROLL_TYPE`, `SYSTEM.EQUIPMENT_CATEGORIES`, missing imports in `applications/combat.mjs`
|
||||||
|
- **`isPrimaryController` consolidated** to local function
|
||||||
|
- **Aether/Grace deduction merged** via `_deductResourceOnCast()`
|
||||||
|
- **`nextDefenseData` deduped** via `_storeNextDefenseData()`
|
||||||
|
- **`buildDefenseReactionButtons` extracted** from `combat.mjs`; fixes stale Grit/Luck snapshots
|
||||||
|
- **HP HUD toggling extracted** to `helpers.mjs`
|
||||||
|
- **`node --check` passes all 55 `.mjs` files**
|
||||||
|
|
||||||
### Symptom (user process)
|
### Pass 5 — Live Verification
|
||||||
1. Monster (GM) attacks player — hits
|
- **D30=30 auto-roll verified** — Club attack shows D30=30 flag
|
||||||
2. Player uses Grit/Luck to boost defense
|
- **Defense request dialog verified** — Monster defense dialog with weapon dropdown
|
||||||
3. Defense now beats attack — reports new result
|
- **Defense reaction dialog verified** — Luck spent, bonus die added, combat result correct
|
||||||
4. Dialog **stays open** — Grit/Luck/bonus dice options still visible
|
- **AZA→Monster attack flow tested end-to-end**: Club attack (D20=16, D30=6) → Monster defense (D20=1) → defense reactions (Continue) → D30 attack bonus processed (+2, total 18)
|
||||||
5. Closing dialog (Continue or X) causes "rolls vanish" — reverts to original result
|
- **BUG FOUND & FIXED: `d30ChangedAttack` infinite loop** — `chat-reaction.mjs:452-455` do-while reset block missing `d30ChangedAttack = false`; added at line 456
|
||||||
|
- **BUG FIX CONFIRMED**: Re-tested full flow — AZA Club attack (13, D30=12) → defense dialog → Monster defense (2, D30=24) → reaction dialog (only 1 show!) → Continue → "AZA hits Monster!" combat result → damage roll (1d6=2, total 3) → Apply Damage button. No infinite loop. Full E2E success.
|
||||||
|
|
||||||
### Root Cause Found — Duplicate cross-client processing (FIXED)
|
### Pass 6 — Cross-Client Mulligan Reroll Fix
|
||||||
|
- **BUG FIX: `handleAttackBoosted` hardcoded `canRerollDefense: false`** — `utils/combat.mjs:142` now computes `canRerollDefense` from `defenseD30message` via `hasD30Reroll()`. Also passes `d30message` to dialog template (was `null`).
|
||||||
|
- **BUG FIX: Missing `defenseRerollContext` in socket data** — added `defenseRerollContext` to `attackBoosted` socket message at `chat-reaction.mjs:773`, and added `rerollDefense` handler in `handleAttackBoosted` at `utils/combat.mjs:203-240` so the mulligan reroll works cross-client.
|
||||||
|
- **BUG FIX: Cross-client mulligan reroll now processes new D30 bonus dice** — after reroll, calls `processD30BonusDice` on the new D30 message to apply bonus dice, flags, and DR multipliers (was silently ignored).
|
||||||
|
- **Import `hasD30Reroll` added** to `utils/combat.mjs`
|
||||||
|
- **`bleed` top-level type handler added** to `processD30BonusDice` in `d30.mjs:79-81` — returns `specialEffect: "bleed"` same as combo path, so ranged attack bleed (values 5,10,15) creates reaction message and sets damage button bleed flag.
|
||||||
|
|
||||||
When monster (GM) attacks player, the `createChatMessage` hook fires on **both** clients:
|
## Key Decisions
|
||||||
|
- **Auto-roll bonus dice without dialog** — matches existing D30=27 (d6E) flow
|
||||||
|
- **`buildDefenseReactionButtons` extracts only button-building** — defense while-loop structures differ between same-client and cross-client; merging loops risks behavioral divergence
|
||||||
|
- **Inline grit/luck deduction uses live actor values**
|
||||||
|
- **Aether/Grace helper uses `costFn` parameter**
|
||||||
|
- **Cross-client mulligan reroll sends full `defenseRerollContext`** via socket so the defender can re-roll the same configured roll on their client
|
||||||
|
|
||||||
```
|
## Next Steps
|
||||||
Player's client: GM's client:
|
1. Test defense request dialogs (character/monster/save) — more variants
|
||||||
defense msg created defense msg synced
|
2. Test all reaction message variants (shield block/fail, d30Bonus/Flag, grit, luck, etc.)
|
||||||
↓ ↓
|
3. Create Player user in Foundry for cross-client socket testing (includes mulligan reroll)
|
||||||
hook fires (line 557) hook fires (line 557)
|
4. Prune dead code: unused exports (~20), unused i18n keys (33), unused templates (2)
|
||||||
isPrimaryController(defender)=true isPrimaryController(defender)=false
|
|
||||||
↓ ↓
|
|
||||||
Defense dialog A shows Defense dialog skipped
|
|
||||||
Player spends Grit Cross-client code (line 1009):
|
|
||||||
defenseRoll=10→16 isPrimaryController(attacker)=true
|
|
||||||
While loop exits defenderOwner=player (≠GM)
|
|
||||||
Comparison: "miss" ↓
|
|
||||||
**Sends attackBoosted with ORIGINAL
|
|
||||||
defenseRoll=10 (stale!)**
|
|
||||||
↓
|
|
||||||
Player receives socket → handleAttackBoosted
|
|
||||||
→ Defense dialog B shows with OLD values
|
|
||||||
→ When closed, comparison: "hit" (overwrites!)
|
|
||||||
```
|
|
||||||
|
|
||||||
Player sees **two** dialogs (A then B). Dialog B uses unboosted values, so closing/ignoring it produces a stale "hit" result that overwrites the correct "miss."
|
## Critical Context
|
||||||
|
- **Chat buttons not interactive via DevTools snapshot** — need JS fallback: `document.querySelectorAll('button').forEach(b => { if (...) b.click(); })`
|
||||||
|
- **Defense flow**: Attack card → target button (`.request-defense-btn`) → defense dialog → defense roll → defense reactions dialog → combat result card with Damage button → damage roll dialog → Apply Damage → HP application
|
||||||
|
- **Clicking "Damage" directly bypasses defense** — rolls unapplied damage to chat
|
||||||
|
- **Same-owner guard** (`chat-reaction.mjs:180-182`) — skips defense when GM owns both, unless `!defenderIsMonster`
|
||||||
|
- **`d30ChangedAttack` infinite loop** — variable wasn't reset in do-while block; fix at `chat-reaction.mjs:456`
|
||||||
|
- **Cross-client mulligan fix**: `handleAttackBoosted` now shows reroll button and handles the reroll action. Requires `defenseRerollContext` in socket data (added).
|
||||||
|
- **Deserialized weapon object** — `weapon.name` works, `weapon.id` undefined, `weapon._id` works
|
||||||
|
- **Fvtt server**: port 31000, foundrydata-dev
|
||||||
|
- **No player user configured** — cannot test cross-client socket flow
|
||||||
|
|
||||||
### Fix
|
## Relevant Files
|
||||||
`lethal-fantasy.mjs:1016` — only send `attackBoosted` socket when `attackerHandledBonus || attackerHasNonGMOwner`. Guards against stale-socket overwrite for GM→player combat (where hook-based processing works without socket), while preserving socket delegation for PC→PC cross-client (where `attackerIsCrossClient` suppresses the hook-based processing on the defender's client).
|
- `module/hooks/chat-reaction.mjs` — all 7 hook registrations; defense do-while loop; **d30ChangedAttack fix (line 456)**; socket data includes `defenseRerollContext` (line 773)
|
||||||
|
- `module/utils/combat.mjs` — `buildDefenseReactionButtons`; **`handleAttackBoosted` mulligan fix (lines 142, 154, 227-249)**; imports `hasD30Reroll`
|
||||||
Before:
|
- `module/utils/d30.mjs` — `processD30BonusDice`: auto-roll, flag reporting, no dialog; `hasD30Reroll` checks `type === "mulligan"`
|
||||||
```js
|
- `module/utils/helpers.mjs` — `_toggleHudWraps`/`_disableHudWraps`
|
||||||
if (defenderOwner && defenderOwner.id !== game.user.id) {
|
- `module/utils.mjs` — barrel re-exporting 23 static methods
|
||||||
game.socket.emit(`system.${SYSTEM.id}`, { type: "attackBoosted", ... })
|
- `module/models/equipment.mjs` — `EQUIPMENT_CATEGORY` fix
|
||||||
return
|
- `module/applications/combat.mjs` — added `import { SYSTEM }` and `import { log }`
|
||||||
}
|
- `templates/chat/reaction-message.hbs` — `d30Flag` text changed; `d30Auto` branch removed
|
||||||
```
|
- `templates/dialogs/d30-special-choice.hbs` — deleted
|
||||||
|
- `lang/en.json` — 33 unused i18n keys remain
|
||||||
After:
|
|
||||||
```js
|
|
||||||
if (defenderOwner && defenderOwner.id !== game.user.id) {
|
|
||||||
if (attackerHandledBonus || attackerHasNonGMOwner) {
|
|
||||||
game.socket.emit(`system.${SYSTEM.id}`, { type: "attackBoosted", ... })
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Same-Client Path
|
|
||||||
Code pattern is identical between attack and defense dialogs — both use `await DialogV2.wait({rejectClose:false})` in a while loop. Same-client defense works correctly because no duplicate socket messages arrive.
|
|
||||||
|
|
||||||
### Other Findings
|
|
||||||
- `offerGritLuckBonus` (`utils.mjs:1121`) is dead code — never called
|
|
||||||
- `promptCombatBonusDie` (`utils.mjs:975`) is correct — DialogV2 resolves to callback return value, not `action`
|
|
||||||
- Cross-client `handleAttackBoosted` (`utils.mjs:291`) still uses `else if` chain without `continue` — functionally correct but differs from same-client pattern
|
|
||||||
|
|
||||||
### Code Paths
|
|
||||||
| Flow | File | Line |
|
|
||||||
|------|------|------|
|
|
||||||
| Same-client attack | `lethal-fantasy.mjs` | 918-1004 |
|
|
||||||
| Same-client defense | `lethal-fantasy.mjs` | 697-870 |
|
|
||||||
| Cross-client defense | `module/utils.mjs` | 291-445 |
|
|
||||||
| Cross-client socket guard | `lethal-fantasy.mjs` | 1006-1037 |
|
|
||||||
| Attack Grit offer | `module/utils.mjs` | 1210-1290 |
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
- `lethal-fantasy.mjs` — Main system hooks, same-client attack/defense reactions
|
|
||||||
- `module/utils.mjs` — Cross-client defense flow, bonus dialogs, compareAttackDefense
|
|
||||||
- `module/documents/actor.mjs` — `prepareRoll()` entry point
|
|
||||||
- `module/documents/roll.mjs` — Roll resolution pipeline
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { SYSTEM } from "../config/system.mjs"
|
||||||
|
import { log } from "../utils.mjs"
|
||||||
|
|
||||||
/* -------------------------------------------- */
|
/* -------------------------------------------- */
|
||||||
export class LethalFantasyCombatTracker extends foundry.applications.sidebar.tabs.CombatTracker {
|
export class LethalFantasyCombatTracker extends foundry.applications.sidebar.tabs.CombatTracker {
|
||||||
|
|||||||
+65
-155
@@ -14,12 +14,12 @@ Hooks.on("renderChatMessageHTML", (message, html, data) => {
|
|||||||
} else {
|
} else {
|
||||||
for (const btn of html.querySelectorAll(".ask-roll-dice")) {
|
for (const btn of html.querySelectorAll(".ask-roll-dice")) {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const type = btn.dataset.type
|
const type = btn.dataset.rollType
|
||||||
const value = btn.dataset.value
|
const value = btn.dataset.rollTarget
|
||||||
const avantage = btn.dataset.avantage ?? "="
|
const avantage = btn.dataset.rollAvantage ?? "normal"
|
||||||
const character = game.user.character
|
const character = game.user.character
|
||||||
if (type === SYSTEM.ROLL_TYPE.RESOURCE) character.rollResource(value)
|
if (type === "resource") character.rollResource(value)
|
||||||
else if (type === SYSTEM.ROLL_TYPE.SAVE) character.rollSave(value, avantage)
|
else if (type === "save") character.rollSave(value, avantage)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,6 +414,15 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
const attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER"))
|
const attackerHasNonGMOwner = attacker && game.users.some(u => u.active && !u.isGM && attacker.testUserPermission(u, "OWNER"))
|
||||||
const attackerIsCrossClient = attackerHasNonGMOwner && !isPrimaryController(attacker)
|
const attackerIsCrossClient = attackerHasNonGMOwner && !isPrimaryController(attacker)
|
||||||
|
|
||||||
|
// When a GM-owned attacker has D30 data, the D30 bonus is only applied on the GM's
|
||||||
|
// client. The defender must wait for the 'attackBoosted' socket to get the updated
|
||||||
|
// attack roll instead of processing the original unboosted value via the hook.
|
||||||
|
const d30PendingFromGM = attackD30message && attacker && !attackerHasNonGMOwner && !isPrimaryController(attacker)
|
||||||
|
// Mirror side: on the GM's client, this socket MUST always be sent when a GM-owned
|
||||||
|
// attacker has D30 data, even if the bonus was 0, because the defender can't know
|
||||||
|
// the outcome without it.
|
||||||
|
const d30RequiresSocket = attackD30message && attacker && !attackerHasNonGMOwner && isPrimaryController(attacker)
|
||||||
|
|
||||||
// Reaction phase — both sides may use grit/luck/shield/mulligan before the outcome is resolved.
|
// Reaction phase — both sides may use grit/luck/shield/mulligan before the outcome is resolved.
|
||||||
// After a mulligan reroll (either side), the comparison restarts so both sides can react to the new numbers.
|
// After a mulligan reroll (either side), the comparison restarts so both sides can react to the new numbers.
|
||||||
let defenderHandledBonus = false
|
let defenderHandledBonus = false
|
||||||
@@ -433,6 +442,8 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
// These persist across mulligan restarts (D30 bonus only applied once)
|
// These persist across mulligan restarts (D30 bonus only applied once)
|
||||||
let defenseD30Processed = false
|
let defenseD30Processed = false
|
||||||
let attackD30Processed = false
|
let attackD30Processed = false
|
||||||
|
// D30 changed the attack in a way that needs cross-client sync
|
||||||
|
let d30ChangedAttack = false
|
||||||
// D30 combat effects for damage application
|
// D30 combat effects for damage application
|
||||||
let d30Bleed = false
|
let d30Bleed = false
|
||||||
let d30DamageMultiplier = 1
|
let d30DamageMultiplier = 1
|
||||||
@@ -442,6 +453,7 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
mulliganRestart = false
|
mulliganRestart = false
|
||||||
defenderHandledBonus = false
|
defenderHandledBonus = false
|
||||||
attackerHandledBonus = false
|
attackerHandledBonus = false
|
||||||
|
d30ChangedAttack = false
|
||||||
|
|
||||||
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
|
// ── D30 bonus dice (defense) — resolved before grit/luck/shield ───────
|
||||||
if (defenseD30message && !defenseD30Processed && isPrimaryController(defender) && !attackerIsCrossClient) {
|
if (defenseD30message && !defenseD30Processed && isPrimaryController(defender) && !attackerIsCrossClient) {
|
||||||
@@ -452,10 +464,6 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
await createReactionMessage(defender, {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, side:"defense"})
|
await createReactionMessage(defender, {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, side:"defense"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (d30Result.specialEffect === "auto") {
|
|
||||||
defenseRoll = attackRollFinal + 1 // auto-block
|
|
||||||
await createReactionMessage(defender, {type:"d30Auto", actorName:defenderName, specialName:d30Result.specialName||"Special Defense", side:"defense"})
|
|
||||||
}
|
|
||||||
if (d30Result.specialEffect === "flag") {
|
if (d30Result.specialEffect === "flag") {
|
||||||
await createReactionMessage(defender, {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName||"Special Effect"})
|
await createReactionMessage(defender, {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName||"Special Effect"})
|
||||||
}
|
}
|
||||||
@@ -467,70 +475,12 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Defense reaction loop ──────────────────────────────────────────────
|
// ── Defense reaction loop ──────────────────────────────────────────────
|
||||||
// Skip when attacker is cross-client — the socket handler (handleAttackBoosted)
|
// Skip when attacker is cross-client, or when D30 bonus is pending from GM —
|
||||||
// will show the defense dialog and create the comparison message.
|
// the socket handler (handleAttackBoosted) will show the defense dialog and
|
||||||
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient) {
|
// create the comparison message with the updated attack roll.
|
||||||
|
if (defender && defenseRoll < attackRollFinal && isPrimaryController(defender) && !isSpellOrMiracle && !attackerIsCrossClient && !d30PendingFromGM) {
|
||||||
while (defenseRoll < attackRollFinal) {
|
while (defenseRoll < attackRollFinal) {
|
||||||
const currentGrit = Number(defender.system?.grit?.current) || 0
|
const buttons = LethalFantasyUtils.buildDefenseReactionButtons(defender, { canRerollDefense, shieldData, canShieldReact, canAdHocShield })
|
||||||
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 (canRerollDefense) {
|
|
||||||
buttons.push({
|
|
||||||
action: "rerollDefense",
|
|
||||||
label: "Re-roll defense (Mulligan)",
|
|
||||||
icon: "fa-solid fa-rotate-right",
|
|
||||||
callback: () => "rerollDefense"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canShieldReact) {
|
|
||||||
buttons.push({
|
|
||||||
action: "shieldReact",
|
|
||||||
label: `Roll shield (${shieldData.label})`,
|
|
||||||
icon: "fa-solid fa-shield",
|
|
||||||
callback: () => "shieldReact"
|
|
||||||
})
|
|
||||||
} else if (canAdHocShield) {
|
|
||||||
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 dialogContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-reaction.hbs", {
|
const dialogContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/defense-reaction.hbs", {
|
||||||
attackerName,
|
attackerName,
|
||||||
@@ -557,7 +507,7 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
if (choice === "grit") {
|
if (choice === "grit") {
|
||||||
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender)
|
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender)
|
||||||
defenseRoll += bonusRoll
|
defenseRoll += bonusRoll
|
||||||
await defender.update({ "system.grit.current": currentGrit - 1 })
|
await defender.update({ "system.grit.current": Math.max(0, (Number(defender.system?.grit?.current) || 0) - 1) })
|
||||||
await createReactionMessage(defender, {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"})
|
await createReactionMessage(defender, {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -565,7 +515,7 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
if (choice === "luck") {
|
if (choice === "luck") {
|
||||||
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender)
|
const bonusRoll = await LethalFantasyUtils.rollBonusDie("1d6", defender)
|
||||||
defenseRoll += bonusRoll
|
defenseRoll += bonusRoll
|
||||||
await defender.update({ "system.luck.current": currentLuck - 1 })
|
await defender.update({ "system.luck.current": Math.max(0, (Number(defender.system?.luck?.current) || 0) - 1) })
|
||||||
await createReactionMessage(defender, {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"})
|
await createReactionMessage(defender, {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -663,12 +613,6 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
await createReactionMessage(attacker, {type:"d30Bonus", actorName:attackerName, value:d30Result.modifier, side:"attack"})
|
await createReactionMessage(attacker, {type:"d30Bonus", actorName:attackerName, value:d30Result.modifier, side:"attack"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (d30Result.specialEffect === "auto") {
|
|
||||||
attackRollFinal = defenseRoll + 1 // auto-hit
|
|
||||||
if (canDialog) {
|
|
||||||
await createReactionMessage(attacker, {type:"d30Auto", actorName:attackerName, specialName:d30Result.specialName||"Special Strike", side:"attack"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (d30Result.specialEffect === "flag" && canDialog) {
|
if (d30Result.specialEffect === "flag" && canDialog) {
|
||||||
await createReactionMessage(attacker, {type:"d30Flag", actorName:attackerName, specialName:d30Result.specialName||"Special Effect"})
|
await createReactionMessage(attacker, {type:"d30Flag", actorName:attackerName, specialName:d30Result.specialName||"Special Effect"})
|
||||||
}
|
}
|
||||||
@@ -685,6 +629,8 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
attackD30Processed = true
|
attackD30Processed = true
|
||||||
|
// Track whether D30 actually changed the attack value (for cross-client sync)
|
||||||
|
d30ChangedAttack = (attackRollFinal !== preD30AttackRoll)
|
||||||
// If D30 boosted attack past defense, restart so defender can react.
|
// If D30 boosted attack past defense, restart so defender can react.
|
||||||
// Only restart when D30 actually changed the outcome (pre-D30 defender was
|
// Only restart when D30 actually changed the outcome (pre-D30 defender was
|
||||||
// winning or tied, post-D30 defender is losing).
|
// winning or tied, post-D30 defender is losing).
|
||||||
@@ -812,7 +758,7 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
// non-GM owner (PC-vs-PC cross-client) — the defender's hook-based
|
// non-GM owner (PC-vs-PC cross-client) — the defender's hook-based
|
||||||
// processing is suppressed by attackerIsCrossClient, so the socket
|
// processing is suppressed by attackerIsCrossClient, so the socket
|
||||||
// handler must show the defense dialog instead.
|
// handler must show the defense dialog instead.
|
||||||
if (attackerHandledBonus || attackerHasNonGMOwner) {
|
if (attackerHandledBonus || attackerHasNonGMOwner || d30RequiresSocket) {
|
||||||
const sData = LethalFantasyUtils.getShieldReactionData(defender)
|
const sData = LethalFantasyUtils.getShieldReactionData(defender)
|
||||||
game.socket.emit(`system.${SYSTEM.id}`, {
|
game.socket.emit(`system.${SYSTEM.id}`, {
|
||||||
type: "attackBoosted",
|
type: "attackBoosted",
|
||||||
@@ -825,6 +771,7 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
damageTier: damageTier || "standard",
|
damageTier: damageTier || "standard",
|
||||||
attackD30message,
|
attackD30message,
|
||||||
defenseD30message,
|
defenseD30message,
|
||||||
|
defenseRerollContext,
|
||||||
hasShield: !!sData,
|
hasShield: !!sData,
|
||||||
shieldLabel: sData?.label || "",
|
shieldLabel: sData?.label || "",
|
||||||
shieldFormula: sData?.formula || "",
|
shieldFormula: sData?.formula || "",
|
||||||
@@ -835,7 +782,8 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Same client: restart for defender loop if attacker boosted past defense
|
// Same client: restart for defender loop if attacker boosted past defense
|
||||||
if (defenseRoll < attackRollFinal && attackerHandledBonus) {
|
// (either via Grit/bonus die in the attack reaction loop, or via D30 bonus)
|
||||||
|
if (defenseRoll < attackRollFinal && (attackerHandledBonus || d30ChangedAttack)) {
|
||||||
mulliganRestart = true
|
mulliganRestart = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -848,12 +796,16 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
// 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
|
// 1. Attacker boosted → attacker's client creates (or socket handler for cross-client)
|
||||||
// 2. Defender boosted → defender's client creates
|
// 2. Defender boosted → defender's client creates
|
||||||
// 3. Nobody boosted → attacker's client (preferred) or defender's client (fallback if same-client)
|
// 3. Nobody boosted → attacker's client (preferred) or defender's client (fallback if same-client)
|
||||||
const shouldCreateMessage = attackerHandledBonus
|
// 4. When D30 is pending from GM, the player's client must wait for the socket
|
||||||
|
// handler — the hook-based values are stale (D30 bonus not applied).
|
||||||
|
const shouldCreateMessage = !d30PendingFromGM && (
|
||||||
|
attackerHandledBonus
|
||||||
|| (!attackerHandledBonus && defenderHandledBonus)
|
|| (!attackerHandledBonus && defenderHandledBonus)
|
||||||
|| (!attackerHandledBonus && !defenderHandledBonus && (
|
|| (!attackerHandledBonus && !defenderHandledBonus && (
|
||||||
(isPrimaryController(defender) && !attackerIsCrossClient)
|
(isPrimaryController(defender) && !attackerIsCrossClient)
|
||||||
|| isPrimaryController(attacker)
|
|| isPrimaryController(attacker)
|
||||||
))
|
))
|
||||||
|
)
|
||||||
|
|
||||||
if (shouldCreateMessage) {
|
if (shouldCreateMessage) {
|
||||||
log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
|
log("Creating comparison message", { attackerHandledBonus, defenderHandledBonus, isDefenderOwner: defender.isOwner })
|
||||||
@@ -882,93 +834,51 @@ Hooks.on("createChatMessage", async (message) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Hook: deduct aether when a spell-attack or spell-power roll is posted to chat
|
async function _deductResourceOnCast(message, rollTypes, itemType, costFn, resourceField, templateType) {
|
||||||
Hooks.on("createChatMessage", async (message) => {
|
if (!rollTypes.includes(message.rolls[0]?.options?.rollType)) return
|
||||||
if (!["spell-attack", "spell-power"].includes(message.rolls[0]?.options?.rollType)) return
|
|
||||||
|
|
||||||
const actorId = message.rolls[0]?.options?.actorId
|
const actorId = message.rolls[0]?.options?.actorId
|
||||||
if (!actorId) return
|
if (!actorId) return
|
||||||
const actor = game.actors.get(actorId)
|
const actor = game.actors.get(actorId)
|
||||||
if (!actor) return
|
if (!actor) return
|
||||||
|
if (!isPrimaryController(actor)) return
|
||||||
// Only the primary controller (player owner or GM) handles this
|
|
||||||
const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
|
|
||||||
const isPrimary = activePlayerOwners.length > 0
|
|
||||||
? activePlayerOwners[0].id === game.user.id
|
|
||||||
: game.user.isGM
|
|
||||||
if (!isPrimary) return
|
|
||||||
|
|
||||||
const rollTarget = message.rolls[0]?.options?.rollTarget
|
const rollTarget = message.rolls[0]?.options?.rollTarget
|
||||||
const spellId = rollTarget?.id || rollTarget?._id
|
const itemId = rollTarget?.id || rollTarget?._id
|
||||||
const spell = spellId ? actor.items.get(spellId) : null
|
const item = itemId ? actor.items.get(itemId) : null
|
||||||
if (!spell || spell.type !== "spell") return
|
if (!item || item.type !== itemType) return
|
||||||
|
|
||||||
const damageTier = message.rolls[0]?.options?.damageTier || "standard"
|
const damageTier = message.rolls[0]?.options?.damageTier || "standard"
|
||||||
const tierCostMap = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" }
|
const cost = costFn(item, damageTier)
|
||||||
const costField = tierCostMap[damageTier] || "cost"
|
|
||||||
const cost = Number(spell.system?.[costField]) || 0
|
|
||||||
if (cost <= 0) return
|
if (cost <= 0) return
|
||||||
|
|
||||||
const currentAether = Number(actor.system.aetherPoints?.value) || 0
|
const current = Number(foundry.utils.getProperty(actor.system, resourceField)) || 0
|
||||||
const newAether = Math.max(0, currentAether - cost)
|
const newValue = Math.max(0, current - cost)
|
||||||
await actor.update({ "system.aetherPoints.value": newAether })
|
await actor.update({ [`system.${resourceField}`]: newValue })
|
||||||
|
|
||||||
const tierLabel = damageTier === "standard" ? "" : ` (${damageTier})`
|
const tierLabel = damageTier === "standard" ? "" : ` (${damageTier})`
|
||||||
const aetherContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {
|
const content = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {
|
||||||
type: "aetherSpend",
|
type: templateType, actorName: actor.name, spellName: item.name, tierLabel,
|
||||||
actorName: actor.name,
|
value: cost, oldValue: current, newValue
|
||||||
spellName: spell.name,
|
|
||||||
tierLabel,
|
|
||||||
value: cost,
|
|
||||||
oldValue: currentAether,
|
|
||||||
newValue: newAether
|
|
||||||
})
|
|
||||||
await ChatMessage.create({
|
|
||||||
content: aetherContent,
|
|
||||||
speaker: ChatMessage.getSpeaker({ actor })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
await ChatMessage.create({ content, speaker: ChatMessage.getSpeaker({ actor }) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook: deduct aether when a spell-attack or spell-power roll is posted to chat
|
||||||
|
Hooks.on("createChatMessage", (message) => _deductResourceOnCast(message,
|
||||||
|
["spell-attack", "spell-power"], "spell",
|
||||||
|
(item, tier) => {
|
||||||
|
const m = { standard: "cost", overpowered: "costOverpowered", overpowered2: "costOverpowered2" }
|
||||||
|
return Number(item.system?.[m[tier] || "cost"]) || 0
|
||||||
|
},
|
||||||
|
"aetherPoints.value", "aetherSpend"
|
||||||
|
))
|
||||||
|
|
||||||
// Hook: deduct grace when a miracle-attack or miracle-power roll is posted to chat
|
// Hook: deduct grace when a miracle-attack or miracle-power roll is posted to chat
|
||||||
Hooks.on("createChatMessage", async (message) => {
|
Hooks.on("createChatMessage", (message) => _deductResourceOnCast(message,
|
||||||
if (!["miracle-attack", "miracle-power"].includes(message.rolls[0]?.options?.rollType)) return
|
["miracle-attack", "miracle-power"], "miracle",
|
||||||
|
(item) => Number(item.system?.level) || 0,
|
||||||
const actorId = message.rolls[0]?.options?.actorId
|
"divinityPoints.value", "graceSpend"
|
||||||
if (!actorId) return
|
))
|
||||||
const actor = game.actors.get(actorId)
|
|
||||||
if (!actor) return
|
|
||||||
|
|
||||||
const activePlayerOwners = game.users.filter(u => u.active && !u.isGM && actor.testUserPermission(u, "OWNER"))
|
|
||||||
const isPrimary = activePlayerOwners.length > 0
|
|
||||||
? activePlayerOwners[0].id === game.user.id
|
|
||||||
: game.user.isGM
|
|
||||||
if (!isPrimary) return
|
|
||||||
|
|
||||||
const rollTarget = message.rolls[0]?.options?.rollTarget
|
|
||||||
const miracleId = rollTarget?.id || rollTarget?._id
|
|
||||||
const miracle = miracleId ? actor.items.get(miracleId) : null
|
|
||||||
if (!miracle || miracle.type !== "miracle") return
|
|
||||||
|
|
||||||
const cost = Number(miracle.system?.level) || 0
|
|
||||||
if (cost <= 0) return
|
|
||||||
|
|
||||||
const currentGrace = Number(actor.system.divinityPoints?.value) || 0
|
|
||||||
const newGrace = Math.max(0, currentGrace - cost)
|
|
||||||
await actor.update({ "system.divinityPoints.value": newGrace })
|
|
||||||
|
|
||||||
const graceContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {
|
|
||||||
type: "graceSpend",
|
|
||||||
actorName: actor.name,
|
|
||||||
spellName: miracle.name,
|
|
||||||
value: cost,
|
|
||||||
oldValue: currentGrace,
|
|
||||||
newValue: newGrace
|
|
||||||
})
|
|
||||||
await ChatMessage.create({
|
|
||||||
content: graceContent,
|
|
||||||
speaker: ChatMessage.getSpeaker({ actor })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Hook pour appliquer automatiquement les dégâts si une cible est définie
|
// Hook pour appliquer automatiquement les dégâts si une cible est définie
|
||||||
Hooks.on("createChatMessage", async (message) => {
|
Hooks.on("createChatMessage", async (message) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default class LethalFantasyEquipment extends foundry.abstract.TypeDataMod
|
|||||||
const requiredInteger = { required: true, nullable: false, integer: true }
|
const requiredInteger = { required: true, nullable: false, integer: true }
|
||||||
|
|
||||||
schema.description = new fields.HTMLField({ required: true, textSearch: true })
|
schema.description = new fields.HTMLField({ required: true, textSearch: true })
|
||||||
schema.category = new fields.StringField({ required: true, initial: "tinbit", choices: SYSTEM.EQUIPMENT_CATEGORIES })
|
schema.category = new fields.StringField({ required: true, initial: "tinbit", choices: SYSTEM.EQUIPMENT_CATEGORY })
|
||||||
|
|
||||||
schema.encLoad = new fields.NumberField({ required: true, initial: 0, min: 0 })
|
schema.encLoad = new fields.NumberField({ required: true, initial: 0, min: 0 })
|
||||||
schema.hi = new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 })
|
schema.hi = new fields.NumberField({ ...requiredInteger, required: true, initial: 0, min: 0 })
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
import { log, loadCompendiumData, loadCompendium, pushCombatOptions, setHookListeners, registerHandlebarsHelpers, getLethargyDice } from "./utils/helpers.mjs"
|
import { log, loadCompendiumData, loadCompendium, pushCombatOptions, setHookListeners, registerHandlebarsHelpers, getLethargyDice } from "./utils/helpers.mjs"
|
||||||
import { hasD30Reroll, processD30BonusDice, _rollD30BonusDie, _buildSpecialLabel, _buildSpecialName } from "./utils/d30.mjs"
|
import { hasD30Reroll, processD30BonusDice, _rollD30BonusDie, _buildSpecialName } from "./utils/d30.mjs"
|
||||||
import { handleSocketEvent, handleAttackerGritOffer, handleAttackBoosted, showDefenseRequest, compareAttackDefense, applyDamage, offerAttackerGritBonus, getCombatBonusDiceChoices, getShieldReactionData, promptCombatBonusDie, promptAdHocShield, rollBonusDie, rerollConfiguredRoll } from "./utils/combat.mjs"
|
import { handleSocketEvent, handleAttackerGritOffer, handleAttackBoosted, showDefenseRequest, compareAttackDefense, applyDamage, offerAttackerGritBonus, getCombatBonusDiceChoices, getShieldReactionData, buildDefenseReactionButtons, promptCombatBonusDie, promptAdHocShield, rollBonusDie, rerollConfiguredRoll } from "./utils/combat.mjs"
|
||||||
|
|
||||||
export { log }
|
export { log }
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ export default class LethalFantasyUtils {
|
|||||||
static hasD30Reroll = hasD30Reroll
|
static hasD30Reroll = hasD30Reroll
|
||||||
static processD30BonusDice = processD30BonusDice
|
static processD30BonusDice = processD30BonusDice
|
||||||
static _rollD30BonusDie = _rollD30BonusDie
|
static _rollD30BonusDie = _rollD30BonusDie
|
||||||
static _buildSpecialLabel = _buildSpecialLabel
|
|
||||||
static _buildSpecialName = _buildSpecialName
|
static _buildSpecialName = _buildSpecialName
|
||||||
static handleSocketEvent = handleSocketEvent
|
static handleSocketEvent = handleSocketEvent
|
||||||
static handleAttackerGritOffer = handleAttackerGritOffer
|
static handleAttackerGritOffer = handleAttackerGritOffer
|
||||||
@@ -25,6 +24,7 @@ export default class LethalFantasyUtils {
|
|||||||
static offerAttackerGritBonus = offerAttackerGritBonus
|
static offerAttackerGritBonus = offerAttackerGritBonus
|
||||||
static getCombatBonusDiceChoices = getCombatBonusDiceChoices
|
static getCombatBonusDiceChoices = getCombatBonusDiceChoices
|
||||||
static getShieldReactionData = getShieldReactionData
|
static getShieldReactionData = getShieldReactionData
|
||||||
|
static buildDefenseReactionButtons = buildDefenseReactionButtons
|
||||||
static promptCombatBonusDie = promptCombatBonusDie
|
static promptCombatBonusDie = promptCombatBonusDie
|
||||||
static promptAdHocShield = promptAdHocShield
|
static promptAdHocShield = promptAdHocShield
|
||||||
static rollBonusDie = rollBonusDie
|
static rollBonusDie = rollBonusDie
|
||||||
|
|||||||
+85
-138
@@ -1,6 +1,6 @@
|
|||||||
import { SYSTEM } from "../config/system.mjs"
|
import { SYSTEM } from "../config/system.mjs"
|
||||||
import { log } from "./helpers.mjs"
|
import { log } from "./helpers.mjs"
|
||||||
import { processD30BonusDice } from "./d30.mjs"
|
import { processD30BonusDice, hasD30Reroll } from "./d30.mjs"
|
||||||
|
|
||||||
export async function handleSocketEvent(msg = {}) {
|
export async function handleSocketEvent(msg = {}) {
|
||||||
log(`handleSocketEvent !`, msg)
|
log(`handleSocketEvent !`, msg)
|
||||||
@@ -100,7 +100,7 @@ export async function handleAttackBoosted(msg) {
|
|||||||
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
|
attackerHandledBonus, attackRollFinal, defenseRoll, attackWeaponId, attackRollType, attackRollKey,
|
||||||
shieldDamageReduction: initialShieldDR,
|
shieldDamageReduction: initialShieldDR,
|
||||||
d30Bleed, d30DamageMultiplier, d30DrMultiplier,
|
d30Bleed, d30DamageMultiplier, d30DrMultiplier,
|
||||||
damageTier, attackD30message, defenseD30message,
|
damageTier, attackD30message, defenseD30message, defenseRerollContext,
|
||||||
hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield
|
hasShield, shieldLabel, shieldFormula, shieldDr, canAdHocShield
|
||||||
} = msg
|
} = msg
|
||||||
|
|
||||||
@@ -124,11 +124,6 @@ export async function handleAttackBoosted(msg) {
|
|||||||
await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
|
await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (d30Result.specialEffect === "auto") {
|
|
||||||
updatedDefenseRoll = attackRollFinal + 1
|
|
||||||
const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Auto", actorName:defenderName, specialName:d30Result.specialName || "Special Defense", side:"defense"})
|
|
||||||
await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
|
|
||||||
}
|
|
||||||
if (d30Result.specialEffect === "flag") {
|
if (d30Result.specialEffect === "flag") {
|
||||||
const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName || "Special Effect"})
|
const msg = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName || "Special Effect"})
|
||||||
await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
|
await ChatMessage.create({content: msg, speaker: ChatMessage.getSpeaker({actor: defender})})
|
||||||
@@ -143,63 +138,8 @@ export async function handleAttackBoosted(msg) {
|
|||||||
// Show the defense reaction dialog — while-loop for multiple reactions
|
// Show the defense reaction dialog — while-loop for multiple reactions
|
||||||
if (defender) {
|
if (defender) {
|
||||||
while (updatedDefenseRoll < attackRollFinal) {
|
while (updatedDefenseRoll < attackRollFinal) {
|
||||||
const currentGrit = Number(defender.system?.grit?.current) || 0
|
const shieldData = canShieldReact ? { label: shieldLabel, formula: shieldFormula, damageReduction: shieldDr } : null
|
||||||
const currentLuck = Number(defender.system?.luck?.current) || 0
|
const buttons = buildDefenseReactionButtons(defender, { canRerollDefense: hasD30Reroll(defenseD30message), shieldData, canShieldReact, canAdHocShield: canAdHoc })
|
||||||
const buttons = []
|
|
||||||
|
|
||||||
if (currentGrit > 0) {
|
|
||||||
buttons.push({
|
|
||||||
action: "grit",
|
|
||||||
type: "button",
|
|
||||||
label: `Spend 1 Grit (+1D6) [${currentGrit} left]`,
|
|
||||||
icon: "fa-solid fa-fist-raised",
|
|
||||||
callback: () => "grit"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentLuck > 0) {
|
|
||||||
buttons.push({
|
|
||||||
action: "luck",
|
|
||||||
type: "button",
|
|
||||||
label: `Spend 1 Luck (+1D6) [${currentLuck} left]`,
|
|
||||||
icon: "fa-solid fa-clover",
|
|
||||||
callback: () => "luck"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons.push({
|
|
||||||
action: "bonusDie",
|
|
||||||
type: "button",
|
|
||||||
label: "Add bonus die",
|
|
||||||
icon: "fa-solid fa-dice",
|
|
||||||
callback: () => "bonusDie"
|
|
||||||
})
|
|
||||||
|
|
||||||
if (canShieldReact) {
|
|
||||||
buttons.push({
|
|
||||||
action: "shieldReact",
|
|
||||||
type: "button",
|
|
||||||
label: `Roll shield (${shieldLabel})`,
|
|
||||||
icon: "fa-solid fa-shield",
|
|
||||||
callback: () => "shieldReact"
|
|
||||||
})
|
|
||||||
} else if (canAdHoc) {
|
|
||||||
buttons.push({
|
|
||||||
action: "adHocShield",
|
|
||||||
type: "button",
|
|
||||||
label: "Roll ad-hoc shield (choose dice + DR)",
|
|
||||||
icon: "fa-solid fa-shield-halved",
|
|
||||||
callback: () => "adHocShield"
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
buttons.push({
|
|
||||||
action: "continue",
|
|
||||||
type: "button",
|
|
||||||
label: "Continue (no defense bonus)",
|
|
||||||
icon: "fa-solid fa-forward",
|
|
||||||
callback: () => "continue"
|
|
||||||
})
|
|
||||||
|
|
||||||
const choice = await foundry.applications.api.DialogV2.wait({
|
const choice = await foundry.applications.api.DialogV2.wait({
|
||||||
window: { title: "Defense reactions — attack boosted" },
|
window: { title: "Defense reactions — attack boosted" },
|
||||||
@@ -211,7 +151,7 @@ export async function handleAttackBoosted(msg) {
|
|||||||
defenderName,
|
defenderName,
|
||||||
defenseRoll: updatedDefenseRoll,
|
defenseRoll: updatedDefenseRoll,
|
||||||
defenseStatus: "currently has",
|
defenseStatus: "currently has",
|
||||||
d30message: null,
|
d30message: defenseD30message || null,
|
||||||
offerText: "The attack was boosted! Choose how to improve the defense."
|
offerText: "The attack was boosted! Choose how to improve the defense."
|
||||||
}),
|
}),
|
||||||
buttons,
|
buttons,
|
||||||
@@ -223,13 +163,13 @@ export async function handleAttackBoosted(msg) {
|
|||||||
if (choice === "grit") {
|
if (choice === "grit") {
|
||||||
const bonusRoll = await rollBonusDie("1d6", defender)
|
const bonusRoll = await rollBonusDie("1d6", defender)
|
||||||
updatedDefenseRoll += bonusRoll
|
updatedDefenseRoll += bonusRoll
|
||||||
await defender.update({ "system.grit.current": currentGrit - 1 })
|
await defender.update({ "system.grit.current": Math.max(0, (Number(defender.system?.grit?.current) || 0) - 1) })
|
||||||
const gritRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"})
|
const gritRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"grit", actorName:defenderName, resource:"Grit", value:bonusRoll, side:"defense"})
|
||||||
await ChatMessage.create({content: gritRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
|
await ChatMessage.create({content: gritRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
|
||||||
} else if (choice === "luck") {
|
} else if (choice === "luck") {
|
||||||
const bonusRoll = await rollBonusDie("1d6", defender)
|
const bonusRoll = await rollBonusDie("1d6", defender)
|
||||||
updatedDefenseRoll += bonusRoll
|
updatedDefenseRoll += bonusRoll
|
||||||
await defender.update({ "system.luck.current": currentLuck - 1 })
|
await defender.update({ "system.luck.current": Math.max(0, (Number(defender.system?.luck?.current) || 0) - 1) })
|
||||||
const luckRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"})
|
const luckRmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"luck", actorName:defenderName, resource:"Luck", value:bonusRoll, side:"defense"})
|
||||||
await ChatMessage.create({content: luckRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
|
await ChatMessage.create({content: luckRmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
|
||||||
} else if (choice === "bonusDie") {
|
} else if (choice === "bonusDie") {
|
||||||
@@ -260,6 +200,44 @@ export async function handleAttackBoosted(msg) {
|
|||||||
speaker: ChatMessage.getSpeaker({ actor: defender })
|
speaker: ChatMessage.getSpeaker({ actor: defender })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} else if (choice === "rerollDefense" && defenseRerollContext) {
|
||||||
|
const oldDefenseRoll = updatedDefenseRoll
|
||||||
|
const reroll = await rerollConfiguredRoll(defenseRerollContext)
|
||||||
|
if (!reroll) continue
|
||||||
|
updatedDefenseRoll = reroll.options?.rollTotal || reroll.total || oldDefenseRoll
|
||||||
|
let newD30message = reroll.options?.D30message || null
|
||||||
|
const mulliganContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {
|
||||||
|
type: "mulligan",
|
||||||
|
actorName: defenderName,
|
||||||
|
side: "defense",
|
||||||
|
oldRoll: oldDefenseRoll,
|
||||||
|
newRoll: updatedDefenseRoll,
|
||||||
|
diceResults: reroll.options?.diceResults || [],
|
||||||
|
D30result: reroll.options?.D30result,
|
||||||
|
D30message: newD30message
|
||||||
|
})
|
||||||
|
await ChatMessage.create({content: mulliganContent, speaker: ChatMessage.getSpeaker({actor: defender})})
|
||||||
|
// Process new D30 bonus dice from the reroll
|
||||||
|
if (newD30message) {
|
||||||
|
defenseD30message = newD30message
|
||||||
|
const d30Result = await processD30BonusDice(defenseD30message, "defense", null, defender, true)
|
||||||
|
if (d30Result.modifier) {
|
||||||
|
updatedDefenseRoll += d30Result.modifier
|
||||||
|
if (d30Result.modifier > 0) {
|
||||||
|
const rmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Bonus", actorName:defenderName, value:d30Result.modifier, side:"defense"})
|
||||||
|
await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (d30Result.specialEffect === "flag") {
|
||||||
|
const rmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30Flag", actorName:defenderName, specialName:d30Result.specialName || "Special Effect"})
|
||||||
|
await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
|
||||||
|
}
|
||||||
|
if (d30Result.specialEffect === "drMultiplier") {
|
||||||
|
const rmContent = await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/chat/reaction-message.hbs", {type:"d30DRMultiplier", actorName:defenderName, value:d30Result.multiplier})
|
||||||
|
await ChatMessage.create({content: rmContent, speaker: ChatMessage.getSpeaker({actor: defender})})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
} else if (choice === "adHocShield" && canAdHoc) {
|
} else if (choice === "adHocShield" && canAdHoc) {
|
||||||
const adHoc = await promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll)
|
const adHoc = await promptAdHocShield(defenderName, attackRollFinal, updatedDefenseRoll)
|
||||||
if (adHoc) {
|
if (adHoc) {
|
||||||
@@ -364,6 +342,19 @@ export async function showDefenseRequest(msg) {
|
|||||||
|
|
||||||
const isMonster = defender.type === "monster"
|
const isMonster = defender.type === "monster"
|
||||||
|
|
||||||
|
const _storeNextDefenseData = (opts = {}) => {
|
||||||
|
game.lethalFantasy = game.lethalFantasy || {}
|
||||||
|
game.lethalFantasy.nextDefenseData = {
|
||||||
|
attackerId, attackRoll, attackerName, defenderName,
|
||||||
|
attackWeaponId, attackRollType, attackRollKey,
|
||||||
|
attackD30result, attackD30message, attackRerollContext,
|
||||||
|
damageTier: msg.damageTier,
|
||||||
|
defenderId: defender.id, defenderTokenId,
|
||||||
|
...(msg.attackNaturalRoll !== undefined && { attackNaturalRoll: msg.attackNaturalRoll }),
|
||||||
|
...(opts.isRanged !== undefined && { isRanged: opts.isRanged })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log(`[LF] showDefenseRequest | attackRollType=${attackRollType} isMonster=${isMonster} defender=${defender?.name}`)
|
log(`[LF] showDefenseRequest | attackRollType=${attackRollType} isMonster=${isMonster} defender=${defender?.name}`)
|
||||||
|
|
||||||
// Spell/miracle attacks use saving throws instead of weapon defense
|
// Spell/miracle attacks use saving throws instead of weapon defense
|
||||||
@@ -398,22 +389,7 @@ export async function showDefenseRequest(msg) {
|
|||||||
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.spellDefense = true // pré-cocher "Save against spell" dans le dialog
|
||||||
game.lethalFantasy.nextDefenseData = {
|
_storeNextDefenseData()
|
||||||
attackerId,
|
|
||||||
attackRoll,
|
|
||||||
attackerName,
|
|
||||||
defenderName,
|
|
||||||
attackWeaponId,
|
|
||||||
attackRollType,
|
|
||||||
attackRollKey,
|
|
||||||
attackD30result,
|
|
||||||
attackD30message,
|
|
||||||
attackRerollContext,
|
|
||||||
attackNaturalRoll: msg.attackNaturalRoll,
|
|
||||||
damageTier: msg.damageTier,
|
|
||||||
defenderId: defender.id,
|
|
||||||
defenderTokenId
|
|
||||||
}
|
|
||||||
if (isMonster) {
|
if (isMonster) {
|
||||||
await defender.system.prepareMonsterRoll("save", result)
|
await defender.system.prepareMonsterRoll("save", result)
|
||||||
} else {
|
} else {
|
||||||
@@ -462,25 +438,7 @@ export async function showDefenseRequest(msg) {
|
|||||||
|
|
||||||
// Si l'utilisateur a validé, lancer le jet de défense
|
// Si l'utilisateur a validé, lancer le jet de défense
|
||||||
if (result) {
|
if (result) {
|
||||||
// Stocker temporairement les données pour le hook preCreateChatMessage
|
_storeNextDefenseData({ isRanged: msg.isRanged })
|
||||||
game.lethalFantasy = game.lethalFantasy || {}
|
|
||||||
game.lethalFantasy.nextDefenseData = {
|
|
||||||
attackerId,
|
|
||||||
attackRoll,
|
|
||||||
attackerName,
|
|
||||||
defenderName,
|
|
||||||
attackWeaponId,
|
|
||||||
attackRollType,
|
|
||||||
attackRollKey,
|
|
||||||
attackD30result,
|
|
||||||
attackD30message,
|
|
||||||
attackRerollContext,
|
|
||||||
attackNaturalRoll: msg.attackNaturalRoll,
|
|
||||||
damageTier: msg.damageTier,
|
|
||||||
defenderId: defender.id,
|
|
||||||
defenderTokenId,
|
|
||||||
isRanged: msg.isRanged
|
|
||||||
}
|
|
||||||
|
|
||||||
await defender.system.prepareMonsterRoll("monster-defense", result)
|
await defender.system.prepareMonsterRoll("monster-defense", result)
|
||||||
}
|
}
|
||||||
@@ -497,23 +455,7 @@ export async function showDefenseRequest(msg) {
|
|||||||
actorImage: defender.img,
|
actorImage: defender.img,
|
||||||
})
|
})
|
||||||
if (roll) {
|
if (roll) {
|
||||||
game.lethalFantasy = game.lethalFantasy || {}
|
_storeNextDefenseData({ isRanged: true })
|
||||||
game.lethalFantasy.nextDefenseData = {
|
|
||||||
attackerId,
|
|
||||||
attackRoll,
|
|
||||||
attackerName,
|
|
||||||
defenderName,
|
|
||||||
attackWeaponId,
|
|
||||||
attackRollType,
|
|
||||||
attackRollKey,
|
|
||||||
attackD30result,
|
|
||||||
attackD30message,
|
|
||||||
attackRerollContext,
|
|
||||||
damageTier: msg.damageTier,
|
|
||||||
defenderId: defender.id,
|
|
||||||
defenderTokenId,
|
|
||||||
isRanged: true
|
|
||||||
}
|
|
||||||
await roll.toMessage({}, { messageMode: roll.options.rollMode })
|
await roll.toMessage({}, { messageMode: roll.options.rollMode })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -558,25 +500,7 @@ export async function showDefenseRequest(msg) {
|
|||||||
|
|
||||||
// Si l'utilisateur a validé, lancer le jet de défense
|
// Si l'utilisateur a validé, lancer le jet de défense
|
||||||
if (result) {
|
if (result) {
|
||||||
// Stocker temporairement les données pour le hook preCreateChatMessage
|
_storeNextDefenseData({ isRanged: msg.isRanged })
|
||||||
game.lethalFantasy = game.lethalFantasy || {}
|
|
||||||
game.lethalFantasy.nextDefenseData = {
|
|
||||||
attackerId,
|
|
||||||
attackRoll,
|
|
||||||
attackerName,
|
|
||||||
defenderName,
|
|
||||||
attackWeaponId,
|
|
||||||
attackRollType,
|
|
||||||
attackRollKey,
|
|
||||||
attackD30result,
|
|
||||||
attackD30message,
|
|
||||||
attackRerollContext,
|
|
||||||
attackNaturalRoll: msg.attackNaturalRoll,
|
|
||||||
damageTier: msg.damageTier,
|
|
||||||
defenderId: defender.id,
|
|
||||||
defenderTokenId,
|
|
||||||
isRanged: msg.isRanged
|
|
||||||
}
|
|
||||||
|
|
||||||
log("Storing defense data for character:", defender.id)
|
log("Storing defense data for character:", defender.id)
|
||||||
|
|
||||||
@@ -584,6 +508,29 @@ export async function showDefenseRequest(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildDefenseReactionButtons(defender, { canRerollDefense = false, shieldData = null, canShieldReact = false, canAdHocShield = false } = {}) {
|
||||||
|
const currentGrit = Number(defender.system?.grit?.current) || 0
|
||||||
|
const currentLuck = Number(defender.system?.luck?.current) || 0
|
||||||
|
const buttons = []
|
||||||
|
if (currentGrit > 0) {
|
||||||
|
buttons.push({ action: "grit", type: "button", label: `Spend 1 Grit (+1D6) [${currentGrit} left]`, icon: "fa-solid fa-fist-raised", callback: () => "grit" })
|
||||||
|
}
|
||||||
|
if (currentLuck > 0) {
|
||||||
|
buttons.push({ action: "luck", type: "button", label: `Spend 1 Luck (+1D6) [${currentLuck} left]`, icon: "fa-solid fa-clover", callback: () => "luck" })
|
||||||
|
}
|
||||||
|
buttons.push({ action: "bonusDie", type: "button", label: "Add bonus die", icon: "fa-solid fa-dice", callback: () => "bonusDie" })
|
||||||
|
if (canRerollDefense) {
|
||||||
|
buttons.push({ action: "rerollDefense", type: "button", label: "Re-roll defense (Mulligan)", icon: "fa-solid fa-rotate-right", callback: () => "rerollDefense" })
|
||||||
|
}
|
||||||
|
if (canShieldReact && shieldData) {
|
||||||
|
buttons.push({ action: "shieldReact", type: "button", label: `Roll shield (${shieldData.label})`, icon: "fa-solid fa-shield", callback: () => "shieldReact" })
|
||||||
|
} else if (canAdHocShield) {
|
||||||
|
buttons.push({ action: "adHocShield", type: "button", label: "Roll ad-hoc shield (choose dice + DR)", icon: "fa-solid fa-shield-halved", callback: () => "adHocShield" })
|
||||||
|
}
|
||||||
|
buttons.push({ action: "continue", type: "button", label: "Continue (no defense bonus)", icon: "fa-solid fa-forward", callback: () => "continue" })
|
||||||
|
return buttons
|
||||||
|
}
|
||||||
|
|
||||||
export function getCombatBonusDiceChoices() {
|
export function getCombatBonusDiceChoices() {
|
||||||
return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"]
|
return ["1d4", "1d6", "1d8", "1d10", "1d12", "1d20", "1d20e"]
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-74
@@ -28,63 +28,34 @@ export async function processD30BonusDice(d30Message, side, naturalRoll = null,
|
|||||||
return { modifier, specialEffect: null, specialName: null }
|
return { modifier, specialEffect: null, specialName: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Choice type ── present all options to the player
|
// ── Choice type ── auto-roll bonus dice, alert about special effects
|
||||||
if (d30Message.type === "choice") {
|
if (d30Message.type === "choice") {
|
||||||
// If we can't show dialogs (wrong client), skip — the primary client
|
// Non-controlling client can't roll dice here — the controlling client
|
||||||
// will communicate its choice result via socket. Auto-rolling here
|
// sends the updated values via socket.
|
||||||
// would give a different modifier on each client, causing divergence.
|
|
||||||
if (!canDialog) {
|
if (!canDialog) {
|
||||||
return { modifier: 0, specialEffect: null, specialName: null }
|
return { modifier: 0, specialEffect: null, specialName: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttons = d30Message.choices.map(c => {
|
// Auto-roll bonus dice (like d6E on 27 — no dialog)
|
||||||
let label
|
const bonusChoice = d30Message.choices.find(c => c.type === "bonus_dice")
|
||||||
let icon
|
let modifier = 0
|
||||||
if (c.type === "bonus_dice") {
|
if (bonusChoice) {
|
||||||
label = `Roll ${c.dice.toUpperCase()} and add to ${side}`
|
modifier = await _rollD30BonusDie(bonusChoice.dice, actor)
|
||||||
icon = "fa-solid fa-dice"
|
|
||||||
} else if (c.type === "special_strike") {
|
|
||||||
label = _buildSpecialLabel(c, naturalRoll)
|
|
||||||
icon = "fa-solid fa-star"
|
|
||||||
} else if (c.type === "special_defense") {
|
|
||||||
label = _buildSpecialLabel(c, naturalRoll)
|
|
||||||
icon = "fa-solid fa-shield-halved"
|
|
||||||
} else {
|
|
||||||
label = c.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())
|
|
||||||
icon = "fa-solid fa-question"
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
action: c.type,
|
|
||||||
type: "button",
|
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
callback: () => c
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const choice = await foundry.applications.api.DialogV2.wait({
|
|
||||||
window: { title: "D30 Special — Choose Effect" },
|
|
||||||
classes: ["lethalfantasy"],
|
|
||||||
content: await foundry.applications.handlebars.renderTemplate("systems/fvtt-lethal-fantasy/templates/dialogs/d30-special-choice.hbs", {
|
|
||||||
description: d30Message.description
|
|
||||||
}),
|
|
||||||
buttons,
|
|
||||||
rejectClose: false
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!choice) return { modifier: 0, specialEffect: null, specialName: null }
|
|
||||||
|
|
||||||
if (choice.type === "bonus_dice") {
|
|
||||||
const modifier = await _rollD30BonusDie(choice.dice, actor)
|
|
||||||
return { modifier, specialEffect: null, specialName: null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (choice.type === "special_strike" || choice.type === "special_defense") {
|
// Inform about special strike/defense or other effects (informational only)
|
||||||
return { modifier: 0, specialEffect: "auto", specialName: _buildSpecialName(choice, naturalRoll) }
|
const specialChoice = d30Message.choices.find(c => c.type === "special_strike" || c.type === "special_defense")
|
||||||
|
if (specialChoice) {
|
||||||
|
return { modifier, specialEffect: "flag", specialName: _buildSpecialName(specialChoice, naturalRoll) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-standard choice (spell_calamity, etc.) — report it
|
// Non-standard choice (spell_calamity, etc.) — report it
|
||||||
return { modifier: 0, specialEffect: "flag", specialName: choice.type }
|
const nonStandardChoice = d30Message.choices.find(c => c.type !== "bonus_dice")
|
||||||
|
if (nonStandardChoice) {
|
||||||
|
return { modifier, specialEffect: "flag", specialName: _buildSpecialName(nonStandardChoice, naturalRoll) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modifier, specialEffect: null, specialName: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Combo type (bleed / internal injury) — flag for wound creation
|
// ── Combo type (bleed / internal injury) — flag for wound creation
|
||||||
@@ -95,6 +66,11 @@ export async function processD30BonusDice(d30Message, side, naturalRoll = null,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Bleed type (ranged attacks) — flag for wound creation, same as combo bleed
|
||||||
|
if (d30Message.type === "bleed") {
|
||||||
|
return { modifier: 0, specialEffect: "bleed", specialName: "Bleeding" }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Damage multiplier type (2x/3x damage before DR)
|
// ── Damage multiplier type (2x/3x damage before DR)
|
||||||
if (d30Message.type === "damage_multiplier") {
|
if (d30Message.type === "damage_multiplier") {
|
||||||
return { modifier: 0, specialEffect: "damageMultiplier", specialName: `x${d30Message.multiplier} Damage`, multiplier: d30Message.multiplier }
|
return { modifier: 0, specialEffect: "damageMultiplier", specialName: `x${d30Message.multiplier} Damage`, multiplier: d30Message.multiplier }
|
||||||
@@ -130,32 +106,6 @@ export async function _rollD30BonusDie(formula, actor, silent = false) {
|
|||||||
return roll.total
|
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
|
|
||||||
*/
|
|
||||||
export function _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.
|
* Build the special effect name based on the D30 result and natural roll.
|
||||||
* @param {Object} specialChoice The choice object with type and options
|
* @param {Object} specialChoice The choice object with type and options
|
||||||
@@ -179,5 +129,5 @@ export function _buildSpecialName(specialChoice, naturalRoll) {
|
|||||||
if (specialChoice.options.includes("perfect")) return "Perfect Defense"
|
if (specialChoice.options.includes("perfect")) return "Perfect Defense"
|
||||||
return "Special Defense"
|
return "Special Defense"
|
||||||
}
|
}
|
||||||
return "Special Effect"
|
return specialChoice.type.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-54
@@ -28,83 +28,55 @@ export function setHookListeners() {
|
|||||||
// hud.token / hud.object gives the Token (PlaceableObject), which has .actor.
|
// hud.token / hud.object gives the Token (PlaceableObject), which has .actor.
|
||||||
const hudActor = hud.token?.actor ?? hud.object?.actor
|
const hudActor = hud.token?.actor ?? hud.object?.actor
|
||||||
if (!hudActor) return
|
if (!hudActor) return
|
||||||
// HP Loss Button (existing)
|
const _toggleHudWraps = (prefix) => {
|
||||||
|
const enable = $(html).find(`.${prefix}-wrap`)[0].classList.contains(`${prefix}-hud-disabled`)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const w = $(html).find(`.${prefix}-wrap`)[i]
|
||||||
|
w.classList.toggle(`${prefix}-hud-active`, enable)
|
||||||
|
w.classList.toggle(`${prefix}-hud-disabled`, !enable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const _disableHudWraps = (prefix) => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const w = $(html).find(`.${prefix}-wrap`)[i]
|
||||||
|
w.classList.remove(`${prefix}-hud-active`)
|
||||||
|
w.classList.add(`${prefix}-hud-disabled`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HP Loss Button
|
||||||
const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {})
|
const lossHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/loss-hp-hud.hbs', {})
|
||||||
$(html).find('div.left').append(lossHPButton);
|
$(html).find('div.left').append(lossHPButton);
|
||||||
$(html).find('img.lethal-hp-loss-hud').click((event) => {
|
$(html).find('img.lethal-hp-loss-hud').click((event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let hpMenu = $(html).find('.hp-loss-wrap')[0]
|
_toggleHudWraps("hp-loss")
|
||||||
if (hpMenu.classList.contains("hp-loss-hud-disabled")) {
|
|
||||||
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-active');
|
|
||||||
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-disabled');
|
|
||||||
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-active');
|
|
||||||
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-disabled');
|
|
||||||
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-active');
|
|
||||||
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-disabled');
|
|
||||||
} else {
|
|
||||||
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
|
|
||||||
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
|
|
||||||
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
|
|
||||||
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
|
|
||||||
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
|
|
||||||
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
$(html).find('.loss-hp-hud-click').click(async (event) => {
|
$(html).find('.loss-hp-hud-click').click(async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let hpLoss = event.currentTarget.dataset.hpValue;
|
await hudActor.applyDamage(Number(event.currentTarget.dataset.hpValue));
|
||||||
await hudActor.applyDamage(Number(hpLoss));
|
_disableHudWraps("hp-loss")
|
||||||
$(html).find('.hp-loss-wrap')[0].classList.remove('hp-loss-hud-active');
|
|
||||||
$(html).find('.hp-loss-wrap')[0].classList.add('hp-loss-hud-disabled');
|
|
||||||
$(html).find('.hp-loss-wrap')[1].classList.remove('hp-loss-hud-active');
|
|
||||||
$(html).find('.hp-loss-wrap')[1].classList.add('hp-loss-hud-disabled');
|
|
||||||
$(html).find('.hp-loss-wrap')[2].classList.remove('hp-loss-hud-active');
|
|
||||||
$(html).find('.hp-loss-wrap')[2].classList.add('hp-loss-hud-disabled');
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// HP Gain Button (new)
|
// HP Gain Button
|
||||||
const gainHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/gain-hp-hud.hbs', {})
|
const gainHPButton = await foundry.applications.handlebars.renderTemplate('systems/fvtt-lethal-fantasy/templates/gain-hp-hud.hbs', {})
|
||||||
$(html).find('div.left').append(gainHPButton);
|
$(html).find('div.left').append(gainHPButton);
|
||||||
$(html).find('img.lethal-hp-gain-hud').click((event) => {
|
$(html).find('img.lethal-hp-gain-hud').click((event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let hpMenu = $(html).find('.hp-gain-wrap')[0]
|
_toggleHudWraps("hp-gain")
|
||||||
if (hpMenu.classList.contains("hp-gain-hud-disabled")) {
|
|
||||||
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-active');
|
|
||||||
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-disabled');
|
|
||||||
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-active');
|
|
||||||
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-disabled');
|
|
||||||
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-active');
|
|
||||||
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-disabled');
|
|
||||||
} else {
|
|
||||||
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
|
|
||||||
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
|
|
||||||
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
|
|
||||||
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
|
|
||||||
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
|
|
||||||
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
$(html).find('.gain-hp-hud-click').click(async (event) => {
|
$(html).find('.gain-hp-hud-click').click(async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
let hpGain = event.currentTarget.dataset.hpValue;
|
await hudActor.applyDamage(Number(event.currentTarget.dataset.hpValue));
|
||||||
await hudActor.applyDamage(Number(hpGain)); // Positive value to add HP
|
// Clear bleeding wounds on heal
|
||||||
// Clear bleeding wounds on heal — regardless of heal amount, any
|
|
||||||
// healing is enough to stop bleeding (field dressing / magic / rest).
|
|
||||||
const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || [])
|
const wounds = foundry.utils.duplicate(hudActor.system.hp.wounds || [])
|
||||||
const hadBleeding = wounds.some(w => w.description === "Bleeding")
|
if (wounds.some(w => w.description === "Bleeding")) {
|
||||||
if (hadBleeding) {
|
|
||||||
await hudActor.update({
|
await hudActor.update({
|
||||||
"system.hp.wounds": wounds.map(w =>
|
"system.hp.wounds": wounds.map(w =>
|
||||||
w.description === "Bleeding" ? { value: 0, duration: 0 } : w
|
w.description === "Bleeding" ? { value: 0, duration: 0 } : w
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
$(html).find('.hp-gain-wrap')[0].classList.remove('hp-gain-hud-active');
|
_disableHudWraps("hp-gain")
|
||||||
$(html).find('.hp-gain-wrap')[0].classList.add('hp-gain-hud-disabled');
|
|
||||||
$(html).find('.hp-gain-wrap')[1].classList.remove('hp-gain-hud-active');
|
|
||||||
$(html).find('.hp-gain-wrap')[1].classList.add('hp-gain-hud-disabled');
|
|
||||||
$(html).find('.hp-gain-wrap')[2].classList.remove('hp-gain-hud-active');
|
|
||||||
$(html).find('.hp-gain-wrap')[2].classList.add('hp-gain-hud-disabled');
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Luck/Grit Buttons
|
// Luck/Grit Buttons
|
||||||
|
|||||||
@@ -21,10 +21,8 @@
|
|||||||
adds <strong>{{formula}}</strong> and rolls <strong>{{value}}</strong> for {{side}}.
|
adds <strong>{{formula}}</strong> and rolls <strong>{{value}}</strong> for {{side}}.
|
||||||
{{else if (equals type "d30Bonus")}}
|
{{else if (equals type "d30Bonus")}}
|
||||||
gains <strong>+{{value}}</strong> from D30 bonus die for {{side}}.
|
gains <strong>+{{value}}</strong> from D30 bonus die for {{side}}.
|
||||||
{{else if (equals type "d30Auto")}}
|
|
||||||
uses <strong>{{specialName}}</strong> from D30 — {{#if (equals side "defense")}}defense automatically succeeds!{{else}}attack automatically hits!{{/if}}
|
|
||||||
{{else if (equals type "d30Flag")}}
|
{{else if (equals type "d30Flag")}}
|
||||||
D30 — <strong>{{specialName}}</strong> triggered for {{actorName}}!
|
D30 — <strong>{{specialName}}</strong> possible for {{actorName}}!
|
||||||
{{else if (equals type "d30Bleed")}}
|
{{else if (equals type "d30Bleed")}}
|
||||||
D30 — <strong>Bleeding/Internal Injury</strong> on hit! Damage past DR will cause a bleeding wound.
|
D30 — <strong>Bleeding/Internal Injury</strong> on hit! Damage past DR will cause a bleeding wound.
|
||||||
{{else if (equals type "d30DamageMultiplier")}}
|
{{else if (equals type "d30DamageMultiplier")}}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<div class="grit-luck-dialog">
|
|
||||||
<p><strong>D30 result:</strong> {{description}}</p>
|
|
||||||
<p>{{localize "LETHALFANTASY.D30.chooseEffect"}}</p>
|
|
||||||
</div>
|
|
||||||
Reference in New Issue
Block a user