From b2befe039e2f24c8fd7f24aac8946b5ae5b99d43 Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Wed, 18 Mar 2026 16:51:10 +0100 Subject: [PATCH] Add initiative rolls --- css/fvtt-oath-hammer.css | 41 ++++- lang/en.json | 24 ++- less/actor-sheet.less | 3 +- module/applications/armor-dialog.mjs | 31 +++- module/applications/miracle-dialog.mjs | 12 +- module/applications/roll-dialog.mjs | 18 +- .../applications/sheets/character-sheet.mjs | 59 ++++++- module/applications/sheets/npc-sheet.mjs | 15 ++ module/applications/spell-dialog.mjs | 12 +- module/applications/weapon-dialog.mjs | 58 ++++-- module/combat.mjs | 36 ++++ module/config/system.mjs | 5 +- module/models/npc.mjs | 5 +- module/models/trait.mjs | 3 +- module/rolls.mjs | 167 ++++++++++++++---- oath-hammer.mjs | 2 + system.json | 1 + templates/actor/character-combat.hbs | 10 +- templates/actor/character-equipment.hbs | 4 +- templates/actor/character-identity.hbs | 65 ++++--- templates/actor/character-magic.hbs | 4 +- templates/actor/character-sheet.hbs | 15 +- templates/actor/npc-combat.hbs | 6 + templates/actor/npc-sheet.hbs | 4 + templates/armor-roll-dialog.hbs | 14 ++ templates/miracle-cast-dialog.hbs | 6 + templates/roll-dialog.hbs | 2 + templates/spell-cast-dialog.hbs | 6 + templates/weapon-attack-dialog.hbs | 14 ++ templates/weapon-defense-dialog.hbs | 14 ++ 30 files changed, 512 insertions(+), 144 deletions(-) create mode 100644 module/combat.mjs diff --git a/css/fvtt-oath-hammer.css b/css/fvtt-oath-hammer.css index ad42e63..38d6fc4 100644 --- a/css/fvtt-oath-hammer.css +++ b/css/fvtt-oath-hammer.css @@ -430,7 +430,8 @@ .oathhammer .character-main .character-identity-bar .identity-slot .identity-name { flex: 1; font-family: "BlueDragon", "Palatino Linotype", serif; - font-size: calc(0.86rem * 0.9); + font-size: 0.86rem; + font-weight: bold; } .oathhammer .character-main .character-identity-bar .identity-slot .slot-icon { font-size: calc(0.86rem * 0.9); @@ -897,6 +898,44 @@ background: rgba(192, 57, 43, 0.1); border-color: rgba(192, 57, 43, 0.4); } + +/* Initiative bar */ +.initiative-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 4px 6px 6px; +} +.initiative-roll-btn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + font-size: calc(0.86rem * 0.9); + font-weight: bold; + color: #2a1a0a; + background: rgba(200, 168, 75, 0.2); + border: 1px solid rgba(200, 168, 75, 0.5); + border-radius: 4px; + cursor: pointer; + text-decoration: none; + text-transform: uppercase; + letter-spacing: 0.04em; + transition: background 0.15s; +} +.initiative-roll-btn:hover { + background: rgba(200, 168, 75, 0.4); + color: #2a1a0a; +} +.initiative-score { + font-size: calc(0.86rem * 0.95); + font-weight: bold; + color: #2a1a0a; + background: rgba(200, 168, 75, 0.15); + border: 1px solid rgba(200, 168, 75, 0.4); + border-radius: 4px; + padding: 2px 10px; +} .oathhammer .item-sheet-common { overflow: auto; padding: 10px 20px; diff --git a/lang/en.json b/lang/en.json index 3661b5d..a6a9240 100644 --- a/lang/en.json +++ b/lang/en.json @@ -161,7 +161,8 @@ }, "TraitType": { "SpecialTrait": "Special Trait", - "ClassTrait": "Class Trait" + "ClassTrait": "Class Trait", + "LineageTrait": "Lineage Trait" }, "Condition": { "Blinded": "Blinded", @@ -243,6 +244,7 @@ "MiracleBlocked": "Divine favour lost — no more miracles today.", "NoEquipment": "No equipment.", "NoTraits": "Drop traits here.", + "NoOaths": "No oaths yet.", "Enchantment": "Enchantment", "Tenet": "Tenet", "Boon": "Boon", @@ -275,7 +277,9 @@ "MagicMissile": "Magic Missile", "SpellSave": "Save", "StressBlocked": "BLOCKED — over stress threshold!", - "ArcaneStressShort": "AS" + "ArcaneStressShort": "AS", + "InitiativeBonus": "Initiative Bonus", + "Initiative": "Initiative" }, "ColorDice": { "White": "White (4+)", @@ -288,7 +292,8 @@ "Miracle": "New Miracle", "Equipment": "New Equipment", "Building": "New Building", - "Trait": "New Trait" + "Trait": "New Trait", + "Oath": "New Oath" }, "ToggleSheet": "Toggle Edit/Play Mode", "Action": { @@ -310,12 +315,15 @@ "SupportersHint": "+1 die each", "LuckSpend": "Luck Points", "LuckHint": "+2 dice each", + "LuckHuman": "Human (+3d)", "Available": "available", "Visibility": "Visibility", "Attribute": "Attribute", "RollSkill": "Click to roll skill check", "ExplodeOn5": "Explode on 5+", "ExplodeOn5Hint": "trait bonus — 5s & 6s explode", + "DiceColor": "Dice Color", + "DiceColorHint": "equipment traits can upgrade dice", "AttackTitle": "Attack: {weapon}", "DamageTitle": "Damage: {weapon}", "Attack": "Attack", @@ -374,7 +382,8 @@ "ArmorRollOptions": "Armor Roll Options", "APPenalty": "AP (Attacker)", "APHint": "attacker's Armor Piercing value", - "ReinforcedHint": "Reinforced — rolling red dice" + "ReinforcedHint": "Reinforced — rolling red dice", + "RollInitiative": "Roll Initiative" }, "Enhancement": { "None": "None", @@ -406,7 +415,9 @@ "DefenseResult": "defense successes", "ArmorRoll": "Armor Roll", "ArmorBypassed": "Armor bypassed (0 dice — AP ≥ AV)", - "Successes": "successes", + "Initiative": "Initiative", + "InitiativeHint": "Opposed Leadership check — winner chooses company order", + "Opposed": "Opposed", "DualAttr": { "DefenseMelee": "melee defense", "FightingNimble": "nimble weapon", @@ -564,6 +575,9 @@ "rarity": { "label": "Rarity" }, + "slots": { + "label": "Slots" + }, "equipped": { "label": "Equipped" }, diff --git a/less/actor-sheet.less b/less/actor-sheet.less index c478e4e..26aaa6b 100644 --- a/less/actor-sheet.less +++ b/less/actor-sheet.less @@ -97,7 +97,8 @@ .identity-name { flex: 1; font-family: @font-secondary; - font-size: @font-size-xs; + font-size: @font-size-base; + font-weight: bold; } .slot-icon { font-size: @font-size-xs; opacity: 0.8; } diff --git a/module/applications/armor-dialog.mjs b/module/applications/armor-dialog.mjs index 725b217..efa6f0c 100644 --- a/module/applications/armor-dialog.mjs +++ b/module/applications/armor-dialog.mjs @@ -2,7 +2,7 @@ * Armor roll dialog. * * Pool = Armor Value (AV) − AP penalty + manual bonus (can go to 0, unlike other pools) - * Reinforced trait on the armor → red dice (3+) + * Reinforced trait on the armor → red dice (3+) by default * Each success on the roll reduces incoming damage by 1. */ export default class OathHammerArmorDialog { @@ -11,7 +11,8 @@ export default class OathHammerArmorDialog { const sys = armor.system const av = sys.armorValue ?? 0 - const isReinforced = [...(sys.traits ?? [])].includes("reinforced") + const isReinforced = [...(sys.traits ?? [])].includes("reinforced") + const defaultColor = isReinforced ? "red" : "white" // AP options — entered by the user based on the attacker's weapon const apOptions = Array.from({ length: 9 }, (_, i) => ({ @@ -25,6 +26,12 @@ export default class OathHammerArmorDialog { return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 } }) + const colorOptions = [ + { value: "white", label: "⬜ White (4+)", selected: defaultColor === "white" }, + { value: "red", label: "🔴 Red (3+)", selected: defaultColor === "red" }, + { value: "black", label: "⬛ Black (2+)", selected: defaultColor === "black" }, + ] + const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes) const context = { @@ -35,6 +42,7 @@ export default class OathHammerArmorDialog { isReinforced, apOptions, bonusOptions, + colorOptions, rollModes, visibility: game.settings.get("core", "rollMode"), } @@ -51,9 +59,14 @@ export default class OathHammerArmorDialog { rejectClose: false, buttons: [{ label: game.i18n.localize("OATHHAMMER.Dialog.RollArmor"), - callback: (_ev, btn) => Object.fromEntries( - [...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value]) - ), + callback: (_ev, btn) => { + const out = {} + for (const el of btn.form.elements) { + if (!el.name) continue + out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value + } + return out + }, }], }) @@ -62,9 +75,11 @@ export default class OathHammerArmorDialog { return { av, isReinforced, - apPenalty: parseInt(result.ap) || 0, - bonus: parseInt(result.bonus) || 0, - visibility: result.visibility ?? game.settings.get("core", "rollMode"), + colorOverride: result.colorOverride || defaultColor, + apPenalty: parseInt(result.ap) || 0, + bonus: parseInt(result.bonus) || 0, + visibility: result.visibility ?? game.settings.get("core", "rollMode"), + explodeOn5: result.explodeOn5 === "true", } } } diff --git a/module/applications/miracle-dialog.mjs b/module/applications/miracle-dialog.mjs index a7232b2..4b4c154 100644 --- a/module/applications/miracle-dialog.mjs +++ b/module/applications/miracle-dialog.mjs @@ -63,9 +63,14 @@ export default class OathHammerMiracleDialog { rejectClose: false, buttons: [{ label: game.i18n.localize("OATHHAMMER.Dialog.InvokeMiracle"), - callback: (_ev, btn) => Object.fromEntries( - [...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value]) - ), + callback: (_ev, btn) => { + const out = {} + for (const el of btn.form.elements) { + if (!el.name) continue + out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value + } + return out + }, }], }) @@ -78,6 +83,7 @@ export default class OathHammerMiracleDialog { isRitual, bonus: parseInt(result.bonus) || 0, visibility: result.visibility ?? game.settings.get("core", "rollMode"), + explodeOn5: result.explodeOn5 === "true", } } } diff --git a/module/applications/roll-dialog.mjs b/module/applications/roll-dialog.mjs index f74ef71..1f20bf7 100644 --- a/module/applications/roll-dialog.mjs +++ b/module/applications/roll-dialog.mjs @@ -65,6 +65,8 @@ export default class OathHammerRollDialog { } const availableLuck = sys.luck?.value ?? 0 + const isHuman = (sys.lineage?.name ?? "").toLowerCase() === "human" + const luckDicePerPoint = isHuman ? 3 : 2 const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes) // Build select option arrays @@ -85,7 +87,7 @@ export default class OathHammerRollDialog { const luckOptions = Array.from({ length: availableLuck + 1 }, (_, i) => ({ value: i, - label: i === 0 ? `0` : `${i} (+${i * 2}d)`, + label: i === 0 ? `0` : `${i} (+${i * luckDicePerPoint}d)`, selected: i === 0, })) @@ -103,6 +105,7 @@ export default class OathHammerRollDialog { colorLabel, threshold, availableLuck, + isHuman, attrOptions, isDualAttr: !!dualDef, rollModes, @@ -145,13 +148,14 @@ export default class OathHammerRollDialog { const attrOverride = result.attrOverride || defaultAttrKey return { - dv: Math.max(0, parseInt(result.dv) ?? 2), - bonus: parseInt(result.bonus) || 0, - luckSpend: Math.min(Math.max(0, parseInt(result.luckSpend) || 0), availableLuck), - supporters: Math.max(0, parseInt(result.supporters) || 0), - explodeOn5: result.explodeOn5 === "true", + dv: Math.max(0, parseInt(result.dv) ?? 2), + bonus: parseInt(result.bonus) || 0, + luckSpend: Math.min(Math.max(0, parseInt(result.luckSpend) || 0), availableLuck), + luckIsHuman: result.luckIsHuman === "true", + supporters: Math.max(0, parseInt(result.supporters) || 0), + explodeOn5: result.explodeOn5 === "true", attrOverride, - visibility: result.visibility ?? game.settings.get("core", "rollMode"), + visibility: result.visibility ?? game.settings.get("core", "rollMode"), } } } diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs index a6a4afd..210a712 100644 --- a/module/applications/sheets/character-sheet.mjs +++ b/module/applications/sheets/character-sheet.mjs @@ -6,7 +6,7 @@ import OathHammerSpellDialog from "../spell-dialog.mjs" import OathHammerMiracleDialog from "../miracle-dialog.mjs" import OathHammerDefenseDialog from "../defense-dialog.mjs" import OathHammerArmorDialog from "../armor-dialog.mjs" -import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast, rollArmorSave, rollWeaponDefense } from "../../rolls.mjs" +import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast, rollArmorSave, rollWeaponDefense, rollInitiativeCheck } from "../../rolls.mjs" export default class OathHammerCharacterSheet extends OathHammerActorSheet { /** @override */ @@ -25,6 +25,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { createMiracle: OathHammerCharacterSheet.#onCreateMiracle, createEquipment: OathHammerCharacterSheet.#onCreateEquipment, createTrait: OathHammerCharacterSheet.#onCreateTrait, + createOath: OathHammerCharacterSheet.#onCreateOath, rollSkill: OathHammerCharacterSheet.#onRollSkill, attackWeapon: OathHammerCharacterSheet.#onAttackWeapon, defendWeapon: OathHammerCharacterSheet.#onDefendWeapon, @@ -33,6 +34,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { castMiracle: OathHammerCharacterSheet.#onCastMiracle, rollArmorSave: OathHammerCharacterSheet.#onRollArmorSave, resetMiracleBlocked: OathHammerCharacterSheet.#onResetMiracleBlocked, + rollInitiative: OathHammerCharacterSheet.#onRollInitiative, }, } @@ -96,15 +98,18 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { _typeLabel: typeKey ? game.i18n.localize(typeKey) : a.system.traitType, _usageLabel: isPassive ? game.i18n.localize("OATHHAMMER.UsagePeriod.None") - : `${a.system.maxUses > 0 ? a.system.maxUses + " / " : ""}${game.i18n.localize(periodKey)}` + : `${a.system.maxUses > 0 ? a.system.maxUses + " / " : ""}${game.i18n.localize(periodKey)}`, + _descTooltip: _stripHtml(a.system.description) } }) context.oaths = doc.itemTypes.oath.map(o => { const typeEntry = SYSTEM.OATH_TYPES[o.system.oathType] + const parts = [o.system.tenet, o.system.boon, o.system.bane].filter(Boolean) return { id: o.id, uuid: o.uuid, img: o.img, name: o.name, system: o.system, _typeLabel: typeEntry ? game.i18n.localize(typeEntry.label) : o.system.oathType, - _violated: o.system.violated + _violated: o.system.violated, + _descTooltip: _stripHtml(parts.join(" ")) } }) context.enrichedBackground = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.background ?? "", { async: true }) @@ -160,6 +165,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { id: w.id, uuid: w.uuid, img: w.img, name: w.name, system: w.system, _groupLabel: groupKey ? game.i18n.localize(groupKey) : w.system.proficiencyGroup, _traitsTooltip: traitsLabel || null, + _descTooltip: _stripHtml(w.system.description), _isMagic: w.system.isMagic } }) @@ -173,6 +179,7 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { id: a.id, uuid: a.uuid, img: a.img, name: a.name, system: a.system, _typeLabel: typeKey ? game.i18n.localize(typeKey) : a.system.armorType, _traitsTooltip: traitsLabel || null, + _descTooltip: _stripHtml(a.system.description), _isMagic: a.system.isMagic } }) @@ -181,16 +188,31 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { context.slotsMax = 10 + (doc.system.attributes.might.rank * 2) context.slotsUsed = doc.items.reduce((sum, item) => sum + (item.system.slots ?? 0), 0) context.slotsOver = context.slotsUsed > context.slotsMax + // Show current initiative score if actor is in an active combat + const combatant = game.combat?.combatants.find(c => c.actor?.id === doc.id) + context.combatantInitiative = combatant?.initiative ?? null break case "magic": context.tab = context.tabs.magic - context.spells = doc.itemTypes.spell - context.miracles = doc.itemTypes.miracle + context.spells = doc.itemTypes.spell.map(s => ({ + id: s.id, uuid: s.uuid, img: s.img, name: s.name, system: s.system, + _descTooltip: _stripHtml(s.system.effect) + })) + context.miracles = doc.itemTypes.miracle.map(m => ({ + id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system, + _descTooltip: _stripHtml(m.system.effect) + })) break case "equipment": context.tab = context.tabs.equipment - context.equipment = doc.itemTypes.equipment - context.magicItems = doc.itemTypes["magic-item"] + context.equipment = doc.itemTypes.equipment.map(e => ({ + id: e.id, uuid: e.uuid, img: e.img, name: e.name, system: e.system, + _descTooltip: _stripHtml(e.system.description) + })) + context.magicItems = doc.itemTypes["magic-item"].map(m => ({ + id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system, + _descTooltip: _stripHtml(m.system.description) + })) context.slotsMax = 10 + (doc.system.attributes.might.rank * 2) context.slotsUsed = doc.items.reduce((sum, item) => sum + (item.system.slots ?? 0), 0) context.slotsOver = context.slotsUsed > context.slotsMax @@ -263,6 +285,10 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Trait"), type: "trait" }]) } + static #onCreateOath(event, target) { + this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Oath"), type: "oath" }]) + } + static async #onRollSkill(event, target) { const skillKey = target.dataset.skill if (!skillKey) return @@ -338,4 +364,23 @@ export default class OathHammerCharacterSheet extends OathHammerActorSheet { if (!opts) return await rollArmorSave(this.document, armor, opts) } + + static async #onRollInitiative() { + const actor = this.document + const combatant = game.combat?.combatants.find(c => c.actor?.id === actor.id) + if (combatant) { + // Delegate to OathHammerCombat.rollInitiative — posts to chat and updates tracker + await game.combat.rollInitiative([combatant.id]) + } else { + // Not in combat — roll for display only + await rollInitiativeCheck(actor) + } + } +} + +/** Strip HTML tags and collapse whitespace for use in data-tooltip attributes. */ +function _stripHtml(html, maxLen = 300) { + if (!html) return "" + const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() + return text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text } diff --git a/module/applications/sheets/npc-sheet.mjs b/module/applications/sheets/npc-sheet.mjs index 2eac717..b64bf98 100644 --- a/module/applications/sheets/npc-sheet.mjs +++ b/module/applications/sheets/npc-sheet.mjs @@ -1,4 +1,5 @@ import OathHammerActorSheet from "./base-actor-sheet.mjs" +import { rollInitiativeCheck } from "../../rolls.mjs" export default class OathHammerNPCSheet extends OathHammerActorSheet { /** @override */ @@ -11,6 +12,9 @@ export default class OathHammerNPCSheet extends OathHammerActorSheet { window: { contentClasses: ["npc-content"], }, + actions: { + rollInitiative: OathHammerNPCSheet.#onRollInitiative, + }, } /** @override */ @@ -62,6 +66,7 @@ export default class OathHammerNPCSheet extends OathHammerActorSheet { case "combat": context.tab = context.tabs.combat context.weapons = doc.itemTypes.weapon + context.combatantInitiative = game.combat?.combatants.find(c => c.actor?.id === doc.id)?.initiative ?? null break case "notes": context.tab = context.tabs.notes @@ -80,4 +85,14 @@ export default class OathHammerNPCSheet extends OathHammerActorSheet { return this._onDropItem(item) } } + + static async #onRollInitiative() { + const actor = this.document + const combatant = game.combat?.combatants.find(c => c.actor?.id === actor.id) + if (combatant) { + await game.combat.rollInitiative([combatant.id]) + } else { + await rollInitiativeCheck(actor) + } + } } diff --git a/module/applications/spell-dialog.mjs b/module/applications/spell-dialog.mjs index de6ca3f..c4d7576 100644 --- a/module/applications/spell-dialog.mjs +++ b/module/applications/spell-dialog.mjs @@ -91,9 +91,14 @@ export default class OathHammerSpellDialog { rejectClose: false, buttons: [{ label: game.i18n.localize("OATHHAMMER.Dialog.CastSpell"), - callback: (_ev, btn) => Object.fromEntries( - [...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value]) - ), + callback: (_ev, btn) => { + const out = {} + for (const el of btn.form.elements) { + if (!el.name) continue + out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value + } + return out + }, }], }) @@ -113,6 +118,7 @@ export default class OathHammerSpellDialog { bonus: parseInt(result.bonus) || 0, grimPenalty: parseInt(result.noGrimoire) || 0, visibility: result.visibility ?? game.settings.get("core", "rollMode"), + explodeOn5: result.explodeOn5 === "true", } } } diff --git a/module/applications/weapon-dialog.mjs b/module/applications/weapon-dialog.mjs index 2b38184..26987ad 100644 --- a/module/applications/weapon-dialog.mjs +++ b/module/applications/weapon-dialog.mjs @@ -97,6 +97,7 @@ export default class OathHammerWeaponDialog { traits: traitLabels, attackBonusOptions, rangeOptions, + colorOptions: _colorOptions(skillColor), rollModes, visibility: game.settings.get("core", "rollMode"), } @@ -113,9 +114,14 @@ export default class OathHammerWeaponDialog { rejectClose: false, buttons: [{ label: game.i18n.localize("OATHHAMMER.Dialog.RollAttack"), - callback: (_ev, btn) => Object.fromEntries( - [...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value]) - ), + callback: (_ev, btn) => { + const out = {} + for (const el of btn.form.elements) { + if (!el.name) continue + out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value + } + return out + }, }], }) @@ -124,8 +130,10 @@ export default class OathHammerWeaponDialog { attackBonus: parseInt(result.attackBonus) || 0, rangeCondition: parseInt(result.rangeCondition) || 0, attrOverride: result.attrOverride || defaultAttr, + colorOverride: result.colorOverride || skillColor, visibility: result.visibility ?? game.settings.get("core", "rollMode"), autoAttackBonus, + explodeOn5: result.explodeOn5 === "true", } } @@ -188,6 +196,12 @@ export default class OathHammerWeaponDialog { { value: -4, label: game.i18n.localize("OATHHAMMER.Dialog.DiminishThird"), selected: false }, ] + // Default dice color: red if parry/block trait matches the default attack type, else white + const defaultDefenseColor = ( + (defaultAttackType === "melee" && hasParry) || + (defaultAttackType === "ranged" && hasBlock) + ) ? "red" : "white" + const bonusOptions = Array.from({ length: 13 }, (_, i) => { const v = i - 6 return { value: v, label: v > 0 ? `+${v}` : String(v), selected: v === 0 } @@ -212,6 +226,7 @@ export default class OathHammerWeaponDialog { attrOptions, diminishOptions, bonusOptions, + colorOptions: _colorOptions(defaultDefenseColor), rollModes, visibility: game.settings.get("core", "rollMode"), } @@ -228,9 +243,14 @@ export default class OathHammerWeaponDialog { rejectClose: false, buttons: [{ label: game.i18n.localize("OATHHAMMER.Dialog.RollDefense"), - callback: (_ev, btn) => Object.fromEntries( - [...btn.form.elements].filter(e => e.name).map(e => [e.name, e.value]) - ), + callback: (_ev, btn) => { + const out = {} + for (const el of btn.form.elements) { + if (!el.name) continue + out[el.name] = el.type === "checkbox" ? String(el.checked) : el.value + } + return out + }, }], }) @@ -241,28 +261,24 @@ export default class OathHammerWeaponDialog { const attrRank = attrChoice === "might" ? mightRank : agiRank const diminishPenalty = parseInt(result.diminish) || 0 const bonus = parseInt(result.bonus) || 0 + const colorOverride = result.colorOverride || defaultDefenseColor - // Resolve red dice and trait bonus based on selected attack type - let redDice = false + // Trait bonus based on selected attack type (color is now user-controlled) let traitBonus = 0 - if (attackType === "melee" && hasParry) { - redDice = true - traitBonus = parryCount >= 2 ? 1 : 0 - } else if (attackType === "ranged" && hasBlock) { - redDice = true - traitBonus = 1 - } + if (attackType === "melee" && hasParry) traitBonus = parryCount >= 2 ? 1 : 0 + if (attackType === "ranged" && hasBlock) traitBonus = 1 return { attackType, attrRank, attrChoice, - redDice, + colorOverride, traitBonus, armorPenalty, diminishPenalty, bonus, - visibility: result.visibility ?? game.settings.get("core", "rollMode"), + visibility: result.visibility ?? game.settings.get("core", "rollMode"), + explodeOn5: result.explodeOn5 === "true", } } @@ -350,3 +366,11 @@ export default class OathHammerWeaponDialog { function _cap(str) { return str.charAt(0).toUpperCase() + str.slice(1) } + +function _colorOptions(defaultColor = "white") { + return [ + { value: "white", label: "⬜ White (4+)", selected: defaultColor === "white" }, + { value: "red", label: "🔴 Red (3+)", selected: defaultColor === "red" }, + { value: "black", label: "⬛ Black (2+)", selected: defaultColor === "black" }, + ] +} diff --git a/module/combat.mjs b/module/combat.mjs new file mode 100644 index 0000000..33a4465 --- /dev/null +++ b/module/combat.mjs @@ -0,0 +1,36 @@ +import { rollInitiativeCheck } from "./rolls.mjs" + +/** + * Custom Combat class for Oath Hammer. + * Initiative = opposed Leadership check (DV=0). + * The number of successes becomes the combatant's initiative score. + */ +export default class OathHammerCombat extends Combat { + /** + * Override Foundry's rollInitiative to use Leadership skill checks. + * For characters: Leadership (Fate) opposed check, successes = score. + * For NPCs: Fate rank + initiative bonus pool. + * + * @param {string|string[]} ids Combatant IDs to roll for + * @param {object} options + */ + async rollInitiative(ids, { formula = null, updateTurn = true, messageOptions = {} } = {}) { + const combatantIds = typeof ids === "string" ? [ids] : ids + const updates = [] + + for (const id of combatantIds) { + const combatant = this.combatants.get(id) + if (!combatant?.isOwner) continue + const actor = combatant.actor + if (!actor) continue + + const { successes } = await rollInitiativeCheck(actor) + updates.push({ _id: id, initiative: successes }) + } + + if (!updates.length) return this + await this.updateEmbeddedDocuments("Combatant", updates) + if (updateTurn && this.turn !== null) await this.update({ turn: 0 }) + return this + } +} diff --git a/module/config/system.mjs b/module/config/system.mjs index 18f95f8..6a6b855 100644 --- a/module/config/system.mjs +++ b/module/config/system.mjs @@ -198,8 +198,9 @@ export const RARITY_DV = { // Two types of trait per the rulebook terminology export const TRAIT_TYPE_CHOICES = { - "special-trait": "OATHHAMMER.TraitType.SpecialTrait", - "class-trait": "OATHHAMMER.TraitType.ClassTrait" + "special-trait": "OATHHAMMER.TraitType.SpecialTrait", + "class-trait": "OATHHAMMER.TraitType.ClassTrait", + "lineage-trait": "OATHHAMMER.TraitType.LineageTrait" } // When a trait's uses reset (none = passive/always on) diff --git a/module/models/npc.mjs b/module/models/npc.mjs index 8b477bc..4730e1b 100644 --- a/module/models/npc.mjs +++ b/module/models/npc.mjs @@ -34,8 +34,9 @@ export default class OathHammerNPC extends foundry.abstract.TypeDataModel { base: new fields.NumberField({ ...requiredInteger, initial: 30, min: 0 }) }) - schema.attackBonus = new fields.NumberField({ ...requiredInteger, initial: 0 }) - schema.damageBonus = new fields.NumberField({ ...requiredInteger, initial: 0 }) + schema.attackBonus = new fields.NumberField({ ...requiredInteger, initial: 0 }) + schema.damageBonus = new fields.NumberField({ ...requiredInteger, initial: 0 }) + schema.initiativeBonus = new fields.NumberField({ ...requiredInteger, initial: 0 }) schema.challengeRating = new fields.StringField({ required: true, nullable: false, initial: "1" }) return schema diff --git a/module/models/trait.mjs b/module/models/trait.mjs index 76beb91..ff60d5c 100644 --- a/module/models/trait.mjs +++ b/module/models/trait.mjs @@ -32,11 +32,10 @@ export default class OathHammerTrait extends foundry.abstract.TypeDataModel { static migrateData(source) { // Migrate old abilityType field → traitType if (source.abilityType !== undefined && source.traitType === undefined) { - const map = { "class-ability": "class-trait", "lineage-trait": "special-trait" } + const map = { "class-ability": "class-trait" } source.traitType = map[source.abilityType] ?? "special-trait" } // Migrate old traitType values - if (source.traitType === "lineage-trait") source.traitType = "special-trait" if (source.traitType === "class-ability") source.traitType = "class-trait" return super.migrateData(source) } diff --git a/module/rolls.mjs b/module/rolls.mjs index fd09398..82cb587 100644 --- a/module/rolls.mjs +++ b/module/rolls.mjs @@ -24,7 +24,7 @@ import { SYSTEM } from "./config/system.mjs" * @returns {Promise<{successes: number, dv: number, isSuccess: boolean}>} */ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { - const { bonus = 0, luckSpend = 0, supporters = 0, attrOverride, visibility, flavor, explodeOn5 = false } = options + const { bonus = 0, luckSpend = 0, luckIsHuman = false, supporters = 0, attrOverride, visibility, flavor, explodeOn5 = false } = options const sys = actor.system const skillDef = SYSTEM.SKILLS[skillKey] @@ -40,8 +40,10 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { const colorType = skill.colorDiceType ?? "white" const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4 + const luckDicePerPoint = luckIsHuman ? 3 : 2 + // Total dice pool (never below 1) - const totalDice = Math.max(attrRank + skillRank + skillMod + bonus + (luckSpend * 2) + supporters, 1) + const totalDice = Math.max(attrRank + skillRank + skillMod + bonus + (luckSpend * luckDicePerPoint) + supporters, 1) // Deduct spent Luck Points from actor if (luckSpend > 0) { @@ -51,6 +53,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { // Roll the dice pool const roll = await new Roll(`${totalDice}d6`).evaluate() + const allRolls = [roll] // Count successes — exploding dice produce additional dice const explodeThreshold = explodeOn5 ? 5 : 6 @@ -67,6 +70,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { while (extraDice > 0) { const xRoll = await new Roll(`${extraDice}d6`).evaluate() + allRolls.push(xRoll) extraDice = 0 for (const r of xRoll.dice[0].results) { const val = r.result @@ -94,7 +98,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { const explodedCount = diceResults.filter(d => d.exploded).length const modParts = [] if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) - if (luckSpend > 0) modParts.push(`+${luckSpend * 2} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP)`) + if (luckSpend > 0) modParts.push(`+${luckSpend * luckDicePerPoint} ${game.i18n.localize("OATHHAMMER.Dialog.LuckSpend")} (${luckSpend}LP${luckIsHuman ? " 👤" : ""})`) if (supporters > 0) modParts.push(`+${supporters} ${game.i18n.localize("OATHHAMMER.Dialog.Supporters")}`) if (explodedCount > 0) modParts.push(`💥 ${explodedCount} ${game.i18n.localize("OATHHAMMER.Roll.Exploded")}`) const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" @@ -132,7 +136,7 @@ export async function rollSkillCheck(actor, skillKey, dv, options = {}) { const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, - rolls: [roll], + rolls: allRolls, sound: CONFIG.sounds.dice, } ChatMessage.applyRollMode(msgData, rollMode) @@ -179,8 +183,10 @@ export async function rollRarityCheck(actor, rarityKey, itemName) { * @param {number} threshold Minimum value to count as a success * @returns {Promise<{roll: Roll, successes: number, diceResults: Array}>} */ -async function _rollPool(pool, threshold) { +async function _rollPool(pool, threshold, explodeOn5 = false) { + const explodeThreshold = explodeOn5 ? 5 : 6 const roll = await new Roll(`${Math.max(pool, 1)}d6`).evaluate() + const rolls = [roll] let successes = 0 const diceResults = [] let extraDice = 0 @@ -188,22 +194,23 @@ async function _rollPool(pool, threshold) { for (const r of roll.dice[0].results) { const val = r.result if (val >= threshold) successes++ - if (val === 6) extraDice++ + if (val >= explodeThreshold) extraDice++ diceResults.push({ val, exploded: false }) } while (extraDice > 0) { const xRoll = await new Roll(`${extraDice}d6`).evaluate() + rolls.push(xRoll) extraDice = 0 for (const r of xRoll.dice[0].results) { const val = r.result if (val >= threshold) successes++ - if (val === 6) extraDice++ + if (val >= explodeThreshold) extraDice++ diceResults.push({ val, exploded: true }) } } - return { roll, successes, diceResults } + return { roll, rolls, successes, diceResults } } /** @@ -229,7 +236,7 @@ function _diceHtml(diceResults, threshold) { * @param {object} options From OathHammerWeaponDialog.promptAttack() */ export async function rollWeaponAttack(actor, weapon, options = {}) { - const { attackBonus = 0, rangeCondition = 0, attrOverride, visibility, autoAttackBonus = 0 } = options + const { attackBonus = 0, rangeCondition = 0, attrOverride, colorOverride, visibility, autoAttackBonus = 0, explodeOn5 = false } = options const sys = weapon.system const actorSys = actor.system @@ -242,23 +249,24 @@ export async function rollWeaponAttack(actor, weapon, options = {}) { const attrKey = attrOverride && actorSys.attributes[attrOverride] ? attrOverride : defaultAttr const attrRank = actorSys.attributes[attrKey].rank const skillRank = actorSys.skills[skillKey].rank - const colorType = actorSys.skills[skillKey].colorDiceType ?? "white" - const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4 + const baseColorType = actorSys.skills[skillKey].colorDiceType ?? "white" + const colorType = colorOverride || baseColorType + const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4 const colorEmoji = colorType === "black" ? "⬛" : colorType === "red" ? "🔴" : "⬜" const totalDice = Math.max(attrRank + skillRank + attackBonus + rangeCondition + autoAttackBonus, 1) - const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) + const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5) const skillLabel = game.i18n.localize(skillDef.label) const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${attrKey.charAt(0).toUpperCase() + attrKey.slice(1)}`) const diceHtml = _diceHtml(diceResults, threshold) - // Modifier summary const modParts = [] if (attackBonus !== 0) modParts.push(`${attackBonus > 0 ? "+" : ""}${attackBonus} ${game.i18n.localize("OATHHAMMER.Dialog.AttackModifier")}`) if (rangeCondition !== 0) modParts.push(`${rangeCondition} ${game.i18n.localize("OATHHAMMER.Dialog.RangeCondition")}`) if (autoAttackBonus > 0) modParts.push(`+${autoAttackBonus} auto`) + if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`) const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" const content = ` @@ -289,7 +297,7 @@ export async function rollWeaponAttack(actor, weapon, options = {}) { const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, - rolls: [roll], + rolls: rolls, sound: CONFIG.sounds.dice, flags: { "fvtt-oath-hammer": { weaponAttack: flagData } }, } @@ -327,7 +335,7 @@ export async function rollWeaponDamage(actor, weapon, options = {}) { const baseDamageDice = sys.usesMight ? Math.max(mightRank + sys.damageMod, 1) : Math.max(sys.damageMod, 1) const totalDice = Math.max(baseDamageDice + sv + damageBonus + autoDamageBonus, 1) - const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) + const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold) const diceHtml = _diceHtml(diceResults, threshold) const modParts = [] @@ -361,7 +369,7 @@ export async function rollWeaponDamage(actor, weapon, options = {}) { const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, - rolls: [roll], + rolls: rolls, sound: CONFIG.sounds.dice, } ChatMessage.applyRollMode(msgData, rollMode) @@ -395,6 +403,7 @@ export async function rollSpellCast(actor, spell, options = {}) { bonus = 0, grimPenalty = 0, visibility, + explodeOn5 = false, } = options const sys = spell.system @@ -406,7 +415,7 @@ export async function rollSpellCast(actor, spell, options = {}) { const threshold = redDice ? 3 : 4 const colorEmoji = redDice ? "🔴" : "⬜" - const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) + const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5) const diceHtml = _diceHtml(diceResults, threshold) // Count 1s for Arcane Stress (unless Safe Spell enhancement) @@ -436,6 +445,7 @@ export async function rollSpellCast(actor, spell, options = {}) { if (poolPenalty !== 0) modParts.push(`${poolPenalty} ${game.i18n.localize("OATHHAMMER.Enhancement." + _cap(enhancement))}`) if (elementalBonus > 0) modParts.push(`+${elementalBonus} ${game.i18n.localize("OATHHAMMER.Dialog.ElementMet")}`) if (grimPenalty < 0) modParts.push(`${grimPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.GrimoireNo")}`) + if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`) const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" const stressLine = `
@@ -468,7 +478,7 @@ export async function rollSpellCast(actor, spell, options = {}) { const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, - rolls: [roll], + rolls: rolls, sound: CONFIG.sounds.dice, } ChatMessage.applyRollMode(msgData, rollMode) @@ -495,6 +505,7 @@ export async function rollMiracleCast(actor, miracle, options = {}) { isRitual = false, bonus = 0, visibility, + explodeOn5 = false, } = options const sys = miracle.system @@ -506,7 +517,7 @@ export async function rollMiracleCast(actor, miracle, options = {}) { const threshold = 4 const colorEmoji = "⬜" - const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) + const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5) const diceHtml = _diceHtml(diceResults, threshold) const isSuccess = successes >= dv @@ -517,9 +528,10 @@ export async function rollMiracleCast(actor, miracle, options = {}) { ? game.i18n.localize("OATHHAMMER.Roll.Success") : game.i18n.localize("OATHHAMMER.Roll.Failure") - const modLine = bonus !== 0 - ? `
${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}
` - : "" + const modParts = [] + if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) + if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`) + const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" const blockedLine = !isSuccess ? `
⚠ ${game.i18n.localize("OATHHAMMER.Roll.MiracleBlocked")}
` @@ -553,7 +565,7 @@ export async function rollMiracleCast(actor, miracle, options = {}) { const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, - rolls: [roll], + rolls: rolls, sound: CONFIG.sounds.dice, } ChatMessage.applyRollMode(msgData, rollMode) @@ -595,7 +607,7 @@ export async function rollDefense(actor, options = {}) { const threshold = redDice ? 3 : 4 const colorEmoji = redDice ? "🔴" : "⬜" - const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) + const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold) const diceHtml = _diceHtml(diceResults, threshold) const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${attrChoice.charAt(0).toUpperCase() + attrChoice.slice(1)}`) @@ -631,7 +643,7 @@ export async function rollDefense(actor, options = {}) { const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, - rolls: [roll], + rolls: rolls, sound: CONFIG.sounds.dice, } ChatMessage.applyRollMode(msgData, rollMode) @@ -666,20 +678,21 @@ export async function rollWeaponDefense(actor, weapon, options = {}) { attackType = "melee", attrRank = 0, attrChoice = "agility", - redDice = false, + colorOverride = "white", traitBonus = 0, armorPenalty = 0, diminishPenalty = 0, bonus = 0, visibility, + explodeOn5 = false, } = options const defRank = actor.system.skills.defense.rank const totalDice = Math.max(attrRank + defRank + traitBonus + armorPenalty + diminishPenalty + bonus, 1) - const threshold = redDice ? 3 : 4 - const colorEmoji = redDice ? "🔴" : "⬜" + const threshold = colorOverride === "black" ? 2 : colorOverride === "red" ? 3 : 4 + const colorEmoji = colorOverride === "black" ? "⬛" : colorOverride === "red" ? "🔴" : "⬜" - const { roll, successes, diceResults } = await _rollPool(totalDice, threshold) + const { roll, rolls, successes, diceResults } = await _rollPool(totalDice, threshold, explodeOn5) const diceHtml = _diceHtml(diceResults, threshold) const attrLabel = game.i18n.localize(`OATHHAMMER.Attribute.${_cap(attrChoice)}`) @@ -691,6 +704,7 @@ export async function rollWeaponDefense(actor, weapon, options = {}) { if (armorPenalty < 0) modParts.push(`${armorPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.ArmorPenalty")}`) if (diminishPenalty < 0) modParts.push(`${diminishPenalty} ${game.i18n.localize("OATHHAMMER.Dialog.DiminishingDefense")}`) if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) + if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`) const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" const content = ` @@ -716,7 +730,7 @@ export async function rollWeaponDefense(actor, weapon, options = {}) { const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, - rolls: [roll], + rolls: rolls, sound: CONFIG.sounds.dice, } ChatMessage.applyRollMode(msgData, rollMode) @@ -740,36 +754,43 @@ export async function rollWeaponDefense(actor, weapon, options = {}) { */ export async function rollArmorSave(actor, armor, options = {}) { const { - av = armor.system.armorValue ?? 0, - isReinforced = false, - apPenalty = 0, - bonus = 0, + av = armor.system.armorValue ?? 0, + isReinforced = false, + colorOverride = null, + apPenalty = 0, + bonus = 0, visibility, + explodeOn5 = false, } = options // Armor CAN be reduced to 0 dice (fully bypassed by AP) - const totalDice = Math.max(av + apPenalty + bonus, 0) - const threshold = isReinforced ? 3 : 4 - const colorEmoji = isReinforced ? "🔴" : "⬜" + const totalDice = Math.max(av + apPenalty + bonus, 0) + const colorType = colorOverride || (isReinforced ? "red" : "white") + const threshold = colorType === "black" ? 2 : colorType === "red" ? 3 : 4 + const colorEmoji = colorType === "black" ? "⬛" : colorType === "red" ? "🔴" : "⬜" let successes = 0 let diceHtml = `${game.i18n.localize("OATHHAMMER.Roll.ArmorBypassed")}` let roll + let rolls = [] if (totalDice > 0) { - const result = await _rollPool(totalDice, threshold) + const result = await _rollPool(totalDice, threshold, explodeOn5) roll = result.roll + rolls = result.rolls successes = result.successes diceHtml = _diceHtml(result.diceResults, threshold) } else { // Zero dice — create a dummy roll with no results so Foundry can still attach it roll = new Roll("0d6") await roll.evaluate() + rolls = [roll] } const modParts = [] if (apPenalty < 0) modParts.push(`AP ${apPenalty}`) if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) + if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`) const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" const content = ` @@ -795,7 +816,7 @@ export async function rollArmorSave(actor, armor, options = {}) { const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, - rolls: [roll], + rolls: rolls, sound: CONFIG.sounds.dice, } ChatMessage.applyRollMode(msgData, rollMode) @@ -803,3 +824,71 @@ export async function rollArmorSave(actor, armor, options = {}) { return { successes, totalDice } } + +// ============================================================ +// INITIATIVE ROLL +// ============================================================ + +/** + * Roll an initiative check for an actor and post the result to chat. + * + * Characters: opposed Leadership check (Fate + Leadership skill, DV=0). + * NPCs: pool of Fate rank + initiativeBonus dice (no skill), threshold 4+. + * + * @param {Actor} actor + * @param {object} [options] + * @param {number} [options.bonus] Extra dice modifier + * @param {string} [options.visibility] Roll mode + * @param {boolean} [options.explodeOn5] Explode on 5+ + * @returns {Promise<{successes: number, dv: number, isSuccess: null}>} + */ +export async function rollInitiativeCheck(actor, options = {}) { + const { bonus = 0, visibility, explodeOn5 = false } = options + + if (actor.type === "character") { + return rollSkillCheck(actor, "leadership", 0, { + bonus, + visibility, + explodeOn5, + flavor: game.i18n.localize("OATHHAMMER.Roll.Initiative"), + }) + } + + // NPC: Fate rank + initiativeBonus + const sys = actor.system + const fateRank = sys.attributes?.fate?.rank ?? 1 + const initBonus = sys.initiativeBonus ?? 0 + const pool = Math.max(fateRank + initBonus + bonus, 1) + const threshold = 4 + + const { roll, rolls, successes, diceResults } = await _rollPool(pool, threshold, explodeOn5) + const diceHtml = _diceHtml(diceResults, threshold) + + const modParts = [] + if (bonus !== 0) modParts.push(`${bonus > 0 ? "+" : ""}${bonus} ${game.i18n.localize("OATHHAMMER.Dialog.Modifier")}`) + if (explodeOn5) modParts.push(`💥 ${game.i18n.localize("OATHHAMMER.Dialog.ExplodeOn5")}`) + const modLine = modParts.length ? `
${modParts.join(" · ")}
` : "" + + const content = ` +
+
⚔ ${game.i18n.localize("OATHHAMMER.Roll.Initiative")} — ${actor.name}
+
+ ${game.i18n.localize("OATHHAMMER.Attribute.Fate")} ${fateRank}${initBonus ? ` + ${initBonus}` : ""} + ⬜ ${pool}d6 (4+) +
+ ${modLine} +
${diceHtml}
+
+ ${successes} + ${game.i18n.localize("OATHHAMMER.Roll.Opposed")} +
+
+ ` + + const rollMode = visibility ?? game.settings.get("core", "rollMode") + const msgData = { speaker: ChatMessage.getSpeaker({ actor }), content, rolls: rolls, sound: CONFIG.sounds.dice } + ChatMessage.applyRollMode(msgData, rollMode) + await ChatMessage.create(msgData) + + return { successes, dv: 0, isSuccess: null } +} diff --git a/oath-hammer.mjs b/oath-hammer.mjs index caf6ba4..e713fae 100644 --- a/oath-hammer.mjs +++ b/oath-hammer.mjs @@ -6,6 +6,7 @@ import * as documents from "./module/documents/_module.mjs" import * as applications from "./module/applications/_module.mjs" import OathHammerUtils from "./module/utils.mjs" import OathHammerWeaponDialog from "./module/applications/weapon-dialog.mjs" +import OathHammerCombat from "./module/combat.mjs" import { rollWeaponDamage } from "./module/rolls.mjs" Hooks.once("init", function () { @@ -18,6 +19,7 @@ Hooks.once("init", function () { game.system.api = { applications, models, documents } CONFIG.Actor.documentClass = documents.OathHammerActor + CONFIG.Combat.documentClass = OathHammerCombat CONFIG.Actor.dataModels = { character: models.OathHammerCharacter, npc: models.OathHammerNPC diff --git a/system.json b/system.json index 4924759..14c5710 100644 --- a/system.json +++ b/system.json @@ -106,6 +106,7 @@ "distance": 5, "units": "ft" }, + "initiative": "@attributes.fate.rank", "primaryTokenAttribute": "grit", "socket": true, "background": "systems/fvtt-oath-hammer/assets/images/cover_art.webp", diff --git a/templates/actor/character-combat.hbs b/templates/actor/character-combat.hbs index 72a667b..5e681f8 100644 --- a/templates/actor/character-combat.hbs +++ b/templates/actor/character-combat.hbs @@ -3,6 +3,12 @@ {{localize "OATHHAMMER.Label.ItemSlots"}} {{slotsUsed}} / {{slotsMax}}
+
+ + {{localize "OATHHAMMER.Dialog.RollInitiative"}} + + {{#if combatantInitiative}}⚔ {{combatantInitiative}}{{/if}} +
{{localize "OATHHAMMER.Label.Weapons"}} {{#unless isPlayMode}}{{/unless}} @@ -22,7 +28,7 @@ {{#each weapons as |weapon|}}
  • - {{weapon.name}} + {{weapon.name}} {{weapon._groupLabel}} {{weapon.system.damageLabel}} {{#if weapon.system.ap}}{{weapon.system.ap}}{{else}}—{{/if}} @@ -62,7 +68,7 @@ {{#each armors as |armor|}}
  • - {{armor.name}} + {{armor.name}} {{armor._typeLabel}} {{armor.system.armorValue}} {{#if armor.system.penalty}}{{armor.system.penalty}}{{else}}—{{/if}} diff --git a/templates/actor/character-equipment.hbs b/templates/actor/character-equipment.hbs index 3994ecb..fcdf268 100644 --- a/templates/actor/character-equipment.hbs +++ b/templates/actor/character-equipment.hbs @@ -36,7 +36,7 @@ {{#each equipment as |equip|}}
  • - {{equip.name}} + {{equip.name}} {{localize equip.system.itemType}} {{equip.system.quantity}}
    @@ -64,7 +64,7 @@ {{#each magicItems as |mi|}}
  • - {{mi.name}} + {{mi.name}} {{localize mi.system.rarity}}
    diff --git a/templates/actor/character-identity.hbs b/templates/actor/character-identity.hbs index 2de8fa4..9c117b6 100644 --- a/templates/actor/character-identity.hbs +++ b/templates/actor/character-identity.hbs @@ -1,8 +1,38 @@
    + + {{!-- Oaths --}}
    - {{localize "OATHHAMMER.Label.Background"}} - {{formInput systemFields.background enriched=enrichedBackground value=system.background name="system.background" toggled=true}} + {{localize "OATHHAMMER.Label.Oaths"}} + {{#unless isPlayMode}}{{/unless}} + + {{#if oaths.length}} +
      +
    • + + {{localize "OATHHAMMER.Label.Name"}} + {{localize "OATHHAMMER.Label.Type"}} + {{localize "OATHHAMMER.Label.Violated"}} + +
    • + {{#each oaths as |oath|}} +
    • + + {{oath.name}} + {{oath._typeLabel}} + {{#if oath._violated}}{{else}}{{/if}} +
      + + +
      +
    • + {{/each}} +
    + {{else}} +

    {{localize "OATHHAMMER.Label.NoOaths"}}

    + {{/if}}
    + + {{!-- Traits --}}
    {{localize "OATHHAMMER.Label.Traits"}} {{#unless isPlayMode}}{{/unless}} @@ -19,7 +49,7 @@ {{#each traits as |trait|}}
  • - {{trait.name}} + {{trait.name}} {{trait._typeLabel}} {{trait._usageLabel}}
    @@ -33,30 +63,11 @@

    {{localize "OATHHAMMER.Label.NoTraits"}}

    {{/if}}
  • - {{#if oaths.length}} + + {{!-- Background --}}
    - {{localize "OATHHAMMER.Label.Oaths"}} -
      -
    • - - {{localize "OATHHAMMER.Label.Name"}} - {{localize "OATHHAMMER.Label.Type"}} - {{localize "OATHHAMMER.Label.Violated"}} - -
    • - {{#each oaths as |oath|}} -
    • - - {{oath.name}} - {{oath._typeLabel}} - {{#if oath._violated}}{{else}}{{/if}} -
      - - -
      -
    • - {{/each}} -
    + {{localize "OATHHAMMER.Label.Background"}} + {{formInput systemFields.background enriched=enrichedBackground value=system.background name="system.background" toggled=true}}
    - {{/if}} +
    diff --git a/templates/actor/character-magic.hbs b/templates/actor/character-magic.hbs index 7865f57..ad8360f 100644 --- a/templates/actor/character-magic.hbs +++ b/templates/actor/character-magic.hbs @@ -23,7 +23,7 @@ {{#each spells as |spell|}}
  • - {{spell.name}} + {{spell.name}} {{spell.system.difficultyValue}} {{localize spell.system.tradition}} @@ -61,7 +61,7 @@ {{#each miracles as |miracle|}}
  • - {{miracle.name}} + {{miracle.name}} {{miracle.system.divineTradition}}
    diff --git a/templates/actor/character-sheet.hbs b/templates/actor/character-sheet.hbs index 3e2258f..5612531 100644 --- a/templates/actor/character-sheet.hbs +++ b/templates/actor/character-sheet.hbs @@ -48,7 +48,7 @@
    - {{!-- Row 3: Unified stats band: resources | attributes | arcane stress --}} + {{!-- Row 3: Unified stats band: resources | attributes --}}
    @@ -63,10 +63,6 @@ / {{formInput systemFields.luck.fields.max value=system.luck.max name="system.luck.max" disabled=isPlayMode}}
    -
    - {{localize "OATHHAMMER.Label.Defense"}} - -
    {{localize "OATHHAMMER.Label.Movement"}} {{formInput systemFields.movement.fields.base value=system.movement.base name="system.movement.base" disabled=isPlayMode}} @@ -84,15 +80,6 @@ {{/each}}
    - -
    - {{localize "OATHHAMMER.Label.ArcaneStress"}} -
    - {{formInput systemFields.arcaneStress.fields.value value=system.arcaneStress.value name="system.arcaneStress.value" disabled=isPlayMode}} - / - {{formInput systemFields.arcaneStress.fields.threshold value=system.arcaneStress.threshold name="system.arcaneStress.threshold" disabled=isPlayMode}} -
    -
    diff --git a/templates/actor/npc-combat.hbs b/templates/actor/npc-combat.hbs index f5591b2..d00a763 100644 --- a/templates/actor/npc-combat.hbs +++ b/templates/actor/npc-combat.hbs @@ -1,4 +1,10 @@
    +
    + + {{localize "OATHHAMMER.Dialog.RollInitiative"}} + + {{#if combatantInitiative}}⚔ {{combatantInitiative}}{{/if}} +
    {{localize "OATHHAMMER.Label.Weapons"}} {{#if weapons.length}} diff --git a/templates/actor/npc-sheet.hbs b/templates/actor/npc-sheet.hbs index 33af4c4..51ac35f 100644 --- a/templates/actor/npc-sheet.hbs +++ b/templates/actor/npc-sheet.hbs @@ -43,6 +43,10 @@ {{formInput systemFields.damageBonus value=system.damageBonus name="system.damageBonus" disabled=isPlayMode}} +
    + + {{formInput systemFields.initiativeBonus value=system.initiativeBonus name="system.initiativeBonus" disabled=isPlayMode}} +
    diff --git a/templates/armor-roll-dialog.hbs b/templates/armor-roll-dialog.hbs index 7033390..d71413c 100644 --- a/templates/armor-roll-dialog.hbs +++ b/templates/armor-roll-dialog.hbs @@ -37,6 +37,20 @@ +
    + + + {{localize "OATHHAMMER.Dialog.DiceColorHint"}} +
    + +
    + + + {{localize "OATHHAMMER.Dialog.ExplodeOn5Hint"}} +
    +
    {{!-- Visibility -----------------------------------------------------------}} diff --git a/templates/miracle-cast-dialog.hbs b/templates/miracle-cast-dialog.hbs index 55e0f7d..4b5f58a 100644 --- a/templates/miracle-cast-dialog.hbs +++ b/templates/miracle-cast-dialog.hbs @@ -53,6 +53,12 @@ {{localize "OATHHAMMER.Dialog.AttackModifierHint"}} +
    + + + {{localize "OATHHAMMER.Dialog.ExplodeOn5Hint"}} +
    +
  • {{!-- Visibility --}} diff --git a/templates/roll-dialog.hbs b/templates/roll-dialog.hbs index 875103c..836d2e7 100644 --- a/templates/roll-dialog.hbs +++ b/templates/roll-dialog.hbs @@ -73,6 +73,8 @@ + + {{localize "OATHHAMMER.Dialog.LuckHint"}} ({{availableLuck}} {{localize "OATHHAMMER.Dialog.Available"}}) {{/if}} diff --git a/templates/spell-cast-dialog.hbs b/templates/spell-cast-dialog.hbs index a53b31f..e0106e2 100644 --- a/templates/spell-cast-dialog.hbs +++ b/templates/spell-cast-dialog.hbs @@ -67,6 +67,12 @@ +
    + + + {{localize "OATHHAMMER.Dialog.ExplodeOn5Hint"}} +
    + {{!-- Visibility --}} diff --git a/templates/weapon-attack-dialog.hbs b/templates/weapon-attack-dialog.hbs index 37a8265..6c14e5c 100644 --- a/templates/weapon-attack-dialog.hbs +++ b/templates/weapon-attack-dialog.hbs @@ -47,6 +47,14 @@ {{localize "OATHHAMMER.Dialog.AttackModifierHint"}} +
    + + + {{localize "OATHHAMMER.Dialog.DiceColorHint"}} +
    + {{#if isRanged}}
    @@ -56,6 +64,12 @@
    {{/if}} +
    + + + {{localize "OATHHAMMER.Dialog.ExplodeOn5Hint"}} +
    + {{!-- Visibility --}} diff --git a/templates/weapon-defense-dialog.hbs b/templates/weapon-defense-dialog.hbs index e91989c..2553514 100644 --- a/templates/weapon-defense-dialog.hbs +++ b/templates/weapon-defense-dialog.hbs @@ -72,6 +72,20 @@ {{localize "OATHHAMMER.Dialog.AttackModifierHint"}} +
    + + + {{localize "OATHHAMMER.Dialog.DiceColorHint"}} +
    + +
    + + + {{localize "OATHHAMMER.Dialog.ExplodeOn5Hint"}} +
    + {{!-- Visibility --}}