From c1164ee38758257c3c06cc6bbeea788254df0f90 Mon Sep 17 00:00:00 2001 From: Wexx <86693821+wexxlee@users.noreply.github.com> Date: Sat, 7 Dec 2024 22:43:23 -0800 Subject: [PATCH] raidboss: FRU - rest of p2 + p3 relativity --- resources/responses.ts | 1 + .../data/07-dt/ultimate/futures_rewritten.ts | 844 +++++++++++++++++- 2 files changed, 823 insertions(+), 22 deletions(-) diff --git a/resources/responses.ts b/resources/responses.ts index 3974cb5f87..531daa71da 100644 --- a/resources/responses.ts +++ b/resources/responses.ts @@ -328,6 +328,7 @@ export const Responses = { bigAoe: (sev?: Severity) => staticResponse(defaultInfoText(sev), Outputs.bigAoe), bleedAoe: (sev?: Severity) => staticResponse(defaultInfoText(sev), Outputs.bleedAoe), spread: (sev?: Severity) => staticResponse(defaultInfoText(sev), Outputs.spread), + protean: (sev?: Severity) => staticResponse(defaultInfoText(sev), Outputs.protean), // for stack marker situations. stackMarker: (sev?: Severity) => staticResponse(defaultAlertText(sev), Outputs.stackMarker), // for getting together without stack marker diff --git a/ui/raidboss/data/07-dt/ultimate/futures_rewritten.ts b/ui/raidboss/data/07-dt/ultimate/futures_rewritten.ts index dc1e600c01..70f6c29d17 100644 --- a/ui/raidboss/data/07-dt/ultimate/futures_rewritten.ts +++ b/ui/raidboss/data/07-dt/ultimate/futures_rewritten.ts @@ -1,3 +1,4 @@ +import Conditions from '../../../../../resources/conditions'; import Outputs from '../../../../../resources/outputs'; import { Responses } from '../../../../../resources/responses'; import { @@ -17,6 +18,27 @@ const isCardinalDir = (dir: DirectionOutput8): boolean => { return Directions.outputCardinalDir.includes(dir as DirectionOutputCardinal); }; +type RelativeClockPos = 'same' | 'opposite' | 'clockwise' | 'counterclockwise' | 'unknown'; +const getRelativeClockPos = ( + start: DirectionOutput8, + compare: DirectionOutput8, +): RelativeClockPos => { + if (start === 'unknown' || compare === 'unknown') + return 'unknown'; + + const startIdx = Directions.output8Dir.indexOf(start); + const compareIdx = Directions.output8Dir.indexOf(compare); + const delta = (compareIdx - startIdx + 8) % 8; + + if (delta === 0) + return 'same'; + else if (delta < 4) + return 'clockwise'; + else if (delta === 4) + return 'opposite'; + return 'counterclockwise'; +}; + // Ordering here matters - the "G1" directions are first, followed by "G2" directions. // Maybe add a config for this? But for now, assume that N->CCW is G1 and NE->CW is G2. const p2KnockbackDirs: DirectionOutput8[] = [ @@ -30,21 +52,130 @@ const p2KnockbackDirs: DirectionOutput8[] = [ 'dirNE', ]; +type RelativityDebuff = 'longFire' | 'mediumFire' | 'shortFire' | 'ice'; +const newRoleMap = () => ({ + support: { + hasIce: false, + shortFire: '', + mediumFire: '', + longFire: [] as string[], + }, + dps: { + hasIce: false, + shortFire: [] as string[], + mediumFire: '', + longFire: '', + }, + ice: '', +}); + +const findNorthDirNum = (dirs: number[]): number => { + for (let i = 0; i < dirs.length; i++) { + for (let j = i + 1; j < dirs.length; j++) { + const [dir1, dir2] = [dirs[i], dirs[j]]; + if (dir1 === undefined || dir2 === undefined) + return -1; + const diff = Math.abs(dir1 - dir2); + if (diff === 2) + return Math.min(dir1, dir2) + 1; + else if (diff === 6) // wrap around + return (Math.max(dir1, dir2) + 1) % 8; + } + } + return -1; +}; + +const p3UROutputStrings = { + yNorthStrat: { + en: '${debuff} (${dir})', + }, + dirCombo: { + en: '${inOut} + ${dir}', + }, + fireSpread: { + en: 'Fire - Spread', + }, + dropRewind: { + en: 'Drop Rewind', + }, + baitStoplight: { + en: 'Bait Stoplight', + }, + avoidStoplights: { + en: 'Avoid stoplights', + }, + stack: Outputs.stackMarker, + middle: Outputs.middle, + out: Outputs.out, +}; + export interface Data extends RaidbossData { + readonly triggerSetConfig: { + sinboundRotate: 'aacc' | 'addposonly'; // aacc = always away, cursed clockwise + ultimateRel: 'yNorthDPSEast' | 'none'; + }; actorSetPosTracker: { [id: string]: NetMatches['ActorSetPos'] }; p1ConcealSafeDirs: DirectionOutput8[]; p1StackSpread?: 'stack' | 'spread'; p1FallOfFaithTethers: ('fire' | 'lightning')[]; p2QuadrupleFirstTarget: string; p2QuadrupleDebuffApplied: boolean; - p2IcicleImpactStart: DirectionOutput8; + p2IcicleImpactStart: DirectionOutput8[]; p2AxeScytheSafe?: 'in' | 'out'; p2FrigidStoneTargets: string[]; + p2KBShivaDir?: DirectionOutput8; + p2LightsteepedCount: number; + p2LightRampantPuddles: string[]; + p2SeenFirstHolyLight: boolean; + p3RelativityRoleCount: number; + p3RelativityDebuff?: RelativityDebuff; + p3RelativityRoleMap: ReturnType; + p3RelativityStoplights: { [id: string]: NetMatches['AddedCombatant'] }; + p3RelativityYellowDirNums: number[]; + p3RelativityMyDirStr: string; } const triggerSet: TriggerSet = { id: 'FuturesRewrittenUltimate', zoneId: ZoneId.FuturesRewrittenUltimate, + config: [ + { + id: 'sinboundRotate', + comment: { + en: + `Always Away, Cursed Clockwise: LesBin`, + }, + name: { + en: 'P2 Diamond Dust / Sinbound Holy', + }, + type: 'select', + options: { + en: { + 'Always Away, Cursed Clockwise': 'aacc', + 'Call Add Position Only': 'addposonly', + }, + }, + default: 'aacc', // `addposonly` is not super helpful, and 'aacc' seems to be predominant + }, + { + id: 'ultimateRel', + comment: { + en: + `Y North, DPS E-SW, Supp W-NE: LesBin`, + }, + name: { + en: 'P3 Ultimate Relativity', + }, + type: 'select', + options: { + en: { + 'Y North, DPS E-SW, Supp W-NE': 'yNorthDPSEast', + 'Call Debuffs w/ No Positions': 'none', + }, + }, + default: 'yNorthDPSEast', + }, + ], timelineFile: 'futures_rewritten.txt', initData: () => { return { @@ -53,8 +184,16 @@ const triggerSet: TriggerSet = { p1FallOfFaithTethers: [], p2QuadrupleFirstTarget: '', p2QuadrupleDebuffApplied: false, - p2IcicleImpactStart: 'unknown', + p2IcicleImpactStart: [], p2FrigidStoneTargets: [], + p2LightsteepedCount: 0, + p2LightRampantPuddles: [], + p2SeenFirstHolyLight: false, + p3RelativityRoleCount: 0, + p3RelativityRoleMap: newRoleMap(), + p3RelativityStoplights: {}, + p3RelativityYellowDirNums: [], + p3RelativityMyDirStr: '', }; }, timelineTriggers: [], @@ -397,21 +536,31 @@ const triggerSet: TriggerSet = { type: 'StartsUsing', // 9D0A - Axe Kick (be out), 9D0B - Scythe Kick (be in) netRegex: { id: ['9D0A', '9D0B'], source: 'Oracle\'s Reflection' }, - // there are 2 actors 180 degrees apart, but we only need to collect one + condition: (data) => data.p2AxeScytheSafe === undefined, // don't overwrite during Mirror, Mirror run: (data, matches) => data.p2AxeScytheSafe = matches.id === '9D0A' ? 'out' : 'in', }, { id: 'FRU P2 Icicle Impact Initial Collect', type: 'StartsUsing', netRegex: { id: '9D06' }, - // there are 2 actors 180 degrees apart, but we only need to collect one - condition: (data) => data.p2IcicleImpactStart === 'unknown', - suppressSeconds: 1, + condition: (data) => data.p2IcicleImpactStart.length < 2, run: (data, matches) => { const x = parseInt(matches.x); const y = parseInt(matches.y); const dir = Directions.xyTo8DirOutput(x, y, centerX, centerY); - data.p2IcicleImpactStart = dir; + data.p2IcicleImpactStart.push(dir); + + // Once we have both, reorder the array and make sure the two dirs are opposites + if (data.p2IcicleImpactStart.length === 2) { + data.p2IcicleImpactStart.sort((a, b) => + p2KnockbackDirs.indexOf(a) - p2KnockbackDirs.indexOf(b) + ); + const [dir1 = 'unknown', dir2 = 'unknown'] = data.p2IcicleImpactStart; + if (getRelativeClockPos(dir1, dir2) !== 'opposite') { + console.error(`Unexpected Icicle Impact initial dirs: ${dir1}, ${dir2}`); + data.p2IcicleImpactStart = ['unknown', 'unknown']; + } + } }, }, { @@ -424,21 +573,20 @@ const triggerSet: TriggerSet = { return; const inOut = data.p2AxeScytheSafe ? output[data.p2AxeScytheSafe]!() : output.unknown!(); + const firstIcicle = data.p2IcicleImpactStart[0] ?? 'unknown'; // Assumes that if first Icicle Impacts spawn on cardinals, House of Light baits will also be // cardinals and Frigid Stone puddle drops will be intercards, and vice versa. if (data.p2FrigidStoneTargets.includes(data.me)) { - const dir = data.p2IcicleImpactStart === 'unknown' + const dir = firstIcicle === 'unknown' ? output.unknown!() - : (isCardinalDir(data.p2IcicleImpactStart) - ? output.intercards!() - : output.cardinals!()); + : (isCardinalDir(firstIcicle) ? output.intercards!() : output.cardinals!()); return output.dropPuddle!({ inOut: inOut, dir: dir }); } - const dir = data.p2IcicleImpactStart === 'unknown' + const dir = firstIcicle === 'unknown' ? output.unknown!() - : (isCardinalDir(data.p2IcicleImpactStart) ? output.cardinals!() : output.intercards!()); + : (isCardinalDir(firstIcicle) ? output.cardinals!() : output.intercards!()); return output.baitCleave!({ inOut: inOut, dir: dir }); }, outputStrings: { @@ -460,16 +608,10 @@ const triggerSet: TriggerSet = { type: 'Ability', // use the 'star' (Frigid Stone) drops to fire this alert, as Heavenly Strike has no cast time. netRegex: { id: '9D07', capture: false }, // source name can vary due to actor re-use + durationSeconds: 3.5, suppressSeconds: 1, alertText: (data, _matches, output) => { - const startDir = data.p2IcicleImpactStart; - const startIdx = p2KnockbackDirs.indexOf(startDir); - if (startIdx === -1) - return output.kb!(); - - // give safe directional outputs in the same order they appear in p2KnockbackDirs - const dir1 = startIdx < 4 ? startDir : p2KnockbackDirs[startIdx - 4] ?? 'unknown'; - const dir2 = startIdx >= 4 ? startDir : p2KnockbackDirs[startIdx + 4] ?? 'unknown'; + const [dir1 = 'unknown', dir2 = 'unknown'] = data.p2IcicleImpactStart; return output.kbDir!({ kb: output.kb!(), dir1: output[dir1]!(), dir2: output[dir2]!() }); }, outputStrings: { @@ -481,10 +623,668 @@ const triggerSet: TriggerSet = { unknown: Outputs.unknown, }, }, - // Crystals + { + id: 'FRU P2 Shiva Cleave Add Collect', + type: 'StartsUsing', + // The Shiva that will cast Twin Silence/Twin Stillness is the same actor + // that casts Sinbound Holy (9D10). + netRegex: { id: '9D10' }, + run: (data, matches) => { + const x = parseFloat(matches.x); + const y = parseFloat(matches.y); + data.p2KBShivaDir = Directions.xyTo8DirOutput(x, y, centerX, centerY); + }, + }, + { + // TODO: Add 'Always Away, Cursed Clockwise' strat + id: 'FRU P2 Sinbound Holy Rotation', + type: 'Ability', + // We can determine the player's knockbac dir from Heavenly Strike (9D0F). It occurs after + // Shiva is already casting Sinbound Holy. + netRegex: { id: '9D0F' }, + condition: Conditions.targetIsYou(), + durationSeconds: 5, + infoText: (data, matches, output) => { + const x = parseFloat(matches.targetX); + const y = parseFloat(matches.targetY); + const playerDir = Directions.xyTo8DirOutput(x, y, centerX, centerY); + const addDir = data.p2KBShivaDir ?? 'unknown'; + if (playerDir === 'unknown' || addDir === 'unknown') + return; + + const relPos = getRelativeClockPos(playerDir, addDir); + if (data.triggerSetConfig.sinboundRotate === 'aacc') + switch (relPos) { + case 'same': + case 'opposite': + return output.aaccCursed!(); + case 'clockwise': + return output.aaccClockwise!(); + case 'counterclockwise': + return output.aaccCounterclockwise!(); + default: + break; + } + return output[relPos]!(); + }, + outputStrings: { + aaccCursed: { + en: 'Cursed Add - Fast Clockwise', + }, + aaccClockwise: { + en: 'Rotate Counterclockwise (away from add)', + }, + aaccCounterclockwise: { + en: 'Rotate Clockwise (away from add)', + }, + same: { + en: 'Add is on knockback', + }, + opposite: { + en: 'Add is opposite knockback', + }, + clockwise: { + en: 'Add is clockwise', + }, + counterclockwise: { + en: 'Add is counterclockwise', + }, + }, + }, + { + id: 'FRU P2 Shining Armor', + type: 'GainsEffect', + netRegex: { effectId: '8E1', capture: false }, + suppressSeconds: 1, + countdownSeconds: 4.9, + response: Responses.lookAway('alarm'), + }, + { + id: 'FRU P2 Twin Silence/Stillness First', + type: 'StartsUsing', + // 9D01 - Twin Stillness (back safe -> front safe) + // 9D02 - Twin Silence (front safe -> back safe) + netRegex: { id: ['9D01', '9D02'] }, + durationSeconds: 2.8, + response: (data, matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = { + aaccSilence: { + en: '(stay in front)', + }, + silence: Outputs.front, + stillness: Outputs.back, + }; + if (data.triggerSetConfig.sinboundRotate === 'aacc') + return matches.id === '9D01' + ? { alertText: output.stillness!() } + : { infoText: output.aaccSilence!() }; + return matches.id === '9D01' + ? { alertText: output.stillness!() } + : { alertText: output.silence!() }; + }, + }, + { + id: 'FRU P2 Twin Silence/Stillness Second', + type: 'StartsUsing', + // 9D01 - Twin Stillness (back safe -> front safe) + // 9D02 - Twin Silence (front safe -> back safe) + netRegex: { id: ['9D01', '9D02'] }, + delaySeconds: 3, // waiting until the Ability line fires is too late, so delay off the 0x14 line. + alertText: (_data, matches, output) => + matches.id === '9D01' ? output.stillness!() : output.silence!(), + outputStrings: { + silence: Outputs.back, + stillness: Outputs.front, + }, + }, + { + id: 'FRU P2 Mirror Mirror Initial', + type: 'StartsUsing', + netRegex: { id: '9D0B', source: 'Usurper of Frost', capture: false }, + condition: (data) => data.p2AxeScytheSafe !== undefined, // don't fire during DD + delaySeconds: 1, + alertText: (_data, _matches, output) => output.baitCleave!(), + outputStrings: { + baitCleave: { + en: 'Bait cleave', + }, + }, + }, + { + id: 'FRU P2 Mirror Mirror Reflected', + type: 'StartsUsing', + // 9D0D = Reflected Scythe Kick (from Frozen Mirrors) + netRegex: { id: '9D0D', capture: false }, + delaySeconds: 5, // cast time is 9.7s + suppressSeconds: 1, + alertText: (_data, _matches, output) => output.baitCleave!(), + outputStrings: { + baitCleave: { + en: 'Bait cleave', + }, + }, + }, + { + // separate trigger for Banish II during LR + id: 'FRU P2 Mirror Mirror Banish III', + type: 'StartsUsing', + // 9D1C - Banish III (partners) + // 9D1D - Banish III (spread) + netRegex: { id: ['9D1C', '9D1D'] }, + condition: (data) => data.p2SeenFirstHolyLight === false, + infoText: (_data, matches, output) => + matches.id === '9D1C' ? output.partners!() : output.spread!(), + outputStrings: { + partners: Outputs.stackPartner, + spread: Outputs.spread, + }, + }, + { + id: 'FRU P2 Lightsteeped Counter', + type: 'GainsEffect', + netRegex: { effectId: '8D1' }, + condition: Conditions.targetIsYou(), + // can't just increment, since one puddle player gets 2 stacks on initial application + run: (data, matches) => data.p2LightsteepedCount = parseInt(matches.count), + }, + { + id: 'FRU P2 Light Rampant Setup', + type: 'HeadMarker', + netRegex: { id: '0177' }, + alertText: (data, matches, output) => { + data.p2LightRampantPuddles.push(matches.target); + if (data.p2LightRampantPuddles.length < 2) + return; + + const p1 = data.party.member(data.p2LightRampantPuddles[0]); + const p2 = data.party.member(data.p2LightRampantPuddles[1]); + if (data.me === matches.target) + return output.puddle!({ other: p1 }); + else if (data.p2LightRampantPuddles[0] === data.me) + return output.puddle!({ other: p2 }); + return output.tether!({ p1: p1, p2: p2 }); + }, + outputStrings: { + puddle: { + en: 'Puddles on you (w/ ${other})', + }, + tether: { + en: 'Tether on you (Puddles: ${p1}, ${p2})', + }, + }, + }, + { + id: 'FRU P2 Light Rampant Tower', + type: 'Ability', + // fire when the second set of Holy Light orbs explode + netRegex: { id: '9D1B', source: 'Holy Light', capture: false }, + suppressSeconds: 1, + response: (data, _matches, output) => { + // cactbot-builtin-response + output.responseOutputStrings = { + towerSoak: { + en: 'Soak middle tower', + }, + towerAvoid: { + en: 'Avoid middle tower', + }, + }; + + if (!data.p2SeenFirstHolyLight) + return; + return data.p2LightsteepedCount === 2 + ? { alertText: output.towerSoak!() } + : { infoText: output.towerAvoid!() }; + }, + run: (data) => data.p2SeenFirstHolyLight = true, + }, + { + // separate trigger for Banish II during MM + id: 'FRU P2 Light Rampant Banish III', + type: 'StartsUsing', + // 9D1C - Banish III (partners) + // 9D1D - Banish III (spread) + netRegex: { id: ['9D1C', '9D1D'], source: 'Usurper of Frost' }, + condition: (data) => data.p2SeenFirstHolyLight === true, + delaySeconds: 1, + alertText: (data, matches, output) => { + const partnerSpread = matches.id === '9D1C' ? output.partners!() : output.spread!(); + if (data.p2LightsteepedCount === 2) + return output.afterTower!({ partnerSpread: partnerSpread }); + return partnerSpread; + }, + outputStrings: { + afterTower: { + en: '${partnerSpread} (after tower)', + }, + partners: Outputs.stackPartner, + spread: Outputs.spread, + }, + }, + { + id: 'FRU P2 The House of Light', + type: 'StartsUsing', + netRegex: { id: '9CFD', source: 'Usurper of Frost', capture: false }, + response: Responses.protean(), + }, + { + id: 'FRU P2 Absolute Zero', + type: 'StartsUsing', + netRegex: { id: '9D20', source: 'Usurper of Frost', capture: false }, + delaySeconds: 4, + response: Responses.bigAoe(), + }, + // Crystals + { + id: 'FRU Intermission Junction', + type: 'WasDefeated', + netRegex: { target: 'Ice Veil', capture: false }, + delaySeconds: 5, + response: Responses.bigAoe(), + }, // P3 -- Oracle Of Darkness + { + id: 'FRU P3 Ultimate Relativity AoE', + type: 'StartsUsing', + netRegex: { id: '9D4A', source: 'Oracle of Darkness', capture: false }, + delaySeconds: 4, // cast time is 9.7s + response: Responses.bigAoe(), + }, + { + id: 'FRU P3 Ultimate Relativity Debuff Collect', + type: 'GainsEffect', + // 997 = Spell-in-Waiting: Dark Fire III (11s, 21s, or 31s) + // 99E = Spell-in-Waiting: Dark Blizzard III (21s) + netRegex: { effectId: ['997', '99E'] }, + run: (data, matches) => { + data.p3RelativityRoleCount++; + + const dur = parseFloat(matches.duration); + let debuff: RelativityDebuff; + if (matches.effectId === '99E') + debuff = 'ice'; + else if (dur < 12) + debuff = 'shortFire'; + else if (dur < 22) + debuff = 'mediumFire'; + else + debuff = 'longFire'; + + const rawRole = data.party.member(matches.target).role; + let role: 'dps' | 'support'; + if (rawRole === 'tank' || rawRole === 'healer') + role = 'support'; + else if (rawRole === 'dps') + role = 'dps'; + else + return; + + if (debuff === 'ice') { + data.p3RelativityRoleMap[role].hasIce = true; + data.p3RelativityRoleMap.ice = matches.target; + } else if (role === 'dps' && debuff === 'shortFire') + data.p3RelativityRoleMap.dps.shortFire.push(matches.target); + else if (role === 'support' && debuff === 'longFire') + data.p3RelativityRoleMap.support.longFire.push(matches.target); + else + data.p3RelativityRoleMap[role][debuff] = matches.target; + + if (data.me === matches.target) + data.p3RelativityDebuff = debuff; + }, + }, + { + id: 'FRU P3 Ultimate Relativity Initial Debuff', + type: 'GainsEffect', + netRegex: { effectId: ['997', '99E'], capture: false }, + condition: (data) => data.p3RelativityRoleCount === 8, + durationSeconds: 8, + infoText: (data, _matches, output) => { + const role = data.role === 'dps' ? 'dps' : 'support'; + const debuff = data.p3RelativityDebuff; + if (debuff === undefined) + return; + + if (debuff === 'ice') + return output.debuffSolo!({ debuff: output.ice!() }); + else if (debuff === 'mediumFire') + return output.debuffSolo!({ debuff: output.mediumFire!() }); + else if (debuff === 'longFire') { + if (role === 'dps') + return output.debuffSolo!({ debuff: output.longFire!() }); + const other = data.p3RelativityRoleMap.support.longFire.find((e) => e !== data.me); + return output.debuffShared!({ + debuff: output.longFire!(), + other: data.party.member(other), + }); + } + if (role === 'support') + return output.debuffSolo!({ debuff: output.shortFire!() }); + const other = data.p3RelativityRoleMap.dps.shortFire.find((e) => e !== data.me); + return output.debuffShared!({ + debuff: output.shortFire!(), + other: data.party.member(other), + }); + }, + outputStrings: { + debuffSolo: { + en: '${debuff} on you', + }, + debuffShared: { + en: '${debuff} on you (w/ ${other})', + }, + shortFire: { + en: 'Short Fire', + }, + mediumFire: { + en: 'Medium Fire', + }, + longFire: { + en: 'Long Fire', + }, + ice: { + en: 'Ice', + }, + }, + }, + { + id: 'FRU P3 Ultimate Relativity Stoplight Collect', + type: 'AddedCombatant', + netRegex: { npcBaseId: '17832' }, + run: (data, matches) => data.p3RelativityStoplights[matches.id] = matches, + }, + { + id: 'FRU P3 Ultimate Relativity Y North Spot', + type: 'Tether', + // boss tethers to 5 stoplights - 0085 are purple tethers, 0086 are yellow + netRegex: { id: '0086' }, + // condition: (data) => data.triggerSetConfig.ultimateRel === 'yNorthDPSEast', + run: (data, matches, output) => { + const id = matches.sourceId; + const stoplight = data.p3RelativityStoplights[id]; + if (stoplight === undefined) + return; + + const x = parseFloat(stoplight.x); + const y = parseFloat(stoplight.y); + data.p3RelativityYellowDirNums.push(Directions.xyTo8DirNum(x, y, centerX, centerY)); + + if (data.p3RelativityYellowDirNums.length !== 3) + return; + + const northDirNum = findNorthDirNum(data.p3RelativityYellowDirNums); + if (northDirNum === -1 || data.p3RelativityDebuff === undefined) { + data.p3RelativityMyDirStr = output.unknown!(); + return; + } + + const role = data.role === 'dps' ? 'dps' : 'support'; + const debuff = data.p3RelativityDebuff; + + if (role === 'dps') { + if (debuff === 'longFire' || debuff === 'ice') { + const myDirNum = (northDirNum + 4) % 8; // relative South + data.p3RelativityMyDirStr = output[Directions.output8Dir[myDirNum] ?? 'unknown']!(); + } else if (debuff === 'mediumFire') { + const myDirNum = (northDirNum + 2) % 8; // relative East + data.p3RelativityMyDirStr = output[Directions.output8Dir[myDirNum] ?? 'unknown']!(); + } else if (debuff === 'shortFire') { + const dirs = [ // relative SE/SW + Directions.output8Dir[(northDirNum + 3) % 8] ?? 'unknown', + Directions.output8Dir[(northDirNum + 5) % 8] ?? 'unknown', + ]; + data.p3RelativityMyDirStr = dirs.map((dir) => output[dir]!()).join(output.or!()); + } + } else { // supports + if (debuff === 'shortFire' || debuff === 'ice') { + const myDirNum = northDirNum; // relative North + data.p3RelativityMyDirStr = output[Directions.output8Dir[myDirNum] ?? 'unknown']!(); + } else if (debuff === 'mediumFire') { + const myDirNum = (northDirNum + 6) % 8; // relative West + data.p3RelativityMyDirStr = output[Directions.output8Dir[myDirNum] ?? 'unknown']!(); + } else if (debuff === 'longFire') { + const dirs = [ // relative NE/NW + Directions.output8Dir[(northDirNum + 1) % 8] ?? 'unknown', + Directions.output8Dir[(northDirNum + 7) % 8] ?? 'unknown', + ]; + data.p3RelativityMyDirStr = dirs.map((dir) => output[dir]!()).join(output.or!()); + } + } + }, + outputStrings: { + ...Directions.outputStrings8Dir, + or: Outputs.or, + unknown: Outputs.unknown, + }, + }, + // There are six steps to the mechanic. A player's action at each step is determined by their + // debuff combo (which is deterministic based on the initial fire/ice debuff). + // Since every player receives a 9A0 debuff ('Spell-in-Waiting: Return) of varying lengths, + // fire each of these triggers based on a delay from when 9A0 is applied at the start of UR. + { + id: 'FRU P3 Ultimate Rel 1st Fire/Stack', + type: 'GainsEffect', + netRegex: { effectId: '9A0' }, + condition: Conditions.targetIsYou(), + delaySeconds: 4, + durationSeconds: 7, + alertText: (data, _matches, output) => { + const debuff = data.p3RelativityDebuff; + if (debuff === undefined) + return; + + if (data.triggerSetConfig.ultimateRel !== 'yNorthDPSEast') + return output[debuff]!(); + const dirStr = data.p3RelativityMyDirStr; + if (debuff !== 'shortFire') + return output.yNorthStrat!({ debuff: output[debuff]!(), dir: output.middle!() }); + return output.yNorthStrat!({ debuff: output.shortFire!(), dir: dirStr }); + }, + outputStrings: { + yNorthStrat: p3UROutputStrings.yNorthStrat, + shortFire: p3UROutputStrings.fireSpread, + mediumFire: p3UROutputStrings.stack, + longFire: p3UROutputStrings.stack, + ice: p3UROutputStrings.stack, + middle: p3UROutputStrings.middle, + }, + }, + { + id: 'FRU P3 Ultimate Rel 1st Bait/Rewind', + type: 'GainsEffect', + netRegex: { effectId: '9A0' }, + condition: Conditions.targetIsYou(), + delaySeconds: 11, + alertText: (data, _matches, output) => { + const role = data.role === 'dps' ? 'dps' : 'support'; + const debuff = data.p3RelativityDebuff; + if (debuff === undefined) + return; + + if (data.triggerSetConfig.ultimateRel !== 'yNorthDPSEast') + return output[debuff]!(); + + const dirStr = data.p3RelativityMyDirStr; + + if (debuff === 'longFire') + return output.yNorthStrat!({ debuff: output.longFire!(), dir: dirStr }); + else if (debuff === 'ice') { + // dps ice bait stoplights; support ice drops rewind (eruption - out) + const comboDirStr = role === 'dps' + ? dirStr + : output.dirCombo!({ inOut: output.out!(), dir: dirStr }); + return role === 'dps' + ? output.yNorthStrat!({ debuff: output.iceDps!(), dir: comboDirStr }) + : output.yNorthStrat!({ debuff: output.iceSupport!(), dir: comboDirStr }); + } else if (debuff === 'mediumFire') { + // dps mediumFire drops rewind (dark water - stack mid); support mediumFire drops rewind (eruption - out) + const comboDirStr = role === 'dps' + ? output.dirCombo!({ inOut: output.middle!(), dir: dirStr }) + : output.dirCombo!({ inOut: output.out!(), dir: dirStr }); + return output.yNorthStrat!({ debuff: output.mediumFire!(), dir: comboDirStr }); + } + // shortFires all drop rewinds (eruption - out) + const comboDirStr = output.dirCombo!({ inOut: output.out!(), dir: dirStr }); + return output.yNorthStrat!({ debuff: output.shortFire!(), dir: comboDirStr }); + }, + outputStrings: { + yNorthStrat: p3UROutputStrings.yNorthStrat, + dirCombo: p3UROutputStrings.dirCombo, + shortFire: p3UROutputStrings.dropRewind, + mediumFire: p3UROutputStrings.dropRewind, + longFire: p3UROutputStrings.baitStoplight, + iceDps: p3UROutputStrings.baitStoplight, + iceSupport: p3UROutputStrings.dropRewind, + middle: p3UROutputStrings.middle, + out: p3UROutputStrings.out, + }, + }, + { + id: 'FRU P3 Ultimate Rel 2nd Fire/Stack + Ice', + type: 'GainsEffect', + netRegex: { effectId: '9A0' }, + condition: Conditions.targetIsYou(), + delaySeconds: 16, + alertText: (data, _matches, output) => { + const debuff = data.p3RelativityDebuff; + if (debuff === undefined) + return; + + if (data.triggerSetConfig.ultimateRel !== 'yNorthDPSEast') + return output[debuff]!(); + + const dirStr = data.p3RelativityMyDirStr; + if (debuff !== 'mediumFire') + return output.yNorthStrat!({ debuff: output[debuff]!(), dir: output.middle!() }); + return output.yNorthStrat!({ debuff: output.mediumFire!(), dir: dirStr }); + }, + outputStrings: { + yNorthStrat: p3UROutputStrings.yNorthStrat, + shortFire: p3UROutputStrings.stack, + mediumFire: p3UROutputStrings.fireSpread, + longFire: p3UROutputStrings.stack, + ice: p3UROutputStrings.stack, + middle: p3UROutputStrings.middle, + }, + }, + { + id: 'FRU P3 Ultimate Rel 2nd Bait/Rewind', + type: 'GainsEffect', + netRegex: { effectId: '9A0' }, + condition: Conditions.targetIsYou(), + delaySeconds: 21, + alertText: (data, _matches, output) => { + const role = data.role === 'dps' ? 'dps' : 'support'; + const debuff = data.p3RelativityDebuff; + if (debuff === undefined) + return; + + if (data.triggerSetConfig.ultimateRel !== 'yNorthDPSEast') + return output[debuff]!(); + + const dirStr = data.p3RelativityMyDirStr; + + if (debuff === 'shortFire') + return output.yNorthStrat!({ debuff: output.shortFire!(), dir: dirStr }); + else if (debuff === 'ice') { + // dps ice baits drops rewind (gaze - in); support ice baits stoplight + const comboDirStr = role === 'support' + ? dirStr + : output.dirCombo!({ inOut: output.middle!(), dir: data.p3RelativityMyDirStr }); + return role === 'dps' + ? output.yNorthStrat!({ debuff: output.iceDps!(), dir: comboDirStr }) + : output.yNorthStrat!({ debuff: output.iceSupport!(), dir: comboDirStr }); + } else if (debuff === 'mediumFire') { + // mediumFires have nothing to do, but they'll be stacking mid next + return output.yNorthStrat!({ debuff: output.mediumFire!(), dir: output.middle!() }); + } + // longFires all drop rewinds (gaze - in) + const comboDirStr = output.dirCombo!({ inOut: output.middle!(), dir: dirStr }); + return output.yNorthStrat!({ debuff: output.longFire!(), dir: comboDirStr }); + }, + outputStrings: { + yNorthStrat: p3UROutputStrings.yNorthStrat, + dirCombo: p3UROutputStrings.dirCombo, + shortFire: p3UROutputStrings.baitStoplight, + mediumFire: p3UROutputStrings.avoidStoplights, + longFire: p3UROutputStrings.dropRewind, + iceDps: p3UROutputStrings.dropRewind, + iceSupport: p3UROutputStrings.baitStoplight, + middle: p3UROutputStrings.middle, + out: p3UROutputStrings.out, + }, + }, + { + id: 'FRU P3 Ultimate Rel 3rd Fire/Stack', + type: 'GainsEffect', + netRegex: { effectId: '9A0' }, + condition: Conditions.targetIsYou(), + delaySeconds: 26, + alertText: (data, _matches, output) => { + const debuff = data.p3RelativityDebuff; + if (debuff === undefined) + return; + + if (data.triggerSetConfig.ultimateRel !== 'yNorthDPSEast') + return output[debuff]!(); + + const dirStr = data.p3RelativityMyDirStr; + if (debuff !== 'longFire') + return output.yNorthStrat!({ debuff: output[debuff]!(), dir: output.middle!() }); + return output.yNorthStrat!({ debuff: output.longFire!(), dir: dirStr }); + }, + outputStrings: { + yNorthStrat: p3UROutputStrings.yNorthStrat, + shortFire: p3UROutputStrings.stack, + mediumFire: p3UROutputStrings.stack, + longFire: p3UROutputStrings.fireSpread, + ice: p3UROutputStrings.stack, + middle: p3UROutputStrings.middle, + }, + }, + { + id: 'FRU P3 Ultimate Rel 3nd Bait/Rewind', + type: 'GainsEffect', + netRegex: { effectId: '9A0' }, + condition: Conditions.targetIsYou(), + delaySeconds: 31, + alertText: (data, _matches, output) => { + const debuff = data.p3RelativityDebuff; + if (debuff === undefined) + return; + + if (data.triggerSetConfig.ultimateRel !== 'yNorthDPSEast') + return output[debuff]!(); + + const dirStr = data.p3RelativityMyDirStr; + + if (debuff !== 'mediumFire') + return output.yNorthStrat!({ debuff: output[debuff]!(), dir: output.middle!() }); + return output.yNorthStrat!({ debuff: output.mediumFire!(), dir: dirStr }); + }, + outputStrings: { + yNorthStrat: p3UROutputStrings.yNorthStrat, + shortFire: p3UROutputStrings.avoidStoplights, + mediumFire: p3UROutputStrings.baitStoplight, + longFire: p3UROutputStrings.avoidStoplights, + ice: p3UROutputStrings.avoidStoplights, + middle: p3UROutputStrings.middle, + out: p3UROutputStrings.out, + }, + }, + { + id: 'FRU P3 Ultimate Rel Look Out', + type: 'GainsEffect', + // 99B - Rewind triggered + netRegex: { effectId: '99B' }, + condition: Conditions.targetIsYou(), + delaySeconds: (_data, matches) => parseFloat(matches.duration) - 4, + countdownSeconds: (_data, matches) => parseFloat(matches.duration), + response: Responses.lookAway('alarm'), + }, // P4 -- Duo // P5 -- Pandora