'use strict';

import {
  CardId,
  Effect,
  EffectSource,
  EngineNode,
  EventCard,
  FactionId,
  FrontId,
  FunctionNode,
  Game,
  Icon,
  StateNode,
  PlayCardArgs,
  Player,
  PlayerCard,
  PlayerTurnArgs,
  SeqNode,
  States,
  View,
  FascistId,
  ClosestToDefeat,
  ClosestToVictory,
} from './types';

import data, {
  ANARCHIST,
  COMMUNIST,
  MODERATE,
  ANARCHISTS_ID,
  COMMUNISTS_ID,
  MODERATES_ID,
  LIBERTY,
  CLOSEST_TO_DEFEAT,
  CLOSEST_TO_VICTORY,
  COLLECTIVIZATION,
  GOVERNMENT,
  INITIATIVE_PLAYER,
  SOVIET_SUPPORT,
  FOREIGN_AID,
  ON,
  PLAYER_WITH_MOST_HERO_POINTS,
  SELF,
  ANY,
  TEAMWORK_BONUS,
  MORALE_BONUS,
  OFF,
  VICTORY,
  DEFEAT,
  FRONTS,
  create_effect,
  AWAY_FROM_CENTER,
  TOWARDS_CENTER,
  ARCHIVES_MEDALLION_ID,
  INTELLIGENCE_MEDALLION_ID,
  ORGANIZATION_MEDALLION_ID,
  STRATEGY_MEDALLION_ID,
  PROPAGANDA_MEDALLION_ID,
  VOLUNTEERS_MEDALLION_ID,
  ALL_PLAYERS,
  OTHER_PLAYERS,
  MADRID,
  SOUTHERN,
  COMMUNIST_EXTRA_HERO_POINT,
  LIBERTY_OR_COLLECTIVIZATION,
  ANARCHIST_EXTRA_HERO_POINT,
  ARAGON,
  FASCIST_ID,
  NORTHERN,
} from './data';

const OBSERVER = 'Observer';

const states = {} as States;
let game = {} as Game; // = null
var view = {} as View; // = null

const POOL_ID = 3;

const role_ids: FactionId[] = [ANARCHISTS_ID, COMMUNISTS_ID, MODERATES_ID];

const faction_player_map: Record<FactionId, Player> = [
  ANARCHIST,
  COMMUNIST,
  MODERATE,
];

const player_faction_map: Record<Player, FactionId> = {
  [ANARCHIST]: ANARCHISTS_ID,
  [COMMUNIST]: COMMUNISTS_ID,
  [MODERATE]: MODERATES_ID,
};

const front_names = [
  'Aragon Front',
  'Madrid Front',
  'Northern Front',
  'Southern Front',
  'the Front closest to Defeat',
  'the Front closest to Victory',
];

const bonus_names: string[] = ['Morale Bonus', 'Teamwork Bonus'];

const {
  cards,
  medallions,
  //  fronts,
  tracks,
} = data;

const bonuses = [MORALE_BONUS, TEAMWORK_BONUS];

const faction_cards = [
  make_list(37, 54) as CardId[],
  make_list(19, 36) as CardId[],
  make_list(1, 18) as CardId[],
];

const fascist_decks = {
  1: make_list(55, 72) as CardId[],
  2: make_list(73, 90) as CardId[],
  3: make_list(91, 108) as CardId[],
};

export const scenarios = ['Standard'];

export const roles: readonly Player[] = [ANARCHIST, COMMUNIST, MODERATE];

function gen_action(action: string, argument?: number | string) {
  if (argument === undefined) {
    view.actions![action] = 1;
  } else {
    if (!(action in view.actions)) view.actions[action] = [];
    view.actions[action].push(argument);
  }
}

function gen_action_blank_marker(marker_id: number) {
  gen_action('blank_marker', marker_id);
}

function gen_action_bonus(bonus_id: number) {
  gen_action('bonus', bonus_id);
}

function gen_action_card(card_id: CardId) {
  gen_action('card', card_id);
}

function gen_action_front(front_id: FrontId) {
  gen_action('front', front_id);
}

function gen_action_medallion(medallion_id: number) {
  gen_action('medallion', medallion_id);
}

function gen_spend_hero_points() {
  const faction = get_active_faction();
  const can_spend_hp =
    game.faction_turn === faction && game.hero_points[faction] > 0;
  if (can_spend_hp) {
    gen_action('spend_hp');
  }
}

export function action(
  state: Game,
  player: Player,
  action: string,
  arg: unknown
) {
  game = state;

  if (action !== 'undo' && game.state !== 'choose_card') {
    push_undo();
  }

  let S = states[game.state];
  if (action in S) S[action](arg, player);
  else if (action === 'undo' && game.undo && game.undo.length > 0) pop_undo();
  else throw new Error('Invalid action: ' + action);
  return game;
}

// #region ENGINE

const state_node = 'l';
const seq_node = 's';
const function_node = 'f';
const resolved = 1;

function create_state_node<T = any>(
  state: string,
  faction: FactionId | 'None' | 'all' | FactionId[],
  args?: T
): StateNode {
  return {
    t: state_node,
    s: state,
    p: faction,
    a: args,
    r: 0,
  };
}

function create_function_node(func_name: string, args?: any): FunctionNode {
  return {
    t: function_node,
    f: func_name,
    a: args,
    r: 0,
  };
}

function create_seq_node(children: EngineNode[]): SeqNode {
  return {
    t: seq_node,
    c: children,
  };
}

// Use to force confirm question if possible to undo
function checkpoint() {
  if (game.undo.length > 0) {
    insert_after_active_node(
      create_state_node('confirm_turn', get_active_faction())
    );
  }
  resolve_active_and_proceed();
}

function setup_bag_of_glory() {
  game.engine = [
    create_state_node('add_glory', game.initiative),
    create_function_node('end_of_turn'),
  ];
  next();
}

function setup_choose_card() {
  game.fascist = 0;

  game.engine = [create_state_node('choose_card', 'all')];

  game.engine.push(create_function_node('setup_player_turn'));
  next();
}

function setup_final_bid() {
  game.fascist = 0;

  log_header('Final Bid', 't');
  const player_order = get_player_order();
  game.engine = player_order.map((faction_id) =>
    create_state_node('choose_final_bid', faction_id)
  );
  game.engine.push(create_function_node('checkpoint'));
  game.engine.push(create_function_node('resolve_final_bid'));
  game.engine.push(create_function_node('setup_choose_card'));
  next();
}

function setup_player_turn() {
  game.fascist = 0;
  game.card_played = 0;

  const next_faction =
    game.first_player === null
      ? get_player_order()[0]
      : get_next_faction_in_player_order(get_active_faction());

  if (game.first_player === null) {
    game.first_player = next_faction;
  }

  game.engine = [
    create_function_node('start_of_player_turn', { f: next_faction }),
    create_state_node('player_turn', next_faction),
    create_function_node('end_of_player_turn', { f: next_faction }),
  ];

  next();
}

// Check if player needs to discard cards. If so inserts state node
function check_end_of_year_discard() {
  const { f: faction } = get_active_node_args();

  if (
    game.hands[faction].length > get_hand_limit(faction) ||
    game.tableaus[faction].length > game.year
  ) {
    insert_after_active_node(create_state_node('end_of_year_discard', faction));
  }

  resolve_active_and_proceed();
}

function end_of_player_turn() {
  const { f: faction } = get_active_node_args();
  if (get_next_faction_in_player_order(faction) === game.first_player) {
    game.engine = [
      create_state_node('change_active_player', game.initiative),
      create_function_node('resolve_fascist_test'),
      create_function_node('setup_bag_of_glory'),
    ];
  } else {
    game.engine = [create_function_node('setup_player_turn')];
  }
  next();
}

function start_of_player_turn() {
  const args = get_active_node_args();
  game.faction_turn = args.f;
  game.played_card = game.selected_cards[args.f][0];
  log_header("C" + game.played_card, args.f);
  resolve_active_and_proceed();
}

const engine_functions: Record<string, Function> = {
  check_end_of_year_discard,
  checkpoint,
  end_of_player_turn,
  end_of_turn,
  end_of_year_cleanup,
  end_resolving_event_effects,
  setup_bag_of_glory,
  setup_choose_card,
  setup_final_bid,
  setup_player_turn,
  start_of_player_turn,
  start_year,
  resolve_fascist_test,
  resolve_final_bid,
  log_trigger,
  // Unique card effects
  card1_event2,
  card3_event2,
  card10_event2,
  card16_event2,
  card17_event3,
  card20_event3,
  card22_event3,
  card23_event1,
  card26_event1,
  card29_event2,
  card35_event2,
  card42_event3,
  card45_event2,
  card46_event3,
  card50_event2,
  card53_event2,
  card54_event1,
  setup_return_card_from_trash,
  trash_card,
};

function get_active(
  engine: EngineNode[]
): { parent: EngineNode[]; node: FunctionNode | StateNode } | null {
  for (let i of engine) {
    if ((i.t === state_node || i.t === function_node) && i.r !== resolved) {
      return { parent: engine, node: i };
    }
    if (i.t === seq_node) {
      const next_child = get_active(i.c);
      if (next_child !== null) {
        return next_child;
      }
    }
  }
  return null;
}

function get_active_node(
  engine: EngineNode[] = game.engine
): FunctionNode | StateNode | null {
  const a = get_active(engine);
  return a === null ? null : a.node;
}

function get_nodes_for_state(
  state: string,
  engine: EngineNode[] = game.engine
): StateNode[] {
  let nodes = [];
  for (let i of engine) {
    if (i.t === state_node && i.s === state) {
      nodes.push(i);
    }
    if (i.t === seq_node) {
      nodes = nodes.concat(get_nodes_for_state(state, i.c));
    }
  }
  return nodes;
}

function get_active_node_args<T = any>(): T {
  const node = get_active_node(game.engine);
  if (node.t === state_node || node.t === function_node) {
    return node.a ?? {};
  }
  return null;
}

function update_active_node_args<T = any>(args: Partial<T>) {
  const node = get_active_node(game.engine);
  if (node.t === state_node || node.t === function_node) {
    node.a = {
      ...node.a,
      ...args,
    };
  }
}

function insert_before_or_after_active_node(
  node: EngineNode,
  position: 'before' | 'after',
  engine: EngineNode[] = game.engine
) {
  const a = get_active(engine);
  if (a === null) {
    return;
  }
  const i = a.parent.indexOf(a.node);
  if (i >= 0) {
    array_insert(a.parent, i + (position == 'after' ? 1 : 0), node);
  }
}

function insert_after_active_node(
  node: EngineNode,
  engine: EngineNode[] = game.engine
) {
  insert_before_or_after_active_node(node, 'after', engine);
}

function insert_before_active_node(
  node: EngineNode,
  engine: EngineNode[] = game.engine
) {
  insert_before_or_after_active_node(node, 'before', engine);
}

function get_next_active(p: StateNode['p']): Player | Player[] | 'None' {
  if (Array.isArray(p)) {
    return p.map((faction) => faction_player_map[faction]);
  }
  if (p === 'all') {
    return roles.slice();
  }
  if (p === 'None') {
    return 'None';
  } else {
    return faction_player_map[p];
  }
}

function next(checkpoint = false) {
  if (checkpoint) {
    clear_undo();
  }
  const node = get_active_node(game.engine);
  if (node.t === function_node && engine_functions[node.f]) {
    const args = node.a;
    if (args !== undefined) {
      engine_functions[node.f](args);
    } else {
      engine_functions[node.f]();
    }
  } else if (node.t === 'l') {
    game.state = node.s;

    // Control switches to another player and player can undo
    // so ask to confirm turn
    const current_active = game.active;

    const next_active = get_next_active(node.p);

    if (next_active !== current_active && game.undo.length > 0) {
      insert_before_active_node(
        create_state_node('confirm_turn', get_active_faction())
      );
      game.state = 'confirm_turn';
      return;
    }
    game.active = next_active;
    if (states[game.state].auto_resolve && states[game.state].auto_resolve()) {
      resolve_active_and_proceed();
    }
  }
}

function resolve_active_node() {
  const next_node = get_active_node(game.engine);
  if (next_node !== null) {
    next_node.r = resolved;
  }
}

function resolve_active_and_proceed(checkpoint = false) {
  resolve_active_node();
  next(checkpoint);
}

// #endregion

// #region VIEW

export { game_view as view };

function game_view(state: Game, current: Player | 'Observer') {
  game = state;

  const faction: FactionId | null =
    current === OBSERVER ? null : player_faction_map[current];

  view = {
    log: game.log,
    prompt: null,
    bonuses: game.bonuses,
    current_events: game.current_events,
    first_player: game.first_player,
    fronts: game.fronts,
    glory: game.glory,
    hand: faction === null ? [] : game.hands[faction],
    // discard: faction === null ? [] : game.discard[faction],
    // trash: faction === null ? [] : game.trash[faction],
    // deck: faction === null ? [] : list_deck(faction),
    hero_points: game.hero_points,
    initiative: game.initiative,
    medallions: game.medallions,
    played_card: game.played_card,
    player_order:
      current === OBSERVER
        ? get_player_order()
        : get_player_order_in_game(faction),
    selected_cards: current === OBSERVER ? [] : game.selected_cards[faction],
    tableaus: game.tableaus,
    tracks: game.tracks,
    triggered_track_effects: game.triggered_track_effects,
    used_medallions: game.used_medallions,
    year: game.year,
    fascist: game.fascist,
  };

  if (!game.hidden_bag)
    view.bag_of_glory = game.bag_of_glory;

  if (game.state === 'game_over') {
    view.prompt = game.victory;
  } else if (
    current !== game.active &&
    !game.active.includes(current as Player)
  ) {
    let inactive = states[game.state].inactive || game.state;
    view.prompt = Array.isArray(game.active)
      ? `Waiting for ${game.active.join(' and ')} to ${inactive}.`
      : `Waiting for ${game.active} to ${inactive}.`;
  } else {
    view.actions = {};
    if (game.undo && game.undo.length > 0) view.actions.undo = 1;
    else view.actions.undo = 0;
    states[game.state].prompt(current);

    let node = get_active_node()
    if (node && node.a && node.a.src)
      view.prompt = get_source_name(node.a.src) + ": " + view.prompt
  }

  return view;
}

// #endregion

// #region SETUP

export function setup(seed: number, _scenario: string, options: Record<string,boolean>) {
  // game.seed = seed;
  game = {
    seed: seed,
    state: null,
    active: ANARCHIST,
    active_abilities: [],
    bag_of_glory: [ANARCHISTS_ID, COMMUNISTS_ID, MODERATES_ID],
    bonuses: [ON, ON],
    current_events: [],
    discard: [
      [],
      [],
      [],
      [],
    ],
    engine: [],
    faction_turn: null,
    fronts: [
      {
        value: -2,
        contributions: [],
        status: null,
      },
      {
        value: -2,
        contributions: [],
        status: null,
      },
      {
        value: -2,
        contributions: [],
        status: null,
      },
      {
        value: -2,
        contributions: [],
        status: null,
      },
    ],
    glory: [],
    first_player: null,
    hands: [
      [],
      [],
      [],
    ],
    hero_points: [2, 2, 0, 14],
    initiative: MODERATES_ID,
    medallions: [[],[],[],[]],
    played_card: null,
    player_order: [MODERATE],
    selected_cards: [
      [],
      [],
      [],
    ],
    tableaus: [
      [],
      [],
      [],
    ],
    tracks: [5, 5, 6, 3, 3],
    trash: [
      [],
      [],
      [],
    ],
    triggered_track_effects: [],
    log: [],
    undo: [],
    used_medallions: [],
    top_of_events_deck: null,
    turn: 0,
    year: 0,
    glory_current_year: null,
    fascist: 0,
    card_played: 0,
  };

  log_header('Land and Freedom', 't')

  if (options.hidden_bag)
    game.hidden_bag = 1;

  // Randomly choose second player
  game.player_order.push(roles[random(2)]);
  // Remaining role is 3rd player
  game.player_order.push(
    game.player_order[1] === ANARCHIST ? COMMUNIST : ANARCHIST
  );

  draw_medallions();

  start_year();
  return game;
}

function draw_hand_cards(faction_id: FactionId, count: number, indent = true) {
  const deck = list_deck(faction_id);

  if (game.medallions[faction_id].includes(INTELLIGENCE_MEDALLION_ID)) {
    count++;
  }
  let drawn_cards = 0;

  // Draw all remaining cards
  if (deck.length < count) {
    count = count - deck.length;
    drawn_cards += deck.length;
    game.hands[faction_id] = game.hands[faction_id].concat(deck);
    game.discard[faction_id] = [];
  }

  for (let i = 0; i < count; i++) {
    const deck = list_deck(faction_id);
    if (deck.length > 0) {
      game.hands[faction_id].push(draw_card(deck));
      drawn_cards++;
    }
  }

  if (indent)
    logi(`${get_player(faction_id)} +${drawn_cards} cards`);
  else
    log(`${get_player(faction_id)} +${drawn_cards} cards.`);
}

// #endregion

function start_year() {
  game.year++;
  log_header(`Year ${game.year}`, 't');
  game.turn = 1;
  game.current_events = [];
  role_ids.forEach((role) => {
    draw_hand_cards(role, 5, false);
  });
  start_turn();
}

function start_turn() {
  log_header(`Turn ${game.turn}`, 't');

  const cardId = draw_fascist_card();
  game.current_events.push(cardId);

  const card = cards[cardId] as EventCard;
  log_header("C" + cardId, 'f')
  log("Fascist Event:")

  game.fascist = 1;
  game.engine = card.effects.map((effect) =>
    resolve_effect(effect, 'fascist_event')
  );
  game.engine.push(create_state_node('confirm_fascist_turn', game.initiative, { src: 'fascist_event' }));
  if (game.year === 3 && game.turn === 4) {
    game.engine.push(create_function_node('setup_final_bid'));
  } else {
    game.engine.push(create_function_node('setup_choose_card'));
  }
  next();
}

// region STATES

function player_can_resolve_icon(icon: Icon): boolean {
  if (icon === 'teamwork_on' && game.bonuses[TEAMWORK_BONUS] === ON) {
    return false;
  }
  return true;
}

const track_icon_to_track_id_map = {
  collectivization: COLLECTIVIZATION,
  d_collectivization: COLLECTIVIZATION,
  foreign_aid: FOREIGN_AID,
  d_foreign_aid: FOREIGN_AID,
  government: GOVERNMENT,
  d_government: GOVERNMENT,
  liberty: LIBERTY,
  d_liberty: LIBERTY,
  soviet_support: SOVIET_SUPPORT,
  d_soviet_support: SOVIET_SUPPORT,
};

states.activate_icon = {
  inactive: 'activate an icon',
  prompt() {
    gen_spend_hero_points();
    const c = cards[game.played_card] as PlayerCard;

    view.prompt = 'Morale Bonus: ';
    view.prompt += join_oxford_comma(c.icons.map(get_icon_name), "or");
    view.prompt += '.';
    let can_activate_icon = false;
    for (const i of c.icons) {
      const count = get_icon_count_in_tableau(i);
      let direction = 0;
      switch (i) {
        case 'add_to_front':
          const possible_fronts = get_fronts_to_add_to(ANY);
          for (let f of possible_fronts) {
            gen_action_front(f);
          }
          if (possible_fronts.length > 0) {
            can_activate_icon = true;
          }
          break;
        case 'collectivization':
        case 'foreign_aid':
        case 'liberty':
        case 'soviet_support':
          const can_move_ss = gen_move_track(
            track_icon_to_track_id_map[i],
            game.tracks[track_icon_to_track_id_map[i]] + count
          );
          can_activate_icon = can_activate_icon || can_move_ss;
          break;
        case 'government':
          direction = game.active === COMMUNIST ? -1 : 1;
          const can_move_g = gen_move_track(
            track_icon_to_track_id_map[i],
            game.tracks[track_icon_to_track_id_map[i]] + direction * count
          );
          can_activate_icon = can_activate_icon || can_move_g;
          break;
        case 'd_collectivization':
        case 'd_foreign_aid':
        case 'd_government':
        case 'd_liberty':
        case 'd_soviet_support':
          const can_move_t = gen_move_track(
            track_icon_to_track_id_map[i],
            game.tracks[track_icon_to_track_id_map[i]] - count
          );
          can_activate_icon = can_activate_icon || can_move_t;
          break;
        case 'government_to_center':
          direction = game.tracks[GOVERNMENT] >= 6 ? -1 : 1;
          const can_move_gtoc = gen_move_track(
            track_icon_to_track_id_map[i],
            game.tracks[track_icon_to_track_id_map[i]] + direction * count
          );
          can_activate_icon = can_activate_icon || can_move_gtoc;
          break;
        case 'teamwork_on':
          if (game.bonuses[TEAMWORK_BONUS] === OFF) {
            gen_action_bonus(TEAMWORK_BONUS);
            can_activate_icon = true;
          } else {
            can_activate_icon = false;
          }
          break;
        default:
          gen_action(i);
      }

      if (!player_can_resolve_icon(i)) {
        view.actions[i] = 0;
      }
      if (!can_activate_icon) {
        gen_action('skip');
      }
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  front(f: FrontId) {
    update_front(
      f,
      get_icon_count_in_tableau('add_to_front'),
      get_active_faction()
    );
    resolve_active_and_proceed();
  },
  tr0(x: number) {
    if (can_use_medallion(ORGANIZATION_MEDALLION_ID)) {
      insert_use_organization_medallion_node(LIBERTY, x);
    } else {
      move_track_to(0, x);
    }
    resolve_active_and_proceed();
  },
  tr1(x: number) {
    if (can_use_medallion(ORGANIZATION_MEDALLION_ID)) {
      insert_use_organization_medallion_node(COLLECTIVIZATION, x);
    } else {
      move_track_to(1, x);
    }
    resolve_active_and_proceed();
  },
  tr2(x: number) {
    if (can_use_medallion(ORGANIZATION_MEDALLION_ID)) {
      insert_use_organization_medallion_node(GOVERNMENT, x);
    } else {
      move_track_to(2, x);
    }
    resolve_active_and_proceed();
  },
  tr3(x: number) {
    if (can_use_medallion(ORGANIZATION_MEDALLION_ID)) {
      insert_use_organization_medallion_node(SOVIET_SUPPORT, x);
    } else {
      move_track_to(3, x);
    }
    resolve_active_and_proceed();
  },
  tr4(x: number) {
    if (can_use_medallion(ORGANIZATION_MEDALLION_ID)) {
      insert_use_organization_medallion_node(FOREIGN_AID, x);
    } else {
      move_track_to(4, x);
    }
    resolve_active_and_proceed();
  },
  draw_card() {
    draw_hand_cards(
      get_active_faction(),
      get_icon_count_in_tableau('draw_card')
    );
    resolve_active_and_proceed();
  },
  bonus(b: number) {
    update_bonus(b, ON);
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

states.add_card_to_tableau = {
  inactive: 'add a card to their tableau',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Add a card to your tableau.';
    const faction = get_active_faction();
    for (const c of game.hands[faction]) {
      gen_action_card(c);
    }
    if (game.hands[faction].length === 0) {
      gen_action('skip');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  card(c: CardId) {
    const faction_id = get_active_faction();
    array_remove(game.hands[faction_id], game.hands[faction_id].indexOf(c));
    game.tableaus[faction_id].push(c);
    logp(`added C${c} to their tableau`);
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

states.add_glory = {
  inactive: 'add tokens to the Bag of Glory',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Add tokens to the Bag of Glory.';
    gen_action('add_glory');
  },
  spend_hp() {
    resolve_spend_hp();
  },
  add_glory() {
    let number = 1;
    if (game.turn === 4) {
      number++;
    }
    log_br();
    log("Bag of Glory:");
    add_glory(get_active_faction(), number);
    resolve_active_and_proceed();
  },
};

states.add_to_front = {
  inactive: 'support a Front',
  prompt() {
    gen_spend_hero_points();
    const args = get_active_node_args();
    const possible_fronts = get_fronts_to_add_to(args.t);
    if (possible_fronts.length === 0) {
      view.prompt = 'No valid front to add strength to.'
      gen_action('skip');
    } else if (possible_fronts.length === 4) {
      view.prompt = `Support any Front.`;
    } else {
      view.prompt = `Support ${join_oxford_comma(possible_fronts.map(x => front_names[x]), 'or')}.`;
    }

    for (let f of possible_fronts) {
      gen_action_front(f);
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  front(f: FrontId) {
    const value = get_active_node_args().v;
    update_front(f, value, get_active_faction());
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

states.attack_front = {
  inactive: 'attack a Front',
  prompt() {
    gen_spend_hero_points();
    const { t: target, n } = get_active_node_args();

    const possible_fronts = get_fronts_to_add_to(target, n);
    const number_of_fronts = possible_fronts.length;

    if (number_of_fronts === 0) {
      view.prompt = 'No valid front to attack.';
      gen_action('skip');
    } else if (possible_fronts.length === 4) {
      view.prompt = `Attack any Front.`;
    } else {
      view.prompt = `Attack ${join_oxford_comma(possible_fronts.map(x => front_names[x]), 'or')}.`;
    }

    possible_fronts.forEach((id) => gen_action('front', id));
  },
  spend_hp() {
    resolve_spend_hp();
  },
  front(f: FrontId) {
    const value = get_active_node_args().v;
    update_front(f, value);
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

states.break_tie_final_bid = {
  inactive: 'break tie for Final Bid',
  prompt() {
    view.prompt = 'Choose the winner of the Final Bid';
    const { winners } = get_active_node_args();
    for (const f of winners) {
      gen_action(faction_player_map[f]);
    }
  },
  Anarchist() {
    win_final_bid(ANARCHISTS_ID);
    resolve_active_and_proceed();
  },
  Communist() {
    win_final_bid(COMMUNISTS_ID);
    resolve_active_and_proceed();
  },
  Moderate() {
    win_final_bid(MODERATES_ID);
    resolve_active_and_proceed();
  },
};

states.break_tie_winner = {
  inactive: 'break tie for winner of the game',
  prompt() {
    view.prompt = 'Choose the winner of the game';
    const { winners } = get_active_node_args();
    for (const f of winners) {
      gen_action(faction_player_map[f]);
    }
  },
  Anarchist() {
    const { glory } = get_active_node_args();
    win_game(ANARCHIST, glory);
    resolve_active_and_proceed();
  },
  Communist() {
    const { glory } = get_active_node_args();
    win_game(COMMUNIST, glory);
    resolve_active_and_proceed();
  },
  Moderate() {
    const { glory } = get_active_node_args();
    win_game(MODERATE, glory);
    resolve_active_and_proceed();
  },
};

/**
 * Change does not do anything, but it will change
 * active player and trigger a confirm for the previous
 * state
 */
states.change_active_player = {
  inactive: '',
  auto_resolve() {
    return true;
  },
  prompt() {
    view.prompt = '';
  },
};

states.choose_area_ap = {
  inactive: 'choose area to use Action Points',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Use Action Points.';
    const strength: number = get_active_node_args().strength;
    let can_use_ap = false;
    for (const track of tracks) {
      can_use_ap = gen_move_track_change(track.id, strength) || can_use_ap;
    }
    const fronts = get_fronts_to_add_to(ANY);
    if (fronts.length > 0) {
      can_use_ap = true;
    }
    for (const front of fronts) {
      gen_action_front(front);
    }
    for (const bonus of bonuses) {
      if (game.bonuses[bonus] === OFF) {
        gen_action_bonus(bonus);
        can_use_ap = true;
      }
    }
    if (!can_use_ap) {
      gen_action('skip');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  bonus(b: number) {
    // Turn on bonus
    update_bonus(b, ON);
    const s: number = get_active_node_args().strength;

    // Check if other bonus can be activated
    const other_bonus = b === TEAMWORK_BONUS ? MORALE_BONUS : TEAMWORK_BONUS;
    if (s > 1 && game.bonuses[other_bonus] === OFF) {
      insert_after_active_node(
        resolve_effect(create_effect('bonus', other_bonus, ON))
      );
    }
    resolve_active_and_proceed();
  },
  front(f: FrontId) {
    const s: number = get_active_node_args().strength;
    update_front(f, s, get_active_faction());
    resolve_active_and_proceed();
  },
  tr0(x: number) {
    move_track_to(0, x);
    resolve_active_and_proceed();
  },
  tr1(x: number) {
    move_track_to(1, x);
    resolve_active_and_proceed();
  },
  tr2(x: number) {
    move_track_to(2, x);
    resolve_active_and_proceed();
  },
  tr3(x: number) {
    move_track_to(3, x);
    resolve_active_and_proceed();
  },
  tr4(x: number) {
    move_track_to(4, x);
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

states.change_bonus = {
  inactive: 'select Bonus',
  prompt() {
    gen_spend_hero_points();
    const args = get_active_node_args();
    if (
      (args.v === ON &&
        game.bonuses[TEAMWORK_BONUS] === ON &&
        game.bonuses[MORALE_BONUS] === ON) ||
      (args.v === OFF && game.bonuses[args.t] === OFF)
    ) {
      gen_action('skip');
    }
    if (args.t === ANY && args.v === ON) {
      view.prompt = 'Turn on a Bonus.';
      for (const bonus of bonuses) {
        if (game.bonuses[bonus] === OFF) {
          gen_action_bonus(bonus);
        }
      }
    } else {
      view.prompt = `Turn ${args.v === OFF ? 'off' : 'on'} ${
        bonus_names[args.t]
      }.`;
      gen_action_bonus(args.t);
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  bonus(b: number) {
    const value = get_active_node_args().v;
    update_bonus(b, value);
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

// Used for effects that allow play of an extra card
states.play_card = {
  inactive: 'choose a card',
  prompt() {
    gen_spend_hero_points();

    view.prompt = 'Play a card.';

    const faction = get_active_faction();

    const hand = game.hands[faction];
    for (let c of hand) {
      if (!game.selected_cards[faction].includes(c)) {
        gen_action_card(c);
      }
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  card(c: CardId) {
    const faction = get_active_faction();
    game.selected_cards[faction] = [c];

    game.card_played = 0;
    // NOTE: I don't think we are using game.played_card in the UI at the moment?
    game.played_card = game.selected_cards[faction][0];

    resolve_active_and_proceed();
  },
};

// Multiactive choose card state
states.choose_card = {
  inactive: 'choose a card',
  prompt(player: Player) {
    gen_spend_hero_points();
    view.prompt = 'Choose a card to play this turn.';

    const faction = player_faction_map[player];

    if (game.selected_cards[faction].length === 0) {
      view.actions.undo = 0;
      const hand = game.hands[faction];
      for (let c of hand) {
        if (!game.selected_cards[faction].includes(c)) {
          gen_action_card(c);
        }
      }
    } else {
      view.actions.undo = 1;
      view.actions.confirm = 1;
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  card(c: CardId, player: Player) {
    const faction = player_faction_map[player];
    game.selected_cards[faction] = [c];
  },
  undo(_, player: Player) {
    const faction = player_faction_map[player];
    game.selected_cards[faction] = [];
  },
  confirm(_, player: Player) {
    set_delete(game.active as Player[], player);
    if (game.active.length === 0) {
      resolve_active_and_proceed();
    }
  },
};

states.choose_final_bid = {
  inactive: 'choose Final Bid',
  prompt() {
    view.prompt = 'Add a card to the Final Bid.';
    const faction = get_active_faction();
    for (let c of game.hands[faction]) {
      if (!game.selected_cards[faction].includes(c)) {
        gen_action_card(c);
      }
    }
    gen_action('done');
  },
  card(c: CardId) {
    const faction = get_active_faction();
    game.selected_cards[faction].push(c);
    const number_selected = game.selected_cards[faction].length;

    const number_hand = game.hands[faction].length;
    if (
      number_selected === 3 ||
      (number_hand < 4 && number_selected === number_hand - 1)
    ) {
      resolve_active_and_proceed();
    } else {
      next();
    }
  },
  done() {
    resolve_active_and_proceed(true);
  },
};

function setup_momentum() {
  const faction = get_active_faction();

  // Player received medallion outside of their normal turn
  // and must be resolved
  if (game.faction_turn !== faction) {
    insert_after_active_node(
      resolve_effect(create_effect('play_card', faction, 1), 'momentum')
    );
    return;
  }

  // Player gets medallion during their turn. Need to check if it can be player
  // right away or not. Depends on whether card for this turn has been fully resolved or not

  // Get player turn node
  const node: StateNode<PlayerTurnArgs> = get_nodes_for_state('player_turn')[0];

  const player_needs_to_play_card = !game.card_played;
  const { use_ap, use_morale_bonus, resolving_event } =
    node.a ?? ({} as PlayerTurnArgs);

  if (
    player_needs_to_play_card ||
    use_ap ||
    use_morale_bonus ||
    resolving_event
  ) {
    // Player hasn't fully resolved this turns card. Update args to enable button
    node.a = {
      ...(node.a || {}),
      use_momentum: true,
    };
  } else {
    // Player can resolve choosing a new card
    insert_after_active_node(
      create_state_node<PlayCardArgs>('play_card', faction, {
        src: 'momentum',
      })
    );
  }
}

states.choose_medallion = {
  inactive: 'earn a medallion',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Earn a Medallion.';
    for (let m of game.medallions[POOL_ID]) {
      gen_action_medallion(m);
    }
    if (!game.medallions[POOL_ID].some((m) => m !== null)) {
      gen_action('skip');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  medallion(m: number) {
    const faction = get_active_faction();
    const medallion = medallions[m];

    logp(`earned ${medallion.name}`);

    const index = game.medallions[POOL_ID].indexOf(m);

    game.medallions[POOL_ID][index] = null;

    switch (m) {
      case 0:
        add_glory(faction, 1);
        break;
      case 1:
        gain_hero_points(faction, 7);
        break;
      case 2:
        setup_momentum();
        break;
      default:
        game.medallions[faction].push(m);
    }
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

states.confirm_turn = {
  inactive: 'confirm their turn',
  prompt() {
    view.prompt = 'You will not be able to undo this action.';
    gen_action('confirm');
  },
  confirm() {
    resolve_active_and_proceed(true);
  },
};

states.confirm_fascist_turn = {
  inactive: 'confirm fascist turn',
  prompt() {
    view.prompt = "Done.";
    gen_action('confirm');
  },
  confirm() {
    resolve_active_and_proceed(true);
  },
};

states.draw_card = {
  inactive: 'draw a card',
  auto_resolve() {
    const { src, v } = get_active_node_args();
    if (src !== 'fascist_test') {
      return false;
    }
    draw_hand_cards(get_active_faction(), v);
    return true;
  },
  prompt() {
    gen_spend_hero_points();
    const { v } = get_active_node_args();
    view.prompt = v === 1 ? 'Draw a card.' : `Draw ${v} cards.`;
    gen_action(v === 1 ? 'draw_card' : 'draw_cards');
  },
  spend_hp() {
    resolve_spend_hp();
  },
  draw_card() {
    const { v } = get_active_node_args();
    draw_hand_cards(get_active_faction(), v);
    resolve_active_and_proceed(true);
  },
  draw_cards() {
    const { v } = get_active_node_args();
    draw_hand_cards(get_active_faction(), v);
    resolve_active_and_proceed(true);
  },
};

states.draw_glory = {
  inactive: 'draw from the Bag of Glory',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Draw from the Bag of Glory.';
    gen_action('draw_glory');
  },
  draw_glory() {
    const index = random(game.bag_of_glory.length);
    const faction = game.bag_of_glory[index];

    game.glory.push(faction);

    game.glory_current_year = game.glory_current_year = [
      false,
      false,
      false,
    ];

    game.glory_current_year[faction] = true;

    array_remove(game.bag_of_glory, index);

    logi(`Pulled T${faction} from the Bag`);

    resolve_active_and_proceed(true);
  },
};

states.end_of_year_discard = {
  inactive: 'discard cards from hand and tableau',
  prompt() {
    const faction_id = get_active_faction();
    const hand = game.hands[faction_id];
    const hand_limit = get_hand_limit(faction_id);
    const needs_to_discard_from_hand = hand.length > hand_limit;
    if (needs_to_discard_from_hand) {
      for (let c of hand) gen_action_card(c);
    }
    const tableau = game.tableaus[faction_id];
    const needs_to_discard_from_tableau = tableau.length > game.year;
    if (needs_to_discard_from_tableau) {
      for (let c of tableau) gen_action_card(c);
    }

    if (needs_to_discard_from_hand && needs_to_discard_from_tableau) {
      view.prompt = 'Discard a card from your hand or tableau';
    } else {
      view.prompt = `Discard a card from your ${
        needs_to_discard_from_hand ? 'hand' : 'tableau'
      }`;
    }
  },
  card(c: CardId) {
    const faction_id = get_active_faction();
    if (game.hands[faction_id].includes(c)) {
      game.hands[faction_id] = game.hands[faction_id].filter((id) => id !== c);
    } else if (game.tableaus[faction_id].includes(c)) {
      game.tableaus[faction_id] = game.tableaus[faction_id].filter(
        (id) => id !== c
      );
    }
    game.discard[faction_id].push(c);
    if (
      game.hands[faction_id].length > get_hand_limit(faction_id) ||
      game.tableaus[faction_id].length > game.year
    ) {
      // More cards to discard so resolve same state again
      next();
    } else {
      log(`${game.active} discarded cards.`);
      resolve_active_and_proceed();
    }
  },
};

states.hero_points = {
  inactive: 'gain Hero points',
  auto_resolve() {
    const { src, v } = get_active_node_args();
    if (src !== 'fascist_test') {
      return false;
    }
    if (v < 0) {
      lose_hero_points(get_active_faction(), v);
    } else {
      gain_hero_points(get_active_faction(), v);
    }
    return true;
  },
  prompt() {
    gen_spend_hero_points();
    const value = get_active_node_args().v;
    if (value < 0) {
      view.prompt =
        value < -1
          ? `Lose ${Math.abs(value)} Hero points`
          : 'Lose 1 Hero point';
      gen_action('lose_hp');
      return;
    }
    if (game.hero_points[POOL_ID] > 0) {
      view.prompt =
        value > 1 ? `Fascist Test: Gain ${value} Hero points.` : 'Fascist Test: Gain 1 Hero point.';
      gen_action('gain_hp');
    } else {
      view.prompt = 'Fascist Test: No Hero points available in pool.';
      gen_action('skip');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  gain_hp() {
    const value = get_active_node_args().v;
    gain_hero_points(get_active_faction(), value);
    resolve_active_and_proceed();
  },
  lose_hp() {
    const value = get_active_node_args().v;
    lose_hero_points(get_active_faction(), value);
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

states.game_over = {
  get inactive() {
    return game.victory;
  },
  prompt() {
    view.prompt = game.victory;
  },
};

function resolve_player_with_most_hero_points(faction: FactionId) {
  const value = get_active_node_args().v;
  if (value < 0) {
    lose_hero_points(faction, value);
  } else {
    gain_hero_points(faction, value);
  }
  resolve_active_and_proceed();
}

states.select_player_with_most_hero_points = {
  inactive: 'choose a Player',
  prompt() {
    gen_spend_hero_points();
    const { v } = get_active_node_args();
    view.prompt =
      v < 0
        ? 'Choose player to lose Hero points.'
        : 'Choose player to gain Hero points.';
    const factions = get_factions_with_most_hero_poins();
    for (let faction_id of factions) {
      gen_action(faction_player_map[faction_id]);
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  Anarchist() {
    resolve_player_with_most_hero_points(ANARCHISTS_ID);
  },
  Communist() {
    resolve_player_with_most_hero_points(COMMUNISTS_ID);
  },
  Moderate() {
    resolve_player_with_most_hero_points(MODERATES_ID);
  },
};

states.move_track = {
  inactive: 'move a Track',
  prompt() {
    gen_spend_hero_points();
    const node = get_active_node();
    const track = node.a.t;
    const value = node.a.v;

    const name =
      track === LIBERTY_OR_COLLECTIVIZATION
        ? 'Liberty OR Collectivization'
        : tracks[track].name;

    if (value === 1)
      view.prompt = `Move ${name} one step up.`;
    else if (value == -1)
      view.prompt = `Move ${name} one step down.`;
    else if (value > 0)
      view.prompt = `Move ${name} ${value} steps up.`;
    else
      view.prompt = `Move ${name} ${-value} steps down.`;
    if (track === GOVERNMENT && value === TOWARDS_CENTER) {
      view.prompt = `Move ${name} towards center.`;
    } else if (track === GOVERNMENT && value === AWAY_FROM_CENTER) {
      view.prompt = `Move ${name} away from center.`;
    }

    let can_move_track = false;

    if (track === LIBERTY_OR_COLLECTIVIZATION) {
      can_move_track = gen_move_track(LIBERTY, game.tracks[LIBERTY] + value) || can_move_track;
      can_move_track = gen_move_track(COLLECTIVIZATION, game.tracks[COLLECTIVIZATION] + value) || can_move_track;
    } else if (
      track === GOVERNMENT &&
      (value === TOWARDS_CENTER || value === AWAY_FROM_CENTER)
    ) {
      const direction = get_government_track_direction(value);
      // Value equals direction because away / towards always moves 1 step
      can_move_track = gen_move_track(track, game.tracks[track] + direction) || can_move_track;
    } else {
      can_move_track = gen_move_track(track, game.tracks[track] + value) || can_move_track;
    }
    if (!can_move_track) {
      gen_action('skip');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  tr0(x: number) {
    move_track_to(0, x);
    resolve_active_and_proceed();
  },
  tr1(x: number) {
    move_track_to(1, x);
    resolve_active_and_proceed();
  },
  tr2(x: number) {
    move_track_to(2, x);
    resolve_active_and_proceed();
  },
  tr3(x: number) {
    move_track_to(3, x);
    resolve_active_and_proceed();
  },
  tr4(x: number) {
    move_track_to(4, x);
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

/**
 * A player can always move a track up, unless Communist player
 * during their turn
 */
function can_move_track_up(track_id: number): boolean {
  const faction = get_active_faction();
  return game.faction_turn === COMMUNISTS_ID &&
    faction === COMMUNISTS_ID &&
    track_id === GOVERNMENT
    ? false
    : true;
}

/**
 * Not allowed for:
 * - Anarchist and Liberty or Collectivization
 * - Communist and Soviet Support
 * - Moderate and Forgeign Aid
 * Note: all during their turn
 */
function can_move_track_down(track_id): boolean {
  const faction = get_active_faction();
  if (
    game.faction_turn === ANARCHISTS_ID &&
    faction === ANARCHISTS_ID &&
    (track_id === LIBERTY || track_id === COLLECTIVIZATION)
  ) {
    return false;
  }

  if (
    game.faction_turn === COMMUNISTS_ID &&
    faction === COMMUNISTS_ID &&
    track_id === SOVIET_SUPPORT
  ) {
    return false;
  }

  if (
    game.faction_turn === MODERATES_ID &&
    faction === MODERATES_ID &&
    (track_id === GOVERNMENT || track_id === FOREIGN_AID)
  ) {
    return false;
  }

  return true;
}

// NOTE: we can probably remove this state. I don't think it's used anywhere anymore
states.move_track_up_or_down = {
  inactive: 'move a track',
  auto_resolve() {
    const { track_id, strength } = get_active_node_args();
    const can_move_up = can_move_track_up(track_id);
    const can_move_down = can_move_track_down(track_id);
    if (can_move_up && can_move_down) {
      return false;
    }
    if (can_move_up) {
      move_track(track_id, strength);
    } else if (can_move_down) {
      move_track(track_id, -1 * strength);
    }
    return true;
  },
  prompt() {
    gen_spend_hero_points();
    const { track_id, strength } = get_active_node_args();
    const can_move_up = can_move_track_up(track_id);
    const can_move_down = can_move_track_down(track_id);
    const track_name = get_track_name(track_id);
    if (can_move_up) gen_move_track(track_id, strength);
    if (can_move_down) gen_move_track(track_id, -strength);
    view.prompt = `Move ${track_name}.`;
  },
  spend_hp() {
    resolve_spend_hp();
  },
  tr0(x) {
    move_track_to(0, x);
    resolve_active_and_proceed();
  },
  tr1(x) {
    move_track_to(1, x);
    resolve_active_and_proceed();
  },
  tr2(x) {
    move_track_to(2, x);
    resolve_active_and_proceed();
  },
  tr3(x) {
    move_track_to(3, x);
    resolve_active_and_proceed();
  },
  tr4(x) {
    move_track_to(4, x);
    resolve_active_and_proceed();
  },
};

states.peek_fascist_cards = {
  inactive: 'peek at Fascist cards',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Return one card to the top of the Fascist deck.';
    view.fascist_cards = game.fascist_cards;
    for (const c of game.fascist_cards) {
      gen_action_card(c);
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  card(c: CardId) {
    game.top_of_events_deck = c;
    for (const ec of game.fascist_cards) {
      if (ec !== c) {
        game.discard[FASCIST_ID].push(ec);
      }
    }
    delete game.fascist_cards;
    resolve_active_and_proceed();
  },
};

function resolve_spend_hp() {
  // insert spend hero points node before current node
  // so it will return to current node after resolving
  log("Hero points:")
  insert_before_active_node(
    create_state_node('spend_hero_points', get_active_faction())
  );
  next();
}

function set_player_turn_prompt({
  can_play_card,
  use_ap,
  use_momentum,
  use_morale_bonus,
}: PlayerTurnArgs & { can_spend_hp: boolean; can_play_card: boolean }) {
  if (can_play_card)
    view.prompt = "Play card for Action Points or for the Event."
  else if (use_momentum)
    view.prompt = "Play a second card."
  else if (use_ap && use_morale_bonus)
    view.prompt = "Use Action Points and Morale Bonus."
  else if (use_morale_bonus)
    view.prompt = "Use Morale Bonus."
  else if (use_ap)
    view.prompt = "Use Action Points."
  else
    view.prompt = "Player Turn: Done."
}

states.player_turn = {
  inactive: 'play their turn',
  prompt() {
    gen_spend_hero_points();
    const faction_id = get_active_faction();
    let { use_ap, use_morale_bonus, use_momentum } =
      get_active_node_args<PlayerTurnArgs>();

    use_morale_bonus = use_morale_bonus && game.bonuses[MORALE_BONUS] === ON;

    const can_spend_hp =
      game.faction_turn === faction_id && game.hero_points[faction_id] > 0;

    const can_play_card = !game.card_played;
    if (use_momentum) {
      gen_action('use_momentum');
      if (use_ap || use_morale_bonus || can_play_card) {
        view.actions['use_momentum'] = 0;
      }
    }

    set_player_turn_prompt({
      can_play_card,
      can_spend_hp,
      use_ap,
      use_momentum,
      use_morale_bonus,
    });

    if (can_play_card) {
      gen_action('play_to_tableau');
      gen_action('play_for_event');
    }
    if (use_ap) {
      gen_action('use_ap');
    }
    if (use_morale_bonus && game.bonuses[MORALE_BONUS] === ON) {
      gen_action('use_morale_bonus');
    }
    if (!(can_play_card || use_ap || use_morale_bonus || use_momentum)) {
      gen_action('end_turn');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  end_turn() {
    game.faction_turn = null;
    game.played_card = null;
    game.selected_cards[get_active_faction()] = [];
    resolve_active_and_proceed(true);
  },
  play_to_tableau() {
    game.card_played = 1;
    const faction = get_active_faction();
    const { strength } = play_card_to_tableau(faction);
    update_active_node_args<PlayerTurnArgs>({
      use_morale_bonus: true,
      use_ap: true,
      strength,
    });
    next();
  },
  play_for_event() {
    game.card_played = 1;
    const faction = get_active_faction();
    log('Played for Event:');
    const { effects } = play_card_for_event(faction);
    update_active_node_args<PlayerTurnArgs>({
      resolving_event: true,
    });

    const node = create_effects_node(effects, 'player_event');
    node.c.push(create_function_node('trash_card', faction));
    node.c.push(create_function_node('end_resolving_event_effects'));
    insert_before_active_node(node);

    next();
  },
  use_ap() {
    log("Action Points:");
    const faction = get_active_faction();
    const { strength } = get_active_node_args();
    update_active_node_args({
      use_ap: false,
    });
    insert_before_active_node(
      create_state_node('choose_area_ap', faction, {
        strength,
      })
    );
    next();
  },
  use_momentum() {
    log("Momentum:");
    const faction = get_active_faction();
    // We need to update since there can be a case where
    // morale bonus hasn't been used yet but is still set to true
    // due to bonus being turned off.
    game.card_played = 0;
    game.selected_cards[faction] = [];
    update_active_node_args<PlayerTurnArgs>({
      use_morale_bonus: false,
      use_momentum: false,
    });
    insert_before_active_node(
      create_state_node<PlayCardArgs>('play_card', faction, {
        src: 'momentum',
      })
    )
    next();
  },
  use_morale_bonus() {
    log(`Morale Bonus:`)
    // Update args before inserting node before current node,
    // otherwise it will update args of inserted node
    update_active_node_args({
      use_morale_bonus: false,
    });
    insert_before_active_node(
      create_state_node('activate_icon', get_active_faction())
    );
    next();
  },
};

states.remove_blank_marker = {
  inactive: 'remove a Blank marker',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Remove a Blank marker';

    for (const b of game.triggered_track_effects) {
      gen_action_blank_marker(b);
    }
    if (game.triggered_track_effects.length === 0) {
      view.prompt = 'No Blank marker to remove.';
      gen_action('skip');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  blank_marker(b: number) {
    const faction = get_active_faction();
    pay_hero_points(faction, 1);

    const track_id = Math.floor(b / 11);
    const space_id = b % 11;
    logp(`removed blank marker from ${get_track_name(track_id)} ${space_id}`);
    game.triggered_track_effects = game.triggered_track_effects.filter(
      (id) => id !== b
    );
    game.used_medallions.push(ARCHIVES_MEDALLION_ID);
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

/**
 * Event cards 6 and 39
 * 6: remove 1 attack from up to three fronts
 * 16: Move up to 2 Attacks from a Front to another Front
 * 39: remove up to 3 attacks from a Front; place 2 back(-2)
 */
states.remove_attack_from_fronts = {
  inactive: 'remove attacks',
  prompt() {
    gen_spend_hero_points();
    const { f, v: card_id } = get_active_node_args();
    view.prompt =
      card_id === 6
        ? 'Remove an attack from a Front.'
        : 'Remove attacks from a Front.';
    const front_data = f ?? [];

    let is_front_with_attacks = false;
    FRONTS.forEach((id) => {
      if (game.fronts[id].value >= 0 || game.fronts[id].status !== null) {
        return;
      }
      if (card_id === 6 && front_data.includes(id)) {
        return;
      }
      is_front_with_attacks = true;
      gen_action_front(id);
    });
    if (!is_front_with_attacks) {
      view.prompt = 'No valid Front to remove attacks from.';
      gen_action('skip');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  front(id: FrontId) {
    const { f, v: card_id } = get_active_node_args();

    const removed_value =
      card_id === 6 ? 1 : Math.min(3, Math.abs(game.fronts[id].value));

    update_front(id, removed_value, get_active_faction());

    const fronts = f ?? [];
    fronts.push(id);

    update_active_node_args({ f: fronts });

    if (card_id === 6 && fronts.length === 3) {
      resolve_active_and_proceed();
    } else if (card_id === 39 || card_id === 16) {
      insert_after_active_node(
        create_state_node('attack_front', get_active_faction(), {
          t: ANY,
          v: card_id === 39 ? -2 : -1 * removed_value,
          n: card_id === 16 ? id : undefined,
          src: 'player_event'
        })
      );
      resolve_active_and_proceed();
    }
  },
  skip() {
    const { f, v: card_id } = get_active_node_args();
    const values = f ?? [];
    if (card_id === 39 && values.length > 0) {
      insert_after_active_node(
        create_state_node('attack_front', get_active_faction(), {
          t: ANY,
          v: -2,
          src: 'player_event'
        })
      );
    }
    resolve_active_and_proceed();
  },
};

states.return_card = {
  inactive: 'return a card to their hand',
  prompt() {
    const faction = get_active_faction();
    gen_spend_hero_points();
    view.prompt = 'Return a card to your hand.';
    view.trash = game.trash[faction];
    let possible = false;
    for (let c of game.trash[faction]) {
      if (c !== game.played_card) {
        gen_action_card(c);
        possible = true;
      }
    }
    if (!possible) {
      view.prompt = 'No card in trash to return to your hand.';
      gen_action('skip');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  card(c: CardId) {
    const faction = get_active_faction();
    array_remove(game.trash[faction], game.trash[faction].indexOf(c));
    game.hands[faction].push(c);
    logp(`returned a card to their hand`);
    resolve_active_and_proceed();
  },
  skip() {
    resolve_active_and_proceed();
  },
};

function gen_spend_hero_points_move_track(track_id: number, change: number) {
  for (let i = 1; i <= change; ++i) {
    gen_move_track_change(track_id, i);
  }
}

function pay_hero_points_to_move_track(track_id: number, new_value: number) {
  const cost_per_step = [3, 3, 4, 2, 2];
  const change = Math.abs(game.tracks[track_id] - new_value);
  pay_hero_points(get_active_faction(), cost_per_step[track_id] * change);
}

states.spend_hero_points = {
  inactive: 'spend Hero points',
  prompt() {
    const hero_points = game.hero_points[get_active_faction()];

    view.prompt = `Spend up to ${hero_points} Hero points.`;

    const faction = get_active_faction();

    if (hero_points === 0) {
      return;
    }

    gen_action('draw_card');
    if (can_use_medallion(ARCHIVES_MEDALLION_ID, faction)) {
      gen_action('remove_blank_marker');
      if (game.triggered_track_effects.length === 0) {
        view.actions['remove_blank_marker'] = 0;
      }
    }
    if (can_use_medallion(VOLUNTEERS_MEDALLION_ID, faction)) {
      gen_action('add_to_front');
    }

    if (hero_points < 2) {
      return;
    }

    for (const bonus of bonuses) {
      if (game.bonuses[bonus] === OFF) {
        gen_action_bonus(bonus);
      }
    }

    gen_spend_hero_points_move_track(FOREIGN_AID, Math.floor(hero_points / 2));
    gen_spend_hero_points_move_track(
      SOVIET_SUPPORT,
      Math.floor(hero_points / 2)
    );

    if (hero_points < 3) {
      return;
    }

    gen_spend_hero_points_move_track(
      COLLECTIVIZATION,
      Math.floor(hero_points / 3)
    );
    gen_spend_hero_points_move_track(LIBERTY, Math.floor(hero_points / 3));

    if (hero_points < 4) {
      return;
    }

    gen_spend_hero_points_move_track(GOVERNMENT, Math.floor(hero_points / 4));
  },
  add_to_front() {
    const faction = get_active_faction();
    pay_hero_points(faction, 1);
    insert_after_active_node(
      create_state_node('add_to_front', faction, {
        t: ANY,
        v: 1,
      })
    );
    resolve_active_and_proceed();
  },
  bonus(b: number) {
    update_active_node_args({
      turn_on_bonus: false,
    });
    update_bonus(b, ON);
    pay_hero_points(get_active_faction(), 2);
    resolve_active_and_proceed();
  },
  draw_card() {
    const faction = get_active_faction();
    pay_hero_points(faction, 1);
    draw_hand_cards(faction, 1);
    resolve_active_and_proceed();
  },
  remove_blank_marker() {
    const faction = get_active_faction();
    if (game.used_medallions) {
      game.used_medallions.push(ARCHIVES_MEDALLION_ID);
    } else {
      game.used_medallions = [ARCHIVES_MEDALLION_ID];
    }
    insert_after_active_node(create_state_node('remove_blank_marker', faction));
    resolve_active_and_proceed();
  },
  tr0(x: number) {
    pay_hero_points_to_move_track(LIBERTY, x);
    move_track_to(0, x);
    resolve_active_and_proceed();
  },
  tr1(x: number) {
    pay_hero_points_to_move_track(COLLECTIVIZATION, x);
    move_track_to(1, x);
    resolve_active_and_proceed();
  },
  tr2(x: number) {
    pay_hero_points_to_move_track(GOVERNMENT, x);
    move_track_to(2, x);
    resolve_active_and_proceed();
  },
  tr3(x: number) {
    pay_hero_points_to_move_track(SOVIET_SUPPORT, x);
    move_track_to(3, x);
    resolve_active_and_proceed();
  },
  tr4(x: number) {
    pay_hero_points_to_move_track(FOREIGN_AID, x);
    move_track_to(4, x);
    resolve_active_and_proceed();
  },
};

states.swap_card_tableau_hand = {
  inactive: 'swap cards',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Swap a card in your tableau with a card in your hand.';
    const faction = get_active_faction();
    gen_action('skip');
    if (game.tableaus[faction].length === 0) {
      view.prompt = 'No card in your tableau to swap.';
      return;
    }
    if (game.hands[faction].length === 0) {
      view.prompt = 'No card in your hand to swap.';
      return;
    }
    if (
      !game.selected_cards[faction].some((card_id) =>
        game.hands[faction].includes(card_id)
      )
    ) {
      for (const c of game.hands[faction]) {
        gen_action_card(c);
      }
    }
    if (
      !game.selected_cards[faction].some((card_id) =>
        game.tableaus[faction].includes(card_id)
      )
    ) {
      for (const c of game.tableaus[faction]) {
        gen_action_card(c);
      }
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  card(c: CardId) {
    const faction = get_active_faction();
    const selected_cards = game.selected_cards[faction];

    selected_cards.push(c);

    if (selected_cards.length === 2) {
      const hand_card_index = selected_cards.findIndex((card_id) =>
        game.hands[faction].includes(card_id)
      );

      const tableau_card_index = hand_card_index === 0 ? 1 : 0;

      const hand = game.hands[faction];
      const tableau = game.tableaus[faction];

      array_remove(hand, hand.indexOf(selected_cards[hand_card_index]));
      array_remove(
        tableau,
        tableau.indexOf(selected_cards[tableau_card_index])
      );
      hand.push(selected_cards[tableau_card_index]);
      tableau.push(selected_cards[hand_card_index]);
      game.selected_cards[faction] = [];
      resolve_active_and_proceed();
    }
  },
  skip() {
    const faction = get_active_faction();
    game.selected_cards[faction] = [];
    resolve_active_and_proceed();
  },
};

function resolve_take_hero_points(faction: FactionId) {
  const { v } = get_active_node_args();
  const amount = Math.min(v, game.hero_points[faction]);
  lose_hero_points(faction, amount);
  gain_hero_points(get_active_faction(), amount);
  resolve_active_and_proceed();
}

states.take_hero_points = {
  inactive: 'take Hero points',
  prompt() {
    gen_spend_hero_points();
    const { v } = get_active_node_args();
    view.prompt =
      v === 1
        ? 'Take a Hero point from any player.'
        : `Take ${v} Hero points from any player.`;
    const active_faction = get_active_faction();
    let target_exists = false;
    for (const faction of role_ids) {
      if (faction !== active_faction && game.hero_points[faction] > 0) {
        gen_action(faction_player_map[faction]);
        target_exists = true;
      }
    }

    if (!target_exists) {
      view.prompt =
        'No Hero points to take from another player.';
      gen_action('skip');
    }
  },
  spend_hp() {
    resolve_spend_hp();
  },
  Anarchist() {
    resolve_take_hero_points(ANARCHISTS_ID);
  },
  Communist() {
    resolve_take_hero_points(COMMUNISTS_ID);
  },
  Moderate() {
    resolve_take_hero_points(MODERATES_ID);
  },
  skip() {
    resolve_active_and_proceed();
  },
};

function trash_card(faction: FactionId) {
  const index = game.selected_cards[faction].length - 1;
  const card_id = game.selected_cards[faction][index];

  array_remove(game.hands[faction], game.hands[faction].indexOf(card_id));
  array_remove(game.selected_cards[faction], index);
  game.trash[faction].push(card_id);
  resolve_active_and_proceed();
}

states.use_organization_medallion = {
  inactive: 'use Organization Medallion',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Use Organization Medallion?';

    gen_action('yes');
    gen_action('no');
  },
  spend_hp() {
    resolve_spend_hp();
  },
  yes() {
    const faction = get_active_faction();
    pay_hero_points(faction, 1);
    game.used_medallions.push(ORGANIZATION_MEDALLION_ID);

    // Value is the clicked location on the track
    let { t, v } = get_active_node_args();

    // If player uses medallion we need to add or subtract
    // depending on direction of movement
    if (v > game.tracks[t]) {
      v++;
    } else {
      v--;
    }

    move_track(t, v - game.tracks[t]);
    resolve_active_and_proceed();
  },
  no() {
    const { t, v } = get_active_node_args();

    move_track(t, v);
    resolve_active_and_proceed();
  },
};

states.use_strategy_medallion = {
  inactive: 'use Strategy Medallion',
  prompt() {
    gen_spend_hero_points();
    view.prompt = 'Use Strategy Medallion?';

    gen_action('yes');
    gen_action('no');
  },
  spend_hp() {
    resolve_spend_hp();
  },
  yes() {
    game.used_medallions.push(STRATEGY_MEDALLION_ID);
    const { f } = get_active_node_args();
    const faction = get_active_faction();

    update_front(f, 1, faction);

    resolve_active_and_proceed();
  },
  no() {
    resolve_active_and_proceed();
  },
};

// #endrregion

// #region card effects

function card1_event2() {
  const value = game.tracks[FOREIGN_AID] >= 6 ? 3 : 2;
  insert_after_active_node(
    resolve_effect(create_effect('front', NORTHERN, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card3_event2() {
  const value = game.tracks[FOREIGN_AID] >= 8 ? 2 : 1;
  insert_after_active_node(
    resolve_effect(create_effect('track', GOVERNMENT, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card10_event2() {
  if (game.tracks[FOREIGN_AID] >= 6) {
    insert_after_active_node(
      resolve_effect(create_effect('draw_card', SELF, 2), 'player_event')
    );
  }
  resolve_active_and_proceed();
}

function card16_event2() {
  const value = game.tracks[GOVERNMENT] >= 6 ? 4 : 3;

  insert_after_active_node(
    resolve_effect(create_effect('track', FOREIGN_AID, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card17_event3() {
  const value = game.tracks[GOVERNMENT] >= 6 ? -4 : -3;
  insert_after_active_node(
    resolve_effect(create_effect('track', COLLECTIVIZATION, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card20_event3() {
  const value = game.tracks[SOVIET_SUPPORT] >= 6 ? 2 : 1;
  insert_after_active_node(
    create_seq_node([
      resolve_effect(create_effect('front', MADRID, value), 'player_event'),
      resolve_effect(create_effect('front', SOUTHERN, value), 'player_event'),
    ])
  );
  resolve_active_and_proceed();
}

function card22_event3() {
  const value = game.tracks[SOVIET_SUPPORT] >= 8 ? -3 : -3;
  insert_after_active_node(
    resolve_effect(create_effect('track', GOVERNMENT, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card23_event1() {
  const value = game.tracks[SOVIET_SUPPORT] >= 6 ? 4 : 3;
  insert_after_active_node(
    resolve_effect(create_effect('front', ANY, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card26_event1() {
  game.active_abilities.push(COMMUNIST_EXTRA_HERO_POINT);
  resolve_active_and_proceed();
}

function card29_event2() {
  const value = game.tracks[GOVERNMENT] <= 5 ? -3 : -2;
  insert_after_active_node(
    resolve_effect(create_effect('track', LIBERTY, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card35_event2() {
  const value = game.tracks[GOVERNMENT] <= 5 ? 2 : 1;
  insert_after_active_node(
    resolve_effect(create_effect('track', SOVIET_SUPPORT, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card42_event3() {
  game.active_abilities.push(ANARCHIST_EXTRA_HERO_POINT);
  resolve_active_and_proceed();
}

function card45_event2() {
  if (game.tracks[LIBERTY] >= 6) {
    insert_after_active_node(
      resolve_effect(create_effect('track', COLLECTIVIZATION, 1), 'player_event')
    );
  }
  resolve_active_and_proceed();
}

function card46_event3() {
  game.fascist_cards = [];
  for (let i = 0; i < 3; ++i) {
    game.fascist_cards.push(draw_fascist_card());
  }
  resolve_active_and_proceed();
}

function card50_event2() {
  const value = game.tracks[COLLECTIVIZATION] >= 8 ? 3 : 2;
  insert_after_active_node(
    resolve_effect(create_effect('front', ARAGON, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card53_event2() {
  const value = game.tracks[LIBERTY] >= 8 ? 3 : 2;
  insert_after_active_node(
    resolve_effect(create_effect('front', ANY, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function card54_event1() {
  const value = game.tracks[COLLECTIVIZATION] >= 8 ? 3 : 2;
  insert_after_active_node(
    resolve_effect(create_effect('track', LIBERTY, value), 'player_event')
  );
  resolve_active_and_proceed();
}

function setup_return_card_from_trash() {
  resolve_active_and_proceed();
}

// #endregion

// #region GAME FUNCTIONS

function add_glory(
  faction: FactionId,
  amount: number
) {
  for (let i = 0; i < amount; ++i)
    game.bag_of_glory.push(faction);
  if (game.hidden_bag) {
    if (amount > 1)
      logi(`Added ${amount} tokens to the Bag`);
    else
      logi(`Added token to the Bag`);
  } else {
    if (amount > 1)
      logi(`Added ${amount} T${faction} to the Bag`);
    else
      logi(`Added T${faction} to the Bag`);
  }
}

function check_initiative() {
  let initiative: FactionId;
  if (game.tracks[LIBERTY] >= 6 && game.tracks[COLLECTIVIZATION] >= 6) {
    initiative = ANARCHISTS_ID;
  } else if (game.tracks[GOVERNMENT] <= 5) {
    initiative = COMMUNISTS_ID;
  } else {
    initiative = MODERATES_ID;
  }

  if (game.initiative === initiative) {
    return;
  }
  game.initiative = initiative;
  logi(`${faction_player_map[initiative]} claimed Initiative`);
}

function war_is_won() {
  let won_fronts = 0;
  for (const f of FRONTS) {
    if (game.fronts[f].value >= 1) {
      won_fronts++;
    }
  }
  return won_fronts >= 3;
}

function determine_winner() {
  const glory = {
    [ANARCHISTS_ID]: 0,
    [COMMUNISTS_ID]: 0,
    [MODERATES_ID]: 0,
  };
  for (const g of game.glory) {
    glory[g]++;
  }
  let highest_glory = 0;
  let winners = [];
  for (let f of role_ids) {
    if (glory[f] === highest_glory) {
      winners.push(f);
    } else if (glory[f] > highest_glory) {
      highest_glory = glory[f];
      winners = [f];
    }
  }
  if (winners.length === 1) {
    win_game(faction_player_map[winners[0]], highest_glory);
  } else {
    insert_after_active_node(
      create_state_node('break_tie_winner', game.initiative, {
        winners,
        glory: highest_glory,
      })
    );
  }
  resolve_active_and_proceed();
}

function end_of_turn() {
  game.fronts.forEach(front => {
    front.contributions = [];
  });
  game.active_abilities = [];
  game.used_medallions = [];
  game.first_player = null;
  if (game.turn === 4) {
    end_of_year();
  } else {
    game.turn++;
    start_turn();
  }
}

function end_of_year() {
  log_header('End of the Year', 't');

  if (game.year === 3) {
    const is_won = war_is_won();
    if (is_won) {
      log('The war is won!');
    } else {
      game_over('Fascist', 'The war is lost. All players lose the game!');
      resolve_active_and_proceed();
      return;
    }
  }

  const glory_to_draw = [0, 1, 2, 5];

  game.glory_current_year = [
    false,
    false,
    false,
  ];

  const player_order = get_player_order();

  const engine = [];

  for (let i = 0; i < glory_to_draw[game.year]; ++i) {
    engine.push(create_state_node('draw_glory', player_order[i % 3]));
  }
  engine.push(create_function_node('end_of_year_cleanup'));

  game.engine = engine;
  next(true);
}

function end_of_year_cleanup() {
  if (game.year === 3) {
    // end of game
    determine_winner();
    return;
  }

  const players_to_gain_hero_points = role_ids.filter(
    (f) => !game.glory_current_year?.[f]
  );
  gain_hero_points_in_player_order(players_to_gain_hero_points, game.year);

  // Setup card discarding
  game.engine = get_player_order().map((f) =>
    create_function_node('check_end_of_year_discard', { f })
  );
  game.engine.push(create_function_node('checkpoint'));
  game.engine.push(create_function_node('start_year'));

  // New deck is used for next year so  clear top card
  // if it was set
  game.top_of_events_deck = null;
  game.glory_current_year = null;

  next();
}

function end_resolving_event_effects() {
  // Get player turn node
  const node: StateNode<PlayerTurnArgs> = get_nodes_for_state('player_turn')[0];

  // Update args
  node.a = {
    ...(node.a || {}),
    resolving_event: false,
  };
  resolve_active_and_proceed();
}

function gain_hero_points_in_player_order(factions: FactionId[], value) {
  for (const f of get_player_order()) {
    if (factions.includes(f)) {
      gain_hero_points(f, value);
    }
  }
}

function gain_hero_points(
  faction_id: FactionId,
  value: number,
  skip_abilities = false // Used to prevent gaining of extra hero points when taking them from another player
) {
  if (game.hero_points[POOL_ID] === 0) {
    return;
  }
  if (
    !skip_abilities &&
    faction_id === ANARCHISTS_ID &&
    game.active_abilities.includes(ANARCHIST_EXTRA_HERO_POINT)
  ) {
    value++;
    array_remove(
      game.active_abilities,
      game.active_abilities.indexOf(ANARCHIST_EXTRA_HERO_POINT)
    );
  }
  if (
    !skip_abilities &&
    faction_id === COMMUNISTS_ID &&
    (game.active_abilities || []).includes(COMMUNIST_EXTRA_HERO_POINT)
  ) {
    value++;
    game.active_abilities = (game.active_abilities || []).filter(
      (ability) => ability !== COMMUNIST_EXTRA_HERO_POINT
    );
  }
  const gain = Math.min(game.hero_points[POOL_ID], value);
  game.hero_points[POOL_ID] -= gain;
  game.hero_points[faction_id] += gain;
  logi(`${get_player(faction_id)} +${gain} HP`);
}

function game_over(result: Player | 'Fascist', victory: string) {
  insert_after_active_node(create_state_node('game_over', 'None'));
  game.result = result;
  game.victory = victory;
  game.undo = [];
  log_br();
  log(game.victory);
}

function get_hand_limit(faction: FactionId) {
  let hand_limit = game.year;
  if (game.medallions[faction].includes(INTELLIGENCE_MEDALLION_ID)) {
    hand_limit++;
  }
  return hand_limit;
}

function play_card_for_event(faction: FactionId): PlayerCard {
  const index = game.selected_cards[faction].length - 1;
  const card_id = game.selected_cards[faction][index];
  const card = cards[card_id];
  game.played_card = card_id;
  return card as PlayerCard;
}

function play_card_to_tableau(faction: FactionId): PlayerCard {
  const index = game.selected_cards[faction].length - 1;
  const card_id = game.selected_cards[faction][index];
  const card = cards[card_id];
  game.played_card = card_id;

  array_remove(game.hands[faction], game.hands[faction].indexOf(card_id));

  game.tableaus[faction].push(card_id);
  return card as PlayerCard;
}

function resolve_fascist_test() {
  game.fascist = 2;

  const test = get_current_event().test;
  const front = test.front
  const status = game.fronts[front].status;

  const test_passed =
    status === VICTORY ||
    (status !== DEFEAT && game.fronts[front].value >= test.value);

  const hero_point_actions: EngineNode[] = [];

  log_header("C" + get_current_event_id(), 'f');

  if (test_passed) {
    log(front_names[front] + ' Test successful:');

    for (const faction of get_player_order()) {
      let hero_points_gain = game.fronts[front].contributions.includes(
        faction
      )
        ? 2
        : 0;
      if (can_use_medallion(PROPAGANDA_MEDALLION_ID, faction)) {
        hero_points_gain += 2;
      }
      if (hero_points_gain > 0) {
        const node = resolve_effect(
          create_effect('hero_points', faction, hero_points_gain),
          'fascist_test'
        );
        hero_point_actions.push(node);
      }
    }

    if (hero_point_actions.length > 0) {
      insert_after_active_node(create_seq_node(hero_point_actions));
    }
  } else {
    log(front_names[front] + ' Test failed:');
  }

  const effect = test_passed ? test.pass : test.fail;
  const node = resolve_effect(effect, 'fascist_test');

  if (node !== null) {
    insert_after_active_node(node);
  }

  resolve_active_and_proceed();
}

function resolve_final_bid() {
  let highest_bid = 0;
  let winners: FactionId[] = [];
  for (const f of get_player_order()) {
    let player_bid = 0;
    for (const c of game.selected_cards[f]) {
      player_bid += (cards[c] as PlayerCard).strength;
    }
    log(`${faction_player_map[f]} bids ${player_bid}`);
    if (player_bid === highest_bid) {
      winners.push(f);
    } else if (player_bid > highest_bid) {
      highest_bid = player_bid;
      winners = [f];
    }
    game.hands[f] = game.hands[f].filter(
      (c) => !game.selected_cards[f].includes(c)
    );
    game.discard[f].concat(game.selected_cards[f]);
    game.selected_cards[f] = [];
  }

  if (winners.length === 1) {
    win_final_bid(winners[0]);
  } else {
    insert_after_active_node(
      create_state_node('break_tie_final_bid', game.initiative, { winners })
    );
  }

  resolve_active_and_proceed();
}

function get_fronts_to_add_to(
  target: string | number,
  not?: FrontId
): FrontId[] {
  if (target === CLOSEST_TO_DEFEAT || target === CLOSEST_TO_VICTORY) {
    return get_fronts_closest_to(target);
  } else if (target === ANY) {
    return FRONTS.filter((id) => game.fronts[id].status === null && id !== not);
  } else if (game.fronts[target].status === DEFEAT) {
    return get_fronts_closest_to(CLOSEST_TO_DEFEAT);
  } else if (game.fronts[target].status === VICTORY) {
    return get_fronts_to_add_to(ANY);
  } else {
    return [target as FrontId];
  }
}

function get_max_value_for_track(track_id: number) {
  switch (track_id) {
    case LIBERTY:
      const max_lib = game.tracks[COLLECTIVIZATION] >= 8 ? 10 : 7;
      return Math.max(max_lib, game.tracks[LIBERTY]);
    case GOVERNMENT:
      const max_gov = game.tracks[FOREIGN_AID] >= 8 ? 10 : 7;
      return Math.max(max_gov, game.tracks[GOVERNMENT]);
    case COLLECTIVIZATION:
    case SOVIET_SUPPORT:
    case FOREIGN_AID:
    default:
      return 10;
  }
}

function get_min_value_for_track(track_id: number) {
  switch (track_id) {
    case GOVERNMENT:
      const min_gov = game.tracks[SOVIET_SUPPORT] >= 8 ? 1 : 4;
      return Math.min(min_gov, game.tracks[GOVERNMENT]);
    case LIBERTY:
    case COLLECTIVIZATION:
    case SOVIET_SUPPORT:
    case FOREIGN_AID:
    default:
      return 0;
  }
}

// TOWARDS_CENTER = 10;
// AWAY_FROM_CENTER = 11;
function get_government_track_direction(direction: 10 | 11): -1 | 1 {
  const value = game.tracks[GOVERNMENT];
  if (
    (direction === TOWARDS_CENTER && value >= 6) ||
    (direction === AWAY_FROM_CENTER && value <= 5)
  ) {
    return -1;
  } else {
    return 1;
  }
}

const track_action_name = ['tr0', 'tr1', 'tr2', 'tr3', 'tr4'];

function gen_move_track(track_id: number, new_value: number) {
  new_value = Math.max(new_value, get_min_value_for_track(track_id));
  new_value = Math.min(new_value, get_max_value_for_track(track_id));
  if (new_value === game.tracks[track_id]) {
    return false;
  }
  gen_action(track_action_name[track_id], new_value);
  return true;
}

function gen_move_track_change(track_id: number, change: number) {
  const current_value = game.tracks[track_id];

  let can_move_track = false;
  if (can_move_track_up(track_id)) {
    can_move_track = gen_move_track(track_id, current_value + change) || can_move_track;
  }

  if (can_move_track_down(track_id)) {
    can_move_track = gen_move_track(track_id, current_value - change) || can_move_track;
  }
  return can_move_track;
}

function move_track(track_id: number, change: number) {
  const current_value = game.tracks[track_id];
  let new_value = current_value + change;
  new_value = Math.max(new_value, get_min_value_for_track(track_id));
  new_value = Math.min(new_value, get_max_value_for_track(track_id));
  move_track_to(track_id, new_value);
}

function move_track_to(track_id: number, new_value: number) {
  const current_value = game.tracks[track_id];
  let change = new_value - current_value;
  game.tracks[track_id] = new_value;
  if (change > 0)
    logi(`${get_track_name(track_id)} +${change} to ${new_value}`);
    //logi(`${get_track_name(track_id)} -> to ${new_value}`);
  else if (change < 0)
    logi(`${get_track_name(track_id)} ${change} to ${new_value}`);
    //logi(`${get_track_name(track_id)} <- to ${new_value}`);

  check_initiative();

  const triggered_spaces =
    change > 0
      ? make_list(current_value + 1, new_value).reverse()
      : make_list(new_value, current_value - 1);

  triggered_spaces.forEach((space_id) => {
    const trigger = tracks[track_id].triggers[space_id];
    if (
      trigger !== null &&
      !game.triggered_track_effects.includes(
        get_blank_marker_id(track_id, space_id)
      )
    ) {
      if (space_id !== 0) {
        game.triggered_track_effects.push(
          get_blank_marker_id(track_id, space_id)
        );
      }
      const node = resolve_effect(trigger, tracks[track_id].action);
      if (node !== null) {
        insert_after_active_node(node);
        insert_after_active_node(create_function_node('log_trigger', [track_id, space_id]));
      }
    }
  });
}

function pay_hero_points(faction: FactionId, amount: number) {
  game.hero_points[faction] -= amount;
  game.hero_points[POOL_ID] += amount;
}

function can_use_medallion(medallion_id: number, faction?: FactionId) {
  faction = faction === undefined ? get_active_faction() : faction;
  const can_use =
    game.medallions[faction].includes(medallion_id) &&
    !game.used_medallions.includes(medallion_id);

  if (medallion_id === ORGANIZATION_MEDALLION_ID) {
    return can_use && game.hero_points[faction] > 0;
  } else {
    return can_use;
  }
}

function insert_use_organization_medallion_node(
  track_id: number,
  value: number
) {
  const faction = get_active_faction();

  insert_after_active_node(
    create_state_node('use_organization_medallion', faction, {
      t: track_id,
      v: value,
    })
  );
}

function update_bonus(bonus_id: number, status: number) {
  if (game.bonuses[bonus_id] === status) {
    return;
  }
  game.bonuses[bonus_id] = status;
  if (status === ON)
    logi(`${bonus_names[bonus_id]} on`);
  else
    logi(`${bonus_names[bonus_id]} off`);
}

function update_front(
  // f: string,
  front_id: FrontId,
  change: number,
  faction_id: FactionId | null = null
) {
  // Check teamwork bonus
  const player_token_on_front =
    faction_id !== null &&
    game.fronts[front_id].contributions.includes(faction_id);
  if (
    game.bonuses[TEAMWORK_BONUS] === ON &&
    change > 0 &&
    faction_id !== null &&
    !player_token_on_front &&
    game.fronts[front_id].contributions.length > 0
  ) {
    change += 1;
  }
  const value_before = game.fronts[front_id].value;
  game.fronts[front_id].value += change;

  if (change > 0)
    logi(`${front_names[front_id]} +${change}`)
  else if (change < 0)
    logi(`${front_names[front_id]} ${change}`)

  if (
    faction_id !== null &&
    value_before <= 0 &&
    game.fronts[front_id].value > 0
  ) {
    gain_hero_points(faction_id, 1);
  }

  // Add token to front if player contributed
  if (
    faction_id !== null &&
    !game.fronts[front_id].contributions.includes(faction_id)
  ) {
    game.fronts[front_id].contributions.push(faction_id);
  }

  if (
    change > 0 &&
    faction_id !== undefined &&
    game.fronts[front_id].value < 10 &&
    can_use_medallion(STRATEGY_MEDALLION_ID)
  ) {
    insert_after_active_node(
      create_state_node('use_strategy_medallion', get_active_faction(), {
        f: front_id,
      })
    );
  }

  // Check victory / defeat on a front
  if (game.fronts[front_id].value >= 10) {
    victory_on_a_front(front_id);
  } else if (game.fronts[front_id].value <= -10) {
    defeat_on_a_front(front_id);
  }
}

function defeat_on_a_front(front_id: FrontId) {
  game.fronts[front_id].status = DEFEAT;

  log_br()
  log('Defeat on ' + get_front_name(front_id) + '!');
  log_br()

  // Check game end
  if (front_id === MADRID || get_defeated_front_count() == 2) {
    game_over('Fascist', 'All players lose the game!');
    return;
  }
  insert_after_active_node(
    create_effects_node([
      create_effect('bonus', MORALE_BONUS, OFF),
      create_effect('track', COLLECTIVIZATION, -1),
      create_effect('track', SOVIET_SUPPORT, -1),
      create_effect('track', FOREIGN_AID, -1),
    ])
  );
}

function victory_on_a_front(front_id: FrontId) {
  game.fronts[front_id].status = VICTORY;

  log_br()
  log('Victory on ' + get_front_name(front_id) + '!');
  log_br()

  gain_hero_points_in_player_order(game.fronts[front_id].contributions, 3);
}

function create_effects_node(
  effects: Effect[],
  source?: EffectSource
): SeqNode {
  const nodes = effects.reduce((accrued: EngineNode[], current: Effect) => {
    const node = resolve_effect(current, source);
    if (node !== null) {
      accrued.push(node);
    }
    return accrued;
  }, []);
  return create_seq_node(nodes);
}

function get_faction_to_resolve_effect(effect: Effect): FactionId {
  if (!effect.faction) {
    return get_active_faction();
  }
  if (effect.faction === INITIATIVE_PLAYER) {
    return game.initiative;
  }
  return effect.faction;
}

const effect_type_state_map: Record<string, string> = {
  add_card_to_tableau: 'add_card_to_tableau',
  attack: 'attack_front',
  bonus: 'change_bonus',
  front: 'add_to_front',
  medallion: 'choose_medallion',
  remove_blank_marker: 'remove_blank_marker',
  return_card: 'return_card',
  swap_card_tableau_hand: 'swap_card_tableau_hand',
  take_hero_points: 'take_hero_points',
  track: 'move_track',
};

function resolve_effect(effect: Effect, source?: EffectSource): EngineNode {
  const args = {
    t: effect.target,
    v: effect.value,
    src: source,
  };

  const faction = get_faction_to_resolve_effect(effect);

  if (effect.type === 'function') {
    return create_function_node(effect.target as string);
  }
  if (effect.type === 'state') {
    return create_state_node(effect.target as string, faction, {
      v: effect.value,
      src: source,
    });
  }

  // Default cases where effect type is mapped to a state
  let state = effect_type_state_map[effect.type];
  if (state !== undefined) {
    return create_state_node(state, faction, args);
  }

  // Specific mapping based on target
  const strategies = [
    {
      condition:
        effect.type === 'hero_points' &&
        effect.target === PLAYER_WITH_MOST_HERO_POINTS,
      resolve: () => {
        return create_state_node(
          'select_player_with_most_hero_points',
          faction,
          args
        );
      },
    },
    {
      condition: effect.type === 'hero_points' && effect.target === ALL_PLAYERS,
      resolve: () => {
        return create_seq_node(
          get_player_order().map((faction) =>
            create_state_node('hero_points', faction, args)
          )
        );
      },
    },
    {
      condition: effect.type === 'hero_points' && effect.target === SELF,
      resolve: () => {
        return create_state_node('hero_points', faction, args);
      },
    },
    {
      condition:
        effect.type === 'hero_points' &&
        role_ids.includes(effect.target as FactionId),
      resolve: () => {
        return create_state_node(
          'hero_points',
          effect.target as FactionId,
          args
        );
      },
    },
    {
      condition:
        effect.type === 'hero_points' && effect.target === INITIATIVE_PLAYER,
      resolve: () => {
        return create_state_node('hero_points', game.initiative, args);
      },
    },
    {
      condition: effect.type === 'draw_card' && effect.target === SELF,
      resolve: () => {
        return create_state_node('draw_card', faction, args);
      },
    },
    {
      condition:
        effect.type === 'draw_card' && effect.target === INITIATIVE_PLAYER,
      resolve: () => {
        return create_state_node('draw_card', game.initiative, args);
      },
    },
    {
      condition:
        effect.type === 'draw_card' &&
        role_ids.includes(effect.target as FactionId),
      resolve: () => {
        return create_state_node('draw_card', effect.target as FactionId, args);
      },
    },
    {
      condition: effect.type === 'draw_card' && effect.target === ALL_PLAYERS,
      resolve: () => {
        return create_seq_node(
          get_player_order(get_active_faction()).map((faction) =>
            create_state_node('draw_card', faction, args)
          )
        );
      },
    },
    {
      condition: effect.type === 'draw_card' && effect.target === OTHER_PLAYERS,
      resolve: () => {
        const state_nodes = get_player_order(get_active_faction()).map(
          (faction) => create_state_node('draw_card', faction, args)
        );
        array_remove(state_nodes, 0); // Remove current player
        return create_seq_node(state_nodes);
      },
    },
    {
      condition: effect.type === 'play_card',
      resolve: () => {
        return create_seq_node([
          create_state_node('play_card', faction, { src: source }),
          create_state_node('player_turn', faction, { src: source }),
        ]);
      },
    },
  ];

  const strategy = strategies.find((strategy) => strategy.condition);

  if (strategy) {
    return strategy.resolve();
  } else {
    throw new Error('Unresolved effect: ' + effect);
  }
}

function win_final_bid(faction_id: FactionId) {
  log_br();
  log(`${faction_player_map[faction_id]} wins the Final Bid`);
  game.glory.push(faction_id);
}

function win_game(player: Player, glory: number) {
  game_over(player, `${player} wins the game with a total of ${glory} Glory!`);
}

// #endregion

// #region CARDS

function draw_card(deck: CardId[]): CardId {
  clear_undo();
  let i = random(deck.length);
  let c = deck[i] as CardId;
  set_delete(deck, c);
  return c;
}

function draw_fascist_card(): CardId {
  if (game.top_of_events_deck !== null) {
    const card_id = game.top_of_events_deck;
    game.top_of_events_deck = null;
    return card_id;
  }
  return draw_card(list_deck(FASCIST_ID));
}

function lose_hero_points(faction: FactionId, value: number) {
  const points_lost = Math.min(game.hero_points[faction], Math.abs(value));
  game.hero_points[POOL_ID] += points_lost;
  game.hero_points[faction] -= points_lost;
  if (points_lost !== 0)
    logi(`${get_player(faction)} -${points_lost} HP`);
}

// #endregion

// #region FRONTS

function get_fronts_closest_to(target: ClosestToDefeat | ClosestToVictory): FrontId[] {
  let minValue = 100;
  let maxValue = -100;
  for (let front of game.fronts) {
    if (front.status === null) {
      minValue = Math.min(minValue, front.value);
      maxValue = Math.max(maxValue, front.value);
    }
  }

  // Possible if all fronts have either been
  // defeated or are victorious
  if (minValue === 100)
    return [];

  const targetValue = target === CLOSEST_TO_DEFEAT ? minValue : maxValue;

  const closest = []
  for (let i = 0; i < game.fronts.length; ++i)
    if (game.fronts[i].value === targetValue)
      closest.push(i)
  return closest
}

// #endregion

// #region LOGGING

function log_br() {
  if (game.log.length > 0 && game.log[game.log.length - 1] !== '')
    game.log.push('');
}

function log(msg: string) {
  game.log.push(msg);
}

function log_header(msg: string, prefix: string) {
  log_br();
  log(`#${prefix} ${msg}`);
  log_br();
}

function log_trigger(args) {
  let [ track_id, space_id ] = args;
  log(`Trigger ${get_track_name(track_id)} ${space_id}:`);
  resolve_active_and_proceed();
}

function logi(msg: string) {
  log(">" + msg);
}

function logp(msg: string) {
  log(">" + game.active + " " + msg);
}

// #endregion LOGGING

// #region UTILITY

function get_active_faction(): FactionId {
  return player_faction_map[game.active as Player];
}

function get_blank_marker_id(track_id: number, space_id: number) {
  return track_id * 11 + space_id;
}

function get_front_name(id: FrontId | 'd' | 'v') {
  return front_names[id];
}

function get_current_event_id(): CardId {
  return game.current_events[game.current_events.length - 1]
}

function get_current_event(): EventCard {
  return cards[get_current_event_id()] as EventCard;
}

function get_defeated_front_count() {
  let count = 0;
  for (const front_id of FRONTS) {
    if (game.fronts[front_id].status === DEFEAT) {
      count++;
    }
  }
  return count;
}

function get_icon_count_in_tableau(
  icon: Icon,
  faction: FactionId = get_active_faction()
) {
  let count = 0;
  for (const c of game.tableaus[faction]) {
    const card = cards[c] as PlayerCard;
    for (const i of card.icons) {
      if (i === icon) {
        ++count;
      }
    }
  }
  return count;
}

function get_player(faction_id: FactionId) {
  return faction_player_map[faction_id];
}

// Gets player order
function get_player_order(
  first_player: FactionId = game.initiative
): FactionId[] {
  const order = [];
  let faction = first_player;
  for (let i = 0; i < 3; ++i) {
    order.push(faction);
    faction =
      game.year === 2
        ? get_previous_faction(faction)
        : get_next_faction(faction);
  }

  return order;
}

function get_next_faction_in_player_order(faction_id: FactionId): FactionId {
  return get_player_order(faction_id)[1];
}

// Gets previous faction in game order (so does not change for year 2)
function get_previous_faction(faction_id: FactionId): FactionId {
  const index = game.player_order.indexOf(faction_player_map[faction_id]);
  if (index === 0) {
    return player_faction_map[game.player_order[2]];
  }
  return player_faction_map[game.player_order[index - 1]];
}

// Gets next faction in game order
function get_next_faction(faction_id: FactionId): FactionId {
  const index = game.player_order.indexOf(faction_player_map[faction_id]);
  if (index === 2) {
    return player_faction_map[game.player_order[0]];
  }
  return player_faction_map[game.player_order[index + 1]];
}

// Gets game order
function get_player_order_in_game(
  first_player: FactionId = game.initiative
): FactionId[] {
  const order = [];
  let faction = first_player;
  for (let i = 0; i < 3; ++i) {
    order.push(faction);
    faction = get_next_faction(faction);
  }

  return order;
}

function join_oxford_comma(list: string[], conjunction: string) {
  let n = list.length
  if (n == 0)
    return "nothing";
  if (n == 1)
    return list[0];
  if (n == 2)
    return list[0] + " " + conjunction + " " + list[1];
  let result = list[0];
  for (let i = 1; i < n; ++i) {
    result += ", ";
    if (i == n - 1)
      result += conjunction + " "
    result += list[i];
  }
  return result
}

const icon_names = {
	add_to_front: "+1 to Front",
	collectivization: "Increase Collectivization",
	foreign_aid: "Increase Foreign Aid",
	liberty: "Increase Liberty",
	soviet_support: "Increase Soviet Support",
	government: "Increase Government",
	d_collectivization: "Decrease Collectivization",
	d_foreign_aid: "Decrease Foreign Aid",
	d_government: "Decrease Government",
	d_liberty: "Decrease Liberty",
	d_soviet_support: "Decrease Soviet Support",
	government_to_center: "Move Government to Center",
	teamwork_on: "Turn on Teamwork Bonus",
	draw_card: "Draw a Card",
}

function get_icon_name(icon: Icon): string {
  return icon_names[icon]
}

function get_source_name(source: EffectSource): string {
  switch (source) {
    case 'player_event':
      return cards[game.played_card].title;
    case 'fascist_event':
      return cards[game.current_events[game.current_events.length - 1]].title;
    case 'fascist_test':
      return 'Fascist Test';
    case 'tr0': return tracks[0].name + ' Trigger';
    case 'tr1': return tracks[1].name + ' Trigger';
    case 'tr2': return tracks[2].name + ' Trigger';
    case 'tr3': return tracks[3].name + ' Trigger';
    case 'tr4': return tracks[4].name + ' Trigger';
    case 'track_icon':
      return 'Track Trigger';
    case 'momentum':
      return 'Momentum';
  }
  return source;
}

function get_factions_with_most_hero_poins(): FactionId[] {
  let most_hero_points = null;
  let faction_ids = [];
  game.hero_points.forEach((value, id) => {
    if (id === POOL_ID) {
      return;
    }
    if (most_hero_points === null || value > most_hero_points) {
      most_hero_points = value;
      faction_ids = [id];
    } else if (most_hero_points === value) {
      faction_ids.push(id);
    }
  });
  return faction_ids;
}

function get_track_name(track_id: number): string {
  return tracks[track_id].name;
}

function make_list(first: number, last: number): number[] {
  let list = [];
  for (let i = first; i <= last; i++) list.push(i);
  return list;
}

function list_deck(id: FactionId | FascistId) {
  const deck = [];
  const card_list: CardId[] =
    id === FASCIST_ID ? fascist_decks[game.year] : faction_cards[id];
  card_list.forEach((card) => {
    if (id === FASCIST_ID) {
      if (game.current_events.includes(card)) return;
      if (game.discard[id].includes(card)) return;
    } else if (
      game.hands[id].includes(card) ||
      game.discard[id].includes(card) ||
      game.tableaus[id].includes(card) ||
      game.trash[id].includes(card)
    ) {
      return;
    }
    deck.push(card);
  });
  return deck;
}

function draw_medallions() {
  const medallion_ids = make_list(0, 8) as number[];
  log("Medallions:")
  for (let m = 0; m < 5; ++m) {
    let i = random(medallion_ids.length);
    let r = medallion_ids[i] as CardId;
    set_delete(medallion_ids, r);
    game.medallions[POOL_ID].push(r);
    logi("M" + r)
  }
}

// #endregion

// #region COMMON LIBRARY

function clear_undo() {
  if (game.undo) {
    game.undo.length = 0;
  }
}

function push_undo() {
  if (game.undo) {
    let copy = {} as Game;
    for (let k in game) {
      let v = game[k];
      if (k === 'undo') continue;
      else if (k === 'log') v = v.length;
      else if (typeof v === 'object' && v !== null) v = object_copy(v);
      copy[k] = v;
    }
    game.undo.push(copy);
  }
  return game.undo;
}

function pop_undo() {
  if (game.undo) {
    let save_log = game.log;
    let save_undo = game.undo;
    game = save_undo.pop();
    (save_log as string[]).length = game.log as unknown as number;
    game.log = save_log;
    game.undo = save_undo;
  }
}

function random(range: number): number {
  // An MLCG using integer arithmetic with doubles.
  // https://www.ams.org/journals/mcom/1999-68-225/S0025-5718-99-00996-5/S0025-5718-99-00996-5.pdf
  // m = 2**35 − 31
  return (game.seed = (game.seed * 200105) % 34359738337) % range;
}

// Fast deep copy for objects without cycles
function object_copy(original) {
  if (Array.isArray(original)) {
    let n = original.length;
    let copy = new Array(n);
    for (let i = 0; i < n; ++i) {
      let v = original[i];
      if (typeof v === 'object' && v !== null) copy[i] = object_copy(v);
      else copy[i] = v;
    }
    return copy;
  } else {
    let copy = {};
    for (let i in original) {
      let v = original[i];
      if (typeof v === 'object' && v !== null) copy[i] = object_copy(v);
      else copy[i] = v;
    }
    return copy;
  }
}

// Array remove and insert (faster than splice)

function array_remove<T>(array: T[], index: number) {
  let n = array.length;
  for (let i = index + 1; i < n; ++i) array[i - 1] = array[i];
  array.length = n - 1;
}

function array_insert<T>(array: T[], index: number, item: T) {
  for (let i = array.length; i > index; --i) array[i] = array[i - 1];
  array[index] = item;
}

// function array_remove_pair<T>(array: T[], index: number) {
//   let n = array.length;
//   for (let i = index + 2; i < n; ++i) array[i - 2] = array[i];
//   array.length = n - 2;
// }

// function array_insert_pair<K, V>(
//   array: (K | V)[],
//   index: number,
//   key: K,
//   value: V
// ) {
//   for (let i = array.length; i > index; i -= 2) {
//     array[i] = array[i - 2];
//     array[i + 1] = array[i - 1];
//   }
//   array[index] = key;
//   array[index + 1] = value;
// }

// Set as plain sorted array

// function set_clear<T>(set: T[]) {
//   // eslint-disable-line @typescript-eslint/no-unused-vars
//   set.length = 0;
// }

// function set_has<T>(set: T[], item: T) {
//   let a = 0;
//   let b = set.length - 1;
//   while (a <= b) {
//     const m = (a + b) >> 1;
//     const x = set[m];
//     if (item < x) b = m - 1;
//     else if (item > x) a = m + 1;
//     else return true;
//   }
//   return false;
// }

// function set_add<T>(set: T[], item: T) {
//   // eslint-disable-line @typescript-eslint/no-unused-vars
//   let a = 0;
//   let b = set.length - 1;
//   while (a <= b) {
//     const m = (a + b) >> 1;
//     const x = set[m];
//     if (item < x) b = m - 1;
//     else if (item > x) a = m + 1;
//     else return set;
//   }
//   return array_insert(set, a, item);
// }

// function set_delete<T>(set: T[], item: T) {
//   let a = 0;
//   let b = set.length - 1;
//   while (a <= b) {
//     let m = (a + b) >> 1;
//     let x = set[m];
//     if (item < x) b = m - 1;
//     else if (item > x) a = m + 1;
//     else {
//       array_remove(set, m);
//       return;
//     }
//   }
// }

function set_delete<T>(set: T[], item: T) {
  // eslint-disable-line @typescript-eslint/no-unused-vars
  let a = 0;
  let b = set.length - 1;
  while (a <= b) {
    const m = (a + b) >> 1;
    const x = set[m];
    if (item < x) b = m - 1;
    else if (item > x) a = m + 1;
    else return array_remove(set, m);
  }
  return set;
}

// #endregion
// vim: sts=2:sw=2:et