# Ashmoor Delve: our own cooperative dungeon campaign, written for Tabletwin (no existing game's
# names, cards or scenarios). Patterns: a campaign of three linked scenarios with state that carries
# over (gold, experience, items, a key found in one scenario that weakens the boss of the next);
# heroes with a hand of two-halved ability cards (initiative, top and bottom action, cards that are
# lost when used); resting; monsters that act from a random behaviour card per type and choose
# their own target; conditions (poison, stun, immobilize); a boss that summons and hits in an area;
# attack modifier decks; coins, traps and an exit on a hex map.
# yaml-language-server: $schema=https://tabletwin.com/models/docs/schema.json
game: ashmoor-delve
label: Ashmoor Delve
players: "2 players, together"
num_players: 2
mode: cooperative
summary: >
  Two heroes, the Warden and the Lanternwright, go down into the ash-choked halls under Ashmoor:
  three scenarios in a row on a hex map. Each round every hero secretly picks two ability cards
  (one sets the initiative) or rests; each monster type turns over a behaviour card. In order of
  initiative, heroes use the top half of one card and the bottom half of the other (or a basic
  attack or move instead; a half that moves is carried out first), and monsters walk to the nearest hero they can hit. Cards run out, so
  heroes must rest, and a hero who cannot play or rest is exhausted. Gold, experience (more hit
  points) and items carry over; the key from the archive breaks the Ash King's ward. A scenario
  may be tried twice; win all three to win the campaign.
scope: >
  Original game. A hero's hand is all eight of their cards. Each monster type turns over a random
  one of its six behaviour cards every round (there is no behaviour deck to run through).
  Conditions last until the end of the affected figure's next turn, except poison, which lasts
  until healed: a poisoned figure takes 1 extra damage from every hit, and the first heal or rest
  removes the poison instead of healing. A shield lasts from the figure's turn to the end of the
  round. Traps go off when a hero ends a move on them.

boards:
  delve:
    kind: hex
    width: 9
    height: 7
    label: the halls under Ashmoor (9 × 7 hexes)

constants:
  HEROES: [warden, lanternwright]
  HERO_STATS:
    warden: {label: Warden, hp: 11, shield: 1}
    lanternwright: {label: Lanternwright, hp: 8, shield: 0}
  XP_PER_HP: 3
  MONSTERS:
    ravager: {label: Ravager, hp: 7, move: 2, attack: 3, range: 1, shield: 0}
    slinger: {label: Slinger, hp: 4, move: 2, attack: 2, range: 3, shield: 0}
    imp: {label: Cinder Imp, hp: 3, move: 3, attack: 1, range: 1, shield: 0}
    king: {label: The Ash King, hp: 15, move: 1, attack: 3, range: 2, shield: 1}
  KEY_WARD: 5
  TRAP_DAMAGE: 2
  REST: "Rest: lose a discarded card, take the others back, heal 2"
  BASIC_TOP: {label: "Basic: attack 2", attack: 2, range: 1}
  BASIC_BOTTOM: {label: "Basic: move 2", move: 2}
  MODIFIERS: [0, 0, 0, 0, 0, 0, 1, 1, -1, -1, 2, -2, x2, miss]
  ITEMS:
    - {id: boots, label: "Iron Boots (3 gold): +1 to every move", cost: 3}
    - {id: oil, label: "Lantern Oil (3 gold): +1 to every ranged attack", cost: 3}
    - {id: coat, label: "Ash-mail Coat (4 gold): +1 shield", cost: 4}
  ITEM_FOR: {move: boots, ranged: oil, shield: coat}
  SCENARIOS:
    - id: stair
      label: "1 · The Collapsed Stair"
      goal: slay
      rounds: 10
      start: ["-3,6", "-2,6"]
      monsters:
        - {id: ravager1, type: ravager, cell: "5,1"}
        - {id: slinger1, type: slinger, cell: "7,0"}
        - {id: imp1, type: imp, cell: "5,2"}
      obstacles: ["2,3", "3,3", "4,4", "0,1"]
      terrain: {"1,2": 2, "2,2": 2, "-1,5": 2}
      coins: ["6,0", "8,1", "-1,4"]
      traps: ["3,4"]
      exits: []
      key: null
    - id: archive
      label: "2 · The Drowned Archive"
      goal: escape
      rounds: 10
      start: ["-1,3", "-2,4"]
      monsters:
        - {id: slinger1, type: slinger, cell: "6,1"}
        - {id: ravager1, type: ravager, cell: "6,3"}
        - {id: imp1, type: imp, cell: "7,2"}
      obstacles: ["2,1", "3,1", "4,5", "3,6", "0,5"]
      terrain: {"3,2": 2, "3,3": 2, "2,4": 2, "2,5": 2}
      coins: ["0,2", "5,4"]
      traps: ["4,2"]
      exits: ["7,3", "6,4"]
      key: "4,0"
    - id: throne
      label: "3 · Throne of Cinders"
      goal: boss
      rounds: 14
      start: ["0,6", "2,6"]
      monsters:
        - {id: ash_king, type: king, cell: "4,1"}
        - {id: imp1, type: imp, cell: "2,1"}
        - {id: imp2, type: imp, cell: "6,1"}
        - {id: ravager1, type: ravager, cell: "4,3"}
      obstacles: ["1,3", "6,3", "3,0", "5,0"]
      terrain: {"3,2": 2, "4,2": 2, "2,4": 2, "4,4": 2}
      coins: ["0,2", "7,2"]
      traps: ["1,5", "5,5"]
      exits: []
      key: null

content:
  # Each half of a card is an action: move, attack (range, around, all_in_range, pattern,
  # poison, stun, immobilize), heal (self, range), shield (until the end of the round), loot
  # (coins within that distance), xp, and lost (the card is lost once this half is used).
  hero_cards:
    warden:
      - {id: shield_bash, label: Shield Bash, status: verified, ini: 18, deck: warden,
         text: "Top: Attack 3, stun (lost, 1 xp)\nBottom: Move 3",
         top: {label: "Attack 3, stun (lost, 1 xp)", attack: 3, stun: true, xp: 1, lost: true},
         bottom: {label: "Move 3", move: 3}}
      - {id: hold_the_line, label: Hold the Line, status: verified, ini: 64, deck: warden,
         text: "Top: Shield 2 this round\nBottom: Move 2, heal 2 yourself",
         top: {label: "Shield 2 this round", shield: 2},
         bottom: {label: "Move 2, heal 2 yourself", move: 2, heal: 2, self: true}}
      - {id: cleave, label: Cleave, status: verified, ini: 32, deck: warden,
         text: "Top: Attack 2 on every adjacent enemy\nBottom: Move 2",
         top: {label: "Attack 2 on every adjacent enemy", attack: 2, around: true},
         bottom: {label: "Move 2", move: 2}}
      - {id: steady_advance, label: Steady Advance, status: verified, ini: 45, deck: warden,
         text: "Top: Move 3, attack 2\nBottom: Loot 1",
         top: {label: "Move 3, attack 2", move: 3, attack: 2},
         bottom: {label: "Loot 1", loot: 1}}
      - {id: iron_will, label: Iron Will, status: verified, ini: 77, deck: warden,
         text: "Top: Heal 3 yourself\nBottom: Move 1, shield 1 this round",
         top: {label: "Heal 3 yourself", heal: 3, self: true},
         bottom: {label: "Move 1, shield 1 this round", move: 1, shield: 1}}
      - {id: crushing_strike, label: Crushing Strike, status: verified, ini: 51, deck: warden,
         text: "Top: Attack 4 (1 xp)\nBottom: Attack 2, immobilize",
         top: {label: "Attack 4 (1 xp)", attack: 4, xp: 1},
         bottom: {label: "Attack 2, immobilize", attack: 2, immobilize: true}}
      - {id: rally, label: Rally, status: verified, ini: 27, deck: warden,
         text: "Top: Heal 2, range 2\nBottom: Move 4 (lost, 1 xp)",
         top: {label: "Heal 2, range 2", heal: 2, range: 2},
         bottom: {label: "Move 4 (lost, 1 xp)", move: 4, xp: 1, lost: true}}
      - {id: last_stand, label: Last Stand, status: verified, ini: 90, deck: warden,
         text: "Top: Attack 5 (lost, 2 xp)\nBottom: Shield 2 this round",
         top: {label: "Attack 5 (lost, 2 xp)", attack: 5, xp: 2, lost: true},
         bottom: {label: "Shield 2 this round", shield: 2}}
    lanternwright:
      - {id: flare, label: Flare, status: verified, ini: 12, deck: lanternwright,
         text: "Top: Attack 2, range 3\nBottom: Move 3",
         top: {label: "Attack 2, range 3", attack: 2, range: 3},
         bottom: {label: "Move 3", move: 3}}
      - {id: kindle, label: Kindle, status: verified, ini: 30, deck: lanternwright,
         text: "Top: Attack 2, range 3, poison\nBottom: Move 2",
         top: {label: "Attack 2, range 3, poison", attack: 2, range: 3, poison: true},
         bottom: {label: "Move 2", move: 2}}
      - {id: lantern_burst, label: Lantern Burst, status: verified, ini: 55, deck: lanternwright,
         text: "Top: Attack 2 on three hexes in a triangle, range 2 (1 xp)\nBottom: Move 2, loot 2",
         top: {label: "Attack 2 on three hexes in a triangle, range 2 (1 xp)", attack: 2, range: 2, pattern: [[0, 0], e, se], xp: 1},
         bottom: {label: "Move 2, loot 2", move: 2, loot: 2}}
      - {id: mend, label: Mend, status: verified, ini: 40, deck: lanternwright,
         text: "Top: Heal 3, range 3\nBottom: Move 3",
         top: {label: "Heal 3, range 3", heal: 3, range: 3},
         bottom: {label: "Move 3", move: 3}}
      - {id: blinding_glare, label: Blinding Glare, status: verified, ini: 21, deck: lanternwright,
         text: "Top: Attack 1, range 3, stun (lost, 1 xp)\nBottom: Move 4",
         top: {label: "Attack 1, range 3, stun (lost, 1 xp)", attack: 1, range: 3, stun: true, xp: 1, lost: true},
         bottom: {label: "Move 4", move: 4}}
      - {id: smoke_step, label: Smoke Step, status: verified, ini: 9, deck: lanternwright,
         text: "Top: Move 4\nBottom: Attack 2, range 2",
         top: {label: "Move 4", move: 4},
         bottom: {label: "Attack 2, range 2", attack: 2, range: 2}}
      - {id: ember_lance, label: Ember Lance, status: verified, ini: 66, deck: lanternwright,
         text: "Top: Attack 4, range 4 (1 xp)\nBottom: Heal 2 yourself",
         top: {label: "Attack 4, range 4 (1 xp)", attack: 4, range: 4, xp: 1},
         bottom: {label: "Heal 2 yourself", heal: 2, self: true}}
      - {id: inferno, label: Inferno, status: verified, ini: 85, deck: lanternwright,
         text: "Top: Attack 3 on every enemy within range 2 (lost, 2 xp)\nBottom: Move 2",
         top: {label: "Attack 3 on every enemy within range 2 (lost, 2 xp)", attack: 3, all_in_range: 2, xp: 2, lost: true},
         bottom: {label: "Move 2", move: 2}}
  # Behaviour cards: changes to the type's move and attack, and what else happens.
  monster_cards:
    ravager:
      - {id: rv_swing, label: Lumbering Swing, status: verified, ini: 60, deck: ravager, move: 0, attack: 0, text: "Move ±0, attack ±0"}
      - {id: rv_charge, label: Charge, status: verified, ini: 35, deck: ravager, move: 1, attack: -1, text: "Move +1, attack −1"}
      - {id: rv_crush, label: Crush, status: verified, ini: 72, deck: ravager, move: -1, attack: 1, immobilize: true, text: "Move −1, attack +1, immobilize"}
      - {id: rv_brace, label: Brace, status: verified, ini: 50, deck: ravager, move: 0, attack: 0, guard: 1, text: "Move ±0, attack ±0, shield 1 this round"}
      - {id: rv_frenzy, label: Frenzy, status: verified, ini: 25, deck: ravager, move: 1, attack: 0, text: "Move +1, attack ±0"}
      - {id: rv_roar, label: Roar, status: verified, ini: 80, deck: ravager, move: -1, attack: 2, text: "Move −1, attack +2"}
    slinger:
      - {id: sl_volley, label: Volley, status: verified, ini: 30, deck: slinger, move: 0, attack: 0, text: "Move ±0, attack ±0"}
      - {id: sl_retreat, label: Shoot and Slip, status: verified, ini: 15, deck: slinger, move: 1, attack: -1, text: "Move +1, attack −1"}
      - {id: sl_poison, label: Poisoned Stone, status: verified, ini: 55, deck: slinger, move: 0, attack: 0, poison: true, text: "Move ±0, attack ±0, poison"}
      - {id: sl_aim, label: Careful Aim, status: verified, ini: 70, deck: slinger, move: -2, attack: 1, text: "Move −2, attack +1"}
      - {id: sl_pin, label: Pin Down, status: verified, ini: 40, deck: slinger, move: 0, attack: -1, immobilize: true, text: "Move ±0, attack −1, immobilize"}
      - {id: sl_scatter, label: Scatter, status: verified, ini: 20, deck: slinger, move: 1, attack: 0, text: "Move +1, attack ±0"}
    imp:
      - {id: im_dart, label: Dart, status: verified, ini: 10, deck: imp, move: 1, attack: 0, text: "Move +1, attack ±0"}
      - {id: im_bite, label: Burning Bite, status: verified, ini: 22, deck: imp, move: 0, attack: 0, poison: true, text: "Move ±0, attack ±0, poison"}
      - {id: im_flit, label: Flit, status: verified, ini: 18, deck: imp, move: 2, attack: -1, text: "Move +2, attack −1"}
      - {id: im_cackle, label: Cackle, status: verified, ini: 45, deck: imp, move: 0, attack: 1, text: "Move ±0, attack +1"}
      - {id: im_swarm, label: Swarm, status: verified, ini: 30, deck: imp, move: 0, attack: 0, text: "Move ±0, attack ±0"}
      - {id: im_hide, label: Hide in the Ash, status: verified, ini: 62, deck: imp, move: -1, attack: 0, guard: 1, text: "Move −1, attack ±0, shield 1 this round"}
    king:
      - {id: kg_lash, label: Ember Lash, status: verified, ini: 40, deck: king, move: 0, attack: 0, text: "Move ±0, attack ±0"}
      - {id: kg_wave, label: Ash Wave, status: verified, ini: 55, deck: king, move: 0, attack: -1, wave: 2, text: "Move ±0, attack −1 on every hero within 2"}
      - {id: kg_brood, label: Call the Brood, status: verified, ini: 70, deck: king, move: 0, attack: -1, summon: imp, text: "Move ±0, attack −1, summons an imp"}
      - {id: kg_smoulder, label: Smoulder, status: verified, ini: 30, deck: king, move: 0, attack: -1, heal: 2, guard: 1, text: "Move ±0, attack −1, shield 1 this round, heals 2"}
      - {id: kg_stride, label: Stride, status: verified, ini: 50, deck: king, move: 1, attack: 1, text: "Move +1, attack +1"}
      - {id: kg_crown, label: Crown of Cinders, status: verified, ini: 80, deck: king, move: -1, attack: 2, stun: true, text: "Move −1, attack +2, stun"}

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 once fallen or exhausted)"
    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]"
  bonus:
    text: "1 if player p owns the item for this kind of action (move, ranged, shield)"
    params: [p, kind]
    x: "1 if ITEM_FOR[kind] in items[p] else 0"
  shield_of:
    params: [f]
    x: "f['shield'] + f.get('guard', 0) + (bonus(f['player'], 'shield') if f['side'] == 'hero' else 0)"
  blocked:
    text: "cells a figure of this side cannot enter: rubble and enemies"
    params: [side]
    x: "rubble + [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 rubble and not any(f['cell'] == c and f['id'] != own_id for f in figures)"
  in_range:
    text: "within range (counted around rubble) and in sight"
    params: [a, b, reach]
    let:
      cost: "path_cost('delve', a, b, rubble) if distance('delve', a, b) <= reach else None"
    x: "cost is not None and cost <= reach and line_of_sight('delve', a, b, True, rubble)"
  attack_cells:
    text: "empty cells from which monster m can attack hero h"
    params: [m, h, reach]
    x: "[c for c in cells('delve') if distance('delve', c, h['cell']) <= reach and free(c, m['id']) and in_range(c, h['cell'], reach)]"
  route:
    params: [m, a, b]
    x: "path_cost('delve', a, b, blocked('monster'), mire, passable('monster', m['id']))"
  focus:
    text: "the hero the monster can attack with the least movement; on a tie the one with fewer hit points, then the lower 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]), h['hp'], h['player'], h['id'])
         for h in heroes()]
    x: "min(scores)[3] 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('delve', m['cell'], movement, blocked('monster'), mire, 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 can hit with this half: one enemy in range, every adjacent one, every one within a range, or a rotated pattern"
    params: [me, h]
    let:
      reach: "h.get('range', 1)"
      sets: >-
        ([[m for m in monsters() if distance('delve', me['cell'], m['cell']) == 1]] if h.get('around')
         else [[m for m in monsters() if in_range(me['cell'], m['cell'], h['all_in_range'])]] if h.get('all_in_range')
         else [[m for m in monsters() if m['cell'] in shape('delve', t, h['pattern'], d)]
               for t in cells('delve') if in_range(me['cell'], t, reach) for d in range(6)] if h.get('pattern')
         else [[m] for m in monsters() if in_range(me['cell'], m['cell'], reach)])
    x: "list({str(sorted([m['id'] for m in s])): s for s in sets if s}.values())"
  damage:
    text: "attack plus modifier (doubled on x2, nothing on a miss), +1 against a poisoned target, minus shield"
    params: [base, mod, shield, poisoned]
    x: "0 if mod == 'miss' else max(0, (base * 2 if mod == 'x2' else base + mod) + (1 if poisoned else 0) - shield)"

state:
  zones:
    figures: {kind: hexgrid, owner: global, visibility: public, note: "heroes and monsters 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]"}
    played: {kind: set, owner: player, visibility: owner, note: "this round's two cards, laid in front of the hero (leading card first) until it acts"}
    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}
    behaviour: {kind: set, owner: global, visibility: public, note: "this round's behaviour card of each monster type"}
  vars:
    # the campaign: kept from one scenario to the next
    stage: {scope: global, type: int, init: 0, persist: campaign, note: "index of the scenario being played"}
    tries: {scope: global, type: int, init: 0, persist: campaign, note: "failed tries of this scenario"}
    failed: {scope: global, type: bool, init: false, persist: campaign}
    won: {scope: global, type: int, init: 0, persist: campaign, note: "scenarios won"}
    gold: {scope: global, type: int, init: 0, persist: campaign}
    xp: {scope: player, type: int, init: 0, persist: campaign}
    items: {scope: player, type: list, init_x: "[[] for q in players]", persist: campaign}
    key_found: {scope: global, type: bool, init: false, persist: campaign, note: "the archive key: the Ash King starts weaker"}
    # one scenario
    round_no: {scope: global, type: int, init: 1}
    rubble: {scope: global, type: list, init: [], note: "impassable cells; they also block sight"}
    mire: {scope: global, type: dict, init: {}, note: "difficult terrain: cell → cost to enter"}
    coins: {scope: global, type: list, init: [], note: "one entry per coin, by cell"}
    traps: {scope: global, type: list, init: []}
    exits: {scope: global, type: list, init: []}
    key_at: {scope: global, type: list, init: [], note: "where the archive key lies (until found)"}
    summoned: {scope: global, type: int, init: 0}
    p: {scope: global, type: ref, init: 0, note: "the hero choosing cards"}
    who: {scope: global, type: ref, init: null, note: "the figure whose turn it is"}
    order: {scope: global, type: list, init: []}
    halves: {scope: global, type: list, init: [], note: "the acting hero's two halves, in order"}
    acting: {scope: global, type: dict, init: null, note: "the half being carried out"}
    picked: {scope: player, type: list, init: null, note: "this round's two cards (leading first), or rest"}
    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: campaign
  label: Campaign
  kind: loop
  until: "all three scenarios won, or one failed twice"
  until_x: "stage >= len(SCENARIOS) or failed"
  children:
    - {id: play_scenario, label: Scenario, kind: subflow, ref: scenario}
    - {id: wrap_up, label: After the scenario, kind: step, do: wrap_up}
    - id: town
      label: Town
      kind: branch
      cases:
        - when: "a scenario was won and another follows"
          when_x: "results.get('scenario') == 'win' and stage < len(SCENARIOS)"
          children:
            - id: shopping
              label: Shopping
              kind: loop
              over_x: players
              as: p
              once: true
              children: [{id: shop, label: Buy an item, kind: step, actor_x: p, do: shop}]

flows:
  scenario:
    id: scenario
    label: Scenario
    kind: sequence
    children:
      - {id: setup, label: Set up the scenario, kind: step, do: setup}
      - id: rounds
        label: Rounds
        kind: loop
        note: "until a scenario end condition fires"
        children:
          - id: choose_cards
            label: Heroes choose cards
            kind: loop
            over_x: "[f['player'] for f in heroes()]"
            as: p
            once: true
            children:
              - {id: choose, label: Choose two cards or rest, kind: step, actor_x: p, do: choose_cards}
          - {id: monster_cards, label: Monsters turn over 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:
  setup:
    effect:
      # everything without persist starts fresh: the map, hands, decks, rounds
      - {op: reset, scope: scenario}
      - {op: let, bind: s, value_x: "SCENARIOS[stage]"}
      - {op: set, var: rubble, value_x: "list(s['obstacles'])"}
      - {op: set, var: mire, value_x: "dict(s['terrain'])"}
      - {op: set, var: coins, value_x: "list(s['coins'])"}
      - {op: set, var: traps, value_x: "list(s['traps'])"}
      - {op: set, var: exits, value_x: "list(s['exits'])"}
      - {op: set, var: key_at, value_x: "[s['key']] if s['key'] and not key_found else []"}
      - op: for_each
        items_x: players
        as: q
        body:
          - op: create
            to: figures
            what_x: >-
              {'id': HEROES[q], 'label': HERO_STATS[HEROES[q]]['label'], 'side': 'hero', 'player': q,
               'hp': HERO_STATS[HEROES[q]]['hp'] + xp[q] // XP_PER_HP, 'max': HERO_STATS[HEROES[q]]['hp'] + xp[q] // XP_PER_HP,
               'shield': HERO_STATS[HEROES[q]]['shield'], 'cell': s['start'][q]}
      - op: for_each
        items_x: "s['monsters']"
        as: m
        body:
          - op: create
            to: figures
            what_x: >-
              {'id': m['id'], 'label': MONSTERS[m['type']]['label'] + ('' if m['type'] == 'king' else ' ' + m['id'][-1]),
               'side': 'monster', 'type': m['type'],
               'hp': MONSTERS[m['type']]['hp'] - (KEY_WARD if m['type'] == 'king' and key_found else 0),
               'max': MONSTERS[m['type']]['hp'], 'shield': MONSTERS[m['type']]['shield'], 'cell': m['cell']}
      - {op: emit, event: scenario_started}

  choose_cards:
    effect:
      - {op: let, bind: me, value_x: "hero(actor)"}
      - op: if
        cond: "fewer than two cards in hand and fewer than two discarded: exhausted"
        cond_x: "me is not None and len(hand[actor]) < 2 and len(discard[actor]) < 2"
        then:
          - {op: move, what_x: me, from: figures, to: defeated}
          - {op: set, var: "picked[actor]", value: null}
          - {op: emit, event: exhausted}
        else:
          - op: choose
            options: "a leading card (its initiative counts), or rest"
            options_x: "(list(hand[actor]) + ([REST] if len(discard[actor]) >= 2 else [])) if len(hand[actor]) >= 2 else [REST]"
            bind: lead
            secret: true
          - op: if
            cond: rest
            cond_x: "lead == REST"
            then:
              # resting: lose one discarded card of your choice, take the others back, heal 2
              - {op: choose, options: "the card to lose", options_x: "list(discard[actor])", bind: gone}
              - {op: move, what_x: gone, from: "discard[actor]", to: "lost[actor]"}
              - {op: move, what_x: "list(discard[actor])", from: "discard[actor]", to: "hand[actor]"}
              - {op: set, of_x: me, attr: hp, value_x: "me['hp'] if me.get('poisoned') else min(me['max'], me['hp'] + 2)"}
              - {op: set, of_x: me, attr: poisoned, value: false}
              - {op: set, var: "picked[actor]", value: rest}
              - {op: emit, event: rested}
            else:
              - {op: choose, options: "the second card", options_x: "[c for c in hand[actor] if c != lead]", bind: second, secret: true}
              - {op: move, what_x: "[lead, second]", from: "hand[actor]", to: "played[actor]"}
              - {op: set, var: "picked[actor]", value_x: "[lead, second]"}

  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}
          - {op: create, what_x: mc, to: behaviour}

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

  hero_acts:
    effect:
      - {op: let, bind: me, value_x: "hero(actor)"}
      - {op: let, bind: pair, value_x: "picked[actor]"}
      - op: if
        cond: "stunned: the cards are spent without effect"
        cond_x: "me.get('stunned')"
        then:
          - {op: move, what_x: pair, from: "played[actor]", to: "discard[actor]"}
          - {op: set, of_x: me, attr: stunned, value: false}
          - {op: set, of_x: me, attr: immobilized, value: false}
          - {op: emit, event: stunned_turn}
        else:
          - {op: choose, options: "the card whose top half you use (the other card gives the bottom half)", options_x: pair, bind: upper}
          - {op: let, bind: lower, value_x: "pair[1] if upper == pair[0] else pair[0]"}
          - {op: choose, options: "the top half, or a basic attack", options_x: "[{**upper['origin']['top'], 'on': upper}, {**BASIC_TOP, 'on': upper}]", bind: top}
          - {op: choose, options: "the bottom half, or a basic move", options_x: "[{**lower['origin']['bottom'], 'on': lower}, {**BASIC_BOTTOM, 'on': lower}]", bind: bottom}
          # a half that moves goes first (unless both or neither move)
          - {op: set, var: halves, value: "the two halves in the order they are carried out", value_x: "[bottom, top] if bottom.get('move') and not top.get('move') else [top, bottom]"}
          - op: for_each
            items_x: halves
            as: h
            body:
              - {op: set, var: acting, value_x: h}
              # 1. move
              - op: if
                cond: "a move, unless immobilized"
                cond_x: "h.get('move') and me in figures and not me.get('immobilized')"
                then:
                  - op: choose
                    options: "where to (staying is allowed)"
                    options_x: "[me['cell']] + reachable('delve', me['cell'], h['move'] + bonus(actor, 'move'), blocked('hero'), mire, passable('hero', me['id']))"
                    bind: dest
                  - {op: set, of_x: me, attr: cell, value_x: dest}
                  - op: if
                    cond: "ended the move on a trap"
                    cond_x: "dest in traps"
                    then:
                      - {op: set, of_x: me, attr: hp, value_x: "max(0, me['hp'] - TRAP_DAMAGE)"}
                      - {op: set, var: traps, value_x: "[c for c in traps if c != dest]"}
                      - {op: emit, event: trap_sprung}
                      - op: if
                        cond_x: "me['hp'] <= 0"
                        then: [{op: move, what_x: me, from: figures, to: defeated}, {op: emit, event: hero_fallen}]
              # 2. attack
              - op: if
                cond_x: "h.get('attack') and me in figures"
                then:
                  - {op: let, bind: choices, value_x: "attack_options(me, h)"}
                  - op: if
                    cond: "someone to hit"
                    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: let, bind: mod, value_x: "hero_mod_discard[-1]"}
                          - {op: if, cond_x: "mod in ('x2', 'miss')", then: [{op: set, var: reshuffle_hero, value: true}]}
                          - {op: let, bind: base, value_x: "h['attack'] + (bonus(actor, 'ranged') if h.get('range', h.get('all_in_range', 1)) > 1 else 0)"}
                          - {op: set, of_x: target, attr: hp, value_x: "max(0, target['hp'] - damage(base, mod, shield_of(target), target.get('poisoned')))"}
                          - {op: emit, event: monster_hit}
                          - op: if
                            cond: "not a miss: conditions take hold"
                            cond_x: "mod != 'miss'"
                            then:
                              - {op: if, cond_x: "h.get('poison')", then: [{op: set, of_x: target, attr: poisoned, value: true}]}
                              - {op: if, cond_x: "h.get('stun')", then: [{op: set, of_x: target, attr: stunned, value: true}]}
                              - {op: if, cond_x: "h.get('immobilize')", then: [{op: set, of_x: target, attr: immobilized, value: true}]}
                          - op: if
                            cond: "defeated: it drops a coin"
                            cond_x: "target['hp'] <= 0"
                            then:
                              - {op: add, var: coins, amount_x: "[target['cell']]"}
                              - {op: move, what_x: target, from: figures, to: defeated}
                              - {op: emit, event: monster_defeated}
              # 3. heal
              - op: if
                cond_x: "h.get('heal') and me in figures"
                then:
                  - op: choose
                    options: "whom you heal"
                    options_x: "[me] if h.get('self') else [x for x in heroes() if x == me or in_range(me['cell'], x['cell'], h.get('range', 1))]"
                    bind: patient
                  - {op: set, of_x: patient, attr: hp, value_x: "patient['hp'] if patient.get('poisoned') else min(patient['max'], patient['hp'] + h['heal'])"}
                  - {op: set, of_x: patient, attr: poisoned, value: false}
                  - {op: emit, event: healed}
              # 4. shield until the end of the round
              - op: if
                cond_x: "h.get('shield') and me in figures"
                then: [{op: set, of_x: me, attr: guard, value_x: "me.get('guard', 0) + h['shield']"}]
              # 5. loot: coins (and the key) within that distance
              - op: if
                cond_x: "h.get('loot') and me in figures"
                then:
                  - {op: add, var: gold, amount_x: "len([c for c in coins if distance('delve', me['cell'], c) <= h['loot']])"}
                  - {op: set, var: coins, value_x: "[c for c in coins if distance('delve', me['cell'], c) > h['loot']]"}
                  - op: if
                    cond_x: "key_at and distance('delve', me['cell'], key_at[0]) <= h['loot']"
                    then: [{op: set, var: key_found, value: true}, {op: set, var: key_at, value: []}, {op: emit, event: key_found}]
              # 6. experience
              - {op: if, cond_x: "h.get('xp') and me in figures", then: [{op: add, var: "xp[actor]", amount_x: "h['xp']"}]}
          # spent cards: lost when a lost half was used, otherwise discarded
          - op: if
            cond_x: "top.get('lost')"
            then: [{op: move, what_x: upper, from: "played[actor]", to: "lost[actor]"}]
            else: [{op: move, what_x: upper, from: "played[actor]", to: "discard[actor]"}]
          - op: if
            cond_x: "bottom.get('lost')"
            then: [{op: move, what_x: lower, from: "played[actor]", to: "lost[actor]"}]
            else: [{op: move, what_x: lower, from: "played[actor]", to: "discard[actor]"}]
          # end of the turn: pick up what lies on your hex
          - op: if
            cond_x: "me in figures"
            then:
              - {op: add, var: gold, amount_x: "coins.count(me['cell'])"}
              - {op: set, var: coins, value_x: "[c for c in coins if c != me['cell']]"}
              - op: if
                cond_x: "me['cell'] in key_at"
                then: [{op: set, var: key_found, value: true}, {op: set, var: key_at, value: []}, {op: emit, event: key_found}]
              - {op: set, of_x: me, attr: immobilized, value: false}

  monster_acts:
    effect:
      - {op: let, bind: m, value_x: "fig(who)"}
      - {op: let, bind: mc, value_x: "monster_card[m['type']]"}
      - {op: let, bind: stats, value_x: "MONSTERS[m['type']]"}
      - op: if
        cond: stunned
        cond_x: "m.get('stunned')"
        then:
          - {op: set, of_x: m, attr: stunned, value: false}
          - {op: set, of_x: m, attr: immobilized, value: false}
          - {op: emit, event: stunned_turn}
        else:
          - {op: let, bind: movement, value_x: "0 if m.get('immobilized') else max(0, stats['move'] + mc['move'])"}
          - {op: let, bind: attack, value_x: "max(0, stats['attack'] + mc['attack'])"}
          - {op: let, bind: reach, value_x: "stats['range']"}
          - {op: if, cond_x: "mc.get('guard')", then: [{op: set, of_x: m, attr: guard, value_x: "mc['guard']"}]}
          - op: if
            cond_x: "mc.get('heal')"
            then: [{op: set, of_x: m, attr: hp, value_x: "m['hp'] if m.get('poisoned') else min(m['max'], m['hp'] + mc['heal'])"}, {op: set, of_x: m, attr: poisoned, value: false}]
          - op: if
            cond: "summons an imp on a free hex next to it"
            cond_x: "mc.get('summon') and [c for c in neighbours('delve', m['cell']) if free(c, '')]"
            then:
              - {op: add, var: summoned, amount: 1}
              - op: create
                to: figures
                what_x: >-
                  {'id': 'brood' + str(summoned), 'label': 'Brood imp ' + str(summoned), 'side': 'monster', 'type': 'imp',
                   'hp': MONSTERS['imp']['hp'], 'max': MONSTERS['imp']['hp'], 'shield': 0,
                   'cell': [c for c in neighbours('delve', m['cell']) if free(c, '')][0]}
              - {op: emit, event: imp_summoned}
          - op: if
            cond: "a wave: every hero within range is attacked"
            cond_x: "mc.get('wave')"
            then:
              - op: for_each
                items_x: "[h for h in heroes() if in_range(m['cell'], h['cell'], mc['wave'])]"
                as: target
                body:
                  - 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], shield_of(target), target.get('poisoned')))"}
                  - {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}]
            else:
              - {op: let, bind: h, value_x: "focus(m, reach)"}
              - op: if
                cond: "a hero to go for"
                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: let, bind: mod, value_x: "monster_mod_discard[-1]"}
                      - {op: if, cond_x: "mod 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, mod, shield_of(target), target.get('poisoned')))"}
                      - {op: emit, event: hero_hit}
                      - op: if
                        cond_x: "mod != 'miss'"
                        then:
                          - {op: if, cond_x: "mc.get('poison')", then: [{op: set, of_x: target, attr: poisoned, value: true}]}
                          - {op: if, cond_x: "mc.get('stun')", then: [{op: set, of_x: target, attr: stunned, value: true}]}
                          - {op: if, cond_x: "mc.get('immobilize')", then: [{op: set, of_x: target, attr: immobilized, value: true}]}
                      - op: if
                        cond_x: "target['hp'] <= 0"
                        then: [{op: move, what_x: target, from: figures, to: defeated}, {op: emit, event: hero_fallen}]
          - {op: set, of_x: m, attr: immobilized, value: false}

  finish_round:
    effect:
      - {op: set, var: monster_card, value: {}}
      - op: for_each
        items_x: "list(behaviour)"
        as: b
        body: [{op: destroy, what_x: b, from: behaviour}]
      - op: for_each
        items_x: "list(figures)"
        as: f
        body: [{op: set, of_x: f, attr: guard, value: 0}]
      - 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}

  wrap_up:
    effect:
      - op: if
        cond: "scenario won: on to the next"
        cond_x: "results.get('scenario') == 'win'"
        then:
          - {op: add, var: won, amount: 1}
          - {op: add, var: stage, amount: 1}
          - {op: set, var: tries, value: 0}
          - {op: emit, event: scenario_won}
        else:
          - {op: add, var: tries, amount: 1}
          - {op: emit, event: scenario_lost}
          - {op: if, cond: "lost twice", cond_x: "tries >= 2", then: [{op: set, var: failed, value: true}]}

  shop:
    effect:
      - op: choose
        options: "an item you can afford, or nothing"
        options_x: "[i for i in ITEMS if i['id'] not in items[actor] and i['cost'] <= gold] + ['nothing']"
        bind: buy
      - op: if
        cond_x: "buy != 'nothing'"
        then:
          - {op: add, var: gold, amount_x: "-buy['cost']"}
          - {op: add, var: "items[actor]", amount_x: "[buy['id']]"}
          - {op: emit, event: item_bought}

end:
  - {id: cleared, scope: scenario, if: "no monsters left (slay goal)", if_x: "SCENARIOS[stage]['goal'] == 'slay' and not monsters()", result: win}
  - {id: escaped, scope: scenario, if: "every hero still standing is on an exit (escape goal)", if_x: "SCENARIOS[stage]['goal'] == 'escape' and len(heroes()) > 0 and all(h['cell'] in exits for h in heroes())", result: win}
  - {id: king_fallen, scope: scenario, if: "the Ash King is defeated (boss goal)", if_x: "SCENARIOS[stage]['goal'] == 'boss' and fig('ash_king') is None", result: win}
  - {id: fallen, scope: scenario, if: "no hero left standing", if_x: "len(heroes()) == 0", result: loss}
  - {id: time_up, scope: scenario, if: "after the scenario's last round", if_x: "round_no > SCENARIOS[stage]['rounds']", result: loss}
  - {id: campaign_won, if: "all three scenarios won", if_x: "stage >= len(SCENARIOS)", result: win}
  - {id: campaign_lost, if: "a scenario lost twice", if_x: "failed", result: loss}

# Shown while playing, the first time the step comes up (learn as you play)
tutorial:
  - {at: choose_cards, text: "Each round you pick two cards. The first one you pick sets your initiative: a low number acts early, a high number acts late. With two or more discarded cards you may rest instead: take them back, lose one of them for good and heal 2. Every monster type then turns over a behaviour card: how far they move and how hard they hit this round."}
  - {at: hero_acts, text: "On your turn you use the top half of one card and the bottom half of the other; the move half goes first, so you can walk up and strike. A basic attack (2) or basic move (2) can replace either half. Monsters walk to the nearest hero they can reach and attack; slingers hit from three hexes away. A stunned hero skips the turn (the two cards are still discarded)."}
  - {at: shop, text: "Gold you looted buys an item that lasts the whole campaign: boots for moving, oil for ranged attacks, a coat for shielding. Experience raises your hit points for the next scenario. A lost scenario may be tried once more; losing it twice ends the campaign."}

# The story: passages told while playing, and read aloud if the player wants (the sound menu). Each
# is told at a moment (start, a step, an event, end; won or lost in a cooperative game), once per
# game unless it has repeat, and only when its when_x holds at that moment. The view's music follows
# moments and passage ids (views/ashmoor-delve.yaml, music).
story:
  - id: prologue
    at: start
    title: Ashmoor
    text: >
      For a week Ashmoor burned, and then the fires went out by themselves. That was a month ago.
      Since then the cellars breathe smoke at night, the wells taste of cinders, and the ash in the
      streets is warm to the touch. The Warden takes up the iron shield; the Lanternwright fills the
      lamp with the last good oil in town. Together they go down the broken stair.
  - id: again
    at: scenario_started
    when_x: "tries > 0"
    repeat: true
    title: Once more
    text: >
      You were driven back once. This time you know where the floor gives way and where the
      slingers hide. There will be no third time.
  - id: stair
    at: scenario_started
    when_x: "SCENARIOS[stage]['id'] == 'stair'"
    repeat: true
    title: The Collapsed Stair
    text: >
      Halfway down, the stair gives way to rubble and grey drifts. Something heavy shuffles below,
      breathing like a forge, and a sling hums in the dark. Nothing will let you pass while it
      still stands. Clear the hall.
  - id: archive
    at: scenario_started
    when_x: "SCENARIOS[stage]['id'] == 'archive'"
    repeat: true
    title: The Drowned Archive
    text: >
      Water found the town archive long before the fire did. The shelves stand knee-deep in grey
      mire, and three hundred years of records have gone to paste. Somewhere here lies the iron key
      to the king's ward. Find it if you can, and make for the far doors before the dark closes in.
  - id: throne
    at: scenario_started
    when_x: "SCENARIOS[stage]['id'] == 'throne'"
    repeat: true
    title: Throne of Cinders
    text: >
      At the bottom of everything sits the Ash King: a crown of embers on a head of smoke. When the
      king breathes, the walls glow, and imps crawl out of the cracks at a word. End it here.
  - id: ward
    at: scenario_started
    when_x: "SCENARIOS[stage]['id'] == 'throne' and key_found"
    text: >
      The archive key grows warm in your hand as you near the throne. The ward around the king
      flickers, and is weaker for it.
  - {id: key, at: key_found, text: "Under a fallen shelf, wrapped in oilcloth: the archive key. Cold iron, and heavier than it looks."}
  - {id: brood, at: imp_summoned, text: "The Ash King laughs, and the ash on the floor stands up and grins."}
  - {id: alone, at: hero_fallen, when_x: "len(heroes()) > 0", text: "A hero falls in the ash. The other fights on alone."}
  - id: back_up
    at: scenario_won
    when_x: "stage < len(SCENARIOS)"
    repeat: true
    title: Back to the light
    text: >
      The last of them goes still. You count the coins, bind your wounds and climb back up to the
      town, where the smith and the oil seller are waiting.
  - id: driven_back
    at: scenario_lost
    when_x: "tries < 2"
    repeat: true
    text: >
      You stumble back up the stair with the ash at your heels. Catch your breath: the way down is
      still open.
  - id: victory
    at: won
    title: Ashmoor wakes
    text: >
      The crown of embers rolls across the floor and goes dark. Up in the town the wells run clear
      for the first time in a month, and the morning smells of rain. Ashmoor will be rebuilt, and
      its songs will have a shield and a lamp in them.
  - id: defeat
    at: lost
    title: The ash settles
    text: >
      The lamp gutters out. Above, the people of Ashmoor pack what they can carry and take the road
      north. Behind them the town goes quiet under the ash, and below it something breathes.
