diff options
Diffstat (limited to 'rules.js')
-rw-r--r-- | rules.js | 1570 |
1 files changed, 1570 insertions, 0 deletions
diff --git a/rules.js b/rules.js new file mode 100644 index 0000000..3682c88 --- /dev/null +++ b/rules.js @@ -0,0 +1,1570 @@ +"use strict"; + +// TODO: frank seat adjustment at setup +// TODO: saladin seat adjustment at setup + +exports.scenarios = [ + "Rules 2.0", +]; + +const { CARDS, BLOCKS, TOWNS, PORTS, ROADS } = require('./data'); + +const FRANK = "Frank"; +const SARACEN = "Saracen"; +const ASSASSINS = "Assassins"; +const ENEMY = { Frank: "Saracen", Saracen: "Frank" }; +const OBSERVER = "Observer"; +const BOTH = "Both"; +const F_POOL = "F. Pool"; +const S_POOL = "S. Pool"; + +const HIT_TEXT = "\u2605"; +const MISS_TEXT = "\u2606"; + +const ATTACK_MARK = "*"; +const RESERVE_MARK_1 = "\u2020"; +const RESERVE_MARK_2 = "\u2021"; +const NO_MARK = ""; + +let states = {}; + +let game = null; + +function log(...args) { + let s = Array.from(args).join(""); + game.log.push(s); +} + +function logp(...args) { + let s = game.active + " " + Array.from(args).join(""); + game.log.push(s); +} + +function log_move_start(from) { + game.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.turn_log.push(game.move_buf); + delete game.move_buf; +} + +function print_turn_log_no_count(text) { + function print_move(last) { + return "\n" + last.join(" \u2192 "); + } + if (game.turn_log.length > 0) { + game.turn_log.sort(); + for (let entry of game.turn_log) + text += print_move(entry); + } else { + text += "\nnothing."; + } + log(text); + delete game.turn_log; +} + +function print_turn_log(text) { + function print_move(last) { + return "\n" + n + " " + last.join(" \u2192 "); + } + game.turn_log.sort(); + let last = game.turn_log[0]; + let n = 0; + for (let entry of game.turn_log) { + if (entry.toString() != last.toString()) { + text += print_move(last); + n = 0; + } + ++n; + last = entry; + } + if (n > 0) + text += print_move(last); + else + text += "\nnothing."; + log(text); + delete game.turn_log; +} + +function is_active_player(current) { + return (current == game.active) || (game.active == BOTH && current != OBSERVER); +} + +function is_inactive_player(current) { + return current == OBSERVER || (game.active != current && game.active != BOTH); +} + +function remove_from_array(array, item) { + let i = array.indexOf(item); + if (i >= 0) + array.splice(i, 1); +} + +function clear_undo() { + game.undo = []; +} + +function push_undo() { + game.undo.push(JSON.stringify(game, (k,v) => { + if (k === 'undo') return undefined; + if (k === 'log') return v.length; + return v; + })); +} + +function pop_undo() { + let undo = game.undo; + let log = game.log; + Object.assign(game, JSON.parse(undo.pop())); + game.undo = undo; + log.length = game.log; + game.log = log; +} + +function gen_action_undo(view) { + if (!view.actions) + view.actions = {} + if (game.undo && game.undo.length > 0) + view.actions.undo = 1; + else + view.actions.undo = 0; +} + +function gen_action(view, action, argument) { + if (!view.actions) + view.actions = {} + if (argument != undefined) { + if (!(action in view.actions)) { + view.actions[action] = [ argument ]; + } else { + if (!view.actions[action].includes(argument)) + view.actions[action].push(argument); + } + } else { + view.actions[action] = true; + } +} + +function roll_d6() { + return Math.floor(Math.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 = Math.floor(Math.random() * deck.length); + hand.push(deck[k]); + deck.splice(k, 1); + } + return hand; +} + +function block_name(who) { + return BLOCKS[who].name; +} + +function block_type(who) { + return BLOCKS[who].type; +} + +function block_home(who) { + let home = BLOCKS[who].home; + if (home == "Aquitaine") return "England"; + if (home == "Bourgogne") return "France"; + if (home == "Flanders") return "France"; + return home; +} + +function block_pool(who) { + if (BLOCKS[who].owner == FRANK) + return F_POOL; + return S_POOL; +} + +function block_owner(who) { + return BLOCKS[who].owner; +} + +function block_initiative(who) { + return BLOCKS[who].combat[0]; +} + +function block_printed_fire_power(who) { + return BLOCKS[who].combat[1] | 0; +} + +function block_fire_power(who, where) { + let combat = block_printed_fire_power(who); + return combat; +} + +function block_move(who) { + return BLOCKS[who].move; +} + +function block_max_steps(who) { + return BLOCKS[who].steps; +} + +function is_block_on_map(who) { + return game.location[who] && game.location[who] != F_POOL && game.location[who] != S_POOL; +} + +function can_activate(who) { + return block_owner(who) == game.active && is_block_on_map(who) && !game.moved[who]; +} + +function road_id(a, b) { + return (a < b) ? a + "/" + b : b + "/" + a; +} + +function road_was_last_used_by_enemy(from, to) { + return game.last_used[road_id(from, to)] == ENEMY[game.active]; +} + +function road_type(a, b) { + return ROADS[road_id(a,b)]; +} + +function road_limit(a, b) { + return game.road_limit[road_id(a,b)] || 0; +} + +function reset_road_limits() { + game.road_limit = {}; +} + +function count_friendly(where) { + let p = game.active; + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == p) + ++count; + return count; +} + +function count_enemy(where) { + let p = ENEMY[game.active]; + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == p) + ++count; + return count; +} + +function count_enemy_excluding_reserves(where) { + let p = ENEMY[game.active]; + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == p) + if (!game.reserves.includes(b)) + ++count; + return count; +} + +function is_friendly_town(where) { return count_friendly(where) > 0 && count_enemy(where) == 0; } +function is_enemy_town(where) { return count_friendly(where) == 0 && count_enemy(where) > 0; } +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_friendly_or_vacant_town(where) { return is_friendly_town(where) || is_vacant_town(where); } + +function is_fortified_port(where) { + return TOWNS[where].fortified_port; +} + +function is_port(where) { + return TOWNS[where].port; +} + +function is_friendly_port(where) { + // TODO: Tripoli/Tyre are friendly to besieged defender! + return TOWNS[where].port && is_friendly_town(where); +} + +function have_contested_towns() { + for (let where in TOWNS) + if (is_contested_town(where)) + return true; + return false; +} + +function count_pinning(where) { + return count_enemy_excluding_reserves(where); +} + +function count_pinned(where) { + let count = 0; + for (let b in BLOCKS) + if (game.location[b] == where && block_owner(b) == game.active) + if (!game.reserves.includes(b)) + ++count; + return count; +} + +function is_pinned(who, from) { + if (game.active == game.p2) { + if (count_pinned(from) <= count_pinning(from)) + return true; + } + return false; +} + +function can_block_use_road(who, from, to) { + switch (road_type(from, to)) { + 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(who, from, to)) { + if (count_pinning(from) > 0) + if (road_was_last_used_by_enemy(from, to)) + return false; + return true; + } + return false; +} + +function can_block_land_move(who) { + if (can_activate(who)) { + let from = game.location[who]; + if (from) { + if (is_pinned(who, from)) + return false; + for (let to of TOWNS[from].exits) + if (can_block_land_move_to(who, from, to)) + return true; + } + } + return false; +} + +function can_block_sea_move_to(who, from, to) { + if (is_port(to)) { + // English Crusaders may attack by sea if they are the Main Attacker + if (who == "Richard" || who == "Robert" || who == "Crossbows") { + if (game.attacker[to] != FRANK) + return false; + if (game.main_road[to] != "England") + return false; + return true; + } + return is_friendly_port(to); + } + return false; +} + +function can_block_sea_move(who) { + if (can_activate(who)) { + let from = game.location[who]; + if (is_friendly_port(from)) { + for (let to of PORTS) + if (to != from && can_block_sea_move_to(who, from, to)) + return true; + } + } + return false; +} + +function can_block_continue(who, from, to) { + if (is_contested_town(to)) + return false; + if (game.distance >= block_move(who)) + return false; + return true; +} + +function can_block_retreat_to(who, to) { + if (is_friendly_town(to) || is_vacant_town(to)) { + let from = game.location[who]; + if (can_block_use_road(who, from, to)) { + if (road_was_last_used_by_enemy(from, to)) + return false; + return true; + } + } + return false; +} + +function can_block_regroup_to(who, to) { + if (is_friendly_town(to) || is_vacant_town(to)) { + let from = game.location[who]; + if (can_block_use_road(who, from, to)) + return true; + } + return false; +} + +function can_block_regroup(who) { + if (block_owner(who) == game.active) { + let from = game.location[who]; + for (let to of TOWNS[from].exits) + if (can_block_regroup_to(who, to)) + return true; + } + return false; +} + +function can_block_muster_via(who, from, next, muster) { + if (can_block_land_move_to(who, from, next) && is_friendly_or_vacant_town(next)) { + if (next == muster) + return true; + if (road_type(from, next) != 'minor') { + if (TOWNS[next].exits.includes(muster)) + if (can_block_land_move_to(who, next, muster)) + return true; + } + } +} + +function can_block_muster(who, muster) { + let from = game.location[who]; + if (from == muster) + return false; + if (can_activate(who)) { + if (is_pinned(who, from)) + return false; + for (let next of TOWNS[from].exits) + if (can_block_muster_via(who, from, next, muster)) + return true; + } + return false; +} + +function can_muster_to(muster) { + for (let b in BLOCKS) + if (can_block_muster(b, muster)) + return true; + return false; +} + +function is_battle_reserve(who) { + return game.reserves.includes(who); +} + +function is_attacker(who) { + if (game.location[who] == game.where && block_owner(who) == game.attacker[game.where]) + return !game.reserves.includes(who); + return false; +} + +function is_defender(who) { + if (game.location[who] == game.where && block_owner(who) != game.attacker[game.where]) + return !game.reserves.includes(who); + return false; +} + +function disband(who) { + log(block_name(who) + " disbands."); + game.location[who] = block_pool(who); + game.steps[who] = block_max_steps(who); +} + +function eliminate_block(who) { + log(block_name(who) + " is eliminated."); + game.location[who] = null; + game.steps[who] = block_max_steps(who); +} + +function reduce_block(who) { + if (game.steps[who] == 1) { + eliminate_block(who); + } else { + --game.steps[who]; + } +} + +function filter_battle_blocks(ci, is_candidate) { + let output = null; + for (let b in BLOCKS) { + if (is_candidate(b) && !game.moved[b]) { + if (block_initiative(b) == ci) { + if (!output) + output = []; + output.push(b); + } + } + } + return output; +} + +function count_attackers() { + let count = 0; + for (let b in BLOCKS) + if (is_attacker(b)) + ++count; + return count; +} + +function count_defenders() { + let count = 0; + for (let b in BLOCKS) + if (is_defender(b)) + ++count; + return count; +} + +// GAME TURN + +function start_year() { + log(""); + log("Start Year " + game.year + "."); + + let deck = shuffle_deck(); + game.f_hand = deal_cards(deck, 6); + game.s_hand = deal_cards(deck, 6); + + start_game_turn(); +} + +function start_game_turn() { + // Reset movement and attack tracking state + reset_road_limits(); + game.last_used = {}; + game.attacker = {}; + game.reserves = []; + game.moved = {}; + + goto_card_phase(); +} + +function end_game_turn() { + if (game.f_hand.length > 0) + start_game_turn() + else + goto_winter_turn(); +} + +// 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; +} + +states.play_card = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for players to play a card."; + if (current == FRANK) { + if (game.f_card) { + view.prompt = "Waiting for Saracen to play a card."; + gen_action(view, 'undo'); + } else { + view.prompt = "Play a card."; + for (let c of game.f_hand) + gen_action(view, 'play', c); + } + } + if (current == SARACEN) { + if (game.s_card) { + view.prompt = "Waiting for Frank to play a card."; + gen_action(view, 'undo'); + } else { + view.prompt = "Play a card."; + for (let c of game.s_hand) + gen_action(view, 'play', c); + } + } + }, + play: function (card, current) { + if (current == FRANK) { + remove_from_array(game.f_hand, card); + game.f_card = card; + } + if (current == SARACEN) { + remove_from_array(game.s_hand, card); + game.s_card = card; + } + if (game.s_card > 0 && game.f_card > 0) + reveal_cards(); + }, + undo: function (_, current) { + if (current == FRANK) { + game.f_hand.push(game.f_card); + game.f_card = 0; + } + if (current == SARACEN) { + game.s_hand.push(game.s_card); + game.s_card = 0; + } + } +} + +function reveal_cards() { + log("Frank plays " + CARDS[game.f_card].name + "."); + log("Saracen plays " + CARDS[game.s_card].name + "."); + game.show_cards = true; + + let fc = CARDS[game.f_card]; + let sc = CARDS[game.s_card]; + + if (fc.event && sc.event) { + log("Game Turn is cancelled."); + end_game_turn(); + return; + } + + let fp = fc.moves; + let sp = sc.moves; + if (fp == sp) { + if (roll_d6() > 3) + ++fp; + else + ++sp; + } + + if (fp > sp) { + game.p1 = FRANK; + game.p2 = SARACEN; + } else { + game.p1 = SARACEN; + game.p2 = FRANK; + } + + game.active = game.p1; + start_player_turn(); +} + +function start_player_turn() { + log(""); + log("Start " + game.active + " turn."); + reset_road_limits(); + let card = CARDS[game.active == FRANK ? 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.activated = null; + game.main_road = null; + + if (game.active == game.p2) { + goto_battle_phase(); + } else { + game.active = game.p2; + start_player_turn(); + } +} + +// EVENTS + +function goto_event_card(event) { + end_player_turn(); +} + +// ACTION PHASE + +function move_block(who, from, to) { + game.location[who] = to; + game.road_limit[road_id(from, to)] = road_limit(from, to) + 1; + game.distance ++; + if (is_contested_town(to)) { + game.last_used[road_id(from, to)] = game.active; + if (!game.attacker[to]) { + game.attacker[to] = game.active; + game.main_road[to] = from; + return ATTACK_MARK; + } else { + // Attacker main attack or reinforcements + if (game.attacker[to] == game.active) { + if (game.main_road[to] != from) { + game.reserves.push(who); + return RESERVE_MARK_1; + } + return ATTACK_MARK; + } + + // Defender reinforcements + if (!game.main_road[to]) + game.main_road[to] = from; + + if (game.main_road[to] == from) { + game.reserves.push(who); + return RESERVE_MARK_1; + } else { + game.reserves.push(who); + return RESERVE_MARK_2; + } + } + } + return false; +} + +function goto_move_phase(moves) { + game.state = 'group_move'; + game.moves = moves; + game.activated = []; + game.main_road = {}; + game.turn_log = []; +} + +function end_move_phase() { + game.moves = 0; + clear_undo(); + print_turn_log(game.active + " moves:"); + end_player_turn(); +} + +states.group_move = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Move Phase: Waiting for " + game.active + "."; + view.prompt = "Group Move: Choose a block to group move. " + game.moves + "AP left."; + gen_action_undo(view); + gen_action(view, 'end_move_phase'); + if (game.moves > 0) { + gen_action(view, 'sea_move'); + gen_action(view, 'muster'); + for (let b in BLOCKS) + if (can_block_land_move(b)) + gen_action(view, 'block', b); + } else { + for (let b in BLOCKS) { + let from = game.location[b]; + if (game.activated.includes(from)) + if (can_block_land_move(b)) + gen_action(view, 'block', b); + } + } + }, + block: function (who) { + push_undo(); + game.who = who; + game.origin = game.location[who]; + game.distance = 0; + game.state = 'group_move_to'; + }, + sea_move: function () { + game.state = 'sea_move'; + }, + muster: function () { + game.state = 'muster'; + }, + end_move_phase: end_move_phase, + undo: pop_undo +} + +states.group_move_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + view.prompt = "Group Move: Move " + block_name(game.who) + "."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + let from = game.location[game.who]; + if (game.distance > 0) + gen_action(view, 'town', from); + for (let to of TOWNS[from].exits) { + if (can_block_land_move_to(game.who, from, to)) + gen_action(view, 'town', to); + } + }, + town: function (to) { + 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); + if (!can_block_continue(game.who, from, to)) + end_move(); + }, + block: function () { + if (game.distance == 0) + pop_undo(); + else + end_move(); + }, + undo: pop_undo +} + +states.sea_move = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Move Phase: Waiting for " + game.active + "."; + view.prompt = "Sea Move: Choose a block to sea move. " + game.moves + "AP left."; + gen_action_undo(view); + gen_action(view, 'end_move_phase'); + gen_action(view, 'group_move'); + if (game.moves > 0) { + gen_action(view, 'muster'); + for (let b in BLOCKS) { + let from = game.location[b]; + if (can_block_sea_move(b)) { + gen_action(view, 'block', b); + } + } + } + }, + group_move: function () { + game.state = 'group_move'; + }, + muster: function () { + game.state = 'muster'; + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'sea_move_to'; + }, + end_move_phase: end_move_phase, + undo: pop_undo +} + +states.sea_move_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + view.prompt = "Sea Move " + block_name(game.who) + "."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + let from = game.location[game.who]; + for (let to of PORTS) + if (to != from && can_block_sea_move_to(game.who, from, to)) + gen_action(view, 'town', to); + }, + town: function (to) { + --game.moves; + let from = game.location[game.who]; + game.location[game.who] = to; + game.moved[game.who] = true; + log_move_start(from); + log_move_continue("Sea"); + + // English Crusaders attack! + if (is_contested_town(to)) { + game.attacker[to] = FRANK; + game.main_road[to] = "England"; + log_move_continue(to + ATTACK_MARK); + } else { + log_move_continue(to); + } + + game.state = 'sea_move'; + game.who = null; + }, + block: pop_undo, + undo: pop_undo +} + +states.muster = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + view.prompt = "Muster: Choose one friendly or vacant muster area."; + gen_action_undo(view); + gen_action(view, 'group_move'); + gen_action(view, 'sea_move'); + gen_action_undo(view); + gen_action(view, 'end_action_phase'); + for (let where in TOWNS) { + if (is_friendly_or_vacant_town(where)) + if (can_muster_to(where)) + gen_action(view, 'town', where); + } + }, + area: function (where) { + push_undo(); + game.where = where; + game.state = 'muster_who'; + }, + end_action_phase: function () { + clear_undo(); + print_turn_log(game.active + " musters:"); + end_player_turn(); + }, + undo: pop_undo, +} + +states.muster_who = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to move."; + view.prompt = "Muster: Move blocks to the designated muster area."; + gen_action_undo(view); + gen_action(view, 'end_action_phase'); + for (let b in BLOCKS) + if (can_block_muster(b, game.where)) + gen_action(view, 'block', b); + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'muster_move_1'; + }, + end_action_phase: function () { + game.where = null; + clear_undo(); + print_turn_log(game.active + " musters:"); + end_player_turn(); + }, + undo: pop_undo, +} + +function end_move() { + if (game.distance > 0) { + let to = game.location[game.who]; + if (!game.activated.includes(game.origin)) { + logp("activates " + game.origin + "."); + game.activated.push(game.origin); + game.moves --; + } + game.moved[game.who] = true; + } + log_move_end(); + game.who = null; + game.distance = 0; + game.origin = null; + game.state = 'group_move'; +} + +// BATTLE PHASE + +function goto_battle_phase() { + if (have_contested_towns()) { + game.active = game.p1; + game.state = 'battle_phase'; + } else { + goto_draw_phase(); + } +} + +states.battle_phase = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to choose a battle."; + view.prompt = "Choose the next battle to fight!"; + for (let where in TOWNS) + if (is_contested_town(where)) + gen_action(view, 'town', where); + }, + town: function (where) { + start_battle(where); + }, +} + +function start_battle(where) { + game.flash = ""; + log(""); + log("Battle in " + where + "."); + game.where = where; + game.battle_round = 0; + game.state = 'battle_round'; + start_battle_round(); +} + +function resume_battle() { + if (game.victory) + return goto_game_over(); + game.who = null; + game.state = 'battle_round'; + pump_battle_round(); +} + +function end_battle() { + game.flash = ""; + game.battle_round = 0; + reset_road_limits(); + game.moved = {}; + + game.active = game.attacker[game.where]; + let victor = game.active; + if (is_contested_town(game.where)) + victor = ENEMY[game.active]; + else if (is_enemy_town(game.where)) + victor = ENEMY[game.active]; + log(victor + " wins the battle in " + game.where + "!"); + + goto_retreat(); +} + +function bring_on_reserves(round) { + // TODO: defender reserves in round 3... + for (let b in BLOCKS) { + if (game.location[b] == game.where) { + remove_from_array(game.reserves, b); + } + } +} + +function start_battle_round() { + if (++game.battle_round <= 3) { + log("~ Battle Round " + game.battle_round + " ~"); + + reset_road_limits(); + game.moved = {}; + + if (game.battle_round == 2) { + if (count_defenders() == 0) { + log("Defending main force was eliminated."); + log("The attacker is now the defender."); + game.attacker[game.where] = ENEMY[game.attacker[game.where]]; + } else if (count_attackers() == 0) { + log("Attacking main force was eliminated."); + } + bring_on_reserves(2); + } + + if (game.battle_round == 3) { + bring_on_reserves(3); + } + + pump_battle_round(); + } else { + end_battle(); + } +} + +function pump_battle_round() { + if (is_friendly_town(game.where) || is_enemy_town(game.where)) { + end_battle(); + } else if (count_attackers() == 0 || count_defenders() == 0) { + start_battle_round(); + } else { + function battle_step(active, initiative, candidate) { + game.battle_list = filter_battle_blocks(initiative, candidate); + if (game.battle_list) { + game.active = active; + return true; + } + return false; + } + + let attacker = game.attacker[game.where]; + let defender = ENEMY[attacker]; + + if (battle_step(defender, 'A', is_defender)) return; + if (battle_step(attacker, 'A', is_attacker)) return; + if (battle_step(defender, 'B', is_defender)) return; + if (battle_step(attacker, 'B', is_attacker)) return; + if (battle_step(defender, 'C', is_defender)) return; + if (battle_step(attacker, 'C', is_attacker)) return; + + start_battle_round(); + } +} + +function retreat_with_block(b) { + game.who = b; + game.state = 'retreat_in_battle'; +} + +function roll_attack(verb, b) { + game.hits = 0; + let fire = block_fire_power(b, game.where); + let printed_fire = block_printed_fire_power(b); + let rolls = []; + let results = []; + let steps = game.steps[b]; + for (let i = 0; i < steps; ++i) { + let die = roll_d6(); + rolls.push(die); + if (die <= fire) { + results.push(HIT_TEXT); + ++game.hits; + } else { + results.push(MISS_TEXT); + } + } + game.flash += block_name(b) + " " + BLOCKS[b].combat; + if (fire > printed_fire) + game.flash += "+" + (fire - printed_fire); + game.flash += " " + verb + "\n" + rolls.join(" ") + " = " + results.join(" "); +} + +function fire_with_block(b) { + game.moved[b] = true; + game.flash = ""; + roll_attack("fires", b); + log(game.flash); + if (game.hits > 0) { + game.active = ENEMY[game.active]; + goto_battle_hits(); + } else { + resume_battle(); + } +} + +states.battle_round = { + show_battle: true, + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to choose a combat action."; + view.prompt = "Battle: Charge, Fire, Harry, or Retreat with an army."; + for (let b of game.battle_list) { + gen_action(view, 'block', b); // take default action + gen_action(view, 'battle_fire', b); + gen_action(view, 'battle_retreat', b); + // Turcopoles and Nomads can Harry (fire and retreat) + if (block_type(b) == 'turcopoles' || block_type(b) == 'nomads') + gen_action(view, 'battle_harry', b); + // All Frank B blocks are knights who can Charge + if (block_owner(b) == FRANK && block_initiative(b) == 'B') + gen_action(view, 'battle_charge', b); + } + }, + battle_charge: function (who) { + charge_with_block(who); + }, + battle_fire: function (who) { + fire_with_block(who); + }, + battle_harry: function (who) { + harry_with_block(who); + }, + battle_retreat: function (who) { + retreat_with_block(who); + }, + block: function (who) { + fire_with_block(who); + }, +} + +function goto_battle_hits() { + game.battle_list = list_victims(game.active); + if (game.battle_list.length == 0) + resume_battle(); + else + game.state = 'battle_hits'; +} + +function apply_hit(who) { + game.flash = block_name(who) + " takes a hit."; + log(game.flash); + reduce_block(who, 'combat'); + game.hits--; + if (game.hits == 1) + game.flash += " 1 hit left."; + else if (game.hits > 1) + game.flash += " " + game.hits + " hits left."; + if (game.hits == 0) + resume_battle(); + else + goto_battle_hits(); +} + +function list_victims(p) { + let is_candidate = (p == game.attacker[game.where]) ? is_attacker : is_defender; + let max = 0; + for (let b in BLOCKS) + if (is_candidate(b) && game.steps[b] > max) + max = game.steps[b]; + let list = []; + for (let b in BLOCKS) + if (is_candidate(b) && game.steps[b] == max) + list.push(b); + return list; +} + +states.battle_hits = { + show_battle: true, + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to assign hits."; + view.prompt = "Assign " + game.hits + (game.hits != 1 ? " hits" : " hit") + " to your armies."; + for (let b of game.battle_list) { + gen_action(view, 'battle_hit', b); + gen_action(view, 'block', b); + } + }, + battle_hit: function (who) { + apply_hit(who); + }, + block: function (who) { + apply_hit(who); + }, +} + +function goto_retreat() { + game.active = game.attacker[game.where]; + if (is_contested_town(game.where)) { + game.state = 'retreat'; + game.turn_log = []; + clear_undo(); + } else { + clear_undo(); + goto_regroup(); + } +} + +states.retreat = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to retreat."; + view.prompt = "Retreat: Choose an army to move."; + gen_action_undo(view); + let can_retreat = false; + for (let b in BLOCKS) { + if (game.location[b] == game.where && can_block_retreat(b)) { + gen_action(view, 'block', b); + can_retreat = true; + } + } + if (!is_contested_town(game.where) || !can_retreat) + gen_action(view, 'end_retreat'); + }, + end_retreat: function () { + for (let b in BLOCKS) + if (game.location[b] == game.where && block_owner(b) == game.active) + eliminate_block(b); + print_turn_log(game.active + " retreats:"); + clear_undo(); + 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 = "Waiting for " + game.active + " to retreat."; + view.prompt = "Retreat: Move the army to a friendly or neutral area."; + gen_action_undo(view); + gen_action(view, 'block', game.who); + let can_retreat = false; + for (let to of AREAS[game.where].exits) { + if (can_block_retreat_to(game.who, to)) { + gen_action(view, 'area', to); + can_retreat = true; + } + } + if (!can_retreat) + gen_action(view, 'eliminate'); + }, + area: function (to) { + let from = game.where; + game.turn_log.push([from, to]); + move_block(game.who, game.where, to); + game.who = null; + game.state = 'retreat'; + }, + eliminate: function () { + eliminate_block(game.who); + game.who = null; + game.state = 'retreat'; + }, + block: pop_undo, + undo: pop_undo +} + +states.retreat_in_battle = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to retreat."; + gen_action(view, 'undo'); + gen_action(view, 'block', game.who); + view.prompt = "Retreat: Move the army to a friendly or vacant area."; + for (let to of TOWNS[game.where].exits) + if (can_block_retreat_to(game.who, to)) + gen_action(view, 'town', to); + }, + town: function (to) { + logp("retreats to " + to + "."); + game.location[game.who] = to; + resume_battle(); + }, + eliminate: function () { + eliminate_block(game.who); + resume_battle(); + }, + block: function (to) { + resume_battle(); + }, + undo: function () { + resume_battle(); + } +} + +function goto_regroup() { + game.active = game.attacker[game.where]; + if (is_enemy_town(game.where)) + game.active = ENEMY[game.active]; + log(game.active + " wins the battle in " + game.where + "."); + game.state = 'regroup'; + game.turn_log = []; +} + +states.regroup = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to regroup."; + view.prompt = "Regroup: Choose an army to move."; + gen_action_undo(view); + gen_action(view, 'end_regroup'); + for (let b in BLOCKS) { + if (game.location[b] == game.where) { + if (can_block_regroup(b)) + gen_action(view, 'block', b); + } + } + }, + block: function (who) { + push_undo(); + game.who = who; + game.state = 'regroup_to'; + }, + end_regroup: function () { + print_turn_log(game.active + " regroups:"); + game.where = null; + clear_undo(); + goto_battle_phase(); + }, + undo: pop_undo +} + +states.regroup_to = { + prompt: function (view, current) { + if (is_inactive_player(current)) + return view.prompt = "Waiting for " + game.active + " to regroup."; + view.prompt = "Regroup: Move the army to a friendly or vacant area."; + 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) { + game.turn_log.push([from, to]); + move_block(game.who, game.where, to); + game.who = null; + game.state = 'regroup'; + }, + block: pop_undo, + undo: pop_undo +} + +// DRAW PHASE + +function goto_draw_phase() { + end_game_turn(); +} + +// GAME OVER + +function goto_game_over() { + game.active = "None"; + game.state = 'game_over'; +} + +states.game_over = { + prompt: function (view, current) { + view.prompt = game.victory; + } +} + +// SETUP + +function deploy(who, where) { + game.location[who] = where; + game.steps[who] = block_max_steps(who); +} + +function reset_blocks() { + for (let b in BLOCKS) { + game.location[b] = null; + game.steps[b] = block_max_steps(b); + } +} + +function setup_game() { + reset_blocks(); + game.year = 1187; + for (let b in BLOCKS) { + if (block_owner(b) == FRANK) { + switch (block_type(b)) { + case 'pilgrims': + case 'crusaders': + deploy(b, block_pool(b)); + break; + default: + deploy(b, block_home(b)); + break; + } + } + if (block_owner(b) == SARACEN) { + if (block_type(b) == 'emirs') + deploy(b, block_home(b)); + if (block_type(b) == 'nomads') + deploy(b, block_pool(b)); + } + if (block_owner(b) == ASSASSINS) { + deploy(b, block_home(b)); + } + } +} + +// VIEW + +function make_battle_view() { + let battle = { + FA: [], FB: [], FC: [], FR: [], + SA: [], SB: [], SC: [], SR: [], + flash: game.flash + }; + + battle.title = game.attacker[game.where] + " attacks " + game.where; + battle.title += " \u2014 round " + game.battle_round + " of 3"; + + function fill_cell(cell, owner, fn) { + for (let b in BLOCKS) + if (game.location[b] == game.where & block_owner(b) == owner && fn(b)) + cell.push([b, game.steps[b], game.moved[b]?1:0]) + } + + fill_cell(battle.FR, FRANK, b => is_battle_reserve(b)); + fill_cell(battle.FA, FRANK, b => !is_battle_reserve(b) && block_initiative(b) == 'A'); + fill_cell(battle.FB, FRANK, b => !is_battle_reserve(b) && block_initiative(b) == 'B'); + fill_cell(battle.FC, FRANK, b => !is_battle_reserve(b) && block_initiative(b) == 'C'); + + fill_cell(battle.SR, SARACEN, b => is_battle_reserve(b)); + fill_cell(battle.SA, SARACEN, b => !is_battle_reserve(b) && block_initiative(b) == 'A'); + fill_cell(battle.SB, SARACEN, b => !is_battle_reserve(b) && block_initiative(b) == 'B'); + fill_cell(battle.SC, SARACEN, b => !is_battle_reserve(b) && block_initiative(b) == 'C'); + + return battle; +} + +exports.ready = function (scenario, players) { + return players.length === 2; +} + +exports.setup = function (scenario, players) { + game = { + attacker: {}, + road_limit: {}, + last_used: {}, + location: {}, + log: [], + main_road: {}, + moved: {}, + moves: 0, + prompt: null, + reserves: [], + show_cards: false, + steps: {}, + who: null, + where: null, + undo: [], + } + setup_game(); + start_year(); + return game; +} + +exports.action = function (state, current, action, arg) { + game = state; + // TODO: check action and argument against action list + if (is_active_player(current)) { + let S = states[game.state]; + if (action in S) { + S[action](arg, current); + } else { + throw new Error("Invalid action: " + action); + } + } + return state; +} + +exports.resign = function (state, current) { + game = state; + if (game.state != 'game_over') { + log(""); + log(current + " resigned."); + game.active = "None"; + game.state = 'game_over'; + game.victory = current + " resigned."; + game.result = ENEMY[current]; + } +} + +exports.view = function(state, current) { + game = state; + + let view = { + log: game.log, + year: game.year, + active: game.active, + f_card: (game.show_cards || current == FRANK) ? game.f_card : 0, + s_card: (game.show_cards || current == SARACEN) ? game.s_card : 0, + hand: (current == FRANK) ? game.f_hand : (current == SARACEN) ? game.s_hand : [], + who: (game.active == current) ? game.who : null, + where: game.where, + known: {}, + secret: { Frank: {}, Saracen: {}, Assassins: {} }, + battle: null, + prompt: null, + actions: null, + }; + + states[game.state].prompt(view, current); + + if (states[game.state].show_battle) + view.battle = make_battle_view(); + + for (let b in BLOCKS) { + let a = game.location[b]; + if (!a) + continue; + if (a == F_POOL) // && current != FRANK) + continue; + if (a == S_POOL) // && current != SARACEN) + continue; + if (a == F_POOL || a == S_POOL) + a = "Pool"; + + let is_known = false; + if (current == block_owner(b)) + is_known = true; + if (b == ASSASSINS) + is_known = true; + + if (is_known) { + view.known[b] = [a, game.steps[b], game.moved[b] ? 1 : 0]; + } else { + let list = view.secret[BLOCKS[b].owner]; + if (!(a in list)) + list[a] = [0, 0]; + list[a][0]++; + if (game.moved[b]) + list[a][1]++; + } + } + + return view; +} |