Skip to content

Latest commit

 

History

History
969 lines (743 loc) · 16.1 KB

slides.re.mdx

File metadata and controls

969 lines (743 loc) · 16.1 KB

export { Components } from './Components'; export { Themes } from './Themes';

--

<Stack center stretch style={{ imageRendering: 'pixelated', inset: 0, overflow: 'hidden', position: 'absolute', zIndex: 0, zoom: 0.63, }}

<Video src="/AC.mp4" style={{ transform: 'translateY(-7%)', }} /> <Stack center selfCenter stretch style={{ width: '63%', zIndex: 1, }} vertical

<TitleBox children={ <h1 style={{ marginBottom: 0 }}>Building the AI for Athena Crisis } /> <TitleBox children={ <> by Christoph Nakazawa{' '} <a href="https://cpojer.net" style={{ textDecoration: 'none' }}> @cpojer </> } style={{ alignSelf: 'end', color: '#000', marginTop: 20, padding: '12px 20px', }} /> <TitleBox children={ <a href="https://turn-based-ai.nakazawa.dev/" style={{ textDecoration: 'none' }} > turn-based-ai.nakazawa.dev } style={{ alignSelf: 'end', marginTop: 20, padding: '12px 20px', }} />


image: Cover.jpg

--

Nakazawa Tech KK


image: CoverEmpty.jpg transition: none

--

Nakazawa Tech KK


image: CoverEmpty.jpg transition: none

--

React Native


image: CoverEmpty.jpg transition: none

--

Jest & JavaScript Infra


image: CoverEmpty.jpg theme: dark transition: leaveOnly

--

Nakazawa Tech KK

Work with us!
- [nakazawa.dev](https://nakazawa.dev)

- [[email protected]](mailto:[email protected])

theme: dark

--

Demo


Building the AI for Athena Crisis


transition: none

--

Building the AI for Athena Crisis

Warning: Code Heavy Talk

transition: leaveOnly

--

Building the AI for Athena Crisis

But: Beginner Friendly

<Stack alignCenter nowrap style={{flex: 1}}>

AI Prerequisites

  • Good abstractions
  • Strong primitives
  • Search algorithms
  • Math for decision making

AI Assumptions

  • Fast
  • Stateless
  • Deterministic
  • Composable

Athena Crisis Architecture

  • Immutable persistent data structures
  • Declarative game state
  • Server ⇔ Client
  • Optimistic Updates
  • Actions → Transformers → Action Responses

transition: none

--

Athena Crisis Architecture


transition: none

--

Athena Crisis Architecture

type Vector = { x: number; y: number };
type AttackUnitAction = {
  from: Vector;
  to: Vector;
  type: 'AttackUnit';
};
/// ----- transforms -----
type AttackUnitActionResponse = {
  from: Vector;
  to: Vector;
  type: 'AttackUnit';
  unitA?: Unit;
  unitB?: Unit;
};
/// ----- via codegen -----
type EncodedAttackUnitActionResponse = [
  type: 1,
  fromX: number,
  fromY: number,
  toX: number,
  toY: number,
  unitA?: PlainUnit | null,
  unitB?: PlainUnit | null,
];
function attackUnit(map: MapData, { from, to }: AttackUnitAction) {
  const unitA = map.units.get(from);
  const unitB = map.units.get(to);

  if (
    map.isOpponent(unitA, unitB) &&
    !unitA.isCompleted() &&
    unitA.canAttack(from.distance(to))
  ) {
    // damage calculation…
    return {
      from,
      to,
      type: 'AttackUnit',
      unitA: a.isDead() ? undefined : a,
      unitB: b.isDead() ? undefined : b,
    };
  }
}

transition: none

--

Athena Crisis Architecture

const VisibleAttackUnit = {
  Both: true,

  Source: ({ from, to, unitA }: AttackUnitActionResponse, map: MapData) => ({
    direction: getAttackDirection(from, to),
    from,
    type: 'HiddenTargetAttackUnit',
    unitA,
    weapon: getAttackWeapon(map, from, map.units.get(to)),
  }),

  Target: ({ from, to, unitB }: AttackUnitActionResponse, map: MapData) => ({
    direction: getAttackDirection(to, from),
    to,
    type: 'HiddenSourceAttackUnit',
    unitB,
    weapon: hasCounterAttack
      ? getAttackWeapon(map, to, map.units.get(from))
      : undefined,
  }),
};

transition: none

--

Athena Crisis Architecture

function applyActionResponse(
  map: MapData,
  actionResponse: ActionResponse,
): MapData {
  switch (actionResponse.type) {
    case 'AttackUnit': {
      const { from, to, unitA, unitB } = actionResponse;
      const originalUnitA = map.units.get(from);
      const originalUnitB = map.units.get(to);
      let { units } = map;
      units =
        unitA && originalUnitA
          ? units.set(from, originalUnitA.copy(unitA).complete())
          : units.delete(from);
      units =
        unitB && originalUnitB
          ? units.set(to, originalUnitB.copy(unitB))
          : units.delete(to);

      return map.copy({ units });
    }
  }
}

transition: leaveOnly

--

Athena Crisis Architecture

map = applyActionResponse(map, {
  from: { x: 1, y: 1 },
  to: { x: 2, y: 3 },
  type: 'Move',
});

map = applyActionResponse(map, {
  from: { x: 2, y: 3 },
  to: { x: 3, y: 3 },
  type: 'AttackUnit',
});

theme: dark

--

Let's implement AI behaviors


AI: Basics

class AthenaCrisisAI {
  public readonly responses: ReadonlyArray<ActionRespones> = [];

  private act(map: MapData, action: Action): MapData | null {
    const response = executeAction(map, action);
    this.responses.push(response);
    return response;
  }

  private endTurn(map: MapData): MapData | null {
    const currentMap = this.act(map, EndTurnAction());
    if (!currentMap) {
      throw new Error('Error executing end turn action.');
    }
    // Return `null` to indicate that the turn ended.
    return null;
  }

  public action(map: MapData): MapData | null {
    return this.endTurn(map);
  }
}

transition: leaveOnly

--

AI: Basics

let map = ;
const ai = new AthenaCrisisAI();
while (map?.getCurrentPlayer().isBot()) {
  map = ai.action(map);
}
const responses = ai.responses; // [{type: 'EndTurn'}]

AI: Move

class AthenaCrisisAI {
  public action(map: MapData): MapData | null {
    return this.move(map) || this.endTurn(map);
  }
  private move(map: MapData): MapData | null {
    const [from, unit] = map.units.findFirst(
      filterAvailableUnits(map.getCurrentPlayer()),
    );
    const positions = getInterestingPositions(map, from, unit);
    const clusters = calculateClusters(map, positions);
    const to = findPathToTargets(map, unit, clusters, from);
    return to
      ? this.act(currentMap, MoveAction(from, to))
      : this.act(currentMap, CompleteUnitAction(from));
  }
}

A* – A Star


transition: leaveOnly

--

A* – A Star


AI: Get Interesting Positions

function getInterestingPositions(
  map: MapData,
  from: Vector,
  unit: Unit,
): ReadonlyArray<Vector> {
  const isInDanger = !unit.info.hasAttack() || unit.isOutOfAmmo();
  if (isInDanger) {
    return map.buildings.filter((building) =>
      map.matchesPlayer(unit, building),
    );
  }

  if (unit.info.hasAbility(Ability.Capture)) {
    return map.buildings.filter((building) => map.isOpponent(unit, building));
  }

  return map.units.filter((unitB) => map.isOpponent(unit, unitB));
}

AI: Calculate Clusters


transition: leaveOnly

--

AI: Calculate Clusters

import skmeans from 'skmeans';

function calculateClusters(
  map: MapData,
  positions: ReadonlyArray<Vector>,
): ReadonlyArray<Vector> {
  return skmeans(positions, 3, 'kmpp', 10).centroids.map((centroid) =>
    position(Math.round(centroid[0]), Math.round(centroid[1])),
  );
}

AI: Find Path To Targets

function findPathToTargets(
  map: MapData,
  unit: Unit,
  targets: ReadonlyArray<Vector>,
  from: Vector,
): Vector | null {
  let [target, radius] = findClosestTarget(map, unit, targets, from);
  const moveableRadius = calculateRadius(map, unit, from);
  while (target) {
    if (moveableRadius.has(target.vector)) {
      return target.vector;
    }
    target = (target.parent && radius.get(target.parent)) || null;
  }
  return null;
}

AI: Move

class AthenaCrisisAI {
  public action(map: MapData): MapData | null {
    return this.move(map) || this.endTurn(map);
  }
  private move(map: MapData): MapData | null {
    const [from, unit] = map.units.findFirst(
      filterAvailableUnits(map.getCurrentPlayer()),
    );
    const positions = getInterestingPositions(map, from, unit);
    const clusters = calculateClusters(map, positions);
    const to = findPathToTargets(map, unit, clusters, from);
    return to
      ? this.act(currentMap, MoveAction(from, to))
      : this.act(currentMap, CompleteUnitAction(from));
  }
}

AI: Attack

class AthenaCrisisAI {
  public action(map: MapData): MapData | null {
    return this.attack(map) || this.move(map) || this.endTurn(map);
  }
  private attack(map: MapData): MapData | null {
    let {from, to} = getBestAttack(
      map,
      map.units.filter(filterAvailableUnits(map.getCurrentPlayer()))),
    );
    if (from && to) {
      let currentMap = map;
      if (from.distance(to.vector) > 1 && to.parent) {
        currentMap = this.act(currentMap, MoveAction(from, to.parent));
        from = to.parent;
      }
      return this.act(currentMap, AttackAction(from, to.vector));
    }
    return null;
  }
}

AI: Get Best Attack

function getBestAttack(
  map: MapData,
  units: ReadonlyArray<[Vector, Unit]>,
): Array<PossibleAttack> {
  const possibleAttacks: Array<PossibleAttack> = [];
  units.forEach(([position, unitA]) => {
    attackable(map, unitA, position).forEach((item) => {
      const { unit: unitB, vector } = item;
      const damage = calculateLikelyDamage(unitA, unitB, map);

      // Weight = Damage!?
      let weight = damage;

      possibleAttacks.push({
        unitB,
        from: position,
        to: item,
        weight,
      });
    });
  });
  return maxBy(possibleAttacks, ({ weight }) => weight);
}

AI: Attack Weights

let weight = damage; // Initialize `weight` with `damage`.
const isKill = damage >= unitB.health;
if (isKill) {
  // Boost the weight if the unit will be killed.
  weight = (1 / Math.max(damage, 5)) * Math.pow(MaxHealth, 2);
  // Skip low damage actions.
} else if (
  damage < 10 &&
  unitB.info.hasAttack() &&
  !unitA.info.isLongRange() &&
  position.distance(vector) === 1
) {
  const counter = calculateLikelyDamage(
    unitB.modifyHealth(-damage),
    unitA,
    map,
  );
  // Skip if the counter attack is worse.
  // No suicide.
  if (counter > damage || counter > unitA.health) {
    return;
  }
}
const building = map.buildings.get(vector);
if (unitB.canCapture() && building) {
  weight *= unitB.isCapturing() ? 10 : 1;
  // Prioritize specific unit states.
} else if (
  unitB.isTransportingUnits() ||
  unitB.info.isLongRange() ||
  building?.info.isPlayerHQ()
) {
  weight *= 6;
  // Prioritize if the unit has an attack.
} else if (unitB.info.hasAttack()) {
  weight *= 5;
} else if () {
  
}

AI: Attack

class AthenaCrisisAI {
  public action(map: MapData): MapData | null {
    return this.attack(map) || this.move(map) || this.endTurn(map);
  }
  private attack(map: MapData): MapData | null {
    let {from, to} = getBestAttack(
      map,
      map.units.filter(filterAvailableUnits(map.getCurrentPlayer()))),
    );
    if (from && to) {
      let currentMap = map;
      if (from.distance(to.vector) > 1 && to.parent) {
        currentMap = this.act(currentMap, MoveAction(from, to.parent));
        from = to.parent;
      }
      return this.act(currentMap, AttackAction(from, to.vector));
    }
    return null;
  }
}

What's next?

  • Add support for all unit and building capabilities
  • Support "fog-of-war" by limiting the AIs vision
  • Add customizable AI Behaviors: aggressive, defensive, passive etc.
  • Predictive Optimization & Planning

ReMDX


image: CoverEmpty.jpg theme: dark

Nakazawa Tech KK

Work with us!

- [nakazawa.dev](https://nakazawa.dev)

- [[email protected]](mailto:[email protected])