diff --git a/.idea/runConfigurations/FoundryV14.xml b/.idea/runConfigurations/FoundryV14.xml
deleted file mode 100644
index e17edefc..00000000
--- a/.idea/runConfigurations/FoundryV14.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/runConfigurations/FoundryV14__Next_.xml b/.idea/runConfigurations/FoundryV14__Next_.xml
new file mode 100644
index 00000000..2051c133
--- /dev/null
+++ b/.idea/runConfigurations/FoundryV14__Next_.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/browser-tests/e2e/mighty-deeds.spec.js b/browser-tests/e2e/mighty-deeds.spec.js
new file mode 100644
index 00000000..8b4616e3
--- /dev/null
+++ b/browser-tests/e2e/mighty-deeds.spec.js
@@ -0,0 +1,269 @@
+const { test, expect } = require('@playwright/test')
+
+/**
+ * E2E tests for Mighty Deed table prompts (issue #319)
+ * Create a world deed table, attack with a warrior until the deed die
+ * succeeds (3+), and verify the attack chat card offers the deed table
+ * prompt and that clicking Roll Deed posts the table result to chat.
+ *
+ * PREREQUISITES:
+ * 1. Start Foundry: npx @foundryvtt/foundryvtt-cli launch --world=v14
+ * 2. Run tests: npm test
+ *
+ * The tests will automatically log in as Gamemaster (no password).
+ */
+
+/* global game, ui, Actor, RollTable, CONFIG, CONST */
+
+/**
+ * Create the test fixtures in the live world: a Mighty Deed roll table
+ * and a warrior with a deed die attack bonus and an equipped weapon.
+ * @param {import('@playwright/test').Page} page
+ * @returns {Promise<{actorId: string, weaponId: string, registryEntry: object}>}
+ */
+async function createDeedFixtures (page) {
+ return page.evaluate(async () => {
+ await RollTable.create({
+ name: 'E2E Deed Table',
+ formula: '1d7',
+ results: [
+ { type: CONST.TABLE_RESULT_TYPES.TEXT, description: 'Off-balance: enemy gets a Ref save or is knocked prone.', range: [3, 3] },
+ { type: CONST.TABLE_RESULT_TYPES.TEXT, description: 'Knockdown: a human-sized opponent is knocked prone.', range: [4, 4] },
+ { type: CONST.TABLE_RESULT_TYPES.TEXT, description: 'Throw: the opponent is knocked down and thrown 10 feet.', range: [5, 99] }
+ ]
+ })
+
+ const actor = await Actor.create({
+ name: 'E2E Warrior',
+ type: 'Player',
+ system: {
+ details: { sheetClass: 'Warrior', attackBonus: '+d4' },
+ class: { className: 'Warrior' },
+ config: { attackBonusMode: 'autoPerAttack' }
+ }
+ })
+ const [weapon] = await actor.createEmbeddedDocuments('Item', [{
+ name: 'E2E Longsword',
+ type: 'weapon',
+ system: { actionDie: '1d20', toHit: '@ab', damage: '1d8+@ab', melee: true, equipped: true }
+ }])
+
+ return {
+ actorId: actor.id,
+ weaponId: weapon.id,
+ registryEntry: CONFIG.DCC.mightyDeedsTables['E2E Deed Table']
+ }
+ })
+}
+
+/**
+ * Attack with the warrior's weapon until the deed die result matches the
+ * wanted success state, and return that attack's chat message data.
+ * @param {import('@playwright/test').Page} page
+ * @param {{actorId: string, weaponId: string}} ids
+ * @param {boolean} wantSuccess - true to stop on a deed of 3+, false to stop on a failed deed
+ */
+async function attackUntilDeed (page, ids, wantSuccess) {
+ return page.evaluate(async ({ actorId, weaponId, wantSuccess }) => {
+ const actor = game.actors.get(actorId)
+ for (let attempt = 0; attempt < 30; attempt++) {
+ const before = game.messages.size
+ await actor.rollWeaponAttack(weaponId)
+ // rollWeaponAttack does not await its ChatMessage.create, so wait for the card to land
+ for (let w = 0; w < 50 && game.messages.size === before; w++) {
+ await new Promise(resolve => setTimeout(resolve, 100))
+ }
+ const msg = game.messages.contents.at(-1)
+ if (game.messages.size > before && Boolean(msg.system?.deedRollSuccess) === wantSuccess) {
+ return {
+ messageId: msg.id,
+ deedDieRollResult: msg.system.deedDieRollResult,
+ deedRollSuccess: msg.system.deedRollSuccess,
+ deedTables: msg.system.deedTables,
+ contentHasPrompt: msg.content.includes('deed-table-prompt')
+ }
+ }
+ }
+ return null
+ }, { ...ids, wantSuccess })
+}
+
+/**
+ * Toggle the off-by-default `mightyDeedsEnabled` world setting (issue #319).
+ * @param {import('@playwright/test').Page} page
+ * @param {boolean} enabled
+ */
+async function setMightyDeedsEnabled (page, enabled) {
+ await page.evaluate((value) => game.settings.set('dcc', 'mightyDeedsEnabled', value), enabled)
+}
+
+test.describe('Mighty Deeds E2E Tests', () => {
+ let consoleErrors = []
+
+ test.beforeAll(async () => {
+ let serverUp
+ try {
+ const response = await fetch('http://localhost:30000/', { signal: AbortSignal.timeout(5000) })
+ serverUp = response.ok
+ } catch {
+ serverUp = false
+ }
+ if (!serverUp) {
+ throw new Error(
+ 'Could not connect to Foundry VTT at http://localhost:30000.\n\n' +
+ 'Please start Foundry before running tests:\n' +
+ '1. Run: npx @foundryvtt/foundryvtt-cli launch --world=v14\n' +
+ '2. Run tests again: npm test'
+ )
+ }
+ })
+
+ test.beforeEach(async ({ page }) => {
+ consoleErrors = []
+ page.on('console', msg => {
+ if (msg.type() === 'error') {
+ consoleErrors.push(msg.text())
+ }
+ })
+
+ await page.setViewportSize({ width: 1280, height: 800 })
+
+ await page.goto('http://localhost:30000/join')
+ await page.waitForTimeout(1000)
+
+ const isInGame = await page.locator('.game.system-dcc').isVisible({ timeout: 1000 }).catch(() => false)
+
+ if (!isInGame) {
+ const userSelect = page.locator('select[name="userid"]')
+ await userSelect.waitFor({ state: 'visible', timeout: 10000 })
+ await page.selectOption('select[name="userid"]', { label: 'Gamemaster' })
+ await page.click('button[name="join"]')
+ await page.waitForSelector('.game.system-dcc', { timeout: 30000 })
+ }
+
+ await page.waitForSelector('#actors', { timeout: 10000, state: 'attached' })
+
+ // System settings register in the async ready hook on every client
+ // load - sheet renders read them, so wait until registration is done
+ await page.waitForFunction(() => game.settings?.settings?.has('dcc.coinWeight'), { timeout: 15000 })
+
+ // Remove any Foundry notification banners
+ await page.evaluate(() => document.querySelectorAll('#notifications .notification').forEach(n => n.remove()))
+
+ // Clean up leftover test entities from previous runs
+ await page.evaluate(async () => {
+ for (const actor of game.actors.filter(a => a.name.startsWith('E2E '))) {
+ await actor.delete()
+ }
+ for (const table of game.tables.filter(t => t.name.startsWith('E2E '))) {
+ await table.delete()
+ }
+ })
+
+ // Reset the deed prompt to its off-by-default state for test isolation (issue #319)
+ await setMightyDeedsEnabled(page, false)
+
+ // Close any welcome dialogs
+ for (const selector of ['#dcc-welcome-dialog', '#dcc-core-book-welcome-dialog']) {
+ const dialog = page.locator(selector)
+ if (await dialog.isVisible({ timeout: 500 }).catch(() => false)) {
+ await page.keyboard.press('Escape')
+ await page.waitForTimeout(300)
+ }
+ }
+ })
+
+ test.afterEach(async () => {
+ const significantErrors = consoleErrors.filter(err => !err.includes('favicon.ico'))
+ expect(significantErrors, `Console errors detected: ${significantErrors.join('\n')}`).toHaveLength(0)
+ })
+
+ test('world tables with Deed in the name register and unregister', async ({ page }) => {
+ const fixtures = await createDeedFixtures(page)
+
+ // The createRollTable hook picked the world table up immediately
+ expect(fixtures.registryEntry).toEqual({ name: 'E2E Deed Table', path: 'E2E Deed Table' })
+
+ // Deleting the table removes it from the registry
+ const afterDelete = await page.evaluate(async () => {
+ await game.tables.getName('E2E Deed Table').delete()
+ return CONFIG.DCC.mightyDeedsTables['E2E Deed Table'] || null
+ })
+ expect(afterDelete).toBeNull()
+ })
+
+ test('a successful deed offers the table prompt and Roll Deed posts the result', async ({ page }) => {
+ const fixtures = await createDeedFixtures(page)
+ await setMightyDeedsEnabled(page, true)
+
+ const success = await attackUntilDeed(page, fixtures, true)
+ expect(success, 'no successful deed in 30 attacks').not.toBeNull()
+ expect(success.deedDieRollResult).toBeGreaterThanOrEqual(3)
+ // Other deed tables may be registered too (e.g. the dcc-core-book pack)
+ expect(success.deedTables).toContainEqual({ name: 'E2E Deed Table', path: 'E2E Deed Table' })
+ expect(success.contentHasPrompt).toBe(true)
+
+ // Select the test table and click Roll Deed on the rendered chat card
+ await page.evaluate(() => ui.sidebar.expand())
+ await page.click('button[data-tab="chat"]')
+ await page.waitForTimeout(500)
+ await page.evaluate(() => ui.chat.scrollBottom({ immediate: true }))
+ const card = page.locator(`#chat .chat-message[data-message-id="${success.messageId}"]`)
+ await card.locator('.deed-table-select').selectOption('E2E Deed Table')
+ const button = card.locator('.roll-deed-table')
+ await button.scrollIntoViewIfNeeded()
+ await expect(button).toBeVisible()
+
+ const messagesBefore = await page.evaluate(() => game.messages.size)
+ await button.click()
+ await expect.poll(async () => {
+ return page.evaluate(() => game.messages.size)
+ }, { timeout: 10000 }).toBeGreaterThan(messagesBefore)
+
+ const result = await page.evaluate(() => {
+ const msg = game.messages.contents.at(-1)
+ return { flavor: msg.flavor, content: msg.content, isMightyDeed: msg.getFlag('dcc', 'isMightyDeed') }
+ })
+ expect(result.isMightyDeed).toBe(true)
+ expect(result.flavor).toContain('E2E Deed Table')
+ expect(result.flavor).toContain(`(${success.deedDieRollResult})`)
+ // The posted result is the table entry matching the deed die value
+ const expected = {
+ 3: 'Off-balance',
+ 4: 'Knockdown'
+ }[success.deedDieRollResult] || 'Throw'
+ expect(result.content).toContain(expected)
+
+ // One-shot: the button disables after posting and a second click adds no further result
+ await expect(button).toBeDisabled()
+ const countAfterFirst = await page.evaluate(() => game.messages.size)
+ await button.click({ force: true }).catch(() => {})
+ await page.waitForTimeout(500)
+ expect(await page.evaluate(() => game.messages.size)).toBe(countAfterFirst)
+ })
+
+ test('a failed deed shows no table prompt', async ({ page }) => {
+ const fixtures = await createDeedFixtures(page)
+ await setMightyDeedsEnabled(page, true)
+
+ const failure = await attackUntilDeed(page, fixtures, false)
+ expect(failure, 'no failed deed in 30 attacks').not.toBeNull()
+ expect(failure.deedDieRollResult).toBeLessThan(3)
+ expect(failure.deedTables).toEqual([])
+ expect(failure.contentHasPrompt).toBe(false)
+ })
+
+ test('with the setting disabled (default), a successful deed shows no prompt', async ({ page }) => {
+ const fixtures = await createDeedFixtures(page)
+ // mightyDeedsEnabled is left at its default (false) by beforeEach.
+ // The table is still registered, but the attack card must not offer it.
+ expect(fixtures.registryEntry).toEqual({ name: 'E2E Deed Table', path: 'E2E Deed Table' })
+
+ const success = await attackUntilDeed(page, fixtures, true)
+ expect(success, 'no successful deed in 30 attacks').not.toBeNull()
+ expect(success.deedDieRollResult).toBeGreaterThanOrEqual(3)
+ // Feature off: no tables attached and no prompt rendered even on a deed success
+ expect(success.deedTables).toEqual([])
+ expect(success.contentHasPrompt).toBe(false)
+ })
+})
diff --git a/browser-tests/e2e/playwright.config.js b/browser-tests/e2e/playwright.config.js
index d939f8d2..94cd3f72 100644
--- a/browser-tests/e2e/playwright.config.js
+++ b/browser-tests/e2e/playwright.config.js
@@ -15,7 +15,13 @@ module.exports = defineConfig({
timeout: 60000, // 60 seconds per test
use: {
baseURL: 'http://localhost:30000',
- trace: 'on-first-retry'
+ trace: 'on-first-retry',
+ launchOptions: {
+ // Hardware-accelerated WebGL in headless Chromium (Metal on macOS).
+ // Without it Foundry detects SwiftShader and shows a permanent
+ // "hardware acceleration" banner that intercepts clicks in tests.
+ args: process.platform === 'darwin' ? ['--use-angle=metal'] : []
+ }
},
projects: [
{
diff --git a/docs/dev/EXTENSION_API.md b/docs/dev/EXTENSION_API.md
index 039c08e1..8f8502a0 100644
--- a/docs/dev/EXTENSION_API.md
+++ b/docs/dev/EXTENSION_API.md
@@ -89,6 +89,7 @@ audited sibling modules for the path before slimming.
| `dcc.postActorImport` | `module/parser.js:273` | `xcc` (self-emission) | §2.5 | Fired after Purple Sorcerer / stat-block import. |
| `dcc.registerCriticalHitsPack` | `module/settings.js:76` | `dcc-core-book`, `xcc-core-book` (emitters) | §2.10, §2.11 | Emitted by settings change AND re-emitted by content packs during `dcc.ready`. |
| `dcc.registerDisapprovalPack` | `module/settings.js:124` | `dcc-core-book`, `xcc-core-book`, `dcc-annual-1` (emitters) | §2.10, §2.11 | Same pattern. |
+| `dcc.registerMightyDeedsPack` | `module/settings.js` (onChange emitter); handler in `module/settings-table-hooks.mjs` | `dcc-core-book` (companion pack, emitter) | §2.10, §2.11 | Same pattern as `registerDisapprovalPack`. Registers a compendium of Mighty Deed result tables surfaced on the attack card's deed prompt (issue #319). Emitted by the `mightyDeedsCompendium` settings change AND re-emitted by content packs during `dcc.ready`. The prompt itself is gated behind the off-by-default `mightyDeedsEnabled` world setting. |
| `dcc.registerLevelDataPack` | *(system listens; emitted by packs)* | `dcc-core-book`, `xcc-core-book`, `dcc-crawl-classes` (emitters); system listens at `dcc.js:923` | §2.10, §2.11 | System is a *listener* here, not an emitter. Class progressions come in through this. |
| `dcc.setFumbleTable` | `module/settings.js:108` | `dcc-core-book`, `xcc-core-book` (emitters) | §2.10 | |
| `dcc.setDivineAidTable` | `module/settings.js:172` | `dcc-core-book` (emitter) | §2.10 | |
diff --git a/docs/user-guide/Mighty-Deeds.md b/docs/user-guide/Mighty-Deeds.md
index 13245639..ad765907 100644
--- a/docs/user-guide/Mighty-Deeds.md
+++ b/docs/user-guide/Mighty-Deeds.md
@@ -30,3 +30,19 @@ You can use `+@ab` in your weapon's to hit and damage fields to include the deed
See [Advanced Character Settings](Advanced-Character-Settings.md) for more details on the Attack Bonus Mode setting.
+## Mighty Deed Table Prompt (optional)
+
+When enabled, a successful deed (a deed die of **3 or higher**) adds a prompt to the attack chat card: a dropdown of available Mighty Deed tables plus a **Roll Deed** button. Pick the table for the deed you declared and click **Roll Deed** to look the deed die result up on that table and post the outcome to chat.
+
+This feature is **off by default**. To turn it on:
+
+1. Open **Game Settings → Configure Settings → Dungeon Crawl Classics**
+2. Enable **Enable Mighty Deed Tables**
+
+Tables are gathered from two places:
+
+- **World roll tables** whose name contains **"Deed"** are picked up automatically (created, renamed, and deleted tables update live).
+- A **Mighty Deeds Tables Compendium** can be selected under the manual compendium settings; modules (such as the core rulebook content) can also register deed-table packs via the `dcc.registerMightyDeedsPack` hook.
+
+If no deed tables exist, or the deed fails, the attack card is unchanged and no prompt appears.
+
diff --git a/lang/cn.json b/lang/cn.json
index 0eb47c12..7722253f 100644
--- a/lang/cn.json
+++ b/lang/cn.json
@@ -239,6 +239,7 @@
"DCC.DamageDie": "伤害骰",
"DCC.DamageModifier": "伤害调整值",
"DCC.DamageRollInvalidFormulaInline": "无效伤害公式`{formula}`<\/b>",
+ "DCC.Deed": "壮举",
"DCC.DeedDie": "壮举骰",
"DCC.DeedRoll": "投壮举骰",
"DCC.Deity": "神祇",
@@ -467,6 +468,9 @@
"DCC.MercurialMagicRerollPrompt": "重投或查阅无常魔法投骰?",
"DCC.MercurialMagicRoll": "无常魔法投骰",
"DCC.MercurialTabHint": "为此法术投骰并设置无常魔法",
+ "DCC.MightyDeedRollFlavor": "在{table}上的武勇壮举({roll})",
+ "DCC.MightyDeedTableNotFound": "找不到壮举表{table}",
+ "DCC.MightyDeedTableSelectHint": "选择用于查询壮举骰结果的壮举表",
"DCC.MightyDeedsHowToLink": "武勇壮举使用方法",
"DCC.MightyDeedsLink": "@UUID[Compendium.dcc-core-book.dcc-core-text.JournalEntry.n5gqDCyFO09A3GAJ.JournalEntryPage.UrVBDNG5csbS5bBa#mighty-deeds-of-arms]{武勇壮举}",
"DCC.MightyDeedsOfArms": "武勇壮举",
@@ -540,6 +544,7 @@
"DCC.ResolveValueEmote": "已投骰{itemName}价值: {pp}pp{ep}ep{gp}gp{sp}sp{cp}cp",
"DCC.Roll": "投骰",
"DCC.RollCritical": "投骰大成功",
+ "DCC.RollDeed": "投壮举",
"DCC.RolledAbilityEmote": "{actorName} 投骰 {abilityName} 出 {abilityInlineRollHTML}。",
"DCC.AbilityCheckPenaltyNote": "
若应用检定惩罚,总计为 {total}。<\/p>",
"DCC.RolledCritEmote": "{actorName} 投骰大成功表{critTableName}{critResult} 出 {critInlineRollHTML}",
@@ -639,6 +644,10 @@
"DCC.SettingManualCompendiumConfigurationHint": "不使用核心书模组,而是自行建立数据库合集——不推荐",
"DCC.SettingMercurialMagicTable": "无常魔法表",
"DCC.SettingMercurialMagicTableHint": "为无常魔法投骰表格——必须在合集中存在",
+ "DCC.SettingMightyDeedsEnabled": "启用壮举表",
+ "DCC.SettingMightyDeedsEnabledHint": "当战士的壮举骰成功(3 或更高)时,在攻击卡上提供壮举表提示。默认关闭。",
+ "DCC.SettingMightyDeedsTablesCompendium": "壮举表合集",
+ "DCC.SettingMightyDeedsTablesCompendiumHint": "用于查找壮举表的合集。名称中包含'Deed'的世界表也会出现在攻击骰聊天卡上",
"DCC.SettingShowRollModifierByDefault": "默认显示调整投骰对话框",
"DCC.SettingShowRollModifierByDefaultHint": "无需按住 ⌘\/CTRL,点击角色卡物品就显示调整投骰对话框",
"DCC.SettingTurnUnholyTable": "驱散亵渎表",
diff --git a/lang/de.json b/lang/de.json
index 15ddbe92..cf18a4dd 100644
--- a/lang/de.json
+++ b/lang/de.json
@@ -239,6 +239,7 @@
"DCC.DamageDie": "Schadenswürfel",
"DCC.DamageModifier": "Schadensmodifikator",
"DCC.DamageRollInvalidFormulaInline": "Ungültige Schadensformel '{formula}'",
+ "DCC.Deed": "Großtat",
"DCC.DeedDie": "Kriegerwürfel",
"DCC.DeedRoll": "Großtatwurf",
"DCC.Deity": "Gottheit",
@@ -467,6 +468,9 @@
"DCC.MercurialMagicRerollPrompt": "Launenhaften Effekt nachschlagen oder erneut auswürfeln?",
"DCC.MercurialMagicRoll": "Launenhafter Effekt",
"DCC.MercurialTabHint": "Würfle und konfiguriere launenhafte Magie für diesen Zauber",
+ "DCC.MightyDeedRollFlavor": "Großtat ({roll}) auf {table}",
+ "DCC.MightyDeedTableNotFound": "Großtat-Tabelle {table} nicht gefunden",
+ "DCC.MightyDeedTableSelectHint": "Wähle die Großtat-Tabelle, auf der das Kriegerwürfel-Ergebnis nachgeschlagen wird",
"DCC.MightyDeedsHowToLink": "Großtaten How-To",
"DCC.MightyDeedsLink": "@UUID[Compendium.dcc-core-book.dcc-core-text.JournalEntry.n5gqDCyFO09A3GAJ.JournalEntryPage.UrVBDNG5csbS5bBa#mighty-deeds-of-arms]{Mighty Deeds of Arms}",
"DCC.MightyDeedsOfArms": "Großtaten des Kriegers",
@@ -540,6 +544,7 @@
"DCC.ResolveValueEmote": "Erwürfelter Wert von {itemName}: {pp} PM {ep} EM {gp} GM {sp} SM {cp} KM",
"DCC.Roll": "Wurf",
"DCC.RollCritical": "Kritischen Treffer würfeln",
+ "DCC.RollDeed": "Großtat würfeln",
"DCC.RolledAbilityEmote": "{actorName} würfelte {abilityInlineRollHTML} für {abilityName}.",
"DCC.AbilityCheckPenaltyNote": "
Wenn Kontrollabzug angewendet wird, beträgt die Summe {total}.
",
"DCC.RolledCritEmote": "{actorName} würfelte {critInlineRollHTML} auf Krit-Tabelle {critTableName}{critResult}",
@@ -639,6 +644,10 @@
"DCC.SettingManualCompendiumConfigurationHint": "Falls du deine eigenen Nachschlage-Kompendien erstellen möchtest, anstatt das Grundregelbuch-Modul zu verwenden - nicht empfohlen",
"DCC.SettingMercurialMagicTable": "Tabelle Launenhafte Magie",
"DCC.SettingMercurialMagicTableHint": "Würfeltabelle für Launenhafte Magie - muss in einem Kompendium liegen",
+ "DCC.SettingMightyDeedsEnabled": "Großtat-Tabellen aktivieren",
+ "DCC.SettingMightyDeedsEnabledHint": "Bietet auf Angriffskarten eine Großtat-Tabellenauswahl an, wenn der Großtat-Würfel eines Kriegers erfolgreich ist (3 oder höher). Standardmäßig deaktiviert.",
+ "DCC.SettingMightyDeedsTablesCompendium": "Kompendium für Großtat-Tabellen",
+ "DCC.SettingMightyDeedsTablesCompendiumHint": "Kompendium, in dem nach Großtat-Tabellen gesucht wird. Welttabellen mit 'Deed' im Namen werden ebenfalls auf der Angriffswurf-Chatkarte angeboten",
"DCC.SettingShowRollModifierByDefault": "Zeige standardmäßig Würfeldialog",
"DCC.SettingShowRollModifierByDefaultHint": "Vertausche das Standardverhalten des Würfeldialogs, so dass er immer erscheint, außer bei Strg-Klick auf das Würfelsymbol.",
"DCC.SettingTurnUnholyTable": "Tabelle Unheiliges vertreiben",
diff --git a/lang/en.json b/lang/en.json
index 6a48ec22..645b3ca1 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -239,6 +239,7 @@
"DCC.DamageDie": "Damage Die",
"DCC.DamageModifier": "Damage Modifier",
"DCC.DamageRollInvalidFormulaInline": "Invalid Damage formula '{formula}'",
+ "DCC.Deed": "Deed",
"DCC.DeedDie": "Deed Die",
"DCC.DeedRoll": "Roll Deed Die",
"DCC.Deity": "Deity",
@@ -467,6 +468,9 @@
"DCC.MercurialMagicRerollPrompt": "Re-roll or Lookup Mercurial Magic Roll?",
"DCC.MercurialMagicRoll": "Mercurial Magic Roll",
"DCC.MercurialTabHint": "Roll and configure mercurial magic for this spell",
+ "DCC.MightyDeedRollFlavor": "Mighty Deed ({roll}) on {table}",
+ "DCC.MightyDeedTableNotFound": "Unable to find Mighty Deed table {table}",
+ "DCC.MightyDeedTableSelectHint": "Choose the Mighty Deed table to look the deed die result up on",
"DCC.MightyDeedsHowToLink": "Mighty Deeds How-To",
"DCC.MightyDeedsLink": "@UUID[Compendium.dcc-core-book.dcc-core-text.JournalEntry.n5gqDCyFO09A3GAJ.JournalEntryPage.UrVBDNG5csbS5bBa#mighty-deeds-of-arms]{Mighty Deeds of Arms}",
"DCC.MightyDeedsOfArms": "Mighty Deeds of Arms",
@@ -540,6 +544,7 @@
"DCC.ResolveValueEmote": "Rolled value of {itemName}: {pp} Pp. {ep} Ep. {gp} Gp. {sp} Sp. {cp} Cp.",
"DCC.Roll": "Roll",
"DCC.RollCritical": "Roll Critical",
+ "DCC.RollDeed": "Roll Deed",
"DCC.RolledAbilityEmote": "{actorName} rolled {abilityInlineRollHTML} for their {abilityName}.",
"DCC.AbilityCheckPenaltyNote": "
If check penalty applies, total is {total}.
",
"DCC.RolledCritEmote": "{actorName} rolled {critInlineRollHTML} on Crit Table {critTableName}{critResult}",
@@ -639,6 +644,10 @@
"DCC.SettingManualCompendiumConfigurationHint": "If you want to build your own lookup compendia instead of using the Core Book Module - not recommended",
"DCC.SettingMercurialMagicTable": "Mercurial Magic Table",
"DCC.SettingMercurialMagicTableHint": "Roll Table to use for Mercurial Magic effects - must be in a compendium pack",
+ "DCC.SettingMightyDeedsEnabled": "Enable Mighty Deed Tables",
+ "DCC.SettingMightyDeedsEnabledHint": "Offer a Mighty Deed table prompt on attack cards when a warrior's deed die succeeds (3 or higher). Off by default.",
+ "DCC.SettingMightyDeedsTablesCompendium": "Mighty Deeds Tables Compendium",
+ "DCC.SettingMightyDeedsTablesCompendiumHint": "Compendium to look in for Mighty Deed tables. World tables with 'Deed' in the name are also offered on the attack roll chat card",
"DCC.SettingShowRollModifierByDefault": "Show the Modify Roll dialog by default",
"DCC.SettingShowRollModifierByDefaultHint": "Show Roll Modifier dialog without having to CMD/CTRL click on character sheet items",
"DCC.SettingTurnUnholyTable": "Turn Unholy Table",
diff --git a/lang/es.json b/lang/es.json
index 03316764..961216ac 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -239,6 +239,7 @@
"DCC.DamageDie": "Dado de daño",
"DCC.DamageModifier": "Modificador de daño",
"DCC.DamageRollInvalidFormulaInline": "Fórmula inválida de daño '{formula}'",
+ "DCC.Deed": "Hazaña",
"DCC.DeedDie": "Dado de acción",
"DCC.DeedRoll": "Tirar dado de acción",
"DCC.Deity": "Deidad",
@@ -467,6 +468,9 @@
"DCC.MercurialMagicRerollPrompt": "¿Volver a tirar o buscar la tirada de Magia Mercurial?",
"DCC.MercurialMagicRoll": "Tirada de Magia Mercurial",
"DCC.MercurialTabHint": "Tirar y configurar magia mercurial para este hechizo",
+ "DCC.MightyDeedRollFlavor": "Hazaña poderosa ({roll}) en {table}",
+ "DCC.MightyDeedTableNotFound": "No se encuentra la tabla de hazañas {table}",
+ "DCC.MightyDeedTableSelectHint": "Elige la tabla de hazañas en la que consultar el resultado del dado de acción",
"DCC.MightyDeedsHowToLink": "Cómo hacer Hazañas Asombrosas",
"DCC.MightyDeedsLink": "@UUID[Compendium.dcc-core-book.dcc-core-text.JournalEntry.n5gqDCyFO09A3GAJ.JournalEntryPage.UrVBDNG5csbS5bBa#mighty-deeds-of-arms]{Hazañas poderosas de armas}",
"DCC.MightyDeedsOfArms": "Hazañas poderosas de armas",
@@ -540,6 +544,7 @@
"DCC.ResolveValueEmote": "Valor tirado de {itemName}: {pp} pp. {ep} ep. {gp} gp. {sp} sp. {cp} cp.",
"DCC.Roll": "Tirar",
"DCC.RollCritical": "Tirada crítica",
+ "DCC.RollDeed": "Tirar hazaña",
"DCC.RolledAbilityEmote": "{actorName} tiró {abilityInlineRollHTML} para su {abilityName}.",
"DCC.AbilityCheckPenaltyNote": "
Si se aplica la penalización de control, el total es {total}.
",
"DCC.RolledCritEmote": "{actorName} tiró {critInlineRollHTML} en la tabla de críticos {critTableName}{critResult}",
@@ -639,6 +644,10 @@
"DCC.SettingManualCompendiumConfigurationHint": "Si quieres crear tus propios compendios de consulta en lugar de usar el módulo Core Book - no recomendado",
"DCC.SettingMercurialMagicTable": "Tabla de Magia Mercurial",
"DCC.SettingMercurialMagicTableHint": "Tabla para usar en efectos de magia mercurial - debe estar en un paquete de compendio",
+ "DCC.SettingMightyDeedsEnabled": "Habilitar Tablas de Hazañas",
+ "DCC.SettingMightyDeedsEnabledHint": "Ofrece una selección de tabla de Hazañas en las cartas de ataque cuando el dado de hazaña de un guerrero tiene éxito (3 o más). Desactivado por defecto.",
+ "DCC.SettingMightyDeedsTablesCompendium": "Compendio de Tablas de Hazañas",
+ "DCC.SettingMightyDeedsTablesCompendiumHint": "Compendio donde buscar tablas de hazañas poderosas. Las tablas del mundo con 'Deed' en el nombre también se ofrecen en la tarjeta de chat de la tirada de ataque",
"DCC.SettingShowRollModifierByDefault": "Mostrar diálogo de modificar tirada por defecto",
"DCC.SettingShowRollModifierByDefaultHint": "Mostrar diálogo de modificador sin tener que CMD/CTRL clic en elementos de la hoja",
"DCC.SettingTurnUnholyTable": "Tabla de Rechazo a lo Impío",
diff --git a/lang/fr.json b/lang/fr.json
index 6f265be4..d45b282f 100644
--- a/lang/fr.json
+++ b/lang/fr.json
@@ -225,6 +225,7 @@
"DCC.DamageDie": "Dé de dégat",
"DCC.DamageModifier": "Mod. dégats",
"DCC.DamageRollInvalidFormulaInline": " Formule de dégât invalide '{formula}'",
+ "DCC.Deed": "Haut fait",
"DCC.DeedDie": "Dé de haut fait",
"DCC.DeedRoll": "Jet de haut fait",
"DCC.Deity": "Divinité",
@@ -454,6 +455,9 @@
"DCC.MercurialMagicRerollPrompt": "Relancer ou choisir le résultat du jet de magie mercurielle?",
"DCC.MercurialMagicRoll": "Jet de magie mercurielle",
"DCC.MercurialTabHint": "Lancer et configurer la magie mercurielle pour ce sort",
+ "DCC.MightyDeedRollFlavor": "Haut fait ({roll}) sur {table}",
+ "DCC.MightyDeedTableNotFound": "Table de hauts faits {table} introuvable",
+ "DCC.MightyDeedTableSelectHint": "Choisissez la table de hauts faits sur laquelle consulter le résultat du dé de haut fait",
"DCC.MightyDeedsHowToLink": "Guide des hauts faits",
"DCC.MightyDeedsLink": "@Compendium[dcc-core-book.dcc-core-journals.1gcH2FKbeGbxWtIQ]{Haut fait d'armes}",
"DCC.MightyDeedsOfArms": "Hauts faits d'arme",
@@ -567,6 +571,7 @@
"DCC.RollTreasureValue": "Déterminer la valeur du trésor",
"DCC.RollUnder": "{name} (Lancer en tant que)",
"DCC.RollWisdomCheckHint": "Cliquez pour lancer un test de Sagesse, CMD/CTRL clic pour plus d'options",
+ "DCC.RollDeed": "Lancer le haut fait",
"DCC.RolledAbilityEmote": "{actorName} a lancé {abilityInlineRollHTML} pour sa {abilityName}.",
"DCC.AbilityCheckPenaltyNote": "
Si la pénalité de contrôle s'applique, le total est {total}.
",
"DCC.RolledCritEmote": "{actorName} a lancé {critInlineRollHTML} sur Table Crit {critTableName}{critResult}",
@@ -626,6 +631,10 @@
"DCC.SettingManualCompendiumConfigurationHint": "Si vous voulez construire vos propres compendia de recherche au lieu d'utiliser le Module Core Book - non recommandé",
"DCC.SettingMercurialMagicTable": "Table de la magie mercurielle",
"DCC.SettingMercurialMagicTableHint": "Table à utiliser pour les effets de la magie mercurielle - doit être dans un compendium pack",
+ "DCC.SettingMightyDeedsEnabled": "Activer les tables de hauts faits",
+ "DCC.SettingMightyDeedsEnabledHint": "Propose une sélection de table de hauts faits sur les cartes d'attaque lorsque le dé de haut fait d'un guerrier réussit (3 ou plus). Désactivé par défaut.",
+ "DCC.SettingMightyDeedsTablesCompendium": "Compendium des tables de hauts faits",
+ "DCC.SettingMightyDeedsTablesCompendiumHint": "Compendium où chercher les tables de hauts faits. Les tables du monde contenant 'Deed' dans leur nom sont aussi proposées sur la carte de jet d'attaque",
"DCC.SettingShowRollModifierByDefault": "Montrer les modificateurs du jets",
"DCC.SettingShowRollModifierByDefaultHint": "Inverse le comportement des modificateur de jets pour les appliquer par défaut sauf si le jet est CTRL-CLICKé.",
"DCC.SettingSpellSideEffectsCompendium": "Compendium des Effets Secondaires de Sorts",
diff --git a/lang/it.json b/lang/it.json
index 64bd7389..5df883ec 100644
--- a/lang/it.json
+++ b/lang/it.json
@@ -239,6 +239,7 @@
"DCC.DamageDie": "Dado Danno",
"DCC.DamageModifier": "Mod. Danno",
"DCC.DamageRollInvalidFormulaInline": "Formula Danno non valida '{formula}'",
+ "DCC.Deed": "Cimento",
"DCC.DeedDie": "Dado Cimento",
"DCC.DeedRoll": "Tiro Dado Cimento",
"DCC.Deity": "Divinità",
@@ -467,6 +468,9 @@
"DCC.MercurialMagicRerollPrompt": "Tira ancora o Guarda?",
"DCC.MercurialMagicRoll": "Tiri Magia Mercuriale",
"DCC.MercurialTabHint": "Tira e configura l'effetto mercuriale per questo Incantesimo",
+ "DCC.MightyDeedRollFlavor": "Prode Cimento ({roll}) su {table}",
+ "DCC.MightyDeedTableNotFound": "Tabella dei Cimenti {table} non trovata",
+ "DCC.MightyDeedTableSelectHint": "Scegli la tabella dei Cimenti su cui consultare il risultato del Dado Cimento",
"DCC.MightyDeedsHowToLink": "Guida ai Prodi Cimenti d'Arme",
"DCC.MightyDeedsLink": "@UUID[Compendium.dcc-core-book.dcc-core-text.JournalEntry.n5gqDCyFO09A3GAJ.JournalEntryPage.UrVBDNG5csbS5bBa#mighty-deeds-of-arms]{Mighty Deeds of Arms}",
"DCC.MightyDeedsOfArms": "Prode Cimento d'Arme",
@@ -540,6 +544,7 @@
"DCC.ResolveValueEmote": "{itemName} vale: {pp} mp. {ep} me. {gp} mo. {sp} ma. {cp} mr.",
"DCC.Roll": "Tiro",
"DCC.RollCritical": "Tira Critico",
+ "DCC.RollDeed": "Tira Cimento",
"DCC.RolledAbilityEmote": "{actorName} tira {abilityInlineRollHTML} per {abilityName}",
"DCC.AbilityCheckPenaltyNote": "
Se si applica la penalità di controllo, il totale è {total}.
",
"DCC.RolledCritEmote": "{actorName} tira {critInlineRollHTML} sulla Tabella dei Critici {critTableName}{critResult}",
@@ -639,6 +644,10 @@
"DCC.SettingManualCompendiumConfigurationHint": "Per quando si vuole costruire un proprio Compendio invece di usare il modulo Core Book, anche se non è consigliabile.",
"DCC.SettingMercurialMagicTable": "Tabella Magia Mercuriale",
"DCC.SettingMercurialMagicTableHint": "Tabella da usare per Magia Mercuriale - deve trovarsi in un Compendio",
+ "DCC.SettingMightyDeedsEnabled": "Abilita le Tabelle dei Cimenti",
+ "DCC.SettingMightyDeedsEnabledHint": "Offre una selezione della tabella dei Cimenti sulle carte di attacco quando il dado del cimento di un guerriero ha successo (3 o più). Disattivato per impostazione predefinita.",
+ "DCC.SettingMightyDeedsTablesCompendium": "Compendio delle Tabelle dei Cimenti",
+ "DCC.SettingMightyDeedsTablesCompendiumHint": "Compendio in cui cercare le tabelle dei Prodi Cimenti. Anche le tabelle del mondo con 'Deed' nel nome vengono offerte nella carta chat del tiro d'attacco",
"DCC.SettingShowRollModifierByDefault": "Mostra il dialogo Modifica Tiro per impostazione predefinita",
"DCC.SettingShowRollModifierByDefaultHint": "Mostra il dialogo Modifica Tiro senza dover cliccare CMD/CTRL sugli oggetti della scheda del personaggio",
"DCC.SettingTurnUnholyTable": "Tabella Scacciare l'Empio",
diff --git a/lang/pl.json b/lang/pl.json
index c9488328..eaeae6d6 100644
--- a/lang/pl.json
+++ b/lang/pl.json
@@ -239,6 +239,7 @@
"DCC.DamageDie": "Kostka Obrażeń",
"DCC.DamageModifier": "Modyfikator Obrażeń",
"DCC.DamageRollInvalidFormulaInline": "Nieprawidłowa formuła Obrażeń '{formula}'",
+ "DCC.Deed": "Czyn",
"DCC.DeedDie": "Kostka Czynu",
"DCC.DeedRoll": "Rzuć Kostką Czynu",
"DCC.Deity": "Bóstwo",
@@ -467,6 +468,9 @@
"DCC.MercurialMagicRerollPrompt": "Przerzucić czy Sprawdzić Rzut Merkurialnej Magii?",
"DCC.MercurialMagicRoll": "Rzut Merkurialnej Magii",
"DCC.MercurialTabHint": "Rzuć i skonfiguruj merkurialną magię dla tego czaru",
+ "DCC.MightyDeedRollFlavor": "Potężny Czyn ({roll}) na {table}",
+ "DCC.MightyDeedTableNotFound": "Nie znaleziono tabeli Czynów {table}",
+ "DCC.MightyDeedTableSelectHint": "Wybierz tabelę Czynów, w której zostanie sprawdzony wynik Kostki Czynu",
"DCC.MightyDeedsHowToLink": "Instrukcja Potężnych Czynów",
"DCC.MightyDeedsLink": "@UUID[Compendium.dcc-core-book.dcc-core-text.JournalEntry.n5gqDCyFO09A3GAJ.JournalEntryPage.UrVBDNG5csbS5bBa#mighty-deeds-of-arms]{Potężne Czyny Broni}",
"DCC.MightyDeedsOfArms": "Potężne Czyny Broni",
@@ -540,6 +544,7 @@
"DCC.ResolveValueEmote": "Wyrzucono wartość {itemName}: {pp} P. {ep} E. {gp} Z. {sp} S. {cp} M.",
"DCC.Roll": "Rzut",
"DCC.RollCritical": "Rzuć Krytyk",
+ "DCC.RollDeed": "Rzuć Czyn",
"DCC.RolledAbilityEmote": "{actorName} rzucił {abilityInlineRollHTML} na swoją {abilityName}.",
"DCC.AbilityCheckPenaltyNote": "
Jeśli kara kontrolna ma zastosowanie, suma wynosi {total}.
",
"DCC.RolledCritEmote": "{actorName} rzucił {critInlineRollHTML} na Tabeli Krytyka {critTableName}{critResult}",
@@ -639,6 +644,10 @@
"DCC.SettingManualCompendiumConfigurationHint": "Jeśli chcesz zbudować własne kompendium wyszukiwania zamiast używać Modułu Core Book - nie zalecane",
"DCC.SettingMercurialMagicTable": "Tabela Merkurialnej Magii",
"DCC.SettingMercurialMagicTableHint": "Tabela Rzutów do użycia dla efektów Merkurialnej Magii - musi być w pakiecie kompendium",
+ "DCC.SettingMightyDeedsEnabled": "Włącz Tabele Czynów",
+ "DCC.SettingMightyDeedsEnabledHint": "Udostępnia wybór Tabeli Czynów na kartach ataku, gdy kość czynu wojownika odniesie sukces (3 lub więcej). Domyślnie wyłączone.",
+ "DCC.SettingMightyDeedsTablesCompendium": "Kompendium Tabel Czynów",
+ "DCC.SettingMightyDeedsTablesCompendiumHint": "Kompendium, w którym wyszukiwane są tabele Potężnych Czynów. Tabele świata zawierające 'Deed' w nazwie są również dostępne na karcie czatu rzutu ataku",
"DCC.SettingShowRollModifierByDefault": "Pokazuj okno Modyfikacji Rzutu domyślnie",
"DCC.SettingShowRollModifierByDefaultHint": "Pokazuj okno Modyfikatora Rzutu bez konieczności kliku CMD/CTRL na elementach arkusza postaci",
"DCC.SettingTurnUnholyTable": "Tabela Odwracania Nieczystych",
diff --git a/module/__tests__/chat-and-hook-wiring.test.js b/module/__tests__/chat-and-hook-wiring.test.js
index 98031210..03f6ae6e 100644
--- a/module/__tests__/chat-and-hook-wiring.test.js
+++ b/module/__tests__/chat-and-hook-wiring.test.js
@@ -25,7 +25,8 @@ vi.mock('../chat.js', () => ({
emoteDamageRoll: vi.fn(),
emoteInitiativeRoll: vi.fn(),
emoteSavingThrowRoll: vi.fn(),
- emoteSkillCheckRoll: vi.fn()
+ emoteSkillCheckRoll: vi.fn(),
+ attachMightyDeedListeners: vi.fn()
}))
vi.mock('../parser.js', () => ({
@@ -201,6 +202,8 @@ describe('onRenderChatMessageHTML', () => {
expect(chat.enforceMinimumDamage).toHaveBeenCalledWith(message, html)
expect(SpellResult.processChatMessage).toHaveBeenCalledWith(message, html, {})
expect(TableResult.processChatMessage).toHaveBeenCalledWith(message, html, {})
+ // Mighty Deed prompt listeners are attached after the emote/lookup passes (issue #319)
+ expect(chat.attachMightyDeedListeners).toHaveBeenCalledWith(message, html)
})
test('forwards the dcc.ItemId flag onto a data-item-id attribute', async () => {
diff --git a/module/__tests__/settings-table-hooks.test.js b/module/__tests__/settings-table-hooks.test.js
index f98178f8..bdcd438e 100644
--- a/module/__tests__/settings-table-hooks.test.js
+++ b/module/__tests__/settings-table-hooks.test.js
@@ -10,6 +10,7 @@ import {
SETTINGS_TABLE_HOOKS,
onRegisterCriticalHitsPack,
onRegisterDisapprovalPack,
+ onRegisterMightyDeedsPack,
onRegisterLevelDataPack,
onRegisterMercurialMagicTable,
onSetDivineAidTable,
@@ -207,6 +208,7 @@ describe('onSetTurnUnholyTable', () => {
describe('SETTINGS_TABLE_HOOKS dispatch table', () => {
test('routes each documented hook name to the corresponding handler', () => {
expect(SETTINGS_TABLE_HOOKS['dcc.registerDisapprovalPack']).toBe(onRegisterDisapprovalPack)
+ expect(SETTINGS_TABLE_HOOKS['dcc.registerMightyDeedsPack']).toBe(onRegisterMightyDeedsPack)
expect(SETTINGS_TABLE_HOOKS['dcc.registerCriticalHitsPack']).toBe(onRegisterCriticalHitsPack)
expect(SETTINGS_TABLE_HOOKS['dcc.setDivineAidTable']).toBe(onSetDivineAidTable)
expect(SETTINGS_TABLE_HOOKS['dcc.setFumbleTable']).toBe(onSetFumbleTable)
@@ -217,12 +219,13 @@ describe('SETTINGS_TABLE_HOOKS dispatch table', () => {
expect(SETTINGS_TABLE_HOOKS['dcc.setTurnUnholyTable']).toBe(onSetTurnUnholyTable)
})
- test('covers exactly the nine documented hook names', () => {
+ test('covers exactly the ten documented hook names', () => {
expect(Object.keys(SETTINGS_TABLE_HOOKS).sort()).toEqual([
'dcc.registerCriticalHitsPack',
'dcc.registerDisapprovalPack',
'dcc.registerLevelDataPack',
'dcc.registerMercurialMagicTable',
+ 'dcc.registerMightyDeedsPack',
'dcc.setDivineAidTable',
'dcc.setFumbleTable',
'dcc.setLayOnHandsTable',
@@ -254,8 +257,8 @@ describe('registerSettingsTableHooks', () => {
}
})
- test('registers exactly nine listeners — one per dispatch-table entry', () => {
+ test('registers exactly ten listeners — one per dispatch-table entry', () => {
registerSettingsTableHooks()
- expect(globalThis.Hooks.on).toHaveBeenCalledTimes(9)
+ expect(globalThis.Hooks.on).toHaveBeenCalledTimes(10)
})
})
diff --git a/module/__tests__/table-loading.test.js b/module/__tests__/table-loading.test.js
index e1082990..2dbd3dc3 100644
--- a/module/__tests__/table-loading.test.js
+++ b/module/__tests__/table-loading.test.js
@@ -400,6 +400,16 @@ describe('onImportAdventure', () => {
describe('onCreateRollTable', () => {
beforeEach(() => {
globalThis.CONFIG.DCC.disapprovalTables = {}
+ globalThis.CONFIG.DCC.mightyDeedsTables = {}
+ })
+
+ test('adds the table to mightyDeedsTables when the name contains "Deed" (issue #319)', () => {
+ onCreateRollTable({ name: 'Mighty Deed: Disarm' })
+
+ expect(globalThis.CONFIG.DCC.mightyDeedsTables['Mighty Deed: Disarm']).toEqual({
+ name: 'Mighty Deed: Disarm',
+ path: 'Mighty Deed: Disarm'
+ })
})
test('adds the table to disapprovalTables when the name contains "Disapproval"', () => {
@@ -432,6 +442,7 @@ describe('onDeleteRollTable', () => {
'Cleric Disapproval': { name: 'Cleric Disapproval', path: 'Cleric Disapproval' },
'Other Table': { name: 'Other Table', path: 'Other Table' }
}
+ globalThis.CONFIG.DCC.mightyDeedsTables = {}
onDeleteRollTable({ name: 'Cleric Disapproval' })
@@ -439,8 +450,22 @@ describe('onDeleteRollTable', () => {
expect(globalThis.CONFIG.DCC.disapprovalTables['Other Table']).toBeDefined()
})
+ test('removes the table from mightyDeedsTables by name (issue #319)', () => {
+ globalThis.CONFIG.DCC.disapprovalTables = {}
+ globalThis.CONFIG.DCC.mightyDeedsTables = {
+ 'Mighty Deed': { name: 'Mighty Deed', path: 'Mighty Deed' },
+ 'Other Deed': { name: 'Other Deed', path: 'Other Deed' }
+ }
+
+ onDeleteRollTable({ name: 'Mighty Deed' })
+
+ expect(globalThis.CONFIG.DCC.mightyDeedsTables['Mighty Deed']).toBeUndefined()
+ expect(globalThis.CONFIG.DCC.mightyDeedsTables['Other Deed']).toBeDefined()
+ })
+
test('is a no-op when the named table is not currently tracked', () => {
globalThis.CONFIG.DCC.disapprovalTables = {}
+ globalThis.CONFIG.DCC.mightyDeedsTables = {}
expect(() => onDeleteRollTable({ name: 'Not Present' })).not.toThrow()
})
@@ -451,6 +476,7 @@ describe('onUpdateRollTable', () => {
globalThis.CONFIG.DCC.disapprovalTables = {
'Cleric Disapproval': { name: 'Cleric Disapproval', path: 'Cleric Disapproval' }
}
+ globalThis.CONFIG.DCC.mightyDeedsTables = {}
globalThis.game.tables = Object.assign([], { getName: vi.fn() })
onUpdateRollTable({ name: 'Cleric Disapproval' }, { description: 'changed' })
@@ -463,6 +489,7 @@ describe('onUpdateRollTable', () => {
'Cleric Disapproval': { name: 'Cleric Disapproval', path: 'dcc-core.dcc-tables.Cleric Disapproval' },
'Old World Name': { name: 'Old World Name', path: 'Old World Name' }
}
+ globalThis.CONFIG.DCC.mightyDeedsTables = {}
globalThis.game.tables = Object.assign(
[{ name: 'New World Disapproval' }],
{ getName: vi.fn() }
@@ -491,6 +518,7 @@ describe('onUpdateRollTable', () => {
globalThis.CONFIG.DCC.disapprovalTables = {
'Stale Disapproval': { name: 'Stale Disapproval', path: 'Stale Disapproval' }
}
+ globalThis.CONFIG.DCC.mightyDeedsTables = {}
globalThis.game.tables = Object.assign(
[{ name: 'Renamed Random Table' }],
{ getName: vi.fn() }
@@ -504,6 +532,36 @@ describe('onUpdateRollTable', () => {
expect(globalThis.CONFIG.DCC.disapprovalTables['Stale Disapproval']).toBeUndefined()
expect(globalThis.CONFIG.DCC.disapprovalTables['Renamed Random Table']).toBeUndefined()
})
+
+ test('preserves compendium deed entries and rebuilds the world half on rename (issue #319)', () => {
+ globalThis.CONFIG.DCC.disapprovalTables = {}
+ globalThis.CONFIG.DCC.mightyDeedsTables = {
+ 'Core Deed': { name: 'Core Deed', path: 'dcc-core-book.dcc-tables.Core Deed' },
+ 'Old Deed Name': { name: 'Old Deed Name', path: 'Old Deed Name' }
+ }
+ globalThis.game.tables = Object.assign(
+ [{ name: 'New World Deed' }],
+ { getName: vi.fn() }
+ )
+
+ onUpdateRollTable(
+ { name: 'New World Deed' },
+ { name: 'New World Deed' }
+ )
+
+ // Compendium deed entry survives (its path contains a dot)
+ expect(globalThis.CONFIG.DCC.mightyDeedsTables['Core Deed']).toEqual({
+ name: 'Core Deed',
+ path: 'dcc-core-book.dcc-tables.Core Deed'
+ })
+ // Stale world deed entry is gone after rebuild
+ expect(globalThis.CONFIG.DCC.mightyDeedsTables['Old Deed Name']).toBeUndefined()
+ // New world deed entry from game.tables walk is present
+ expect(globalThis.CONFIG.DCC.mightyDeedsTables['New World Deed']).toEqual({
+ name: 'New World Deed',
+ path: 'New World Deed'
+ })
+ })
})
describe('TABLE_LOADING_HOOKS dispatch table', () => {
diff --git a/module/__tests__/utilities.test.js b/module/__tests__/utilities.test.js
index df714db7..ed243e00 100644
--- a/module/__tests__/utilities.test.js
+++ b/module/__tests__/utilities.test.js
@@ -12,7 +12,8 @@ import {
getCritTableResult,
getFumbleTableResult,
getFumbleTableNameFromCritTableName,
- getNPCFumbleTableResult
+ getNPCFumbleTableResult,
+ getTableFromPath
} from '../utilities.js'
import { clearAllTableCaches, critTableDocCache, critTableLinkCache } from '../adapter/table-cache.mjs'
@@ -523,6 +524,68 @@ describe('Utilities', () => {
})
})
+ describe('getTableFromPath', () => {
+ let mockPack
+ let mockTable
+
+ beforeEach(() => {
+ mockTable = {
+ name: 'Deed: Trips and Throws',
+ getResultsForRoll: vi.fn()
+ }
+
+ mockPack = {
+ index: [{ _id: 'deed-table-id', name: 'Deed: Trips and Throws' }],
+ getDocument: vi.fn().mockResolvedValue(mockTable)
+ }
+
+ global.game = {
+ packs: {
+ get: vi.fn().mockReturnValue(mockPack)
+ },
+ tables: {
+ getName: vi.fn().mockReturnValue(null)
+ }
+ }
+ })
+
+ it('returns null for an empty path', async () => {
+ expect(await getTableFromPath('')).toBeNull()
+ expect(await getTableFromPath(null)).toBeNull()
+ })
+
+ it('resolves a compendium path', async () => {
+ const result = await getTableFromPath('some-module.deed-tables.Deed: Trips and Throws')
+ expect(global.game.packs.get).toHaveBeenCalledWith('some-module.deed-tables')
+ expect(mockPack.getDocument).toHaveBeenCalledWith('deed-table-id')
+ expect(result).toBe(mockTable)
+ })
+
+ it('resolves a world table by name', async () => {
+ const worldTable = { name: 'Deed: Disarm' }
+ global.game.tables.getName.mockReturnValue(worldTable)
+
+ const result = await getTableFromPath('Deed: Disarm')
+ expect(global.game.tables.getName).toHaveBeenCalledWith('Deed: Disarm')
+ expect(result).toBe(worldTable)
+ })
+
+ it('falls back to a world table when the pack is missing', async () => {
+ global.game.packs.get.mockReturnValue(null)
+ const worldTable = { name: 'some-module.deed-tables.Deed: Trips and Throws' }
+ global.game.tables.getName.mockReturnValue(worldTable)
+
+ const result = await getTableFromPath('some-module.deed-tables.Deed: Trips and Throws')
+ expect(result).toBe(worldTable)
+ })
+
+ it('returns null when nothing matches', async () => {
+ global.game.packs.get.mockReturnValue(null)
+ const result = await getTableFromPath('Nonexistent Table')
+ expect(result).toBeNull()
+ })
+ })
+
describe('getFumbleTableResult', () => {
let mockRoll
let mockPack
diff --git a/module/actor/rolls-weapon-mixin.mjs b/module/actor/rolls-weapon-mixin.mjs
index 36de950c..dc65da61 100644
--- a/module/actor/rolls-weapon-mixin.mjs
+++ b/module/actor/rolls-weapon-mixin.mjs
@@ -1,4 +1,4 @@
-/* global game, Hooks, Roll, ChatMessage, ui, foundry */
+/* global CONFIG, game, Hooks, Roll, ChatMessage, ui, foundry */
import { ensurePlus, getCritTableResult, getCritTableLink, getFumbleTableResult, getNPCFumbleTableResult, getFumbleTableNameFromCritTableName, addDamageFlavorToRolls } from '../utilities.js'
import {
@@ -146,6 +146,14 @@ export const RollsWeaponMixin = (Base) => class extends Base {
const deedDieRollResult = attackRollResult.deedDieRollResult
const deedRollSuccess = attackRollResult.deedDieRollResult > 2
+ // On a successful deed, offer a prompt to look up the deed die result on a Mighty Deed table (issue #319).
+ // Gated on the `mightyDeedsEnabled` world setting (off by default) so the prompt never appears unless a GM opts in.
+ let deedTables = []
+ if (deedRollSuccess && game.settings.get('dcc', 'mightyDeedsEnabled')) {
+ deedTables = Object.values(CONFIG.DCC.mightyDeedsTables || {})
+ .sort((a, b) => a.name.localeCompare(b.name))
+ }
+
// Crit roll
let critRollFormula = ''
let critInlineRoll = ''
@@ -272,6 +280,7 @@ export const RollsWeaponMixin = (Base) => class extends Base {
deedDieRoll,
deedDieRollResult,
deedRollSuccess,
+ deedTables,
fumbleInlineRoll,
fumblePrompt,
fumbleRoll,
diff --git a/module/chat-and-hook-wiring.mjs b/module/chat-and-hook-wiring.mjs
index 1b632722..ee9e3783 100644
--- a/module/chat-and-hook-wiring.mjs
+++ b/module/chat-and-hook-wiring.mjs
@@ -117,6 +117,9 @@ export async function onRenderChatMessageHTML (message, html, data) {
// Process table result navigation AFTER emote/lookup functions have modified the HTML
// This ensures event listeners are attached to the final DOM elements
TableResult.processChatMessage(message, html, data)
+
+ // Attach Mighty Deed table prompt listeners after the emote functions have modified the HTML (issue #319)
+ chat.attachMightyDeedListeners(message, html)
}
/**
diff --git a/module/chat.js b/module/chat.js
index 58f45fa1..1366804f 100644
--- a/module/chat.js
+++ b/module/chat.js
@@ -1,7 +1,7 @@
-/* global canvas, foundry, game, ui */
+/* global canvas, foundry, game, ui, ChatMessage */
// noinspection DuplicatedCode
-import { getCritTableResult, getFumbleTableResult, getNPCFumbleTableResult, addDamageFlavorToRolls } from './utilities.js'
+import { getCritTableResult, getFumbleTableResult, getNPCFumbleTableResult, getTableFromPath, addDamageFlavorToRolls } from './utilities.js'
const { TextEditor } = foundry.applications.ux
@@ -270,6 +270,16 @@ export const emoteAttackRoll = function (message, html) {
deedDieHTML = `${message.system.deedDieRollResult}`
}
deedRollHTML = game.i18n.format('DCC.AttackRollDeedEmoteSegment', { deed: deedDieHTML })
+
+ // Re-add the Mighty Deed table prompt, since the emote replaces the card content
+ if (message.system.deedTables?.length) {
+ const options = message.system.deedTables.map(t => ``).join('')
+ deedRollHTML += `
+
+
+
+
`
+ }
}
let crit = ''
@@ -340,6 +350,70 @@ export const emoteAttackRoll = function (message, html) {
}
}
+/**
+ * Attach listeners for the Mighty Deed table prompt on attack cards
+ * @param message
+ * @param html
+ */
+export const attachMightyDeedListeners = function (message, html) {
+ html.querySelectorAll('.roll-deed-table').forEach(el => {
+ el.addEventListener('click', _onRollMightyDeed.bind(message))
+ })
+}
+
+/**
+ * Look up the deed die result on the selected Mighty Deed table and post the result to chat
+ * @param {Object} event The originating click event
+ */
+const _onRollMightyDeed = async function (event) {
+ const button = event.currentTarget
+ // One-shot: ignore repeat clicks and guard against a double-fire while the
+ // async table lookup is in flight. Re-enabled below only if the lookup fails.
+ if (button.disabled) { return }
+ const select = button.closest('.deed-table-prompt')?.querySelector('.deed-table-select')
+ const reEnable = () => { button.disabled = false; if (select) { select.disabled = false } }
+ button.disabled = true
+ if (select) { select.disabled = true }
+
+ const container = button.closest('.deed-table-prompt')
+ if (!container) { reEnable(); return }
+
+ const deedRoll = parseInt(container.getAttribute('data-deed-roll'))
+ const tablePath = container.querySelector('.deed-table-select')?.value
+ if (!tablePath || isNaN(deedRoll)) { reEnable(); return }
+
+ const table = await getTableFromPath(tablePath)
+ if (!table) {
+ ui.notifications.warn(game.i18n.format('DCC.MightyDeedTableNotFound', { table: tablePath }))
+ reEnable()
+ return
+ }
+
+ const result = table.getResultsForRoll(deedRoll)[0]
+ if (!result) {
+ ui.notifications.warn(game.i18n.localize('DCC.TableResultOutOfBounds'))
+ reEnable()
+ return
+ }
+
+ const resultText = await TextEditor.enrichHTML(addDamageFlavorToRolls(result.description))
+ const actor = game.actors.get(this.system?.actorId)
+ await ChatMessage.create({
+ user: game.user.id,
+ speaker: ChatMessage.getSpeaker({ actor }),
+ flavor: game.i18n.format('DCC.MightyDeedRollFlavor', { table: table.name, roll: deedRoll }),
+ flags: {
+ 'dcc.isMightyDeed': true
+ },
+ content: `
+
+
+
${resultText}
+
+
`
+ })
+}
+
/**
* Change crit rolls into emotes
* @param message
diff --git a/module/config.js b/module/config.js
index c332d57f..d47224a2 100644
--- a/module/config.js
+++ b/module/config.js
@@ -363,6 +363,7 @@ DCC.levelDataPacks = null
DCC.fumbleTable = null
DCC.layOnHandsTable = null
DCC.mercurialMagicTable = null
+DCC.mightyDeedsPacks = null
// Per-class mercurial magic table registry, keyed by lowercase
// `system.details.sheetClass` (e.g. `'wizard'`, `'elf'`, `'blaster'`,
@@ -449,6 +450,9 @@ DCC.turnUnholyTable = null
// List of available disapproval tables for the cleric sheet, generated from disapprovalPacks
DCC.disapprovalTables = {}
+// List of available Mighty Deed tables for the attack card deed prompt, generated from mightyDeedsPacks
+DCC.mightyDeedsTables = {}
+
// Registry for skills that use a table lookup - maps skill name to config property
// System defaults defined here, modules can register their own
DCC.skillTables = {
diff --git a/module/settings-table-hooks.mjs b/module/settings-table-hooks.mjs
index 403faa6a..77b3ed4c 100644
--- a/module/settings-table-hooks.mjs
+++ b/module/settings-table-hooks.mjs
@@ -23,6 +23,13 @@ export function onRegisterDisapprovalPack (value, fromSystemSetting = false) {
}
}
+export function onRegisterMightyDeedsPack (value, fromSystemSetting = false) {
+ const mightyDeedsPacks = CONFIG.DCC.mightyDeedsPacks
+ if (mightyDeedsPacks) {
+ mightyDeedsPacks.addPack(value, fromSystemSetting)
+ }
+}
+
export function onRegisterCriticalHitsPack (value, fromSystemSetting = false) {
const criticalHitPacks = CONFIG.DCC.criticalHitPacks
if (criticalHitPacks) {
@@ -97,6 +104,7 @@ export function onSetTurnUnholyTable (value, fromSystemSetting = false) {
export const SETTINGS_TABLE_HOOKS = Object.freeze({
'dcc.registerDisapprovalPack': onRegisterDisapprovalPack,
+ 'dcc.registerMightyDeedsPack': onRegisterMightyDeedsPack,
'dcc.registerCriticalHitsPack': onRegisterCriticalHitsPack,
'dcc.setDivineAidTable': onSetDivineAidTable,
'dcc.setFumbleTable': onSetFumbleTable,
diff --git a/module/settings.js b/module/settings.js
index be83387c..5bdfdcac 100644
--- a/module/settings.js
+++ b/module/settings.js
@@ -149,6 +149,36 @@ export const registerSystemSettings = async function () {
}
})
+ /**
+ * Enable the Mighty Deed table prompt on attack cards (issue #319).
+ * Off by default — when disabled the attack card never offers the deed
+ * table dropdown, regardless of which deed tables exist in the world.
+ */
+ game.settings.register('dcc', 'mightyDeedsEnabled', {
+ name: 'DCC.SettingMightyDeedsEnabled',
+ hint: 'DCC.SettingMightyDeedsEnabledHint',
+ scope: 'world',
+ type: Boolean,
+ default: false,
+ config: true
+ })
+
+ /**
+ * Compendium to look in for Mighty Deed tables
+ */
+ game.settings.register('dcc', 'mightyDeedsCompendium', {
+ name: 'DCC.SettingMightyDeedsTablesCompendium',
+ hint: 'DCC.SettingMightyDeedsTablesCompendiumHint',
+ scope: 'world',
+ config: manualConfig,
+ default: '',
+ type: String,
+ choices: tableCompendiumNames,
+ onChange: value => {
+ Hooks.callAll('dcc.registerMightyDeedsPack', value, true)
+ }
+ })
+
/**
* Table to use for turn unholy
*/
diff --git a/module/table-loading.mjs b/module/table-loading.mjs
index eaae34de..da6f43c3 100644
--- a/module/table-loading.mjs
+++ b/module/table-loading.mjs
@@ -28,6 +28,16 @@ function isDisapprovalTable (tableName) {
return tableName.includes('Disapproval') || tableName.includes(disapprovalText)
}
+/**
+ * Module-private predicate for Mighty Deed tables (issue #319). Mirrors
+ * `isDisapprovalTable` — reads `game.i18n` per call so the localized
+ * "Deed" string reflects the active language at hook-fire time.
+ */
+function isMightyDeedsTable (tableName) {
+ const deedText = game.i18n.localize('DCC.Deed')
+ return tableName.includes('Deed') || tableName.includes(deedText)
+}
+
/**
* Set up compendium links for the equipment tab if dcc-core-book module
* is active. Stores links in `CONFIG.DCC.coreBookCompendiumLinks`.
@@ -110,6 +120,54 @@ export function registerTables () {
CONFIG.DCC.disapprovalPacks._updateHook(CONFIG.DCC.disapprovalPacks)
}
+ // Create manager for Mighty Deed table packs (issue #319). Mirrors the
+ // disapproval manager: pull tables from the configured compendium plus
+ // any world table whose name contains "Deed" (or the localized term).
+ // The attack card only surfaces these when the `mightyDeedsEnabled`
+ // world setting is on, but the registry stays populated regardless so
+ // the compendium picker and world-table tracking behave consistently.
+ CONFIG.DCC.mightyDeedsPacks = new TablePackManager({
+ updateHook: async (manager) => {
+ // Clear Mighty Deed tables
+ CONFIG.DCC.mightyDeedsTables = {}
+
+ // For each valid pack, update the list of Mighty Deed tables available on the attack card deed prompt
+ // Using table name as key to enable de-duplication
+ for (const packName of manager.packs) {
+ const pack = game.packs.get(packName)
+ if (pack) {
+ for (const value of pack.index.values()) {
+ // Use table name as key for de-duplication
+ CONFIG.DCC.mightyDeedsTables[value.name] = {
+ name: value.name,
+ path: `${packName}.${value.name}`
+ }
+ }
+ }
+ }
+
+ // Add world tables to the Mighty Deed tables list if they contain "Deed" in their name
+ // World tables will overwrite compendium tables with the same name (preferred)
+ // If multiple world tables have the same name, the last one processed wins
+ for (const table of game.tables) {
+ if (isMightyDeedsTable(table.name)) {
+ // Use table name as key - this overwrites compendium tables with same name
+ CONFIG.DCC.mightyDeedsTables[table.name] = {
+ name: table.name,
+ path: table.name
+ }
+ }
+ }
+ }
+ })
+ const mightyDeedsCompendium = game.settings.get('dcc', 'mightyDeedsCompendium')
+ if (mightyDeedsCompendium) {
+ CONFIG.DCC.mightyDeedsPacks.addPack(mightyDeedsCompendium, true)
+ } else {
+ // No compendium configured - still scan world tables for Mighty Deed tables
+ CONFIG.DCC.mightyDeedsPacks._updateHook(CONFIG.DCC.mightyDeedsPacks)
+ }
+
// Create manager for critical hit table packs and register the system setting
CONFIG.DCC.criticalHitPacks = new TablePackManager()
CONFIG.DCC.criticalHitPacks.addPack(game.settings.get('dcc', 'critsCompendium'), true)
@@ -253,6 +311,14 @@ export function onCreateRollTable (table) {
path: table.name
}
}
+
+ // Add to Mighty Deed tables list if the name contains "Deed" (issue #319)
+ if (isMightyDeedsTable(table.name)) {
+ CONFIG.DCC.mightyDeedsTables[table.name] = {
+ name: table.name,
+ path: table.name
+ }
+ }
}
/**
@@ -261,6 +327,7 @@ export function onCreateRollTable (table) {
export function onDeleteRollTable (table) {
// Use table name as key to find and delete
delete CONFIG.DCC.disapprovalTables[table.name]
+ delete CONFIG.DCC.mightyDeedsTables[table.name]
}
/**
@@ -292,6 +359,24 @@ export function onUpdateRollTable (table, changes) {
}
}
}
+
+ // Rebuild the Mighty Deed world tables list the same way (issue #319)
+ const deedCompendiumTables = {}
+ for (const [key, value] of Object.entries(CONFIG.DCC.mightyDeedsTables)) {
+ // Keep compendium tables (they have paths with dots like "pack.table")
+ if (value.path.includes('.')) {
+ deedCompendiumTables[key] = value
+ }
+ }
+ CONFIG.DCC.mightyDeedsTables = deedCompendiumTables
+ for (const worldTable of game.tables) {
+ if (isMightyDeedsTable(worldTable.name)) {
+ CONFIG.DCC.mightyDeedsTables[worldTable.name] = {
+ name: worldTable.name,
+ path: worldTable.name
+ }
+ }
+ }
}
}
diff --git a/module/utilities.js b/module/utilities.js
index 88207007..b2680a1b 100644
--- a/module/utilities.js
+++ b/module/utilities.js
@@ -213,6 +213,32 @@ function resolveCritTableLink (critTableSuffix) {
return null
}
+/**
+ * Resolve a RollTable from a path string
+ * @param {string} tablePath - a world table name, or a compendium path like 'scope.pack-name.Table Name'
+ * @returns {Promise