"use strict"

/*

Special scenario rules implemented:
	zero morale - instant loss

Special card rules implemented:

	place_2_blue
	place_2_red
	place_2_blue_if_dice
	place_2_red_if_dice
	remove_after_screen
	suffer_1_less_1_max
	suffer_1_less
	start_with_no_cubes
	take_from
	rout_with
	remove_with
	wild
	attack_reserve
	attack_choose_target
	take_from
	may_take_from

*/

// TODO: morale cube limit (cannot place on special if maxed)
// TODO: null action when action says to take cards from other dice?

const data = require("./data.js")

function clamp(x, min, max) {
	return Math.max(min, Math.min(max, x))
}

function find_scenario(n) {
	let ix = data.scenarios.findIndex(s => s.number === n)
	if (ix < 0)
		throw new Error("cannot find scenario " + n)
	return ix
}

function find_card(s, n) {
	let ix = data.cards.findIndex(c => c.scenario === s && c.name === n)
	if (ix < 0)
		throw new Error("cannot find card " + n)
	return ix
}

// for (let c of data.cards) for (let a of c.actions) console.log(a.type, a.effect)
// for (let c of data.cards) console.log(c.dice)

const P1 = "First"
const P2 = "Second"

var states = {}
var game = null
var view = null

const POOL = -1

const RED = 0
const PINK = 1
const BLUE = 2
const DKBLUE = 3

exports.scenarios = {
	"": [ "Random" ],
}

const scenario_roles = {}

for (let s of data.scenarios) {
	let id = s.number + " - " + s.name
	let x = s.expansion
	if (!(x in exports.scenarios)) {
		exports.scenarios[""].push("Random - " + x)
		exports.scenarios[x] = []
	}
	exports.scenarios[x].push(id)
	scenario_roles[id] = [ s.players[0].name, s.players[1].name ]
}

exports.is_random_scenario = function (scenario) {
	return scenario.startsWith("Random")
}

exports.select_random_scenario = function (scenario, seed) {
	if (scenario === "Random") {
		let info = data.scenarios[seed % data.scenarios.length]
		return info.number + " - " + info.name
	}
	if (scenario.startsWith("Random - ")) {
		let list = exports.scenarios[scenario.replace("Random - ", "")]
		return list[seed % list.length]
	}
	return scenario
}

exports.roles = [ P1, P2 ]

exports.action = function (state, player, action, arg) {
	game = state
	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
}

exports.view = function (state, player) {
	game = state

	view = {
		log: game.log,
		prompt: null,
		scenario: game.scenario,
		dice: game.dice,
		sticks: game.sticks,
		cubes: game.cubes,
		morale: game.morale,
		lost: game.lost,
		front: game.front,
		reserve: game.reserve,
		selected: game.selected,
		target: game.target,
		hits: game.hits,
		self: game.self,
	}

	if (game.target2 >= 0 && game.hits2 >= 0) {
		view.target2 = game.target2
		view.hits2 = game.hits2
	}

	if (game.state === "game_over") {
		view.prompt = game.victory
	} else if (player !== game.active) {
		let inactive = states[game.state].inactive || game.state
		view.prompt = `Waiting for ${player_name(player_index())} to ${inactive}.`
	} else {
		view.actions = {}
		states[game.state].prompt()
		if (game.undo && game.undo.length > 0)
			view.actions.undo = 1
		else
			view.actions.undo = 0
	}

	return view
}

exports.resign = function (state, player) {
	game = state
	if (game.state !== 'game_over') {
		if (player === P1)
			return goto_game_over(P2, P1 + " resigned.")
		if (player === P2)
			return goto_game_over(P1, P2 + " resigned.")
	}
}

function goto_game_over(result, victory) {
	game.state = "game_over"
	game.active = "None"
	game.result = result
	game.victory = result + " victory! " + victory
	log("")
	log(result + " victory!")
	log(victory)
	return true
}

states.game_over = {
	prompt() {
		view.prompt = game.victory
	},
}

// === SPECIAL RULES - CARD NUMBERS ===

const S2_MARSTON_MOOR = find_scenario(2)
const S2_RUPERTS_LIFEGUARD = find_card(2, "Rupert's Lifeguard")
const S2_NORTHERN_HORSE = find_card(2, "Northern Horse")
const S2_TILLIERS_LEFT = find_card(2, "Tillier's Left")
const S2_TILLIERS_RIGHT = find_card(2, "Tillier's Right")
const S2_BYRON = find_card(2, "Byron")

const S4_BOSWORTH_FIELD = find_scenario(4)
const S4_THE_STANLEYS = find_card(4, "The Stanleys")
const S4_NORTHUMBERLAND = find_card(4, "Northumberland")

const S7_THE_DUNES = find_scenario(7)
const S7_THE_ENGLISH_FLEET = find_card(7, "The English Fleet")
const S7_DON_JUAN_JOSE = find_card(7, "Don Juan Jose")
const S7_SPANISH_RIGHT_CAVALRY = find_card(7, "Spanish Right Cavalry")

const S8_BROOKLYN_HEIGHTS = find_scenario(8)
const S8_CLINTON = find_card(8, "Clinton")
const S8_GRANT = find_card(8, "Grant")
const S8_HESSIANS = find_card(8, "Hessians")

const S37_INKERMAN = find_scenario(37)
const S37_PAULOFFS_LEFT = find_card(37, "Pauloff's Left")
const S37_PAULOFFS_RIGHT = find_card(37, "Pauloff's Right")
const S37_BRITISH_TROOPS = find_card(37, "British Troops")
const S37_FRENCH_TROOPS = find_card(37, "French Troops")
const S37_SOIMONOFF = find_card(37, "Soimonoff")
const S37_THE_FOG = find_card(37, "The Fog")

const S3201_GAINES_MILL = find_scenario(3201)
const S3201_JACKSON = find_card(3201, "Jackson")
const S3201_DH_HILL = find_card(3201, "D.H. Hill")
const S3201_AP_HILL = find_card(3201, "A.P. Hill")
const S3201_LONGSTREET = find_card(3201, "Longstreet")

const S9_ST_ALBANS = find_scenario(9)
const S9_HENRY_VI = find_card(9, "Henry VI")
const S9_SHROPSHIRE_LANE = find_card(9, "Shropshire Lane")
const S9_SOPWELL_LANE = find_card(9, "Sopwell Lane")
const S9_ARCHERS = find_card(9, "Archers")
const S9_WARWICK = find_card(9, "Warwick")

const S11_MORTIMERS_CROSS = find_scenario(11)
const S12_TOWTON = find_scenario(12)
const S13_EDGECOTE_MOOR = find_scenario(13)

const S15_TEWKESBURY = find_scenario(15)
const S15_A_PLUMP_OF_SPEARS = find_card(15, "A Plump of Spears")
const S15_SOMERSET = find_card(15, "Somerset")
const S15_WENLOCK = find_card(15, "Wenlock")

const S16_STOKE_FIELD = find_scenario(16)

const S22_GABIENE = find_scenario(22)
const S22_EUMENES_CAMP = find_card(22, "Eumenes's Camp")
const S22_SILVER_SHIELDS = find_card(22, "The Silver Shields")

const S25_WHEATFIELD = find_scenario(25)
const S25_STONY_HILL = find_card(25, "Stony Hill")
const S25_WOFFORD = find_card(25, "Wofford")
const S25_ZOOK = find_card(25, "Zook")
const S25_KELLY = find_card(25, "Kelly")

const S26_PEACH_ORCHARD = find_scenario(26)
const S26_FATAL_BLUNDER = find_card(26, "Fatal Blunder")

const S28_CULPS_HILL = find_scenario(28)
const S28_BREASTWORKS = find_card(28, "Breastworks")
const S28_GEARY = find_card(28, "Geary")

const S29_GETTYS_2ND = find_scenario(29)
const S29_MCLAWS = find_card(29, "McLaws")
const S29_ANDERSON = find_card(29, "Anderson")
const S29_HOOD = find_card(29, "Hood")
const S29_EARLY = find_card(29, "Early")
const S29_JOHNSON = find_card(29, "Johnson")
const S29_MEADE = find_card(29, "Meade")
const S29_LITTLE_ROUND_TOP = find_card(29, "Little Round Top")

const S30_EDGEHILL = find_scenario(30)
const S30_BALFOUR = find_card(30, "Balfour")
const S30_STAPLETON = find_card(30, "Stapleton")
const S30_RUPERT = find_card(30, "Rupert of the Rhine")
const S30_WILMOT = find_card(30, "Wilmot")
const S30_ESSEX = find_card(30, "Charles Essex")
const S30_GERARD = find_card(30, "Gerard")

const S31_NEWBURY_1ST = find_scenario(31)
const S31_BYRON = find_card(31, "Byron")
const S31_SKIPPON = find_card(31, "Skippon")
const S31_WENTWORTH = find_card(31, "Wentworth")
const S31_ROYALIST_GUNS = find_card(31, "Royalist Guns")
const S31_GERARD = find_card(31, "Gerard")
const S31_STAPLETON = find_card(31, "Stapleton")
const S31_LONDON_TRAINED_BANDS = find_card(31, "London Trained Bands")

const S35_AULDEARN = find_scenario(35)
const S35_MONTROSE = find_card(35, "Montrose")
const S35_GORDON = find_card(35, "Gordon")

const S39_MARSAGLIA = find_scenario(39)
const S39_CANNONS = find_card(39, "Cannons")
const S39_EUGENE = find_card(39, "Eugene")
const S39_DUKE_OF_SAVOY = find_card(39, "Duke of Savoy")
const S39_BAYONETS = find_card(39, "Bayonets!")
const S39_CATINAT = find_card(39, "Catinat")
const S39_HOGUETTE = find_card(39, "Hoguette")

const S40_CHIARI = find_scenario(40)
const S40_CASSINES_I = find_card(40, "Cassines I")
const S40_NIGRELLI = find_card(40, "Nigrelli")
const S40_KRIECHBAUM = find_card(40, "Kriechbaum")
const S40_CASSINES_II = find_card(40, "Cassines II")
const S40_MANNSFELDT = find_card(40, "Mannsfeldt")
const S40_GUTTENSTEIN = find_card(40, "Guttenstein")

// === SETUP ===

exports.setup = function (seed, scenario, options) {
	// TODO: "Random"

	scenario = parseInt(scenario)
	scenario = data.scenarios.findIndex(s => s.number === scenario)
	if (scenario < 0)
		throw Error("cannot find scenario: " + scenario)

	let info = data.scenarios[scenario]

	game = {
		seed: seed,
		scenario: scenario,
		log: [],
		undo: [],

		active: P1,
		state: "roll",

		reacted: -1,

		// dice value and position
		dice: [
			0, POOL, 0, POOL, 0, POOL, 0, POOL, 0, POOL, 0, POOL,
			0, POOL, 0, POOL, 0, POOL, 0, POOL, 0, POOL, 0, POOL,
		],

		// sticks (map normal formation -> count)
		sticks: [],

		// cubes (map special formation -> count)
		cubes: [],

		morale: [ info.players[0].morale, info.players[1].morale ],
		lost: [ 0, 0 ],
		front: [ [], [], ],
		reserve: [ [], [] ],

		// dice value placed on what card
		rolled: 0,
		placed: [],

		// current action
		routed: [ 0, 0 ],
		selected: -1,
		target: -1,
		hits: 0,
		self: 0,

		// for breastworks etc
		self2: 0,
		target2: -1,
		hits2: 0,
	}

	function setup_formation(front, reserve, c) {
		let card = data.cards[c]
		if (card.reserve)
			set_add(reserve, c)
		else
			set_add(front, c)
		if (card.special) {
			if (card_has_rule(c, "start_with_no_cubes"))
				add_cubes(c, 0)
			else
				add_cubes(c, 1)
		} else {
			set_sticks(c, card.strength)
		}
	}

	for (let p = 0; p < 2; ++p) {
		for (let c of info.players[p].cards)
			setup_formation(game.front[p], game.reserve[p], c)
	}

	log(".h1 " + info.name)
	log(".h2 " + info.date)
	log("")
	if (info.rule_text) {
		log(info.rule_text)
		log("")
	}

	if (info.players[0].tactical > 0 || info.players[1].tactical > 0) {
		log("Tactical Victory:")
		if (info.players[0].tactical > 0)
			log(">" + player_name(0) + ": " + info.players[0].tactical)
		if (info.players[1].tactical > 0)
			log(">" + player_name(1) + ": " + info.players[1].tactical)
		log("")
	}

	if (game.scenario === S37_INKERMAN) {
		map_set(game.cubes, S37_THE_FOG, 3)
	}

	goto_start_turn()

	return game
}

// === GAME STATE ACCESSORS ===

function count_total_cubes() {
	let n = game.morale[0] + game.morale[1]
	for (let i = 1; i < game.cubes.length; i += 2)
		n += game.cubes[i]
	return n
}

function card_has_rule(c, name) {
	let rules = data.cards[c].rules
	if (rules)
		return rules[name]
	return false
}

function card_number(c) {
	return data.cards[c].number
}

function card_name(c) {
	return data.cards[c].name
}

function player_name() {
	let p = player_index()
	return data.scenarios[game.scenario].players[p].name
}

function set_opponent_active() {
	if (game.active === P1)
		game.active = P2
	else
		game.active = P1
}

function player_index() {
	if (game.active === P1)
		return 0
	return 1
}

function get_cubes(c) {
	return map_get(game.cubes, c, 0)
}

function add_cubes(c, n) {
	let limit = data.cards[c].special
	let old = get_cubes(c)
	map_set(game.cubes, c, Math.min(limit, old + n))
}

function remove_cubes(c, n) {
	let old = get_cubes(c)
	map_set(game.cubes, c, Math.max(0, old - n))
}

function get_sticks(c) {
	return map_get(game.sticks, c, 0)
}

function set_sticks(c, n) {
	map_set(game.sticks, c, n)
}

function remove_sticks(c, n) {
	let p = find_card_owner(c)
	let old = get_sticks(c)
	n = Math.min(n, old)
	game.lost[p] += n
	set_sticks(c, old - n)
}

function remove_dice(c) {
	for (let i = 0; i < 12; ++i) {
		if (get_dice_location(i) === c) {
			set_dice_location(i, POOL)
			set_dice_value(i, 0)
		}
	}
}

function take_all_dice(from, to) {
	log("Take dice from " + from + " to " + to + ".")
	for (let i = 0; i < 12; ++i) {
		if (get_dice_location(i) === from) {
			set_dice_location(i, to)
		}
	}
}

function take_one_die(from, to) {
	log("One die from " + from + " to " + to + ".")
	for (let i = 0; i < 12; ++i) {
		if (get_dice_location(i) === from) {
			set_dice_location(i, to)
			if (to === POOL)
				set_dice_value(i, 0)
			to = POOL
		}
	}
}

function take_wild_die(from, to) {
	log("Wild die from " + from + " to " + to + ".")
	for (let i = 0; i < 12; ++i) {
		if (get_dice_location(i) === from) {
			set_dice_location(i, to)
			set_dice_value(i, 0)
			to = POOL
		}
	}
}

function eliminate_card(c) {
	remove_dice(c)
	remove_cubes(c, 3)
	set_delete(game.front[0], c)
	set_delete(game.front[1], c)
	set_delete(game.reserve[0], c)
	set_delete(game.reserve[1], c)
}

function rout_card(c) {
	let p = find_card_owner(c)
	game.lost[p] += get_sticks(c)
	log(c + " routed.")
	eliminate_card(c)
}

function pursue_card(c) {
	log(c + " pursued.")
	eliminate_card(c)
}

function retire_card(c) {
	log(c + " retired.")
	eliminate_card(c)
}

function remove_card(c) {
	log(c + " removed.")
	eliminate_card(c)
}

function pay_for_action(c) {
	if (data.cards[c].special)
		remove_cubes(c, 1)
	else
		remove_dice(c)
}

function get_player_dice_value(p, i) {
	return game.dice[p * 12 + i * 2]
}

function get_player_dice_location(p, i) {
	return game.dice[p * 12 + i * 2 + 1]
}

function set_player_dice_value(p, i, v) {
	game.dice[p * 12 + i * 2] = v
}

function set_player_dice_location(p, i, v) {
	game.dice[p * 12 + i * 2 + 1] = v
}

function get_dice_value(d) {
	return game.dice[d * 2]
}

function get_dice_location(i) {
	return game.dice[i * 2 + 1]
}

function set_dice_location(d, v) {
	game.dice[d * 2 + 1] = v
}

function set_dice_value(d, v) {
	game.dice[d * 2] = v
}

// === HOW TO WIN ===

function is_card_in_play(c) {
	return (
		set_has(game.front[0], c) ||
		set_has(game.front[1], c)
	)
}

function is_card_in_reserve(c) {
	return (
		set_has(game.reserve[0], c) ||
		set_has(game.reserve[1], c)
	)
}

function is_card_in_play_or_reserve(c) {
	return (
		set_has(game.front[0], c) ||
		set_has(game.front[1], c) ||
		set_has(game.reserve[0], c) ||
		set_has(game.reserve[1], c)
	)
}

function is_removed_from_play(c) {
	return !is_card_in_play_or_reserve(c)
}

function card_has_active_link(c) {
	let link = data.cards[c].link
	if (link) {
		for (let t of link)
			if (is_card_in_play(t))
				return true
	}
	return false
}

function card_has_attack_with_valid_target(c) {
	for (let a of data.cards[c].actions) {
		if (a.type === "Attack") {
			let attack_reserve = card_has_rule(c, "attack_reserve")
			for (let t of a.target_list) {
				if (is_card_in_play(t))
					return true
				if (attack_reserve && is_card_in_reserve(t))
					return true
			}
		}
	}
	return false
}

function is_impossible_to_attack() {
	let p = player_index()
	for (let c of game.front[p])
		if (card_has_attack_with_valid_target(c))
			return false
	return true
}

function check_impossible_to_attack_victory() {
	if (is_impossible_to_attack()) {
		if (player_index() === 0)
			return goto_game_over(P2, P1 + " has no more attacks!")
		else
			return goto_game_over(P1, P2 + " has no more attacks!")
	}
	return false
}

function check_victory() {
	let info = data.scenarios[game.scenario]

	// Scenario specific victory conditions.
	if (game.scenario === S39_MARSAGLIA) {
		if (is_removed_from_play(S39_HOGUETTE) && is_removed_from_play(S39_CATINAT))
			goto_game_over(P1, P2 + " lost both linked formations.")
		if (is_removed_from_play(S39_DUKE_OF_SAVOY) && is_removed_from_play(S39_EUGENE))
			goto_game_over(P2, P1 + " lost both linked formations.")
	}

	if (game.morale[0] === 0)
		return goto_game_over(P2, P1 + " has run out of morale!")
	if (game.morale[1] === 0)
		return goto_game_over(P1, P2 + " has run out of morale!")

	let tv0 = info.players[1].lost
	let tv1 = info.players[0].lost

	if (info.players[0].tactical > 0 && tv0 >= info.players[0].tactical)
		return goto_game_over(P2, P2 + " tactical victory!")

	if (info.players[1].tactical > 0 && tv1 >= info.players[1].tactical)
		return goto_game_over(P1, P1 + " tactical victory!")

	return false
}

// === ROLL PHASE ===

function is_pool_die(i, v) {
	let p = player_index()
	return get_player_dice_location(p, i) < 0 && get_player_dice_value(p, i) === v
}

function is_pool_die_range(i, lo, hi) {
	let p = player_index()
	if (get_player_dice_location(p, i) < 0) {
		let v = get_player_dice_value(p, i)
		return v >= lo && v <= hi
	}
	return false
}

function placed_any_dice_on_wing(w) {
	for (let i = 0; i < game.placed.length; i += 2) {
		let c = game.placed[i]
		if (data.cards[c].wing === w)
			return true
	}
	return false
}

function is_straight_4_or_3(c) {
	if (game.scenario === S28_CULPS_HILL) {
		if (game.rolled >= 5)
			return 4
		else
			return 3
	}
	if (game.scenario === S31_NEWBURY_1ST) {
		if (is_card_in_play(S31_SKIPPON))
			return 4
		else
			return 3
	}
	throw new Error("Missing rule for Straight 3/4 choice")
}

const place_dice_once = {
	"(1)": true,
	"(2)": true,
	"(3)": true,
	"(4)": true,
	"(5)": true,
	"(6)": true,
	"(1)/(2)": true,
	"(1-3)": true,
	"(1-4)": true,
	"(1-5)": true,
	"(2)/(3)": true,
	"(2-4)": true,
	"(2-5)": true,
	"(2-6)": true,
	"(3)/(4)": true,
	"(3-5)": true,
	"(3-6)": true,
	"(4)/(5)": true,
	"(4-6)": true,
	"(5)/(6)": true,
}

const place_dice_check = {
	"Full House": check_full_house,
	"Straight 4/3": check_straight_4_or_3,
	"Straight 3": check_straight_3,
	"Straight 4": check_straight_4,
	"Doubles": check_doubles,
	"Triples": check_triples,
	"1": (c) => check_single(c, 1),
	"2": (c) => check_single(c, 2),
	"3": (c) => check_single(c, 3),
	"4": (c) => check_single(c, 4),
	"5": (c) => check_single(c, 5),
	"6": (c) => check_single(c, 6),
	"(1)": (c) => check_single(c, 1),
	"(2)": (c) => check_single(c, 2),
	"(3)": (c) => check_single(c, 3),
	"(4)": (c) => check_single(c, 4),
	"(5)": (c) => check_single(c, 5),
	"(6)": (c) => check_single(c, 6),
	"Any": (c) => check_range(c, 1, 6),
	"1/2": (c) => check_range(c, 1, 2),
	"1-3": (c) => check_range(c, 1, 3),
	"1/2/3": (c) => check_range(c, 1, 3),
	"1-4": (c) => check_range(c, 1, 4),
	"1-5": (c) => check_range(c, 1, 5),
	"2/3": (c) => check_range(c, 2, 2),
	"2-4": (c) => check_range(c, 2, 4),
	"2/3/4": (c) => check_range(c, 2, 4),
	"2-5": (c) => check_range(c, 2, 5),
	"2-6": (c) => check_range(c, 2, 6),
	"3/4": (c) => check_range(c, 3, 4),
	"3-5": (c) => check_range(c, 3, 5),
	"3-6": (c) => check_range(c, 3, 6),
	"4/5": (c) => check_range(c, 4, 5),
	"4-6": (c) => check_range(c, 4, 6),
	"4/5/6": (c) => check_range(c, 4, 6),
	"5/6": (c) => check_range(c, 5, 6),
	"(1)/(2)": (c) => check_range(c, 1, 2),
	"(1-3)": (c) => check_range(c, 1, 3),
	"(1-4)": (c) => check_range(c, 1, 4),
	"(1-5)": (c) => check_range(c, 1, 5),
	"(2)/(3)": (c) => check_range(c, 2, 2),
	"(2-4)": (c) => check_range(c, 2, 4),
	"(2-5)": (c) => check_range(c, 2, 5),
	"(2-6)": (c) => check_range(c, 2, 6),
	"(3)/(4)": (c) => check_range(c, 3, 4),
	"(3-5)": (c) => check_range(c, 3, 5),
	"(3-6)": (c) => check_range(c, 3, 6),
	"(4)/(5)": (c) => check_range(c, 4, 5),
	"(4-6)": (c) => check_range(c, 4, 6),
	"(5)/(6)": (c) => check_range(c, 5, 6),
}

const place_dice_gen = {
	"Full House": gen_full_house,
	"Straight 4/3": gen_straight_4_or_3,
	"Straight 3": gen_straight_3,
	"Straight 4": gen_straight_4,
	"Doubles": gen_doubles,
	"Triples": gen_triples,
	"1": (c) => gen_single(c, 1),
	"2": (c) => gen_single(c, 2),
	"3": (c) => gen_single(c, 3),
	"4": (c) => gen_single(c, 4),
	"5": (c) => gen_single(c, 5),
	"6": (c) => gen_single(c, 6),
	"(1)": (c) => gen_single(c, 1),
	"(2)": (c) => gen_single(c, 2),
	"(3)": (c) => gen_single(c, 3),
	"(4)": (c) => gen_single(c, 4),
	"(5)": (c) => gen_single(c, 5),
	"(6)": (c) => gen_single(c, 6),
	"Any": (c) => gen_range(c, 1, 6),
	"1/2": (c) => gen_range(c, 1, 2),
	"1-3": (c) => gen_range(c, 1, 3),
	"1/2/3": (c) => gen_range(c, 1, 3),
	"1-4": (c) => gen_range(c, 1, 4),
	"1-5": (c) => gen_range(c, 1, 5),
	"2/3": (c) => gen_range(c, 2, 2),
	"2-4": (c) => gen_range(c, 2, 4),
	"2/3/4": (c) => gen_range(c, 2, 4),
	"2-5": (c) => gen_range(c, 2, 5),
	"2-6": (c) => gen_range(c, 2, 6),
	"3/4": (c) => gen_range(c, 3, 4),
	"3-5": (c) => gen_range(c, 3, 5),
	"3/4/5": (c) => gen_range(c, 3, 5),
	"3-6": (c) => gen_range(c, 3, 6),
	"4/5": (c) => gen_range(c, 4, 5),
	"4-6": (c) => gen_range(c, 4, 6),
	"4/5/6": (c) => gen_range(c, 4, 6),
	"5/6": (c) => gen_range(c, 5, 6),
	"(1)/(2)": (c) => gen_range(c, 1, 2),
	"(1-3)": (c) => gen_range(c, 1, 3),
	"(1-4)": (c) => gen_range(c, 1, 4),
	"(1-5)": (c) => gen_range(c, 1, 5),
	"(2)/(3)": (c) => gen_range(c, 2, 2),
	"(2-4)": (c) => gen_range(c, 2, 4),
	"(2-5)": (c) => gen_range(c, 2, 5),
	"(2-6)": (c) => gen_range(c, 2, 6),
	"(3)/(4)": (c) => gen_range(c, 3, 4),
	"(3-5)": (c) => gen_range(c, 3, 5),
	"(3-6)": (c) => gen_range(c, 3, 6),
	"(4)/(5)": (c) => gen_range(c, 4, 5),
	"(4-6)": (c) => gen_range(c, 4, 6),
	"(5)/(6)": (c) => gen_range(c, 5, 6),
}

const place_dice_take = {
	"Full House": take_full_house,
	"Straight 4/3": take_straight_4_or_3,
	"Straight 3": take_straight_3,
	"Straight 4": take_straight_4,
	"Doubles": take_doubles,
	"Triples": take_triples,
	"1": take_single,
	"2": take_single,
	"3": take_single,
	"4": take_single,
	"5": take_single,
	"6": take_single,
	"(1)": take_single,
	"(2)": take_single,
	"(3)": take_single,
	"(4)": take_single,
	"(5)": take_single,
	"(6)": take_single,
	"Any": (c, d) => take_single(c, d),
	"1/2": (c, d) => take_single(c, d),
	"1-3": (c, d) => take_single(c, d),
	"1/2/3": (c, d) => take_single(c, d),
	"1-4": (c, d) => take_single(c, d),
	"1-5": (c, d) => take_single(c, d),
	"2/3": (c, d) => take_single(c, d),
	"2-4": (c, d) => take_single(c, d),
	"2/3/4": (c, d) => take_single(c, d),
	"2-5": (c, d) => take_single(c, d),
	"2-6": (c, d) => take_single(c, d),
	"3/4": (c, d) => take_single(c, d),
	"3-5": (c, d) => take_single(c, d),
	"3/4/5": (c, d) => take_single(c, d),
	"3-6": (c, d) => take_single(c, d),
	"4/5": (c, d) => take_single(c, d),
	"4-6": (c, d) => take_single(c, d),
	"4/5/6": (c, d) => take_single(c, d),
	"5/6": (c, d) => take_single(c, d),
	"(1)/(2)": (c, d) => take_single(c, d),
	"(1-3)": (c, d) => take_single(c, d),
	"(1-4)": (c, d) => take_single(c, d),
	"(1-5)": (c, d) => take_single(c, d),
	"(2)/(3)": (c, d) => take_single(c, d),
	"(2-4)": (c, d) => take_single(c, d),
	"(2-5)": (c, d) => take_single(c, d),
	"(2-6)": (c, d) => take_single(c, d),
	"(3)/(4)": (c, d) => take_single(c, d),
	"(3-5)": (c, d) => take_single(c, d),
	"(3-6)": (c, d) => take_single(c, d),
	"(4)/(5)": (c, d) => take_single(c, d),
	"(4-6)": (c, d) => take_single(c, d),
	"(5)/(6)": (c, d) => take_single(c, d),
}

function can_place_dice(c) {

	let pattern = data.cards[c].dice
	if (!pattern)
		return false

	let pred = place_dice_check[pattern]
	if (!pred)
		throw Error("bad pattern definition: " + pattern)

	// At per card limit?
	if (place_dice_once[pattern]) {
		if (map_has(game.placed, c))
			return false
	}

	// At cube limit?
	if (data.cards[c].special) {
		// Max on card
		if (get_cubes(c) >= data.cards[c].special)
			return false

		// Max available
		let n_cubes = count_total_cubes()
		if (n_cubes >= 10)
			return false

		if (game.scenario === S12_TOWTON) {
			if (n_cubes >= 8)
				return false
		}
	}

	// At per wing limit?
	let wing = data.cards[c].wing
	let n_wing = 0
	for (let i = 0; i < game.placed.length; i += 2) {
		let x = game.placed[i]
		if (x !== c) {
			let i_wing = data.cards[x].wing
			if (i_wing === wing)
				n_wing ++
		}
	}
	if (n_wing >= game.place_max[wing])
		return false

	if (game.scenario === S8_BROOKLYN_HEIGHTS) {
		if (c === S8_CLINTON) {
			if (is_removed_from_play(S8_GRANT))
				return false
			if (is_removed_from_play(S8_HESSIANS))
				return false
		}
	}

	if (game.scenario === S3201_GAINES_MILL) {
		if (c === S3201_JACKSON) {
			if (!has_any_dice_on_card(S3201_DH_HILL))
				return false
		}
	}

	if (game.scenario === S29_GETTYS_2ND) {
		if (c === S29_MCLAWS) {
			if (!has_any_dice_on_card(S29_HOOD))
				return false
		}
		if (c === S29_ANDERSON) {
			if (!has_any_dice_on_card(S29_MCLAWS))
				return false
		}
		if (c === S29_EARLY || c === S29_JOHNSON) {
			let red = 0
			if (has_any_dice_on_card(S29_HOOD)) ++red
			if (has_any_dice_on_card(S29_MCLAWS)) ++red
			if (has_any_dice_on_card(S29_ANDERSON)) ++red
			if (red < 2)
				return false
		}
	}

	return pred(c)
}

function can_place_value(c, v) {
	let old_v = map_get(game.placed, c, 0)
	return old_v === 0 || old_v === v
}

function pool_has_single(v) {
	for (let i = 0; i < 6; ++i)
		if (is_pool_die(i, v))
			return true
	return false
}

function check_single_count(c, v, x) {
	if (!can_place_value(c, v))
		return false
	let n = 0
	for (let i = 0; i < 6; ++i)
		if (is_pool_die(i, v) && ++n >= x)
			return true
	return false
}

function check_single(c, v) {
	if (!can_place_value(c, v))
		return false
	for (let i = 0; i < 6; ++i)
		if (is_pool_die(i, v))
			return true
	return false
}

function check_range(c, lo, hi) {
	let old_v = map_get(game.placed, c, 0)
	if (old_v > 0)
		return pool_has_single(old_v)
	for (let i = 0; i < 6; ++i)
		if (is_pool_die_range(i, lo, hi))
			return true
	return false
}

function check_all_3(c, x, y, z) {
	if (!can_place_value(c, x))
		return false
	return pool_has_single(x) && pool_has_single(y) && pool_has_single(z)
}

function check_all_4(c, x, y, z, w) {
	if (!can_place_value(c, x))
		return false
	return pool_has_single(x) && pool_has_single(y) && pool_has_single(z) && pool_has_single(w)
}

function check_straight_4_or_3(c) {
	if (is_straight_4_or_3(c) === 4)
		return check_straight_4(c)
	else
		return check_straight_3(c)
}

function check_straight_3(c) {
	return (
		check_all_3(c, 1, 2, 3) ||
		check_all_3(c, 2, 3, 4) ||
		check_all_3(c, 3, 4, 5) ||
		check_all_3(c, 4, 5, 6)
	)
}

function check_straight_4(c) {
	return (
		check_all_4(c, 1, 2, 3, 4) ||
		check_all_4(c, 2, 3, 4, 5) ||
		check_all_4(c, 3, 4, 5, 6)
	)
}

function check_doubles(c) {
	return (
		check_single_count(c, 1, 2) ||
		check_single_count(c, 2, 2) ||
		check_single_count(c, 3, 2) ||
		check_single_count(c, 4, 2) ||
		check_single_count(c, 5, 2) ||
		check_single_count(c, 6, 2)
	)
}

function check_triples(c) {
	return (
		check_single_count(c, 1, 3) ||
		check_single_count(c, 2, 3) ||
		check_single_count(c, 3, 3) ||
		check_single_count(c, 4, 3) ||
		check_single_count(c, 5, 3) ||
		check_single_count(c, 6, 3)
	)
}

function check_full_house(c) {
	for (let x = 1; x <= 6; ++x) {
		for (let y = 1; y <= 6; ++y) {
			if (x !== y) {
				if (check_single_count(c, x, 3) && check_single_count(c, y, 2))
					return true
			}
		}
	}
	return false
}

function gen_pool_die(v) {
	let p = player_index()
	for (let i = 0; i < 6; ++i)
		if (get_player_dice_location(p, i) < 0 && get_player_dice_value(p, i) === v)
			gen_action_die(p * 6 + i)
}

function gen_single(c, v) {
	if (!can_place_value(c, v))
		return false
	gen_pool_die(v)
}

function gen_range(c, lo, hi) {
	for (let v = lo; v <= hi; ++v)
		gen_single(c, v)
}

function gen_straight_4_or_3(c) {
	if (is_straight_4_or_3(c) === 4)
		gen_straight_4(c)
	else
		gen_straight_3(c)
}

function gen_straight_3(c) {
	if (check_all_3(c, 1, 2, 3))
		gen_pool_die(1)
	if (check_all_3(c, 2, 3, 4))
		gen_pool_die(2)
	if (check_all_3(c, 3, 4, 5))
		gen_pool_die(3)
	if (check_all_3(c, 4, 5, 6))
		gen_pool_die(4)
}

function gen_straight_4(c) {
	if (check_all_4(c, 1, 2, 3, 4))
		gen_pool_die(1)
	if (check_all_4(c, 2, 3, 4, 5))
		gen_pool_die(2)
	if (check_all_4(c, 3, 4, 5, 6))
		gen_pool_die(3)
}

function gen_doubles(c) {
	for (let v = 1; v <= 6; ++v)
		if (check_single_count(c, v, 2))
			gen_pool_die(v)
}

function gen_triples(c) {
	for (let v = 1; v <= 6; ++v)
		if (check_single_count(c, v, 3))
			gen_pool_die(v)
}

function gen_full_house(c) {
	for (let x = 1; x <= 6; ++x) {
		for (let y = 1; y <= 6; ++y) {
			if (x !== y) {
				if (check_single_count(c, x, 3) && check_single_count(c, y, 2))
					gen_pool_die(x)
			}
		}
	}
}

function find_and_take_single(c, v) {
	let p = player_index()
	for (let i = 0; i < 6; ++i) {
		if (get_player_dice_location(p, i) < 0 && get_player_dice_value(p, i) === v) {
			set_player_dice_location(p, i, c)
			return
		}
	}
	throw new Error("cannot find die of value " + v)
}

function take_single(c, d) {
	set_dice_location(d, c)
	map_set(game.placed, c, get_dice_value(d))
}

function take_doubles(c, d) {
	let v = get_dice_value(d)
	take_single(c, d)
	find_and_take_single(c, v)
}

function take_triples(c, d) {
	let v = get_dice_value(d)
	take_single(c, d)
	find_and_take_single(c, v)
	find_and_take_single(c, v)
}

function take_full_house(c, d) {
	let x = get_dice_value(d)
	for (let y = 1; y <= 6; ++y) {
		if (x !== y) {
			if (check_single_count(c, x, 3) && check_single_count(c, y, 2)) {
				find_and_take_single(c, x)
				find_and_take_single(c, x)
				find_and_take_single(c, x)
				find_and_take_single(c, y)
				find_and_take_single(c, y)
			}
		}
	}
}

function take_straight_4_or_3(c, d) {
	if (is_straight_4_or_3(c) === 4)
		take_straight_4(c, d)
	else
		take_straight_3(c, d)
}

function take_straight_3(c, d) {
	let v = get_dice_value(d)
	take_single(c, d)
	find_and_take_single(c, v+1)
	find_and_take_single(c, v+2)
}

function take_straight_4(c, d) {
	let v = get_dice_value(d)
	take_single(c, d)
	find_and_take_single(c, v+1)
	find_and_take_single(c, v+2)
	find_and_take_single(c, v+3)
}

function goto_roll_phase() {
	game.selected = -1
	game.target = -1
	game.target2 = -1
	game.action = 0
	game.state = "roll"

	game.place_max = [ 1, 1, 1, 1 ]

	let p = player_index()
	for (let c of game.front[p]) {
		if (card_has_rule(c, "place_2_blue"))
			game.place_max[BLUE] = 2
		if (card_has_rule(c, "place_2_red"))
			game.place_max[RED] = 2
		if (card_has_rule(c, "place_2_red_if_dice") && has_any_dice_on_card(c))
			game.place_max[RED] = 2

		/*
		// NOT USED (YET)
		if (card_has_rule(c, "place_2_dkblue")) game.place_max[DKBLUE] = 2
		if (card_has_rule(c, "place_2_pink")) game.place_max[PINK] = 2
		if (card_has_rule(c, "place_2_blue_if_dice") && has_any_dice_on_card(c)) game.place_max[BLUE] = 2
		if (card_has_rule(c, "place_2_dkblue_if_dice") && has_any_dice_on_card(c)) game.place_max[DKBLUE] = 2
		if (card_has_rule(c, "place_2_pink_if_dice") && has_any_dice_on_card(c)) game.place_max[PINK] = 2
		*/
	}
}

states.roll = {
	prompt() {
		if (game.reacted === player_index())
			view.prompt = "Skipped action phase; roll the dice in your pool."
		else
			view.prompt = "Roll the dice in your pool."
		view.actions.roll = 1
		view.actions.end_turn = 0
	},
	roll() {
		clear_undo()
		roll_dice_in_pool()
	},
}

function roll_dice_in_pool() {
	game.rolled = 0
	if (game.reacted === player_index())
		game.reacted = -1
	let p = player_index()
	for (let i = 0; i < 6; ++i) {
		if (get_player_dice_location(p, i) < 0) {
			set_player_dice_value(p, i, random(6) + 1)
			game.rolled++
		}
	}
	game.state = "place"
}

function gen_place_dice_select_card() {
	let p = player_index()
	for (let c of game.front[p]) {
		if (c === game.selected)
			continue
		if (can_place_dice(c))
			gen_action_card(c)
	}
}

states.place = {
	prompt() {
		view.prompt = "Place dice on your formations."
		gen_place_dice_select_card()
		view.actions.end_turn = 1
	},
	card(c) {
		push_undo()
		game.selected = c
		game.state = "place_on_card"
	},
	end_turn() {
		end_roll_phase()
	},
}

states.place_on_card = {
	prompt() {
		let card = data.cards[game.selected]
		view.prompt = "Place dice on " + card.name + "."

		gen_place_dice_select_card()

		place_dice_gen[card.dice](game.selected)

		view.actions.end_turn = 1
	},
	card(c) {
		if (c === game.selected) {
			game.selected = -1
			game.state = "place"
		} else {
			game.selected = c
			game.state = "place_on_card"
		}
	},
	die(d) {
		push_undo()
		place_dice_take[data.cards[game.selected].dice](game.selected, d)
		if (!can_place_dice(game.selected)) {
			game.selected = -1
			game.state = "place"
		}
	},
	end_turn() {
		end_roll_phase()
	},
}

function end_roll_phase() {
	push_undo()

	// Remove placed dice to add cube on special cards.
	for (let c of game.front[player_index()]) {
		let s = data.cards[c].special
		if (s && has_any_dice_on_card(c)) {
			map_set(game.cubes, c, Math.min(s, get_cubes(c) + 1))
			remove_dice(c)
		}
	}

	// Blank out unused dice.
	let p = player_index()
	for (let i = 0; i < 6; ++i)
		if (get_player_dice_location(p, i) < 0)
			set_player_dice_value(p, i, 0)

	if (game.scenario === S26_PEACH_ORCHARD) {
		if (is_card_in_play(S26_FATAL_BLUNDER)) {
			if (!placed_any_dice_on_wing(PINK)) {
				game.state = "s26_fatal_blunder"
				return
			}
		}
	}

	if (game.scenario === S28_CULPS_HILL) {
		if (get_cubes(S28_GEARY) === 5)
			return goto_game_over(P2, "Geary's Division arrived.")
	}

	end_turn()
}

states.s26_fatal_blunder = {
	prompt() {
		view.prompt = "Fatal Blunder!"
		if (is_card_in_play(S26_FATAL_BLUNDER)) {
			gen_action_card(S26_FATAL_BLUNDER)
		} else {
			let done = true
			for (let c of game.front[0]) {
				if (data.cards[c].wing === PINK) {
					gen_action_card(c)
					done = false
				}
			}
			if (done)
				view.actions.end_turn = 1
		}
	},
	card(c) {
		if (c === S26_FATAL_BLUNDER) {
			log("Fatal Blunder!")
			remove_card(S26_FATAL_BLUNDER)
			game.morale[0] ++
		} else {
			rout_card(c)
			game.morale[0] --
			game.morale[1] ++
		}
	},
	end_turn() {
		if (check_victory())
			return
		end_turn()
	}
}

function end_turn() {
	clear_undo()

	map_clear(game.placed)
	game.place_max = null

	set_opponent_active()
	goto_start_turn()
}

// === ACTION PHASE ===

function side_get_wild_die_card(p) {
	if (game.scenario === S11_MORTIMERS_CROSS || game.scenario === S12_TOWTON || game.scenario === S16_STOKE_FIELD) {
		for (let c of game.front[p])
			if (card_has_rule(c, "wild") && has_any_dice_on_card(c))
				return c
	}
	return -1
}

function side_has_wild_die(p) {
	return side_get_wild_die_card(p) >= 0
}

function has_any_dice_on_card(c) {
	for (let i = 0; i < 12; ++i)
		if (get_dice_location(i) === c)
			return true
	return false
}

function has_any_cubes_on_card(c) {
	return get_cubes(c) >= 1
}

function count_dice_on_card(c) {
	let n = 0
	for (let i = 0; i < 12; ++i)
		if (get_dice_location(i) === c)
			++n
	return n
}

function count_dice_on_card_with_value(c, v) {
	let n = 0
	for (let i = 0; i < 12; ++i)
		if (get_dice_location(i) === c && get_dice_value(i) === v)
			++n
	return n
}

function require_pair(c) {
	for (let v = 1; v <= 6; ++v)
		if (count_dice_on_card_with_value(c, v) >= 2)
			return true
	return false
}

function require_triplet(c) {
	for (let v = 1; v <= 6; ++v)
		if (count_dice_on_card_with_value(c, v) >= 3)
			return true
	return false
}

function require_full_house(c) {
	let n3 = 0
	let n2 = 0
	for (let v = 1; v <= 6; ++v) {
		let n = count_dice_on_card_with_value(c, v)
		if (n >= 3)
			++n3
		else if (n >= 2)
			++n2
	}
	return (n3 >= 2) || (n3 >= 1 && n2 >= 1)
}

function require_two_pairs(c) {
	let n = 0
	for (let v = 1; v <= 6; ++v)
		if (count_dice_on_card_with_value(c, v) >= 2)
			++n
	return n >= 2
}

function check_cube_requirement(c, req) {
	switch (req) {
		case "3 cubes":
			return get_cubes(c) >= 3
		case "Voluntary":
		case undefined:
			return get_cubes(c) >= 1
		default:
			throw new Error("invalid action requirement: " + req)
	}
}

function check_dice_requirement(c, req, wild) {
	switch (req) {
		case "Full House":
			return require_full_house(c)
		case "Pair":
		case "Pair, Voluntary":
			// NOTE: Only requirement needed for Wild die scenarios.
			if (wild)
				return has_any_dice_on_card(c)
			return require_pair(c)
		case "Triplet":
			return require_triplet(c)
		case "Two Pairs":
			return require_two_pairs(c)
		case "Voluntary":
		case undefined:
			return has_any_dice_on_card(c)
		default:
			throw new Error("invalid action requirement: " + req)
	}
}

function card_has_any_actions(c) {
	for (let a of data.cards[c].actions)
		if (is_action(c, a))
			return true
	return false
}

function is_action(c, a) {
	return (a.type === "Bombard" || a.type === "Attack" || a.type === "Command")
}

function is_reaction(c, a) {
	return (a.type === "Screen" || a.type === "Counterattack" || a.type === "Absorb")
}

function is_mandatory_reaction(c, a) {
	return (
		a.requirement !== "Voluntary" &&
		a.requirement !== "Pair, Voluntary"
	)
}

function can_take_action(c, a, ix) {
	if (a.type === "Attack") {
		if (find_target_of_attack(c, a) < 0)
			return false
	}

	if (a.type === "Command") {
		if (find_first_target_of_command(c, a) < 0)
			return false
	}

	if (game.scenario === S8_BROOKLYN_HEIGHTS) {
		if (c === S8_CLINTON) {
			// Clinton - may only attack if both Grant and Hessians have dice on them.
			if (!has_any_dice_on_card(S8_GRANT) || !has_any_dice_on_card(S8_HESSIANS))
				return false
		}
	}

	if (game.scenario === S3201_GAINES_MILL) {
		if (c === S3201_JACKSON) {
			// Jackson - may only attack if D.H. Hill and one other formation have dice
			if (!has_any_dice_on_card(S3201_DH_HILL))
				return false
			if (!has_any_dice_on_card(S3201_AP_HILL) && !has_any_dice_on_card(S3201_LONGSTREET))
				return false
		}
	}

	if (game.scenario === S35_AULDEARN) {
		if (c === S35_MONTROSE) {
			// May only perform the second action after having previously performed the first.
			if (ix === 1) {
				if (is_card_in_reserve(S35_GORDON))
					return false
			}
		}
	}

	if (a.type === "Bombard" || a.type === "Attack" || a.type === "Command") {
		if (data.cards[c].special)
			return check_cube_requirement(c, a.requirement)
		else
			return check_dice_requirement(c, a.requirement, false)
	}
	return false
}

function s40_can_take_cassines_action(c, a, b) {
	return (player_index() === 1) && (get_sticks(c) < 3) && (is_card_in_play(a) || is_card_in_play(b))
}

function can_take_any_action() {
	let p = player_index()
	for (let c of game.front[p]) {
		if (card_has_any_actions(c)) {
			if (has_any_dice_on_card(c))
				return true
			if (has_any_cubes_on_card(c)) // TODO: check requirements!
				return true
		}
	}

	if (game.scenario === S40_CHIARI) {
		if (s40_can_take_cassines_action(S40_CASSINES_I, S40_NIGRELLI, S40_KRIECHBAUM))
			return true
		if (s40_can_take_cassines_action(S40_CASSINES_II, S40_MANNSFELDT, S40_GUTTENSTEIN))
			return true
	}

	return false
}

function count_cards_remaining_from_wing(w) {
	let n = 0
	for (let c of game.front[0])
		if (data.cards[c].wing === w)
			++n
	for (let c of game.front[1])
		if (data.cards[c].wing === w)
			++n
	for (let c of game.reserve[0])
		if (data.cards[c].wing === w)
			++n
	for (let c of game.reserve[1])
		if (data.cards[c].wing === w)
			++n
	return n
}

function goto_start_turn() {
	if (check_impossible_to_attack_victory())
		return

	if (game.scenario === S25_WHEATFIELD) {
		// Rout Stony Hill at start of Union turn if it is the only Blue card left.
		if (player_index() === 1) {
			if (is_card_in_play(S25_STONY_HILL)) {
				if (count_cards_remaining_from_wing(BLUE) === 1) {
					game.state = "s25_stony_hill"
					return
				}
			}
		}
	}

	goto_action_phase()
}

states.s25_stony_hill = {
	prompt() {
		view.prompt = "Rout Stony Hill!"
		gen_action_card(S25_STONY_HILL)
	},
	card(c) {
		rout_card(S25_STONY_HILL)
		game.morale[0] --
		game.morale[1] ++
		if (check_victory())
			return
		goto_action_phase()
	},
}

function goto_action_phase() {
	if (game.reacted === player_index()) {
		end_action_phase()
	} else {
		if (can_take_any_action())
			game.state = "action"
		else
			end_action_phase()
	}
}

function end_action_phase() {
	game.hits = 0
	game.self = 0
	game.hits2 = 0
	game.self2 = 0
	game.selected = -1
	game.target = -1
	game.target2 = -1
	goto_routing()
}

states.action = {
	prompt() {
		view.prompt = "Take an action."
		view.actions.roll = 1
		view.actions.end_turn = 0

		let p = player_index()
		for (let c of game.front[p]) {
			let has_dice = has_any_dice_on_card(c)
			let has_cube = has_any_cubes_on_card(c)
			if (has_dice || has_cube) {
				if (data.cards[c].actions.length >= 1) {
					if (is_action(c, data.cards[c].actions[0])) {
						if (can_take_action(c, data.cards[c].actions[0], 0))
							gen_action_action1(c)
						else if (has_dice)
							gen_action_null1(c)
					}
				}
				if (data.cards[c].actions.length >= 2) {
					if (is_action(c, data.cards[c].actions[1])) {
						if (can_take_action(c, data.cards[c].actions[1], 1))
							gen_action_action2(c)
						else if (has_dice)
							gen_action_null2(c)
					}
				}
				if (data.cards[c].retire)
					gen_action_retire(c)
			}
		}

		if (game.scenario === S40_CHIARI) {
			if (s40_can_take_cassines_action(S40_CASSINES_I, S40_NIGRELLI, S40_KRIECHBAUM))
				gen_action_card(S40_CASSINES_I)
			if (s40_can_take_cassines_action(S40_CASSINES_II, S40_MANNSFELDT, S40_GUTTENSTEIN))
				gen_action_card(S40_CASSINES_II)
		}
	},
	retire(c) {
		push_undo()
		retire_card(c)
		end_action_phase()
	},
	a1(c) {
		push_undo()
		goto_take_action(c, 0)
	},
	a2(c) {
		push_undo()
		goto_take_action(c, 1)
	},
	n1(c) {
		push_undo()
		goto_null(c)
	},
	n2(c) {
		push_undo()
		goto_null(c)
	},
	roll() {
		clear_undo()
		goto_roll_phase()
		roll_dice_in_pool()
	},
	card(c) {
		push_undo()
		if (game.scenario === S40_CHIARI) {
			game.selected = c
			game.state = "s40_cassines"
		}
	}
}

states.s40_cassines = {
	prompt() {
		view.prompt = "Cassines: Move one unit stick to this card."
		if (game.selected === S40_CASSINES_I) {
			if (is_card_in_play(S40_NIGRELLI))
				gen_action_card(S40_NIGRELLI)
			if (is_card_in_play(S40_KRIECHBAUM))
				gen_action_card(S40_KRIECHBAUM)
		}
		if (game.selected === S40_CASSINES_II) {
			if (is_card_in_play(S40_MANNSFELDT))
				gen_action_card(S40_MANNSFELDT)
			if (is_card_in_play(S40_GUTTENSTEIN))
				gen_action_card(S40_GUTTENSTEIN)
		}
	},
	card(c) {
		log(game.selected + " moved one stick from " + c)
		set_sticks(c, get_sticks(c) - 1)
		set_sticks(game.selected, get_sticks(game.selected) + 1)
		end_action_phase()
	},
}

function goto_null(c) {
	log("Fizzled " + card_number(c))
	pay_for_action(c)
	end_action_phase()
}

function goto_take_action(c, ix) {
	let a = data.cards[c].actions[ix]
	game.selected = c
	game.action = ix
	switch (a.type) {
		case "Attack":
			if (card_has_rule(game.selected, "attack_choose_target"))
				goto_attack_choose_target()
			else
				goto_attack(find_target_of_attack(c, a))
			break
		case "Bombard":
			game.state = "bombard"
			break
		case "Command":
			goto_command()
			break
	}
}

function current_action() {
	return data.cards[game.selected].actions[game.action]
}

function find_target_of_attack(c, a) {
	let in_res = card_has_rule(c, "attack_reserve")
	for (let c of a.target_list) {
		if (set_has(game.front[0], c))
			return c
		if (set_has(game.front[1], c))
			return c
		if (in_res) {
			if (set_has(game.reserve[0], c))
				return c
			if (set_has(game.reserve[1], c))
				return c
		}
	}
	return -1
}

function find_first_target_of_command(c, a) {

	if (game.scenario === S37_INKERMAN) {
		if (c === S37_THE_FOG)
			return S37_THE_FOG
	}

	if (!a.target_list)
		throw new Error("no rule for Command target: " + a.target)

	for (let t of a.target_list) {
		if (is_card_in_reserve(t))
			return t
	}

	return -1
}

function find_all_targets_of_command(a) {
	let list = []
	for (let t of a.target_list) {
		if (is_card_in_reserve(t))
			list.push(t)
	}
	return list
}

states.bombard = {
	prompt() {
		view.prompt = "Bombard."
		view.actions.bombard = 1
	},
	bombard() {
		log(card_name(game.selected) + " bombarded.")
		let opp = 1 - player_index()
		game.morale[opp] --
		pay_for_action(game.selected)
		end_action_phase()
	},
}

function goto_attack_choose_target() {
	let a = current_action()
	let candidates = []
	for (let c of a.target_list) {
		if (set_has(game.front[0], c))
			candidates.push(c)
		if (set_has(game.front[1], c))
			candidates.push(c)
	}
	if (candidates.length > 1)
		game.state = "attack_choose_target"
	else
		goto_attack(candidates[0])
}

states.attack_choose_target = {
	prompt() {
		view.prompt = "Choose the target of your attack."
		let a = current_action()
		for (let c of a.target_list) {
			if (set_has(game.front[0], c))
				gen_action_card(c)
			if (set_has(game.front[1], c))
				gen_action_card(c)
		}
	},
	card(c) {
		goto_attack(c)
	},
}

function goto_attack(target) {
	let a = current_action()

	let take_from = card_has_rule(game.selected, "take_from")
	if (take_from) {
		for (let from of take_from)
			if (has_any_dice_on_card(from))
				take_all_dice(from, game.selected)
	}

	let take_1_from = card_has_rule(game.selected, "take_1_from")
	if (take_1_from) {
		for (let from of take_1_from)
			if (has_any_dice_on_card(from))
				take_one_die(from, game.selected)
	}

	game.state = "attack"
	game.target = target

	update_attack1()
	update_attack2()
}

// Update hits and self hits.
function update_attack1() {
	let a = current_action()

	game.hits = get_attack_hits(game.selected, a)
	game.self = get_attack_self(game.selected, a) + game.self2

	if (game.scenario === S2_MARSTON_MOOR) {
		if (is_card_in_play(S2_RUPERTS_LIFEGUARD)) {
			if (game.target === S2_TILLIERS_LEFT)
				game.hits -= 1
			if (game.target === S2_TILLIERS_RIGHT)
				game.hits -= 1
		}
	}

	if (game.scenario === S37_INKERMAN) {
		// Until the first Fog Cube is lifted.
		if (get_cubes(S37_THE_FOG) === 3) {
			game.hits -= 1
		}
	}

	if (game.scenario === S9_ST_ALBANS) {
		// Defensive Works (negated by Archers)
		if (game.target === S9_SHROPSHIRE_LANE || game.target === S9_SOPWELL_LANE) {
			if (is_card_in_play(S9_HENRY_VI))
				if (!has_any_cubes_on_card(S9_ARCHERS))
					game.hits = Math.min(1, game.hits)
		}
	}

	if (game.scenario === S15_TEWKESBURY) {
		if (game.target === S15_SOMERSET) {
			if (has_any_dice_on_card(S15_A_PLUMP_OF_SPEARS))
				game.hits += 1
		}
	}

	if (game.scenario === S22_GABIENE) {
		if (game.target === S22_SILVER_SHIELDS) {
			if (is_card_in_play(S22_EUMENES_CAMP)) {
				game.hits = Math.min(1, game.hits - 1)
			}
		}
	}

	if (game.scenario === S30_EDGEHILL) {
		if (game.selected === S30_GERARD && game.target === S30_ESSEX)
			game.hits += 1
	}

	if (game.scenario === S31_NEWBURY_1ST) {
		if (game.selected === S31_WENTWORTH) {
			if (has_any_dice_on_card(S31_BYRON) && is_card_in_play(S31_SKIPPON)) {
				game.hits += 1
			}
		}
		if (game.target === S31_WENTWORTH) {
			if (has_any_dice_on_card(S31_BYRON) && is_card_in_play(S31_SKIPPON)) {
				game.hits -= 1
			}
		}
	}

	if (game.scenario === S39_MARSAGLIA) {
		if (game.selected === S39_EUGENE) {
			if (has_any_dice_on_card(S39_CANNONS)) {
				game.self = 0
			}
		}
		if (game.selected === S39_CATINAT && game.target2 === S39_BAYONETS) {
			game.self = 0
		}
	}

	let extra = card_has_rule(game.selected, "extra_hit_if_dice_on")
	if (extra && has_any_dice_on_card(extra[0]))
		game.hits += 1

	if (card_has_rule(game.target, "suffer_1_less_1_max"))
		game.hits = clamp(game.hits - 1, 0, 1)
	if (card_has_rule(game.target, "suffer_1_less"))
		game.hits = Math.max(0, game.hits - 1)

	if (card_has_active_link(game.target))
		game.hits = Math.max(0, game.hits - 1)
}

// Update hits and self hits for defensive abilities that redirect or steal hits.
function update_attack2() {
	if (game.scenario === S28_CULPS_HILL) {
		if (is_card_in_play(S28_BREASTWORKS)) {
			if (data.cards[game.target].wing === DKBLUE) {
				if (game.hits > 0) {
					game.target2 = S28_BREASTWORKS
					if (game.hits > 1) {
						game.hits2 = game.hits - 1
						game.hits = 1
					} else {
						game.hits2 = 1
						game.hits = 0
					}
				}
			}
		}
	}
}

states.attack = {
	prompt() {
		view.prompt = "Attack " + card_name(game.target) + "."
		gen_action_card(game.target)

		let w = side_get_wild_die_card(player_index())
		if (w >= 0)
			gen_action_dice_on_card(w)

		let may_take_from = card_has_rule(game.selected, "may_take_from")
		if (may_take_from) {
			for (let from of may_take_from)
				gen_action_dice_on_card(from)
		}

		let may_take_from_extra = card_has_rule(game.selected, "may_take_from_extra_self")
		if (may_take_from_extra) {
			for (let from of may_take_from_extra)
				gen_action_dice_on_card(from)
		}

		if (game.scenario === S39_MARSAGLIA) {
			if (game.selected === S39_CATINAT) {
				gen_action_dice_on_card(S39_BAYONETS)
			}
		}

		view.actions.attack = 1
	},
	attack() {
		log(card_name(game.selected) + " attacked " + card_name(game.target) + ".")
		if (can_opponent_react()) {
			clear_undo()
			set_opponent_active()
			game.state = "react"
		} else {
			resume_attack()
		}
	},
	card(_) {
		this.attack()
	},
	die(d) {
		let from = get_dice_location(d)

		let w = side_get_wild_die_card(player_index())
		if (w === from) {
			take_wild_die(w, game.selected)
			return
		}

		let may_take_from = card_has_rule(game.selected, "may_take_from")
		if (may_take_from) {
			take_all_dice(from, game.selected)
			update_attack1()
			update_attack2()
			return
		}

		let may_take_from_extra = card_has_rule(game.selected, "may_take_from_extra_self")
		if (may_take_from_extra) {
			take_all_dice(from, game.selected)
			game.self2 = 1
			update_attack1()
			update_attack2()
			return
		}

		if (game.scenario === S39_MARSAGLIA) {
			if (game.selected === S39_CATINAT && from === S39_BAYONETS) {
				take_all_dice(from, game.selected)
				game.target2 = S39_BAYONETS
				update_attack1()
				update_attack2()
				return
			}
		}

		throw new Error("no handler for taking dice from other card")
	}
}

function resume_attack() {
	pay_for_action(game.selected)

	remove_sticks(game.selected, game.self)
	remove_sticks(game.target, game.hits)
	if (game.target2 >= 0)
		remove_sticks(game.target2, game.hits2)

	end_action_phase()
}

// === COMMAND ===

function goto_command() {

	if (game.scenario === S37_INKERMAN && game.selected === S37_THE_FOG) {
		log("The Fog Lifts...")
		remove_cubes(S37_THE_FOG, 1)
		remove_dice(game.selected)
		end_action_phase()
		return
	}

	game.state = "command"
}

states.command = {
	prompt() {
		let list = find_all_targets_of_command(current_action())
		view.prompt = "Bring " + list.map(c => card_name(c)).join(" and ") + " out of reserve."
		for (let t of list)
			gen_action_card(t)
	},
	card(c) {
		log(card_name(game.selected) + " commanded " + card_name(c) + " out of reserve.")
		let p = player_index()
		set_delete(game.reserve[p], c)
		set_add(game.front[p], c)

		if (game.scenario === S4_BOSWORTH_FIELD) {
			if (c === S4_THE_STANLEYS) {
				if (is_card_in_play_or_reserve(S4_NORTHUMBERLAND)) {
					log("The Stanleys rout Northumberland.")
					set_sticks(S4_NORTHUMBERLAND, 0)
				}
			}
		}

		if (game.scenario === S37_INKERMAN) {
			if (c === S37_PAULOFFS_LEFT) {
				log("Morale Cube added to Russian side.")
				game.morale[0] += 1
			}
		}

		if (game.scenario === S13_EDGECOTE_MOOR) {
			// TODO: pay all 3 cubes? remove cards from play?
			if (game.reserve[0].length === 0 && game.reserve[1].length > 0) {
				log("Gained a second morale cube.")
				game.morale[0] += 1
			}
			if (game.reserve[1].length === 0 && game.reserve[0].length > 0) {
				log("Gained a second morale cube.")
				game.morale[1] += 1
			}
		}

		if (find_first_target_of_command(game.selected, current_action()) < 0) {
			pay_for_action(game.selected)
			end_action_phase()
		}
	},
}

// === REACTION ===

function can_opponent_react() {

	if (game.scenario === S39_MARSAGLIA) {
		if (game.selected === S39_CATINAT && game.target2 === S39_BAYONETS)
			return false
	}

	let p = 1 - player_index()
	let wild = side_has_wild_die(p)
	for (let c of game.front[p])
		if (can_card_react(c, wild))
			return true
	return false
}

function can_card_react(c, wild) {
	let has_dice = has_any_dice_on_card(c)
	let has_cube = has_any_cubes_on_card(c)
	if (has_dice || has_cube) {
		if (data.cards[c].actions.length >= 1)
			if (is_reaction(c, data.cards[c].actions[0]))
				if (can_take_reaction(c, data.cards[c].actions[0], wild))
					return true
		if (data.cards[c].actions.length >= 2)
			if (is_reaction(c, data.cards[c].actions[1]))
				if (can_take_reaction(c, data.cards[c].actions[1], wild))
					return true
	}
	return false
}

function can_take_reaction(c, a, wild) {
	switch (a.type) {
	default:
		throw new Error("invalid reaction: " + a.type)
	case "Screen":
		// if a friendly formation is attacked by a listed enemy formation
		// ... or a listed formation is attacked (Wheatfield Road Artillery, etc)
		if (!a.target_list.includes(game.selected) && !a.target_list.includes(game.target))
			return false
		break
	case "Counterattack":
		// if this formation is attacked
		if (game.target !== c)
			return false
		// ... by one of the listed targets
		if (!a.target_list.includes(game.selected))
			return false
		break
	case "Absorb":
		// if attack target is listed on absorb action
		if (!a.target_list.includes(game.target))
			return false
		break
	}

	if (game.scenario === S7_THE_DUNES) {
		if (has_any_cubes_on_card(S7_THE_ENGLISH_FLEET)) {
			if (c === S7_DON_JUAN_JOSE)
				return false
			if (c === S7_SPANISH_RIGHT_CAVALRY)
				return false
		}
	}

	if (game.scenario === S15_TEWKESBURY) {
		if (c === S15_WENLOCK) {
			if (is_removed_from_play(S15_SOMERSET))
				return false
		}
	}

	if (game.scenario === S31_NEWBURY_1ST) {
		if (c === S31_GERARD) {
			// TODO: or is it while London New Bands is in play?
			if (is_removed_from_play(S31_SKIPPON))
				return false
		}
	}

	if (data.cards[c].special)
		return check_cube_requirement(c, a.requirement)
	else
		return check_dice_requirement(c, a.requirement, wild)
}

function take_wild_die_if_needed_for_reaction(c, ix) {
	let w = side_get_wild_die_card(player_index())
	if (w >= 0) {
		let a = data.cards[c].actions[ix]
		if (!can_take_reaction(c, a, false)) {
			take_wild_die(w, c)
		}
	}
}

states.react = {
	prompt() {
		view.prompt = card_name(game.selected) + " attacks " + card_name(game.target) + "!"
		let voluntary = true
		let p = player_index()
		let wild = side_has_wild_die(p)
		for (let c of game.front[p]) {
			let has_dice = has_any_dice_on_card(c)
			let has_cube = has_any_cubes_on_card(c)
			if (has_dice || has_cube) {
				for (let i = 0; i < data.cards[c].actions.length; ++i) {
					let a = data.cards[c].actions[i]
					if (is_reaction(c, a)) {
						let must = false
						let may = false
						if (is_mandatory_reaction(c, a)) {
							must = can_take_reaction(c, a, false)
							if (!must && wild && can_take_reaction(c, a, true))
								may = true
						} else {
							may = can_take_reaction(c, a, wild)
						}
						if (must)
							voluntary = false
						if (must || may) {
							if (i === 0) gen_action_action1(c)
							if (i === 1) gen_action_action2(c)
						}
					}
				}
			}
		}
		if (voluntary)
			view.actions.pass = 1
	},
	a1(c) {
		push_undo()
		take_wild_die_if_needed_for_reaction(c, 0)
		goto_take_reaction(c, 0)
	},
	a2(c) {
		push_undo()
		take_wild_die_if_needed_for_reaction(c, 1)
		goto_take_reaction(c, 1)
	},
	pass() {
		set_opponent_active()
		resume_attack()
	},
}

function goto_take_reaction(c, ix) {
	let a = data.cards[c].actions[ix]
	switch (a.type) {
		case "Screen":
			goto_screen(c, a)
			break
		case "Absorb":
			goto_absorb(c, a)
			break
		case "Counterattack":
			goto_counterattack(c, a)
			break
	}
}

function end_reaction() {
	clear_undo()
	set_opponent_active()
	resume_attack()
}

// === SCREEN ===

function goto_screen(c, a) {
	game.reacted = player_index()

	game.target = c

	update_attack1()

	switch (a.effect)
	{
	default:
		throw new Error("invalid screen effect: " + a.effect)
	case undefined:
		game.hits = 0
		game.self = 0
		break
	case "If either Chariot formation is screened, it suffers one Hit!":
		game.hits = 0
		if (card_has_rule(game.selected, "is_chariot"))
			game.self = 1
		else
			game.self = 0
		break
	}

	update_attack2()

	game.state = "screen"
}

states.screen = {
	prompt() {
		view.prompt = "Screen attack from " + card_name(game.selected) + "."
		view.actions.screen = 1
	},
	screen() {
		log(card_name(game.target) + " screened.")
		pay_for_action(game.target)

		if (card_has_rule(game.target, "remove_after_screen"))
			remove_card(game.target)

		end_reaction()
	},
}

// === ABSORB ===

function goto_absorb(c, a) {
	game.reacted = player_index()

	if (game.scenario === S29_GETTYS_2ND) {
		if (c === S29_MEADE) {
			game.state = "s29_meade"
			return
		}
	}

	game.target = c

	update_attack1()

	switch (a.effect)
	{
	default:
		throw new Error("invalid absorb effect: " + a.effect)
	case "When target suffers Hits, this card suffers them instead.":
	case "When target suffers Hits, this unit suffers them instead.":
		break
	case "When target suffers Hits, this card suffers 1 hit ONLY instead.":
	case "When target suffers Hits, this unit suffers 1 hit ONLY instead.":
		game.hits = 1
		break
	case "When target suffers Hits, this card suffers one less Hit instead.":
		game.hits = Math.max(0, game.hits - 1)
		break
	case "When target suffers Hits, this card suffers 1 less hit per die.":
	case "When target suffers Hits, this unit suffers 1 less hit per die.":
		game.hits = Math.max(0, game.hits - count_dice_on_card(c))
		break
	}

	update_attack2()

	game.state = "absorb"
}

states.s29_meade = {
	prompt() {
		view.prompt = "Choosy any friendly Formation except Little Round Top to absorb the hits instead."
		let p = player_index()
		for (let c of game.front[p]) {
			if (c !== S29_MEADE && c !== S29_LITTLE_ROUND_TOP && c !== game.target)
				gen_action_card(c)
		}
	},
	card(c) {
		remove_dice(S29_MEADE)
		game.target = c
		update_attack1()
		update_attack2()
		game.state = "absorb"
	}
}

states.absorb = {
	prompt() {
		view.prompt = "Absorb attack from " + card_name(game.selected) + "."
		view.actions.absorb = 1
	},
	absorb() {
		log(card_name(game.target) + " absorbed.")
		pay_for_action(game.target)
		end_reaction()
	},
}

// === COUNTERATTACK ===

function goto_counterattack(c, a) {
	game.reacted = player_index()

	update_attack1()

	switch (a.effect)
	{
	default:
		throw new Error("invalid counterattack effect: " + a.effect)
	case "1 hit per die.":
		game.self += count_dice_on_card(c)
		break
	case "1 hit.":
		game.self += 1
		break
	case "1 hit. Additionally, this unit only suffers one hit.":
		game.self += 1
		game.hits = 1
		break
	case "1 hit. Additionally, this unit suffers one less hit per die.":
		game.self += 1
		game.hits = Math.max(0, game.hits - count_dice_on_card(c))
		break
	case "1 hit. Additionally, this unit suffers one less hit.":
		game.self += 1
		game.hits -= 1
		break
	case "This unit suffers ONE less hit and never more than one.":
		game.self += 1
		game.hits = clamp(game.hits - 1, 0, 1)
		break
	case "This unit suffers TWO less hits and never more than one.":
		game.self += 1
		game.hits = clamp(game.hits - 2, 0, 1)
		break
	}

	update_attack2()

	game.state = "counterattack"
}

states.counterattack = {
	prompt() {
		view.prompt = "Counterattack " + card_name(game.selected) + "."
		view.actions.counterattack = 1
	},
	counterattack() {
		log(card_name(game.target) + " counterattacked.")
		pay_for_action(game.target)
		end_reaction()
	},
}

// === ATTACK EFFECTS ===

function get_attack_hits(c, a) {
	switch (a.effect) {
		default:
			throw new Error("invalid attack effect: " + a.effect)
		case "1 hit.":
		case "1 hit. Warwick Retires upon completing this Attack Action.":
		case "1 hit. You CHOOSE the target.":
		case "1 hit. 1 self per action.":
		case "1 hit per action. 1 self per action.":
			return 1
		case "1 hit per die.":
		case "1 hit per die. 1 self per action.":
		case "1 hit per die. Ignore first target until it comes out of Reserve.":
		case "1 hit per die (but see below). 1 self per action.":
		case "1 hit per die (plus dice from E. Phalanx).":
		case "1 hit per die. 1 self per action. (But see Sharpshooters.)":
		case "1 hit per die. 1 self per action. (But see 4th Alabama.)":
		case "1 hit per die. 1 self per action. (But see Semmes.)":
		case "1 hit per die (also take dice from 141st Pennsylvania). 1 self per action.":
		case "1 hit per die (also take dice from 68th Pennsylvania). 1 self per action.":
		case "1 hit per die. 1 self per action. (But see William Fielding.)":
		case "1 hit per die (1 extra vs Essex). 1 self per action. (See W. Fielding.)":
		case "1 hit per die. 1 self per action (but see Cannons).":
		case "1 hit per die. 1 self per action (but see Bayonets!).":
		case "1 hit per die. 1 self per action. You CHOOSE the target.":
			return count_dice_on_card(c)
		case "1 hit per pair.":
		case "1 hit per pair. 1 self per action.":
			return count_dice_on_card(c) >> 1
		case "1 hit, PLUS 1 hit per die. 1 self per action.":
		case "1 hit, PLUS 1 hit per die. 1 self per action. Fightin' Irish!":
			return 1 + count_dice_on_card(c)
		case "2 hits, PLUS 1 hit per die. 1 self per action.":
			return 2 + count_dice_on_card(c)
		case "2 hits.":
			return 2
		case "5 hits.":
			return 5
	}
}

function get_attack_self(c, a) {
	switch (a.effect) {
		default:
			throw new Error("invalid attack effect: " + a.effect)
		case "1 hit.":
		case "1 hit. Warwick Retires upon completing this Attack Action.":
		case "1 hit. You CHOOSE the target.":
		case "1 hit per die.":
		case "1 hit per die. Ignore first target until it comes out of Reserve.":
		case "1 hit per die (plus dice from E. Phalanx).":
		case "1 hit per pair.":
		case "2 hits.":
		case "5 hits.":
			return 0
		case "1 hit. 1 self per action.":
		case "1 hit per action. 1 self per action.":
		case "1 hit per die. 1 self per action.":
		case "1 hit per die (but see below). 1 self per action.":
		case "1 hit per die. 1 self per action. (But see Sharpshooters.)":
		case "1 hit per die. 1 self per action. (But see 4th Alabama.)":
		case "1 hit per die. 1 self per action. (But see Semmes.)":
		case "1 hit per die (also take dice from 141st Pennsylvania). 1 self per action.":
		case "1 hit per die (also take dice from 68th Pennsylvania). 1 self per action.":
		case "1 hit per die. 1 self per action. (But see William Fielding.)":
		case "1 hit per die (1 extra vs Essex). 1 self per action. (See W. Fielding.)":
		case "1 hit per die. 1 self per action (but see Cannons).":
		case "1 hit per die. 1 self per action (but see Bayonets!).":
		case "1 hit per die. 1 self per action. You CHOOSE the target.":
		case "1 hit per pair. 1 self per action.":
		case "1 hit, PLUS 1 hit per die. 1 self per action.":
		case "1 hit, PLUS 1 hit per die. 1 self per action. Fightin' Irish!":
		case "2 hits, PLUS 1 hit per die. 1 self per action.":
			return 1
	}
}

// === ROUTING/PURSUIT/REMOVE/FORCE-RETIRE ===

function find_card_owner(c) {
	if (set_has(game.front[0], c) || set_has(game.reserve[0], c))
		return 0
	if (set_has(game.front[1], c) || set_has(game.reserve[1], c))
		return 1
	throw new Error("card not found in any player area")
}

function should_rout_card(c) {
	if (!data.cards[c].special) {
		if (get_sticks(c) === 0)
			return true
	}

	let rout_with = card_has_rule(c, "rout_with")
	if (rout_with) {
		for (let other of rout_with)
			if (!is_removed_from_play(other))
				return false
		return true
	}

	return false
}

function should_pursue(c) {
	let pursuit = data.cards[c].pursuit
	if (pursuit !== undefined)
		return !set_has(game.front[0], pursuit) && !set_has(game.front[1], pursuit)
	return false
}

function should_remove_card(c) {
	let remove_with = card_has_rule(c, "remove_with")
	if (remove_with) {
		for (let other of remove_with)
			if (!is_removed_from_play(other))
				return false
		return true
	}
	return false
}

function should_retire_card(c) {
	let retire_with = card_has_rule(c, "retire_with")
	if (retire_with) {
		for (let other of retire_with)
			if (!is_removed_from_play(other))
				return false
		return true
	}

	if (game.scenario === S25_WHEATFIELD) {
		if (c === S25_ZOOK || c === S25_KELLY) {
			if (is_card_in_play(S25_WOFFORD))
				return true
		}
	}

	return false
}

function goto_routing() {
	game.routed = [ 0, 0 ]

	if (game.scenario === S2_MARSTON_MOOR) {
		// TODO: pause with separate state?
		if (is_card_in_play(S2_RUPERTS_LIFEGUARD)) {
			if (should_rout_card(S2_NORTHERN_HORSE)) {
				log("Rupert's Lifeguard added to Northern Horse.")
				set_sticks(S2_NORTHERN_HORSE, 1)
				remove_card(S2_RUPERTS_LIFEGUARD)
			}
			if (should_rout_card(S2_BYRON)) {
				log("Rupert's Lifeguard added to Byron.")
				set_sticks(S2_BYRON, 1)
				remove_card(S2_RUPERTS_LIFEGUARD)
			}
		}
	}

	resume_routing()
}

function resume_routing() {
	game.state = "routing"

	for (let p = 0; p <= 1; ++p) {
		for (let c of game.front[p])
			if (should_rout_card(c) || should_remove_card(c) || should_retire_card(c) || should_pursue(c))
				return
		for (let c of game.reserve[p])
			if (should_rout_card(c) || should_remove_card(c) || should_retire_card(c))
				return
	}

	end_routing()
}

states.routing = {
	prompt() {
		view.prompt = "Routing: Remove routing and pursuing cards from play!"
		for (let p = 0; p <= 1; ++p) {
			for (let c of game.front[p])
				if (should_rout_card(c) || should_remove_card(c) || should_retire_card(c) || should_pursue(c))
					gen_action_card(c)
			for (let c of game.reserve[p])
				if (should_rout_card(c) || should_remove_card(c) || should_retire_card(c))
					gen_action_card(c)
		}
	},
	card(c) {
		if (should_rout_card(c)) {
			log(card_name(c) + " routed.")
			let p = find_card_owner(c)
			game.routed[p] += data.cards[c].morale
			rout_card(c)
		} else if (should_retire_card(c)) {
			log(card_name(c) + " retired.")
			retire_card(c)
		} else if (should_pursue(c)) {
			log(card_name(c) + " pursued.")
			pursue_card(c)
		} else {
			log(card_name(c) + " removed.")
			remove_card(c)
		}
		resume_routing()
	},
}

function end_routing() {
	// Normal morale loss and gain
	if (game.morale[0] > 0 && game.morale[1] > 0) {
		if ((game.routed[0] > 0 && !game.routed[1]) || (game.routed[1] > 0 && !game.routed[0])) {
			if (game.routed[0]) {
				game.routed[0] = Math.min(game.routed[0], game.morale[0])
				game.morale[0] -= game.routed[0]
				// do not gain for special scenarios
				if (game.morale[1] > 0)
					game.morale[1] += game.routed[0]
			} else {
				game.routed[1] = Math.min(game.routed[1], game.morale[1])
				game.morale[1] -= game.routed[1]
				// do not gain for special scenarios
				if (game.morale[0] > 0)
					game.morale[0] += game.routed[1]
			}
		}
		if (check_victory())
			return
	} else {
		// SPECIAL: S3 - Plains of Abraham
		// SPECIAL: S34 - Tippermuir - Royalists
		// SPECIAL: S35 - Auldearn - Royalists

		// Instant loss if any card routs for side at 0 morale (S3, S34, S35).
		if (game.morale[0] === 0 && game.routed[0])
			return goto_game_over(P2, P1 + " card routed!")
		if (game.morale[1] === 0 && game.routed[1])
			return goto_game_over(P1, P2 + " card routed!")

		// Remove instead of take cubes for side at 0 morale
		if (game.routed[0]) {
			game.morale[0] -= Math.min(game.routed[0], game.morale[0])
			if (game.morale[0] === 0)
				return goto_game_over(P2, P1 + " has run out of morale!")
		}
		if (game.routed[1]) {
			game.morale[1] -= Math.min(game.routed[1], game.morale[1])
			if (game.morale[1] === 0)
				return goto_game_over(P1, P2 + " has run out of morale!")
		}
	}

	game.routed = null

	goto_reserve()
}

// === RESERVE ===

function should_enter_reserve(c) {
	let reserve = data.cards[c].reserve

	if (game.scenario === S37_INKERMAN) {
		if (c === S37_BRITISH_TROOPS)
			return get_cubes(S37_THE_FOG) === 1
		if (c === S37_FRENCH_TROOPS)
			return get_cubes(S37_THE_FOG) === 0
	}

	if (Array.isArray(reserve)) {
		for (let t of reserve) {
			if (is_removed_from_play(t))
				return true
		}
	}

	if (game.scenario === S30_EDGEHILL) {
		if (c === S30_BALFOUR || c === S30_STAPLETON) {
			return is_removed_from_play(S30_RUPERT) && is_removed_from_play(S30_WILMOT)
		}
	}

	return false
}

function goto_reserve() {
	resume_reserve()
}

function resume_reserve() {
	game.state = "reserve"
	for (let p = 0; p <= 1; ++p)
		for (let c of game.reserve[p])
			if (should_enter_reserve(c))
				return
	end_reserve()
}

function end_reserve() {
	goto_roll_phase()
}

states.reserve = {
	prompt() {
		view.prompt = "Enter reserves!"
		for (let p = 0; p <= 1; ++p)
			for (let c of game.reserve[p])
				if (should_enter_reserve(c))
					gen_action_card(c)
	},
	card(c) {
		log(card_name(c) + " came out of reserve.")
		let p = find_card_owner(c)
		set_delete(game.reserve[p], c)
		set_add(game.front[p], c)
		resume_reserve()
	},
}

// === COMMON LIBRARY ===

function gen_action(action, argument) {
	if (!(action in view.actions))
		view.actions[action] = [ argument ]
	else
		set_add(view.actions[action], argument)
}

function gen_action_dice_on_card(c) {
	for (let d = 0; d < 12; ++d) {
		if (get_dice_location(d) === c) {
			gen_action_die(d)
			return
		}
	}
}

function gen_action_card(c) {
	gen_action("card", c)
}

function gen_action_die(d) {
	gen_action("die", d)
}

function gen_action_action1(c) {
	gen_action("a1", c)
}

function gen_action_action2(c) {
	gen_action("a2", c)
}

function gen_action_null1(c) {
	gen_action("n1", c)
}

function gen_action_null2(c) {
	gen_action("n2", c)
}

function gen_action_retire(c) {
	gen_action("retire", c)
}

function log(msg) {
	game.log.push(msg)
}

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

function push_undo() {
	if (game.undo) {
		let copy = {}
		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)
	}
}

function pop_undo() {
	if (game.undo) {
		let save_log = game.log
		let save_undo = game.undo
		game = save_undo.pop()
		save_log.length = game.log
		game.log = save_log
		game.undo = save_undo
	}
}

function random(range) {
	// Largest MLCG that will fit its state in a double.
	// Uses BigInt for arithmetic, so is an order of magnitude slower.
	// https://www.ams.org/journals/mcom/1999-68-225/S0025-5718-99-00996-5/S0025-5718-99-00996-5.pdf
	// m = 2**53 - 111
	return (game.seed = Number(BigInt(game.seed) * 5667072534355537n % 9007199254740881n)) % 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(array, index) {
	let n = array.length
	for (let i = index + 1; i < n; ++i)
		array[i - 1] = array[i]
	array.length = n - 1
}

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

function array_insert_pair(array, index, key, value) {
	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_has(set, item) {
	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
			return true
	}
	return false
}

function set_add(set, item) {
	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
			return
	}
	array_insert(set, a, item)
}

function set_delete(set, item) {
	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
		}
	}
}

// Map as plain sorted array of key/value pairs

function map_clear(map) {
	map.length = 0
}

function map_has(map, key) {
	let a = 0
	let b = (map.length >> 1) - 1
	while (a <= b) {
		let m = (a + b) >> 1
		let x = map[m<<1]
		if (key < x)
			b = m - 1
		else if (key > x)
			a = m + 1
		else
			return true
	}
	return false
}

function map_get(map, key, missing) {
	let a = 0
	let b = (map.length >> 1) - 1
	while (a <= b) {
		let m = (a + b) >> 1
		let x = map[m<<1]
		if (key < x)
			b = m - 1
		else if (key > x)
			a = m + 1
		else
			return map[(m<<1)+1]
	}
	return missing
}

function map_set(map, key, value) {
	let a = 0
	let b = (map.length >> 1) - 1
	while (a <= b) {
		let m = (a + b) >> 1
		let x = map[m<<1]
		if (key < x)
			b = m - 1
		else if (key > x)
			a = m + 1
		else {
			map[(m<<1)+1] = value
			return
		}
	}
	array_insert_pair(map, a<<1, key, value)
}