Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

oopsy: add new helpers for missed/multiple events #475

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion test/helper/test_oopsy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines 158 to +159
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This definition should probably go before the comment for slightly easier readability.

if (abilityId.startsWith('TODO'))
continue;
const prevId = abilityIdToId[abilityId];
Expand Down
1 change: 1 addition & 0 deletions types/data.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 23 additions & 2 deletions types/oopsy.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export type InternalOopsyTriggerType =
| 'Damage'
| 'GainsEffect'
| 'Share'
| 'Solo';
| 'Solo'
| 'Missed'
| 'Multiple';

export type DeathReportData = {
lang: Lang;
Expand Down Expand Up @@ -103,7 +105,22 @@ export type OopsyTrigger<Data extends OopsyData> =
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<Data extends OopsyData> = () => Omit<Data, keyof OopsyData>;

Expand All @@ -124,6 +141,10 @@ export type OopsyMistakeMapFields = {
shareFail?: MistakeMap;
soloWarn?: MistakeMap;
soloFail?: MistakeMap;
missedWarn?: CollectMistakeMap;
missedFail?: CollectMistakeMap;
multipleWarn?: CollectMistakeMap;
multipleFail?: CollectMistakeMap;
};

type SimpleOopsyTriggerSet<Data extends OopsyData> = {
Expand Down
179 changes: 167 additions & 12 deletions ui/oopsyraidsy/damage_tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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<OopsyData> = {
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,
};
},
};
Expand All @@ -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<OopsyData> = {
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,
};
},
};
Expand All @@ -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<OopsyData> = {
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,
};
},
};
Expand All @@ -623,25 +669,130 @@ 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<OopsyData> = {
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,
};
},
};
this.ProcessTrigger(trigger);
}
}

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<OopsyData> = {
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<OopsyData> = {
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();

Expand Down Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions ui/oopsyraidsy/oopsy_common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`,
};
};
Loading
Loading