# Patterns: hex map with walls, obstacles and difficult terrain; initiative each round; choosing
# cards, resting and losing cards; monsters with a fixed way of thinking (focus, move to an attack
# cell, attack); range and line of sight; area attack with a rotated pattern; attack modifier
# decks that are reshuffled after x2 or a miss.
game: hex-skirmish
label: Hex Skirmish
players: "2 players, together"
num_players: 2
mode: cooperative
summary: >
  Two heroes (warrior and seer) against three monsters in a corridor with a wall and a gap.
  Each round every hero secretly picks a card (initiative, movement, attack or healing);
  the monsters draw one behaviour card per type. Lowest initiative goes first. Monsters pick
  their own target and walk to a cell from which they can attack. Win when all monsters are
  defeated; lose when both heroes fall or after eight rounds.
scope: >
  Our own small setup in the spirit of tactical dungeon games; no existing cards. A hero plays one
  card per round (no top and bottom halves). On equal distance a monster picks the hero with the
  lowest player number, not the one with the lowest initiative.

boards:
  map:
    kind: hex
    width: 7
    height: 5
    label: corridor with a wall
    missing: ["3,0", "3,1", "2,3", "1,4"]

constants:
  MAX_ROUNDS: 8
  HEROES: [warrior, seer]
  OBSTACLES: ["5,2"]
  TERRAIN: {"2,2": 2, "1,2": 2}
  AROUND: [e, se, sw, w, nw, ne]
  MONSTERS:
    guard: {move: 2, attack: 3, range: 1}
    archer: {move: 2, attack: 2, range: 3}
  MODIFIERS: [0, 0, 0, 0, 1, 1, -1, -1, 2, -2, x2, miss]
  SETUP:
    - {id: warrior, label: Warrior, side: hero, player: 0, hp: 10, max: 10, shield: 1, cell: "0,1"}
    - {id: seer, label: Seer, side: hero, player: 1, hp: 7, max: 7, shield: 0, cell: "-1,3"}
    - {id: guard1, label: Guard 1, side: monster, type: guard, hp: 6, max: 6, shield: 1, cell: "5,1"}
    - {id: guard2, label: Guard 2, side: monster, type: guard, hp: 6, max: 6, shield: 1, cell: "4,3"}
    - {id: archer1, label: Archer, side: monster, type: archer, hp: 4, max: 4, shield: 0, cell: "6,0"}

content:
  hero_cards:
    warrior:
      - {id: charge, label: Charge, status: verified, ini: 15, move: 4, attack: 2, range: 1, note: "Move 4, attack an adjacent enemy for 2."}
      - {id: heavy_blow, label: Heavy Blow, status: verified, ini: 40, move: 2, attack: 4, range: 1, note: "Move 2, attack an adjacent enemy for 4."}
      - {id: whirlwind, label: Whirlwind, status: verified, ini: 28, move: 1, attack: 2, range: 1, around: true, pattern: [e, se, sw, w, nw, ne], note: "Move 1, attack every adjacent enemy for 2."}
      - {id: cautious, label: Cautious, status: verified, ini: 60, move: 3, attack: 3, range: 1, note: "Move 3, attack an adjacent enemy for 3."}
    seer:
      - {id: spark, label: Spark, status: verified, ini: 20, move: 2, attack: 2, range: 3, note: "Move 2, attack at range 3 for 2."}
      - {id: fireball, label: Fireball, status: verified, ini: 55, move: 1, attack: 2, range: 3, pattern: [[0, 0], e, se], note: "Move 1, hit a triangle of three cells within range 3 for 2; it may be rotated."}
      - {id: restore, label: Restore, status: verified, ini: 35, move: 2, heal: 3, range: 2, note: "Move 2, heal 3 on yourself or a hero within range 2."}
      - {id: slip_away, label: Slip Away, status: verified, ini: 12, move: 4, attack: 1, range: 2, note: "Move 4, attack at range 2 for 1."}
  monster_cards:
    guard:
      - {id: gd_step, label: Step and Strike, status: verified, ini: 45, move: 0, attack: 0}
      - {id: gd_quick, label: Quick Lunge, status: verified, ini: 30, move: 1, attack: -1}
      - {id: gd_heavy, label: Crushing Blow, status: verified, ini: 70, move: -1, attack: 1}
    archer:
      - {id: ar_shot, label: Shot, status: verified, ini: 20, move: 0, attack: 0}
      - {id: ar_walk, label: Running Fire, status: verified, ini: 50, move: 1, attack: -1}
      - {id: ar_aim, label: Take Aim, status: verified, ini: 65, move: -1, attack: 1}

functions:
  heroes:
    x: "[f for f in figures if f['side'] == 'hero']"
  monsters:
    x: "[f for f in figures if f['side'] == 'monster']"
  hero:
    text: "the figure of player p (None if it has fallen)"
    params: [p]
    x: "([f for f in figures if f.get('player') == p] + [None])[0]"
  fig:
    params: [i]
    x: "([f for f in figures if f['id'] == i] + [None])[0]"
  blocked:
    text: "cells a figure of this side cannot pass through: obstacles and enemies"
    params: [side]
    x: "OBSTACLES + [f['cell'] for f in figures if f['side'] != side]"
  passable:
    text: "cells of allies: you may pass through, but not end there"
    params: [side, own_id]
    x: "[f['cell'] for f in figures if f['side'] == side and f['id'] != own_id]"
  free:
    params: [c, own_id]
    x: "c not in OBSTACLES and not any(f['cell'] == c and f['id'] != own_id for f in figures)"
  in_range:
    text: "within range (counted around walls) and in sight (corner to corner)"
    params: [a, b, reach]
    let:
      cost: "path_cost('map', a, b) if distance('map', a, b) <= reach else None"
    x: "cost is not None and cost <= reach and line_of_sight('map', a, b, True)"
  attack_cells:
    text: "empty cells from which monster m can attack hero h"
    params: [m, h, reach]
    x: "[c for c in cells('map') if distance('map', c, h['cell']) <= reach and free(c, m['id']) and in_range(c, h['cell'], reach)]"
  route:
    text: "movement points for monster m from cell a to cell b (None: unreachable)"
    params: [m, a, b]
    x: "path_cost('map', a, b, blocked('monster'), TERRAIN, passable('monster', m['id']))"
  focus:
    text: "the hero the monster can attack with the least movement; on a tie: lowest player number"
    params: [m, reach]
    let:
      scores: >-
        [(min([k for k in [route(m, m['cell'], c) for c in attack_cells(m, h, reach)] if k is not None] + [999]), n, h['id'])
         for n, h in enumerate(heroes())]
    x: "min(scores)[2] if scores and min(scores)[0] < 999 else None"
  destination:
    text: "the cheapest reachable cell from which the monster can attack; otherwise as close as possible"
    params: [m, h, movement, reach]
    let:
      candidates: "[m['cell']] + reachable('map', m['cell'], movement, blocked('monster'), TERRAIN, passable('monster', m['id']))"
      targets: "attack_cells(m, h, reach)"
      direct: "[c for c in candidates if c in targets]"
    x: >-
      (direct[0] if direct else
       min([(min([k for k in [route(m, c, d) for d in targets] if k is not None] + [999]), n, c)
            for n, c in enumerate(candidates)])[2])
  attack_options:
    text: "whom a hero with card c can hit: one target, everything around, or a rotated pattern on a cell in sight"
    params: [me, c]
    let:
      pat: "c['origin'].get('pattern')"
      sets: >-
        ([[m] for m in monsters() if in_range(me['cell'], m['cell'], c['range'])] if not pat
         else [[m for m in monsters() if m['cell'] in shape('map', me['cell'], pat)]] if c.get('around')
         else [[m for m in monsters() if m['cell'] in shape('map', t, pat, d)]
               for t in cells('map') if in_range(me['cell'], t, c['range']) for d in range(6)])
    x: "list({str([m['id'] for m in s]): s for s in sets if s}.values())"
  damage:
    params: [base, mod, shield]
    x: "0 if mod == 'miss' else max(0, (base * 2 if mod == 'x2' else base + mod) - shield)"

state:
  zones:
    figures: {kind: hexgrid, owner: global, visibility: public, init_x: SETUP, note: "figures on the map; cell = where they stand"}
    defeated: {kind: set, owner: global, visibility: public}
    hand: {kind: set, owner: player, visibility: owner, init_x: "[content['hero_cards'][h] for h in HEROES]"}
    discard: {kind: stack, owner: player, visibility: public}
    lost: {kind: set, owner: player, visibility: public}
    hero_mod_deck: {kind: stack, owner: global, visibility: hidden, shuffled: true, init_x: MODIFIERS, note: "attack modifiers of the heroes"}
    hero_mod_discard: {kind: stack, owner: global, visibility: public}
    monster_mod_deck: {kind: stack, owner: global, visibility: hidden, shuffled: true, init_x: MODIFIERS, note: "attack modifiers of the monsters"}
    monster_mod_discard: {kind: stack, owner: global, visibility: public}
  vars:
    round_no: {scope: global, type: int, init: 1}
    p: {scope: global, type: ref, init: 0, note: "player choosing a card"}
    who: {scope: global, type: str, init: null, note: "figure whose turn it is"}
    order: {scope: global, type: list, init: []}
    card: {scope: player, type: dict, init: null, note: "card chosen this round"}
    monster_card: {scope: global, type: dict, init: {}, note: "behaviour card per monster type this round"}
    reshuffle_hero: {scope: global, type: bool, init: false}
    reshuffle_monster: {scope: global, type: bool, init: false}

flow:
  id: battle
  label: Battle
  kind: loop
  note: "stops through the end conditions"
  children:
    - id: choose_cards
      label: Heroes choose a card
      kind: loop
      over_x: "[f['player'] for f in heroes()]"
      as: p
      once: true
      children:
        - {id: choose, label: Choose a card (or rest), kind: step, actor_x: p, do: choose_card}
    - {id: monster_cards, label: Monsters draw a behaviour card, kind: step, do: draw_monster_cards}
    - {id: order, label: Order by initiative, kind: step, do: set_order}
    - id: turns
      label: Turns
      kind: loop
      over_x: order
      as: who
      once: true
      children:
        - id: turn
          kind: branch
          switch: "hero or monster (and still on the map)"
          cases:
            - when: hero
              when_x: "fig(who) is not None and fig(who)['side'] == 'hero'"
              children: [{id: hero_turn, label: Hero acts, kind: step, actor_x: "fig(who)['player']", do: hero_acts}]
            - when: monster
              when_x: "fig(who) is not None and fig(who)['side'] == 'monster'"
              children: [{id: monster_turn, label: Monster acts, kind: step, do: monster_acts}]
    - {id: end_round, label: End of round, kind: step, do: finish_round}

actions:
  choose_card:
    effect:
      - op: if
        cond: "no cards in hand: rest"
        cond_x: "len(hand[actor]) == 0"
        then:
          - op: if
            cond: "fewer than two discarded cards: exhausted"
            cond_x: "len(discard[actor]) < 2"
            then:
              - {op: move, what_x: "hero(actor)", from: figures, to: defeated}
              - {op: emit, event: exhausted}
            else:
              - {op: move, what_x: "list(discard[actor])", from: "discard[actor]", to: "hand[actor]"}
              - {op: roll, die_x: "hand[actor]", bind: gone}
              - {op: move, what_x: gone, from: "hand[actor]", to: "lost[actor]"}
              - {op: emit, event: rested}
      - op: if
        cond_x: "len(hand[actor]) > 0 and hero(actor) is not None"
        then:
          - {op: choose, options: "cards in hand", options_x: "hand[actor]", bind: c, secret: true}
          - {op: move, what_x: c, from: "hand[actor]", to: "discard[actor]"}
          - {op: set, var: "card[actor]", value_x: c}
        else:
          - {op: set, var: "card[actor]", value: null}

  draw_monster_cards:
    effect:
      - op: for_each
        items_x: "sorted(set(m['type'] for m in monsters()))"
        as: t
        body:
          - {op: roll, die_x: "content['monster_cards'][t]", bind: mc}
          - {op: set, var: "monster_card[t]", value_x: mc}

  set_order:
    effect:
      - op: set
        var: order
        value: "by initiative; on a tie heroes first"
        value_x: >-
          [t[3] for t in sorted(
             [(card[f['player']]['ini'], 0, n, f['id']) for n, f in enumerate(figures)
              if f['side'] == 'hero' and card[f['player']] is not None]
           + [(monster_card[f['type']]['ini'], 1, n, f['id']) for n, f in enumerate(figures) if f['side'] == 'monster'])]

  hero_acts:
    effect:
      - {op: let, bind: me, value_x: "hero(actor)"}
      - {op: let, bind: c, value_x: "card[actor]"}
      - op: choose
        options: "where to (staying is allowed)"
        options_x: "[me['cell']] + reachable('map', me['cell'], c['move'], blocked('hero'), TERRAIN, passable('hero', me['id']))"
        bind: dest
      - {op: set, of_x: me, attr: cell, value_x: dest}
      - op: if
        cond: "healing card"
        cond_x: "c.get('heal')"
        then:
          - {op: choose, options: "hero in range", options_x: "[h for h in heroes() if in_range(me['cell'], h['cell'], c['range'])]", bind: target}
          - {op: set, of_x: target, attr: hp, value_x: "min(target['max'], target['hp'] + c['heal'])"}
          - {op: emit, event: healed}
        else:
          - {op: let, bind: choices, value_x: "attack_options(me, c)"}
          - op: if
            cond_x: "len(choices) > 0"
            then:
              - {op: choose, options: "whom you hit", options_x: choices, bind: hit}
              - op: for_each
                items_x: hit
                as: target
                body:
                  - op: if
                    cond_x: "len(hero_mod_deck) == 0"
                    then: [{op: move, what_x: "list(hero_mod_discard)", from: hero_mod_discard, to: hero_mod_deck}, {op: shuffle, zone: hero_mod_deck}]
                  - {op: draw, from: hero_mod_deck, to: hero_mod_discard}
                  - {op: if, cond_x: "hero_mod_discard[-1] in ('x2', 'miss')", then: [{op: set, var: reshuffle_hero, value: true}]}
                  - {op: set, of_x: target, attr: hp, value_x: "max(0, target['hp'] - damage(c['attack'], hero_mod_discard[-1], target['shield']))"}
                  - {op: emit, event: monster_hit}
                  - op: if
                    cond_x: "target['hp'] <= 0"
                    then: [{op: move, what_x: target, from: figures, to: defeated}, {op: emit, event: monster_defeated}]

  monster_acts:
    effect:
      - {op: let, bind: m, value_x: "fig(who)"}
      - {op: let, bind: mc, value_x: "monster_card[m['type']]"}
      - {op: let, bind: movement, value_x: "max(0, MONSTERS[m['type']]['move'] + mc['move'])"}
      - {op: let, bind: attack, value_x: "max(0, MONSTERS[m['type']]['attack'] + mc['attack'])"}
      - {op: let, bind: reach, value_x: "MONSTERS[m['type']]['range']"}
      - {op: let, bind: h, value_x: "focus(m, reach)"}
      - op: if
        cond: "there is a hero to focus on"
        cond_x: "h is not None"
        then:
          - {op: set, of_x: m, attr: cell, value_x: "destination(m, fig(h), movement, reach)"}
          - op: if
            cond_x: "in_range(m['cell'], fig(h)['cell'], reach)"
            then:
              - {op: let, bind: target, value_x: "fig(h)"}
              - op: if
                cond_x: "len(monster_mod_deck) == 0"
                then: [{op: move, what_x: "list(monster_mod_discard)", from: monster_mod_discard, to: monster_mod_deck}, {op: shuffle, zone: monster_mod_deck}]
              - {op: draw, from: monster_mod_deck, to: monster_mod_discard}
              - {op: if, cond_x: "monster_mod_discard[-1] in ('x2', 'miss')", then: [{op: set, var: reshuffle_monster, value: true}]}
              - {op: set, of_x: target, attr: hp, value_x: "max(0, target['hp'] - damage(attack, monster_mod_discard[-1], target['shield']))"}
              - {op: emit, event: hero_hit}
              - op: if
                cond_x: "target['hp'] <= 0"
                then: [{op: move, what_x: target, from: figures, to: defeated}, {op: emit, event: hero_fallen}]

  finish_round:
    effect:
      - op: if
        cond_x: reshuffle_hero
        then: [{op: move, what_x: "list(hero_mod_discard)", from: hero_mod_discard, to: hero_mod_deck}, {op: shuffle, zone: hero_mod_deck}, {op: set, var: reshuffle_hero, value: false}]
      - op: if
        cond_x: reshuffle_monster
        then: [{op: move, what_x: "list(monster_mod_discard)", from: monster_mod_discard, to: monster_mod_deck}, {op: shuffle, zone: monster_mod_deck}, {op: set, var: reshuffle_monster, value: false}]
      - {op: add, var: round_no, amount: 1}

end:
  - {id: won, check: immediate, if: "all monsters defeated", if_x: "not monsters()", result: win}
  - {id: fallen, check: immediate, if: "both heroes fallen or exhausted", if_x: "not heroes()", result: loss}
  - {id: time_up, check: end_of_round, if: "after the last round", if_x: "round_no > MAX_ROUNDS", result: loss}
