diff options
author | Tor Andersson <tor@ccxvii.net> | 2021-05-01 00:48:49 +0200 |
---|---|---|
committer | Tor Andersson <tor@ccxvii.net> | 2022-11-16 19:12:39 +0100 |
commit | fcab360a00988f56c113b2f824411ba18e4d9ae2 (patch) | |
tree | cd6f619b6c56a4d0e8ab843828cb4545e8f4859e /rules.js | |
download | richard-iii-fcab360a00988f56c113b2f824411ba18e4d9ae2.tar.gz |
richard: Import Richard III.
Diffstat (limited to 'rules.js')
-rw-r--r-- | rules.js | 3431 |
1 files changed, 3431 insertions, 0 deletions
diff --git a/rules.js b/rules.js new file mode 100644 index 0000000..3f6dd9d --- /dev/null +++ b/rules.js @@ -0,0 +1,3431 @@ +"use strict"; + +// TODO: execute enemy heirs during supply phase +// TODO: reuse supply and goes-home states for pretender and king + +// TODO: tweak block layout and positioning + +exports.scenarios = [ + "Wars of the Roses", + "Kingmaker", + "Richard III", +]; + +const { CARDS, BLOCKS, AREAS, BORDERS } = require('./data'); + +const LANCASTER = "Lancaster"; +const YORK = "York"; +const REBEL = "Rebel"; +const ENEMY = { Lancaster: "York", York: "Lancaster" } +const OBSERVER = "Observer"; +const BOTH = "Both"; +const POOL = "Pool"; +const MINOR = "Minor"; + +// serif cirled numbers +const DIE_HIT = [ 0, '\u2776', '\u2777', '\u2778', '\u2779', '\u277A', '\u277B' ]; +const DIE_MISS = [ 0, '\u2460', '\u2461', '\u2462', '\u2463', '\u2464', '\u2465' ]; + +const ATTACK_MARK = " *"; +const RESERVE_MARK = ""; + +let states = {}; + +let game = null; + +function log(...args) { + let s = Array.from(args).join(""); + game.log.push(s); +} + +function logp(...args) { + let s = game.active + " " + Array.from(args).join(""); + game.log.push(s); +} + +function log_move_start(from) { + game.turn_buf = [ from ]; +} + +function log_move_continue(to, mark) { + if (mark) + game.turn_buf.push(to + mark); + else + game.turn_buf.push(to); +} + +function log_move_end() { + if (game.turn_buf) { + game.turn_log.push(game.turn_buf); + delete game.turn_buf; + } +} + +function print_turn_log_no_count(text) { + function print_move(last) { + return "\n" + last.join(" \u2192 "); + } + if (game.turn_log.length > 0) { + game.turn_log.sort(); + for (let entry of game.turn_log) + text += print_move(entry); + } else { + text += "\nnothing."; + } + log(text); + delete game.turn_log; +} + +function print_turn_log(text) { + function print_move(last) { + return "\n" + n + " " + last.join(" \u2192 "); + } + game.turn_log.sort(); + let last = game.turn_log[0]; + let n = 0; + for (let entry of game.turn_log) { + if (entry.toString() != last.toString()) { + text += print_move(last); + n = 0; + } + ++n; + last = entry; + } + if (n > 0) + text += print_move(last); + else + text += "\nnothing."; + log(text); + delete game.turn_log; +} + +function is_active_player(current) { + return (current == game.active) || (game.active == BOTH && current != OBSERVER); +} + +function is_inactive_player(current) { + return current == OBSERVER || (game.active != current && game.active != BOTH); +} + +function remove_from_array(array, item) { + let i = array.indexOf(item); + if (i >= 0) + array.splice(i, 1); +} + +function clear_undo() { + game.undo = []; +} + +function push_undo() { + game.undo.push(JSON.stringify(game, (k,v) => { + if (k === 'undo') return undefined; + if (k === 'log') return v.length; + return v; + })); +} + +function pop_undo() { + let undo = game.undo; + let log = game.log; + Object.assign(game, JSON.parse(undo.pop())); + game.undo = undo; + log.length = game.log; + game.log = log; +} + +function gen_action_undo(view) { + if (!view.actions) + view.actions = {} + if (game.undo && game.undo.length > 0) + view.actions.undo = 1; + else + view.actions.undo = 0; +} + +function gen_action(view, action, argument) { + if (!view.actions) + view.actions = {} + if (argument != undefined) { + if (!(action in view.actions)) { + view.actions[action] = [ argument ]; + } else { + if (!view.actions[action].includes(argument)) + view.actions[action].push(argument); + } + } else { + view.actions[action] = 1; + } +} + +function roll_d6() { + return Math.floor(Math.random() * 6) + 1; +} + +function shuffle_deck() { + let deck = []; + for (let c = 1; c <= 25; ++c) + deck.push(c); + return deck; +} + +function deal_cards(deck, n) { + let hand = []; + for (let i = 0; i < n; ++i) { + let k = Math.floor(Math.random() * deck.length); + hand.push(deck[k]); + deck.splice(k, 1); + } + return hand; +} + +function count_ap(hand) { + let count = 0; + for (let c of hand) + count += CARDS[c].actions; + return count; +} + +function is_pretender_heir(who) { + return (block_owner(who) == block_owner(game.pretender) && block_type(who) == 'heir'); +} + +function is_royal_heir(who) { + return (block_owner(who) == block_owner(game.king) && block_type(who) == 'heir'); +} + +function is_dead(who) { + if (who in BLOCKS) + return !game.location[who]; + return !game.location[who+"/L"] && !game.location[who+"/Y"]; +} + +function is_shield_area_for(where, who, combat) { + let haystack = AREAS[where].shields; + let needle = BLOCKS[who].shield; + + // Nevilles going to exile in Calais + if (where == "Calais") { + if (who == "Warwick/L" || who == "Kent/L" || who == "Salisbury/L") + return false; + if (count_blocks_exclude_mercenaries("Calais") < 4) { + if (who == "Kent/Y") + return is_area_friendly_to("East Yorks", LANCASTER); + if (who == "Salisbury/Y") + return is_area_friendly_to("North Yorks", LANCASTER); + } + } + + // Exeter and Clarence as enemy nobles + if (who == "Exeter/Y") + return where == "Cornwall"; + if (who == "Clarence/L") + return (where == "South Yorks" || where == "Rutland" || where == "Hereford"); + + // Everyone can always use their own shield + if (haystack && haystack.includes(needle)) + return true; + + // Nevilles can use each other's shields if their owner is dead + if (is_neville(who)) { + if (is_dead("Warwick") && haystack.includes("Warwick")) + return true; + if (is_dead("Kent") && haystack.includes("Kent")) + return true; + if (is_dead("Salisbury") && haystack.includes("Salisbury")) + return true; + } + + // York heirs can use any York shield + if (is_heir(who) && block_owner(who) == YORK) { + if (haystack.includes("York")) + return !combat || find_senior_heir_in_area(YORK, where) == who; + } + + // Lancaster heirs can use each other's specific shields if their owner is dead + if (is_heir(who) && block_owner(who) == LANCASTER) { + let available = false; + if (haystack.includes("Lancaster")) + available = true; + if (is_dead("Exeter") && haystack.includes("Exeter")) + available = true; + if (is_dead("Somerset") && haystack.includes("Somerset")) + available = true; + if (is_dead("Richmond") && haystack.includes("Richmond")) + available = true; + if (available) + return !combat || find_senior_heir_in_area(LANCASTER, where) == who; + } + + return false; +} + +function is_at_home(who) { + let where = game.location[who]; + if (!where || where == MINOR || where == POOL) + return true; + if (is_pretender_heir(who)) + return is_exile_area(where); + if (is_royal_heir(who)) + return is_shield_area_for(where, who, false) || is_crown_area(where); + if (block_type(who) == 'nobles') + return is_shield_area_for(where, who, false); + if (block_type(who) == 'church') + return has_cathedral(where) == block_home(who); + return true; +} + +function is_in_exile(who) { + return is_exile_area(game.location[who]); +} + +function is_home_for(where, who) { + if (is_pretender_heir(who)) + return is_shield_area_for(where, who, false); + if (is_royal_heir(who)) + return is_crown_area(where) || is_shield_area_for(where, who, false); + if (block_type(who) == 'nobles') + return is_shield_area_for(where, who, false); + if (block_type(who) == 'church') + return block_home(who) == has_cathedral(where); + return false; +} + +function is_available_home_for(where, who) { + if (who == "Clarence/L") + return is_home_for(where, who) && is_vacant_area(where); + return is_home_for(where, who) && is_friendly_or_vacant_area(where); +} + +function count_available_homes(who) { + let count = 0; + for (let where in AREAS) + if (is_available_home_for(where, who)) + ++count; + return count; +} + +function available_home(who) { + for (let where in AREAS) + if (is_available_home_for(where, who)) + return where; +} + +function go_home_if_possible(who) { + if (!is_in_exile(who)) { + let n = count_available_homes(who); + if (n == 0) { + game.turn_log.push([block_name(who), "Pool"]); + disband(who); + } else if (n == 1) { + let home = available_home(who); + if (game.location[who] != home) { + game.location[who] = home; + game.turn_log.push([block_name(who), game.location[who]]); // TODO: "Home"? + } + } else { + return true; + } + } + return false; +} + +function is_on_map_not_in_exile_or_man(who) { + let where = game.location[who]; + return where && where != MINOR && + where != POOL && + where != "Isle of Man" && + !is_exile_area(where); +} + +function is_land_area(where) { + return where && where != MINOR && where != POOL && !is_sea_area(where); +} + +function is_area_friendly_to(where, owner) { + let save_active = game.active; + game.active = owner; + let result = is_friendly_area(where); + game.active = save_active; + return result; +} + +function is_london_friendly_to(owner) { + return is_area_friendly_to("Middlesex", owner); +} + +function count_lancaster_nobles() { + let count = 0; + for (let b in BLOCKS) + if (block_owner(b) == LANCASTER && + (block_type(b) == 'nobles' || block_type(b) == 'church')) + if (is_on_map_not_in_exile_or_man(b)) + ++count; + if (is_london_friendly_to(LANCASTER)) + ++count; + return count; +} + +function count_york_nobles() { + let count = 0; + for (let b in BLOCKS) + if (block_owner(b) == YORK && + (block_type(b) == 'nobles' || block_type(b) == 'church')) + if (is_on_map_not_in_exile_or_man(b)) + ++count; + if (is_london_friendly_to(YORK)) + ++count; + return count; +} + +function block_name(who) { + return BLOCKS[who].name; +} + +function block_type(who) { + return BLOCKS[who].type; +} + +function block_home(who) { + return BLOCKS[who].home; +} + +function block_owner(who) { + if (who == REBEL) + return block_owner(game.pretender); + return BLOCKS[who].owner; +} + +function block_initiative(who) { + if (block_type(who) == 'bombard') + return game.battle_round == 1 ? 'A' : 'D'; + return BLOCKS[who].combat[0]; +} + +function block_printed_fire_power(who) { + return BLOCKS[who].combat[1] | 0; +} + +function block_fire_power(who, where) { + let combat = block_printed_fire_power(who); + if (is_defender(who)) { + if (is_heir(who) && is_shield_area_for(where, who, true)) + ++combat; + if (is_crown_area(where) && is_senior_royal_heir_in(who, where)) + ++combat; + if (is_noble(who) && is_shield_area_for(where, who, true)) + ++combat; + if (is_church(who) && block_home(who) == has_cathedral(where)) + ++combat; + if (is_levy(who) && block_home(who) == has_city(where)) + ++combat; + if (who == "Welsh Mercenary" && is_wales(where)) + ++combat; + } + return combat; +} + +function is_mercenary(who) { + return BLOCKS[who].type == 'mercenaries'; +} + +function is_heir(who) { + return BLOCKS[who].type == 'heir'; +} + +function is_noble(who) { + return BLOCKS[who].type == 'nobles'; +} + +function is_church(who) { + return BLOCKS[who].type == 'church'; +} + +function is_levy(who) { + return BLOCKS[who].type == 'levies'; +} + +function is_rose_noble(who) { + return BLOCKS[who].type == 'nobles' && !BLOCKS[who].loyalty; +} + +function is_neville(who) { + let name = block_name(who); + return name == "Warwick" || name == "Kent" || name == "Salisbury"; +} + +function block_loyalty(source, target) { + let source_name = source ? block_name(source) : "Event"; + if (source_name == "Warwick") { + let target_name = block_name(target); + if (target_name == "Kent" || target_name == "Salisbury") + return 1; + if (target_name == "Northumberland" || target_name == "Westmoreland") + return 0; + } + return BLOCKS[target].loyalty | 0; +} + +function can_defect(source, target) { + // Clarence and Exeter can't defect if they are the king or pretender + if (target == game.king || target == game.pretender) + return false; + return block_loyalty(source, target) > 0 && !game.defected[target]; +} + +function can_attempt_treason_event() { + for (let b in BLOCKS) { + if (game.active == game.attacker[game.where]) { + if (is_defender(b) && can_defect(null, b)) + return true; + } else { + if (is_attacker(b) && can_defect(null, b)) + return true; + } + } + return false; +} + +function treachery_tag(who) { + if (who == game.king) return 'King'; + if (who == game.pretender) return 'Pretender'; + if (who == "Warwick/L" || who == "Warwick/Y") return 'Warwick'; + return game.active; +} + +function can_attempt_treachery(who) { + let once = treachery_tag(who); + if (game.battle_list.includes(who) && !game.treachery[once]) { + for (let b in BLOCKS) { + if (game.active == game.attacker[game.where]) { + if (is_defender(b) && can_defect(who, b)) + return true; + } else { + if (is_attacker(b) && can_defect(who, b)) + return true; + } + } + } + return false; +} + +function block_max_steps(who) { + return BLOCKS[who].steps; +} + +function can_activate(who) { + return block_owner(who) == game.active && !game.moved[who] && !game.dead[who]; +} + +function is_area_on_map(location) { + return location && location != MINOR && location != POOL; +} + +function is_block_on_map(b) { + return is_area_on_map(game.location[b]); +} + +function is_block_alive(b) { + return is_area_on_map(game.location[b]) && !game.dead[b]; +} + +function border_id(a, b) { + return (a < b) ? a + "/" + b : b + "/" + a; +} + +function border_was_last_used_by_enemy(from, to) { + return game.last_used[border_id(from, to)] == ENEMY[game.active]; +} + +function border_was_last_used_by_active(from, to) { + return game.last_used[border_id(from, to)] == game.active; +} + +function border_type(a, b) { + return BORDERS[border_id(a,b)]; +} + +function border_limit(a, b) { + return game.border_limit[border_id(a,b)] || 0; +} + +function reset_border_limits() { + game.border_limit = {}; +} + +function count_friendly(where) { + let p = game.active; + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == p && !game.dead[b]) + ++count; + return count; +} + +function count_enemy(where) { + let p = ENEMY[game.active]; + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == p && !game.dead[b]) + ++count; + return count; +} + +function count_enemy_excluding_reserves(where) { + let p = ENEMY[game.active]; + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == p) + if (!game.reserves.includes(b)) + ++count; + return count; +} + +function is_friendly_area(where) { return is_land_area(where) && count_friendly(where) > 0 && count_enemy(where) == 0; } +function is_enemy_area(where) { return is_land_area(where) && count_friendly(where) == 0 && count_enemy(where) > 0; } +function is_vacant_area(where) { return is_land_area(where) && count_friendly(where) == 0 && count_enemy(where) == 0; } +function is_contested_area(where) { return is_land_area(where) && count_friendly(where) > 0 && count_enemy(where) > 0; } +function is_friendly_or_vacant_area(where) { return is_friendly_area(where) || is_vacant_area(where); } + +function has_city(where) { + return AREAS[where].city; +} + +function has_cathedral(where) { + return AREAS[where].cathedral; +} + +function is_crown_area(where) { + return AREAS[where].crown; +} + +function is_major_port(where) { + return AREAS[where].major_port; +} + +function is_sea_area(where) { + return where == 'Irish Sea' || where == 'North Sea' || where == 'English Channel'; +} + +function is_wales(where) { + return where == "Caernarvon" || where == "Pembroke" || where == "Powys" || where == "Glamorgan"; +} + +function is_lancaster_exile_area(where) { + return where == "France" || where == "Scotland"; +} + +function is_york_exile_area(where) { + return where == "Calais" || where == "Ireland"; +} + +function is_exile_area(where) { + return is_lancaster_exile_area(where) || is_york_exile_area(where); +} + +function is_friendly_exile_area(where) { + return (game.active == LANCASTER) ? is_lancaster_exile_area(where) : is_york_exile_area(where); +} + +function is_enemy_exile_area(where) { + return (game.active == YORK) ? is_lancaster_exile_area(where) : is_york_exile_area(where); +} + +function is_pretender_exile_area(where) { + return (game.pretender == LANCASTER) ? is_lancaster_exile_area(where) : is_york_exile_area(where); +} + +function can_recruit_to(who, to) { + if (who == "Welsh Mercenary") + return is_wales(to) && is_friendly_or_vacant_area(to); + switch (block_type(who)) { + case 'heir': + // Not in rulebook, but they can be disbanded to the pool during exile limit check... + // Use same rules as entering a minor noble. + if (block_owner(who) == block_owner(game.king)) + return is_crown_area(to) && is_friendly_or_vacant_area(to); + else + return is_pretender_exile_area(to); + case 'nobles': + return is_shield_area_for(to, who, false) && is_friendly_or_vacant_area(to); + case 'church': + return block_home(who) == has_cathedral(to) && is_friendly_or_vacant_area(to); + case 'levies': + return block_home(who) == has_city(to) && is_friendly_or_vacant_area(to); + case 'bombard': + return has_city(to) && is_friendly_area(to); + case 'rebel': + return !is_exile_area(to) && is_vacant_area(to); + } + return false; +} + +function can_recruit(who) { + // Move one group events: + if (game.active == game.force_march) return false; + if (game.active == game.surprise) return false; + if (game.active == game.treason) return false; + + // Must use AP for sea moves: + if (game.active == game.piracy) return false; + + if (can_activate(who) && game.location[who] == POOL) + for (let to in AREAS) + if (can_recruit_to(who, to)) + return true; + return false; +} + +function have_contested_areas() { + for (let where in AREAS) + if (is_area_on_map(where) && is_contested_area(where)) + return true; + return false; +} + +function count_pinning(where) { + return count_enemy_excluding_reserves(where); +} + +function count_pinned(where) { + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == game.active) + if (!game.reserves.includes(b)) + ++count; + return count; +} + +function is_pinned(who, from) { + if (game.active == game.p2) { + if (count_pinned(from) <= count_pinning(from)) + return true; + } + return false; +} + +function can_block_sea_move_to(who, from, to) { + if (is_enemy_exile_area(to)) + return false; + if (game.active == game.force_march) + return false; + if (who == REBEL || who == "Scots Mercenary" || who == "Welsh Mercenary") + return false; + if (block_type(who) == 'bombard' || block_type(who) == 'levies') + return false; + if (border_type(from, to) == 'sea') + return true; + return false; +} + +function can_block_sea_move(who) { + if (can_activate(who)) { + let from = game.location[who]; + if (from) { + if (is_pinned(who, from)) + return false; + for (let to of AREAS[from].exits) + if (can_block_sea_move_to(who, from, to)) + return true; + } + } + return false; +} + +function can_block_use_border(who, from, to) { + if (game.active == game.surprise) { + switch (border_type(from, to)) { + case 'major': return border_limit(from, to) < 5; + case 'river': return border_limit(from, to) < 4; + case 'minor': return border_limit(from, to) < 3; + case 'sea': return false; + } + } else { + switch (border_type(from, to)) { + case 'major': return border_limit(from, to) < 4; + case 'river': return border_limit(from, to) < 3; + case 'minor': return border_limit(from, to) < 2; + case 'sea': return false; + } + } +} + +function count_borders_crossed(to) { + let count = 0; + for (let from of AREAS[to].exits) + if (border_was_last_used_by_active(from, to)) + ++count; + return count; +} + +function can_block_land_move_to(who, from, to) { + if (is_enemy_exile_area(to)) + return false; + if (game.active == game.piracy) + return false; + if (can_block_use_border(who, from, to)) { + // limit number of borders used to attack/reinforce + let contested = is_contested_area(to); + if (contested && !border_was_last_used_by_active(from, to)) { + // p1 or p2 attacking + if (game.attacker[to] == game.active) { + if (count_borders_crossed(to) >= 3) + return false; + } + if (game.active == game.p2) { + // p2 reinforcing battle started by p1 + if (game.attacker[to] == game.p1) { + if (count_borders_crossed(to) >= 2) + return false; + } + } + } + if (count_pinning(from) > 0) + if (border_was_last_used_by_enemy(from, to)) + return false; + return true; + } + return false; +} + +function can_block_land_move(who) { + if (can_activate(who)) { + let from = game.location[who]; + if (from) { + if (is_pinned(who, from)) + return false; + for (let to of AREAS[from].exits) + if (can_block_land_move_to(who, from, to)) + return true; + } + } + return false; +} + +function can_block_continue(who, from, to) { + if (is_contested_area(to)) + return false; + if (border_type(from, to) == 'minor') + return false; + if (game.active == game.force_march) { + if (game.distance >= 3) + return false; + } else { + if (game.distance >= 2) + return false; + } + if (to == game.last_from) + return false; + return true; +} + +function can_block_retreat_to(who, to) { + if (is_friendly_area(to) || is_vacant_area(to)) { + let from = game.location[who]; + if (can_block_use_border(who, from, to)) { + if (border_was_last_used_by_enemy(from, to)) + return false; + return true; + } + } + return false; +} + +function can_block_regroup_to(who, to) { + if (is_friendly_area(to) || is_vacant_area(to)) { + let from = game.location[who]; + if (can_block_use_border(who, from, to)) + return true; + } + return false; +} + +function can_block_regroup(who) { + if (block_owner(who) == game.active) { + let from = game.location[who]; + for (let to of AREAS[from].exits) + if (can_block_regroup_to(who, to)) + return true; + } + return false; +} + +function can_block_muster_via(who, from, next, muster) { + if (can_block_land_move_to(who, from, next) && is_friendly_or_vacant_area(next)) { + if (next == muster) + return true; + if (border_type(from, next) != 'minor') { + if (AREAS[next].exits.includes(muster)) + if (can_block_land_move_to(who, next, muster)) + return true; + } + } +} + +function can_block_muster(who, muster) { + let from = game.location[who]; + if (from == muster) + return false; + if (can_activate(who) && is_block_on_map(who)) { + if (is_pinned(who, from)) + return false; + for (let next of AREAS[from].exits) + if (can_block_muster_via(who, from, next, muster)) + return true; + } + return false; +} + +function can_muster_to(muster) { + for (let b in BLOCKS) + if (can_block_muster(b, muster)) + return true; + return false; +} + +function is_battle_reserve(who) { + return game.reserves.includes(who); +} + +function is_attacker(who) { + if (game.location[who] == game.where && block_owner(who) == game.attacker[game.where] && !game.dead[who]) + return !game.reserves.includes(who); + return false; +} + +function is_defender(who) { + if (game.location[who] == game.where && block_owner(who) != game.attacker[game.where] && !game.dead[who]) + return !game.reserves.includes(who); + return false; +} + +function swap_blocks(a) { + let b = BLOCKS[a].enemy; + game.location[b] = game.location[a]; + game.steps[b] = game.steps[a]; + game.location[a] = null; + game.steps[a] = block_max_steps(a); + return b; +} + +function disband(who) { + game.location[who] = POOL; + game.steps[who] = block_max_steps(who); +} + +function check_instant_victory() { + if (is_dead("York") && is_dead("March") && is_dead("Rutland") && is_dead("Clarence") && is_dead("Gloucester")) { + log("All York heirs are dead!"); + game.victory = "Lancaster wins by eliminating all enemy heirs!"; + game.result = LANCASTER; + } + if (is_dead("Henry VI") && is_dead("Prince Edward") && is_dead("Exeter") && is_dead("Somerset") && is_dead("Richmond")) { + log("All Lancaster heirs are dead!"); + game.victory = "York wins by eliminating all enemy heirs!"; + game.result = YORK; + } +} + +function eliminate_block(who) { + log(block_name(who) + " is eliminated."); + game.flash += " " + block_name(who) + " is eliminated."; + if (who == "Exeter/Y") { + game.location[who] = null; + ++game.killed_heirs[LANCASTER]; + return check_instant_victory(); + } + if (who == "Clarence/L") { + game.location[who] = null; + ++game.killed_heirs[YORK]; + return check_instant_victory(); + } + if (is_heir(who)) { + game.location[who] = null; + ++game.killed_heirs[block_owner(who)]; + if (who == game.pretender) + game.pretender = find_senior_heir(block_owner(game.pretender)); + // A new King is only crowned in the supply phase. + return check_instant_victory(); + } + if (is_rose_noble(who) || is_neville(who)) { + game.location[who] = null; + return; + } + if (is_mercenary(who)) { + switch (who) { + case "Welsh Mercenary": game.location[who] = POOL; break; + case "Irish Mercenary": game.location[who] = "Ireland"; break; + case "Burgundian Mercenary": game.location[who] = "Calais"; break; + case "Calais Mercenary": game.location[who] = "Calais"; break; + case "Scots Mercenary": game.location[who] = "Scotland"; break; + case "French Mercenary": game.location[who] = "France"; break; + } + game.steps[who] = block_max_steps(who); + game.dead[who] = true; + return; + } + game.location[who] = POOL; + game.steps[who] = block_max_steps(who); + game.dead[who] = true; +} + +function reduce_block(who) { + if (game.steps[who] == 1) { + eliminate_block(who); + } else { + --game.steps[who]; + } +} + +function count_attackers() { + let count = 0; + for (let b in BLOCKS) + if (is_attacker(b)) + ++count; + return count; +} + +function count_defenders() { + let count = 0; + for (let b in BLOCKS) + if (is_defender(b)) + ++count; + return count; +} + +function count_blocks_exclude_mercenaries(where) { + let count = 0; + for (let b in BLOCKS) + if (!(game.reduced && game.reduced[b]) && game.location[b] == where && !is_mercenary(b)) + ++count; + return count; +} + +function count_blocks(where) { + let count = 0; + for (let b in BLOCKS) + if (!(game.reduced && game.reduced[b]) && game.location[b] == where) + ++count; + return count; +} + +function add_blocks_exclude_mercenaries(list, where) { + for (let b in BLOCKS) + if (!(game.reduced && game.reduced[b]) && game.location[b] == where && !is_mercenary(b)) + list.push(b); +} + +function add_blocks(list, where) { + for (let b in BLOCKS) + if (!(game.reduced && game.reduced[b]) && game.location[b] == where) + list.push(b); +} + +function check_supply_penalty() { + game.supply = []; + for (let where in AREAS) { + if (is_friendly_area(where)) { + if (where == "Calais" || where == "France") { + if (count_blocks_exclude_mercenaries(where) > 4) + add_blocks_exclude_mercenaries(game.supply, where); + } else if (where == "Ireland" || where == "Scotland") { + if (count_blocks_exclude_mercenaries(where) > 2) + add_blocks_exclude_mercenaries(game.supply, where); + } else if (has_city(where)) { + if (count_blocks(where) > 5) + add_blocks(game.supply, where); + } else { + if (count_blocks(where) > 4) + add_blocks(game.supply, where); + } + } + } + return game.supply.length > 0; +} + +function check_exile_limits() { + game.exiles = []; + for (let where in AREAS) { + if (is_friendly_area(where)) { + if (where == "Calais" || where == "France") { + if (count_blocks_exclude_mercenaries(where) > 4) + add_blocks_exclude_mercenaries(game.exiles, where); + } else if (where == "Ireland" || where == "Scotland") { + if (count_blocks_exclude_mercenaries(where) > 2) + add_blocks_exclude_mercenaries(game.exiles, where); + } + } + } + if (game.exiles.length > 0) + return true; + delete game.exiles; + return false; +} + +// SETUP + +function find_block(owner, name) { + if (name in BLOCKS) + return name; + name = name + "/" + owner[0]; + if (name in BLOCKS) + return name; + throw new Error("Block not found: " + name); +} + +function deploy(who, where) { + if (where == "Enemy") + return; + if (!(where in AREAS)) + throw new Error("Area not found: " + where); + game.location[who] = where; + game.steps[who] = BLOCKS[who].steps; +} + +function deploy_lancaster(name, where) { + deploy(find_block(LANCASTER, name), where); +} + +function deploy_york(name, where) { + deploy(find_block(YORK, name), where); +} + +function reset_blocks() { + for (let b in BLOCKS) { + game.location[b] = null; + game.steps[b] = block_max_steps(b); + } +} + +function setup_game() { + reset_blocks(); + + game.campaign = 1; + game.end_campaign = 3; + game.pretender = "York"; + game.king = "Henry VI"; + + deploy_lancaster("Henry VI", "Middlesex"); + deploy_lancaster("Somerset", "Dorset"); + deploy_lancaster("Exeter", "Cornwall"); + deploy_lancaster("Devon", "Cornwall"); + deploy_lancaster("Pembroke", "Pembroke"); + deploy_lancaster("Wiltshire", "Wilts"); + deploy_lancaster("Oxford", "Essex"); + deploy_lancaster("Beaumont", "Lincoln"); + deploy_lancaster("Clifford", "North Yorks"); + deploy_lancaster("French Mercenary", "France"); + deploy_lancaster("Scots Mercenary", "Scotland"); + deploy_lancaster("Buckingham", "Pool"); + deploy_lancaster("Northumberland", "Pool"); + deploy_lancaster("Shrewsbury", "Pool"); + deploy_lancaster("Westmoreland", "Pool"); + deploy_lancaster("Rivers", "Pool"); + deploy_lancaster("Stanley", "Pool"); + deploy_lancaster("Bristol (levy)", "Pool"); + deploy_lancaster("Coventry (levy)", "Pool"); + deploy_lancaster("Newcastle (levy)", "Pool"); + deploy_lancaster("York (levy)", "Pool"); + deploy_lancaster("York (church)", "Pool"); + deploy_lancaster("Bombard", "Pool"); + deploy_lancaster("Welsh Mercenary", "Pool"); + deploy_lancaster("Prince Edward", "Minor"); + deploy_lancaster("Richmond", "Minor"); + deploy_lancaster("Canterbury (church)", "Enemy"); + deploy_lancaster("Clarence", "Enemy"); + deploy_lancaster("Warwick", "Enemy"); + deploy_lancaster("Salisbury", "Enemy"); + deploy_lancaster("Kent", "Enemy"); + + deploy_york("York", "Ireland"); + deploy_york("Rutland", "Ireland"); + deploy_york("Irish Mercenary", "Ireland"); + deploy_york("March", "Calais"); + deploy_york("Warwick", "Calais"); + deploy_york("Salisbury", "Calais"); + deploy_york("Kent", "Calais"); + deploy_york("Calais Mercenary", "Calais"); + deploy_york("Burgundian Mercenary", "Calais"); + deploy_york("Norfolk", "Pool"); + deploy_york("Suffolk", "Pool"); + deploy_york("Arundel", "Pool"); + deploy_york("Essex", "Pool"); + deploy_york("Worcester", "Pool"); + deploy_york("Hastings", "Pool"); + deploy_york("Herbert", "Pool"); + deploy_york("Canterbury (church)", "Pool"); + deploy_york("London (levy)", "Pool"); + deploy_york("Norwich (levy)", "Pool"); + deploy_york("Salisbury (levy)", "Pool"); + deploy_york("Bombard", "Pool"); + deploy_york("Rebel", "Pool"); + deploy_york("Clarence", "Minor"); + deploy_york("Gloucester", "Minor"); + deploy_york("Exeter", "Enemy"); + deploy_york("Buckingham", "Enemy"); + deploy_york("Northumberland", "Enemy"); + deploy_york("Westmoreland", "Enemy"); + deploy_york("Shrewsbury", "Enemy"); + deploy_york("Rivers", "Enemy"); + deploy_york("Stanley", "Enemy"); + deploy_york("York (church)", "Enemy"); +} + +function setup_kingmaker() { + reset_blocks(); + + game.campaign = 2; + game.end_campaign = 2; + game.pretender = "Henry VI"; + game.king = "March"; + + deploy_york("March", "Middlesex"); + deploy_york("Gloucester", "South Yorks"); + deploy_york("Buckingham", "Warwick"); + deploy_york("Norfolk", "East Anglia"); + deploy_york("Suffolk", "East Anglia"); + deploy_york("Arundel", "Sussex"); + deploy_york("Essex", "Essex"); + deploy_york("Hastings", "Leicester"); + deploy_york("Rivers", "Leicester"); + deploy_york("Stanley", "Lancaster"); + deploy_york("Irish Mercenary", "Ireland"); + deploy_york("Calais Mercenary", "Calais"); + deploy_york("Burgundian Mercenary", "Calais"); + deploy_york("Northumberland", "Pool"); + deploy_york("Westmoreland", "Pool"); + deploy_york("Canterbury (church)", "Pool"); + deploy_york("Bombard", "Pool"); + deploy_york("London (levy)", "Pool"); + deploy_york("Norwich (levy)", "Pool"); + deploy_york("Salisbury (levy)", "Pool"); + deploy_york("Warwick", "Enemy"); + deploy_york("Clarence", "Enemy"); + deploy_york("Shrewsbury", "Enemy"); + deploy_york("York (church)", "Enemy"); + deploy_york("Exeter", "Enemy"); + + deploy_lancaster("Henry VI", "Middlesex"); + deploy_lancaster("Prince Edward", "France"); + deploy_lancaster("Exeter", "France"); + deploy_lancaster("Warwick", "France"); + deploy_lancaster("Clarence", "France"); + deploy_lancaster("Oxford", "France"); + deploy_lancaster("French Mercenary", "France"); + deploy_lancaster("Scots Mercenary", "Scotland"); + deploy_lancaster("Pembroke", "Pool"); + deploy_lancaster("Shrewsbury", "Pool"); + deploy_lancaster("York (church)", "Pool"); + deploy_lancaster("Welsh Mercenary", "Pool"); + deploy_lancaster("Bombard", "Pool"); + deploy_lancaster("Bristol (levy)", "Pool"); + deploy_lancaster("Coventry (levy)", "Pool"); + deploy_lancaster("Newcastle (levy)", "Pool"); + deploy_lancaster("York (levy)", "Pool"); + deploy_lancaster("Rebel", "Pool"); + deploy_lancaster("Richmond", "Minor"); + deploy_lancaster("Buckingham", "Enemy"); + deploy_lancaster("Northumberland", "Enemy"); + deploy_lancaster("Rivers", "Enemy"); + deploy_lancaster("Westmoreland", "Enemy"); + deploy_lancaster("Stanley", "Enemy"); + deploy_lancaster("Canterbury (church)", "Enemy"); + + // Prisoner! + game.dead["Henry VI"] = true; +} + +function setup_richard_iii() { + reset_blocks(); + + game.campaign = 3; + game.end_campaign = 3; + game.pretender = "Richmond"; + game.king = "Gloucester"; + + deploy_york("Gloucester", "Middlesex"); + deploy_york("Norfolk", "East Anglia"); + deploy_york("Suffolk", "East Anglia"); + deploy_york("Arundel", "Sussex"); + deploy_york("Essex", "Essex"); + deploy_york("Northumberland", "Northumbria"); + deploy_york("Stanley", "Lancaster"); + deploy_york("Irish Mercenary", "Ireland"); + deploy_york("Calais Mercenary", "Calais"); + deploy_york("Burgundian Mercenary", "Calais"); + deploy_york("Westmoreland", "Pool"); + deploy_york("Canterbury (church)", "Pool"); + deploy_york("York (church)", "Pool"); + deploy_york("Bombard", "Pool"); + deploy_york("London (levy)", "Pool"); + deploy_york("Norwich (levy)", "Pool"); + deploy_york("Salisbury (levy)", "Pool"); + deploy_york("Buckingham", "Enemy"); + deploy_york("Shrewsbury", "Enemy"); + deploy_york("Rivers", "Enemy"); + + deploy_lancaster("Richmond", "France"); + deploy_lancaster("Oxford", "France"); + deploy_lancaster("Pembroke", "France"); + deploy_lancaster("French Mercenary", "France"); + deploy_lancaster("Scots Mercenary", "Scotland"); + deploy_lancaster("Buckingham", "Glamorgan"); + deploy_lancaster("Rivers", "Leicester"); + deploy_lancaster("Shrewsbury", "Pool"); + deploy_lancaster("Welsh Mercenary", "Pool"); + deploy_lancaster("Bombard", "Pool"); + deploy_lancaster("Bristol (levy)", "Pool"); + deploy_lancaster("Coventry (levy)", "Pool"); + deploy_lancaster("Newcastle (levy)", "Pool"); + deploy_lancaster("York (levy)", "Pool"); + deploy_lancaster("Rebel", "Pool"); + deploy_lancaster("Northumberland", "Enemy"); + deploy_lancaster("Westmoreland", "Enemy"); + deploy_lancaster("Stanley", "Enemy"); + deploy_lancaster("Canterbury (church)", "Enemy"); + deploy_lancaster("York (church)", "Enemy"); +} + +// Kingmaker scenario special rule +function free_henry_vi() { + if (game.dead["Henry VI"]) { + if ((game.active == LANCASTER && is_friendly_area("Middlesex")) || + (game.active == YORK && is_enemy_area("Middlesex"))) { + log("Henry VI is rescued!"); + delete game.dead["Henry VI"]; + } + } +} + +// GAME TURN + +function start_campaign() { + log(""); + log("Start Campaign " + game.campaign + "."); + + // TODO: Use board game mulligan rules instead of automatically redealing? + do { + let deck = shuffle_deck(); + game.l_hand = deal_cards(deck, 7); + game.y_hand = deal_cards(deck, 7); + } while (count_ap(game.l_hand) <= 13 || count_ap(game.y_hand) <= 13); + + start_game_turn(); +} + +function start_game_turn() { + log(""); + log("Start Turn " + (8-game.l_hand.length) + " of campaign " + game.campaign + "."); + + // Reset movement and attack tracking state + reset_border_limits(); + game.last_used = {}; + game.attacker = {}; + game.reserves = []; + game.moved = {}; + + goto_card_phase(); +} + +function end_game_turn() { + delete game.force_march; + delete game.piracy; + delete game.is_pirate; + delete game.surprise; + delete game.treason; + + if (game.l_hand.length > 0) + start_game_turn() + else + goto_political_turn(); +} + +// CARD PHASE + +function goto_card_phase() { + game.l_card = 0; + game.y_card = 0; + game.show_cards = false; + game.state = 'play_card'; + game.active = BOTH; +} + +function resume_play_card() { + if (game.l_card > 0 && game.y_card > 0) + reveal_cards(); + else if (game.l_card > 0) + game.active = YORK; + else if (game.y_card > 0) + game.active = LANCASTER; + else + game.active = BOTH; +} + +states.play_card = { + prompt: function (view, current) { + if (current == OBSERVER) + return view.prompt = "Waiting for players to play a card."; + if (current == LANCASTER) { + if (game.l_card) { + view.prompt = "Waiting for York to play a card."; + gen_action(view, 'undo'); + } else { + view.prompt = "Play a card."; + for (let c of game.l_hand) + gen_action(view, 'play', c); + } + } + if (current == YORK) { + if (game.y_card) { + view.prompt = "Waiting for Lancaster to play a card."; + gen_action(view, 'undo'); + } else { + view.prompt = "Play a card."; + for (let c of game.y_hand) + gen_action(view, 'play', c); + } + } + }, + play: function (card, current) { + if (current == LANCASTER) { + remove_from_array(game.l_hand, card); + game.l_card = card; + } + if (current == YORK) { + remove_from_array(game.y_hand, card); + game.y_card = card; + } + resume_play_card(); + }, + undo: function (_, current) { + if (current == LANCASTER) { + game.l_hand.push(game.l_card); + game.l_card = 0; + } + if (current == YORK) { + game.y_hand.push(game.y_card); + game.y_card = 0; + } + resume_play_card(); + } +} + +function reveal_cards() { + log("Lancaster plays " + CARDS[game.l_card].name + "."); + log("York plays " + CARDS[game.y_card].name + "."); + game.show_cards = true; + + let pretender = block_owner(game.pretender); + + let lc = CARDS[game.l_card]; + let yc = CARDS[game.y_card]; + + let lp = (lc.event ? 10 : 0) + lc.actions * 2 + (pretender == LANCASTER ? 1 : 0); + let yp = (yc.event ? 10 : 0) + yc.actions * 2 + (pretender == YORK ? 1 : 0); + + if (lp > yp) { + game.p1 = LANCASTER; + game.p2 = YORK; + } else { + game.p1 = YORK; + game.p2 = LANCASTER; + } + + game.active = game.p1; + start_player_turn(); +} + +function start_player_turn() { + log(""); + log("Start " + game.active + " turn."); + reset_border_limits(); + let lc = CARDS[game.l_card]; + let yc = CARDS[game.y_card]; + if (game.active == LANCASTER && lc.event) + goto_event_card(lc.event); + else if (game.active == YORK && yc.event) + goto_event_card(yc.event); + else if (game.active == LANCASTER) + goto_action_phase(lc.actions); + else if (game.active == YORK) + goto_action_phase(yc.actions); +} + +function end_player_turn() { + game.moves = 0; + game.activated = null; + game.move_port = null; + game.main_border = null; + + // Remove "Surprise" road limit bonus for retreats and regroups. + delete game.surprise; + + if (game.active == game.p2) { + goto_battle_phase(); + } else { + game.active = game.p2; + start_player_turn(); + } +} + +// EVENTS + +function goto_event_card(event) { + switch (event) { + case 'force_march': + game.force_march = game.active; + goto_action_phase(1); + break; + case 'muster': + goto_muster_event(); + break; + case 'piracy': + game.piracy = game.active; + game.is_pirate = {}; + goto_action_phase(2); + break; + case 'plague': + game.state = 'plague_event'; + break; + case 'surprise': + game.surprise = game.active; + goto_action_phase(1); + break; + case 'treason': + game.treason = game.active; + goto_action_phase(1); + break; + } +} + +states.plague_event = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Plague: Waiting for " + game.active + " to choose a city."; + view.prompt = "Plague: Choose an enemy city area."; + gen_action(view, 'pass'); + for (let where in AREAS) + if (is_enemy_area(where) && has_city(where)) + gen_action(view, 'area', where); + }, + area: function (where) { + log("Plague ravages " + has_city(where) + "!"); + for (let b in BLOCKS) { + if (game.location[b] == where) + reduce_block(b); + } + end_player_turn(); + }, + pass: function () { + end_player_turn(); + } +} + +function goto_muster_event() { + game.state = 'muster_event'; + game.turn_log = []; + clear_undo(); +} + +states.muster_event = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Muster: Waiting for " + game.active + " to muster."; + view.prompt = "Muster: Choose one friendly or vacant muster area."; + gen_action_undo(view); + gen_action(view, 'end_action_phase'); + for (let where in AREAS) { + if (is_friendly_or_vacant_area(where)) + if (can_muster_to(where)) + gen_action(view, 'area', where); + } + }, + area: function (where) { + game.where = where; + game.state = 'muster_who'; + }, + end_action_phase: function () { + clear_undo(); + print_turn_log(game.active + " musters:"); + end_player_turn(); + }, + undo: pop_undo, +} + +states.muster_who = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Muster: Waiting for " + game.active + " to muster."; + view.prompt = "Muster: Move blocks to the designated muster area."; + gen_action_undo(view); + gen_action(view, 'end_action_phase'); + for (let b in BLOCKS) + if (can_block_muster(b, game.where)) + gen_action(view, 'block', b); + }, + block: function (who) { + game.who = who; + game.state = 'muster_move_1'; + }, + end_action_phase: function () { + game.where = null; + clear_undo(); + print_turn_log(game.active + " musters:"); + end_player_turn(); + }, + undo: pop_undo, +} + +states.muster_move_1 = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Muster: Waiting for " + game.active + " to muster."; + view.prompt = "Muster: Move " + block_name(game.who) + " to the designated muster area."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + let from = game.location[game.who]; + for (let to of AREAS[from].exits) { + if (can_block_muster_via(game.who, from, to, game.where)) + gen_action(view, 'area', to); + } + }, + block: function () { + game.who = null; + game.state = 'muster_who'; + }, + area: function (to) { + let from = game.location[game.who]; + log_move_start(from); + log_move_continue(to); + move_block(game.who, from, to); + if (to == game.where) { + log_move_end(); + game.moved[game.who] = true; + game.who = null; + game.state = 'muster_who'; + } else { + game.state = 'muster_move_2'; + } + }, + undo: pop_undo, +} + +states.muster_move_2 = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Muster: Waiting for " + game.active + " to muster."; + view.prompt = "Muster: Move " + block_name(game.who) + " to the designated muster area."; + gen_action_undo(view); + gen_action(view, 'area', game.where); + }, + area: function (to) { + log_move_continue(to); + log_move_end(); + move_block(game.who, game.location[game.who], to); + game.moved[game.who] = true; + game.who = null; + game.state = 'muster_who'; + }, + undo: pop_undo, +} + +// ACTION PHASE + +function use_border(from, to) { + game.border_limit[border_id(from, to)] = border_limit(from, to) + 1; +} + +function move_block(who, from, to) { + game.location[who] = to; + use_border(from, to); + game.distance ++; + if (is_contested_area(to)) { + game.last_used[border_id(from, to)] = game.active; + if (!game.attacker[to]) { + game.attacker[to] = game.active; + game.main_border[to] = from; + } else { + if (game.attacker[to] != game.active || game.main_border[to] != from) { + game.reserves.push(who); + return RESERVE_MARK; + } + } + return ATTACK_MARK; + } + return ""; +} + +function goto_action_phase(moves) { + game.state = 'action_phase'; + game.moves = moves; + game.activated = []; + game.move_port = {}; + game.main_border = {}; + game.turn_log = []; + game.recruit_log = []; + clear_undo(); +} + +states.action_phase = { + prompt: function (view, current) { + if (is_inactive_player(current)) { + if (game.active == game.piracy) + return view.prompt = "Piracy: Waiting for " + game.active + "."; + if (game.active == game.force_march) + return view.prompt = "Force March: Waiting for " + game.active + "."; + if (game.active == game.surprise) + return view.prompt = "Surprise: Waiting for " + game.active + "."; + if (game.active == game.treason) + return view.prompt = "Treason: Waiting for " + game.active + "."; + else + return view.prompt = "Action Phase: Waiting for " + game.active + "."; + } + + if (game.active == game.piracy) { + view.prompt = "Piracy: Choose an army to sea move. Attacking is allowed. " + game.moves + "AP left."; + } else if (game.active == game.force_march) { + view.prompt = "Force March: Move one group. Blocks can move up to 3 areas and may attack."; + } else if (game.active == game.surprise) { + view.prompt = "Surprise: Move one group. Border limit is +1 to cross all borders."; + } else if (game.active == game.treason) { + view.prompt = "Treason: Move one group."; + } else { + view.prompt = "Action Phase: Choose an army to move or recruit. " + game.moves + "AP left."; + } + + gen_action_undo(view); + gen_action(view, 'end_action_phase'); + for (let b in BLOCKS) { + let from = game.location[b]; + if (can_recruit(b)) { + if (game.moves > 0) + gen_action(view, 'block', b); + } + if (can_block_land_move(b)) { + if (game.moves == 0) { + if (game.activated.includes(from)) + gen_action(view, 'block', b); + } else { + gen_action(view, 'block', b); + } + } + if (can_block_sea_move(b)) { + if (game.moves == 0) { + if (game.move_port[game.location[b]]) + gen_action(view, 'block', b); + } else { + gen_action(view, 'block', b); + } + } + } + }, + block: function (who) { + push_undo(); + game.who = who; + game.origin = game.location[who]; + if (game.origin == POOL) { + game.state = 'recruit_where'; + } else { + game.distance = 0; + game.last_from = null; + game.state = 'move_to'; + } + }, + end_action_phase: function () { + if (game.turn_log.length > 0) + print_turn_log(game.active + " moves:"); + game.turn_log = game.recruit_log; + if (game.turn_log.length > 0) + print_turn_log(game.active + " recruits:"); + game.turn_log = null; + game.recruit_log = null; + + clear_undo(); + game.moves = 0; + end_player_turn(); + }, + undo: pop_undo, +} + +states.recruit_where = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to recruit."; + view.prompt = "Recruit " + block_name(game.who) + " where?"; + gen_action_undo(view); + gen_action(view, 'block', game.who); + for (let to in AREAS) + if (can_recruit_to(game.who, to)) + gen_action(view, 'area', to); + }, + area: function (to) { + game.recruit_log.push([to]); + --game.moves; + game.location[game.who] = to; + game.moved[game.who] = true; + end_action(); + }, + block: pop_undo, + undo: pop_undo, +} + +states.move_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + view.prompt = "Move " + block_name(game.who) + "."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + let from = game.location[game.who]; + if (game.distance > 0) + gen_action(view, 'area', from); + for (let to of AREAS[from].exits) { + if (to != game.last_from && can_block_land_move_to(game.who, from, to)) + gen_action(view, 'area', to); + else if (game.distance == 0 && can_block_sea_move_to(game.who, from, to)) { + let has_destination_port = false; + if (game.moves == 0) { + for (let port of AREAS[to].exits) + if (game.move_port[game.origin] == port) + has_destination_port = true; + } else { + if (game.active == game.piracy) + has_destination_port = true; + else + for (let port of AREAS[to].exits) + if (port != game.origin && is_friendly_or_vacant_area(port)) + has_destination_port = true; + } + if (has_destination_port) + gen_action(view, 'area', to); + } + } + }, + block: function () { + if (game.distance == 0) + pop_undo(); + else + end_move(); + }, + area: function (to) { + let from = game.location[game.who]; + if (to == from) { + end_move(); + return; + } + if (game.distance == 0) + log_move_start(from); + game.last_from = from; + if (is_sea_area(to)) { + log_move_continue(to); + game.location[game.who] = to; + game.state = 'sea_move_to'; + } else { + let mark = move_block(game.who, from, to); + log_move_continue(to, mark); + if (!can_block_continue(game.who, from, to)) + end_move(); + } + }, + undo: pop_undo, +} + +states.sea_move_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + if (game.active == game.piracy) { + view.prompt = "Piracy: Sea Move " + block_name(game.who) + " to a coastal area."; + } else { + view.prompt = "Sea Move " + block_name(game.who) + " to a friendly or vacant coastal area."; + } + gen_action_undo(view); + for (let to of AREAS[game.location[game.who]].exits) { + if (to == game.last_from) + continue; + if (is_friendly_or_vacant_area(to)) { + if (game.moves == 0) { + if (game.move_port[game.origin] == to) + gen_action(view, 'area', to); + } else { + gen_action(view, 'area', to); + } + } else if (game.active == game.piracy && game.moves > 0) { + // Can attack with piracy, but no port-to-port bonus. + gen_action(view, 'area', to); + } + } + }, + area: function (to) { + game.location[game.who] = to; + game.moved[game.who] = true; + + if (game.active == game.piracy && is_contested_area(to)) { + // Can attack with piracy, but no port-to-port bonus. + log_move_continue(to, ATTACK_MARK); + game.is_pirate[game.who] = true; + if (!game.attacker[to]) + game.attacker[to] = game.active; + logp("sea moves."); + --game.moves; + } else { + // Can sea move two blocks between same major ports for 1 AP. + log_move_continue(to); + if (game.move_port[game.origin] == to) { + delete game.move_port[game.origin]; + } else { + logp("sea moves."); + --game.moves; + if (is_major_port(game.origin) && is_major_port(to)) + game.move_port[game.origin] = to; + } + } + + log_move_end(); + end_action(); + }, + undo: pop_undo, +} + +function end_move() { + if (game.distance > 0) { + log_move_end(); + if (!game.activated.includes(game.origin)) { + logp("activates " + game.origin + "."); + game.activated.push(game.origin); + game.moves --; + } + game.moved[game.who] = true; + } + game.last_from = null; + end_action(); +} + +function end_action() { + free_henry_vi(); + game.who = null; + game.distance = 0; + game.origin = null; + game.state = 'action_phase'; +} + +// BATTLE PHASE + +function goto_battle_phase() { + if (have_contested_areas()) { + game.active = game.p1; + game.state = 'battle_phase'; + } else { + goto_supply_phase(); + } +} + +states.battle_phase = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to choose a battle."; + view.prompt = "Choose the next battle to fight!"; + for (let where in AREAS) + if (is_area_on_map(where) && is_contested_area(where)) + gen_action(view, 'area', where); + }, + area: function (where) { + start_battle(where); + }, +} + +function start_battle(where) { + game.flash = ""; + log(""); + log("Battle in " + where + "."); + game.where = where; + game.battle_round = 0; + game.defected = {}; + game.treachery = {}; + + if (game.treason && can_attempt_treason_event()) { + game.active = game.treason; + game.state = 'treason_event'; + } else { + game.state = 'battle_round'; + start_battle_round(); + } +} + +function resume_battle() { + if (game.result) + return goto_game_over(); + game.who = null; + game.state = 'battle_round'; + pump_battle_round(); +} + +function end_battle() { + free_henry_vi(); + game.flash = ""; + game.battle_round = 0; + reset_border_limits(); + game.moved = {}; + game.defected = {}; + game.treachery = {}; + goto_regroup(); +} + +states.treason_event = { + show_battle: true, + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Treason: Waiting for " + game.active + " to choose a target."; + view.prompt = "Treason: Choose a target or pass."; + gen_action(view, 'pass'); + for (let b in BLOCKS) { + if (game.active == game.attacker[game.where]) { + if (is_defender(b) && can_defect(null, b)) { + gen_action(view, 'battle_treachery', b); + gen_action(view, 'block', b); + } + } else { + if (is_attacker(b) && can_defect(null, b)) { + gen_action(view, 'battle_treachery', b); + gen_action(view, 'block', b); + } + } + } + }, + battle_treachery: function (target) { + delete game.treason; + attempt_treachery(null, target); + game.state = 'battle_round'; + start_battle_round(); + }, + block: function (target) { + delete game.treason; + attempt_treachery(null, target); + game.state = 'battle_round'; + start_battle_round(); + }, + pass: function () { + game.state = 'battle_round'; + start_battle_round(); + } +} + +function bring_on_reserves(owner, moved) { + for (let b in BLOCKS) { + if (block_owner(b) == owner && game.location[b] == game.where) { + remove_from_array(game.reserves, b); + game.moved[b] = moved; + } + } +} + +function start_battle_round() { + if (++game.battle_round <= 4) { + log("~ Battle round " + game.battle_round + " ~"); + + reset_border_limits(); + game.moved = {}; + + if (game.battle_round > 1) { + bring_on_reserves(LANCASTER, false); + bring_on_reserves(YORK, false); + } + + pump_battle_round(); + } else { + end_battle(); + } +} + +function pump_battle_round() { + if (is_friendly_area(game.where) || is_enemy_area(game.where)) { + end_battle(); + return; + } + + if (count_attackers() == 0 || count_defenders() == 0) { + // Deploy reserves immediately if all blocks on one side are eliminated. + if (count_attackers() == 0) { + log("Attacking main force eliminated."); + bring_on_reserves(game.attacker[game.where], true); + } else if (count_defenders() == 0) { + log("Defending main force was eliminated."); + bring_on_reserves(ENEMY[game.attacker[game.where]], true); + if (game.battle_round == 1) { + log("The attacker is now the defender."); + game.attacker[game.where] = ENEMY[game.attacker[game.where]]; + } + } + } + + function filter_battle_blocks(ci, is_candidate) { + let output = null; + for (let b in BLOCKS) { + if (is_candidate(b) && !game.moved[b] && !game.dead[b]) { + if (block_initiative(b) == ci) { + if (!output) + output = []; + output.push(b); + } + } + } + return output; + } + + function battle_step(active, initiative, candidate) { + game.battle_list = filter_battle_blocks(initiative, candidate); + if (game.battle_list) { + game.active = active; + return true; + } + return false; + } + + let attacker = game.attacker[game.where]; + let defender = ENEMY[attacker]; + + if (battle_step(defender, 'A', is_defender)) return; + if (battle_step(attacker, 'A', is_attacker)) return; + if (battle_step(defender, 'B', is_defender)) return; + if (battle_step(attacker, 'B', is_attacker)) return; + if (battle_step(defender, 'C', is_defender)) return; + if (battle_step(attacker, 'C', is_attacker)) return; + if (battle_step(defender, 'D', is_defender)) return; + if (battle_step(attacker, 'D', is_attacker)) return; + + start_battle_round(); +} + +function pass_with_block(b) { + game.flash = block_name(b) + " passes."; + log(game.flash); + game.moved[b] = true; + resume_battle(); +} + +function can_retreat_with_block(who) { + if (game.location[who] == game.where) { + if (game.battle_round > 1) { + if (game.active == game.piracy && game.is_pirate[who]) { + return true; + } else { + for (let to of AREAS[game.where].exits) + if (can_block_retreat_to(who, to)) + return true; + } + } + } + return false; +} + +function must_retreat_with_block(who) { + if (game.location[who] == game.where) + if (game.battle_round == 4) + return (block_owner(who) == game.attacker[game.where]); + return false; +} + +function retreat_with_block(who) { + if (can_retreat_with_block(who)) { + game.who = who; + game.state = 'retreat_in_battle'; + } else { + eliminate_block(who); + resume_battle(); + } +} + +function roll_attack(b, verb) { + game.hits = 0; + let fire = block_fire_power(b, game.where); + let printed_fire = block_printed_fire_power(b); + let rolls = []; + let steps = game.steps[b]; + for (let i = 0; i < steps; ++i) { + let die = roll_d6(); + if (die <= fire) { + rolls.push(DIE_HIT[die]); + ++game.hits; + } else { + rolls.push(DIE_MISS[die]); + } + } + + game.flash += block_name(b) + " " + BLOCKS[b].combat; + if (fire > printed_fire) + game.flash += "+" + (fire - printed_fire); + game.flash += "\n" + verb + " " + rolls.join(" ") + "\n"; + if (game.hits == 0) + game.flash += "and misses."; + else if (game.hits == 1) + game.flash += "and scores 1 hit."; + else + game.flash += "and scores " + game.hits + " hits."; +} + +function fire_with_block(b) { + game.moved[b] = true; + game.flash = ""; + roll_attack(b, "fires"); + log(game.flash); + if (game.hits > 0) { + game.active = ENEMY[game.active]; + goto_battle_hits(); + } else { + resume_battle(); + } +} + +function attempt_treachery(source, target) { + if (source) { + let once = treachery_tag(source); + game.treachery[once] = true; + game.moved[source] = true; + } + let n = block_loyalty(source, target); + let rolls = []; + let result = true; + for (let i = 0; i < n; ++i) { + let die = roll_d6(); + if ((die & 1) == 1) { + rolls.push(DIE_MISS[die]); + result = false; + } else { + rolls.push(DIE_HIT[die]); + } + } + if (source) + game.flash = block_name(source) + " treachery " + rolls.join(" "); + else + game.flash = "Treason event " + rolls.join(" "); + if (result) { + game.flash += " converts " + block_name(target) + "!"; + target = swap_blocks(target); + game.defected[target] = true; + game.reserves.push(target); + } else { + game.flash += " fails to convert " + block_name(target) + "."; + } + log(game.flash); +} + +function charge_with_block(heir, target) { + let n; + game.moved[heir] = true; + game.flash = ""; + roll_attack(heir, "charges " + block_name(target)); + log(game.flash); + n = Math.min(game.hits, game.steps[target]); + if (n == game.steps[target]) { + eliminate_block(target); + } else { + while (n-- > 0) + reduce_block(target); + let charge_flash = game.flash; + game.flash = ""; + roll_attack(target, "counter-attacks"); + log(game.flash); + n = Math.min(game.hits, game.steps[heir]); + while (n-- > 0) + reduce_block(heir); + game.flash = charge_flash + "\n" + game.flash; + } + resume_battle(); +} + +function can_block_fire(who) { + if (is_attacker(who)) + return game.battle_round < 4; + if (is_defender(who)) + return true; + return false; +} + +function can_block_retreat(who) { + if (game.location[who] == game.where) + return game.battle_round > 1; + return false; +} + +function find_minor_heir(owner) { + let candidate = null; + for (let b in BLOCKS) { + if (block_owner(b) == owner && block_type(b) == 'heir' && game.location[b] == MINOR) + if (!candidate || BLOCKS[b].heir < BLOCKS[candidate].heir) + candidate = b; + } + return candidate; +} + +function find_senior_heir(owner) { + let candidate = null; + for (let b in BLOCKS) + if (block_owner(b) == owner && block_type(b) == 'heir' && is_block_on_map(b)) + if (!candidate || BLOCKS[b].heir < BLOCKS[candidate].heir) + candidate = b; + return candidate; +} + +function find_next_king(owner) { + let candidate = null; + for (let b in BLOCKS) + if (block_owner(b) == owner && block_type(b) == 'heir' && game.location[b]) + if (!candidate || BLOCKS[b].heir < BLOCKS[candidate].heir) + candidate = b; + return candidate; +} + +function find_senior_heir_in_area(owner, where) { + let candidate = null; + for (let b in BLOCKS) { + if (block_owner(b) == owner && block_type(b) == 'heir' && game.location[b] == where) { + if (is_battle_reserve(b)) + continue; + if (!candidate || BLOCKS[b].heir < BLOCKS[candidate].heir) + candidate = b; + } + } + return candidate; +} + +function is_senior_royal_heir_in(who, where) { + return find_senior_heir_in_area(block_owner(game.king), where) == who; +} + +function can_heir_charge() { + let heir = find_senior_heir_in_area(game.active, game.where); + if (heir && !game.moved[heir]) { + if (is_attacker(heir)) + return game.battle_round < 4 ? heir : null; + if (is_defender(heir)) + return heir; + } + return null; +} + +states.battle_round = { + show_battle: true, + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to choose a combat action."; + view.prompt = "Battle: Choose a combat action with an army."; + + let can_fire = false; + let can_retreat = false; + let must_retreat = false; + let can_pass = false; + if (game.active == game.attacker[game.where]) { + if (game.battle_round < 4) can_fire = true; + if (game.battle_round > 1) can_retreat = true; + if (game.battle_round < 4) can_pass = true; + if (game.battle_round == 4) must_retreat = true; + } else { + can_fire = true; + if (game.battle_round > 1) can_retreat = true; + can_pass = true; + } + for (let b of game.battle_list) { + if (can_fire) gen_action(view, 'battle_fire', b); + if (must_retreat || (can_retreat && can_retreat_with_block(b))) + gen_action(view, 'battle_retreat', b); + if (can_pass) gen_action(view, 'battle_pass', b); + gen_action(view, 'block', b); + } + + let heir = can_heir_charge(); + if (heir && game.battle_list.includes(heir)) { + gen_action(view, 'battle_charge', heir); + } + if (can_attempt_treachery(game.king)) + gen_action(view, 'battle_treachery', game.king); + if (can_attempt_treachery(game.pretender)) + gen_action(view, 'battle_treachery', game.pretender); + if (can_attempt_treachery("Warwick/L")) + gen_action(view, 'battle_treachery', "Warwick/L"); + if (can_attempt_treachery("Warwick/Y")) + gen_action(view, 'battle_treachery', "Warwick/Y"); + }, + battle_fire: function (who) { + fire_with_block(who); + }, + battle_retreat: function (who) { + retreat_with_block(who); + }, + battle_pass: function (who) { + pass_with_block(who); + }, + battle_charge: function (who) { + game.who = who; + game.state = 'battle_charge'; + }, + battle_treachery: function (who) { + game.who = who; + game.state = 'battle_treachery'; + }, + block: function (who) { + if (can_block_fire(who)) + fire_with_block(who); + else if (can_retreat_with_block(who)) + retreat_with_block(who); + else if (must_retreat_with_block(who)) + eliminate_block(who); + else + pass_with_block(who); + }, +} + +states.battle_charge = { + show_battle: true, + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Heir Charge: Waiting for " + game.active + " to choose a target."; + view.prompt = "Heir Charge: Choose a target."; + gen_action(view, 'undo'); + for (let b in BLOCKS) { + if (game.active == game.attacker[game.where]) { + if (is_defender(b)) { + gen_action(view, 'battle_charge', b); + gen_action(view, 'block', b); + } + } else { + if (is_attacker(b)) { + gen_action(view, 'battle_charge', b); + gen_action(view, 'block', b); + } + } + } + }, + battle_charge: function (target) { + charge_with_block(game.who, target); + }, + block: function (target) { + charge_with_block(game.who, target); + }, + undo: function () { + resume_battle(); + } +} + +states.battle_treachery = { + show_battle: true, + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Treachery: Waiting for " + game.active + " to choose a target."; + view.prompt = "Treachery: Choose a target."; + gen_action(view, 'undo'); + for (let b in BLOCKS) { + if (game.active == game.attacker[game.where]) { + if (is_defender(b) && can_defect(game.who, b)) { + gen_action(view, 'battle_treachery', b); + gen_action(view, 'block', b); + } + } else { + if (is_attacker(b) && can_defect(game.who, b)) { + gen_action(view, 'battle_treachery', b); + gen_action(view, 'block', b); + } + } + } + }, + battle_treachery: function (target) { + attempt_treachery(game.who, target); + resume_battle(); + }, + block: function (target) { + attempt_treachery(game.who, target); + resume_battle(); + }, + undo: function () { + resume_battle(); + } +} + +function goto_battle_hits() { + game.battle_list = list_victims(game.active); + if (game.battle_list.length == 0) + resume_battle(); + else + game.state = 'battle_hits'; +} + +function apply_hit(who) { + let n = Math.min(game.hits, game.steps[who]); + if (n == 1) + game.flash = block_name(who) + " takes " + n + " hit."; + else + game.flash = block_name(who) + " takes " + n + " hits."; + while (n-- > 0) { + reduce_block(who); + game.hits--; + } + game.battle_list = list_victims(game.active); + if (game.battle_list.length > 0) { + if (game.hits == 1) + game.flash += " 1 hit left."; + else if (game.hits > 1) + game.flash += " " + game.hits + " hits left."; + } + if (game.hits == 0) + resume_battle(); + else + goto_battle_hits(); +} + +function list_victims(p) { + let is_candidate = (p == game.attacker[game.where]) ? is_attacker : is_defender; + let max = 0; + for (let b in BLOCKS) + if (is_candidate(b) && game.steps[b] > max) + max = game.steps[b]; + let list = []; + for (let b in BLOCKS) + if (is_candidate(b) && game.steps[b] == max) + list.push(b); + return list; +} + +states.battle_hits = { + show_battle: true, + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to assign hits."; + view.prompt = "Assign " + game.hits + (game.hits != 1 ? " hits" : " hit") + " to your armies."; + for (let b of game.battle_list) { + gen_action(view, 'battle_hit', b); + gen_action(view, 'block', b); + } + }, + battle_hit: function (who) { + apply_hit(who); + }, + block: function (who) { + apply_hit(who); + }, +} + +states.retreat_in_battle = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to retreat."; + gen_action(view, 'undo'); + if (game.active == game.piracy && game.is_pirate[game.who]) { + view.prompt = "Retreat: Move the army to a friendly or vacant areas in the same sea zone."; + for (let to of AREAS[game.where].exits) + if (is_sea_area(to)) + gen_action(view, 'area', to); + } else { + view.prompt = "Retreat: Move the army to a friendly or vacant area."; + for (let to of AREAS[game.where].exits) + if (can_block_retreat_to(game.who, to)) + gen_action(view, 'area', to); + } + }, + area: function (to) { + if (is_sea_area(to)) { + game.location[game.who] = to; + game.state = 'sea_retreat_to'; + } else { + game.flash = block_name(game.who) + " retreats."; + logp("retreats to " + to + "."); + use_border(game.where, to); + game.location[game.who] = to; + resume_battle(); + } + }, + eliminate: function () { + game.flash = ""; + eliminate_block(game.who); + resume_battle(); + }, + undo: function () { + resume_battle(); + } +} + +states.sea_retreat_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to retreat."; + view.prompt = "Retreat: Move the army to a friendly or vacant area in the same sea zone."; + // TODO: only eliminate if no retreat is possible + gen_action(view, 'eliminate'); + let from = game.location[game.who]; + for (let to of AREAS[from].exits) + if (is_friendly_or_vacant_area(to)) + gen_action(view, 'area', to); + }, + area: function (to) { + game.flash = block_name(game.who) + " retreats by sea."; + logp("sea retreats to " + to + "."); + game.location[game.who] = to; + resume_battle(); + }, + eliminate: function () { + game.flash = ""; + eliminate_block(game.who); + resume_battle(); + }, + undo: function () { + game.location[game.who] = game.where; + resume_battle(); + } +} + +function goto_regroup() { + game.active = game.attacker[game.where]; + if (is_enemy_area(game.where)) + game.active = ENEMY[game.active]; + log("~ " + game.active + " wins the battle ~"); + game.state = 'regroup'; + game.turn_log = []; + clear_undo(); +} + +states.regroup = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to regroup."; + view.prompt = "Regroup: Choose an army to move."; + gen_action_undo(view); + gen_action(view, 'end_regroup'); + for (let b in BLOCKS) { + if (game.location[b] == game.where) { + if (game.active == game.piracy) { + if (game.is_pirate[b]) + gen_action(view, 'block', b); + } else { + if (can_block_regroup(b)) + gen_action(view, 'block', b); + } + } + } + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'regroup_to'; + }, + end_regroup: function () { + game.where = null; + clear_undo(); + print_turn_log(game.active + " regroups:"); + goto_battle_phase(); + }, + undo: pop_undo, +} + +states.regroup_to = { + prompt: function (view, current) { + if (game.active == game.piracy && game.is_pirate[game.who]) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to regroup."; + view.prompt = "Regroup: Move the army to a friendly or vacant area in the same sea zone."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + for (let to of AREAS[game.where].exits) + if (is_sea_area(to)) + gen_action(view, 'area', to); + } else { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to regroup."; + view.prompt = "Regroup: Move the army to a friendly or vacant area."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + for (let to of AREAS[game.where].exits) + if (can_block_regroup_to(game.who, to)) + gen_action(view, 'area', to); + } + }, + area: function (to) { + if (is_sea_area(to)) { + game.location[game.who] = to; + game.state = 'sea_regroup_to'; + } else { + game.turn_log.push([game.where, to]); + move_block(game.who, game.where, to); + game.who = null; + game.state = 'regroup'; + } + }, + block: pop_undo, + undo: pop_undo, +} + +states.sea_regroup_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to regroup."; + view.prompt = "Regroup: Move the army to a friendly or vacant area in the same sea zone."; + gen_action_undo(view); + let from = game.location[game.who]; + for (let to of AREAS[from].exits) + if (is_friendly_or_vacant_area(to)) + gen_action(view, 'area', to); + }, + area: function (to) { + logp("sea regroups to " + to + "."); + game.location[game.who] = to; + game.who = null; + game.state = 'regroup' + }, + undo: pop_undo, +} + +// SUPPLY PHASE + +function goto_supply_phase() { + game.moved = {}; + + if (!game.location[game.king]) { + game.king = find_next_king(block_owner(game.king)); + log("The King is dead; long live the king!"); + if (game.location[game.king] == MINOR) + log("The new King is a minor."); + else + log("The new King is in " + game.location[game.king] + "."); + } + + goto_execute_clarence(); +} + +function goto_execute_clarence() { + if (is_block_alive("Clarence/L")) { + game.active = LANCASTER; + game.state = 'execute_clarence'; + game.who = "Clarence/L"; + } else { + goto_execute_exeter(); + } +} + +states.execute_clarence = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to execute Clarence."; + view.prompt = "Supply Phase: Execute enemy heir Clarence?"; + gen_action(view, 'execute_clarence'); + gen_action(view, 'pass'); + }, + execute_clarence: function () { + logp("executes Clarence."); + eliminate_block("Clarence/L"); + game.who = null; + if (game.result) + return goto_game_over(); + goto_execute_exeter(); + }, + pass: function () { + game.who = null; + goto_execute_exeter(); + } +} + +function goto_execute_exeter() { + if (is_block_alive("Exeter/Y")) { + game.active = YORK; + game.state = 'execute_exeter'; + game.who = "Exeter/Y"; + } else { + goto_enter_pretender_heir(); + } +} + +states.execute_exeter = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to execute Exeter."; + view.prompt = "Supply Phase: Execute enemy heir Exeter?"; + gen_action(view, 'execute_exeter'); + gen_action(view, 'pass'); + }, + execute_exeter: function () { + logp("executes Exeter."); + eliminate_block("Exeter/Y"); + game.who = null; + if (game.result) + return goto_game_over(); + goto_enter_pretender_heir(); + }, + pass: function () { + game.who = null; + goto_enter_pretender_heir(); + } +} + +// PRETENDER SUPPLY PHASE + +function goto_enter_pretender_heir() { + game.active = block_owner(game.pretender); + let n = game.killed_heirs[game.active]; + if (n > 0 && (game.who = find_minor_heir(game.active))) + game.state = 'enter_pretender_heir'; + else + goto_supply_limits_pretender(); +} + +states.enter_pretender_heir = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to enter pretender heirs."; + view.prompt = "Death of an Heir: Enter " + block_name(game.who) + " in an exile area."; + for (let where in AREAS) + if (is_pretender_exile_area(where)) + gen_action(view, 'area', where); + }, + block: function () { + game.who = null; + }, + area: function (to) { + log(block_name(game.who) + " comes of age in " + to + "."); + --game.killed_heirs[game.active]; + game.location[game.who] = to; + game.who = null; + goto_enter_pretender_heir(); + }, +} + +function goto_supply_limits_pretender() { + game.reduced = {}; + game.active = block_owner(game.pretender); + if (check_supply_penalty()) { + game.state = 'supply_limits_pretender'; + game.turn_log = []; + clear_undo(); + } else { + delete game.supply; + delete game.reduced; + goto_enter_royal_heir(); + } +} + +states.supply_limits_pretender = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to check supply limits."; + view.prompt = "Supply Phase: Reduce blocks in over-stacked areas."; + gen_action_undo(view); + if (game.supply.length == 0) + gen_action(view, 'end_supply_phase'); + for (let b of game.supply) + gen_action(view, 'block', b); + }, + block: function (who) { + push_undo(); + game.turn_log.push([game.location[who]]); + game.reduced[who] = true; + reduce_block(who); + check_supply_penalty(); + }, + end_supply_phase: function () { + delete game.supply; + delete game.reduced; + clear_undo(); + print_turn_log(game.active + " reduces:"); + if (game.result) + return goto_game_over(); + goto_enter_royal_heir(); + }, + undo: pop_undo, +} + +// KING SUPPLY PHASE + +function goto_enter_royal_heir() { + game.active = block_owner(game.king); + let n = game.killed_heirs[game.active]; + if (n > 0 && (game.who = find_minor_heir(game.active))) + game.state = 'enter_royal_heir'; + else + goto_supply_limits_king(); +} + +states.enter_royal_heir = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to enter royal heirs."; + view.prompt = "Death of an Heir: Enter " + block_name(game.who) + " in a Crown area."; + let can_enter = false; + for (let where in AREAS) { + if (is_crown_area(where) && is_friendly_or_vacant_area(where)) { + gen_action(view, 'area', where); + can_enter = true; + } + } + if (!can_enter) + gen_action(view, 'pass'); + }, + block: function () { + game.who = null; + }, + area: function (to) { + log(block_name(game.who) + " comes of age in " + to + "."); + --game.killed_heirs[game.active]; + game.location[game.who] = to; + game.who = null; + goto_enter_royal_heir(); + }, + pass: function () { + game.who = null; + goto_supply_limits_king(); + } +} + +function goto_supply_limits_king() { + game.reduced = {}; + game.active = block_owner(game.king); + if (check_supply_penalty()) { + game.state = 'supply_limits_king'; + game.turn_log = []; + clear_undo(); + } else { + delete game.supply; + delete game.reduced; + end_game_turn(); + } +} + +states.supply_limits_king = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to check supply limits."; + view.prompt = "Supply Phase: Reduce blocks in over-stacked areas."; + gen_action_undo(view); + if (game.supply.length == 0) + gen_action(view, 'end_supply_phase'); + for (let b of game.supply) + gen_action(view, 'block', b); + }, + block: function (who) { + push_undo(); + game.turn_log.push([game.location[who]]); + game.reduced[who] = true; + reduce_block(who); + check_supply_penalty(); + }, + end_supply_phase: function () { + delete game.supply; + delete game.reduced; + clear_undo(); + print_turn_log(game.active + " reduces:"); + if (game.result) + return goto_game_over(); + end_game_turn(); + }, + undo: pop_undo, +} + +// POLITICAL TURN + +function goto_political_turn() { + log(""); + log("Start Political Turn."); + + game.turn_log = []; + + // Levies disband + for (let b in BLOCKS) { + if (!is_land_area(game.location[b])) + continue; + switch (block_type(b)) { + case 'bombard': + case 'levies': + case 'rebel': + game.turn_log.push([game.location[b]]); + disband(b); + break; + case 'mercenaries': + switch (b) { + case "Welsh Mercenary": + game.turn_log.push([game.location[b]]); + disband(b); + break; + case "Irish Mercenary": + if (game.location[b] != "Ireland") { + game.turn_log.push([game.location[b], "Ireland"]); + game.location[b] = "Ireland"; + } + break; + case "Burgundian Mercenary": + case "Calais Mercenary": + if (game.location[b] != "Calais") { + game.turn_log.push([game.location[b], "Calais"]); + game.location[b] = "Calais"; + } + break; + case "Scots Mercenary": + if (game.location[b] != "Scotland") { + game.turn_log.push([game.location[b], "Scotland"]); + game.location[b] = "Scotland"; + } + break; + case "French Mercenary": + if (game.location[b] != "France") { + game.turn_log.push([game.location[b], "France"]); + game.location[b] = "France"; + } + break; + } + break; + } + } + + print_turn_log("Levies disband:"); + + // Usurpation + let l_count = count_lancaster_nobles(); + let y_count = count_york_nobles(); + log(""); + log("Lancaster controls " + l_count + " nobles."); + log("York controls " + y_count + " nobles."); + if (l_count > y_count && block_owner(game.king) == YORK) { + game.king = find_senior_heir(LANCASTER); + game.pretender = find_senior_heir(YORK); + log(game.king + " usurps the throne!"); + } else if (y_count > l_count && block_owner(game.king) == LANCASTER) { + game.king = find_senior_heir(YORK); + game.pretender = find_senior_heir(LANCASTER); + log(game.king + " usurps the throne!"); + } else { + log(game.king + " remains king."); + } + + // Game ends after last Usurpation check + if (game.campaign == game.end_campaign) + return goto_game_over(); + + log(""); + goto_pretender_goes_home(); +} + +// PRETENDER GOES HOME + +function goto_pretender_goes_home() { + game.active = block_owner(game.pretender); + game.state = 'pretender_goes_home'; + game.turn_log = []; + let choices = false; + for (let b in BLOCKS) + if (block_owner(b) == game.active && is_block_on_map(b)) + if (go_home_if_possible(b)) + choices = true; + if (!choices) { + print_turn_log_no_count("Pretender goes home:"); + goto_exile_limits_pretender(); + } else { + clear_undo(); + } +} + +states.pretender_goes_home = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for the Pretender to go to exile."; + gen_action_undo(view); + let done = true; + for (let b in BLOCKS) { + if (block_owner(b) == game.active && is_block_on_map(b) && !game.moved[b]) { + if (!is_in_exile(b)) { + if (is_heir(b)) { + done = false; + gen_action(view, 'block', b); + } else if (!is_at_home(b)) { + done = false; + let n = count_available_homes(b); + if (n > 1) + gen_action(view, 'block', b); + } + } + } + } + if (done) { + view.prompt = "Pretender Goes Home: You may move nobles to another home."; + for (let b in BLOCKS) { + if (block_owner(b) == game.active && is_block_on_map(b) && !game.moved[b]) { + if (!is_in_exile(b)) { + if (is_at_home(b)) { + let n = count_available_homes(b); + if (n > 1) + gen_action(view, 'block', b); + } + } + } + } + gen_action(view, 'end_political_turn'); + } else { + view.prompt = "Pretender Goes Home: Move the pretender and his heirs to exile, and nobles to home."; + } + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'pretender_goes_home_to'; + }, + end_political_turn: function () { + clear_undo(); + print_turn_log_no_count("Pretender goes home:"); + goto_exile_limits_pretender(); + }, + undo: pop_undo, +} + +states.pretender_goes_home_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for the Pretender to go to exile."; + if (is_heir(game.who)) + view.prompt = "Pretender Goes Home: Move " + block_name(game.who) + " to exile."; + else + view.prompt = "Pretender Goes Home: Move " + block_name(game.who) + " to home."; + gen_action(view, 'block', game.who); + for (let where in AREAS) { + if (where != game.location[game.who]) { + if (is_heir(game.who)) { + if (is_friendly_exile_area(where)) + gen_action(view, 'area', where); + } else if (is_available_home_for(where, game.who)) { + gen_action(view, 'area', where); + } + } + } + }, + area: function (to) { + if (is_exile_area(to)) + game.turn_log.push([block_name(game.who), to]); // TODO: "Exile"? + else + game.turn_log.push([block_name(game.who), to]); // TODO: "Home"? + game.moved[game.who] = true; + game.location[game.who] = to; + game.who = null; + game.state = 'pretender_goes_home'; + }, + block: pop_undo, + undo: pop_undo, +} + +function goto_exile_limits_pretender() { + game.moved = {}; + game.active = block_owner(game.pretender); + if (check_exile_limits()) { + game.state = 'exile_limits_pretender'; + clear_undo(); + } else { + goto_king_goes_home(); + } +} + +states.exile_limits_pretender = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to check exile limits."; + view.prompt = "Campaign Reset: Disband one block in each over-stacked exile area."; + gen_action_undo(view); + if (game.exiles.length == 0) + gen_action(view, 'end_exile_limits'); + for (let b of game.exiles) + gen_action(view, 'block', b); + }, + block: function (who) { + push_undo(); + let where = game.location[who]; + logp("disbands in " + where + "."); + game.exiles = game.exiles.filter(b => game.location[b] != where); + disband(who); + }, + end_exile_limits: function () { + goto_king_goes_home(); + }, + undo: pop_undo, +} + +// KING GOES HOME + +function goto_king_goes_home() { + game.active = block_owner(game.king); + game.state = 'king_goes_home'; + game.turn_log = []; + let choices = false; + for (let b in BLOCKS) + if (block_owner(b) == game.active && is_block_on_map(b)) + if (go_home_if_possible(b)) + choices = true; + if (!choices) { + print_turn_log_no_count("King goes home:"); + goto_exile_limits_king(); + } else { + clear_undo(); + } +} + +states.king_goes_home = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for the King to go home."; + gen_action_undo(view); + let done = true; + for (let b in BLOCKS) { + if (block_owner(b) == game.active && is_block_on_map(b) && !game.moved[b]) { + if (!is_in_exile(b)) { + if (!is_at_home(b)) { + done = false; + let n = count_available_homes(b); + if (n > 1) + gen_action(view, 'block', b); + } + } + } + } + if (done) { + view.prompt = "King Goes Home: You may move nobles and heirs to another home."; + for (let b in BLOCKS) { + if (block_owner(b) == game.active && is_block_on_map(b) && !game.moved[b]) { + if (!is_in_exile(b)) { + if (is_at_home(b)) { + let n = count_available_homes(b); + if (n > 1) + gen_action(view, 'block', b); + } + } + } + } + gen_action(view, 'end_political_turn'); + } else { + view.prompt = "King Goes Home: Move the King, the royal heirs, and nobles to home."; + } + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'king_goes_home_to'; + }, + end_political_turn: function () { + clear_undo(); + print_turn_log_no_count("King goes home:"); + goto_exile_limits_king(); + }, + undo: pop_undo, +} + +states.king_goes_home_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for the King to go home."; + view.prompt = "King Goes Home: Move " + block_name(game.who) + " to home."; + gen_action(view, 'block', game.who); + for (let where in AREAS) + if (where != game.location[game.who]) + if (is_available_home_for(where, game.who)) + gen_action(view, 'area', where); + }, + area: function (to) { + game.turn_log.push([block_name(game.who), to]); // TODO: "Home"? + game.moved[game.who] = true; + game.location[game.who] = to; + game.who = null; + game.state = 'king_goes_home'; + }, + block: pop_undo, + undo: pop_undo, +} + +function goto_exile_limits_king() { + game.moved = {}; + game.active = block_owner(game.king); + if (check_exile_limits()) { + game.state = 'exile_limits_king'; + clear_undo(); + } else { + end_political_turn(); + } +} + +states.exile_limits_king = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to check exile limits."; + view.prompt = "Campaign Reset: Disband one block in each over-stacked exile area."; + gen_action_undo(view); + if (game.exiles.length == 0) + gen_action(view, 'end_exile_limits'); + for (let b of game.exiles) + gen_action(view, 'block', b); + }, + block: function (who) { + push_undo(); + let where = game.location[who]; + logp("disbands in " + where + "."); + game.exiles = game.exiles.filter(b => game.location[b] != where); + disband(who); + }, + end_exile_limits: function () { + end_political_turn(); + }, + undo: pop_undo, +} + +function end_political_turn() { + // Campaign reset + game.dead = {}; + for (let b in BLOCKS) + game.steps[b] = block_max_steps(b); + + ++game.campaign; + start_campaign(); +} + +// GAME OVER + +function goto_game_over() { + game.active = "None"; + game.state = 'game_over'; + if (!game.result) { + game.result = block_owner(game.king); + game.victory = game.result + " wins!"; + } + log(""); + log(game.victory); +} + +states.game_over = { + prompt: function (view, current) { + view.prompt = game.victory; + } +} + +function make_battle_view() { + let battle = { + LA: [], LB: [], LC: [], LD: [], LR: [], + YA: [], YB: [], YC: [], YD: [], YR: [], + flash: game.flash + }; + + battle.title = game.attacker[game.where] + " attacks " + game.where; + battle.title += " \u2014 round " + game.battle_round + " of 4"; + + function fill_cell(cell, owner, fn) { + for (let b in BLOCKS) + if (game.location[b] == game.where & block_owner(b) == owner && !game.dead[b] && fn(b)) + cell.push([b, game.steps[b], game.moved[b]?1:0]) + } + + fill_cell(battle.LR, LANCASTER, b => is_battle_reserve(b)); + fill_cell(battle.LA, LANCASTER, b => !is_battle_reserve(b) && block_initiative(b) == 'A'); + fill_cell(battle.LB, LANCASTER, b => !is_battle_reserve(b) && block_initiative(b) == 'B'); + fill_cell(battle.LC, LANCASTER, b => !is_battle_reserve(b) && block_initiative(b) == 'C'); + fill_cell(battle.LD, LANCASTER, b => !is_battle_reserve(b) && block_initiative(b) == 'D'); + + fill_cell(battle.YR, YORK, b => is_battle_reserve(b)); + fill_cell(battle.YA, YORK, b => !is_battle_reserve(b) && block_initiative(b) == 'A'); + fill_cell(battle.YB, YORK, b => !is_battle_reserve(b) && block_initiative(b) == 'B'); + fill_cell(battle.YC, YORK, b => !is_battle_reserve(b) && block_initiative(b) == 'C'); + fill_cell(battle.YD, YORK, b => !is_battle_reserve(b) && block_initiative(b) == 'D'); + + return battle; +} + +exports.setup = function (scenario, players) { + if (players.length != 2) + throw new Error("Invalid player count: " + players.length); + game = { + attacker: {}, + border_limit: {}, + last_used: {}, + location: {}, + log: [], + main_border: {}, + moved: {}, + dead: {}, + moves: 0, + prompt: null, + reserves: [], + show_cards: false, + steps: {}, + who: null, + where: null, + killed_heirs: { Lancaster: 0, York: 0 }, + } + if (scenario == "Wars of the Roses") + setup_game(); + else if (scenario == "Kingmaker") + setup_kingmaker(); + else if (scenario == "Richard III") + setup_richard_iii(); + else + throw new Error("Unknown scenario:", scenario); + start_campaign(); + return game; +} + +exports.action = function (state, current, action, arg) { + game = state; + // TODO: check current, action and argument against action list + if (true) { + let S = states[game.state]; + if (action in S) + S[action](arg, current); + else + throw new Error("Invalid action: " + action); + } + return state; +} + +exports.resign = function (state, current) { + game = state; + if (game.state != 'game_over') { + log(""); + log(current + " resigned."); + game.active = "None"; + game.state = 'game_over'; + game.victory = current + " resigned."; + game.result = ENEMY[current]; + } +} + +exports.view = function(state, current) { + game = state; + + let view = { + log: game.log, + campaign: game.campaign + " of " + game.end_campaign, + active: game.active, + king: game.king, + pretender: game.pretender, + l_card: (game.show_cards || current == LANCASTER) ? game.l_card : 0, + y_card: (game.show_cards || current == YORK) ? game.y_card : 0, + hand: (current == LANCASTER) ? game.l_hand : (current == YORK) ? game.y_hand : [], + who: (game.active == current) ? game.who : null, + where: game.where, + known: {}, + secret: { York: {}, Lancaster: {}, Rebel: {} }, + battle: null, + prompt: null, + actions: null, + }; + + states[game.state].prompt(view, current); + + if (states[game.state].show_battle) + view.battle = make_battle_view(); + + for (let b in BLOCKS) { + let a = game.location[b]; + if (!a) + continue; + + let is_known = false; + if (current == block_owner(b) || (game.dead[b] && is_block_on_map(b)) || game.state == 'game_over') + is_known = true; + + if (is_known) { + view.known[b] = [a, game.steps[b], (game.moved[b] || game.dead[b]) ? 1 : 0]; + } else if (a != POOL && a != MINOR) { + let list = view.secret[BLOCKS[b].owner]; + if (!(a in list)) + list[a] = [0, 0]; + list[a][0]++; + if (game.moved[b] || game.dead[b]) + list[a][1]++; + } + } + + return view; +} |