From a480632a82ecc98d87ce989f1b847b2ebe560ae7 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Thu, 11 Jun 2026 22:47:20 -0400 Subject: [PATCH 01/51] initial triggers --- .../data/07-dt/ultimate/dancing_mad.ts | 349 +++++++++++++++++- 1 file changed, 348 insertions(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index f507a4ffd37..b0b6f9209ec 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -7,10 +7,11 @@ import { OutputStrings, TriggerSet } from '../../../../../types/trigger'; // TODO: P1 Tele-Portent configuration options -type Phase = 'p1' | 'p2' | 'p3'; +type Phase = 'p1' | 'p2' | 'p3' | 'p4'; const phases: { [id: string]: Phase } = { 'C24C': 'p2', // Ultimate Embrace, God Kefka 'C3F7': 'p3', // Aero III Assault (from Kefka), Chaos and Exdeath + 'C2DC': 'p4', // Kefka Says, Kefka with Chaos and Neo Exdeath }; // const centerX = 100; @@ -42,6 +43,12 @@ export interface Data extends RaidbossData { doubleTroubleTrapTargets: string[]; myTelePortent1?: 'up' | 'down' | 'right' | 'left'; myTelePortent2?: 'up' | 'down' | 'right' | 'left'; + // Phase 3 + isFireShort?: boolean; + myElement?: 'fire' | 'water'; + inLine: { [name: string]: number }; + firstAccretion?: string; + secondAccretion?: string; } const headMarkerData = { @@ -203,6 +210,8 @@ const triggerSet: TriggerSet = { fakeEyeTowerIds: [], waveCannonTargets: [], doubleTroubleTrapTargets: [], + // Phase 3 + inLine: {}, }; }, triggers: [ @@ -1171,12 +1180,350 @@ const triggerSet: TriggerSet = { delete data.fireMarker; }, }, + { + id: 'DMU P3 Epic Hero/Fated Hero Debuffs', + // Applied to 4 nearest players when Chaos and Exdeath finish casting + // C2E2/C2E3 The Decisive Battle + // 1060 Epic Hero: Can only damage Chaos, preferred by Melee DPS + // 1062 Fated Hero: Can only damage Exdeath, preferred by Ranged DPS + // These fall off once Exdeath casts BB12 Thunder III + type: 'GainsEffect', + netRegex: { effectId: ['1060', '1062'], capture: true }, + condition: Conditions.targetIsYou(), + infoText: (_data, matches, output) => { + return matches.effectId === '1060' ? output.epic!() : output.fated!(); + }, + outputStrings: { + epic: { + en: 'Attack Chaos', + }, + fated: { + en: 'Attack Exdeath', + }, + }, + }, + { + id: 'DMU P3 Bowels of Agony', + type: 'StartsUsing', + netRegex: { id: 'BAF2', source: 'Chaos', capture: false }, + response: Responses.aoe(), + }, + { + id: 'DMU P3 Entropy and Dynamic Fluid Debuff Collector', + // TODO: Get crystal element spawn locations + // Applied at BAF2 Bowels of Agony + // 640 Entropy: On expiration player is hit with point blank AoE and fire + // crystal targets two closest players with donut AoEs + // 641 Dynamic Flood: On expiration creates donut AoE around the player + // and water crystal targets two closest players with point-blank AoEs + // + // Entropy or Dynamic Fluid will have 20s and the other 45s duration + // At the same time, elemental crystals spawn at intercardinals + // Fire and Water Crystals will be opposite each other + // Wind Crystal will be between on the opposite side + // + // Exdeath Tank needs to go to element that has the long timer + // Chaos Tank needs to go between wind crystal and element with short timer + type: 'GainsEffect', + netRegex: { effectId: ['640', '641'], capture: true }, + condition: (data) => data.myElement === undefined, + run: (data, matches) => { + const id = matches.effectId; + if (data.isFireShort === undefined) { + const isShort = parseFloat(matches.duration) < 21; + data.isFireShort = (isShort && id === '640') || + (!isShort && id === '641') ? true : false; + } + if (data.me === matches.target) + data.myElement = id === '640' ? 'fire' : 'water'; + }, + }, + { + id: 'DMU P3 Headwind/Tailwind Debuff', + // Applied at BAF2 Bowels of Agony + // 642 Headwind: Face away from knockback source, wind crystal targets + // nearest player with 2-person stack + // 643 Tailwind: Face towards knockback source, wind crystal targets + // nearest player with 2-person stack + type: 'GainsEffect', + netRegex: { effectId: ['642', '643'], capture: true }, + condition: Conditions.targetIsYou(), + delaySeconds: 0.1, + infoText: (data, matches, output) => { + const myElement = data.myElement; + const short = data.isFireShort + ? output.shortFire!() + : output.shortWater!(); + const wind = matches.effectId === '642' + ? output.headwind!() + : output.tailwind!(); + if (myElement !== undefined) + return output.withElement!({ + short: short, + element: output[myElement]!(), + wind: wind, + }); + return output.withoutElement!({ + short: short, + wind: wind, + }); + }, + outputStrings: { + shortFire: { + en: 'Short Fire', + }, + shortWater: { + en: 'Short Water', + }, + fire: { + en: 'Fire', + }, + water: { + en: 'Water', + }, + headwind: { + en: 'Headwind on YOU', + }, + tailwind: { + en: 'Tailwind on YOU', + }, + withElement: { + en: '${short}: ${element} + ${wind}', + }, + withoutElement: { + en: '${short}: ${wind}', + }, + }, + }, + { + id: 'DMU P3 Thunder III AOE', + type: 'StartsUsing', + netRegex: { id: 'BB12', source: 'Exdeath', capture: true }, + durationSeconds: (_data, matches) => parseFloat(matches.castTime), // 7s castTime + infoText: (_data, matches, output) => { + const boss = matches.source; + return output.awayFromBoss!({ boss: boss }); + }, + outputStrings: { + awayFromBoss: { + en: 'Away from ${boss}', + }, + }, + }, + { + id: 'DMU P3 Thunder III Tankbuster', + // Tankbuster that targets nearest player and then nearest again after 3s + type: 'StartsUsing', + netRegex: { id: 'BB09', source: 'Exdeath', capture: true }, + response: (data, matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = { + avoid: { + en: '${boss}${cleaves}', + }, + tankCleaveNearThenSwap: { + en: 'Near ${boss}${cleave} => ${swap}', + }, + boss: { + en: '${boss}: ', + }, + tankCleave: Outputs.tankCleave, + avoidTankCleaves: Outputs.avoidTankCleaves, + tankSwap: Outputs.tankSwap, + }; + + const severity = data.role === 'tank' || data.role === 'healer' + ? 'alertText' + : 'infoText'; + const boss = output.boss!({ boss: matches.source }); + + if (data.role === 'tank') + return { + [severity]: output.tankCleaveNearThenSwap!({ + boss: boss, + cleave: output.tankCleave!(), + swap: output.tankSwap!(), + }), + }; + + return { + [severity]: output.avoid!({ + boss: boss, + cleaves: output.avoidTankCleaves!(), + }), + }; + }, + }, + { + id: 'DMU P3 Thunder III Tank Swap', + type: 'Ability', + netRegex: { id: 'BB09', source: 'Exdeath', capture: true }, + condition: (data) => data.role === 'tank', + suppressSeconds: 4, + alertText: (data, matches, output) => { + const boss = matches.source; + if (matches.target === data.me) + return output.awayFromBoss!({ boss: boss }); + return output.beNearBoss!({ boss: boss }); + }, + outputStrings: { + beNearBoss: { + en: 'Be Near ${boss} (swap)', + }, + awayFromBoss: { + en: 'Away from ${boss} (swap)', + }, + }, + }, + { + id: 'DMU P3 Longitudinal Implosion', + type: 'StartsUsing', + netRegex: { id: 'BAFD', source: 'Chaos', capture: false }, + infoText: (_data, _matches, output) => output.sides!(), + outputStrings: { + sides: Outputs.sidesThenFrontBack, + }, + }, + { + id: 'DMU P3 Latitudinal Implosion', + type: 'StartsUsing', + netRegex: { id: 'BAFE', source: 'Chaos', capture: false }, + infoText: (_data, _matches, output) => output.frontBack!(), + outputStrings: { + frontBack: Outputs.frontBackThenSides, + }, + }, + { + id: 'DMU P3 Umbra Smash', + // At start of cast the target of BB00 Umbra Smash has been locked + // Instead of a timeline trigger, ues one of these abilities to trigger: + // BAFD Longitudinal Implosion + // BAFE Latitudinal Implosion + type: 'Ability', + netRegex: { id: ['BAFD', 'BAFE'], source: 'Chaos', capture: false }, + suppressSeconds: 99999, + infoText: (_data, _matches, output) => output.bait!(), + outputStrings: { + bait: { + en: 'Bait Jump', + }, + }, + }, + { + id: 'DMU P3 Vaccuum Wave', + type: 'StartsUsing', + netRegex: { id: 'BB13', source: 'Chaos', capture: true }, + infoText: (_data, matches, output) => { + return output.knockbackFromBoss!({ chaos: matches.source }); + }, + outputStrings: { + knockbackFromBoss: { + en: 'Knockback from ${chaos}', + }, + }, + }, + { + id: 'DMU P3 Damning Edict', + type: 'StartsUsing', + netRegex: { id: 'BB01', source: 'Chaos', capture: false }, + response: Responses.getBehind(), + }, + { + id: 'DMU P3 In Line Debuff Collector', + type: 'GainsEffect', + netRegex: { effectId: ['BBC', 'BBD', 'BBE'] }, + run: (data, matches) => { + const effectToNum: { [effectId: string]: number } = { + BBC: 1, + BBD: 2, + BBE: 3, + } as const; + const num = effectToNum[matches.effectId]; + if (num === undefined) + return; + data.inLine[matches.target] = num; + }, + }, + { + id: 'DMU P3 Accretion Collector', + // Will be applied to 1 DPS and 1 Healer + // One will have First in Line, the other will have Second in Line + type: 'GainsEffect', + netRegex: { effectId: '644', capture: true }, + delaySeconds: 0.1, // Delay for In Line debuffs + run: (data, matches) => { + const target = matches.target; + if (data.inLine[target] === 1) + data.firstAccretion = target; + else + data.secondAccretion = target; + }, + }, + { + id: 'DMU P3 In Line Debuff', + type: 'GainsEffect', + netRegex: { effectId: ['BBC', 'BBD', 'BBE'], capture: false }, + delaySeconds: 0.1, + durationSeconds: 5, + suppressSeconds: 1, + infoText: (data, _matches, output) => { + const myNum = data.inLine[data.me]; + if (myNum === undefined) + return; + + // Let healers know Accretion order + // String may be too long to provide list of partners + if (data.role === 'healer') { + const first = data.firstAccretion; + const second = data.secondAccretion; + const player1 = first === data.me + ? output.you!() + : data.party.member(first); + const player2 = second === data.me + ? output.you!() + : data.party.member(second); + + return output.accretionHealer!({ + num: myNum, + player1: player1, + player2: player2, + }); + } + + // Rest of players will get partners + const partners = []; + for (const [name, num] of Object.entries(data.inLine)) + if (num === myNum && name !== data.me) + partners.push(data.party.member(name)); + const msg = partners?.join(', '); + + return output.text!({ num: myNum, players: msg }); + }, + outputStrings: { + you: { + en: 'YOU', + }, + text: { + en: '${num} (with ${players})', + de: '${num} (mit ${players})', + fr: '${num} (avec ${players})', + ja: '${num} (${players})', + cn: '${num} (与${players})', + ko: '${num} (+ ${players})', + tc: '${num} (與${players})', + }, + accretionHealer: { + en: '${num}: Accretion on ${player1} => ${player2}', + }, + }, + }, ], timelineReplace: [ { 'locale': 'en', 'replaceText': { 'Future\'s End/Past\'s End': 'Future/Past\'s End', + 'Longitudinal Implosion/Latitudinal Implosion': 'Long/Lat Implosion', }, }, { From af93d851a52d40d6f3b91d07383dd3fb8dea343f Mon Sep 17 00:00:00 2001 From: Legends0 Date: Thu, 11 Jun 2026 22:53:26 -0400 Subject: [PATCH 02/51] lint + missing delayseconds for umbra smash --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index b0b6f9209ec..e35cd68b444 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1232,7 +1232,9 @@ const triggerSet: TriggerSet = { if (data.isFireShort === undefined) { const isShort = parseFloat(matches.duration) < 21; data.isFireShort = (isShort && id === '640') || - (!isShort && id === '641') ? true : false; + (!isShort && id === '641') + ? true + : false; } if (data.me === matches.target) data.myElement = id === '640' ? 'fire' : 'water'; @@ -1401,6 +1403,7 @@ const triggerSet: TriggerSet = { // BAFE Latitudinal Implosion type: 'Ability', netRegex: { id: ['BAFD', 'BAFE'], source: 'Chaos', capture: false }, + delaySeconds: 10, suppressSeconds: 99999, infoText: (_data, _matches, output) => output.bait!(), outputStrings: { From 08b89bb372967dcda729863ee373e36f89bccd2a Mon Sep 17 00:00:00 2001 From: Legends0 Date: Thu, 11 Jun 2026 23:01:36 -0400 Subject: [PATCH 03/51] lint --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index e35cd68b444..bd5d012eed2 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1232,9 +1232,9 @@ const triggerSet: TriggerSet = { if (data.isFireShort === undefined) { const isShort = parseFloat(matches.duration) < 21; data.isFireShort = (isShort && id === '640') || - (!isShort && id === '641') - ? true - : false; + (!isShort && id === '641') + ? true + : false; } if (data.me === matches.target) data.myElement = id === '640' ? 'fire' : 'water'; From add1f156edaa60bb8dd97fe3975eaec4ca7f8b2a Mon Sep 17 00:00:00 2001 From: Legends0 Date: Fri, 12 Jun 2026 22:16:37 -0400 Subject: [PATCH 04/51] Trigger for Entropy/Fluid + Crystal Baits --- .../data/07-dt/ultimate/dancing_mad.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index bd5d012eed2..903b7a6e1f0 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1,6 +1,7 @@ import Conditions from '../../../../../resources/conditions'; import Outputs from '../../../../../resources/outputs'; import { Responses } from '../../../../../resources/responses'; +import Util from '../../../../../resources/util'; import ZoneId from '../../../../../resources/zone_id'; import { RaidbossData } from '../../../../../types/data'; import { OutputStrings, TriggerSet } from '../../../../../types/trigger'; @@ -46,6 +47,8 @@ export interface Data extends RaidbossData { // Phase 3 isFireShort?: boolean; myElement?: 'fire' | 'water'; + fireElementPlayers: string[]; + waterElementPlayers: string[]; inLine: { [name: string]: number }; firstAccretion?: string; secondAccretion?: string; @@ -211,6 +214,8 @@ const triggerSet: TriggerSet = { waveCannonTargets: [], doubleTroubleTrapTargets: [], // Phase 3 + fireElementPlayers: [], + waterElementPlayers: [], inLine: {}, }; }, @@ -1238,6 +1243,11 @@ const triggerSet: TriggerSet = { } if (data.me === matches.target) data.myElement = id === '640' ? 'fire' : 'water'; + + if (id === '640') + data.fireElementPlayers.push(matches.target); + else + data.waterElementPlayers.push(matches.target); }, }, { @@ -1297,6 +1307,82 @@ const triggerSet: TriggerSet = { }, }, }, + { + id: 'DMU P3 Entropy and Fire Crystal', + // Late goes off 2s after BAFF Shockwave + type: 'GainsEffect', + netRegex: { effectId: '640', capture: true }, + delaySeconds: (_data, matches) => parseFloat(matches.duration) - 5, // 7s after Lat/Long when Late + suppressSeconds: 1, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = { + you: { + en: 'YOU', + }, + fireOnPlayersCrystal: { + en: 'Spread on ${players} / Bait Fire Donut', + }, + fireOnPlayers: { + en: 'Spread on ${players}', + }, + }; + + const severity = data.myElement === 'fire' ? 'alertText' : 'infoText'; + const players = data.fireElementPlayers.map( + (player) => { + if (player === data.me) + return output.you!(); + return data.party.member(player); + }, + ); + const msg = players?.join(', '); + + // Tanks and Melee aren't expected to bait crystals, so shorten output + if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) + return { [severity]: output.fireOnPlayers!() }; + + return { [severity]: output.fireOnPlayersCrystal!({ players: msg }) }; + }, + }, + { + id: 'DMU P3 Dynamic Fluid and Water Crystal', + // Late goes off 2s after BAFF Shockwave + type: 'GainsEffect', + netRegex: { effectId: '641', capture: true }, + delaySeconds: (_data, matches) => parseFloat(matches.duration) - 5, // 7s after Lat/Long when Late + suppressSeconds: 1, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = { + you: { + en: 'YOU', + }, + waterOnPlayersCrystal: { + en: 'Donut on ${players} / Bait Water AOE', + }, + waterOnPlayers: { + en: 'Donut on ${players}', + }, + }; + + const severity = data.myElement === 'fire' ? 'alertText' : 'infoText'; + const players = data.fireElementPlayers.map( + (player) => { + if (player === data.me) + return output.you!(); + return data.party.member(player); + }, + ); + const msg = players?.join(', '); + + // Tanks and Melee aren't expected to bait crystals, so shorten output + if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) + return { [severity]: output.waterOnPlayers!() }; + + return { [severity]: output.waterOnPlayersCrystal!({ players: msg }) }; + }, + }, { id: 'DMU P3 Thunder III AOE', type: 'StartsUsing', From 1438c0e03141346488b98093fbda5e4db9278ad2 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sat, 13 Jun 2026 00:12:43 -0400 Subject: [PATCH 05/51] ultima blaster and head/tailwind vacuum wave --- .../data/07-dt/ultimate/dancing_mad.ts | 137 ++++++++++++++++-- 1 file changed, 128 insertions(+), 9 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 903b7a6e1f0..b429c4fb076 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1,12 +1,13 @@ import Conditions from '../../../../../resources/conditions'; import Outputs from '../../../../../resources/outputs'; import { Responses } from '../../../../../resources/responses'; -import Util from '../../../../../resources/util'; +import Util, { Directions } from '../../../../../resources/util'; import ZoneId from '../../../../../resources/zone_id'; import { RaidbossData } from '../../../../../types/data'; -import { OutputStrings, TriggerSet } from '../../../../../types/trigger'; +import { LocaleText, OutputStrings, TriggerSet } from '../../../../../types/trigger'; // TODO: P1 Tele-Portent configuration options +// TODO: P3 Tailwind/Headwind resolution configuration options type Phase = 'p1' | 'p2' | 'p3' | 'p4'; const phases: { [id: string]: Phase } = { @@ -15,8 +16,8 @@ const phases: { [id: string]: Phase } = { 'C2DC': 'p4', // Kefka Says, Kefka with Chaos and Neo Exdeath }; -// const centerX = 100; -// const centerY = 100; +const centerX = 100; +const centerY = 100; export interface Data extends RaidbossData { // General @@ -47,8 +48,12 @@ export interface Data extends RaidbossData { // Phase 3 isFireShort?: boolean; myElement?: 'fire' | 'water'; + myWind?: 'head' | 'tail'; fireElementPlayers: string[]; waterElementPlayers: string[]; + firstBlaster: number[]; + firstBlasterDirNum?: number; + blasterRotation?: number; inLine: { [name: string]: number }; firstAccretion?: string; secondAccretion?: string; @@ -216,6 +221,7 @@ const triggerSet: TriggerSet = { // Phase 3 fireElementPlayers: [], waterElementPlayers: [], + firstBlaster: [], inLine: {}, }; }, @@ -1251,7 +1257,7 @@ const triggerSet: TriggerSet = { }, }, { - id: 'DMU P3 Headwind/Tailwind Debuff', + id: 'DMU P3 Headwind/Tailwind Debuff Collector', // Applied at BAF2 Bowels of Agony // 642 Headwind: Face away from knockback source, wind crystal targets // nearest player with 2-person stack @@ -1260,6 +1266,13 @@ const triggerSet: TriggerSet = { type: 'GainsEffect', netRegex: { effectId: ['642', '643'], capture: true }, condition: Conditions.targetIsYou(), + run: (data, matches) => data.myWind = matches.effectId === '642' ? 'head' : 'tail', + }, + { + id: 'DMU P3 Headwind/Tailwind Debuff', + type: 'GainsEffect', + netRegex: { effectId: ['642', '643'], capture: true }, + condition: Conditions.targetIsYou(), delaySeconds: 0.1, infoText: (data, matches, output) => { const myElement = data.myElement; @@ -1383,6 +1396,18 @@ const triggerSet: TriggerSet = { return { [severity]: output.waterOnPlayersCrystal!({ players: msg }) }; }, }, + { + id: 'DMU P3 Headwind/Tailwind Cleanup', + // If players resolve winds prior to Exdeath's Vacuum Wave + // Long debuffs could get knocked back into the other crystal + // Short Debuffs could run to other crystal's donut if fire or stack/bait if water + // The remaining 4 players will have to resolve during knockback + // Note that each time these are lost, the wind crystal triggers nearest player with 2-person stack + type: 'LosesEffect', + netRegex: { effectId: ['642', '643'], capture: true }, + condition: Conditions.targetIsYou(), + run: (data) => delete data.myWind, + }, { id: 'DMU P3 Thunder III AOE', type: 'StartsUsing', @@ -1481,6 +1506,70 @@ const triggerSet: TriggerSet = { frontBack: Outputs.frontBackThenSides, }, }, + { + id: 'DMU P3 Ultima Blaster Collect', + // Starts from random cardinal/intercardinal then rotates either CW or CCW + // These are raidwide AOEs, but also include telegraphed lines and explosions + // TODO: Verify the this is correct + type: 'Ability', + netRegex: { id: 'BAE3', source: 'Kefka', capture: true }, + condition: (data, matches) => { + const x2 = parseFloat(matches.x); + const y2 = parseFloat(matches.y); + if (data.firstBlaster === undefined) { + data.firstBlaster = [x2, y2]; + data.firstBlasterDirNum = Directions.xyTo8DirNum(x2, y2, centerX, centerY); + return false; + } + + // Get rotation of first and second Kefka blasters + const x1 = data.firstBlaster[0]; + const y1 = data.firstBlaster[1]; + + if (x1 === undefined || y1 === undefined) { + // Try next blaster + data.firstBlaster = [x2, y2]; + return false; + } + + // Compute atan2 of determinant and dot product to get rotational direction + // Note: X and Y are flipped due to Y axis being reversed + data.blasterRotation = Math.atan2(y1 * x2 - x1 * y2, y1 * y2 + x1 * x2); + return true; // Stop execution after 2nd blaster + }, + suppressSeconds: 99999, + }, + { + id: 'DMU P3 Ultima Blaster Rotation', + type: 'Ability', + netRegex: { id: 'BAE3', source: 'Kefka', capture: false }, + condition: (data) => data.blasterRotation !== undefined, + durationSeconds: 10, + suppressSeconds: 99999, + infoText: (data, _matches, output) => { + const rotation = data.blasterRotation; + const dirNum = data.firstBlasterDirNum; + if (rotation === undefined || dirNum === undefined) + return; + + const dir = Directions.output8Dir[dirNum] ?? 'unknown'; + + if (rotation < 0) + return output.clockwise!({ card: output[dir]!() }); + if (rotation > 0) + return output.counterclockwise!({ card: output[dir]!() }); + }, + outputStrings: { + ...Directions.outputStrings8Dir, + unknown: Outputs.unknown, + clockwise: { + en: '<== ${card} Clockwise (Later)', + }, + counterclockwise: { + en: '${card} Counterclockwise (Later) ==>', + }, + }, + }, { id: 'DMU P3 Umbra Smash', // At start of cast the target of BB00 Umbra Smash has been locked @@ -1500,15 +1589,45 @@ const triggerSet: TriggerSet = { }, { id: 'DMU P3 Vaccuum Wave', + // If players have not yet resolved their headwinds, then they will need + // to do so: + // Headwind look at Exdeath + // Tailwind look away from Exdeath + // + // Party can Tank LB3 to survive stacking the winds type: 'StartsUsing', - netRegex: { id: 'BB13', source: 'Chaos', capture: true }, - infoText: (_data, matches, output) => { - return output.knockbackFromBoss!({ chaos: matches.source }); + netRegex: { id: 'BB13', source: 'Exdeath', capture: true }, + infoText: (data, matches, output) => { + const chaosLocaleNames: LocaleText = { + en: 'Chaos', + de: 'Chaos', + fr: 'Chaos', + ja: 'カオス', + cn: '卡奥斯', + ko: '카오스', + tc: '卡奧斯', + }; + const chaosName = chaosLocaleNames[data.parserLang]; + + if (data.myWind === undefined) + return output.knockbackFromChaos!({ chaos: chaosName }); + + return output.text!({ + knockback: output.knockbackFromChaos!({ chaos: chaosName }), + facing: output[data.myWind]!({ target: matches.source }), + }); }, outputStrings: { - knockbackFromBoss: { + head: { + en: 'Face ${target}', + }, + tail: Outputs.lookAwayFromTarget, + knockbackFromChaos: { en: 'Knockback from ${chaos}', }, + text: { + en: '${knockback} + ${facing}', + }, }, }, { From adde9849a22d18a97a62e27b69713fff48487b7e Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sat, 13 Jun 2026 18:46:53 -0400 Subject: [PATCH 06/51] edict update + ultima blaster location trigger NOTE: Still need to confirm the headmarker values and validate output. --- .../data/07-dt/ultimate/dancing_mad.ts | 82 ++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index b429c4fb076..f747a69a010 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -8,6 +8,7 @@ import { LocaleText, OutputStrings, TriggerSet } from '../../../../../types/trig // TODO: P1 Tele-Portent configuration options // TODO: P3 Tailwind/Headwind resolution configuration options +// TODO: P3 Verify number headmarker values type Phase = 'p1' | 'p2' | 'p3' | 'p4'; const phases: { [id: string]: Phase } = { @@ -1518,7 +1519,7 @@ const triggerSet: TriggerSet = { const y2 = parseFloat(matches.y); if (data.firstBlaster === undefined) { data.firstBlaster = [x2, y2]; - data.firstBlasterDirNum = Directions.xyTo8DirNum(x2, y2, centerX, centerY); + data.firstBlasterDirNum = (Directions.xyTo8DirNum(x2, y2, centerX, centerY) + 4) % 8; // Need opposite side return false; } @@ -1630,11 +1631,86 @@ const triggerSet: TriggerSet = { }, }, }, + { + id: 'DMU P1 Ultima Blaster Location', + // Nearest inter-inter cardinal opposite that of first blaster + type: 'HeadMarker', + netRegex: { + id: [headMarkerData['1'], + headMarkerData['2'], + headMarkerData['3'], + headMarkerData['4'], + headMarkerData['5'], + headMarkerData['6'], + headMarkerData['7'], + headMarkerData['8']], + capture: true, + }, + condition: Conditions.targetIsYou(), + infoText: (data, matches, output) => { + const limitCutNumberMap: { [id: string]: number } = { + '004F': 1, + '0050': 2, + '0051': 3, + '0052': 4, + '0053': 5, + '0054': 6, + '0055': 7, + '0056': 8, + }; + const blaster = data.firstBlasterDirNum; + const rotation = data.blasterRotation; + const id = matches.id; + const myNum = limitCutNumberMap[id]; + if (myNum === undefined) + return; + + if (blaster === undefined || rotation === undefined || rotation === 0) + return output.num!({ num: myNum }); + + // Convert 8Dir to 16Dir + const blaster16Dir = blaster * 2; + + const adjustedDirNum = rotation < 0 + ? (myNum + blaster16Dir) % 16 // Clockwise + : (myNum - blaster16Dir + 16) % 16; // Counterclock + + // Find inter-inter cardinal + const spot = Directions.output16Dir[adjustedDirNum] ?? 'unknown'; + return output.text!({ + num: output.num!({myNum}), + spot: output[spot]!(), + }); + }, + outputStrings: { + ...Directions.outputStrings16Dir, + unknown: Outputs.unknown, + num: { + en: '#${num}', + de: '#${num}', + fr: '#${num}', + ja: '${num}番', + cn: '#${num}', + ko: '${num}번째', + tc: '#${num}', + }, + text: { + en: '${num}: ${dir}', + }, + }, + }, { id: 'DMU P3 Damning Edict', type: 'StartsUsing', - netRegex: { id: 'BB01', source: 'Chaos', capture: false }, - response: Responses.getBehind(), + netRegex: { id: 'BB01', source: 'Chaos', capture: true }, + infoText: (_data, matches, output) => { + return output.getBehindTarget!({ target: matches.source }); + }, + outputStrings: { + getBehindTarget: { + en: 'Get Behind ${target}', + }, + }, }, { id: 'DMU P3 In Line Debuff Collector', From a5618af836fffa86a3aa6f687974023b970733b7 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sat, 13 Jun 2026 18:48:46 -0400 Subject: [PATCH 07/51] midding headmarker values These are based on previous encounters usage. --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index f747a69a010..87ddd1e0842 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -74,6 +74,15 @@ const headMarkerData = { 'stack': '0080', // spread (fake) or stack (real) // Phase 1 Tethers 'imageTether': '002D', + // Phase 3 + '1': '004F', + '2': '0050', + '3': '0051', + '4': '0052', + '5': '0053', + '6': '0054', + '7': '0055', + '8': '0056', } as const; const mysteryMagicOutputStrings: OutputStrings = { From 35c962438c9b7abad5388bea7597b27d2f74578f Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sat, 13 Jun 2026 20:23:10 -0400 Subject: [PATCH 08/51] lint and fix missing dir output --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 87ddd1e0842..ae8adfce55b 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1645,14 +1645,16 @@ const triggerSet: TriggerSet = { // Nearest inter-inter cardinal opposite that of first blaster type: 'HeadMarker', netRegex: { - id: [headMarkerData['1'], + id: [ + headMarkerData['1'], headMarkerData['2'], headMarkerData['3'], headMarkerData['4'], headMarkerData['5'], headMarkerData['6'], headMarkerData['7'], - headMarkerData['8']], + headMarkerData['8'] + ], capture: true, }, condition: Conditions.targetIsYou(), @@ -1685,10 +1687,10 @@ const triggerSet: TriggerSet = { : (myNum - blaster16Dir + 16) % 16; // Counterclock // Find inter-inter cardinal - const spot = Directions.output16Dir[adjustedDirNum] ?? 'unknown'; + const safeDir = Directions.output16Dir[adjustedDirNum] ?? 'unknown'; return output.text!({ - num: output.num!({myNum}), - spot: output[spot]!(), + num: output.num!({ num: myNum }), + dir: output[safeDir]!(), }); }, outputStrings: { From 1a815ee4c52277bb83d28456d611ba3f06dbf5e6 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sat, 13 Jun 2026 20:25:32 -0400 Subject: [PATCH 09/51] missing comma --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index ae8adfce55b..783d5b129dd 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1653,7 +1653,7 @@ const triggerSet: TriggerSet = { headMarkerData['5'], headMarkerData['6'], headMarkerData['7'], - headMarkerData['8'] + headMarkerData['8'], ], capture: true, }, From 6d5077b7587b13d82ff4ba2e684d5bfeac40b134 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sun, 14 Jun 2026 22:47:00 -0400 Subject: [PATCH 10/51] add initial slap happy trigger --- .../data/07-dt/ultimate/dancing_mad.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 33c1e557041..1aea0dfdab4 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1923,6 +1923,69 @@ const triggerSet: TriggerSet = { }, }, }, + { + id: 'DMU P3 Slap Happy', + // TODO: Get boss location on teleport (could adjust call to be a direction of the slaps 1-3) + // BAE6 Slap Happy: Boss slaps his right 3 times (party cleave) + left once + // BAE7 Slap Happy: Boss slaps his left 3 times (role cleaves) + right once + // Boss can be in different cardinal/intercardinals + type: 'StartsUsing', + netRegex: { id: ['BAE6', 'BAE7'], source: 'Kefka', capture: true }, + alertText: (_data, matches, output) => { + const id = matches.id; + const x = parseFloat(matches.x); + const y = parseFloat(matches.y); + const bossDirNum = Directions.xyTo8DirNum(x, y, centerX, centerY); + const clockDirNum = (bossDirNum + 2) % 8; + const counterDirNum = (bossDirNum + 6) % 8; // Wrap-around + const clockDir = Directions.output8Dir[clockDirNum] ?? 'unknown'; + const counterDir = Directions.output8Dir[counterDirNum] ?? 'unknown'; + + const isRightSlap = id === 'BAE6'; + const dir = isRightSlap ? clockDir : counterDir; + + return output.slapDirMechThenOut!({ + dir1: output[dir]!(), + mech: isRightSlap + ? output.partyStack!() + : output.roleStacks!(), + out: output.outOfMiddle!(), + }); + }, + outputStrings: { + ...Directions.outputStrings8Dir, + unknown: Outputs.unknown, + outOfMiddle: { + en: 'Out Of Middle', + de: 'Raus aus der Mitte', + fr: 'Sortez du milieu', + ja: '横へ', + cn: '远离中间', + ko: '가운데 피하기', + tc: '遠離中間', + }, + partyStack: { + en: 'Party Stack', + de: 'In der Gruppe sammeln', + fr: 'Package en groupe', + ja: 'あたまわり', + cn: '人群分摊', + ko: '본대 쉐어', + tc: '分攤', + }, + roleStacks: { + en: 'Role Stacks', + de: 'Rollengruppe sammeln', + fr: 'Package par rôle', + cn: '职能分摊', + ko: '역할별 쉐어', + tc: '職能分攤', + }, + slapDirMechThenOut: { + en: '${dir1} + ${mech} => ${out}', + }, + }, + }, ], timelineReplace: [ { From 8f31de1a0511c21852506ecb5d306f74a6ab82f7 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sun, 14 Jun 2026 23:21:09 -0400 Subject: [PATCH 11/51] add accretion 2 trigger --- .../data/07-dt/ultimate/dancing_mad.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index fd6c017b8cb..a5ff2ebea74 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1934,6 +1934,54 @@ const triggerSet: TriggerSet = { }, }, }, + { + id: 'DMU P3 Accretion Cleanup', + type: 'LosesEffect', + netRegex: { effectId: '644', capture: true }, + run: (data, matches) => { + const target = matches.target; + if (target === data.firstAccretion) + delete data.firstAccretion; + else // There is no one else it could be but second + delete data.secondAccretion; + }, + }, + { + id: 'DMU P3 Accretion 2', + // Cleansing 644 Accretion or 154E Primordial Crust triggers BAFA Earthquake + // BAFA Earthquake targets receive D2C Earth Resistance Down II (1.96s) + // Utilizing D2C Earth Resistance Down II to call for healing next player + type: 'GainsEffect', + netRegex: { effectId: 'D2C', capture: true }, + condition: (data) => { + return data.firstAccretion !== undefined || data.secondAccretion !== undefined; + }, + delaySeconds: (_data, matches) => parseFloat(matches.duration), + suppressSeconds: 1, + response: (data, _matches, output) => { + const player = data.firstAccretion !== undefined + ? data.firstAccretion + : data.secondAccretion; + const severity = data.role === 'healer' ? 'alertText' : 'infoText'; + + return { + [severity]: output.healPlayerFull!({ + player: data.party.member(player), + }), + }; + }, + outputStrings: { + healPlayerFull: { + en: 'Heal ${player} to full', + de: 'Heile ${player} voll', + fr: 'Soin complet sur ${player}', + ja: '${player} を全回復して', + cn: '奶满${player}', + ko: '완전 회복: ${player}', + tc: '奶滿${player}', + }, + }, + }, { id: 'DMU P3 Slap Happy', // TODO: Get boss location on teleport (could adjust call to be a direction of the slaps 1-3) From 4129022527398a5ac98ec278d9c9fbc5f1d29b97 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sun, 14 Jun 2026 23:35:33 -0400 Subject: [PATCH 12/51] lint + fix response --- .../data/07-dt/ultimate/dancing_mad.ts | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index a5ff2ebea74..9244d31eb8a 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1942,7 +1942,8 @@ const triggerSet: TriggerSet = { const target = matches.target; if (target === data.firstAccretion) delete data.firstAccretion; - else // There is no one else it could be but second + // There is no one else it could be but second + else delete data.secondAccretion; }, }, @@ -1951,14 +1952,27 @@ const triggerSet: TriggerSet = { // Cleansing 644 Accretion or 154E Primordial Crust triggers BAFA Earthquake // BAFA Earthquake targets receive D2C Earth Resistance Down II (1.96s) // Utilizing D2C Earth Resistance Down II to call for healing next player + // NOTE: This will still trigger if 154E Primordial Crust is cleansed early type: 'GainsEffect', netRegex: { effectId: 'D2C', capture: true }, condition: (data) => { - return data.firstAccretion !== undefined || data.secondAccretion !== undefined; + return data.firstAccretion !== undefined || data.secondAccretion !== undefined; }, delaySeconds: (_data, matches) => parseFloat(matches.duration), suppressSeconds: 1, response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = { + healPlayerFull: { + en: 'Heal ${player} to full', + de: 'Heile ${player} voll', + fr: 'Soin complet sur ${player}', + ja: '${player} を全回復して', + cn: '奶满${player}', + ko: '완전 회복: ${player}', + tc: '奶滿${player}', + }, + }; const player = data.firstAccretion !== undefined ? data.firstAccretion : data.secondAccretion; @@ -1970,17 +1984,6 @@ const triggerSet: TriggerSet = { }), }; }, - outputStrings: { - healPlayerFull: { - en: 'Heal ${player} to full', - de: 'Heile ${player} voll', - fr: 'Soin complet sur ${player}', - ja: '${player} を全回復して', - cn: '奶满${player}', - ko: '완전 회복: ${player}', - tc: '奶滿${player}', - }, - }, }, { id: 'DMU P3 Slap Happy', From d681ca5cdd750001911374548e8f464d25182527 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 15 Jun 2026 19:28:36 -0400 Subject: [PATCH 13/51] add initial blackhole triggers --- .../data/07-dt/ultimate/dancing_mad.ts | 510 ++++++++++++++++++ 1 file changed, 510 insertions(+) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index df8c959110d..1925955704f 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -9,6 +9,11 @@ import { LocaleText, OutputStrings, TriggerSet } from '../../../../../types/trig // TODO: P3 Tailwind/Headwind resolution configuration options // TODO: P3 Verify number headmarker values // TODO: Earlier phase tracking for P5 (counting the jumps to middle?) +// TODO: P3 Blackhole Directions +// TODO: P3 Rework blackhole triggers for player that got hit to receive output over assuming they followed the plan? +// TODO: P3 Blackhole number output replace with Blackhole set (currently it's Nothingness counter) +// TODO: P3 Better no-config support via debuff tracking? +// TODO: P3 Aoe calls for Earthquake and/or some call for those with no tether during swaps? type Phase = 'p1' | 'p2' | 'p3' | 'p4' | 'p5'; const phases: { [id: string]: Phase } = { @@ -63,6 +68,11 @@ export interface Data extends RaidbossData { inLine: { [name: string]: number }; firstAccretion?: string; secondAccretion?: string; + hadAccretion: boolean; + kefkaTeleportDirNum?: number; + blackHoleSet: number; // To be replaced? + nothingnessCount: number; + // blackHoleDirNums: string[]; } const headMarkerData = { @@ -221,6 +231,39 @@ const trapOutputStrings: OutputStrings = { }, }; +const blackHoleOutputStrings: OutputStrings = { + ...Directions.outputStringsCardinalDir, + unknown: Outputs.unknown, + num: { + en: '${num}: ', + de: '${num}: ', + fr: '${num}: ', + ja: '${num}: ', + cn: '${num}: ', + ko: '${num}: ', + tc: '${num}: ', + }, + takeDirTetherClockwise: { + en: '${num} Take ${dir1} Tether Clockwise' + }, + keepTether: { + en: 'Keep Tether', + }, + passTether: { + en: 'Pass Tether', + }, + oneBlackHole: { + en: '${num}${dir}', + }, + twoBlackHoles: { + en: '${num}${dir1}/${dir2}', + }, + threeBlackHoles: { + en: '${num}${dir1}/${dir2}/${dir3}', + }, +}; + + const triggerSet: TriggerSet = { id: 'DancingMadUltimate', zoneId: ZoneId.DancingMadUltimate, @@ -246,6 +289,23 @@ const triggerSet: TriggerSet = { }, default: 'none', }, + { + id: 'blackhole', + comment: { + en: `Kefkabin: #1 DPS, #1 Support, #1 Accretion, #2 DPS, #2 Support, #2 Accretion, #3 DPS, #3 Support`, + }, + name: { + en: 'P3 Black Hole Order', + }, + type: 'select', + options: { + en: { + 'Kefkabin': 'kefka', + 'Generic calls': 'none', + }, + }, + default: 'none', + }, ], timelineFile: 'dancing_mad.txt', initData: () => { @@ -267,6 +327,10 @@ const triggerSet: TriggerSet = { waterElementPlayers: [], firstBlaster: [], inLine: {}, + hadAccretion: false, + blackHoleSet: 0, // To be replaced? + nothingnessCount: 0, + // blackHoleDirNums: [], }; }, triggers: [ @@ -2017,6 +2081,10 @@ const triggerSet: TriggerSet = { data.firstAccretion = target; else data.secondAccretion = target; + + // Store for Black Hole Order + if (data.me === target) + data.hadAccretion = true; }, }, { @@ -2191,6 +2259,448 @@ const triggerSet: TriggerSet = { }, }, }, + { + id: 'DMU P3 Nothingness Counter', + // There are 10 sets of Nothingness from Black Holes to soak + // They always spawn on a cardinal + // The Nothingness beams should be baited cw or ccw for melee uptime + // Getting hit by Nothingness gives 154C Unbecoming + // If hit by Nothingness with 154C Unbecoming, it becomes 154D Meanest Existence + // If hit by Nothingness with 154D Meanest Existence, lethal damage is taken which + // expires 154E Primordial Crust causing an BAFA Earthquake AOE + // + // Accretions Resolve => Slap Happy + // Black Hole Set 1 spawns 1 Black Hole + // 1 => 1 Nothingness (Taken by a 1) + // Black Holes Set 2 spawns 2 Black Holes + // 2 => 2 Nothingness (Taken by two 1s) + // TBs => Damning Edict => Slap Happy + // Black Hole Set 3 spawns 3 Black Holes + // 3 => 3 Nothingness (Taken by two 1s and Accretion 1), 1 player swaps tether + // 4 => 3 Nothingness (Taken by one 1, Accretion 1, and one 2), 1 player swaps tether + // 5 => 3 Nothingness (Taken by two 2s and Accretion 1) + // Damning Edict => Look upon Me and Despair => TBs + // Black Hole Set 3 spawns 3 Black Holes + // 6 => 3 Black Holes (Taken by two 2s and Accretion 2), 1 player swaps tether + // 7 => 3 Black Holes (Taken by one 3, one 2, and Accretion 2), 1 player swaps tether + // 8 => 3 Black Holes (Taken by two 3s, and Accretion 2) + // Lat/Long (White Hole cast here too) => Slap Happy => Look upon Me and Despair + // Black Hole Set 5 spawns 2 Black Holes + // 9 => 2 Black Holes (Taken by two 3s) + // Black Hole Set 6 spawns 1 Black Hole + // 10 => 1 Black Hole (Taken by last 3) + // However, there are will be 10 BAFC Nothingness casts + // Using BAFC Nothingness to track which set we are on + type: 'Ability', + netRegex: { id: 'BAFC', capture: false }, + suppressSeconds: 1, + run: (data) => { + data.nothingnessCount = data.nothingnessCount + 1, + // These will be replaced with either tether or actor tracker + // data.blackHoleDirNums = []; + data.blackHoleSet = data.blackHoleSet + 1; + }, + }, + { + id: 'DMU P3 Black Hole 1, Nothingness 1', + // One Black Hole spawns, causes a single Nothingness + type: 'Tether', + netRegex: { capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 0; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const dir = 'unknown'; // TBD + + if ( + config === 'kefka' && data.inLine[data.me] === 1 && + !data.hadAccretion && data.role === 'dps' + ) + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir]!(), + }), + }; + return { + infoText: output.oneBlackHole!({ + num: data.blackHoleSet, dir: output[dir]!(), + }), + }; + }, + }, + { + id: 'DMU P3 Black Hole 2, Nothingness 1', + // Two Black Holes spawn, each cause a single Nothingness + type: 'Tether', + netRegex: { capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 1; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const dir1 = 'unknown'; // TBD + const dir2 = 'unknown'; // TBD + + if ( + config === 'kefka' && data.inLine[data.me] === 1 && + !data.hadAccretion + ) { + if (data.role === 'dps') + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir1]!(), + }), + }; + // Support #1 + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir2]!(), + }), + }; + } + + return { + infoText: output.twoBlackHoles!({ + num: data.blackHoleSet, + dir1: output[dir1]!(), + dir2: output[dir2]!(), + }), + }; + }, + }, + { + id: 'DMU P3 Black Hole 3, Nothingness 1', + // Three Black Holes spawn, each cause three Nothingness + type: 'Tether', + netRegex: { capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 2; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const dir1 = 'unknown'; // TBD + const dir2 = 'unknown'; // TBD + const dir3 = 'unknown'; // TBD + + if (config === 'kefka' && data.inLine[data.me] === 1) { + if (data.hadAccretion) + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir3]!(), + }), + }; + if (data.role === 'dps') + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir1]!(), + }), + }; + // Support #1 + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir2]!(), + }), + }; + } + + return { + infoText: output.threeBlackHoles!({ + num: data.blackHoleSet, + dir1: output[dir1]!(), + dir2: output[dir2]!(), + dir3: output[dir3]!(), + }), + }; + }, + }, + { + id: 'DMU P3 Black Hole 3, Nothingness 2', + // One player needs to swap tether + // TODO: Move the players with previous tethers to a trigger condition on hit? + type: 'Ability', + netRegex: { id: 'BAFC', capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 3; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const hadAccretion = data.hadAccretion; + const line = data.inLine[data.me]; + const dir = 'unknown'; // TBD + + if (config === 'kefka') { + if (line === 1) { + if (hadAccretion || (data.role !== 'dps')) + return { infoText: output.keepTether!() }; + // DPS #1 + return { alertText: output.passTether!() }; + } + if (line === 2 && !hadAccretion && data.role === 'dps') { + // We could get the player they are taking from, but seems unnecessary at the time + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir]!(), + }), + }; + } + } + }, + }, + { + id: 'DMU P3 Black Hole 3, Nothingness 3', + // One player needs to swap tether + // TODO: Move the players with previous tethers to a trigger condition on hit? + type: 'Ability', + netRegex: { id: 'BAFC', capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 4; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const hadAccretion = data.hadAccretion; + const line = data.inLine[data.me]; + const dir = 'unknown'; // TBD + + if (config === 'kefka') { + if (line === 1) { + if (hadAccretion) + return { infoText: output.keepTether!() }; + if (data.role !== 'dps') + return { alertText: output.passTether!() }; + } + if (line === 2 && !hadAccretion) { + if (data.role !== 'dps') { + // We could get the player they are taking from, but seems unnecessary at the time + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir]!(), + }), + }; + } + // DPS #2 + return { infoText: output.keepTether!() }; + } + } + }, + }, + { + id: 'DMU P3 Black Hole 4, Nothingness 1', + // Three Black Holes spawn, each cause three Nothingness + type: 'Tether', + netRegex: { capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 5; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const dir1 = 'unknown'; // TBD + const dir2 = 'unknown'; // TBD + const dir3 = 'unknown'; // TBD + + if (config === 'kefka' && data.inLine[data.me] === 2) { + if (data.hadAccretion) + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir3]!(), + }), + }; + if (data.role === 'dps') + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir1]!(), + }), + }; + // Support #2 + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir2]!(), + }), + }; + } + + return { + infoText: output.threeBlackHoles!({ + num: data.blackHoleSet, + dir1: output[dir1]!(), + dir2: output[dir2]!(), + dir3: output[dir3]!(), + }), + }; + }, + }, + { + id: 'DMU P3 Black Hole 4, Nothingness 2', + // One player needs to swap tether + // TODO: Move the players with previous tethers to a trigger condition on hit? + type: 'Ability', + netRegex: { id: 'BAFC', capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 6; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const line = data.inLine[data.me]; + const dir = 'unknown'; // TBD + + if (config === 'kefka') { + if (line === 2) { + if (data.hadAccretion || data.role !== 'dps') + return { infoText: output.keepTether!() }; + // DPS #2 + return { alertText: output.passTether!() }; + } + if (line === 3 && data.role === 'dps') { + // We could get the player they are taking from, but seems unnecessary at the time + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir]!(), + }), + }; + } + } + }, + }, + { + id: 'DMU P3 Black Hole 4, Nothingness 3', + // One player needs to swap tether + // TODO: Move the players with previous tethers to a trigger condition on hit? + type: 'Ability', + netRegex: { id: 'BAFC', capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 7; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const line = data.inLine[data.me]; + const dir = 'unknown'; // TBD + + if (config === 'kefka') { + if (line === 2) { + if (data.hadAccretion) + return { infoText: output.keepTether!() }; + if (data.role !== 'dps') + return { alertText: output.passTether!() }; + } + if (line === 3) { + if (data.role === 'dps') + return { infoText: output.keepTether!() }; + // Support #3 + // We could get the player they are taking from, but seems unnecessary at the time + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir]!(), + }), + }; + } + } + }, + }, + { + id: 'DMU P3 Black Hole 5, Nothingness 1', + // Two Black Holes spawn, each cause a single Nothingness + type: 'Tether', + netRegex: { capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 8; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const dir1 = 'unknown'; // TBD + const dir2 = 'unknown'; // TBD + + if (config === 'kefka' && data.inLine[data.me] === 3) { + if (data.role === 'dps') + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir1]!(), + }), + }; + // Support #3 + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir2]!(), + }), + }; + } + + return { + infoText: output.twoBlackHoles!({ + num: data.blackHoleSet, + dir1: output[dir1]!(), + dir2: output[dir2]!(), + }), + }; + }, + }, + { + id: 'DMU P3 Black Hole 6, Nothingness 1', + // One Black Hole spawns, causes a single Nothingness + type: 'Tether', + netRegex: { capture: false }, + condition: (data) => { + return data.phase === 'p3' && data.nothingnessCount === 9; + }, + suppressSeconds: 99999, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = blackHoleOutputStrings; + + const config = data.triggerSetConfig.blackhole; + const dir = 'unknown'; // TBD + + if ( + config === 'kefka' && data.inLine[data.me] === 3 && + data.role !== 'dps' + ) + return { + alertText: output.takeDirTetherClockwise!({ + num: data.blackHoleSet, dir: output[dir]!(), + }), + }; + return { + infoText: output.oneBlackHole!({ + num: data.blackHoleSet, dir: output[dir]!(), + }), + }; + }, + }, ], timelineReplace: [ { From e89b1ef69696751cc4ef092feb3f57b4d0d72b0b Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 15 Jun 2026 19:30:50 -0400 Subject: [PATCH 14/51] missing export config --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 1925955704f..f259c35f0af 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -29,6 +29,7 @@ const centerY = 100; export interface Data extends RaidbossData { readonly triggerSetConfig: { teleportent: 'clockwise' | 'filipino' | 'none'; + blackhole: 'kefka' | 'none'; }; // General phase: Phase | 'unknown'; From 2676799f13ca5fa6b9cc7fb014ab8c6eb7a2bad8 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 15 Jun 2026 19:49:40 -0400 Subject: [PATCH 15/51] lint --- .../data/07-dt/ultimate/dancing_mad.ts | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index f259c35f0af..238b3741259 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -245,7 +245,7 @@ const blackHoleOutputStrings: OutputStrings = { tc: '${num}: ', }, takeDirTetherClockwise: { - en: '${num} Take ${dir1} Tether Clockwise' + en: '${num} Take ${dir} Tether Clockwise', }, keepTether: { en: 'Keep Tether', @@ -293,7 +293,8 @@ const triggerSet: TriggerSet = { { id: 'blackhole', comment: { - en: `Kefkabin: #1 DPS, #1 Support, #1 Accretion, #2 DPS, #2 Support, #2 Accretion, #3 DPS, #3 Support`, + en: + `Kefkabin: #1 DPS, #1 Support, #1 Accretion, #2 DPS, #2 Support, #2 Accretion, #3 DPS, #3 Support`, }, name: { en: 'P3 Black Hole Order', @@ -2296,7 +2297,7 @@ const triggerSet: TriggerSet = { netRegex: { id: 'BAFC', capture: false }, suppressSeconds: 1, run: (data) => { - data.nothingnessCount = data.nothingnessCount + 1, + data.nothingnessCount = data.nothingnessCount + 1; // These will be replaced with either tether or actor tracker // data.blackHoleDirNums = []; data.blackHoleSet = data.blackHoleSet + 1; @@ -2324,12 +2325,14 @@ const triggerSet: TriggerSet = { ) return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir]!(), + num: data.blackHoleSet, + dir1: output[dir]!(), }), }; return { infoText: output.oneBlackHole!({ - num: data.blackHoleSet, dir: output[dir]!(), + num: data.blackHoleSet, + dir: output[dir]!(), }), }; }, @@ -2358,13 +2361,15 @@ const triggerSet: TriggerSet = { if (data.role === 'dps') return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir1]!(), + num: data.blackHoleSet, + dir: output[dir1]!(), }), }; // Support #1 return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir2]!(), + num: data.blackHoleSet, + dir: output[dir2]!(), }), }; } @@ -2400,19 +2405,22 @@ const triggerSet: TriggerSet = { if (data.hadAccretion) return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir3]!(), + num: data.blackHoleSet, + dir: output[dir3]!(), }), }; if (data.role === 'dps') return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir1]!(), + num: data.blackHoleSet, + dir: output[dir1]!(), }), }; // Support #1 return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir2]!(), + num: data.blackHoleSet, + dir: output[dir2]!(), }), }; } @@ -2457,7 +2465,8 @@ const triggerSet: TriggerSet = { // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir]!(), + num: data.blackHoleSet, + dir: output[dir]!(), }), }; } @@ -2495,7 +2504,8 @@ const triggerSet: TriggerSet = { // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir]!(), + num: data.blackHoleSet, + dir: output[dir]!(), }), }; } @@ -2527,19 +2537,22 @@ const triggerSet: TriggerSet = { if (data.hadAccretion) return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir3]!(), + num: data.blackHoleSet, + dir: output[dir3]!(), }), }; if (data.role === 'dps') return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir1]!(), + num: data.blackHoleSet, + dir: output[dir1]!(), }), }; // Support #2 return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir2]!(), + num: data.blackHoleSet, + dir: output[dir2]!(), }), }; } @@ -2583,7 +2596,8 @@ const triggerSet: TriggerSet = { // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir]!(), + num: data.blackHoleSet, + dir: output[dir]!(), }), }; } @@ -2622,7 +2636,8 @@ const triggerSet: TriggerSet = { // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir]!(), + num: data.blackHoleSet, + dir: output[dir]!(), }), }; } @@ -2650,13 +2665,15 @@ const triggerSet: TriggerSet = { if (data.role === 'dps') return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir1]!(), + num: data.blackHoleSet, + dir: output[dir1]!(), }), }; // Support #3 return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir2]!(), + num: data.blackHoleSet, + dir: output[dir2]!(), }), }; } @@ -2692,12 +2709,14 @@ const triggerSet: TriggerSet = { ) return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, dir: output[dir]!(), + num: data.blackHoleSet, + dir: output[dir]!(), }), }; return { infoText: output.oneBlackHole!({ - num: data.blackHoleSet, dir: output[dir]!(), + num: data.blackHoleSet, + dir: output[dir]!(), }), }; }, From de0b419750f17a72180f9826f671e1242939de43 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 15 Jun 2026 19:53:26 -0400 Subject: [PATCH 16/51] missing tab --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 238b3741259..e44b404c3eb 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -294,7 +294,7 @@ const triggerSet: TriggerSet = { id: 'blackhole', comment: { en: - `Kefkabin: #1 DPS, #1 Support, #1 Accretion, #2 DPS, #2 Support, #2 Accretion, #3 DPS, #3 Support`, + `Kefkabin: #1 DPS, #1 Support, #1 Accretion, #2 DPS, #2 Support, #2 Accretion, #3 DPS, #3 Support`, }, name: { en: 'P3 Black Hole Order', From 69f186a958c99c2722c667e68f422c48b40bac36 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 15 Jun 2026 20:54:21 -0400 Subject: [PATCH 17/51] remove extra space --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index e44b404c3eb..06614464f7e 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -264,7 +264,6 @@ const blackHoleOutputStrings: OutputStrings = { }, }; - const triggerSet: TriggerSet = { id: 'DancingMadUltimate', zoneId: ZoneId.DancingMadUltimate, From 3e4ea05d638cb2bc8835a25b3229fa9cc8fd22d2 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Tue, 16 Jun 2026 23:32:31 -0400 Subject: [PATCH 18/51] exdeath tether, fix water/fire calls --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 06614464f7e..cb7a86861c7 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -95,7 +95,9 @@ const headMarkerData = { 'stackPath': '02CB', // When standing in Path of Light tower, causes BAC0 Spelldriver (3-person stack) 'conePath': '02CD', // When standing in Path of Light tower, causes BAC2 Spellwave (cone targetting nearest player) 'spreadPath': '02CC', // When standing in Path of Light tower, causes BAC1 Spellscatter (small aoe on the player) - // Phase 3 + // Phase 3 tethers + 'exdeathTether': '0040', // Exdeath "pulls energy" from Graven Image with BNpcID 4C31 with BB12 Thunder III + // Phase 3 Players '1': '004F', '2': '0050', '3': '0051', @@ -1691,7 +1693,7 @@ const triggerSet: TriggerSet = { // Tanks and Melee aren't expected to bait crystals, so shorten output if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) - return { [severity]: output.fireOnPlayers!() }; + return { [severity]: output.fireOnPlayers!({ players: msg }) }; return { [severity]: output.fireOnPlayersCrystal!({ players: msg }) }; }, @@ -1718,7 +1720,7 @@ const triggerSet: TriggerSet = { }; const severity = data.myElement === 'fire' ? 'alertText' : 'infoText'; - const players = data.fireElementPlayers.map( + const players = data.waterElementPlayers.map( (player) => { if (player === data.me) return output.you!(); @@ -1729,7 +1731,7 @@ const triggerSet: TriggerSet = { // Tanks and Melee aren't expected to bait crystals, so shorten output if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) - return { [severity]: output.waterOnPlayers!() }; + return { [severity]: output.waterOnPlayers!({ players: msg }) }; return { [severity]: output.waterOnPlayersCrystal!({ players: msg }) }; }, @@ -2306,8 +2308,10 @@ const triggerSet: TriggerSet = { id: 'DMU P3 Black Hole 1, Nothingness 1', // One Black Hole spawns, causes a single Nothingness type: 'Tether', - netRegex: { capture: false }, + netRegex: { capture: true }, condition: (data) => { + if (matches.id === headMarkerData['exdeathTether']) + return false; return data.phase === 'p3' && data.nothingnessCount === 0; }, suppressSeconds: 99999, From 66bae18118ef97f69a69991925a30b79ccc01959 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Tue, 16 Jun 2026 23:36:50 -0400 Subject: [PATCH 19/51] add missing matches --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index cb7a86861c7..14e53d5c1e2 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -2309,7 +2309,7 @@ const triggerSet: TriggerSet = { // One Black Hole spawns, causes a single Nothingness type: 'Tether', netRegex: { capture: true }, - condition: (data) => { + condition: (data, matches) => { if (matches.id === headMarkerData['exdeathTether']) return false; return data.phase === 'p3' && data.nothingnessCount === 0; From ef478ffa8a16b4c0a97e26956118fa8b6d9f933d Mon Sep 17 00:00:00 2001 From: Legends0 Date: Wed, 17 Jun 2026 03:28:16 -0400 Subject: [PATCH 20/51] more boa triggers incl crystal locations --- .../data/07-dt/ultimate/dancing_mad.ts | 277 ++++++++++++++++-- 1 file changed, 254 insertions(+), 23 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 14e53d5c1e2..6f90c996174 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1,7 +1,10 @@ import Conditions from '../../../../../resources/conditions'; import Outputs from '../../../../../resources/outputs'; import { Responses } from '../../../../../resources/responses'; -import Util, { Directions } from '../../../../../resources/util'; +import Util, { + DirectionOutputIntercard, + Directions, +} from '../../../../../resources/util'; import ZoneId from '../../../../../resources/zone_id'; import { RaidbossData } from '../../../../../types/data'; import { LocaleText, OutputStrings, TriggerSet } from '../../../../../types/trigger'; @@ -59,10 +62,14 @@ export interface Data extends RaidbossData { // Phase 2 // Phase 3 isFireShort?: boolean; + windCrystalNext: boolean; myElement?: 'fire' | 'water'; myWind?: 'head' | 'tail'; fireElementPlayers: string[]; waterElementPlayers: string[]; + fireCrystalDir?: DirectionOutputIntercard; + waterCrystalDir?: DirectionOutputIntercard; + windCrystalDir?: DirectionOutputIntercard; firstBlaster: number[]; firstBlasterDirNum?: number; blasterRotation?: number; @@ -326,6 +333,7 @@ const triggerSet: TriggerSet = { doubleTroubleTrapTargets: [], // Phase 2 // Phase 3 + windCrystalNext: false, fireElementPlayers: [], waterElementPlayers: [], firstBlaster: [], @@ -1566,7 +1574,7 @@ const triggerSet: TriggerSet = { // 641 Dynamic Flood: On expiration creates donut AoE around the player // and water crystal targets two closest players with point-blank AoEs // - // Entropy or Dynamic Fluid will have 20s and the other 45s duration + // Entropy or Dynamic Fluid will have 19s and the other 46s duration // At the same time, elemental crystals spawn at intercardinals // Fire and Water Crystals will be opposite each other // Wind Crystal will be between on the opposite side @@ -1579,7 +1587,7 @@ const triggerSet: TriggerSet = { run: (data, matches) => { const id = matches.effectId; if (data.isFireShort === undefined) { - const isShort = parseFloat(matches.duration) < 21; + const isShort = parseFloat(matches.duration) < 20; data.isFireShort = (isShort && id === '640') || (!isShort && id === '641') ? true @@ -1601,6 +1609,7 @@ const triggerSet: TriggerSet = { // nearest player with 2-person stack // 643 Tailwind: Face towards knockback source, wind crystal targets // nearest player with 2-person stack + // These have a 68s duration type: 'GainsEffect', netRegex: { effectId: ['642', '643'], capture: true }, condition: Conditions.targetIsYou(), @@ -1646,11 +1655,9 @@ const triggerSet: TriggerSet = { }, headwind: { en: 'Headwind on YOU', - ko: '혼돈의 바람 대상자', }, tailwind: { en: 'Tailwind on YOU', - ko: '혼돈의 역풍 대상자', }, withElement: { en: '${short}: ${element} + ${wind}', @@ -1660,6 +1667,83 @@ const triggerSet: TriggerSet = { }, }, }, + { + id: 'DMU P3 Crystal Location Collector', + // Crystals are added at same time as BAF2 Bowels of Agony + // + // First set spawns at intercardinals + // Wind will be inbetween Fire and Water + // The following are their BNpcIDs: + // 1EC03A => Fire (Red Triangle) Crystal + // 1EC03B => Water (Blue Square) Crystal + // 1EC03C => Wind (Green Diamond) Crystal + // + // Later the Earth Crystal will spawn in the center + // 1EC03D => Earth (Yellow Arrowhead) Crystal (TODO: Verify this BNnpcID) + // They are removed once players lose their respective debuffs + type: 'CombatantMemory', + netRegex: { + change: 'Add', + pair: [{ key: 'BNpcID', value: ['1EC03A', '1EC03B', '1EC03C'] }], + capture: true, + }, + run: (data, matches) => { + const x = parseFloat(matches.pairPosX ?? '0'); + const y = parseFloat(matches.pairPosY ?? '0'); + const bnpcid = matches.pairBNpcID; + const dir = Directions.xyToIntercardDirOutput(x, y, centerX, centerY); + + if (bnpcid === '1EC03A') + data.fireCrystalDir = dir; + else if (bnpcid === '1EC03B') + data.waterCrystalDir = dir; + else + data.windCrystalDir = dir; + }, + }, + { + id: 'DMU P3 Short Crystal and Crystal Locations', + type: 'CombatantMemory', + netRegex: { + change: 'Add', + pair: [{ key: 'BNpcID', value: '1EC03C' }], + capture: false, + }, + delaySeconds: 2, // To prevent overlap with debuffs and time for collect + durationSeconds: 17, // Duration of the first debuff + suppressSeconds: 1, + infoText: (data, _matches, output) => { + const fireDir = data.fireCrystalDir ?? 'unknown'; + const waterDir = data.waterCrystalDir ?? 'unknown'; + const windDir = data.waterCrystalDir ?? 'unknown'; + const fShort = data.isFireShort; + + const fire = output.fire!({ dir: output[fireDir]!() }); + const water = output.water!({ dir: output[waterDir]!() }); + + return output.crystals!({ + short: fShort ? fire : water, + long: fShort ? water : fire, + wind: output.wind!({ dir: output[windDir]!() }), + }); + }, + outputStrings: { + ...Directions.outputStringsIntercardDir, + unknown: Outputs.unknown, + fire: { + en: 'Fire ${dir}', + }, + water: { + en: 'Water ${dir}', + }, + wind: { + en: 'Wind ${dir}', + }, + crystals: { + en: '${short} => ${long} => ${wind} (later)', + }, + }, + }, { id: 'DMU P3 Entropy and Fire Crystal', // Late goes off 2s after BAFF Shockwave @@ -1670,11 +1754,18 @@ const triggerSet: TriggerSet = { response: (data, _matches, output) => { // cactbot-builtin-response output.responseOutputStrings = { + ...Directions.outputStringsIntercardDir, you: { en: 'YOU', }, + bait: { + en: 'Bait Fire Donut', + }, fireOnPlayersCrystal: { - en: 'Spread on ${players} / Bait Fire Donut', + en: '${spread}/${bait}', + }, + fireOnPlayersCrystalDir: { + en: '${spread}/${dir} => ${bait}', }, fireOnPlayers: { en: 'Spread on ${players}', @@ -1690,12 +1781,27 @@ const triggerSet: TriggerSet = { }, ); const msg = players?.join(', '); + const spread = output.fireOnPlayers!({ players: msg }); // Tanks and Melee aren't expected to bait crystals, so shorten output if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) - return { [severity]: output.fireOnPlayers!({ players: msg }) }; + return { [severity]: spread }; - return { [severity]: output.fireOnPlayersCrystal!({ players: msg }) }; + const dir = data.fireCrystalDir; + if (dir === undefined) + return { + [severity]: output.fireOnPlayersCrystal!({ + spread: spread, + bait: output.bait!(), + }), + }; + return { + [severity]: output.fireOnPlayersCrystalDir!({ + spread: spread, + dir: output[dir]!(), + bait: output.bait!(), + }), + }; }, }, { @@ -1708,18 +1814,25 @@ const triggerSet: TriggerSet = { response: (data, _matches, output) => { // cactbot-builtin-response output.responseOutputStrings = { + ...Directions.outputStringsIntercardDir, you: { en: 'YOU', }, + bait: { + en: 'Bait Water AOE', + }, waterOnPlayersCrystal: { - en: 'Donut on ${players} / Bait Water AOE', + en: '${donut}/${bait}', + }, + waterOnPlayersCrystalDir: { + en: '${donut}/${dir} => ${bait}', }, waterOnPlayers: { en: 'Donut on ${players}', }, }; - const severity = data.myElement === 'fire' ? 'alertText' : 'infoText'; + const severity = data.myElement === 'water' ? 'alertText' : 'infoText'; const players = data.waterElementPlayers.map( (player) => { if (player === data.me) @@ -1728,12 +1841,105 @@ const triggerSet: TriggerSet = { }, ); const msg = players?.join(', '); + const donut = output.waterOnPlayers!({ players: msg }); // Tanks and Melee aren't expected to bait crystals, so shorten output if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) - return { [severity]: output.waterOnPlayers!({ players: msg }) }; + return { [severity]: donut }; + + const dir = data.waterCrystalDir; + if (dir === undefined) + return { + [severity]: output.waterOnPlayersCrystal!({ + donut: donut, + bait: output.bait!(), + }), + }; + return { + [severity]: output.waterOnPlayersCrystalDir!({ + donut: donut, + dir: output[dir]!(), + bait: output.bait!(), + }), + }; + }, + }, + { + id: 'DMU P3 Long Crystal and Wind Crystal Locations', + // Inform that long is next, location it will Be + // One of these spells will trigger: + // BAF3 Stray Flames + // BAF6 Stray Spray + type: 'Ability', + netRegex: { id: ['BAF3', 'BAF6'], source: 'Chaos', capture: true }, + condition: (data, matches) => { + const fShort = data.isFireShort; + const id = matches.id; + // Ensure this only outputs if expected crystal went off + return (fShort && id === 'BAF3') || (!fShort && id === 'BAF6'); + }, + durationSeconds: 27, // Duration of the first debuff + suppressSeconds: 99999, + infoText: (data, _matches, output) => { + const fShort = data.isFireShort; + const longCrystalDir = fShort ? data.waterCrystalDir : data.fireCrystalDir; + const longDir = longCrystalDir ?? 'unknown'; + const windDir = data.waterCrystalDir ?? 'unknown'; + + return output.crystals!({ + long: fShort + ? output.fire!({ dir: output[longDir]!() }) + : output.water!({ dir: output[longDir]!() }), + wind: output.wind!({ dir: output[windDir]!() }), + }); + }, + outputStrings: { + ...Directions.outputStringsIntercardDir, + unknown: Outputs.unknown, + fire: { + en: 'Fire ${dir}', + }, + water: { + en: 'Water ${dir}', + }, + wind: { + en: 'Wind ${dir}', + }, + crystals: { + en: '${long} => ${wind} (later)', + }, + }, + }, + { + id: 'DMU P3 Wind Crystal Next Flag', + // By the BAFF Shockwave, the next BAF3 Stray Flames / BAF6 Stray Spray + // Will mean we will need to resolve the wind crystal next + type: 'Ability', + netRegex: { id: 'BAFF', source: 'Chaos', capture: false }, + suppressSeconds: 99999, + run: (data) => data.windCrystalNext = true, + }, + { + id: 'DMU P3 Wind Crystal Location', + // Inform that wind is next + // One of these spells will trigger: + // BAF3 Stray Flames + // BAF6 Stray Spray + type: 'Ability', + netRegex: { id: ['BAF3', 'BAF6'], source: 'Chaos', capture: false }, + condition: (data) => data.windCrystalNext, + suppressSeconds: 99999, + infoText: (data, _matches, output) => { + const windDir = data.waterCrystalDir ?? 'unknown'; - return { [severity]: output.waterOnPlayersCrystal!({ players: msg }) }; + return output.wind!({ dir: output[windDir]!() }); + }, + outputStrings: { + ...Directions.outputStringsIntercardDir, + unknown: Outputs.unknown, + wind: { + en: 'Knockback to Wind ${dir} (later)', + }, }, }, { @@ -1928,7 +2134,7 @@ const triggerSet: TriggerSet = { }, }, { - id: 'DMU P3 Vaccuum Wave', + id: 'DMU P3 Vacuum Wave', // If players have not yet resolved their headwinds, then they will need // to do so: // Headwind look at Exdeath @@ -1948,26 +2154,51 @@ const triggerSet: TriggerSet = { tc: '卡奧斯', }; const chaosName = chaosLocaleNames[data.parserLang]; + const windDir = data.windCrystalDir; + const knockback = output.knockbackFromChaos!({ chaos: chaosName }); + const exdeath = matches.source; + + if (data.myWind === undefined) { + if (windDir === undefined) + return output.knockbackFromChaosToCrystal!({ knockback: knockback }); + return output.knockbackFromChaosToDir!({ + knockback: knockback, + dir: output[windDir]!(), + }); + } - if (data.myWind === undefined) - return output.knockbackFromChaos!({ chaos: chaosName }); - - return output.text!({ - knockback: output.knockbackFromChaos!({ chaos: chaosName }), - facing: output[data.myWind]!({ target: matches.source }), - }); + if (windDir === undefined) + return output.knockbackFromChaosToWindFacing!({ + knockbackDir: output.knockbackFromChaosToCrystal!({ + knockback: knockback, + }), + facing: output[data.myWind]!({ target: exdeath }), + }); + return output.knockbackFromChaosToWindFacing!({ + knockbackDir: output.knockbackFromChaosToDir!({ + knockback: knockback, + dir: output[windDir]!(), + }), + facing: output[data.myWind]!({ target: exdeath }), + }); }, outputStrings: { + ...Directions.outputStringsIntercardDir, head: { en: 'Face ${target}', }, tail: Outputs.lookAwayFromTarget, knockbackFromChaos: { en: 'Knockback from ${chaos}', - ko: '${chaos}에서 넉백', }, - text: { - en: '${knockback} + ${facing}', + knockbackFromChaosToDir: { + en: '${knockback} to ${dir}', + }, + knockbackFromChaosToCrystal: { + en: '${knockback} to Crystal', + }, + knockbackFromChaosToWindFacing: { + en: '${knockbackDir} + ${facing}', }, }, }, From fbcbad01c8198ebd07b72b9cb867c402a7c39784 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Wed, 17 Jun 2026 03:30:51 -0400 Subject: [PATCH 21/51] lint --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 6f90c996174..1dd823cb9c5 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1,10 +1,7 @@ import Conditions from '../../../../../resources/conditions'; import Outputs from '../../../../../resources/outputs'; import { Responses } from '../../../../../resources/responses'; -import Util, { - DirectionOutputIntercard, - Directions, -} from '../../../../../resources/util'; +import Util, { DirectionOutputIntercard, Directions } from '../../../../../resources/util'; import ZoneId from '../../../../../resources/zone_id'; import { RaidbossData } from '../../../../../types/data'; import { LocaleText, OutputStrings, TriggerSet } from '../../../../../types/trigger'; @@ -2175,12 +2172,12 @@ const triggerSet: TriggerSet = { facing: output[data.myWind]!({ target: exdeath }), }); return output.knockbackFromChaosToWindFacing!({ - knockbackDir: output.knockbackFromChaosToDir!({ - knockback: knockback, - dir: output[windDir]!(), - }), - facing: output[data.myWind]!({ target: exdeath }), - }); + knockbackDir: output.knockbackFromChaosToDir!({ + knockback: knockback, + dir: output[windDir]!(), + }), + facing: output[data.myWind]!({ target: exdeath }), + }); }, outputStrings: { ...Directions.outputStringsIntercardDir, From 757506805e04c5370122862a603087f5777538e8 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Thu, 18 Jun 2026 01:22:36 -0400 Subject: [PATCH 22/51] crystal, head/tail, and blaster corrections --- .../data/07-dt/ultimate/dancing_mad.ts | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 1dd823cb9c5..6d40045b6d4 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -102,14 +102,14 @@ const headMarkerData = { // Phase 3 tethers 'exdeathTether': '0040', // Exdeath "pulls energy" from Graven Image with BNpcID 4C31 with BB12 Thunder III // Phase 3 Players - '1': '004F', - '2': '0050', - '3': '0051', - '4': '0052', - '5': '0053', - '6': '0054', - '7': '0055', - '8': '0056', + '1': '0150', + '2': '0151', + '3': '0152', + '4': '0153', + '5': '01B5', + '6': '01B6', + '7': '01B7', + '8': '01B8', } as const; const mysteryMagicOutputStrings: OutputStrings = { @@ -1671,8 +1671,8 @@ const triggerSet: TriggerSet = { // First set spawns at intercardinals // Wind will be inbetween Fire and Water // The following are their BNpcIDs: - // 1EC03A => Fire (Red Triangle) Crystal - // 1EC03B => Water (Blue Square) Crystal + // 1EC03A => Water (Blue Square) Crystal + // 1EC03B => Fire (Red Triangle) Crystal // 1EC03C => Wind (Green Diamond) Crystal // // Later the Earth Crystal will spawn in the center @@ -1691,9 +1691,9 @@ const triggerSet: TriggerSet = { const dir = Directions.xyToIntercardDirOutput(x, y, centerX, centerY); if (bnpcid === '1EC03A') - data.fireCrystalDir = dir; - else if (bnpcid === '1EC03B') data.waterCrystalDir = dir; + else if (bnpcid === '1EC03B') + data.fireCrystalDir = dir; else data.windCrystalDir = dir; }, @@ -1712,7 +1712,7 @@ const triggerSet: TriggerSet = { infoText: (data, _matches, output) => { const fireDir = data.fireCrystalDir ?? 'unknown'; const waterDir = data.waterCrystalDir ?? 'unknown'; - const windDir = data.waterCrystalDir ?? 'unknown'; + const windDir = data.windCrystalDir ?? 'unknown'; const fShort = data.isFireShort; const fire = output.fire!({ dir: output[fireDir]!() }); @@ -2169,22 +2169,22 @@ const triggerSet: TriggerSet = { knockbackDir: output.knockbackFromChaosToCrystal!({ knockback: knockback, }), - facing: output[data.myWind]!({ target: exdeath }), + facing: output[data.myWind]!({ name: exdeath }), }); return output.knockbackFromChaosToWindFacing!({ knockbackDir: output.knockbackFromChaosToDir!({ knockback: knockback, dir: output[windDir]!(), }), - facing: output[data.myWind]!({ target: exdeath }), + facing: output[data.myWind]!({ name: exdeath }), }); }, outputStrings: { ...Directions.outputStringsIntercardDir, - head: { - en: 'Face ${target}', + tail: { + en: 'Face ${name}', }, - tail: Outputs.lookAwayFromTarget, + head: Outputs.lookAwayFromTarget, knockbackFromChaos: { en: 'Knockback from ${chaos}', }, @@ -2218,20 +2218,21 @@ const triggerSet: TriggerSet = { }, condition: Conditions.targetIsYou(), infoText: (data, matches, output) => { - const limitCutNumberMap: { [id: string]: number } = { - '004F': 1, - '0050': 2, - '0051': 3, - '0052': 4, - '0053': 5, - '0054': 6, - '0055': 7, - '0056': 8, + + const blasterNumberMap: { [id: string]: number } = { + '0150': 1, + '0151': 2, + '0152': 3, + '0153': 4, + '01B5': 5, + '01B6': 6, + '01B7': 7, + '01B8': 8, }; const blaster = data.firstBlasterDirNum; const rotation = data.blasterRotation; const id = matches.id; - const myNum = limitCutNumberMap[id]; + const myNum = blasterNumberMap[id]; if (myNum === undefined) return; From 6a239f7c7aa6659097ea7a4fbda644cdb4744ae1 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Thu, 18 Jun 2026 02:07:06 -0400 Subject: [PATCH 23/51] fix ultima blaster location --- .../data/07-dt/ultimate/dancing_mad.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 6d40045b6d4..cd157694c13 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -2053,34 +2053,34 @@ const triggerSet: TriggerSet = { id: 'DMU P3 Ultima Blaster Collect', // Starts from random cardinal/intercardinal then rotates either CW or CCW // These are raidwide AOEs, but also include telegraphed lines and explosions - // TODO: Verify the this is correct + // Ability lines can have erroneous values + // Entity that does these has BNpcID 4BFB, added shortly before + // 271 ActorSetPos and 261 CombatantMemory Change lines are updated just prior to the ability type: 'Ability', netRegex: { id: 'BAE3', source: 'Kefka', capture: true }, - condition: (data, matches) => { - const x2 = parseFloat(matches.x); - const y2 = parseFloat(matches.y); - if (data.firstBlaster === undefined) { - data.firstBlaster = [x2, y2]; - data.firstBlasterDirNum = (Directions.xyTo8DirNum(x2, y2, centerX, centerY) + 4) % 8; // Need opposite side - return false; - } + condition: (data) => data.blasterRotation === undefined, + suppressSeconds: 1, + run: (data, matches) => { + const actor = data.actorPositions[matches.sourceId]; + if (actor === undefined) + return; + const x2 = actor.x; + const y2 = actor.y; // Get rotation of first and second Kefka blasters const x1 = data.firstBlaster[0]; const y1 = data.firstBlaster[1]; - if (x1 === undefined || y1 === undefined) { - // Try next blaster data.firstBlaster = [x2, y2]; - return false; + data.firstBlasterDirNum = (Directions.xyTo8DirNum(x2, y2, centerX, centerY) + 4) % 8; // Need opposite side + // Return to get the next blaster + return; } // Compute atan2 of determinant and dot product to get rotational direction // Note: X and Y are flipped due to Y axis being reversed data.blasterRotation = Math.atan2(y1 * x2 - x1 * y2, y1 * y2 + x1 * x2); - return true; // Stop execution after 2nd blaster }, - suppressSeconds: 99999, }, { id: 'DMU P3 Ultima Blaster Rotation', @@ -2202,6 +2202,7 @@ const triggerSet: TriggerSet = { { id: 'DMU P1 Ultima Blaster Location', // Nearest inter-inter cardinal opposite that of first blaster + // Could also account for player missing a marker as these are added sequentially type: 'HeadMarker', netRegex: { id: [ From bcfa4c546e6d690cbd1e98df0e3adbca9cfbc356 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Thu, 18 Jun 2026 02:10:47 -0400 Subject: [PATCH 24/51] lint --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index bdda8c58d21..ec1bd056b85 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -100,7 +100,7 @@ const headMarkerData = { 'stackPath': '02CB', // When standing in Path of Light tower, causes BAC0 Spelldriver (3-person stack) 'conePath': '02CD', // When standing in Path of Light tower, causes BAC2 Spellwave (cone targetting nearest player) 'spreadPath': '02CC', // When standing in Path of Light tower, causes BAC1 Spellscatter (small aoe on the player) - // Phase 3 tethers + // Phase 3 Tethers 'exdeathTether': '0040', // Exdeath "pulls energy" from Graven Image with BNpcID 4C31 with BB12 Thunder III // Phase 3 Players '1': '0150', @@ -2334,7 +2334,6 @@ const triggerSet: TriggerSet = { }, condition: Conditions.targetIsYou(), infoText: (data, matches, output) => { - const blasterNumberMap: { [id: string]: number } = { '0150': 1, '0151': 2, From c87a82670be81d79702e5a64309d7e2c876d8860 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Thu, 18 Jun 2026 22:16:50 -0400 Subject: [PATCH 25/51] vacuum wave is the kb from exdeath --- .../data/07-dt/ultimate/dancing_mad.ts | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index ee6d78966d0..17250ea0f6a 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -4,7 +4,7 @@ import { Responses } from '../../../../../resources/responses'; import Util, { DirectionOutputIntercard, Directions } from '../../../../../resources/util'; import ZoneId from '../../../../../resources/zone_id'; import { RaidbossData } from '../../../../../types/data'; -import { LocaleText, OutputStrings, TriggerSet } from '../../../../../types/trigger'; +import { OutputStrings, TriggerSet } from '../../../../../types/trigger'; // TODO: P3 Tailwind/Headwind resolution configuration options // TODO: P3 Verify number headmarker values @@ -2277,62 +2277,53 @@ const triggerSet: TriggerSet = { type: 'StartsUsing', netRegex: { id: 'BB13', source: 'Exdeath', capture: true }, infoText: (data, matches, output) => { - const chaosLocaleNames: LocaleText = { - en: 'Chaos', - de: 'Chaos', - fr: 'Chaos', - ja: 'カオス', - cn: '卡奥斯', - ko: '카오스', - tc: '卡奧斯', - }; - const chaosName = chaosLocaleNames[data.parserLang]; const windDir = data.windCrystalDir; - const knockback = output.knockbackFromChaos!({ chaos: chaosName }); const exdeath = matches.source; if (data.myWind === undefined) { + const knockback = output.knockbackFromExdeath!({ exdeath: exdeath }); if (windDir === undefined) - return output.knockbackFromChaosToCrystal!({ knockback: knockback }); - return output.knockbackFromChaosToDir!({ + return output.knockbackToCrystal!({ + knockback: knockback, + }); + return output.knockbackToDir!({ knockback: knockback, dir: output[windDir]!(), }); } + const knockbackFacing = output.knockbackFromFacingExdeath!({ + facing: output[data.myWind]!(), + exdeath: exdeath, + }); + if (windDir === undefined) - return output.knockbackFromChaosToWindFacing!({ - knockbackDir: output.knockbackFromChaosToCrystal!({ - knockback: knockback, - }), - facing: output[data.myWind]!({ name: exdeath }), + return output.knockbackToCrystal!({ + knockback: knockbackFacing, }); - return output.knockbackFromChaosToWindFacing!({ - knockbackDir: output.knockbackFromChaosToDir!({ - knockback: knockback, - dir: output[windDir]!(), - }), - facing: output[data.myWind]!({ name: exdeath }), + return output.knockbackToDir!({ + knockback: knockbackFacing, + dir: output[windDir]!(), }); }, outputStrings: { ...Directions.outputStringsIntercardDir, tail: { - en: 'Face ${name}', + en: 'Face', }, head: Outputs.lookAwayFromTarget, - knockbackFromChaos: { - en: 'Knockback from ${chaos}', + knockbackFromExdeath: { + en: 'Knockback from ${exdeath}', }, - knockbackFromChaosToDir: { + knockbackFromFacingExdeath: { + en: 'Knockback from + ${facing} ${exdeath}', + }, + knockbackToDir: { en: '${knockback} to ${dir}', }, - knockbackFromChaosToCrystal: { + knockbackToCrystal: { en: '${knockback} to Crystal', }, - knockbackFromChaosToWindFacing: { - en: '${knockbackDir} + ${facing}', - }, }, }, { From f1e1a7b526e0f0e475e0159481ab3b52483bd690 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Thu, 18 Jun 2026 22:19:32 -0400 Subject: [PATCH 26/51] lint --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 17250ea0f6a..90217516916 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -2292,10 +2292,10 @@ const triggerSet: TriggerSet = { }); } - const knockbackFacing = output.knockbackFromFacingExdeath!({ - facing: output[data.myWind]!(), - exdeath: exdeath, - }); + const knockbackFacing = output.knockbackFromFacingExdeath!({ + facing: output[data.myWind]!(), + exdeath: exdeath, + }); if (windDir === undefined) return output.knockbackToCrystal!({ From e60a8571cd39234e4e6301d265d0b5b4568a0b85 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Fri, 19 Jun 2026 00:06:42 -0400 Subject: [PATCH 27/51] boa fixes and lb3 config --- resources/util.ts | 1 + .../data/07-dt/ultimate/dancing_mad.ts | 160 ++++++++++++++---- 2 files changed, 125 insertions(+), 36 deletions(-) diff --git a/resources/util.ts b/resources/util.ts index 6e95ef6c2c4..403a0af0be9 100644 --- a/resources/util.ts +++ b/resources/util.ts @@ -402,6 +402,7 @@ export const Directions = { hdgTo4DirNum: hdgTo4DirNum, outputFrom8DirNum: outputFrom8DirNum, outputFromCardinalNum: outputFromCardinalNum, + outputFromIntercardNum: outputFromIntercardNum, combatantStatePosTo8Dir: ( combatant: PluginCombatantState, centerX: number, diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 90217516916..d9466d7db8c 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1,7 +1,7 @@ import Conditions from '../../../../../resources/conditions'; import Outputs from '../../../../../resources/outputs'; import { Responses } from '../../../../../resources/responses'; -import Util, { DirectionOutputIntercard, Directions } from '../../../../../resources/util'; +import Util, { Directions } from '../../../../../resources/util'; import ZoneId from '../../../../../resources/zone_id'; import { RaidbossData } from '../../../../../types/data'; import { OutputStrings, TriggerSet } from '../../../../../types/trigger'; @@ -29,6 +29,7 @@ const centerY = 100; export interface Data extends RaidbossData { readonly triggerSetConfig: { teleportent: 'clockwise' | 'filipino' | 'none'; + boa: 'lb3' | 'none'; blackhole: 'kefka' | 'none'; }; // General @@ -65,9 +66,9 @@ export interface Data extends RaidbossData { myWind?: 'head' | 'tail'; fireElementPlayers: string[]; waterElementPlayers: string[]; - fireCrystalDir?: DirectionOutputIntercard; - waterCrystalDir?: DirectionOutputIntercard; - windCrystalDir?: DirectionOutputIntercard; + fireCrystalDirNum?: number; + waterCrystalDirNum?: number; + windCrystalDirNum?: number; firstBlaster: number[]; firstBlasterDirNum?: number; blasterRotation?: number; @@ -339,6 +340,25 @@ const triggerSet: TriggerSet = { }, default: 'none', }, + { + id: 'boa', + comment: { + en: + `Tank LB3: Ranged players bait Short => Long Crystal, party resolves debuffs at Wind Crystal. Role stack the wind baits after Vacuum Wave
+ Default: Ranged DPS and/or Healers bait crystals, resolve Vacuum Wave into Wind Crystal`, + }, + name: { + en: 'P3 Bowels of Agony Strategy', + }, + type: 'select', + options: { + en: { + 'Tank LB3': 'lb3', + 'Generic calls': 'none', + }, + }, + default: 'none', + }, { id: 'blackhole', comment: { @@ -1824,14 +1844,14 @@ const triggerSet: TriggerSet = { const x = parseFloat(matches.pairPosX ?? '0'); const y = parseFloat(matches.pairPosY ?? '0'); const bnpcid = matches.pairBNpcID; - const dir = Directions.xyToIntercardDirOutput(x, y, centerX, centerY); + const dirNum = Directions.xyTo4DirIntercardNum(x, y, centerX, centerY); if (bnpcid === '1EC03A') - data.waterCrystalDir = dir; + data.waterCrystalDirNum = dirNum; else if (bnpcid === '1EC03B') - data.fireCrystalDir = dir; + data.fireCrystalDirNum = dirNum; else - data.windCrystalDir = dir; + data.windCrystalDirNum = dirNum; }, }, { @@ -1842,13 +1862,22 @@ const triggerSet: TriggerSet = { pair: [{ key: 'BNpcID', value: '1EC03C' }], capture: false, }, - delaySeconds: 2, // To prevent overlap with debuffs and time for collect - durationSeconds: 17, // Duration of the first debuff + delaySeconds: 3, // To prevent overlap with debuffs and time for collect + durationSeconds: 16, // Duration of the first debuff suppressSeconds: 1, infoText: (data, _matches, output) => { - const fireDir = data.fireCrystalDir ?? 'unknown'; - const waterDir = data.waterCrystalDir ?? 'unknown'; - const windDir = data.windCrystalDir ?? 'unknown'; + const fireDirNum = data.fireCrystalDirNum; + const waterDirNum = data.waterCrystalDirNum; + const windDirNum = data.windCrystalDirNum; + const fireDir = fireDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[fireDirNum] ?? 'unknown'; + const waterDir = waterDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[waterDirNum] ?? 'unknown'; + const windDir = windDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[windDirNum] ?? 'unknown'; const fShort = data.isFireShort; const fire = output.fire!({ dir: output[fireDir]!() }); @@ -1882,7 +1911,7 @@ const triggerSet: TriggerSet = { // Late goes off 2s after BAFF Shockwave type: 'GainsEffect', netRegex: { effectId: '640', capture: true }, - delaySeconds: (_data, matches) => parseFloat(matches.duration) - 5, // 7s after Lat/Long when Late + delaySeconds: (_data, matches) => parseFloat(matches.duration) - 4, // 6s after Lat/Long when Late suppressSeconds: 1, response: (data, _matches, output) => { // cactbot-builtin-response @@ -1897,7 +1926,7 @@ const triggerSet: TriggerSet = { fireOnPlayersCrystal: { en: '${spread}/${bait}', }, - fireOnPlayersCrystalDir: { + fireOnPlayersCrystalDirNum: { en: '${spread}/${dir} => ${bait}', }, fireOnPlayers: { @@ -1920,7 +1949,10 @@ const triggerSet: TriggerSet = { if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) return { [severity]: spread }; - const dir = data.fireCrystalDir; + const dirNum = data.fireCrystalDirNum; + const dir = dirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[dirNum] ?? 'unknown'; if (dir === undefined) return { [severity]: output.fireOnPlayersCrystal!({ @@ -1929,7 +1961,7 @@ const triggerSet: TriggerSet = { }), }; return { - [severity]: output.fireOnPlayersCrystalDir!({ + [severity]: output.fireOnPlayersCrystalDirNum!({ spread: spread, dir: output[dir]!(), bait: output.bait!(), @@ -1942,7 +1974,7 @@ const triggerSet: TriggerSet = { // Late goes off 2s after BAFF Shockwave type: 'GainsEffect', netRegex: { effectId: '641', capture: true }, - delaySeconds: (_data, matches) => parseFloat(matches.duration) - 5, // 7s after Lat/Long when Late + delaySeconds: (_data, matches) => parseFloat(matches.duration) - 4, // 6s after Lat/Long when Late suppressSeconds: 1, response: (data, _matches, output) => { // cactbot-builtin-response @@ -1957,7 +1989,7 @@ const triggerSet: TriggerSet = { waterOnPlayersCrystal: { en: '${donut}/${bait}', }, - waterOnPlayersCrystalDir: { + waterOnPlayersCrystalDirNum: { en: '${donut}/${dir} => ${bait}', }, waterOnPlayers: { @@ -1980,7 +2012,10 @@ const triggerSet: TriggerSet = { if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) return { [severity]: donut }; - const dir = data.waterCrystalDir; + const dirNum = data.waterCrystalDirNum; + const dir = dirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[dirNum] ?? 'unknown'; if (dir === undefined) return { [severity]: output.waterOnPlayersCrystal!({ @@ -1989,7 +2024,7 @@ const triggerSet: TriggerSet = { }), }; return { - [severity]: output.waterOnPlayersCrystalDir!({ + [severity]: output.waterOnPlayersCrystalDirNum!({ donut: donut, dir: output[dir]!(), bait: output.bait!(), @@ -2015,14 +2050,22 @@ const triggerSet: TriggerSet = { suppressSeconds: 99999, infoText: (data, _matches, output) => { const fShort = data.isFireShort; - const longCrystalDir = fShort ? data.waterCrystalDir : data.fireCrystalDir; - const longDir = longCrystalDir ?? 'unknown'; - const windDir = data.waterCrystalDir ?? 'unknown'; + const longCrystalDirNum = fShort + ? data.waterCrystalDirNum + : data.fireCrystalDirNum; + const windDirNum = data.windCrystalDirNum; + + const longDir = longCrystalDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[longCrystalDirNum] ?? 'unknown'; + const windDir = windDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[windDirNum] ?? 'unknown'; return output.crystals!({ long: fShort - ? output.fire!({ dir: output[longDir]!() }) - : output.water!({ dir: output[longDir]!() }), + ? output.water!({ dir: output[longDir]!() }) + : output.fire!({ dir: output[longDir]!() }), wind: output.wind!({ dir: output[windDir]!() }), }); }, @@ -2063,9 +2106,21 @@ const triggerSet: TriggerSet = { condition: (data) => data.windCrystalNext, suppressSeconds: 99999, infoText: (data, _matches, output) => { - const windDir = data.waterCrystalDir ?? 'unknown'; - - return output.wind!({ dir: output[windDir]!() }); + const windDirNum = data.windCrystalDirNum; + const config = data.triggerSetConfig.boa; + const windDir = windDirNum === undefined + ? 'unknown' + : config !== 'lb3' + ? Directions.outputIntercardDir[windDirNum] ?? 'unknown' + : data.role === 'healer' + ? Directions.outputIntercardDir[(windDirNum + 3) % 8] ?? 'unknown' // Wrap-around + : Util.isMeleeDpsJob(data.job) || data.role === 'tank' + ? Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown' // Opposite of Wind Crystal + : Directions.outputIntercardDir[(windDirNum + 1) % 8] ?? 'unknown'; // Ranged DPS + + return config !== 'lb3' + ? output.wind!({ dir: output[windDir]!() }) + : output.knockbackToDir!({ dir: output[windDir]!() }); }, outputStrings: { ...Directions.outputStringsIntercardDir, @@ -2073,6 +2128,9 @@ const triggerSet: TriggerSet = { wind: { en: 'Knockback to Wind ${dir} (later)', }, + knockbackToDir: { + en: 'Knockback to ${dir} (later)', + }, }, }, { @@ -2277,11 +2335,20 @@ const triggerSet: TriggerSet = { type: 'StartsUsing', netRegex: { id: 'BB13', source: 'Exdeath', capture: true }, infoText: (data, matches, output) => { - const windDir = data.windCrystalDir; + const windDirNum = data.windCrystalDirNum; + const windDir = windDirNum === undefined + ? 'unknown' + : data.triggerSetConfig.boa !== 'lb3' + ? Directions.outputIntercardDir[windDirNum] ?? 'unknown' + : data.role === 'healer' + ? Directions.outputIntercardDir[(windDirNum + 3) % 8] ?? 'unknown' // Wrap-around + : Util.isMeleeDpsJob(data.job) || data.role === 'tank' + ? Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown' // Opposite of Wind Crystal + : Directions.outputIntercardDir[(windDirNum + 1) % 8] ?? 'unknown'; // Ranged DPS const exdeath = matches.source; if (data.myWind === undefined) { - const knockback = output.knockbackFromExdeath!({ exdeath: exdeath }); + const knockback = output.knockbackFromExdeath!({ name: exdeath }); if (windDir === undefined) return output.knockbackToCrystal!({ knockback: knockback, @@ -2293,8 +2360,7 @@ const triggerSet: TriggerSet = { } const knockbackFacing = output.knockbackFromFacingExdeath!({ - facing: output[data.myWind]!(), - exdeath: exdeath, + facing: output[data.myWind]!({ name: exdeath }), }); if (windDir === undefined) @@ -2309,14 +2375,14 @@ const triggerSet: TriggerSet = { outputStrings: { ...Directions.outputStringsIntercardDir, tail: { - en: 'Face', + en: 'Face ${name}', }, head: Outputs.lookAwayFromTarget, knockbackFromExdeath: { - en: 'Knockback from ${exdeath}', + en: 'Knockback from ${name}', }, knockbackFromFacingExdeath: { - en: 'Knockback from + ${facing} ${exdeath}', + en: 'Knockback from + ${facing}', }, knockbackToDir: { en: '${knockback} to ${dir}', @@ -2326,6 +2392,28 @@ const triggerSet: TriggerSet = { }, }, }, + { + id: 'DMU P3 Vacuum Wave Tank LB3', + type: 'StartsUsing', + netRegex: { id: 'BB13', source: 'Exdeath', capture: true }, + condition: (data) => { + return data.role === 'tank' && + data.triggerSetConfig.boa === 'lb3'; + }, + delaySeconds: (_data, matches) => parseFloat(matches.castTime) - 2, // 8s castTime, damage expected 3.9s after cast + alarmText: (_data, _matches, output) => output.text!(), + outputStrings: { + text: { + en: 'TANK LB!!', + de: 'TANK LB!!', + fr: 'LB TANK !!', + ja: 'タンクLB!!', + cn: '坦克LB!!', + ko: '탱리밋!!', + tc: '坦克LB!!', + }, + }, + }, { id: 'DMU P1 Ultima Blaster Location', // Nearest inter-inter cardinal opposite that of first blaster From aed940bfe375f257a7e815d3de80c7afee1fb5a9 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Fri, 19 Jun 2026 00:10:31 -0400 Subject: [PATCH 28/51] lint --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index d9466d7db8c..9c4fb0a8cea 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -344,7 +344,7 @@ const triggerSet: TriggerSet = { id: 'boa', comment: { en: - `Tank LB3: Ranged players bait Short => Long Crystal, party resolves debuffs at Wind Crystal. Role stack the wind baits after Vacuum Wave
+ `Tank LB3: Ranged players bait Short => Long Crystal, party resolves debuffs at Wind Crystal. Role stack the wind baits after Vacuum Wave
Default: Ranged DPS and/or Healers bait crystals, resolve Vacuum Wave into Wind Crystal`, }, name: { @@ -2058,7 +2058,7 @@ const triggerSet: TriggerSet = { const longDir = longCrystalDirNum === undefined ? 'unknown' : Directions.outputIntercardDir[longCrystalDirNum] ?? 'unknown'; - const windDir = windDirNum === undefined + const windDir = windDirNum === undefined ? 'unknown' : Directions.outputIntercardDir[windDirNum] ?? 'unknown'; @@ -2108,7 +2108,7 @@ const triggerSet: TriggerSet = { infoText: (data, _matches, output) => { const windDirNum = data.windCrystalDirNum; const config = data.triggerSetConfig.boa; - const windDir = windDirNum === undefined + const windDir = windDirNum === undefined ? 'unknown' : config !== 'lb3' ? Directions.outputIntercardDir[windDirNum] ?? 'unknown' @@ -2117,7 +2117,7 @@ const triggerSet: TriggerSet = { : Util.isMeleeDpsJob(data.job) || data.role === 'tank' ? Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown' // Opposite of Wind Crystal : Directions.outputIntercardDir[(windDirNum + 1) % 8] ?? 'unknown'; // Ranged DPS - + return config !== 'lb3' ? output.wind!({ dir: output[windDir]!() }) : output.knockbackToDir!({ dir: output[windDir]!() }); @@ -2336,7 +2336,7 @@ const triggerSet: TriggerSet = { netRegex: { id: 'BB13', source: 'Exdeath', capture: true }, infoText: (data, matches, output) => { const windDirNum = data.windCrystalDirNum; - const windDir = windDirNum === undefined + const windDir = windDirNum === undefined ? 'unknown' : data.triggerSetConfig.boa !== 'lb3' ? Directions.outputIntercardDir[windDirNum] ?? 'unknown' From 9ab95aca0fa32f13f3d0bdc1b5dcb60118a354de Mon Sep 17 00:00:00 2001 From: Legends0 Date: Fri, 19 Jun 2026 07:42:47 -0400 Subject: [PATCH 29/51] add sg3k config I think sg3k may be the default? Im not sure on how else the mechanics are resolved other than maybe changing some of the priorities around. --- .../data/07-dt/ultimate/dancing_mad.ts | 638 +++++++++++++++--- 1 file changed, 539 insertions(+), 99 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 9c4fb0a8cea..8d71ffa7b1b 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -4,16 +4,15 @@ import { Responses } from '../../../../../resources/responses'; import Util, { Directions } from '../../../../../resources/util'; import ZoneId from '../../../../../resources/zone_id'; import { RaidbossData } from '../../../../../types/data'; -import { OutputStrings, TriggerSet } from '../../../../../types/trigger'; +import { LocaleText, OutputStrings, TriggerSet } from '../../../../../types/trigger'; -// TODO: P3 Tailwind/Headwind resolution configuration options // TODO: P3 Verify number headmarker values -// TODO: Earlier phase tracking for P5 (counting the jumps to middle?) // TODO: P3 Blackhole Directions // TODO: P3 Rework blackhole triggers for player that got hit to receive output over assuming they followed the plan? // TODO: P3 Blackhole number output replace with Blackhole set (currently it's Nothingness counter) -// TODO: P3 Better no-config support via debuff tracking? +// TODO: P3 Better Blackhole no-config support via debuff tracking? // TODO: P3 Aoe calls for Earthquake and/or some call for those with no tether during swaps? +// TODO: Earlier phase tracking for P5 (counting the jumps to middle?) type Phase = 'p1' | 'p2' | 'p3' | 'p4' | 'p5'; const phases: { [id: string]: Phase } = { @@ -29,7 +28,7 @@ const centerY = 100; export interface Data extends RaidbossData { readonly triggerSetConfig: { teleportent: 'clockwise' | 'filipino' | 'none'; - boa: 'lb3' | 'none'; + boa: 'lb3' | 'sg3k' | 'none'; blackhole: 'kefka' | 'none'; }; // General @@ -274,9 +273,97 @@ const trapOutputStrings: OutputStrings = { }, }; +const boaOutputStrings: OutputStrings = { + ...Directions.outputStringsIntercardDir, + in: Outputs.in, + out: Outputs.out, + moveExdeathAndChaosThenMech: { + en: 'Move ${exdeath} Middle / ${chaos} to ${dir} => ${mech}', + }, + moveExdeathThenMech: { + en: 'Move ${exdeath} to ${long} => ${mech}', + }, + crystals: { + en: '${short} => ${long} => ${wind} (later)', + }, + shortLongCrystals: { + en: '${short} => ${long}', + }, + crystalsMech: { + en: '${crystals}; ${mech}', + }, + fire: { + en: 'Fire ${dir}', + }, + water: { + en: 'Water ${dir}', + }, + wind: { + en: 'Wind ${dir}', + }, + tail: { + en: 'Face ${name}', + }, + head: Outputs.lookAwayFromTarget, + you: { + en: 'YOU', + }, + baitFireDonut: { + en: 'Bait Fire Donut', + }, + baitWaterAoe: { + en: 'Bait Water AOE', + }, + baitCrystal: { + en: 'Bait ${crystal} ${inout}', + }, + fireOnPlayersCrystalDirNum: { + en: '${spread}/${dir} => ${bait}', + }, + fireOnPlayers: { + en: 'Spread on ${players}', + }, + waterOnPlayersCrystalDirNum: { + en: '${donut}/${dir} => ${bait}', + }, + waterOnPlayers: { + en: 'Donut on ${players}', + }, + mechThenMech: { + en: '${mech1} => ${mech2}', + }, + getMiddleNearPlayer: { + en: 'Get Middle Near ${player}', + }, + getHitByDonut: Outputs.goIntoMiddle, + knockbackToDir: { + en: 'Knockback to ${dir} ${facing}', + }, + beNearWind: { + en: 'Be Near ${dir}', + }, + stackPartner: Outputs.stackPartner, + donutLater: { + en: 'Donut (later)', + }, + roleStacks: { + en: 'Role Stacks', + de: 'Rollengruppe sammeln', + fr: 'Package par rôle', + cn: '职能分摊', + ko: '역할별 쉐어', + tc: '職能分攤', + }, + beNearExdeath: { + en: 'Be Near ${name}', + }, + baitJump: { + en: 'Bait Jump', + }, +}; + const blackHoleOutputStrings: OutputStrings = { ...Directions.outputStringsCardinalDir, - unknown: Outputs.unknown, num: { en: '${num}: ', de: '${num}: ', @@ -345,7 +432,8 @@ const triggerSet: TriggerSet = { comment: { en: `Tank LB3: Ranged players bait Short => Long Crystal, party resolves debuffs at Wind Crystal. Role stack the wind baits after Vacuum Wave
- Default: Ranged DPS and/or Healers bait crystals, resolve Vacuum Wave into Wind Crystal`, + Entropy/Dynamic Fluid Bait (Default): Follows SG3K Raidplan: Entropy/Fluid bait their crystals and get hit by crystal's aoe
+ None: Only calls debuffs and locations`, }, name: { en: 'P3 Bowels of Agony Strategy', @@ -354,10 +442,11 @@ const triggerSet: TriggerSet = { options: { en: { 'Tank LB3': 'lb3', - 'Generic calls': 'none', + 'Entropy/Dynamic Fluid Bait': 'sg3k', + 'Generic Calls': 'none', }, }, - default: 'none', + default: 'sg3k', }, { id: 'blackhole', @@ -372,7 +461,7 @@ const triggerSet: TriggerSet = { options: { en: { 'Kefkabin': 'kefka', - 'Generic calls': 'none', + 'Generic Calls': 'none', }, }, default: 'none', @@ -1213,7 +1302,6 @@ const triggerSet: TriggerSet = { southwest: Outputs.southwest, west: Outputs.west, northwest: Outputs.northwest, - unknown: Outputs.unknown, upup: { en: 'Up Portents', ko: '위쪽 화살표', @@ -1736,7 +1824,6 @@ const triggerSet: TriggerSet = { // Chaos Tank needs to go between wind crystal and element with short timer type: 'GainsEffect', netRegex: { effectId: ['640', '641'], capture: true }, - condition: (data) => data.myElement === undefined, run: (data, matches) => { const id = matches.effectId; if (data.isFireShort === undefined) { @@ -1866,6 +1953,7 @@ const triggerSet: TriggerSet = { durationSeconds: 16, // Duration of the first debuff suppressSeconds: 1, infoText: (data, _matches, output) => { + const config = data.triggerSetConfig.boa; const fireDirNum = data.fireCrystalDirNum; const waterDirNum = data.waterCrystalDirNum; const windDirNum = data.windCrystalDirNum; @@ -1882,29 +1970,184 @@ const triggerSet: TriggerSet = { const fire = output.fire!({ dir: output[fireDir]!() }); const water = output.water!({ dir: output[waterDir]!() }); + const wind = output.wind!({ dir: output[windDir]!() }); + + if (config !== 'none') { + const myElement = data.myElement; + // Tank will need to first position Exdeath for Thunder III AOE + if (data.role === 'tank') { + const exdeathLocaleNames: LocaleText = { + en: 'Exdeath', + de: 'Exdeath', + fr: 'Exdeath', + ja: 'エクスデス', + cn: '艾克斯迪司', + ko: '엑스데스', + tc: '艾克斯迪司', + }; + const exdeathName = exdeathLocaleNames[data.parserLang]; + if (config === 'sg3k') { + if (myElement === 'fire') { + if (fShort) { + return output.moveExdeathThenMech!({ + exdeath: exdeathName, + long: water, + mech: output.baitCrystal!({ + crystal: fire, + inout: Outputs.in, + }), + }); + } + + // Get player expected to be inner water bait + const players = data.waterElementPlayers.filter( + (player) => data.party.isDPS(player), + ); + const player = data.party.member(players[0]); + + return output.moveExdeathThenMech!({ + exdeath: exdeathName, + long: fire, + mech: output.getMiddleNearPlayer!({ + player: player, + }), + }); + } + + if (myElement === 'water') { + if (fShort) + return output.moveExdeathThenMech!({ + exdeath: exdeathName, + long: water, + mech: output.getHitByDonut!(), + }); + + return output.moveExdeathThenMech!({ + exdeath: exdeathName, + long: fire, + mech: output.baitCrystal!({ + crystal: water, + inout: Outputs.in, + }), + }); + } + } + + // LB3 Config + const chaosLocaleNames: LocaleText = { + en: 'Chaos', + de: 'Chaos', + fr: 'Chaos', + ja: 'カオス', + cn: '卡奥斯', + ko: '카오스', + tc: '卡奧斯', + }; + const chaosName = chaosLocaleNames[data.parserLang]; + return output.moveExdeathAndChaosThenMech!({ + exdeath: exdeathName, + chaos: chaosName, + dir: wind, + mech: output.beNearWind!({ + dir: wind, + }), + }); + } + + if (config === 'sg3k') { + if (myElement === 'fire') { + if (fShort) + return output.crystalsMech!({ + crystals: output.shortLongCrystals!({ + short: fire, + long: water, + }), + mech: output.baitCrystal!({ + crystal: fire, + inout: data.role === 'dps' ? output.in!() : output.out!(), + }), + }); + + // Get player expected to be inner water bait + const players = data.waterElementPlayers.filter( + (player) => data.party.isDPS(player), + ); + const player = data.party.member(players[0]); + + return output.crystalsMech!({ + crystals: output.shortLongCrystals!({ + short: water, + long: fire, + }), + mech: output.getMiddleNearPlayer!({ + player: player, + }), + }); + } + + if (myElement === 'water') { + if (fShort) + return output.crystalsMech!({ + crystals: output.shortLongCrystals!({ + short: fire, + long: water, + }), + mech: output.getHitByDonut!(), + }); + return output.crystalsMech!({ + crystals: output.shortLongCrystals!({ + short: water, + long: fire, + }), + mech: output.baitCrystal!({ + crystal: water, + inout: data.role === 'dps' ? output.in!() : output.out!(), + }), + }); + } + return output.crystalsMech!({ + crystals: output.shortLongCrystals!({ + short: fShort ? fire : water, + long: fShort ? water : fire, + }), + mech: output.beNearWind!({ + dir: wind, + }), + }); + } + + // LB3 Config + if (data.role !== 'dps' || Util.isMeleeDpsJob(data.job)) { + return output.crystalsMech!({ + crystals: output.shortLongCrystals!({ + short: fShort ? fire : water, + long: fShort ? water : fire, + }), + mech: output.beNearWind!({ + dir: wind, + }), + }); + } + // Ranged DPS Bait + return output.crystalsMech!({ + crystals: output.shortLongCrystals!({ + short: fShort ? fire : water, + long: fShort ? water : fire, + }), + mech: output.baitCrystal!({ + crystal: fShort ? fire : water, + inout: output.out!(), + }), + }); + } return output.crystals!({ short: fShort ? fire : water, long: fShort ? water : fire, - wind: output.wind!({ dir: output[windDir]!() }), + wind: wind, }); }, - outputStrings: { - ...Directions.outputStringsIntercardDir, - unknown: Outputs.unknown, - fire: { - en: 'Fire ${dir}', - }, - water: { - en: 'Water ${dir}', - }, - wind: { - en: 'Wind ${dir}', - }, - crystals: { - en: '${short} => ${long} => ${wind} (later)', - }, - }, + outputStrings: boaOutputStrings, }, { id: 'DMU P3 Entropy and Fire Crystal', @@ -1915,26 +2158,28 @@ const triggerSet: TriggerSet = { suppressSeconds: 1, response: (data, _matches, output) => { // cactbot-builtin-response - output.responseOutputStrings = { - ...Directions.outputStringsIntercardDir, - you: { - en: 'YOU', - }, - bait: { - en: 'Bait Fire Donut', - }, - fireOnPlayersCrystal: { - en: '${spread}/${bait}', - }, - fireOnPlayersCrystalDirNum: { - en: '${spread}/${dir} => ${bait}', - }, - fireOnPlayers: { - en: 'Spread on ${players}', - }, - }; + output.responseOutputStrings = boaOutputStrings; + const config = data.triggerSetConfig.boa; + const fireDirNum = data.fireCrystalDirNum; + const waterDirNum = data.waterCrystalDirNum; + const windDirNum = data.windCrystalDirNum; + const fireDir = fireDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[fireDirNum] ?? 'unknown'; + const waterDir = waterDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[waterDirNum] ?? 'unknown'; + const windDir = windDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[windDirNum] ?? 'unknown'; + const fShort = data.isFireShort; + const myElement = data.myElement; + const myWind = data.myWind; + + const fire = output.fire!({ dir: output[fireDir]!() }); + const water = output.water!({ dir: output[waterDir]!() }); + const wind = output.wind!({ dir: output[windDir]!() }); - const severity = data.myElement === 'fire' ? 'alertText' : 'infoText'; const players = data.fireElementPlayers.map( (player) => { if (player === data.me) @@ -1945,26 +2190,121 @@ const triggerSet: TriggerSet = { const msg = players?.join(', '); const spread = output.fireOnPlayers!({ players: msg }); - // Tanks and Melee aren't expected to bait crystals, so shorten output - if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) - return { [severity]: spread }; + const isRangedDPS = Util.isRangedDpsJob(data.job) || Util.isCasterDpsJob(data.job); + const severity = config === 'lb3' && isRangedDPS + ? 'alertText' + : myElement === 'fire' + ? 'alertText' + : 'infoText'; + + if (fShort) { + if (config === 'lb3') { + const isNotRanged = data.role !== 'dps' || Util.isMeleeDpsJob(data.job); + return { + [severity]: output.mechThenMech!({ + mech1: isNotRanged ? spread : output.baitCrystal!({ + crystal: fire, + inout: output.out!(), + }), + mech2: isNotRanged + ? output.donutLater!() + : output.baitCrystal!({ + crystal: water, + inout: output.out!(), + }), + }), + }; + } + + if (config === 'sg3k') { + // Get player expected to be inner water bait + const players = data.waterElementPlayers.filter( + (player) => data.party.isDPS(player), + ); + const player = data.party.member(players[0]); - const dirNum = data.fireCrystalDirNum; - const dir = dirNum === undefined + return { + [severity]: output.mechThenMech!({ + mech1: myElement === 'fire' + ? output.baitCrystal!({ + crystal: fire, + inout: data.role === 'dps' ? output.in!() : output.out!(), + }) + : myElement === 'water' + ? output.getHitByDonut!() + : output.beNearWind!({ dir: wind }), + mech2: myElement === 'fire' + ? output.getMiddleNearPlayer!({ + player: player, + }) + : myElement === 'water' + ? output.knockbackToDir!({ + facing: output[myWind ?? 'unknown']!({ name: output[fireDir]!() }), + dir: output[waterDir]!(), + }) + : output.stackPartner!(), + }), + }; + } + } + const exdeathLocaleNames: LocaleText = { + en: 'Exdeath', + de: 'Exdeath', + fr: 'Exdeath', + ja: 'エクスデス', + cn: '艾克斯迪司', + ko: '엑스데스', + tc: '艾克斯迪司', + }; + const exdeathName = exdeathLocaleNames[data.parserLang]; + if (config === 'lb3') { + const isNotRanged = data.role !== 'dps' || Util.isMeleeDpsJob(data.job); + return { + [severity]: output.mechThenMech!({ + mech1: isNotRanged ? spread : output.baitCrystal!({ + crystal: fire, + inout: output.out!(), + }), + mech2: Util.isRangedDpsJob(data.job) + ? output.baitJump!() + : output.beNearExdeath!({ name: exdeathName }), + }), + }; + } + + if (config === 'sg3k') { + // Players will need to get to opposite side of Wind Crystal + const exDeathDir = windDirNum === undefined ? 'unknown' - : Directions.outputIntercardDir[dirNum] ?? 'unknown'; - if (dir === undefined) + : Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown'; return { - [severity]: output.fireOnPlayersCrystal!({ - spread: spread, - bait: output.bait!(), + [severity]: output.mechThenMech!({ + mech1: myElement === 'fire' + ? output.baitCrystal!({ + crystal: fire, + inout: data.role === 'dps' ? output.in!() : output.out!(), + }) + : myElement === 'water' + ? output.getHitByDonut!() + : output.beNearWind!({ dir: wind }), + mech2: myElement === 'fire' + ? output.beNearExdeath!({ name: exdeathName }) + : myElement === 'water' + ? output.knockbackToDir!({ + facing: output[myWind ?? 'unknown']!({ + name: output[fireDir]!(), + }), + dir: output[exDeathDir]!(), + }) + : output.stackPartner!(), }), }; + } return { [severity]: output.fireOnPlayersCrystalDirNum!({ spread: spread, - dir: output[dir]!(), - bait: output.bait!(), + dir: fire, + bait: output.baitFireDonut!(), }), }; }, @@ -1978,26 +2318,35 @@ const triggerSet: TriggerSet = { suppressSeconds: 1, response: (data, _matches, output) => { // cactbot-builtin-response - output.responseOutputStrings = { - ...Directions.outputStringsIntercardDir, - you: { - en: 'YOU', - }, - bait: { - en: 'Bait Water AOE', - }, - waterOnPlayersCrystal: { - en: '${donut}/${bait}', - }, - waterOnPlayersCrystalDirNum: { - en: '${donut}/${dir} => ${bait}', - }, - waterOnPlayers: { - en: 'Donut on ${players}', - }, - }; + output.responseOutputStrings = boaOutputStrings; + const config = data.triggerSetConfig.boa; + const fireDirNum = data.fireCrystalDirNum; + const waterDirNum = data.waterCrystalDirNum; + const windDirNum = data.windCrystalDirNum; + const fireDir = fireDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[fireDirNum] ?? 'unknown'; + const waterDir = waterDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[waterDirNum] ?? 'unknown'; + const windDir = windDirNum === undefined + ? 'unknown' + : Directions.outputIntercardDir[windDirNum] ?? 'unknown'; + const fShort = data.isFireShort; + const myElement = data.myElement; + const myWind = data.myWind; + + const fire = output.fire!({ dir: output[fireDir]!() }); + const water = output.water!({ dir: output[waterDir]!() }); + const wind = output.wind!({ dir: output[windDir]!() }); + + const isRangedDPS = Util.isRangedDpsJob(data.job) || Util.isCasterDpsJob(data.job); + const severity = config === 'lb3' && isRangedDPS + ? 'alertText' + : myElement === 'water' + ? 'alertText' + : 'infoText'; - const severity = data.myElement === 'water' ? 'alertText' : 'infoText'; const players = data.waterElementPlayers.map( (player) => { if (player === data.me) @@ -2008,26 +2357,122 @@ const triggerSet: TriggerSet = { const msg = players?.join(', '); const donut = output.waterOnPlayers!({ players: msg }); - // Tanks and Melee aren't expected to bait crystals, so shorten output - if (data.role === 'tank' || Util.isMeleeDpsJob(data.job)) - return { [severity]: donut }; + if (!fShort) { + if (config === 'lb3') { + const isNotRanged = data.role !== 'dps' || Util.isMeleeDpsJob(data.job); + return { + [severity]: output.mechThenMech!({ + mech1: isNotRanged ? donut : output.baitCrystal!({ + crystal: water, + inout: output.out!(), + }), + mech2: isNotRanged + ? output.roleStacks!() + : output.baitCrystal!({ + crystal: fire, + inout: output.out!(), + }), + }), + }; + } + + if (config === 'sg3k') { + // Get player expected to be inner water bait + const players = data.waterElementPlayers.filter( + (player) => data.party.isDPS(player), + ); + const player = data.party.member(players[0]); - const dirNum = data.waterCrystalDirNum; - const dir = dirNum === undefined + return { + [severity]: output.mechThenMech!({ + mech1: myElement === 'fire' + ? output.getMiddleNearPlayer!({ + player: player, + }) + : myElement === 'water' + ? output.baitCrystal!({ + crystal: water, + inout: data.role === 'dps' ? output.in!() : output.out!(), + }) + : output.beNearWind!({ dir: wind }), + mech2: myElement === 'fire' + ? output.knockbackToDir!({ + facing: output[myWind ?? 'unknown']!({ name: output[waterDir]!() }), + dir: output[fireDir]!(), + }) + : myElement === 'water' + ? output.getHitByDonut!() + : output.stackPartner!(), + }), + }; + } + } + const exdeathLocaleNames: LocaleText = { + en: 'Exdeath', + de: 'Exdeath', + fr: 'Exdeath', + ja: 'エクスデス', + cn: '艾克斯迪司', + ko: '엑스데스', + tc: '艾克斯迪司', + }; + const exdeathName = exdeathLocaleNames[data.parserLang]; + if (config === 'lb3') { + const isNotRanged = data.role !== 'dps' || Util.isMeleeDpsJob(data.job); + return { + [severity]: output.mechThenMech!({ + mech1: isNotRanged ? donut : output.baitCrystal!({ + crystal: water, + inout: output.out!(), + }), + mech2: Util.isRangedDpsJob(data.job) + ? output.baitJump!() + : output.beNearExdeath!({ name: exdeathName }), + }), + }; + } + + if (config === 'sg3k') { + // Get player expected to be inner water bait + const players = data.waterElementPlayers.filter( + (player) => data.party.isDPS(player), + ); + const player = data.party.member(players[0]); + // Players will need to get to opposite side of Wind Crystal + const exDeathDir = windDirNum === undefined ? 'unknown' - : Directions.outputIntercardDir[dirNum] ?? 'unknown'; - if (dir === undefined) + : Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown'; return { - [severity]: output.waterOnPlayersCrystal!({ - donut: donut, - bait: output.bait!(), + [severity]: output.mechThenMech!({ + mech1: myElement === 'fire' + ? output.getMiddleNearPlayer!({ + player: player, + }) + : myElement === 'water' + ? output.baitCrystal!({ + crystal: water, + inout: data.role === 'dps' ? output.in!() : output.out!(), + }) + : output.beNearWind!({ dir: wind }), + mech2: myElement === 'fire' + ? output.beNearExdeath!({ name: exdeathName }) + : myElement === 'water' + ? output.knockbackToDir!({ + facing: output[myWind ?? 'unknown']!({ + name: output[waterDir]!(), + }), + dir: output[exDeathDir]!(), + }) + : output.stackPartner!(), }), }; + } + return { [severity]: output.waterOnPlayersCrystalDirNum!({ donut: donut, - dir: output[dir]!(), - bait: output.bait!(), + dir: water, + bait: output.baitWaterAoe!(), }), }; }, @@ -2071,7 +2516,6 @@ const triggerSet: TriggerSet = { }, outputStrings: { ...Directions.outputStringsIntercardDir, - unknown: Outputs.unknown, fire: { en: 'Fire ${dir}', }, @@ -2124,7 +2568,6 @@ const triggerSet: TriggerSet = { }, outputStrings: { ...Directions.outputStringsIntercardDir, - unknown: Outputs.unknown, wind: { en: 'Knockback to Wind ${dir} (later)', }, @@ -2298,7 +2741,6 @@ const triggerSet: TriggerSet = { }, outputStrings: { ...Directions.outputStrings8Dir, - unknown: Outputs.unknown, clockwise: { en: '<== ${card} Clockwise (Later)', }, @@ -2317,9 +2759,9 @@ const triggerSet: TriggerSet = { netRegex: { id: ['BAFD', 'BAFE'], source: 'Chaos', capture: false }, delaySeconds: 10, suppressSeconds: 99999, - infoText: (_data, _matches, output) => output.bait!(), + infoText: (_data, _matches, output) => output.baitJump!(), outputStrings: { - bait: { + baitJump: { en: 'Bait Jump', }, }, @@ -2334,7 +2776,7 @@ const triggerSet: TriggerSet = { // Party can Tank LB3 to survive stacking the winds type: 'StartsUsing', netRegex: { id: 'BB13', source: 'Exdeath', capture: true }, - infoText: (data, matches, output) => { + alertText: (data, matches, output) => { const windDirNum = data.windCrystalDirNum; const windDir = windDirNum === undefined ? 'unknown' @@ -2470,7 +2912,6 @@ const triggerSet: TriggerSet = { }, outputStrings: { ...Directions.outputStrings16Dir, - unknown: Outputs.unknown, num: { en: '#${num}', de: '#${num}', @@ -2674,7 +3115,6 @@ const triggerSet: TriggerSet = { }, outputStrings: { ...Directions.outputStrings8Dir, - unknown: Outputs.unknown, outOfMiddle: { en: 'Out Of Middle', de: 'Raus aus der Mitte', From 08eb459a226d4efce6032f1b4576a697c6ea3ff2 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Fri, 19 Jun 2026 07:47:40 -0400 Subject: [PATCH 30/51] lint --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 8d71ffa7b1b..457acec4e7d 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -321,7 +321,7 @@ const boaOutputStrings: OutputStrings = { en: '${spread}/${dir} => ${bait}', }, fireOnPlayers: { - en: 'Spread on ${players}', + en: 'Spread on ${players}', }, waterOnPlayersCrystalDirNum: { en: '${donut}/${dir} => ${bait}', @@ -2094,7 +2094,7 @@ const triggerSet: TriggerSet = { }), mech: output.getHitByDonut!(), }); - return output.crystalsMech!({ + return output.crystalsMech!({ crystals: output.shortLongCrystals!({ short: water, long: fire, @@ -2275,8 +2275,8 @@ const triggerSet: TriggerSet = { if (config === 'sg3k') { // Players will need to get to opposite side of Wind Crystal const exDeathDir = windDirNum === undefined - ? 'unknown' - : Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown'; + ? 'unknown' + : Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown'; return { [severity]: output.mechThenMech!({ mech1: myElement === 'fire' @@ -2440,8 +2440,8 @@ const triggerSet: TriggerSet = { const player = data.party.member(players[0]); // Players will need to get to opposite side of Wind Crystal const exDeathDir = windDirNum === undefined - ? 'unknown' - : Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown'; + ? 'unknown' + : Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown'; return { [severity]: output.mechThenMech!({ mech1: myElement === 'fire' From eccb1b85cf87cd929bf70c6861222a73932bcd29 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Fri, 19 Jun 2026 20:53:59 -0400 Subject: [PATCH 31/51] flip fire/water --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 4d632b20ad3..34aa71cd032 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -1925,8 +1925,8 @@ const triggerSet: TriggerSet = { // First set spawns at intercardinals // Wind will be inbetween Fire and Water // The following are their BNpcIDs: - // 1EC03A => Water (Blue Square) Crystal - // 1EC03B => Fire (Red Triangle) Crystal + // 1EC03A => Fire (Red Triangle) Crystal + // 1EC03B => Water (Blue Square) Crystal // 1EC03C => Wind (Green Diamond) Crystal // // Later the Earth Crystal will spawn in the center @@ -1945,9 +1945,9 @@ const triggerSet: TriggerSet = { const dirNum = Directions.xyTo4DirIntercardNum(x, y, centerX, centerY); if (bnpcid === '1EC03A') - data.waterCrystalDirNum = dirNum; - else if (bnpcid === '1EC03B') data.fireCrystalDirNum = dirNum; + else if (bnpcid === '1EC03B') + data.waterCrystalDirNum = dirNum; else data.windCrystalDirNum = dirNum; }, From bc4d69310ec0d3eb27af98aa40c127c5e368c549 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Fri, 19 Jun 2026 22:22:30 -0400 Subject: [PATCH 32/51] fix mod 8 => 4 --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 34aa71cd032..398535aa9dc 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -2287,7 +2287,7 @@ const triggerSet: TriggerSet = { // Players will need to get to opposite side of Wind Crystal const exDeathDir = windDirNum === undefined ? 'unknown' - : Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown'; + : Directions.outputIntercardDir[(windDirNum + 2) % 4] ?? 'unknown'; return { [severity]: output.mechThenMech!({ mech1: myElement === 'fire' @@ -2452,7 +2452,7 @@ const triggerSet: TriggerSet = { // Players will need to get to opposite side of Wind Crystal const exDeathDir = windDirNum === undefined ? 'unknown' - : Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown'; + : Directions.outputIntercardDir[(windDirNum + 2) % 4] ?? 'unknown'; return { [severity]: output.mechThenMech!({ mech1: myElement === 'fire' @@ -2568,10 +2568,10 @@ const triggerSet: TriggerSet = { : config !== 'lb3' ? Directions.outputIntercardDir[windDirNum] ?? 'unknown' : data.role === 'healer' - ? Directions.outputIntercardDir[(windDirNum + 3) % 8] ?? 'unknown' // Wrap-around + ? Directions.outputIntercardDir[(windDirNum + 3) % 4] ?? 'unknown' // Wrap-around : Util.isMeleeDpsJob(data.job) || data.role === 'tank' - ? Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown' // Opposite of Wind Crystal - : Directions.outputIntercardDir[(windDirNum + 1) % 8] ?? 'unknown'; // Ranged DPS + ? Directions.outputIntercardDir[(windDirNum + 2) % 4] ?? 'unknown' // Opposite of Wind Crystal + : Directions.outputIntercardDir[(windDirNum + 1) % 4] ?? 'unknown'; // Ranged DPS return config !== 'lb3' ? output.wind!({ dir: output[windDir]!() }) @@ -2794,10 +2794,10 @@ const triggerSet: TriggerSet = { : data.triggerSetConfig.boa !== 'lb3' ? Directions.outputIntercardDir[windDirNum] ?? 'unknown' : data.role === 'healer' - ? Directions.outputIntercardDir[(windDirNum + 3) % 8] ?? 'unknown' // Wrap-around + ? Directions.outputIntercardDir[(windDirNum + 3) % 4] ?? 'unknown' // Wrap-around : Util.isMeleeDpsJob(data.job) || data.role === 'tank' - ? Directions.outputIntercardDir[(windDirNum + 2) % 8] ?? 'unknown' // Opposite of Wind Crystal - : Directions.outputIntercardDir[(windDirNum + 1) % 8] ?? 'unknown'; // Ranged DPS + ? Directions.outputIntercardDir[(windDirNum + 2) % 4] ?? 'unknown' // Opposite of Wind Crystal + : Directions.outputIntercardDir[(windDirNum + 1) % 4] ?? 'unknown'; // Ranged DPS const exdeath = matches.source; if (data.myWind === undefined) { From b27ec6deea78be9a9665e4938546414fa4eaae29 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sat, 20 Jun 2026 02:10:18 -0400 Subject: [PATCH 33/51] fix ultima blaster location calls --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 398535aa9dc..e56944a6992 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -2701,6 +2701,7 @@ const triggerSet: TriggerSet = { id: 'DMU P3 Ultima Blaster Collect', // Starts from random cardinal/intercardinal then rotates either CW or CCW // These are raidwide AOEs, but also include telegraphed lines and explosions + // TODO: Verify the this is correct // Ability lines can have erroneous values // Entity that does these has BNpcID 4BFB, added shortly before // 271 ActorSetPos and 261 CombatantMemory Change lines are updated just prior to the ability @@ -2743,6 +2744,7 @@ const triggerSet: TriggerSet = { if (rotation === undefined || dirNum === undefined) return; + // Will need 16Dir for positions later const dir = Directions.output8Dir[dirNum] ?? 'unknown'; if (rotation < 0) @@ -2897,22 +2899,24 @@ const triggerSet: TriggerSet = { '01B7': 7, '01B8': 8, }; - const blaster = data.firstBlasterDirNum; + const blasterDirNum = data.firstBlasterDirNum; const rotation = data.blasterRotation; const id = matches.id; const myNum = blasterNumberMap[id]; if (myNum === undefined) return; - if (blaster === undefined || rotation === undefined || rotation === 0) + if (blasterDirNum === undefined || rotation === undefined || rotation === 0) return output.num!({ num: myNum }); - // Convert 8Dir to 16Dir - const blaster16Dir = blaster * 2; + // Subtract 1 from ourself as 1 is 0th position + const adjNum = (myNum - 1) * 2; // Convert our number to 16Dir format + const adjBlaster = blasterDirNum * 2; // Convert blasterDirNum to 16Dir format + // Boss is at an intercard, so +1 or -1 to get inter-inter safe spot const adjustedDirNum = rotation < 0 - ? (myNum + blaster16Dir) % 16 // Clockwise - : (myNum - blaster16Dir + 16) % 16; // Counterclock + ? (adjNum + adjBlaster + 1) % 16 // Clockwise + : ((adjBlaster- 1 - adjNum) + 16) % 16; // Counterclock // Find inter-inter cardinal const safeDir = Directions.output16Dir[adjustedDirNum] ?? 'unknown'; From c435a87a43b8f5d344e63eb6b2acf3a705a531bb Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sat, 20 Jun 2026 02:21:26 -0400 Subject: [PATCH 34/51] fix accretion 1 call --- .../data/07-dt/ultimate/dancing_mad.ts | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index e56944a6992..3c15c5b99ef 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -6,7 +6,6 @@ import ZoneId from '../../../../../resources/zone_id'; import { RaidbossData } from '../../../../../types/data'; import { LocaleText, OutputStrings, TriggerSet } from '../../../../../types/trigger'; -// TODO: P3 Verify number headmarker values // TODO: P3 Blackhole Directions // TODO: P3 Rework blackhole triggers for player that got hit to receive output over assuming they followed the plan? // TODO: P3 Blackhole number output replace with Blackhole set (currently it's Nothingness counter) @@ -2701,7 +2700,6 @@ const triggerSet: TriggerSet = { id: 'DMU P3 Ultima Blaster Collect', // Starts from random cardinal/intercardinal then rotates either CW or CCW // These are raidwide AOEs, but also include telegraphed lines and explosions - // TODO: Verify the this is correct // Ability lines can have erroneous values // Entity that does these has BNpcID 4BFB, added shortly before // 271 ActorSetPos and 261 CombatantMemory Change lines are updated just prior to the ability @@ -2991,10 +2989,10 @@ const triggerSet: TriggerSet = { }, }, { - id: 'DMU P3 In Line Debuff', + id: 'DMU P3 In Line Debuff + Accretion 1', type: 'GainsEffect', netRegex: { effectId: ['BBC', 'BBD', 'BBE'], capture: false }, - delaySeconds: 0.1, + delaySeconds: 0.2, durationSeconds: 5, suppressSeconds: 1, infoText: (data, _matches, output) => { @@ -3064,6 +3062,7 @@ const triggerSet: TriggerSet = { { id: 'DMU P3 Accretion 2', // Cleansing 644 Accretion or 154E Primordial Crust triggers BAFA Earthquake + // on all but the player that had Accretion // BAFA Earthquake targets receive D2C Earth Resistance Down II (1.96s) // Utilizing D2C Earth Resistance Down II to call for healing next player // NOTE: This will still trigger if 154E Primordial Crust is cleansed early @@ -3099,6 +3098,18 @@ const triggerSet: TriggerSet = { }; }, }, + { + id: 'DMU P3 Slap Happy Boss Teleport Collect', + // TODO: Get earlier infoText call + // This could be necessary to call which black holes to grab later + type: 'StartsUsing', + netRegex: { id: ['BAE6', 'BAE7'], source: 'Kefka', capture: true }, + run: (data, matches) => { + const x = parseFloat(matches.x); + const y = parseFloat(matches.y); + data.kefkaTeleportDirNum = Directions.xyTo8DirNum(x, y, centerX, centerY); + }, + }, { id: 'DMU P3 Slap Happy', // TODO: Get boss location on teleport (could adjust call to be a direction of the slaps 1-3) @@ -3228,7 +3239,7 @@ const triggerSet: TriggerSet = { return { alertText: output.takeDirTetherClockwise!({ num: data.blackHoleSet, - dir1: output[dir]!(), + dir: output[dir]!(), }), }; return { From f55553e29a5409e67359f12cd82714fe8eb33b9d Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sat, 20 Jun 2026 02:30:39 -0400 Subject: [PATCH 35/51] prevent output if accretions cleansed during delay --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 3c15c5b99ef..bc9c156958f 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -3086,9 +3086,18 @@ const triggerSet: TriggerSet = { tc: '奶滿${player}', }, }; - const player = data.firstAccretion !== undefined - ? data.firstAccretion - : data.secondAccretion; + const first = data.firstAccretion; + const second = data.secondAccretion; + const player = first !== undefined + ? first + : second !== undefined + ? second + : undefined; + + // This happens if players get cleansed within the delaySeconds, likely causing a wipe + if (player === undefined) + return; + const severity = data.role === 'healer' ? 'alertText' : 'infoText'; return { From dccb8a6e3e5ffce4957e765db5c54032429c63f0 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sat, 20 Jun 2026 02:47:36 -0400 Subject: [PATCH 36/51] lint --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index bc9c156958f..d35221d2de0 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -2914,7 +2914,7 @@ const triggerSet: TriggerSet = { // Boss is at an intercard, so +1 or -1 to get inter-inter safe spot const adjustedDirNum = rotation < 0 ? (adjNum + adjBlaster + 1) % 16 // Clockwise - : ((adjBlaster- 1 - adjNum) + 16) % 16; // Counterclock + : ((adjBlaster - 1 - adjNum) + 16) % 16; // Counterclock // Find inter-inter cardinal const safeDir = Directions.output16Dir[adjustedDirNum] ?? 'unknown'; From 741cfc03c57c372eb27d982b374d0444797075c9 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Sun, 21 Jun 2026 23:37:51 -0400 Subject: [PATCH 37/51] fix slap happy call, black hole tracking+single tethers --- .../data/07-dt/ultimate/dancing_mad.ts | 146 +++++++++++++----- 1 file changed, 104 insertions(+), 42 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index d35221d2de0..f53ddfb3b0f 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -74,10 +74,10 @@ export interface Data extends RaidbossData { firstAccretion?: string; secondAccretion?: string; hadAccretion: boolean; + blackHoleIdDirNums: { [id: string]: number }; kefkaTeleportDirNum?: number; - blackHoleSet: number; // To be replaced? nothingnessCount: number; - // blackHoleDirNums: string[]; + blackHoleTetherDirNums: number[]; } const headMarkerData = { @@ -488,9 +488,9 @@ const triggerSet: TriggerSet = { firstBlaster: [], inLine: {}, hadAccretion: false, - blackHoleSet: 0, // To be replaced? + blackHoleIdDirNums: {}, nothingnessCount: 0, - // blackHoleDirNums: [], + blackHoleTetherDirNums: []; }; }, triggers: [ @@ -3109,19 +3109,20 @@ const triggerSet: TriggerSet = { }, { id: 'DMU P3 Slap Happy Boss Teleport Collect', + // Boss' position data is (100, 100), but heading does update ~2.5s before cast + // 4 Invisible entities via 03 AddCombatant log lines correlate to the slap AoEs + // spawn at time of StartsUsing. These are also ordered in the order they occur. // TODO: Get earlier infoText call // This could be necessary to call which black holes to grab later type: 'StartsUsing', netRegex: { id: ['BAE6', 'BAE7'], source: 'Kefka', capture: true }, run: (data, matches) => { - const x = parseFloat(matches.x); - const y = parseFloat(matches.y); - data.kefkaTeleportDirNum = Directions.xyTo8DirNum(x, y, centerX, centerY); + const heading = parseFloat(matches.heading); + data.kefkaTeleportDirNum = Directions.hdgTo8DirNum(heading); }, }, { id: 'DMU P3 Slap Happy', - // TODO: Get boss location on teleport (could adjust call to be a direction of the slaps 1-3) // BAE6 Slap Happy: Boss slaps his right 3 times (party cleave) + left once // BAE7 Slap Happy: Boss slaps his left 3 times (role cleaves) + right once // Boss can be in different cardinal/intercardinals @@ -3129,11 +3130,10 @@ const triggerSet: TriggerSet = { netRegex: { id: ['BAE6', 'BAE7'], source: 'Kefka', capture: true }, alertText: (_data, matches, output) => { const id = matches.id; - const x = parseFloat(matches.x); - const y = parseFloat(matches.y); - const bossDirNum = Directions.xyTo8DirNum(x, y, centerX, centerY); - const clockDirNum = (bossDirNum + 2) % 8; - const counterDirNum = (bossDirNum + 6) % 8; // Wrap-around + const heading = parseFloat(matches.heading); + const bossDirNum = Directions.hdgTo8DirNum(heading); + const clockDirNum = (bossDirNum + 6) % 8; // Wrap-around + const counterDirNum = (bossDirNum + 2) % 8; const clockDir = Directions.output8Dir[clockDirNum] ?? 'unknown'; const counterDir = Directions.output8Dir[counterDirNum] ?? 'unknown'; @@ -3181,6 +3181,35 @@ const triggerSet: TriggerSet = { }, }, }, + { + id: 'DMU P3 Black Hole Tracker', + // 11 are added with 0.2s of BAFB Black Hole Ability + // Both 261 CombatantMemory Add and 03 AddCombatant are available + // There are also 272 NPCSpawnExtra lines + // These have BNpcID 4C38 + // They should only spawn at cardinals + // Interestingly, they have differing headings as well + // Spawn Location Example: + // (100, 83) + // (94.83, 87.53) + // (93.64, 93.64) (106.36, 93.64) + // (112.47, 94.83) + // (93.64, 106.36)(106.36, 106.36) + // (87.53, 105.17) + // (117, 100) + // (105.17, 112.47) + // (100, 117) + type: 'AddedCombatant', + netRegex: { name: 'Black Hole', capture: true }, + run: (data, matches) => { + const x = parseFloat(matches.x); + const y = parseFloat(matches.y); + const dirNum = Directions.xyTo4DirNum(x, y, centerX, centerY); + + // Storing as dirNum to be sorted later once we have tethers + data.blackHoleIdDirNums[matches.id] = dirNum; + }, + }, { id: 'DMU P3 Nothingness Counter', // There are 10 sets of Nothingness from Black Holes to soak @@ -3218,9 +3247,30 @@ const triggerSet: TriggerSet = { suppressSeconds: 1, run: (data) => { data.nothingnessCount = data.nothingnessCount + 1; - // These will be replaced with either tether or actor tracker - // data.blackHoleDirNums = []; - data.blackHoleSet = data.blackHoleSet + 1; + // Reset the tether dirs for next round + data.blackHoleTetherDirNums = []; + }, + }, + { + id: 'DMU P3 Black Hole Tether Collect', + type: 'Tether', + netRegex: { capture: true }, + condition: (data, matches) => { + if (matches.id === headMarkerData['exdeathTether']) + return false; + // No need to collect the single tether sets + return data.phase === 'p3' && + (data.nothingnessCount !== 0 && data.nothingnessCount !== 9); + }, + run: (data, matches) => { + const dirNum = data.blackHoleIdDirNums[matches.sourceId]; + if (dirNum === undefined) + return; + + // Ignore the tether if it is already stored + // This allows for collection of tethers if instantaneous swap + if (!data.blackHoleTetherDirNums.includes(dirNum)) + data.blackHoleTetherDirNums.push(dirNum); }, }, { @@ -3234,12 +3284,15 @@ const triggerSet: TriggerSet = { return data.phase === 'p3' && data.nothingnessCount === 0; }, suppressSeconds: 99999, - response: (data, _matches, output) => { + response: (data, matches, output) => { // cactbot-builtin-response output.responseOutputStrings = blackHoleOutputStrings; const config = data.triggerSetConfig.blackhole; - const dir = 'unknown'; // TBD + const dirNum = data.blackHoleIdDirNums[matches.sourceId]; + const dir = dirNum === undefined + ? 'unknown' + : Directions.outputCardinalDir[dirNum] ?? 'unknown'; if ( config === 'kefka' && data.inLine[data.me] === 1 && @@ -3247,13 +3300,13 @@ const triggerSet: TriggerSet = { ) return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir]!(), }), }; return { infoText: output.oneBlackHole!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir]!(), }), }; @@ -3283,14 +3336,14 @@ const triggerSet: TriggerSet = { if (data.role === 'dps') return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir1]!(), }), }; // Support #1 return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir2]!(), }), }; @@ -3298,7 +3351,7 @@ const triggerSet: TriggerSet = { return { infoText: output.twoBlackHoles!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir1: output[dir1]!(), dir2: output[dir2]!(), }), @@ -3327,21 +3380,21 @@ const triggerSet: TriggerSet = { if (data.hadAccretion) return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir3]!(), }), }; if (data.role === 'dps') return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir1]!(), }), }; // Support #1 return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir2]!(), }), }; @@ -3349,7 +3402,7 @@ const triggerSet: TriggerSet = { return { infoText: output.threeBlackHoles!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir1: output[dir1]!(), dir2: output[dir2]!(), dir3: output[dir3]!(), @@ -3366,6 +3419,7 @@ const triggerSet: TriggerSet = { condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 3; }, + delaySeconds: 0.1, // Delay for tether collect suppressSeconds: 99999, response: (data, _matches, output) => { // cactbot-builtin-response @@ -3387,7 +3441,7 @@ const triggerSet: TriggerSet = { // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir]!(), }), }; @@ -3404,6 +3458,7 @@ const triggerSet: TriggerSet = { condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 4; }, + delaySeconds: 0.1, // Delay for tether collect suppressSeconds: 99999, response: (data, _matches, output) => { // cactbot-builtin-response @@ -3426,7 +3481,7 @@ const triggerSet: TriggerSet = { // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir]!(), }), }; @@ -3445,6 +3500,7 @@ const triggerSet: TriggerSet = { condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 5; }, + delaySeconds: 0.1, // Delay for tether collect suppressSeconds: 99999, response: (data, _matches, output) => { // cactbot-builtin-response @@ -3459,21 +3515,21 @@ const triggerSet: TriggerSet = { if (data.hadAccretion) return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir3]!(), }), }; if (data.role === 'dps') return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir1]!(), }), }; // Support #2 return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir2]!(), }), }; @@ -3481,7 +3537,7 @@ const triggerSet: TriggerSet = { return { infoText: output.threeBlackHoles!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir1: output[dir1]!(), dir2: output[dir2]!(), dir3: output[dir3]!(), @@ -3498,6 +3554,7 @@ const triggerSet: TriggerSet = { condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 6; }, + delaySeconds: 0.1, // Delay for tether collect suppressSeconds: 99999, response: (data, _matches, output) => { // cactbot-builtin-response @@ -3518,7 +3575,7 @@ const triggerSet: TriggerSet = { // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir]!(), }), }; @@ -3535,6 +3592,7 @@ const triggerSet: TriggerSet = { condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 7; }, + delaySeconds: 0.1, // Delay for tether collect suppressSeconds: 99999, response: (data, _matches, output) => { // cactbot-builtin-response @@ -3558,7 +3616,7 @@ const triggerSet: TriggerSet = { // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir]!(), }), }; @@ -3574,6 +3632,7 @@ const triggerSet: TriggerSet = { condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 8; }, + delaySeconds: 0.1, // Delay for tether collect suppressSeconds: 99999, response: (data, _matches, output) => { // cactbot-builtin-response @@ -3587,14 +3646,14 @@ const triggerSet: TriggerSet = { if (data.role === 'dps') return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir1]!(), }), }; // Support #3 return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir2]!(), }), }; @@ -3602,7 +3661,7 @@ const triggerSet: TriggerSet = { return { infoText: output.twoBlackHoles!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir1: output[dir1]!(), dir2: output[dir2]!(), }), @@ -3618,12 +3677,15 @@ const triggerSet: TriggerSet = { return data.phase === 'p3' && data.nothingnessCount === 9; }, suppressSeconds: 99999, - response: (data, _matches, output) => { + response: (data, matches, output) => { // cactbot-builtin-response output.responseOutputStrings = blackHoleOutputStrings; const config = data.triggerSetConfig.blackhole; - const dir = 'unknown'; // TBD + const dirNum = data.blackHoleIdDirNums[matches.sourceId]; + const dir = dirNum === undefined + ? 'unknown' + : Directions.outputCardinalDir[dirNum] ?? 'unknown'; if ( config === 'kefka' && data.inLine[data.me] === 3 && @@ -3631,13 +3693,13 @@ const triggerSet: TriggerSet = { ) return { alertText: output.takeDirTetherClockwise!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir]!(), }), }; return { infoText: output.oneBlackHole!({ - num: data.blackHoleSet, + num: data.nothingnessCount, dir: output[dir]!(), }), }; From 7a8270fa524f93261a6cf873c7ed232553a0bb25 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 22 Jun 2026 01:37:19 -0400 Subject: [PATCH 38/51] add handling of 2 and 3 tethers --- .../data/07-dt/ultimate/dancing_mad.ts | 88 ++++++++++++++++--- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index f53ddfb3b0f..89785430d82 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -361,6 +361,22 @@ const boaOutputStrings: OutputStrings = { }, }; +// Used to return clockwise from Kefka ordering of Black Hole Tethers +const getCWOrderFromN = ( + n: number, + dirNums: number[], +): number[] => { + // Calculate distance from cardinal + const getCWDistance = (start: number, end: number): number => { + const diff = end - start; + return diff < 0 ? diff + 4 : diff; + }; + + return [...dirNums].sort((a, b) => { + return getCWDistance(n, a) - getCWDistance(n, b); + }); +}; + const blackHoleOutputStrings: OutputStrings = { ...Directions.outputStringsCardinalDir, num: { @@ -3326,8 +3342,20 @@ const triggerSet: TriggerSet = { output.responseOutputStrings = blackHoleOutputStrings; const config = data.triggerSetConfig.blackhole; - const dir1 = 'unknown'; // TBD - const dir2 = 'unknown'; // TBD + const kefkaDir = data.kefkaTeleportDirNum; + const dirNums = data.blackHoleTetherDirNums; + + // Convert Kefka dir to 4Dir + const startDir = kefkaDir !== undefined + ? Math.round(kefkaDir / 2) % 4 + : -1; + const sorted = startDir !== -1 ? getCWOrderFromN(startDir, dirNums) : []; + const dir1 = sorted[0] !== undefined + ? Directions.outputCardinalDir[sorted[0]] ?? 'unknown' + : 'unknown'; + const dir2 = sorted[1] !== undefined + ? Directions.outputCardinalDir[sorted[1]] ?? 'unknown' + : 'unknown'; if ( config === 'kefka' && data.inLine[data.me] === 1 && @@ -3372,9 +3400,23 @@ const triggerSet: TriggerSet = { output.responseOutputStrings = blackHoleOutputStrings; const config = data.triggerSetConfig.blackhole; - const dir1 = 'unknown'; // TBD - const dir2 = 'unknown'; // TBD - const dir3 = 'unknown'; // TBD + const kefkaDir = data.kefkaTeleportDirNum; + const dirNums = data.blackHoleTetherDirNums; + + // Convert Kefka dir to 4Dir + const startDir = kefkaDir !== undefined + ? Math.round(kefkaDir / 2) % 4 + : -1; + const sorted = startDir !== -1 ? getCWOrderFromN(startDir, dirNums) : []; + const dir1 = sorted[0] !== undefined + ? Directions.outputCardinalDir[sorted[0]] ?? 'unknown' + : 'unknown'; + const dir2 = sorted[1] !== undefined + ? Directions.outputCardinalDir[sorted[1]] ?? 'unknown' + : 'unknown'; + const dir3 = sorted[2] !== undefined + ? Directions.outputCardinalDir[sorted[2]] ?? 'unknown' + : 'unknown'; if (config === 'kefka' && data.inLine[data.me] === 1) { if (data.hadAccretion) @@ -3507,9 +3549,23 @@ const triggerSet: TriggerSet = { output.responseOutputStrings = blackHoleOutputStrings; const config = data.triggerSetConfig.blackhole; - const dir1 = 'unknown'; // TBD - const dir2 = 'unknown'; // TBD - const dir3 = 'unknown'; // TBD + const kefkaDir = data.kefkaTeleportDirNum; + const dirNums = data.blackHoleTetherDirNums; + + // Convert Kefka dir to 4Dir + const startDir = kefkaDir !== undefined + ? Math.round(kefkaDir / 2) % 4 + : -1; + const sorted = startDir !== -1 ? getCWOrderFromN(startDir, dirNums) : []; + const dir1 = sorted[0] !== undefined + ? Directions.outputCardinalDir[sorted[0]] ?? 'unknown' + : 'unknown'; + const dir2 = sorted[1] !== undefined + ? Directions.outputCardinalDir[sorted[1]] ?? 'unknown' + : 'unknown'; + const dir3 = sorted[2] !== undefined + ? Directions.outputCardinalDir[sorted[2]] ?? 'unknown' + : 'unknown'; if (config === 'kefka' && data.inLine[data.me] === 2) { if (data.hadAccretion) @@ -3639,8 +3695,20 @@ const triggerSet: TriggerSet = { output.responseOutputStrings = blackHoleOutputStrings; const config = data.triggerSetConfig.blackhole; - const dir1 = 'unknown'; // TBD - const dir2 = 'unknown'; // TBD + const kefkaDir = data.kefkaTeleportDirNum; + const dirNums = data.blackHoleTetherDirNums; + + // Convert Kefka dir to 4Dir + const startDir = kefkaDir !== undefined + ? Math.round(kefkaDir / 2) % 4 + : -1; + const sorted = startDir !== -1 ? getCWOrderFromN(startDir, dirNums) : []; + const dir1 = sorted[0] !== undefined + ? Directions.outputCardinalDir[sorted[0]] ?? 'unknown' + : 'unknown'; + const dir2 = sorted[1] !== undefined + ? Directions.outputCardinalDir[sorted[1]] ?? 'unknown' + : 'unknown'; if (config === 'kefka' && data.inLine[data.me] === 3) { if (data.role === 'dps') From 8660b5224655f7c8dde9f168bc79a98574a7bdb2 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 22 Jun 2026 01:47:23 -0400 Subject: [PATCH 39/51] teleport dir is heading, need to flip, add note in slap happy --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 89785430d82..2291437e963 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -3134,7 +3134,7 @@ const triggerSet: TriggerSet = { netRegex: { id: ['BAE6', 'BAE7'], source: 'Kefka', capture: true }, run: (data, matches) => { const heading = parseFloat(matches.heading); - data.kefkaTeleportDirNum = Directions.hdgTo8DirNum(heading); + data.kefkaTeleportDirNum = (Directions.hdgTo8DirNum(heading) + 4) % 8; }, }, { @@ -3147,6 +3147,7 @@ const triggerSet: TriggerSet = { alertText: (_data, matches, output) => { const id = matches.id; const heading = parseFloat(matches.heading); + // NOTE: Using heading, which is flipped, so CW/CCW are flipped here const bossDirNum = Directions.hdgTo8DirNum(heading); const clockDirNum = (bossDirNum + 6) % 8; // Wrap-around const counterDirNum = (bossDirNum + 2) % 8; From 0894cea9c3561dbc23da8338ecc61136349d13c7 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 22 Jun 2026 02:03:43 -0400 Subject: [PATCH 40/51] add additional teleport-related spells Still need to check that this data is accurate for BAEC/BAED --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 2291437e963..80ffe7c5eef 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -3124,14 +3124,17 @@ const triggerSet: TriggerSet = { }, }, { - id: 'DMU P3 Slap Happy Boss Teleport Collect', + id: 'DMU P3 Boss Teleport Collect', + // For BAE6/BAE7 Slap Happy // Boss' position data is (100, 100), but heading does update ~2.5s before cast // 4 Invisible entities via 03 AddCombatant log lines correlate to the slap AoEs // spawn at time of StartsUsing. These are also ordered in the order they occur. + // + // For BAEC/BAED Look upon Me and Despair, boss also teleports // TODO: Get earlier infoText call // This could be necessary to call which black holes to grab later type: 'StartsUsing', - netRegex: { id: ['BAE6', 'BAE7'], source: 'Kefka', capture: true }, + netRegex: { id: ['BAE6', 'BAE7', 'BAEC', 'BAED'], source: 'Kefka', capture: true }, run: (data, matches) => { const heading = parseFloat(matches.heading); data.kefkaTeleportDirNum = (Directions.hdgTo8DirNum(heading) + 4) % 8; From 5fd6983beda0cf48e7fd2edb11d23f8868f81c4f Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 22 Jun 2026 02:11:44 -0400 Subject: [PATCH 41/51] add swap tether directions This could be optimized by moving the calculation of the directions to when the boss teleports or tethers first appear. Would need to look more closely at the timings if it is tether collect this would be on or boss teleporting... --- .../data/07-dt/ultimate/dancing_mad.ts | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 80ffe7c5eef..2f8612e281e 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -3474,7 +3474,6 @@ const triggerSet: TriggerSet = { const config = data.triggerSetConfig.blackhole; const hadAccretion = data.hadAccretion; const line = data.inLine[data.me]; - const dir = 'unknown'; // TBD if (config === 'kefka') { if (line === 1) { @@ -3484,6 +3483,18 @@ const triggerSet: TriggerSet = { return { alertText: output.passTether!() }; } if (line === 2 && !hadAccretion && data.role === 'dps') { + const kefkaDir = data.kefkaTeleportDirNum; + const dirNums = data.blackHoleTetherDirNums; + + // Convert Kefka dir to 4Dir + const startDir = kefkaDir !== undefined + ? Math.round(kefkaDir / 2) % 4 + : -1; + const sorted = startDir !== -1 ? getCWOrderFromN(startDir, dirNums) : []; + const dir = sorted[0] !== undefined + ? Directions.outputCardinalDir[sorted[0]] ?? 'unknown' + : 'unknown'; + // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ @@ -3513,7 +3524,6 @@ const triggerSet: TriggerSet = { const config = data.triggerSetConfig.blackhole; const hadAccretion = data.hadAccretion; const line = data.inLine[data.me]; - const dir = 'unknown'; // TBD if (config === 'kefka') { if (line === 1) { @@ -3524,6 +3534,18 @@ const triggerSet: TriggerSet = { } if (line === 2 && !hadAccretion) { if (data.role !== 'dps') { + const kefkaDir = data.kefkaTeleportDirNum; + const dirNums = data.blackHoleTetherDirNums; + + // Convert Kefka dir to 4Dir + const startDir = kefkaDir !== undefined + ? Math.round(kefkaDir / 2) % 4 + : -1; + const sorted = startDir !== -1 ? getCWOrderFromN(startDir, dirNums) : []; + const dir = sorted[1] !== undefined + ? Directions.outputCardinalDir[sorted[1]] ?? 'unknown' + : 'unknown'; + // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ @@ -3622,7 +3644,6 @@ const triggerSet: TriggerSet = { const config = data.triggerSetConfig.blackhole; const line = data.inLine[data.me]; - const dir = 'unknown'; // TBD if (config === 'kefka') { if (line === 2) { @@ -3632,6 +3653,18 @@ const triggerSet: TriggerSet = { return { alertText: output.passTether!() }; } if (line === 3 && data.role === 'dps') { + const kefkaDir = data.kefkaTeleportDirNum; + const dirNums = data.blackHoleTetherDirNums; + + // Convert Kefka dir to 4Dir + const startDir = kefkaDir !== undefined + ? Math.round(kefkaDir / 2) % 4 + : -1; + const sorted = startDir !== -1 ? getCWOrderFromN(startDir, dirNums) : []; + const dir = sorted[0] !== undefined + ? Directions.outputCardinalDir[sorted[0]] ?? 'unknown' + : 'unknown'; + // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ @@ -3660,7 +3693,6 @@ const triggerSet: TriggerSet = { const config = data.triggerSetConfig.blackhole; const line = data.inLine[data.me]; - const dir = 'unknown'; // TBD if (config === 'kefka') { if (line === 2) { @@ -3673,6 +3705,18 @@ const triggerSet: TriggerSet = { if (data.role === 'dps') return { infoText: output.keepTether!() }; // Support #3 + const kefkaDir = data.kefkaTeleportDirNum; + const dirNums = data.blackHoleTetherDirNums; + + // Convert Kefka dir to 4Dir + const startDir = kefkaDir !== undefined + ? Math.round(kefkaDir / 2) % 4 + : -1; + const sorted = startDir !== -1 ? getCWOrderFromN(startDir, dirNums) : []; + const dir = sorted[1] !== undefined + ? Directions.outputCardinalDir[sorted[1]] ?? 'unknown' + : 'unknown'; + // We could get the player they are taking from, but seems unnecessary at the time return { alertText: output.takeDirTetherClockwise!({ From 39cf690008a2470520c1c640ca67a4ed1272f047 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 22 Jun 2026 02:13:30 -0400 Subject: [PATCH 42/51] swap ; for , --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 2f8612e281e..a2b15beb628 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -506,7 +506,7 @@ const triggerSet: TriggerSet = { hadAccretion: false, blackHoleIdDirNums: {}, nothingnessCount: 0, - blackHoleTetherDirNums: []; + blackHoleTetherDirNums: [], }; }, triggers: [ From 656970cecfe487494e1b655ab00309b508b8bd90 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 22 Jun 2026 02:18:50 -0400 Subject: [PATCH 43/51] add missing capture for trigger --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index a2b15beb628..7866c4f78ca 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -3788,7 +3788,7 @@ const triggerSet: TriggerSet = { id: 'DMU P3 Black Hole 6, Nothingness 1', // One Black Hole spawns, causes a single Nothingness type: 'Tether', - netRegex: { capture: false }, + netRegex: { capture: true }, condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 9; }, From ac1a3f6908f78ad7cae16f0ef5bde299b0fd099e Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 22 Jun 2026 02:55:55 -0400 Subject: [PATCH 44/51] add some p3 enrage sequence triggers --- .../data/07-dt/ultimate/dancing_mad.ts | 69 +++++++++++++++++-- 1 file changed, 62 insertions(+), 7 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 7866c4f78ca..7d0a0ac32b0 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -6,11 +6,10 @@ import ZoneId from '../../../../../resources/zone_id'; import { RaidbossData } from '../../../../../types/data'; import { LocaleText, OutputStrings, TriggerSet } from '../../../../../types/trigger'; -// TODO: P3 Blackhole Directions // TODO: P3 Rework blackhole triggers for player that got hit to receive output over assuming they followed the plan? -// TODO: P3 Blackhole number output replace with Blackhole set (currently it's Nothingness counter) // TODO: P3 Better Blackhole no-config support via debuff tracking? // TODO: P3 Aoe calls for Earthquake and/or some call for those with no tether during swaps? +// TODO: P3 Blizzard III Stack Headmarker/Player and Role Towers // TODO: Earlier phase tracking for P5 (counting the jumps to middle?) type Phase = 'p1' | 'p2' | 'p3' | 'p4' | 'p5'; @@ -3263,7 +3262,7 @@ const triggerSet: TriggerSet = { // However, there are will be 10 BAFC Nothingness casts // Using BAFC Nothingness to track which set we are on type: 'Ability', - netRegex: { id: 'BAFC', capture: false }, + netRegex: { id: 'BAFC', source: 'Black Hole', capture: false }, suppressSeconds: 1, run: (data) => { data.nothingnessCount = data.nothingnessCount + 1; @@ -3461,7 +3460,7 @@ const triggerSet: TriggerSet = { // One player needs to swap tether // TODO: Move the players with previous tethers to a trigger condition on hit? type: 'Ability', - netRegex: { id: 'BAFC', capture: false }, + netRegex: { id: 'BAFC', source: 'Black Hole', capture: false }, condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 3; }, @@ -3511,7 +3510,7 @@ const triggerSet: TriggerSet = { // One player needs to swap tether // TODO: Move the players with previous tethers to a trigger condition on hit? type: 'Ability', - netRegex: { id: 'BAFC', capture: false }, + netRegex: { id: 'BAFC', source: 'Black Hole', capture: false }, condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 4; }, @@ -3632,7 +3631,7 @@ const triggerSet: TriggerSet = { // One player needs to swap tether // TODO: Move the players with previous tethers to a trigger condition on hit? type: 'Ability', - netRegex: { id: 'BAFC', capture: false }, + netRegex: { id: 'BAFC', source: 'Black Hole', capture: false }, condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 6; }, @@ -3681,7 +3680,7 @@ const triggerSet: TriggerSet = { // One player needs to swap tether // TODO: Move the players with previous tethers to a trigger condition on hit? type: 'Ability', - netRegex: { id: 'BAFC', capture: false }, + netRegex: { id: 'BAFC', source: 'Black Hole', capture: false }, condition: (data) => { return data.phase === 'p3' && data.nothingnessCount === 7; }, @@ -3821,6 +3820,62 @@ const triggerSet: TriggerSet = { }; }, }, + { + id: 'DMU P3 Blizzard III Puddles', + // TODO: Get which role is doing stack + player, and which role is doing towers + type: 'StartsUsing', + netRegex: { id: 'BB0F', source: 'Exdeath', capture: false }, + infoText: (_data, _matches, output) => { + return output.puddlesThenMech!({ + bait: output.baitPuddles!(), + mech1: output.roleStack!(), + mech2: output.getTowers!(), + }); + }, + outputStrings: { + roleStack: { + en: 'Role Stack', + }, + getTowers: Outputs.getTowers, + puddlesThenMech: { + en: '${bait} => ${mech1}/${mech2}', + }, + baitPuddles: { + en: 'Bait Puddles x2', + }, + }, + }, + { + id: 'DMU P3 Stomp-a-Mole Direction', + // In order to avoid 3s D98 Deep Freeze + type: 'StartsUsing', + netRegex: { id: 'BAEF', source: 'Kefka', capture: true }, + durationSeconds: (_data, matches) => parseFloat(matches.castTime) + 5.6, // Time until last Tower + infoText: (_data, matches, output) => { + const heading = parseFloat(matches.heading); + const dirNum = (Directions.hdgTo8DirNum(heading) + 4) % 8; + return output.text!({ dir: output[dirNum]!() }); + }, + outputStrings: { + ...Directions.outputStrings8Dir, + text: { + en: '${dir} Kefka', + }, + }, + }, + { + id: 'DMU P3 Blizzard III Keep Moving', + // In order to avoid 3s D98 Deep Freeze + // Players also need to avoid BB05 Big Bang at this time as well + // BB05 Big Bang goes off at the stack locations + type: 'StartsUsing', + netRegex: { id: 'BB11', source: 'Exdeath', capture: true }, + durationSeconds: (_data, matches) => parseFloat(matches.castTime), + infoText: (_data, _matches, output) => output.keepMoving!(), + outputStrings: { + keepMoving: Outputs.moveAround, + }, + }, ], timelineReplace: [ { From 096cd585dca69fc5180406444bb19419fdb66ce5 Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 22 Jun 2026 23:57:25 -0400 Subject: [PATCH 45/51] change accretion order default to healer first --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 7d0a0ac32b0..047c77f6b95 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -2993,7 +2993,7 @@ const triggerSet: TriggerSet = { delaySeconds: 0.1, // Delay for In Line debuffs run: (data, matches) => { const target = matches.target; - if (data.inLine[target] === 1) + if (data.party.isHealer(matches.target)) data.firstAccretion = target; else data.secondAccretion = target; From 64b6387b8c4cb903f34bf5444aac6e73a62270ee Mon Sep 17 00:00:00 2001 From: Legends0 Date: Mon, 22 Jun 2026 23:58:47 -0400 Subject: [PATCH 46/51] use already defined target --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 047c77f6b95..593b1053d83 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -2993,7 +2993,7 @@ const triggerSet: TriggerSet = { delaySeconds: 0.1, // Delay for In Line debuffs run: (data, matches) => { const target = matches.target; - if (data.party.isHealer(matches.target)) + if (data.party.isHealer(target)) data.firstAccretion = target; else data.secondAccretion = target; From 79be4794bfe118fcfb0cd7eab3f16e0b17b905cd Mon Sep 17 00:00:00 2001 From: Legends0 Date: Tue, 23 Jun 2026 00:12:26 -0400 Subject: [PATCH 47/51] make accretion order configurable, default role --- .../data/07-dt/ultimate/dancing_mad.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 593b1053d83..a83dd13c420 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -27,6 +27,7 @@ export interface Data extends RaidbossData { readonly triggerSetConfig: { teleportent: 'clockwise' | 'filipino' | 'none'; boa: 'lb3' | 'sg3k' | 'none'; + accretion: 'line' | 'role'; blackhole: 'kefka' | 'none'; }; // General @@ -462,6 +463,24 @@ const triggerSet: TriggerSet = { }, default: 'sg3k', }, + { + id: 'accretion', + comment: { + en: + `Order in which players will be told to heal for resolving Accretion debuffs`, + }, + name: { + en: 'P3 Accretion Heal Order', + }, + type: 'select', + options: { + en: { + 'First In Line => Second In Line': 'line', + 'Healer => DPS': 'role', + }, + }, + default: 'role', + }, { id: 'blackhole', comment: { @@ -2993,7 +3012,10 @@ const triggerSet: TriggerSet = { delaySeconds: 0.1, // Delay for In Line debuffs run: (data, matches) => { const target = matches.target; - if (data.party.isHealer(target)) + const first = data.triggerSetConfig.accretion === 'line' + ? data.inLine[target] === 1 + : data.party.isHealer(target); + if (first) data.firstAccretion = target; else data.secondAccretion = target; From e5212bcb4a89d7d3a3c1f5693bc092928c39ef3a Mon Sep 17 00:00:00 2001 From: Legends0 Date: Tue, 23 Jun 2026 00:21:21 -0400 Subject: [PATCH 48/51] lint --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index a83dd13c420..77eea5ccc0e 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -466,8 +466,7 @@ const triggerSet: TriggerSet = { { id: 'accretion', comment: { - en: - `Order in which players will be told to heal for resolving Accretion debuffs`, + en: `Order in which players will be told to heal for resolving Accretion debuffs`, }, name: { en: 'P3 Accretion Heal Order', From 308b504e97b9c3105ed469ca6f595c71e7b017fc Mon Sep 17 00:00:00 2001 From: Legends0 Date: Tue, 23 Jun 2026 00:25:18 -0400 Subject: [PATCH 49/51] remove delay if role-based accretion --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 77eea5ccc0e..b6a0adee964 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -3008,7 +3008,11 @@ const triggerSet: TriggerSet = { // One will have First in Line, the other will have Second in Line type: 'GainsEffect', netRegex: { effectId: '644', capture: true }, - delaySeconds: 0.1, // Delay for In Line debuffs + delaySeconds: (data) => { + if (data.triggerSetConfig.accretion === 'line') + return 0.1; // Delay for In Line debuffs + return 0; + }, run: (data, matches) => { const target = matches.target; const first = data.triggerSetConfig.accretion === 'line' From d9cb8dc179a6f527ecdd5b09b62efe1e165769eb Mon Sep 17 00:00:00 2001 From: Legends0 Date: Tue, 23 Jun 2026 02:07:32 -0400 Subject: [PATCH 50/51] lint --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index c926fa5318e..090d2a2ce1d 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -690,7 +690,7 @@ const triggerSet: TriggerSet = { 'Generic Calls': 'none', }, }, - default: 'none', + default: 'none', }, { id: 'boa', From 14445b8d02adce2ec52609db05130a59d3286f5a Mon Sep 17 00:00:00 2001 From: Legends0 Date: Wed, 24 Jun 2026 01:10:12 -0400 Subject: [PATCH 51/51] add duration to headwind/tailwind debuff trigger --- ui/raidboss/data/07-dt/ultimate/dancing_mad.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts index 5379835f28a..79aa9d019e3 100644 --- a/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts +++ b/ui/raidboss/data/07-dt/ultimate/dancing_mad.ts @@ -3989,6 +3989,7 @@ const triggerSet: TriggerSet = { netRegex: { effectId: ['642', '643'], capture: true }, condition: Conditions.targetIsYou(), delaySeconds: 0.1, + durationSeconds: 18.9, // Duration of the short debuff infoText: (data, matches, output) => { const myElement = data.myElement; const short = data.isFireShort