diff --git a/test/helper/test_oopsy.ts b/test/helper/test_oopsy.ts index b71ccf99a14..d37fd578b77 100644 --- a/test/helper/test_oopsy.ts +++ b/test/helper/test_oopsy.ts @@ -154,8 +154,9 @@ const testOopsyFile = (file: string, info: OopsyTriggerSetInfo) => { const abilityIdToId: { [abilityid: string]: string } = {}; for (const field of oopsyMistakeMapKeys) { - for (const [id, abilityId] of Object.entries(triggerSet[field] ?? {})) { + for (const [id, detail] of Object.entries(triggerSet[field] ?? {})) { // Ignore TODOs from `util/sync_files.ts` that haven't been filled out. + const abilityId = typeof detail === 'string' ? detail : detail.id; if (abilityId.startsWith('TODO')) continue; const prevId = abilityIdToId[abilityId]; diff --git a/types/data.d.ts b/types/data.d.ts index b69c24a8241..0a0f8a10d5f 100644 --- a/types/data.d.ts +++ b/types/data.d.ts @@ -58,6 +58,7 @@ export interface OopsyData { IsPlayerId: (x?: string) => boolean; DamageFromMatches: (matches: NetMatches['Ability']) => number; options: OopsyOptions; + collectors?: { [mistakeId: string]: string[] }; /** @deprecated Use parseFloat instead */ ParseLocaleFloat: (string: string) => number; diff --git a/types/oopsy.d.ts b/types/oopsy.d.ts index 3683796cfa7..748ab81f518 100644 --- a/types/oopsy.d.ts +++ b/types/oopsy.d.ts @@ -31,7 +31,9 @@ export type InternalOopsyTriggerType = | 'Damage' | 'GainsEffect' | 'Share' - | 'Solo'; + | 'Solo' + | 'Missed' + | 'Multiple'; export type DeathReportData = { lang: Lang; @@ -103,7 +105,22 @@ export type OopsyTrigger = id: string; }; -type MistakeMap = { [mistakeId: string]: string }; +type MistakeRole = 'tank' | 'healer' | 'dps'; + +export type MistakeDetails = { + id: string; + onlyForRole?: MistakeRole | MistakeRole[]; // only a mistake if player is in this/these roles + text?: LocaleText; // override default text for this mistake type +}; + +export type CollectMistakeDetails = MistakeDetails & { + collectSeconds?: number; // time to collect before reporting + suppressSeconds?: number; // time until the same mistake can be re-collected and reported + minCount?: number; // for `multipleX` triggers, the # of hits before a mistake is reported +}; + +export type MistakeMap = { [mistakeId: string]: string | MistakeDetails }; +export type CollectMistakeMap = { [mistakeId: string]: string | CollectMistakeDetails }; export type DataInitializeFunc = () => Omit; @@ -124,6 +141,10 @@ export type OopsyMistakeMapFields = { shareFail?: MistakeMap; soloWarn?: MistakeMap; soloFail?: MistakeMap; + missedWarn?: CollectMistakeMap; + missedFail?: CollectMistakeMap; + multipleWarn?: CollectMistakeMap; + multipleFail?: CollectMistakeMap; }; type SimpleOopsyTriggerSet = { diff --git a/ui/oopsyraidsy/damage_tracker.ts b/ui/oopsyraidsy/damage_tracker.ts index f71dc4185ed..1ee48da7fab 100644 --- a/ui/oopsyraidsy/damage_tracker.ts +++ b/ui/oopsyraidsy/damage_tracker.ts @@ -13,9 +13,13 @@ import { Job, Role } from '../../types/job'; import { Matches, NetMatches } from '../../types/net_matches'; import { CactbotBaseRegExp } from '../../types/net_trigger'; import { + CollectMistakeDetails, + CollectMistakeMap, DataInitializeFunc, + InternalOopsyTriggerType, LooseOopsyTrigger, LooseOopsyTriggerSet, + MistakeDetails, MistakeMap, OopsyDeathReason, OopsyField, @@ -30,6 +34,8 @@ import { ZoneIdType } from '../../types/trigger'; import { CombatState } from './combat_state'; import { MistakeCollector } from './mistake_collector'; import { + GetMissedMistakeText, + GetMultipleMistakeText, GetShareMistakeText, GetSoloMistakeText, IsPlayerId, @@ -45,6 +51,16 @@ import { import { OopsyOptions } from './oopsy_options'; import { PlayerStateTracker } from './player_state_tracker'; +type CollectHelperType = 'missed' | 'multiple'; + +// Defaults for collect helper triggers +const collectMistakeDefaults = { + id: 'DUMMY_VALUE', // gets set later + collectSeconds: 1.5, + suppressSeconds: 1.5, + minCount: 2, +}; + const actorControlFadeInCommandPre62 = '40000010'; const actorControlFadeInCommand = '4000000F'; @@ -80,6 +96,19 @@ export const earlyPullTriggerId = 'General Early Pull'; const isOopsyMistake = (x: OopsyMistake | OopsyDeathReason): x is OopsyMistake => 'type' in x; +// Helper function to handle `onlyForRole` mistake properties +const inRequiredRole = ( + mistake: string | MistakeDetails | CollectMistakeDetails, + playerRole: string, +): boolean => { + if (typeof mistake === 'string' || mistake.onlyForRole === undefined) + return true; + const eligibleRoles: string[] = Array.isArray(mistake.onlyForRole) + ? mistake.onlyForRole + : [mistake.onlyForRole]; + return eligibleRoles.includes(playerRole); +}; + export type ProcessedOopsyTriggerSet = LooseOopsyTriggerSet & { filename?: string; }; @@ -547,18 +576,23 @@ export class DamageTracker { if (!dict) return; for (const key in dict) { - const id = dict[key]; + const mistake = dict[key]; + const id = typeof mistake === 'object' ? mistake.id : mistake; const trigger: OopsyTrigger = { id: key, type: 'Ability', netRegex: NetRegexes.ability({ id: id, ...playerDamageFields }), - mistake: (_data, matches) => { + mistake: (data, matches) => { + const text = (typeof mistake === 'object' ? mistake : {}).text ?? matches.ability; + if (mistake !== undefined && !inRequiredRole(mistake, data.role)) + return; + return { type: type, blame: matches.target, reportId: matches.targetId, triggerType: 'Damage', - text: matches.ability, + text: text, }; }, }; @@ -570,18 +604,23 @@ export class DamageTracker { if (!dict) return; for (const key in dict) { - const id = dict[key]; + const mistake = dict[key]; + const id = typeof mistake === 'object' ? mistake.id : mistake; const trigger: OopsyTrigger = { id: key, type: 'GainsEffect', netRegex: NetRegexes.gainsEffect({ effectId: id, ...playerTargetFields }), - mistake: (_data, matches) => { + mistake: (data, matches) => { + const text = (typeof mistake === 'object' ? mistake : {}).text ?? matches.effect; + if (mistake !== undefined && !inRequiredRole(mistake, data.role)) + return; + return { type: type, blame: matches.target, reportId: matches.targetId, triggerType: 'GainsEffect', - text: matches.effect, + text: text, }; }, }; @@ -595,23 +634,30 @@ export class DamageTracker { if (!dict) return; for (const key in dict) { - const id = dict[key]; + const mistake = dict[key]; + const id = typeof mistake === 'object' ? mistake.id : mistake; const trigger: OopsyTrigger = { id: key, type: 'Ability', netRegex: NetRegexes.ability({ type: '22', id: id, ...playerDamageFields }), - mistake: (_data, matches) => { + mistake: (data, matches) => { // Some single target damage is still marked as AOEActionEffect type 22, so check // the number of targets that it hits. const numTargets = parseInt(matches.targetCount); if (numTargets === 1 || isNaN(numTargets)) return; + + const text = (typeof mistake === 'object' ? mistake : {}).text ?? + GetShareMistakeText(matches.ability, numTargets); + if (mistake !== undefined && !inRequiredRole(mistake, data.role)) + return; + return { type: type, blame: matches.target, reportId: matches.targetId, triggerType: 'Share', - text: GetShareMistakeText(matches.ability, numTargets), + text: text, }; }, }; @@ -623,18 +669,24 @@ export class DamageTracker { if (!dict) return; for (const key in dict) { - const id = dict[key]; + const mistake = dict[key]; + const id = typeof mistake === 'object' ? mistake.id : mistake; const trigger: OopsyTrigger = { id: key, type: 'Ability', netRegex: NetRegexes.ability({ type: '21', id: id, ...playerDamageFields }), - mistake: (_data, matches) => { + mistake: (data, matches) => { + const text = (typeof mistake === 'object' ? mistake : {}).text ?? + GetSoloMistakeText(matches.ability); + if (mistake !== undefined && !inRequiredRole(mistake, data.role)) + return; + return { type: type, blame: matches.target, reportId: matches.targetId, triggerType: 'Solo', - text: GetSoloMistakeText(matches.ability), + text: text, }; }, }; @@ -642,6 +694,105 @@ export class DamageTracker { } } + AddCollectTriggers( + type: OopsyMistakeType, + helperType: CollectHelperType, + dict?: CollectMistakeMap, + ): void { + if (!dict) + return; + for (const key in dict) { + const mistake = dict[key]; + if (mistake === undefined) + continue; + + const mistakeDetails = typeof mistake === 'string' + ? { ...collectMistakeDefaults, id: mistake } + : { ...collectMistakeDefaults, ...mistake }; + + // Create a sanitized mistakeId with only alphanumerics to use as the data.collectors prop + // This is a bit of a hack, but the alternatives are worse. And lint/test rules enforce + // unique trigger names, so this shouldn't cause bad things to happen......... /.\ + const sanitizedMistakeId = key.replace(/[^\w]/g, ''); + + const collectTrigger: OopsyTrigger = { + id: `${key} Collect`, + type: 'Ability', + netRegex: NetRegexes.ability({ id: mistakeDetails.id }), + // only collect party members to determine if they were missed + // TODO: Add support for alliances if/once we have a config option? + condition: (data, matches) => data.party.partyNames_.includes(matches.target), + run: (data, matches) => { + ((data.collectors ??= {})[sanitizedMistakeId] ??= []).push(matches.target); + }, + }; + this.ProcessTrigger(collectTrigger); + + const mistakeTrigger: OopsyTrigger = { + id: `${key} Mistake`, + type: 'Ability', + netRegex: NetRegexes.ability({ id: mistakeDetails.id }), + delaySeconds: mistakeDetails.collectSeconds, + // At a minimum, suppress for the collection period so the trigger fires only once + // for that collction. But allow for the possibility that we may want to suppress for longer + // (e.g., for a multi-hit stack, a trigger writer might think it's sufficient to report + // missing the first hit and not re-report multiple times, which could get spammy). + suppressSeconds: Math.max(mistakeDetails.collectSeconds, mistakeDetails.suppressSeconds), + mistake: (data, matches) => { + // TODO: Add support for alliances if/once we have a config option? + const trackList = data.party.partyNames_; + const targeted = (data.collectors ??= {})[sanitizedMistakeId] ??= []; + const minCount = mistakeDetails.minCount; // for 'multiple'-type triggers + + let mistakePlayers: string[] = []; + let triggerType: InternalOopsyTriggerType = 'Damage'; // default + // assume MissedMistakeText if not provided as a default, and override later if needed + // as MultipleMistakeText requires an addiitonal per-user param + let mistakeText = mistakeDetails.text ?? GetMissedMistakeText(matches.ability); + + if (helperType === 'missed') { + triggerType = 'Missed'; + mistakePlayers = trackList.filter((name) => !targeted.includes(name)); + } else if (helperType === 'multiple') { + triggerType = 'Multiple'; + mistakePlayers = trackList.filter( + (name) => targeted.filter((target) => target === name).length >= minCount, + ); + } + + if (mistakePlayers.length === 0) + return; + + const mistakes: OopsyMistake[] = []; + + for (const name of mistakePlayers) { + const playerId = data.party.member(name).id; + const playerRole = data.party.member(name).role; + if (playerId === undefined || playerRole === undefined) + continue; + + const numHits = targeted.filter((target) => target === name).length; + if (helperType === 'multiple' && mistakeDetails.text === undefined) + mistakeText = GetMultipleMistakeText(matches.ability, numHits); + + if (inRequiredRole(mistakeDetails, playerRole)) + mistakes.push({ + type: type, + blame: name, + reportId: playerId, + triggerType: triggerType, + text: mistakeText, + }); + } + + return mistakes; + }, + run: (data) => (data.collectors ??= {})[sanitizedMistakeId] = [], + }; + this.ProcessTrigger(mistakeTrigger); + } + } + ReloadTriggers(): void { this.ProcessDataFiles(); @@ -721,6 +872,10 @@ export class DamageTracker { this.AddShareTriggers('fail', set.shareFail); this.AddSoloTriggers('warn', set.soloWarn); this.AddSoloTriggers('fail', set.soloFail); + this.AddCollectTriggers('warn', 'missed', set.missedWarn); + this.AddCollectTriggers('fail', 'missed', set.missedFail); + this.AddCollectTriggers('warn', 'multiple', set.multipleWarn); + this.AddCollectTriggers('fail', 'multiple', set.multipleFail); for (const trigger of set.triggers ?? []) this.ProcessTrigger(trigger); diff --git a/ui/oopsyraidsy/oopsy_common.ts b/ui/oopsyraidsy/oopsy_common.ts index 4691e9a2cba..ef167b61c0a 100644 --- a/ui/oopsyraidsy/oopsy_common.ts +++ b/ui/oopsyraidsy/oopsy_common.ts @@ -168,3 +168,20 @@ export const GetShareMistakeText = ( ko: `${localeText['ko'] ?? localeText['en']} (같이 맞음: ${numTargets}명)`, }; }; + +export const GetMissedMistakeText = (ability: string | LocaleText): LocaleText => { + const localeText: LocaleText = typeof ability === 'string' ? { en: ability } : ability; + return { + en: `missed ${localeText['en']}`, + }; +}; + +export const GetMultipleMistakeText = ( + ability: string | LocaleText, + numHits: number, +): LocaleText => { + const localeText: LocaleText = typeof ability === 'string' ? { en: ability } : ability; + return { + en: `${localeText['en']} (hit x${numHits})`, + }; +}; diff --git a/ui/oopsyraidsy/player_state_tracker.ts b/ui/oopsyraidsy/player_state_tracker.ts index 65e9e8f82ea..a8e16dfad51 100644 --- a/ui/oopsyraidsy/player_state_tracker.ts +++ b/ui/oopsyraidsy/player_state_tracker.ts @@ -156,17 +156,17 @@ export class PlayerStateTracker { this.triggerSets.push(set); for (const set of this.triggerSets) { for (const value of Object.values(set.damageWarn ?? {})) - this.mistakeDamageMap[value] = 'warn'; + this.mistakeDamageMap[typeof value === 'object' ? value.id : value] = 'warn'; for (const value of Object.values(set.damageFail ?? {})) - this.mistakeDamageMap[value] = 'fail'; + this.mistakeDamageMap[typeof value === 'object' ? value.id : value] = 'fail'; for (const value of Object.values(set.shareWarn ?? {})) - this.mistakeShareMap[value] = 'warn'; + this.mistakeShareMap[typeof value === 'object' ? value.id : value] = 'warn'; for (const value of Object.values(set.shareFail ?? {})) - this.mistakeShareMap[value] = 'fail'; + this.mistakeShareMap[typeof value === 'object' ? value.id : value] = 'fail'; for (const value of Object.values(set.soloWarn ?? {})) - this.mistakeSoloMap[value] = 'warn'; + this.mistakeSoloMap[typeof value === 'object' ? value.id : value] = 'warn'; for (const value of Object.values(set.soloFail ?? {})) - this.mistakeSoloMap[value] = 'fail'; + this.mistakeSoloMap[typeof value === 'object' ? value.id : value] = 'fail'; } }