"use strict" // TODO: optional rule - force marches // TODO: 6.2 - In Sieges, the attacker /may/ retreat or stay on siege. // TODO: new combat deployment in round 2/3 if defenders are wiped out and reserves are coming? // see https://boardgamegeek.com/thread/423599/article/3731006 // TODO: sea move into attacked fortified port as defender responder? -- not besieged yet exports.scenarios = [ "Standard" ] exports.roles = [ "Franks", "Saracens", ] const { CARDS, BLOCKS, TOWNS, PORTS, ROADS, SHIELDS, block_index, town_index } = require('./data') const FRANKS = "Franks" const SARACENS = "Saracens" const OBSERVER = "Observer" const BOTH = "Both" const NOWHERE = 0 const DEAD = 1 const F_POOL = 2 const S_POOL = 3 const SEA = 4 const ENGLAND = 5 const FRANCE = 6 const GERMANIA = 7 const first_town = 5 // TODO: exclude staging areas? include sea? const last_town = TOWNS.length - 1 const last_block = BLOCKS.length - 2 // assassins are not a real block const ALEPPO = town_index["Aleppo"] const ANTIOCH = town_index["Antioch"] const DAMASCUS = town_index["Damascus"] const MASYAF = town_index["Masyaf"] const ST_SIMEON = town_index["St. Simeon"] const TRIPOLI = town_index["Tripoli"] const TYRE = town_index["Tyre"] const NOBODY = -1 const ASSASSINS = block_index["Assassins"] const RICHARD = block_index["Richard"] const ROBERT = block_index["Robert"] const CROSSBOWS = block_index["Crossbows"] const SALADIN = block_index["Saladin"] const AL_ADIL = block_index["Al Adil"] const AL_AZIZ = block_index["Al Aziz"] const AL_AFDAL = block_index["Al Afdal"] const AL_ZAHIR = block_index["Al Zahir"] const ENGLISH_CRUSADERS = [ RICHARD, ROBERT, CROSSBOWS ] const GERMAN_CRUSADERS = [ "Barbarossa", "Frederik", "Leopold" ].map(name => block_index[name]) const FRENCH_CRUSADERS = [ "Philippe", "Hugues", "Fileps" ].map(name => block_index[name]) const SALADIN_FAMILY = [ SALADIN, AL_ADIL, AL_AZIZ, AL_AFDAL, AL_ZAHIR ] const INTRIGUE = 3 const WINTER_CAMPAIGN = 6 const GERMAN_ROADS = [ ST_SIMEON, ANTIOCH, ALEPPO ] const KINGDOMS = { Syria: SARACENS, Antioch: FRANKS, Tripoli: FRANKS, Jerusalem: FRANKS, Egypt: SARACENS, } const VICTORY_TOWNS = [ town_index["Aleppo"], town_index["Damascus"], town_index["Egypt"], town_index["Antioch"], town_index["Tripoli"], town_index["Acre"], town_index["Jerusalem"] ] // serif cirled numbers const DIE_HIT = [ 0, '\u2776', '\u2777', '\u2778', '\u2779', '\u277A', '\u277B' ] const DIE_MISS = [ 0, '\u2460', '\u2461', '\u2462', '\u2463', '\u2464', '\u2465' ] const DIE_SELF = '\u2716' const ATTACK_MARK = "*" const RESERVE_MARK_1 = "\u2020" const RESERVE_MARK_2 = "\u2021" const block_seats = [] const block_seat_names = [] const block_seat_names_or = [] function list_seats(who) { if (block_type(who) === 'nomads') return [ block_home(who) ] let list = [] for (let town = first_town; town <= last_town; ++town) if (set_has(SHIELDS[town], who)) list.push(town) return list } for (let b = 0; b <= last_block; ++b) { block_seats[b] = list_seats(b) let names = block_seats[b].map(town_name) block_seat_names[b] = names.join(", ") block_seat_names_or[b] = join(names, "or") } let states = {} let game = null function random(n) { if (game.rng === 1) return Math.floor(((game.seed = game.seed * 48271 % 0x7fffffff) / 0x7fffffff) * n) return (game.seed = game.seed * 200105 % 34359738337) % n } function log(s) { game.log.push(s) } function logi(s) { game.log.push(">" + s) } function active_adjective() { return (game.active === FRANKS ? "Frank" : "Saracen") } function join(list, conj = "or") { if (list.length === 0) return "" if (list.length === 1) return list[0] if (list.length === 2) return `${list[0]} ${conj} ${list[1]}` return `${list.slice(0,-1).join(", ")}, ${conj} ${list[list.length-1]}` } function log_move_start(from) { game.move_buf = [ from ] } function log_move_continue(to, mark) { if (mark) game.move_buf.push("#" + to + mark) else game.move_buf.push(to) } function log_move_end() { if (game.move_buf && game.move_buf.length > 1) game.summary.push(game.move_buf) delete game.move_buf } function print_summary(text, skip_if_empty = false) { if (skip_if_empty && game.summary.length === 0) { delete game.summary return } let lines = game.summary.map(function (move) { let s = "" for (let i = 0; i < move.length; ++i) { let x = move[i] if (i > 0) s += " \u2192 " if (typeof x === 'number') s += "#" + x else s += x } return s }).sort() delete game.summary log(text) let last = lines[0] let n = 0 for (let entry of lines) { if (entry !== last) { logi(n + " " + last) n = 0 } ++n last = entry } if (n > 0) logi(n + " " + last) else logi("nothing.") } const id_player = [ null, FRANKS, SARACENS ] function player_id(p) { if (p === FRANKS) return 1 if (p === SARACENS) return 2 return 0 } function enemy(p) { if (p === FRANKS) return SARACENS if (p === SARACENS) return FRANKS return null } function is_inactive_player(current) { return current === OBSERVER || (game.active !== current && game.active !== BOTH) } function is_winter() { return game.turn === 6 } function remove_from_array(array, item) { let i = array.indexOf(item) if (i >= 0) array.splice(i, 1) } 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 random(6) + 1 } function shuffle_deck() { let deck = [] for (let c = 1; c <= 27; ++c) deck.push(c) return deck } function deal_cards(deck, n) { let hand = [] for (let i = 0; i < n; ++i) { let k = random(deck.length) hand.push(deck[k]) deck.splice(k, 1) } return hand } function select_random_block(where) { let list = [] for (let b = 0; b <= last_block; ++b) if (game.location[b] === where) list.push(b) if (list.length === 0) return NOBODY return list[random(list.length)] } function select_random_enemy_block(where) { let list = [] for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && block_owner(b) === enemy(game.active)) list.push(b) if (list.length === 0) return NOBODY return list[random(list.length)] } function town_name(where) { return TOWNS[where].name } 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 is_home_seat(where, who) { if (block_type(who) === 'nomads') return where === block_home(who) if (set_has(SHIELDS[where], who)) return true return false } function block_pool(who) { if (BLOCKS[who].owner === FRANKS) return F_POOL return S_POOL } function block_owner(who) { return BLOCKS[who].owner } function block_initiative(who) { return BLOCKS[who].initiative } function block_fire_power(who) { return BLOCKS[who].fire_power } function block_move(who) { return BLOCKS[who].move } function block_max_steps(who) { return BLOCKS[who].steps } function is_saladin_family(who) { return who === SALADIN || who === AL_ADIL || who === AL_AZIZ || who === AL_AFDAL || who === AL_ZAHIR } function is_english_crusader(who) { return who === RICHARD || who === ROBERT || who === CROSSBOWS } function are_crusaders_not_in_pool(crusaders) { for (let b of crusaders) if (game.location[b] === F_POOL) return false return true } function is_block_on_map(who) { let location = game.location[who] return location && location !== DEAD && location !== F_POOL && location !== S_POOL } function is_block_on_land(who) { let location = game.location[who] return location && location !== DEAD && location !== F_POOL && location !== S_POOL && location !== ENGLAND && location !== FRANCE && location !== GERMANIA } function road_id(a, b) { return (a < b) ? a * 100 + b : b * 100 + a } function road_was_last_used_by_enemy(from, to) { return map_get(game.last_used, road_id(from, to)) === player_id(enemy(game.active)) } function road_was_last_used_by_friendly(from, to) { return map_get(game.last_used, road_id(from, to)) === player_id(game.active) } function road_type(a, b) { return ROADS[road_id(a,b)] } function road_limit(a, b) { return map_get(game.road_limit, road_id(a,b), 0) } function set_road_limit(a, b, n) { map_set(game.road_limit, road_id(a,b), n) } function reset_road_limits() { game.road_limit.length = 0 } function count_player(p, where) { let count = 0 for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && block_owner(b) === p) ++count return count } function count_friendly(where) { let p = game.active let count = 0 for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && block_owner(b) === p) ++count return count } function count_enemy(where) { let p = enemy(game.active) let count = 0 for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && block_owner(b) === p) ++count return count } function count_friendly_in_field(where) { let p = game.active let count = 0 for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && block_owner(b) === p) if (!is_block_in_castle(b)) ++count return count } function count_enemy_in_field(where) { let p = enemy(game.active) let count = 0 for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && block_owner(b) === p) if (!is_block_in_castle(b)) ++count return count } function count_friendly_in_field_excluding_reserves(where) { let p = game.active let count = 0 for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && block_owner(b) === p) if (!is_block_in_castle(b) && !is_reserve(b)) ++count return count } function count_enemy_in_field_excluding_reserves(where) { let p = enemy(game.active) let count = 0 for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && block_owner(b) === p) if (!is_block_in_castle(b) && !is_reserve(b)) ++count return count } function count_blocks_in_castle(where) { let n = 0 for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && set_has(game.castle, b)) ++n return n } function count_enemy_in_field_and_reserve(where) { let n = 0 for (let b = 0; b <= last_block; ++b) if (block_owner(b) !== game.active) if (game.location[b] === where && !set_has(game.castle, b)) ++n return n } function count_reserves(where) { let n = 0 for (let b = 0; b <= last_block; ++b) if (block_owner(b) === game.active) if (game.location[b] === where && is_reserve(b)) ++n return n } function is_player_kingdom(p, where) { return KINGDOMS[TOWNS[where].region] === p } function is_friendly_kingdom(where) { return KINGDOMS[TOWNS[where].region] === game.active } function is_enemy_kingdom(where) { return KINGDOMS[TOWNS[where].region] !== game.active } /* Town queries include castle and field. */ function is_friendly_town(where) { return (count_enemy(where) === 0) && (count_friendly(where) > 0 || is_friendly_kingdom(where)) } function is_enemy_town(where) { return (count_friendly(where) === 0) && (count_enemy(where) > 0 || is_enemy_kingdom(where)) } function is_vacant_town(where) { return count_friendly(where) === 0 && count_enemy(where) === 0 } function is_contested_town(where) { return count_friendly(where) > 0 && count_enemy(where) > 0 } function is_enemy_occupied_town(where) { return count_enemy(where) > 0 } /* Field queries exclude castles. */ function is_friendly_field(where) { return (count_enemy_in_field(where) === 0) && (count_friendly_in_field(where) > 0 || is_friendly_kingdom(where)) } function is_enemy_field(where) { return (count_friendly_in_field(where) === 0) && (count_enemy_in_field(where) > 0 || is_enemy_kingdom(where)) } function is_contested_field(where) { return count_friendly_in_field(where) > 0 && count_enemy_in_field(where) > 0 } function is_friendly_or_vacant_field(where) { return is_friendly_field(where) || is_vacant_town(where) } function is_enemy_or_contested_field(where) { return (count_enemy_in_field(where) > 0 || is_enemy_kingdom(where)) } /* Battle field queries exclude castles and reserves. */ function is_contested_battle_field() { let f = count_friendly_in_field_excluding_reserves(game.where) let e = count_enemy_in_field_excluding_reserves(game.where) return f > 0 && e > 0 } function is_friendly_battle_field() { return count_enemy_in_field_excluding_reserves(game.where) === 0 } function is_enemy_battle_field() { return count_friendly_in_field_excluding_reserves(game.where) === 0 } function is_reserve(who) { return set_has(game.reserves1, who) || set_has(game.reserves2, who) } function is_field_attacker(who) { if (game.location[who] === game.where && block_owner(who) === game.attacker[game.where]) return !is_reserve(who) && !is_block_in_castle(who) return false } function is_field_defender(who) { if (game.location[who] === game.where && block_owner(who) !== game.attacker[game.where]) return !is_reserve(who) && !is_block_in_castle(who) return false } function is_field_combatant(who) { if (game.location[who] === game.where) return !is_reserve(who) && !is_block_in_castle(who) return false } function is_block_in_field(who) { return !is_reserve(who) && !is_block_in_castle(who) } function is_siege_attacker(who) { return set_has(game.storming, who) } function is_siege_defender(who) { return is_block_in_castle_in(who, game.where) } function is_siege_combatant(who) { return set_has(game.storming, who) || is_block_in_castle_in(who, game.where) } function castle_limit(where) { return TOWNS[where].rating } function is_more_room_in_castle(where) { return count_blocks_in_castle(where) < castle_limit(where) } function is_within_castle_limit(where) { return count_friendly(where) <= Math.max(1, castle_limit(where)) } function is_castle_town(where) { return castle_limit(where) > 0 } function is_under_siege(where) { return count_blocks_in_castle(where) > 0 } function is_block_in_castle(b) { return set_has(game.castle, b) } function is_block_in_castle_in(b, town) { return game.location[b] === town && set_has(game.castle, b) } function besieged_player(where) { for (let b = 0; b <= last_block; ++b) if (is_block_in_castle_in(b, where)) return block_owner(b) return null } function besieging_player(where) { return enemy(besieged_player(where)) } function is_port(where) { return TOWNS[where].port } function is_friendly_port(where) { return TOWNS[where].port && is_friendly_field(where) } function can_activate(who) { return block_owner(who) === game.active && is_block_on_map(who) && !is_block_in_castle(who) && !set_has(game.moved, who) } function can_activate_for_sea_move(who) { return block_owner(who) === game.active && is_block_on_map(who) && !set_has(game.moved, who) } function count_pinning(where) { return count_enemy_in_field_excluding_reserves(where) } function count_pinned(where) { let count = 0 for (let b = 0; b <= last_block; ++b) if (game.location[b] === where && block_owner(b) === game.active) if (!is_reserve(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_use_road(from, to) { if (game.active === game.guide) { switch (road_type(from, to)) { case 'iron-bridge': // https://boardgamegeek.com/thread/744750/20-rules-iron-bridge-question // fallthrough case 'major': return road_limit(from, to) < 8 case 'minor': return road_limit(from, to) < 4 } } else { switch (road_type(from, to)) { case 'iron-bridge': if (game.iron_bridge) return road_limit(from, to) < 3 else return road_limit(from, to) < 4 case 'major': return road_limit(from, to) < 4 case 'minor': return road_limit(from, to) < 2 } } return false } function can_block_land_move_to(who, from, to) { if (can_block_use_road(from, to)) { if (count_pinning(from) > 0) if (road_was_last_used_by_enemy(from, to)) return false // cannot start or reinforce battles in winter if (is_winter() && is_enemy_occupied_town(to)) { // but can move through friendly sieges if (!is_friendly_field(to)) return false if (game.distance + 1 >= block_move(who)) return false } return true } return false } function can_germans_move(who) { let from = game.location[who] if (from === GERMANIA) { if (can_activate(who)) { for (let to of GERMAN_ROADS) if (can_germans_move_to(who, to)) return true } } return false } function can_germans_move_to(who, to) { if (are_crusaders_not_in_pool(GERMAN_CRUSADERS)) { if (is_winter() && is_enemy_occupied_town(to)) return false if (to === ALEPPO) return true if (to === ANTIOCH) return true if (to === ST_SIMEON) return road_limit(GERMANIA, ST_SIMEON) < 2 } 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 TOWNS[from].exits) if (can_block_land_move_to(who, from, to)) return true } } return false } function can_use_richards_sea_legs(who, to) { // English Crusaders may attack by sea. // If combined with another attack, the English must be the Main Attacker. if (is_english_crusader(who)) { if (is_enemy_or_contested_field(to)) { if (!game.attacker[to]) return true if (game.attacker[to] === FRANKS) return (get_main_road(to) === ENGLAND) } } return false } function can_enter_besieged_port(where) { // Tripoli and Tyre are friendly to besieged defender! if (where === TRIPOLI || where === TYRE) if (besieged_player(where) === game.active) return count_blocks_in_castle(where) < castle_limit(where) return false } function can_leave_besieged_port(where) { // Tripoli and Tyre are friendly to besieged defender! if (where === TRIPOLI || where === TYRE) if (besieged_player(where) === game.active) return true return false } function can_block_sea_move_to(who, to) { if (is_port(to)) { // cannot start or reinforce battles in winter if (!is_winter()) { if (can_use_richards_sea_legs(who, to)) return true if (can_enter_besieged_port(to)) return true } return is_friendly_port(to) } return false } function can_block_sea_move_from(who, from) { if (is_friendly_port(from)) return true if (can_leave_besieged_port(from)) return true if (from === ENGLAND) return are_crusaders_not_in_pool(ENGLISH_CRUSADERS) if (from === FRANCE) return are_crusaders_not_in_pool(FRENCH_CRUSADERS) return false } function can_block_sea_move(who) { if (can_activate_for_sea_move(who)) { let from = game.location[who] if (can_block_sea_move_from(who, from)) { for (let to of PORTS) if (to !== from && can_block_sea_move_to(who, to)) return true } } return false } function can_block_continue(who, from, to) { if (is_contested_field(to)) return false if (game.distance >= block_move(who)) return false return true } function can_block_retreat_to(who, to) { let from = game.location[who] if (block_owner(who) === game.attacker[from]) { if (!road_was_last_used_by_friendly(from, to)) return false } if (is_friendly_field(to) || is_vacant_town(to)) { if (can_block_use_road(from, to)) { if (road_was_last_used_by_enemy(from, to)) return false return true } } return false } function can_block_retreat(who) { if (block_owner(who) === game.active) { let from = game.location[who] for (let to of TOWNS[from].exits) if (can_block_retreat_to(who, to)) return true } return false } function can_block_regroup_to(who, to) { // regroup during winter campaign if (is_winter() && is_enemy_occupied_town(to)) return false if (is_friendly_field(to) || is_vacant_town(to)) { let from = game.location[who] if (can_block_use_road(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 TOWNS[from].exits) if (can_block_regroup_to(who, to)) return true } return false } function can_block_use_road_to_muster(from, to) { return can_block_use_road(from, to) && is_friendly_or_vacant_field(to) } function can_block_muster_with_3_moves(n0, muster) { for (let n1 of TOWNS[n0].exits) { if (can_block_use_road_to_muster(n0, n1)) { if (n1 === muster) return true for (let n2 of TOWNS[n1].exits) { if (n2 === n0) continue // don't backtrack! if (can_block_use_road_to_muster(n1, n2)) { if (n2 === muster) return true if (set_has(TOWNS[n2].exits, muster)) if (can_block_use_road_to_muster(n2, muster)) return true } } } } return false } function can_block_muster_with_2_moves(n0, muster, avoid) { for (let n1 of TOWNS[n0].exits) { if (n1 === avoid) continue if (can_block_use_road_to_muster(n0, n1)) { if (n1 === muster) return true if (set_has(TOWNS[n1].exits, muster)) if (can_block_use_road_to_muster(n1, muster)) return true } } return false } function can_block_muster_with_1_move(n0, muster) { if (set_has(TOWNS[n0].exits, muster)) return can_block_use_road_to_muster(n0, muster) return false } function can_block_muster(who, muster) { let from = game.location[who] if (from === muster) return false if (can_activate(who)) { if (is_pinned(who, from)) return false if (block_move(who) === 3) return can_block_muster_with_3_moves(from, muster) else return can_block_muster_with_2_moves(from, muster, NOWHERE) } return false } function can_muster_to(muster) { for (let b = 0; b <= last_block; ++b) if (can_block_muster(b, muster)) return true return false } function can_muster_anywhere() { for (let where = first_town; where <= last_town; ++where) if (is_friendly_field(where)) if (can_muster_to(where)) return true return false } function lift_siege(where) { if (is_under_siege(where) && !is_contested_town(where)) { log("Siege lifted at #" + where + ".") for (let b = 0; b <= last_block; ++b) if (is_block_in_castle_in(b, where)) set_delete(game.castle, b) } } function lift_all_sieges() { for (let town = first_town; town <= last_town; ++town) lift_siege(town) } function reset_blocks() { for (let b = 0; b <= last_block; ++b) { game.location[b] = NOWHERE game.steps[b] = block_max_steps(b) } } function deploy(who, where) { game.location[who] = where game.steps[who] = block_max_steps(who) } function disband(who) { game.summary.push([game.location[who]]) if (is_saladin_family(who) || block_type(who) === 'crusaders' || block_type(who) === 'military_orders') game.location[who] = NOWHERE // permanently eliminated else game.location[who] = DEAD // into to the pool next year game.steps[who] = block_max_steps(who) } function eliminate_block(who) { set_delete(game.castle, who) if (game.sallying) set_delete(game.sallying, who) if (game.storming) set_delete(game.storming, who) log(block_name(who) + " was eliminated.") if (is_saladin_family(who) || block_type(who) === 'crusaders' || block_type(who) === 'military_orders') game.location[who] = NOWHERE // permanently eliminated else game.location[who] = DEAD // into to the pool next year game.steps[who] = block_max_steps(who) } function reduce_block(who) { if (game.steps[who] === 1) { eliminate_block(who) } else { --game.steps[who] } } // DEPLOYMENT function is_valid_frank_deployment() { let errors = [] for (let town = first_town; town <= last_town; ++town) if (!is_within_castle_limit(town)) errors.push(TOWNS[town].name) return errors } function goto_frank_deployment() { game.active = FRANKS game.state = 'frank_deployment' } states.frank_deployment = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Deployment: Waiting for " + game.active + "." gen_action_undo(view) let errors = is_valid_frank_deployment() if (errors.length === 0) gen_action(view, 'next') for (let b = 0; b <= last_block; ++b) { if (block_owner(b) === game.active && is_block_on_land(b)) if (block_seats[b].length > 1) gen_action(view, 'block', b) } if (errors.length > 0) view.prompt = "Deployment: Too many blocks in " + join(errors, "and") + "." else view.prompt = "Deployment: You may make seat adjustments." }, block: function (who) { push_undo() game.who = who game.state = 'frank_deployment_to' }, next: function () { clear_undo() goto_saracen_deployment() }, undo: pop_undo } states.frank_deployment_to = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Deployment: Waiting for " + game.active + "." view.prompt = "Deployment: Move " + block_name(game.who) + " to " + block_seat_names_or[game.who] + "." gen_action_undo(view) gen_action(view, 'block', game.who) let from = game.location[game.who] for (let town of block_seats[game.who]) if (town !== from) gen_action(view, 'town', town) }, town: function (where) { game.location[game.who] = where game.who = NOBODY game.state = 'frank_deployment' }, block: pop_undo, undo: pop_undo } function goto_saracen_deployment() { for (let i = 0; i < 4; ++i) { let nomad = select_random_block(S_POOL) log(BLOCKS[nomad].name + " arrived in #" + block_home(nomad) + ".") deploy(nomad, block_home(nomad)) } game.active = SARACENS game.state = 'saracen_deployment' game.who = SALADIN } states.saracen_deployment = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Deployment: Waiting for " + game.active + "." view.prompt = "Deployment: You may swap places with Saladin and any other block of his family." gen_action(view, 'next') gen_action_undo(view) if (game.location[SALADIN] === DAMASCUS) { for (let b of SALADIN_FAMILY) if (b !== SALADIN && game.location[b] !== game.location[SALADIN]) gen_action(view, 'block', b) } }, block: function (who) { push_undo() let saladin = game.location[SALADIN] game.location[SALADIN] = game.location[who] game.location[who] = saladin game.who = NOBODY }, next: function () { clear_undo() game.who = NOBODY start_year() }, undo: pop_undo } // GAME TURN function is_friendly_town_for_player(p, where) { return (count_player(enemy(p), where) === 0) && (count_player(p, where) > 0 || is_player_kingdom(p, where)) } function is_friendly_town_for_vp(p, town) { if (is_friendly_town_for_player(p, town)) return true if (is_under_siege(town)) return besieged_player(town) === p return false } function count_victory_points(p) { let vp = 0 for (let town of VICTORY_TOWNS) if (is_friendly_town_for_vp(p, town)) ++ vp return vp } function check_sudden_death() { if (count_victory_points(FRANKS) === 7) { game.state = 'game_over' game.result = FRANKS game.victory = "Franks controlled all seven victory cities." log("") log(game.victory) return true } if (count_victory_points(SARACENS) === 7) { game.state = 'game_over' game.result = SARACENS game.victory = "Saracens controlled all seven victory cities." log("") log(game.victory) return true } } function start_year() { log("") log(".h1 Year " + game.year) game.turn = 1 let deck = shuffle_deck() game.f_hand = deal_cards(deck, 6) game.s_hand = deal_cards(deck, 6) game.prior_f_card = 0 game.prior_s_card = 0 start_game_turn() } function start_game_turn() { log("") log(".h1 Turn " + game.turn + " of Year " + game.year) game.guide = null game.jihad = null // Reset movement and attack tracking state reset_road_limits() game.last_used = [] game.attacker = {} set_clear(game.reserves1) set_clear(game.reserves2) set_clear(game.moved) goto_card_phase() } // CARD PHASE function goto_card_phase() { game.f_card = 0 game.s_card = 0 game.show_cards = false game.state = 'play_card' game.active = BOTH } function resume_play_card() { if (game.s_card && game.f_card) reveal_cards() else if (game.f_card) game.active = SARACENS else if (game.s_card) game.active = FRANKS else game.active = BOTH } states.play_card = { prompt: function (view, current) { if (current === OBSERVER) { view.prior_s_card = game.prior_s_card view.prior_f_card = game.prior_f_card return view.prompt = "Card Phase: Waiting for players to play a card." } if (current === FRANKS) { view.prior_s_card = game.prior_s_card if (game.f_card) { view.prompt = "Card Phase: Waiting for Saracens to play a card." } else { view.prior_f_card = game.prior_f_card view.prompt = "Card Phase: Play a card." for (let c of game.f_hand) if (game.turn > 1 || c !== INTRIGUE) gen_action(view, 'play', c) } } if (current === SARACENS) { view.prior_f_card = game.prior_f_card if (game.s_card) { view.prompt = "Card Phase: Waiting for Franks to play a card." } else { view.prior_s_card = game.prior_s_card view.prompt = "Card Phase: Play a card." for (let c of game.s_hand) if (game.turn > 1 || c !== INTRIGUE) gen_action(view, 'play', c) } } }, play: function (card, current) { if (current === FRANKS) { remove_from_array(game.f_hand, card) game.f_card = card } if (current === SARACENS) { remove_from_array(game.s_hand, card) game.s_card = card } resume_play_card() }, undo: function (_, current) { if (current === FRANKS) { game.f_hand.push(game.f_card) game.f_card = 0 } if (current === SARACENS) { game.s_hand.push(game.s_card) game.s_card = 0 } resume_play_card() } } function reveal_cards() { log("") log("Franks played " + CARDS[game.f_card].name + ".") log("Saracens played " + CARDS[game.s_card].name + ".") game.show_cards = true if (CARDS[game.f_card].event && CARDS[game.s_card].event) { log("Game Turn cancelled.") game.prior_f_card = game.f_card game.prior_s_card = game.s_card end_game_turn() return } if (game.f_card === INTRIGUE) { game.f_card = game.prior_s_card log("Intrigue copied " + CARDS[game.f_card].name + ".") } if (game.s_card === INTRIGUE) { game.s_card = game.prior_f_card log("Intrigue copied " + CARDS[game.s_card].name + ".") } delete game.winter_campaign if (is_winter()) { if (game.f_card === WINTER_CAMPAIGN) game.winter_campaign = FRANKS if (game.s_card === WINTER_CAMPAIGN) game.winter_campaign = SARACENS } game.prior_f_card = game.f_card game.prior_s_card = game.s_card let fp = CARDS[game.f_card].event ? 10 : CARDS[game.f_card].moves let sp = CARDS[game.s_card].event ? 10 : CARDS[game.s_card].moves if (fp === sp) { let die = roll_d6() log("Random first player.") if (die > 3) ++fp else ++sp } if (fp > sp) { game.p1 = FRANKS game.p2 = SARACENS } else { game.p1 = SARACENS game.p2 = FRANKS } game.active = game.p1 start_player_turn() } function start_player_turn() { log("") log(".h2 " + game.active) reset_road_limits() game.main_road = [] let card = CARDS[game.active === FRANKS ? game.f_card : game.s_card] if (card.event) goto_event_card(card.event) else goto_move_phase(card.moves) } function end_player_turn() { game.moves = 0 game.main_road = null if (game.active === game.p2) { goto_combat_phase() } else { game.active = game.p2 start_player_turn() } } // EVENTS function goto_event_card(event) { switch (event) { case 'assassins': goto_assassins(); break case 'guide': goto_guide(); break case 'jihad': goto_jihad(); break case 'manna': goto_manna(); break } } function goto_assassins() { game.state = 'assassins' game.who = ASSASSINS game.assassins = 1 } states.assassins = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Assassins: Waiting for " + game.active + "." view.prompt = "Assassins: Choose one enemy block." for (let b = 0; b <= last_block; ++b) { if (is_block_on_land(b) && block_owner(b) === enemy(game.active)) gen_action(view, 'block', b) } }, block: function (who) { game.where = game.location[who] game.who = select_random_enemy_block(game.where) game.location[ASSASSINS] = game.where game.state = 'assassins_show_1' }, } states.assassins_show_1 = { prompt: function (view, current) { view.assassinate = game.who view.who = ASSASSINS if (is_inactive_player(current)) return view.prompt = "Assassins: Waiting for " + game.active + "." view.prompt = "Assassins: The assassins target " + block_name(game.who) + " in " + town_name(game.where) + "." gen_action(view, 'next') gen_action(view, 'block', game.who) gen_action(view, 'block', ASSASSINS) }, next: assassins_next_1, block: assassins_next_1, } function assassins_next_1() { assassinate(game.who, game.where) game.state = 'assassins_show_2' } states.assassins_show_2 = { prompt: function (view, current) { view.assassinate = game.who view.who = ASSASSINS if (is_inactive_player(current)) return view.prompt = "Assassins: Waiting for " + game.active + "." view.prompt = "Assassins: The assassins hit " + block_name(game.who) + " in " + town_name(game.where) + "." gen_action(view, 'next') gen_action(view, 'block', ASSASSINS) gen_action(view, 'town', MASYAF) }, next: assassins_next_2, block: assassins_next_2, town: assassins_next_2, } function assassins_next_2() { game.location[ASSASSINS] = MASYAF game.who = NOBODY if (count_friendly(game.where) > 0 && count_enemy(game.where) === 0) { goto_regroup() } else { delete game.assassins game.where = NOWHERE end_player_turn() } } function assassinate(who, where) { let hits = 0 let rolls = [] for (let i = 0; i < 3; ++i) { let die = roll_d6() if (die <= 3) { rolls.push(DIE_HIT[die]) ++hits } else { rolls.push(DIE_MISS[die]) } } hits = Math.min(hits, game.steps[who]) log("Assassins hit " + block_name(who) + " at #" + where + ": " + rolls.join("") + ".") for (let i = 0; i < hits; ++i) reduce_block(who) } function goto_guide() { game.guide = game.active game.state = 'move_phase_event' game.summary = [] } function goto_jihad() { game.jihad = game.active game.state = 'move_phase_event' game.summary = [] } function goto_select_jihad() { game.jihad_list = [] for (let where = first_town; where <= last_town; ++where) if (is_contested_field(where) || besieging_player(where) === game.active) game.jihad_list.push(where) if (game.jihad_list.length === 0) { delete game.jihad_list return end_player_turn() } if (game.jihad_list.length === 1) { game.jihad = game.jihad_list[0] log("Jihad in #" + game.jihad + ".") delete game.jihad_list return end_player_turn() } game.state = 'select_jihad' } states.select_jihad = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Jihad: Waiting for " + game.active + "." view.prompt = "Jihad: Select battle for Jihad." for (let town of game.jihad_list) gen_action(view, 'town', town) }, town: function (where) { game.jihad = where log("Jihad in #" + game.jihad + ".") delete game.jihad_list end_player_turn() }, } function goto_manna() { game.state = 'manna' game.moves = 3 set_clear(game.moved) game.summary = [] } states.manna = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Manna: Waiting for " + game.active + "." view.prompt = "Manna: Add one step to three different friendly blocks \u2014 " + game.moves + " left." gen_action_undo(view) gen_action(view, 'next') if (game.moves > 0) { for (let b = 0; b <= last_block; ++b) { if (is_block_on_land(b) && block_owner(b) === game.active && !set_has(game.moved, b)) if (game.steps[b] < block_max_steps(b)) gen_action(view, 'block', b) } } }, block: function (who) { push_undo() game.summary.push([game.location[who]]) ++game.steps[who] --game.moves set_add(game.moved, who) }, next: function () { clear_undo() print_summary(game.active + " used Manna:") set_clear(game.moved) end_player_turn() }, undo: pop_undo } // MOVE PHASE function queue_attack(who, round) { if (round === 1) return ATTACK_MARK if (round === 2) { set_add(game.reserves1, who) return RESERVE_MARK_1 } if (round === 3) { set_add(game.reserves2, who) return RESERVE_MARK_2 } } function get_main_road(to) { return map_get(game.main_road, to, NOWHERE) } function set_main_road(to, x) { map_set(game.main_road, to, x) } function move_block(who, from, to) { game.location[who] = to set_road_limit(from, to, road_limit(from, to) + 1) game.distance ++ if (is_contested_field(to)) { map_set(game.last_used, road_id(from, to), player_id(game.active)) // 6.56 Main Attack relief force by Player 2 arrives one round later than normal let relief_delay = 0 if (game.active === game.p2 && besieged_player(to) === game.p2) { relief_delay = 1 } if (!game.attacker[to]) { game.attacker[to] = game.active set_main_road(to, from) return queue_attack(who, 1 + relief_delay) } else { // Attacker main attack or reinforcements if (game.attacker[to] === game.active) { if (get_main_road(to) !== from) return queue_attack(who, 2 + relief_delay) return queue_attack(who, 1 + relief_delay) } // Defender reinforcements if (!get_main_road(to)) set_main_road(to, from) if (get_main_road(to) === from) { return queue_attack(who, 2) } else { return queue_attack(who, 3) } } } return false } function goto_move_phase(moves) { game.state = 'move_phase' game.moves = moves } function end_move_phase() { if (game.moves > 0) { push_undo() game.state = 'confirm_end_move_phase' return } clear_undo() game.who = NOBODY game.where = NOWHERE game.moves = 0 // declined to use winter campaign if (game.winter_campaign === game.active) delete game.winter_campaign if (game.active === game.jihad) goto_select_jihad() else end_player_turn() } states.confirm_end_move_phase = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." if (game.moves > 1) view.prompt = "Move Phase: You have " + game.moves + " moves left" else view.prompt = "Move Phase: You have 1 move left" view.prompt += " \u2014 are you sure you want to end the move phase?" gen_action_undo(view) gen_action(view, 'end_move_phase') }, end_move_phase: function () { if (game.moves === 1) log(game.active + " did nothing with " + game.moves + " move.") else log(game.active + " did nothing with " + game.moves + " moves.") game.moves = 0 end_move_phase() }, undo: pop_undo } states.move_phase = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." if (game.moves === 0) view.prompt = "Move Phase: No moves left." else if (game.moves === 1) view.prompt = "Move Phase: 1 move left." else view.prompt = "Move Phase: " + game.moves + " moves left." gen_action_undo(view) gen_action(view, 'end_move_phase') if (game.moves > 0) { for (let b = 0; b <= last_block; ++b) { if (can_block_land_move(b)) gen_action(view, 'block', b) if (can_block_sea_move(b)) gen_action(view, 'block', b) if (can_germans_move(b)) gen_action(view, 'block', b) } if (can_muster_anywhere()) gen_action(view, 'muster') if (game.winter_campaign === game.active) gen_action(view, 'winter_campaign') } }, winter_campaign: function () { push_undo() --game.moves game.state = 'winter_campaign' }, muster: function () { push_undo() --game.moves game.state = 'muster' }, block: function (who) { push_undo() game.summary = [] game.who = who game.where = game.location[who] if (game.where === GERMANIA) { game.state = 'german_move_to' } else if (game.where === FRANCE || game.where === ENGLAND) { game.state = 'sea_move_to' } else { game.state = 'move_phase_to' } }, end_move_phase: end_move_phase, undo: pop_undo } states.move_phase_event = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = group_move_name(1) + "Waiting for " + game.active + "." view.prompt = group_move_name(0) + "Choose a block to group move." gen_action_undo(view) gen_action(view, 'end_move_phase') // TODO: can_germans_move! for (let b = 0; b <= last_block; ++b) if (can_block_land_move(b)) gen_action(view, 'block', b) }, block: function (who) { push_undo() game.where = game.location[who] game.who = who game.distance = 0 game.last_from = NOWHERE game.state = 'group_move_to' }, end_move_phase: end_move_phase, undo: pop_undo } // Start new group move or sea move. states.move_phase_to = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." view.prompt = "Move Phase: Move " + block_name(game.who) gen_action_undo(view) gen_action(view, 'block', game.who) let from = game.location[game.who] let can_group_move = false let can_sea_move = false if (can_block_land_move(game.who)) { for (let to of TOWNS[from].exits) { if (can_block_land_move_to(game.who, from, to)) { gen_action(view, 'town', to) can_group_move = true } } } if (can_block_sea_move(game.who)) { gen_action(view, 'town', SEA) can_sea_move = true } if (can_group_move && can_sea_move) view.prompt += " by road or by sea." else if (can_sea_move) view.prompt += " by sea." else view.prompt += " by road." }, town: function (to) { set_add(game.moved, game.who) let from = game.location[game.who] if (to === SEA) { log_move_start(from) log_move_continue(to) game.location[game.who] = SEA game.state = 'sea_move_to' return } -- game.moves game.distance = 0 log_move_start(from) let mark = move_block(game.who, from, to) if (mark) log_move_continue(to, mark) else log_move_continue(to) lift_siege(from) game.last_from = from if (!can_block_continue(game.who, from, to)) end_move() else { game.state = 'group_move_to' } }, block: pop_undo, undo: pop_undo } // GROUP MOVE function group_move_name() { if (game.active === game.jihad) return "Jihad: " if (game.active === game.guide) return "Guide: " return "Group Move: " } function can_group_move_more() { for (let b = 0; b <= last_block; ++b) if (game.location[b] === game.where) if (can_block_land_move(b)) return true return false } states.group_move_who = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = group_move_name(1) + "Waiting for " + game.active + "." view.prompt = group_move_name(0) + "Move blocks from " + town_name(game.where) + "." gen_action_undo(view) if (game.active === game.guide || game.active === game.jihad) gen_action(view, 'end_move_phase') else gen_action(view, 'end_group_move') for (let b = 0; b <= last_block; ++b) if (game.location[b] === game.where) if (can_block_land_move(b)) gen_action(view, 'block', b) }, block: function (who) { push_undo() game.who = who game.distance = 0 game.last_from = NOWHERE game.state = 'group_move_to' }, end_move_phase: function () { end_group_move() end_move_phase() }, end_group_move: end_group_move, undo: pop_undo } states.group_move_to = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = group_move_name(1) + "Waiting for " + game.active + "." view.prompt = group_move_name(0) + "Move " + block_name(game.who) + "." gen_action_undo(view) if (game.distance === 0) gen_action(view, 'block', game.who) let from = game.location[game.who] if (game.distance > 0) { // cannot start or reinforce battles in winter if (!(is_winter() && is_enemy_occupied_town(from))) gen_action(view, 'town', from) } for (let to of TOWNS[from].exits) { if (to !== game.last_from && can_block_land_move_to(game.who, from, to)) { gen_action(view, 'town', to) } } }, town: function (to) { set_add(game.moved, game.who) let from = game.location[game.who] if (to === from) { end_move() return } if (game.distance === 0) log_move_start(from) let mark = move_block(game.who, from, to) if (mark) log_move_continue(to, mark) else log_move_continue(to) lift_siege(from) game.last_from = from if (!can_block_continue(game.who, from, to)) end_move() }, block: pop_undo, undo: pop_undo } function end_move() { log_move_end() game.who = NOBODY game.distance = 0 if (can_group_move_more()) game.state = 'group_move_who' else end_group_move() } function end_group_move() { print_summary(game.active + " activated #" + game.where + ":") game.state = 'move_phase' } // SEA MOVE states.german_move_to = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." view.prompt = "Move Phase: Move " + block_name(game.who) + " to Aleppo, Antioch, or St. Simeon." gen_action_undo(view) gen_action(view, 'block', game.who) for (let to of GERMAN_ROADS) if (can_germans_move_to(game.who, to)) gen_action(view, 'town', to) }, town: function (to) { --game.moves let from = GERMANIA game.location[game.who] = to set_add(game.moved, game.who) game.distance = 0 let mark = move_block(game.who, from, to) if (mark) log(game.active + " moved:\n Germania \u2192 #" + to + mark + ".") else log(game.active + " moved:\n Germania \u2192 #" + to + ".") game.who = NOBODY game.state = 'move_phase' }, block: pop_undo, undo: pop_undo, } states.sea_move_to = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." if (is_english_crusader(game.who)) view.prompt = "Sea Move: Move " + block_name(game.who) + " to a port." else view.prompt = "Sea Move: Move " + block_name(game.who) + " to a friendly port." gen_action_undo(view) gen_action(view, 'block', game.who) let from = game.location[game.who] if (from === GERMANIA) { for (let to of GERMAN_ROADS) if (can_germans_move_to(game.who, to)) gen_action(view, 'town', to) } else { for (let to of PORTS) if (to !== game.where && can_block_sea_move_to(game.who, to)) gen_action(view, 'town', to) } }, town: function (to) { --game.moves let from = game.where game.location[game.who] = to set_add(game.moved, game.who) lift_siege(from) set_delete(game.castle, game.who) if (besieged_player(to) === game.active && is_more_room_in_castle(to)) { // Move into besieged fortified port set_add(game.castle, game.who) log(game.active + " sea moved:") logi("#" + from + " \u2192 #" + to + " castle.") } else if (!is_friendly_port(to)) { // English Crusaders attack! game.attacker[to] = FRANKS set_main_road(to, ENGLAND) log(game.active + " sea moved:") logi("#" + from + " \u2192 #" + to + ATTACK_MARK + ".") } else { // Normal move. log(game.active + " sea moved:") logi("#" + from + " \u2192 #" + to + ".") } game.who = NOBODY game.state = 'move_phase' }, block: pop_undo, undo: pop_undo, } // MUSTER states.muster = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." view.prompt = "Muster: Choose a friendly muster town." gen_action_undo(view) for (let where = first_town; where <= last_town; ++where) { // cannot start or reinforce battles in winter if (is_winter()) { if (is_friendly_town(where)) if (can_muster_to(where)) gen_action(view, 'town', where) } else { if (is_friendly_field(where)) if (can_muster_to(where)) gen_action(view, 'town', where) } } }, town: function (where) { push_undo() game.where = where game.state = 'muster_who' game.summary = [] }, undo: pop_undo, } states.muster_who = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." view.prompt = "Muster: Move blocks to " + town_name(game.where) + "." view.muster = game.where gen_action_undo(view) gen_action(view, 'end_muster') for (let b = 0; b <= last_block; ++b) if (can_block_muster(b, game.where)) gen_action(view, 'block', b) }, block: function (who) { push_undo() game.who = who game.state = 'muster_move_1' }, end_muster: function () { print_summary(game.active + " mustered to #" + game.where + ":") game.where = NOWHERE game.state = 'move_phase' }, undo: pop_undo, } states.muster_move_1 = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." view.prompt = "Muster: Move " + block_name(game.who) + " to " + town_name(game.where) + "." view.muster = game.where gen_action_undo(view) gen_action(view, 'block', game.who) let from = game.location[game.who] let muster = game.where if (block_move(game.who) === 3) { for (let to of TOWNS[from].exits) { if (can_block_use_road_to_muster(from, to)) { if (to === muster || can_block_muster_with_2_moves(to, muster, from)) gen_action(view, 'town', to) } } } else { for (let to of TOWNS[from].exits) { if (can_block_use_road_to_muster(from, to)) { if (to === muster || can_block_muster_with_1_move(to, muster)) gen_action(view, 'town', to) } } } }, town: function (to) { let from = game.location[game.who] set_add(game.moved, game.who) log_move_start(from) log_move_continue(to) move_block(game.who, from, to) lift_siege(from) if (to === game.where) { end_muster_move() } else { game.state = 'muster_move_2' } }, block: pop_undo, undo: pop_undo, } states.muster_move_2 = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." view.prompt = "Muster: Move " + block_name(game.who) + " to " + town_name(game.where) + "." view.muster = game.where gen_action_undo(view) let from = game.location[game.who] let muster = game.where if (block_move(game.who) === 3) { for (let to of TOWNS[from].exits) { if (can_block_use_road_to_muster(from, to)) { if (to === muster || can_block_muster_with_1_move(to, muster)) gen_action(view, 'town', to) } } } else { for (let to of TOWNS[from].exits) { if (can_block_use_road_to_muster(from, to)) { if (to === muster) gen_action(view, 'town', to) } } } }, town: function (to) { let from = game.location[game.who] log_move_continue(to) move_block(game.who, from, to) if (to === game.where) { end_muster_move() } else { game.state = 'muster_move_3' } }, undo: pop_undo, } states.muster_move_3 = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." view.prompt = "Muster: Move " + block_name(game.who) + " to " + town_name(game.where) + "." view.muster = game.where gen_action_undo(view) let from = game.location[game.who] let muster = game.where for (let to of TOWNS[from].exits) { if (can_block_use_road_to_muster(from, to)) { if (to === muster) gen_action(view, 'town', to) } } }, town: function (to) { let from = game.location[game.who] log_move_continue(to) move_block(game.who, from, to) end_muster_move() }, undo: pop_undo, } function end_muster_move() { log_move_end() game.who = NOBODY game.state = 'muster_who' } // WINTER CAMPAIGN states.winter_campaign = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Move Phase: Waiting for " + game.active + "." view.prompt = "Winter Campaign: Select a siege to maintain over the winter." gen_action_undo(view) for (let town = first_town; town <= last_town; ++town) if (is_friendly_field(town) && is_under_siege(town)) gen_action(view, 'town', town) }, town: function (where) { log(game.active + " winter campaigned at #" + where + ".") game.winter_campaign = where game.state = 'move_phase' }, undo: pop_undo } // COMBAT PHASE function goto_combat_phase() { if (is_winter()) { set_clear(game.moved) return end_game_turn() } game.combat_list = [] for (let where = first_town; where <= last_town; ++where) if (is_contested_town(where)) set_add(game.combat_list, where) resume_combat_phase() } function resume_combat_phase() { reset_road_limits() reset_moved_for_combat() if (game.combat_list.length > 0) { game.active = game.p1 game.state = 'combat_phase' } else { goto_draw_phase() } } states.combat_phase = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Battle Phase: Waiting for " + game.active + "." view.prompt = "Battle Phase: Choose the next battle or siege!" for (let where of game.combat_list) gen_action(view, 'town', where) }, town: function (where) { set_delete(game.combat_list, where) game.where = where start_combat() }, } function start_combat() { game.flash = "" log("") log(".h3 Battle at #" + game.where) game.combat_round = 0 game.halfhit = NOBODY game.storming = [] game.sallying = [] game.hits = 0 game.show_castle = 0 game.show_field = 0 if (is_castle_town(game.where)) { if (!is_under_siege(game.where)) { log(".h4 Combat Deployment") game.castle_owner = enemy(game.attacker[game.where]) game.active = game.castle_owner game.state = 'combat_deployment' game.is_existing_siege = 0 } else { game.castle_owner = besieged_player(game.where) if (!game.attacker[game.where]) game.attacker[game.where] = enemy(game.castle_owner) log("Existing siege continued.") game.is_existing_siege = 1 next_combat_round() } } else { game.castle_owner = null next_combat_round() } } function end_combat() { lift_siege(game.where) if (game.jihad === game.where) game.jihad = null delete game.is_existing_siege delete game.castle_owner delete game.storming delete game.sallying delete game.show_castle delete game.show_field game.where = NOWHERE game.flash = "" game.combat_round = 0 resume_combat_phase() } // COMBAT DEPLOYMENT states.combat_deployment = { show_battle: true, prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Battle: Waiting for " + game.active + "." view.prompt = "Battle: Deploy blocks on the field and in the castle." let max = castle_limit(game.where) let n = count_blocks_in_castle(game.where) let have_options = false if (n < max) { for (let b = 0; b <= last_block; ++b) { if (block_owner(b) === game.active && !is_reserve(b)) { if (game.location[b] === game.where && !set_has(game.castle, b)) { gen_action(view, 'withdraw', b) gen_action(view, 'block', b) have_options = true } } } } if (!have_options) view.flash_next = "Click Next when you're done." gen_action_undo(view) gen_action(view, 'next') }, withdraw: function (who) { push_undo() set_add(game.castle, who) }, block: function (who) { push_undo() set_add(game.castle, who) }, next: function () { clear_undo() let n = count_blocks_in_castle(game.where) if (n === 1) log(game.active + " withdrew 1 block.") else log(game.active + " withdrew " + n + " blocks.") game.active = game.attacker[game.where] if (count_enemy_in_field_and_reserve(game.where) === 0) { return goto_regroup() } next_combat_round() }, undo: pop_undo } // REGROUP AFTER FIELD BATTLE/SIEGE VICTORY function print_retreat_summary() { if (game.summary && game.summary.length > 0) print_summary("Retreated from #" + game.where + ":") } function goto_regroup() { lift_siege(game.where) if (!is_under_siege(game.where)) clear_reserves() // no siege battle, reserves arrive before regroup reset_road_limits() reset_moved_for_combat() game.state = 'regroup' game.summary = [] } states.regroup = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Regroup: Waiting for " + game.active + "." view.prompt = "Regroup: Choose a block to move." gen_action_undo(view) gen_action(view, 'end_regroup') for (let b = 0; b <= last_block; ++b) { if (game.location[b] === game.where) { 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 () { clear_undo() reset_road_limits() print_summary(game.active + " regrouped:", true) if (game.assassins) { delete game.assassins game.where = NOWHERE end_player_turn() } else if (is_winter()) goto_winter_2() else if (is_contested_town(game.where)) next_combat_round() else end_combat() }, undo: pop_undo } states.regroup_to = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Regroup: Waiting for " + game.active + "." view.prompt = "Regroup: Move the block to a friendly or vacant town." gen_action_undo(view) gen_action(view, 'block', game.who) for (let to of TOWNS[game.where].exits) if (can_block_regroup_to(game.who, to)) gen_action(view, 'town', to) }, town: function (to) { // We can regroup while reserves are still on the way... set_delete(game.reserves1, game.who) set_delete(game.reserves2, game.who) let from = game.where game.summary.push([from, to]) move_block(game.who, game.where, to) game.who = NOBODY game.state = 'regroup' }, block: pop_undo, undo: pop_undo } // COMBAT ROUND function next_combat_round() { print_retreat_summary() if (game.jihad === game.where && game.combat_round === 1) game.jihad = null switch (game.combat_round) { case 0: return goto_combat_round(1) case 1: return goto_combat_round(2) case 2: return goto_combat_round(3) case 3: return goto_retreat_after_combat() } } function bring_on_reserves(reserves) { let f = 0 let s = 0 for (let b = 0; b <= last_block; ++b) { if (game.location[b] === game.where) { if (set_has(reserves, b)) { if (block_owner(b) === FRANKS) ++f else ++s set_delete(reserves, b) } } } if (f > 0) log(f + " Frank " + (f === 1 ? "reserve arrived." : "reserves arrived.")) if (s > 0) log(s + " Saracen " + (s === 1 ? "reserve arrived." : "reserves arrived.")) } function clear_reserves(where) { for (let b = 0; b <= last_block; ++b) { if (game.location[b] === where) { set_delete(game.reserves1, b) set_delete(game.reserves2, b) } } } function reset_moved_for_combat() { set_clear(game.moved) for (let b of game.reserves1) set_add(game.moved, b) for (let b of game.reserves2) set_add(game.moved, b) } function goto_combat_round(new_combat_round) { game.combat_round = new_combat_round game.summary = [] let was_contested = is_contested_battle_field() // If the main attack regroups away from a new siege while reinforcements // are on the way, we need to skip the first combat round. if (game.combat_round === 1 && is_under_siege(game.where)) { game.active = besieging_player(game.where) if (count_friendly_in_field_excluding_reserves(game.where) === 0) { log("Combat round skipped because main attack regrouped away.") game.combat_round = 2 } } log(".h4 Combat Round " + game.combat_round) if (game.combat_round === 2) bring_on_reserves(game.reserves1) if (game.combat_round === 3) bring_on_reserves(game.reserves2) reset_moved_for_combat() if (is_contested_battle_field()) { if (is_under_siege(game.where)) { if (!was_contested) { log("Relief forces arrived!") if (game.storming.length > 0) { log("Storming canceled by arriving relief force.") game.halfhit = NOBODY game.storming.length = 0 } let old_attacker = game.attacker[game.where] game.attacker[game.where] = besieged_player(game.where) if (old_attacker !== game.attacker[game.where]) { log(game.attacker[game.where] + " are now the attacker.") } } // No sally first round after combat deployment. if (game.combat_round > 1 || game.is_existing_siege) return goto_declare_sally() } return goto_field_battle() } goto_declare_storm() } // DECLARE STORM function goto_declare_storm() { game.active = besieging_player(game.where) // Castle is full. if (game.storming.length === castle_limit(game.where)) return goto_siege_battle() // Field is empty. if (count_friendly(game.where) - game.storming.length === 0) return goto_siege_battle() game.state = 'declare_storm' } states.declare_storm = { show_battle: true, prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Siege Declaration: Waiting for " + game.active + " to declare storm." view.prompt = "Siege Declaration: Declare which blocks should storm the castle." let have_options = false if (game.storming.length < castle_limit(game.where)) { for (let b = 0; b <= last_block; ++b) { if (block_owner(b) === game.active && !is_reserve(b)) { if (game.location[b] === game.where && !set_has(game.storming, b)) { gen_action(view, 'storm', b) gen_action(view, 'block', b) have_options = true } } } } if (!have_options) view.flash_next = "Click Next when you're done." gen_action_undo(view) gen_action(view, 'next') }, storm: storm_with_block, block: storm_with_block, next: function () { clear_undo() let n = game.storming.length if (n === 0) { game.flash = game.active + " declined to storm." if (game.jihad === game.where) game.jihad = null log(game.active + " declined to storm.") goto_declare_sally() } else { goto_siege_battle() } }, undo: pop_undo } function storm_with_block(who) { push_undo() set_add(game.storming, who) if (game.storming.length > 1) game.flash = game.active + " stormed with " + game.storming.length + " blocks." else game.flash = game.active + " stormed with 1 block." log(game.active[0] + ": " + block_name(who) + " stormed.") } // DECLARE SALLY function goto_declare_sally() { game.active = besieged_player(game.where) game.state = 'declare_sally' game.was_contested = is_contested_battle_field() } states.declare_sally = { show_battle: true, prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Siege Declaration: Waiting for " + game.active + " to declare sally." view.prompt = "Siege Declaration: Declare which blocks should sally onto the field." let have_options = false for (let b = 0; b <= last_block; ++b) { if (block_owner(b) === game.active && !is_reserve(b) && is_block_in_castle(b)) { if (game.location[b] === game.where && !set_has(game.sallying, b)) { gen_action(view, 'sally', b) gen_action(view, 'block', b) have_options = true } } } if (!have_options) view.flash_next = "Click Next when you're done." gen_action_undo(view) gen_action(view, 'next') }, sally: sally_with_block, block: sally_with_block, next: function () { clear_undo() let n = game.sallying.length if (n === 0) { game.flash = game.active + " declined to sally." log(game.active + " declined to sally.") } if (is_contested_battle_field()) { if (!game.was_contested) { log(game.active + " are now the attacker.") game.attacker[game.where] = game.active } goto_field_battle() } else if (count_reserves(game.where) > 0) { next_combat_round() } else { goto_siege_attrition() } }, undo: pop_undo } function sally_with_block(who) { push_undo() set_delete(game.castle, who) set_add(game.sallying, who) if (game.sallying.length > 1) game.flash = game.active + " sallied with " + game.sallying.length + " blocks." else game.flash = game.active + " sallied with 1 block." log(game.active[0] + ": " + block_name(who) + " sallied.") } // RETREAT AFTER COMBAT function goto_retreat_after_combat() { reset_moved_for_combat() // withdraw all sallying blocks to castle. for (let b of game.sallying) set_add(game.castle, b) game.sallying.length = 0 // TODO: 6.2 - In Sieges, the attacker /may/ retreat or stay on siege. // withdraw all storming blocks to the field. game.halfhit = NOBODY game.storming.length = 0 if (is_contested_field(game.where)) { log(".h4 Retreat") game.active = game.attacker[game.where] game.state = 'retreat' game.summary = [] } else if (is_under_siege(game.where)) { goto_siege_attrition() } else { end_combat() } } states.retreat = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Retreat: Waiting for " + game.active + "." view.prompt = "Retreat: Choose a block to move." gen_action_undo(view) let can_retreat = false for (let b = 0; b <= last_block; ++b) { if (game.location[b] === game.where && !is_block_in_castle(b) && can_block_retreat(b)) { gen_action(view, 'block', b) can_retreat = true } } if (!is_contested_field(game.where) || !can_retreat) gen_action(view, 'end_retreat') }, end_retreat: function () { clear_undo() for (let b = 0; b <= last_block; ++b) if (game.location[b] === game.where && !is_block_in_castle(b) && block_owner(b) === game.active) eliminate_block(b) print_summary(game.active + " retreated:") game.active = enemy(game.active) goto_regroup() }, block: function (who) { push_undo() game.who = who game.state = 'retreat_to' }, undo: pop_undo } states.retreat_to = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Retreat: Waiting for " + game.active + "." view.prompt = "Retreat: Move the block to a friendly or neutral town." gen_action_undo(view) gen_action(view, 'block', game.who) let can_retreat = false for (let to of TOWNS[game.where].exits) { if (can_block_retreat_to(game.who, to)) { gen_action(view, 'town', to) can_retreat = true } } if (!can_retreat) gen_action(view, 'eliminate') }, town: function (to) { let from = game.where game.summary.push([from, to]) move_block(game.who, game.where, to) game.who = NOBODY game.state = 'retreat' }, eliminate: function () { eliminate_block(game.who) game.who = NOBODY game.state = 'retreat' }, block: pop_undo, undo: pop_undo } // SIEGE ATTRITION function goto_siege_attrition() { log(".h4 Siege Attrition") game.active = besieged_player(game.where) game.state = 'siege_attrition' game.attrition_list = [] for (let b = 0; b <= last_block; ++b) if (is_block_in_castle_in(b, game.where)) set_add(game.attrition_list, b) } function resume_siege_attrition() { if (game.attrition_list.length === 0) { delete game.attrition_list if (!is_under_siege(game.where)) { game.active = enemy(game.active) log("#" + game.where + " fell to siege attrition.") goto_regroup() } else { log("Siege continued.") end_combat() } } } states.siege_attrition = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Siege Attrition: Waiting for " + game.active + "." view.prompt = "Siege Attrition: Roll for siege attrition in " + town_name(game.where) + "." for (let b of game.attrition_list) gen_action(view, 'block', b) }, block: function (who) { let target = (game.where === TYRE || game.where === TRIPOLI) ? 1 : 3 let die = roll_d6() if (die <= target) { log("Attrition roll " + DIE_HIT[die] + ".") reduce_block(who) } else { log("Attrition roll " + DIE_MISS[die] + ".") } set_delete(game.attrition_list, who) resume_siege_attrition() } } // FIELD AND SIEGE BATTLE HELPERS function count_enemy_hp_in_field() { let hp = 0 for (let b = 0; b <= last_block; ++b) if (block_owner(b) !== game.active && is_field_combatant(b)) hp += game.steps[b] return hp } function count_enemy_hp_in_siege() { let hp = 0 for (let b = 0; b <= last_block; ++b) if (block_owner(b) !== game.active && is_siege_combatant(b)) if (is_block_in_castle(b)) hp += game.steps[b] * 2 else hp += game.steps[b] if (game.halfhit !== NOBODY && block_owner(game.halfhit) !== game.active) hp -= 1 return hp } function must_apply_field_hits() { if (game.immediate) return game.hits > 0 return game.hits >= count_enemy_hp_in_field() } function must_apply_siege_hits() { if (game.immediate) return game.hits > 0 return game.hits >= count_enemy_hp_in_siege() } function filter_battle_blocks(ci, is_candidate) { let output = null for (let b = 0; b <= last_block; ++b) { if (is_candidate(b) && !set_has(game.moved, 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) { if (game.active !== active) { game.active = active if (game.hits > 0) { goto_battle_hits() } } return true } return false } function goto_battle_hits() { if (game.state === 'field_battle') goto_field_battle_hits() else if (game.state === 'siege_battle') goto_siege_battle_hits() else throw new Error("invalid battle state") } function pump_battle_step(is_candidate_attacker, is_candidate_defender) { let attacker = game.attacker[game.where] let defender = enemy(attacker) if (game.jihad === game.where && game.combat_round === 1) { if (battle_step(attacker, 'A', is_candidate_attacker)) return if (battle_step(attacker, 'B', is_candidate_attacker)) return if (battle_step(attacker, 'C', is_candidate_attacker)) return if (battle_step(defender, 'A', is_candidate_defender)) return if (battle_step(defender, 'B', is_candidate_defender)) return if (battle_step(defender, 'C', is_candidate_defender)) return } else { if (battle_step(defender, 'A', is_candidate_defender)) return if (battle_step(attacker, 'A', is_candidate_attacker)) return if (battle_step(defender, 'B', is_candidate_defender)) return if (battle_step(attacker, 'B', is_candidate_attacker)) return if (battle_step(defender, 'C', is_candidate_defender)) return if (battle_step(attacker, 'C', is_candidate_attacker)) return } if (game.hits > 0) { game.active = enemy(game.active) return goto_battle_hits() } next_combat_round() } // FIELD BATTLE function goto_field_battle() { resume_field_battle() } function resume_field_battle() { // we have a queued up harry action if (game.harry) { game.active = game.harry game.state = 'harry' delete game.harry return } let save_active = game.active game.active = game.attacker[game.where] if (is_friendly_field(game.where)) { if (game.hits > 0) { game.active = enemy(save_active) return goto_field_battle_hits() } print_retreat_summary() log("Field battle won by " + game.active + ".") game.show_field = 0 return goto_regroup() } if (is_enemy_field(game.where)) { if (game.hits > 0) { game.active = enemy(save_active) return goto_field_battle_hits() } game.active = enemy(game.active) print_retreat_summary() log("Field battle won by " + game.active + ".") game.show_field = 0 return goto_regroup() } if (is_enemy_battle_field()) { if (game.hits > 0) { game.active = enemy(save_active) return goto_field_battle_hits() } print_retreat_summary() log("Attacking main force was eliminated.") return next_combat_round() } if (is_friendly_battle_field()) { if (game.hits > 0) { game.active = enemy(save_active) return goto_field_battle_hits() } print_retreat_summary() log("Defending main force was eliminated.") log("Battlefield control changed.") game.attacker[game.where] = enemy(game.active) // The new defender takes control of the empty castle if (!is_under_siege(game.where)) game.castle_owner = game.active return next_combat_round() } game.active = save_active game.state = 'field_battle' game.show_field = 1 pump_battle_step(is_field_attacker, is_field_defender) } states.field_battle = { show_battle: true, prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Field Battle: Waiting for " + game.active + "." view.prompt = "Field Battle: Choose a combat action." for (let b of game.battle_list) { gen_action(view, 'block', b) // take default action gen_action(view, 'fire', b) if (set_has(game.sallying, b)) { // Only sallying forces may withdraw into the castle gen_action(view, 'withdraw', b) } else { if (can_block_retreat(b)) { gen_action(view, 'retreat', b) // Turcopoles and Nomads can Harry (fire and retreat) if (block_type(b) === 'turcopoles' || block_type(b) === 'nomads') gen_action(view, 'harry', b) } // Defender can withdraw into castle if friendly and there is room. if (game.active !== game.attacker[game.where] && game.active === game.castle_owner) { // TODO: allow swapping place of sallying block, leaving it to die if it cannot withdraw? if (game.sallying.length + count_blocks_in_castle(game.where) < castle_limit(game.where)) gen_action(view, 'withdraw', b) } } // All Frank B blocks are knights who can Charge if (block_owner(b) === FRANKS && block_initiative(b) === 'B') gen_action(view, 'charge', b) } if (game.hits > 0) gen_action(view, 'assign') }, assign: function () { game.active = enemy(game.active) if (game.hits === 1) game.flash = `Inflicted 1 hit.` else game.flash = `Inflicted ${game.hits} hits.` goto_field_battle_hits() }, block: field_fire_with_block, fire: field_fire_with_block, withdraw: field_withdraw_with_block, charge: charge_with_block, harry: harry_with_block, retreat: retreat_with_block, } // SIEGE BATTLE function goto_siege_battle() { game.attacker[game.where] = besieging_player(game.where) game.show_castle = 1 resume_siege_battle() } function resume_siege_battle() { let save_active = game.active game.active = game.attacker[game.where] if (is_friendly_town(game.where)) { log("Siege battle won by " + game.active + ".") return goto_regroup() } if (is_enemy_town(game.where)) { game.active = enemy(game.active) game.halfhit = NOBODY log("Siege battle won by " + game.active + ".") return goto_regroup() } if (count_blocks_in_castle(game.where) === 0) { log("Defending main force was eliminated.") log(game.active + " are now the defender.") game.attacker[game.where] = enemy(game.active) // The new defender takes control of the empty castle game.castle_owner = game.active game.storming.length = 0 return next_combat_round() } if (game.storming.length === 0) { game.halfhit = NOBODY log("Storming repulsed.") return next_combat_round() } game.active = save_active game.state = 'siege_battle' pump_battle_step(is_siege_attacker, is_siege_defender) } states.siege_battle = { show_battle: true, prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Siege Battle: Waiting for " + game.active + "." view.prompt = "Siege Battle: Choose a combat action." for (let b of game.battle_list) { gen_action(view, 'block', b) // take default action gen_action(view, 'fire', b) if (set_has(game.storming, b)) gen_action(view, 'retreat', b) } if (game.hits > 0) gen_action(view, 'assign') }, assign: function () { game.active = enemy(game.active) if (game.hits === 1) game.flash = `Inflicted 1 hit.` else game.flash = `Inflicted ${game.hits} hits.` goto_siege_battle_hits() }, block: siege_fire_with_block, fire: siege_fire_with_block, retreat: siege_withdraw_with_block, } // FIELD BATTLE HITS function goto_field_battle_hits() { game.battle_list = list_field_victims() if (game.battle_list.length === 0) { game.hits = 0 resume_field_battle() } else { game.state = 'field_battle_hits' } } function list_field_victims() { let max = 0 for (let b = 0; b <= last_block; ++b) if (block_owner(b) === game.active && is_field_combatant(b) && game.steps[b] > max) max = game.steps[b] let list = [] for (let b = 0; b <= last_block; ++b) if (block_owner(b) === game.active && is_field_combatant(b) && game.steps[b] === max) list.push(b) return list } states.field_battle_hits = { show_battle: true, prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Field Battle: Waiting for " + game.active + " to assign hits." view.prompt = "Field Battle: Assign " + game.hits + (game.hits !== 1 ? " hits" : " hit") + " to your armies." for (let b of game.battle_list) { gen_action(view, 'hit', b) gen_action(view, 'block', b) } }, hit: apply_field_battle_hit, block: apply_field_battle_hit, } function apply_field_battle_hit_to(who, flash) { let msg msg = block_name(who) + " took a hit." log(game.active[0] + ": " + msg) if (flash) game.flash = msg reduce_block(who) game.hits-- } function apply_field_battle_hit(who) { apply_field_battle_hit_to(who, true) if (game.hits === 0) resume_field_battle() else { game.battle_list = list_field_victims() if (game.battle_list.length === 0) { game.hits = 0 resume_field_battle() } else { game.flash += " " + game.hits + (game.hits === 1 ? " hit left." : " hits left.") } } } // SIEGE BATTLE HITS function goto_siege_battle_hits() { game.battle_list = list_siege_victims() if (game.battle_list.length === 0) { game.hits = 0 resume_siege_battle() } else { game.state = 'siege_battle_hits' } } function list_siege_victims() { if (game.halfhit !== NOBODY && block_owner(game.halfhit) === game.active) return [ game.halfhit ] let max = 0 for (let b = 0; b <= last_block; ++b) if (block_owner(b) === game.active && is_siege_combatant(b) && game.steps[b] > max) max = game.steps[b] let list = [] for (let b = 0; b <= last_block; ++b) if (block_owner(b) === game.active && is_siege_combatant(b) && game.steps[b] === max) list.push(b) return list } states.siege_battle_hits = { show_battle: true, prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Siege Battle: Waiting for " + game.active + " to assign hits." view.prompt = "Siege Battle: Assign " + game.hits + (game.hits !== 1 ? " hits" : " hit") + " to your armies." for (let b of game.battle_list) { gen_action(view, 'hit', b) gen_action(view, 'block', b) } }, hit: apply_siege_battle_hit, block: apply_siege_battle_hit, } function apply_siege_battle_hit_to(who, flash) { let msg = block_name(who) + " took a " if (game.halfhit === who) { msg += "hit." log(game.active[0] + ": " + msg) reduce_block(who) game.halfhit = NOBODY } else { if (is_block_in_castle(who)) { msg += "half-hit." log(game.active[0] + ": " + msg) game.halfhit = who } else { msg += "hit." log(game.active[0] + ": " + msg) reduce_block(who) } } if (flash) game.flash = msg game.hits-- } function apply_siege_battle_hit(who) { apply_siege_battle_hit_to(who, true) if (game.hits === 0) { resume_siege_battle() } else { game.battle_list = list_siege_victims() if (game.battle_list.length === 0) { game.hits = 0 resume_siege_battle() } else { game.flash += " " + game.hits + (game.hits === 1 ? " hit left." : " hits left.") } } } // BATTLE ACTIONS function roll_attack(active, b, verb, is_charge) { let fire = block_fire_power(b, game.where) + is_charge let steps = game.steps[b] let name = block_name(b) + " " + BLOCKS[b].initiative + BLOCKS[b].fire_power let rolls = [] let hits = 0 let self = 0 for (let i = 0; i < steps; ++i) { let die = roll_d6() if (die <= fire) { rolls.push(DIE_HIT[die]) ++hits } else { if (is_charge && die === 6) { rolls.push(DIE_SELF) ++self } else { rolls.push(DIE_MISS[die]) } } } game.hits += hits log(active[0] + ": " + name + " " + verb + " " + rolls.join("") + ".") if (game.immediate) { game.flash = name + " " + verb + " " + rolls.join(" ") + " " if (game.hits === 0) game.flash += "and missed." else if (game.hits === 1) game.flash += "and scored 1 hit." else game.flash += "and scored " + game.hits + " hits." } else { game.flash = name + " " + verb + " " + rolls.join(" ") if (game.hits === 0) game.flash += "." else if (game.hits === 1) game.flash += " for a total of 1 hit." else game.flash += " for a total of " + game.hits + " hits." } if (self > 0) { if (self === 1) game.flash += " " + self + " self hit." else game.flash += " " + self + " self hits." self = Math.min(self, game.steps[b]) while (self-- > 0) reduce_block(b) } } function field_fire_with_block(b) { set_add(game.moved, b) roll_attack(game.active, b, "fired", 0) if (must_apply_field_hits()) { game.active = enemy(game.active) goto_field_battle_hits() } else { resume_field_battle() } } function siege_fire_with_block(b) { set_add(game.moved, b) roll_attack(game.active, b, "fired", 0) if (must_apply_siege_hits()) { game.active = enemy(game.active) goto_siege_battle_hits() } else { resume_siege_battle() } } function charge_with_block(b) { set_add(game.moved, b) roll_attack(game.active, b, "charged", 1) if (must_apply_field_hits()) { game.active = enemy(game.active) goto_field_battle_hits() } else { resume_field_battle() } } function field_withdraw_with_block(b) { game.flash = block_name(b) + " withdrew." log(game.active[0] + ": " + game.flash) set_add(game.moved, b) set_delete(game.sallying, b) set_add(game.castle, b) resume_field_battle() } function siege_withdraw_with_block(b) { game.flash = block_name(b) + " withdrew." log(game.active[0] + ": " + game.flash) set_add(game.moved, b) set_delete(game.storming, b) resume_siege_battle() } function harry_with_block(b) { game.harry = game.active // remember to retreat after hits have been applied game.who = b roll_attack(game.active, b, "harried", 0) if (must_apply_field_hits()) { game.active = enemy(game.active) goto_field_battle_hits() } else { resume_field_battle() } } states.harry = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Field Battle: Waiting for " + game.active + " to retreat the harrying block." view.prompt = "Field Battle: Retreat the harrying block to a friendly or vacant town." for (let to of TOWNS[game.where].exits) if (can_block_retreat_to(game.who, to)) gen_action(view, 'town', to) }, town: function (to) { game.flash += " " + block_name(game.who) + " retreated." game.summary.push([game.active, to]) game.location[game.who] = to move_block(game.who, game.where, to) game.who = NOBODY resume_field_battle() }, } function retreat_with_block(b) { game.who = b game.state = 'retreat_in_battle' } states.retreat_in_battle = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Field Battle: Waiting for " + game.active + " to retreat." gen_action(view, 'undo') gen_action(view, 'block', game.who) view.prompt = "Field Battle: Retreat the block to a friendly or vacant town." for (let to of TOWNS[game.where].exits) if (can_block_retreat_to(game.who, to)) gen_action(view, 'town', to) }, town: function (to) { game.flash = block_name(game.who) + " retreated." log(game.active[0] + ": " + game.flash) game.summary.push([game.active, to]) game.location[game.who] = to move_block(game.who, game.where, to) game.who = NOBODY resume_field_battle() }, block: function () { game.who = NOBODY resume_field_battle() }, undo: function () { game.who = NOBODY resume_field_battle() } } // DRAW PHASE function goto_draw_phase() { delete game.combat_list if (game.year > 1187 && !is_winter()) { game.active = game.p1 log("") start_draw_phase() } else { end_game_turn() } } function start_draw_phase() { game.state = 'draw_phase' if (game.active === FRANKS) { game.who = select_random_block(F_POOL) if (game.who === NOBODY) end_draw_phase() } else { game.who = select_random_block(S_POOL) if (game.who === NOBODY) end_draw_phase() } } states.draw_phase = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Draw Phase: Waiting for " + game.active + "." let can_place = false switch (block_type(game.who)) { case 'crusaders': view.prompt = "Draw Phase: Place " + block_name(game.who) + " in the staging area." gen_action(view, 'town', block_home(game.who)) can_place = true break case 'pilgrims': view.prompt = "Draw Phase: Place " + block_name(game.who) + " in a friendly port." for (let town = first_town; town <= last_town; ++town) { if (is_friendly_port(town) || can_enter_besieged_port(town)) { gen_action(view, 'town', town) can_place = true } } break case 'turcopoles': case 'outremers': case 'emirs': case 'nomads': view.prompt = "Draw Phase: Place " + block_name(game.who) + " at full strength in " + block_seat_names[game.who] + " or at strength 1 in any friendly town." for (let town = first_town; town <= last_town; ++town) { if (town === ENGLAND || town === FRANCE || town === GERMANIA) continue if (is_friendly_town(town)) { if (set_has(block_seats[game.who], town)) gen_action(view, 'town', town) else gen_action(view, 'townb', town) can_place = true } } break } if (!can_place) gen_action(view, 'next') }, townb: function (where) { this.town(where) }, town: function (where) { let type = block_type(game.who) log(game.active + " deployed at #" + where + ".") game.location[game.who] = where if (type === 'turcopoles' || type === 'outremers' || type === 'emirs' || type === 'nomads') { if (is_home_seat(where, game.who)) game.steps[game.who] = block_max_steps(game.who) else game.steps[game.who] = 1 } else { game.steps[game.who] = block_max_steps(game.who) if (can_enter_besieged_port(where)) set_add(game.castle, game.who) } game.who = NOBODY end_draw_phase() }, next: function () { end_draw_phase() }, } function end_draw_phase() { if (game.active === game.p1) { game.active = game.p2 start_draw_phase() } else { end_game_turn() } } function end_game_turn() { if (is_winter()) { goto_winter_1() } else { if (check_sudden_death()) return game.turn ++ start_game_turn() } } // WINTER CAMPAIGN & SUPPLY function goto_winter_1() { log("") log(".h1 Winter of " + game.year) log("") if (game.winter_campaign) goto_winter_siege_attrition() else goto_winter_2() } function goto_winter_siege_attrition() { log(game.active + " winter campaigned at #" + game.winter_campaign + ".") game.where = game.winter_campaign game.active = besieged_player(game.where) game.state = 'winter_siege_attrition' game.attrition_list = [] for (let b = 0; b <= last_block; ++b) if (is_block_in_castle_in(b, game.where)) set_add(game.attrition_list, b) } function resume_winter_siege_attrition() { if (game.attrition_list.length === 0) { delete game.attrition_list if (!is_under_siege(game.where)) { game.active = enemy(game.active) log("#" + game.where + " fell to siege attrition.") goto_regroup() } else { log("Siege continued.") goto_winter_2() } } } states.winter_siege_attrition = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Winter Siege Attrition: Waiting for " + game.active + "." view.prompt = "Winter Siege Attrition: Roll for siege attrition in " + town_name(game.where) + "." for (let b of game.attrition_list) gen_action(view, 'block', b) }, block: function (who) { let target = (game.where === TYRE || game.where === TRIPOLI) ? 2 : 4 let die = roll_d6() if (die <= target) { log("Attrition roll " + DIE_HIT[die] + ".") reduce_block(who) } else { log("Attrition roll " + DIE_MISS[die] + ".") } set_delete(game.attrition_list, who) resume_winter_siege_attrition() } } function goto_winter_2() { game.where = NOWHERE eliminate_besieging_blocks(FRANKS) eliminate_besieging_blocks(SARACENS) lift_all_sieges() if (check_sudden_death()) return if (game.year === 1192) return goto_year_end() goto_winter_supply() } function eliminate_besieging_blocks(owner) { game.summary = [] for (let b = 0; b <= last_block; ++b) { if (block_owner(b) === owner) { let where = game.location[b] if (where === game.winter_campaign) continue if (is_block_on_land(b) && is_under_siege(where)) if (block_owner(b) === besieging_player(where)) disband(b) } } if (game.summary.length > 0) print_summary(owner + " disbanded sieges:") else game.summary = null } function need_winter_supply_check() { for (let town = first_town; town <= last_town; ++town) { if (town === game.winter_campaign) continue if (is_friendly_town(town) && !is_within_castle_limit(town)) return true } return false } function goto_winter_supply() { game.active = FRANKS if (need_winter_supply_check()) { game.state = 'winter_supply' game.summary = [] } else { game.active = SARACENS if (need_winter_supply_check()) { game.state = 'winter_supply' game.summary = [] } else { game.active = FRANKS goto_winter_replacements() } } } states.winter_supply = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Winter Supply: Waiting for " + game.active + "." gen_action_undo(view) let okay_to_end = true for (let b = 0; b <= last_block; ++b) { if (block_owner(b) === game.active) { if (is_block_on_land(b)) { let where = game.location[b] if (where === game.winter_campaign) continue if (!is_within_castle_limit(where)) { gen_action(view, 'block', b) okay_to_end = false } } } } if (okay_to_end) { view.prompt = "Winter Supply: Disband excess blocks \u2014 done." gen_action(view, 'next') } else { view.prompt = "Winter Supply: Disband excess blocks." } }, block: function (who) { push_undo() disband(who) }, next: function () { clear_undo() if (game.summary.length > 0) print_summary(game.active + " disbanded:") if (game.active === FRANKS) { game.active = SARACENS game.summary = [] } else { game.active = FRANKS goto_winter_replacements() } }, undo: pop_undo } // WINTER REPLACEMENTS function goto_winter_replacements() { game.rp = {} for (let town = first_town; town <= last_town; ++town) if (is_under_siege(town)) game.rp[town] = 0 else game.rp[town] = castle_limit(town) game.summary = [] game.state = 'winter_replacements' } function replacement_cost(where) { let region = TOWNS[where].region if (KINGDOMS[region] === game.active) return 1 return 2 } states.winter_replacements = { prompt: function (view, current) { if (is_inactive_player(current)) return view.prompt = "Winter Replacements: Waiting for " + game.active + "." view.prompt = "Winter Replacements: Distribute replacement points." gen_action_undo(view) let okay_to_end = true for (let b = 0; b <= last_block; ++b) { if (block_owner(b) === game.active && is_block_on_land(b)) { let where = game.location[b] let cost = replacement_cost(where) if (is_friendly_town(where) && game.rp[where] >= cost) { if (game.steps[b] < block_max_steps(b)) { gen_action(view, 'block', b) okay_to_end = false } } } } if (okay_to_end) { view.prompt = "Winter Replacements: Distribute replacement points \u2014 done." gen_action(view, 'next') } else { view.prompt = "Winter Replacements: Distribute replacement points." } }, block: function (who) { push_undo() let where = game.location[who] let cost = replacement_cost(where) game.summary.push([where]) game.steps[who] ++ game.rp[where] -= cost }, next: function () { clear_undo() end_winter_replacements() }, undo: pop_undo } function end_winter_replacements() { print_summary(active_adjective() + " replacements:") if (game.active === FRANKS) { game.active = SARACENS game.summary = [] } else { goto_year_end() } } function goto_year_end() { if (game.year === 1192) { game.state = 'game_over' let f_vp = count_victory_points(FRANKS) let s_vp = count_victory_points(SARACENS) if (f_vp > s_vp) { game.result = FRANKS game.victory = "Franks won!" } else if (f_vp < s_vp) { game.victory = "Saracens won!" game.result = SARACENS } else { game.victory = "The game ended in a draw." game.result = "None" } log("") log(game.victory) return } // Return eliminated blocks to pool. for (let b = 0; b <= last_block; ++b) if (game.location[b] === DEAD) game.location[b] = block_pool(b) game.year ++ start_year() } // GAME OVER states.game_over = { prompt: function (view) { view.prompt = game.victory } } // SETUP function setup_game() { reset_blocks() game.year = 1187 game.turn = 0 for (let b = 0; b <= last_block; ++b) { if (block_owner(b) === FRANKS) { switch (block_type(b)) { case 'pilgrims': deploy(b, block_pool(b)) break case 'crusaders': deploy(b, block_pool(b)) break default: deploy(b, block_home(b)) break } } if (block_owner(b) === SARACENS) { if (block_type(b) === 'emirs') deploy(b, block_home(b)) if (block_type(b) === 'nomads') deploy(b, block_pool(b)) } } deploy(ASSASSINS, MASYAF) goto_frank_deployment() } // VIEW function make_battle_view() { let battle = { FR: [], FC: [], FF: [], SR: [], SC: [], SF: [], FCS: (game.castle_owner === FRANKS) ? castle_limit(game.where) : 0, SCS: (game.castle_owner === SARACENS) ? castle_limit(game.where) : 0, storming: game.storming, sallying: game.sallying, halfhit: game.halfhit, flash: game.flash, round: game.combat_round, show_castle: game.show_castle, show_field: game.show_field, town: game.where, } if (is_under_siege(game.where) && !is_contested_battle_field(game.where)) battle.title = enemy(game.castle_owner) + " besiege " + town_name(game.where) else battle.title = game.attacker[game.where] + " attack " + town_name(game.where) if (game.combat_round === 0) battle.title += " \u2014 Combat Deployment" else battle.title += " \u2014 Round " + game.combat_round + " of 3" if (game.where === game.jihad) battle.title += " \u2014 Jihad!" function fill_cell(cell, owner, fn) { for (let b = 0; b <= last_block; ++b) if (game.location[b] === game.where & block_owner(b) === owner && fn(b)) cell.push(b) } fill_cell(battle.FR, FRANKS, b => is_reserve(b)) fill_cell(battle.FC, FRANKS, b => is_block_in_castle(b)) fill_cell(battle.FF, FRANKS, b => is_block_in_field(b) && !set_has(game.storming, b)) fill_cell(battle.FF, SARACENS, b => is_block_in_field(b) && set_has(game.storming, b)) fill_cell(battle.SF, FRANKS, b => is_block_in_field(b) && set_has(game.storming, b)) fill_cell(battle.SF, SARACENS, b => is_block_in_field(b) && !set_has(game.storming, b)) fill_cell(battle.SC, SARACENS, b => is_block_in_castle(b)) fill_cell(battle.SR, SARACENS, b => is_reserve(b)) return battle } exports.setup = function (seed, scenario, options) { game = { seed: seed, log: [], undo: [], active: null, moves: 0, who: NOBODY, where: NOWHERE, show_cards: false, s_hand: [], f_hand: [], s_card: 0, f_card: 0, location: [], steps: [], attacker: {}, road_limit: [], last_used: [], castle: [], main_road: [], moved: [], reserves1: [], reserves2: [], } // Old RNG for ancient replays if (options.rng) game.rng = options.rng log(".h1 Crusader Rex") log("") if (options && options.iron_bridge) { game.iron_bridge = 1 log("Iron Bridge:\nThe road between Antioch and Harim has a move limit of 3.") log("") } if (options && options.immediate) game.immediate = 1 setup_game() return game } exports.action = function (state, current, action, arg) { game = state let S = states[game.state] if (action in S) S[action](arg, current) else throw new Error("Invalid action: " + action) return game } function make_siege_view() { let list = {} for (let town = first_town; town <= last_town; ++town) if (is_under_siege(town)) list[town] = besieging_player(town) return list } function observer_hand() { let hand = [] hand.length = Math.max(game.s_hand.length, game.f_hand.length) hand.fill(0) return hand } exports.is_checkpoint = (a, b) => a.turn !== b.turn exports.view = function(state, current) { game = state let view = { log: game.log, year: game.year, turn: game.turn, active: game.active, p1: game.p1, f_vp: count_victory_points(FRANKS), s_vp: count_victory_points(SARACENS), f_card: (game.show_cards || current === FRANKS) ? game.f_card : 0, s_card: (game.show_cards || current === SARACENS) ? game.s_card : 0, hand: (current === FRANKS) ? game.f_hand : (current === SARACENS) ? game.s_hand : observer_hand(), who: (game.active === current) ? game.who : NOBODY, location: game.location, castle: game.castle, steps: game.steps, moved: game.moved, sieges: make_siege_view(), last_used: game.last_used, road_limit: game.road_limit, battle: null, prompt: null, } if (game.jihad && game.jihad !== game.p1) view.jihad = game.jihad if (game.winter_campaign && game.winter_campaign !== game.p1 && game.winter_campaign !== game.p2) view.winter_campaign = game.winter_campaign if (game.main_road && game.main_road.length > 0) { view.main_road = [] for (let i = 0; i < game.main_road.length; i += 2) if (game.main_road[i+1] !== ENGLAND) set_add(view.main_road, road_id(game.main_road[i+0], game.main_road[i+1])) } states[game.state].prompt(view, current) if (states[game.state].show_battle) view.battle = make_battle_view() return view } // === COMMON LIBRARY === // remove item at index (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 return array } // insert item at index (faster than splice) function array_insert(array, index, item) { for (let i = array.length; i > index; --i) array[i] = array[i - 1] array[index] = item return array } 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 } function set_clear(set) { set.length = 0 } 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 set } 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 return array_remove(set, m) } return set } function set_toggle(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_remove(set, m) } return array_insert(set, a, item) } 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) } // 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 } } function clear_undo() { if (game.undo.length > 0) game.undo = [] } function push_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() { 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 }