The battle system in Final Fantasy XII uses a system they dub "gambits" to control your three party members. This is a rough example of how I might go about implementing something like that system.

What is the gambit system?

If you haven't played the game, the gambits system is a way to set up behaviors of your party members as they roam the world. Because FFXII is not turned based like previous installments but instead plays in real-time, controlling three characters manually and simultaneously would be quite difficult. You're given a list of unlockable slots that pair a target with an action. The targets are typically a pair made up of enemy/ally and some criteria e.g. Nearest Enemy, Ally with HP <50%, etc. The actions are obviously the abilities available to that character e.g. attack, steal, cast magic, etc. The order of the list also plays a role, where commands at the top of the list will always be performed before the ones below it in the case where the criteria matches for multiple entries.

Approach

This isn't something I've fleshed out before writing this post, so I'm inventing it as I go and am probably going to find it's harder than I envision it to be as I go.

The first thing we need is some concept of things that can be considered as a target of a command. This isn't particularly interesting as it can be as straightforward as a base class for all of our party members and enemies:

abstract class Targetable { }

The secondary part of a target is the traits that make it selectable as a target based on some criteria i.e. some state that would make it necessary to target with a particular ability. Party members and enemies obviously have a huge amount of state that might not be relevant for our AI system (e.g. what items or abilities they have), so to keep the system clean and adaptable we should have a dedicated method to provide the state that is relevant to our system, which could look like:

abstract class Targetable {
  public abstract getType(): string;
  protected abstract getTraits(): readonly Trait<any>[];
  
  public getTrait<T>(handle: string): T | null {
    for (const trait of this.getTraits()) {
      if (trait.handle === handle) return trait.value;
    }
    
    return null;
  }
}

interface Trait<T> {
  readonly handle: string;
  readonly value: T | null;
}

The idea here is to have Targetable entities internally provision a list of Traits that it wants to expose for selection, and a public method of accessing those traits. A basic version of this implemented for a typical RPG enemy might look like:

abstract class Enemy extends Targetable {
  public getType(): string {
    return 'enemy';
  }
  
  // Typical RPG stuff here (health, strength, etc).
  // ...
}

class Zombie extends Enemy {
  protected getTraits(): readonly Trait<any> {
    return [
      { handle: 'health', value: this.health },
      { handle: 'distanceToPartyLeader', value: measureDistanceToPartyLeader() },
      { handle: 'debuffs', value: this.status.debuffs.map((debuff: Debuff) => debuff.name) }
    ];
  }
 }

Now given some list of targetables, we can traverse them and select the first one with a trait we care about whose values matches the range we care about to perform an action. e.g. if we wanted to select the first party member that is poisoned:

function getPoisonedAlly(): Targetable | null {
  for (const targetable of targetables) {
    if (targetable.type === 'ally') {
      const debuffs: Trait<readonly string[]> | null = targetable.getTrait<readonly string[]>('debuffs');
    
      if (debuffs && debuffs.includes('poison')) {
        return targetable;
      }
    }
  }
  
  return null;
}

Now we just need a concept of the action list to traverse and make a selection from to perform.

interface ActionListItem {
  readonly action: string;
  readonly type: string;
  readonly trait: string | null;
  evaluate<T>(value: T): boolean;
}

This is our model that maps the target type we care about, the trait we want to select, the action to perform on a successful selection and a function that evaluates to true when the selection should be made, otherwise false. Next we have the actual list of these actions:

type SelectedAction = {
  readonly action: string;
  readonly targetable: Targetable;
};

class ActionList {
  constructor(protected actions: readonly ActionListItem[]> { }
  
  public makeSelection(targetables: readonly Targetable[]): SelectedAction | null {
    for (const action of this.actions) {
      for (const targetable of targetables) {
        if (targetable.getType() === action.type) {
          if (action.trait === null) {
            // No specific traits to check for, just select the first of
            // the relevant type.
            return { action, targetable };
          }
          
          const trait: any | null = targetable.getTrait(action.trait);
          
          if (trait && action.evaluate(trait)) {
            return { action, targetable };
          }
        }
      }
    }
  }
}

Composed for example like this:

const list: ActionList = new ActionList([
  { action: 'cure', type: 'ally', trait: 'health', evaluate: (value: number) => health < 500 },
  { action: 'attack', type: 'enemy', trait: null, evaluate: () => true }
]);

And then on every combat clock tick, party members who manage an action list will select an action if they have no pending actions in their queue:

class PartyMember {
  protected actionQueue: readonly SelectedAction[];
  protected actionList: ActionList;
  
  constructor() {
    this.actionQueue = [];
    this.actionList = new ActionList([ ... ]);
  }
  
  public update(): void {
    if (this.actionQueue.length === 0) {
      this.actionQueue = this.actionQueue.concat(
        this.actionList.makeSelection(
          this.world.findNearby(this.position)
        )
      );
    } else {
      // Has an action in the queue.
      this.performFirstQueuedAction();
    }
  }
}

This has a lot of room for properly fleshing out, but it's a good skeleton to build on and could be easy rolled up into its own module for use in many games.