"use strict" // unit state: location (8 bits), steps (2 bits), supplied, disrupted, moved const max = Math.max const min = Math.min const abs = Math.abs var states = {} var game = null var view = null let { hex_exists, hex_road, side_road, side_limit, hex_name, units, regions } = require("./data") function debug_hexes3(n, list) { console.log("--", n, "--") list = list.map((x,i) => hex_exists[i] ? x : "") for (let y = 0; y < hexh; ++y) console.log("".padStart(y*2," ") + list.slice(y*hexw, (y+1)*hexw).map(x=>String(x).padStart(3, ' ')).join(" ")) } function debug_hexes2(n, list) { console.log("--", n, "--") list = list.map((x,i) => hex_exists[i] ? x : "") for (let y = 0; y < hexh; ++y) console.log("".padStart(y*2," ") + list.slice(y*hexw, (y+1)*hexw).map(x=>String(x).padStart(3, ' ')).join(" ")) } function debug_hexes(n, list) { console.log("--", n, "--") list = list.map((x,i) => hex_exists[i] ? x : "") for (let y = 0; y < hexh; ++y) console.log("".padStart(y," ") + list.slice(y*hexw, (y+1)*hexw).map(x=>String(x).padStart(2, ' ')).join("")) } // Card deck has 42 cards, of which 28 are supply cards, and 14 are dummy cards. // Represent draw pile and hands as [ dummy_supply_count, real_supply_count ] const REAL_SUPPLY_COUNT = 28 const DUMMY_SUPPLY_COUNT = 14 const hexw = 25 const hexh = 9 const first_hex = 7 const last_hex = 215 const hexdeploy = hexw * hexh const hexnext = [ 1, hexw, hexw-1, -1, -hexw, -(hexw-1) ] const hexcount = last_hex + 1 const sidecount = hexcount * 3 const AXIS = 'Axis' const ALLIED = 'Allied' const CLEAR = 2 const PASS = 1 const ROUGH = 0 const NO_ROAD = 0 const TRAIL = 1 const TRACK = 2 const HIGHWAY = 4 const SUPPLY_RANGE = [ 1, 2, 3, -1, 3 ] const EL_AGHEILA = 151 const ALEXANDRIA = 74 const BENGHAZI = 54 const TOBRUK = 37 const BARDIA = 40 const FT_CAPUZZO = 64 const MERSA_BREGA = 152 const BARDIA_FT_CAPUZZO = 122 const region_egypt = regions["Egypt"] const region_el_agheila = regions["El Agheila"] const region_tobruk = regions["Tobruk"] const region_egypt_and_libya = regions["Libya"].concat(regions["Egypt"]) const region_libya_and_sidi_omar = regions["Libya"].concat(regions["Sidi Omar"]) const region_libya_and_sidi_omar_and_sollum = regions["Libya"].concat(regions["Sidi Omar"]).concat(regions["Sollum"]) const region_egypt_and_tobruk = regions["Egypt"].concat(regions["Tobruk"]) const region_libya_except_tobruk = regions["Libya"].filter(r => r !== TOBRUK) const region_all = [] for (let x = first_hex; x <= last_hex; ++x) region_all.push(x) function calc_distance(a, b) { let ax = a % hexw, ay = (a / hexw)|0, az = -ax - ay let bx = b % hexw, by = (b / hexw)|0, bz = -bx - by return max(abs(bx-ax), abs(by-ay), abs(bz-az)) } function calc_distance_map(supply) { let map = new Array(hexcount) for (let x = 0; x < hexcount; ++x) map[x] = calc_distance(supply, x) return map } const distance_to = { [EL_AGHEILA]: calc_distance_map(EL_AGHEILA), [ALEXANDRIA]: calc_distance_map(ALEXANDRIA), [BENGHAZI]: calc_distance_map(BENGHAZI), [TOBRUK]: calc_distance_map(TOBRUK), [BARDIA]: calc_distance_map(BARDIA), } function draw_supply_card(pile) { let x = random(pile[0] + pile[1]) if (x < pile[0]) { pile[0] -- return 0 } else { pile[1] -- return 1 } } function deal_axis_supply_cards(n) { for (let i = 0; i < n; ++i) game.axis_hand[draw_supply_card(game.draw_pile)]++ } function deal_allied_supply_cards(n) { for (let i = 0; i < n; ++i) game.allied_hand[draw_supply_card(game.draw_pile)]++ } function find_unit(name) { for (let u = 0; u < units.length; ++u) if (units[u].name === name) return u throw new Error("cannot find named block: " + name) } function is_map_hex(x) { return next >= first_hex && next <= last_hex && hex_exists[next] === 1 } function is_hex_or_adjacent_to(x, where) { if (x === where) return true for (let s = 0; s < 6; ++s) if (x === where + hexnext[s]) return true return false } function find_units(list) { return list.map(name => find_unit(name)) } function unit_name(u) { return units[u].name } function unit_speed(u) { return units[u].speed } function unit_hex(u) { return game.units[u] >>> 5 } function set_unit_hex(u, x) { game.units[u] = (game.units[u] & 31) | (x << 5) } function unit_lost_steps(u) { return game.units[u] & 3 } function set_unit_lost_steps(u, n) { game.units[u] = (game.units[u] & ~3) | n } function is_unit_supplied(u) { return (game.units[u] & 4) === 4 } function set_unit_supplied(u) { game.units[u] |= 4 } function clear_unit_supplied(u) { game.units[u] &= ~4 } function is_unit_disrupted(u) { return (game.units[u] & 8) === 8 } function set_unit_disrupted(u) { game.units[u] |= 8 } function clear_unit_disrupted(u) { game.units[u] &= ~8 } function is_unit_moved(u) { return (game.units[u] & 16) === 16 } function set_unit_moved(u) { game.units[u] |= 16 } function clear_unit_moved(u) { game.units[u] &= ~16 } function unit_steps(u) { return units[u].steps - unit_lost_steps(u) } function set_unit_steps(u, n) { set_unit_lost_steps(u, units[u].steps - n) } function is_friendly_unit(u) { if (game.active === AXIS) return is_axis_unit(u) return is_allied_unit(u) } function is_enemy_unit(u) { if (game.active === ALLIED) return is_axis_unit(u) return is_allied_unit(u) } function is_friendly_hex(x) { if (game.active === AXIS) return is_axis_hex(x) return is_allied_hex(x) } function is_enemy_hex(x) { if (game.active === ALLIED) return is_axis_hex(x) return is_allied_hex(x) } function is_allied_unit(u) { return units[u].nationality === 'allied' } function is_axis_unit(u) { return units[u].nationality !== 'allied' } function is_axis_hex(x) { if (!hex_exists[x]) return false let has_axis = false, has_allied = false for (let u = 0; u < units.length; ++u) { if (unit_hex(u) === x) { if (is_axis_unit(u)) has_axis = true else has_allied = true } } return has_axis && !has_allied } function is_allied_hex(x) { if (!hex_exists[x]) return false let has_axis = false, has_allied = false for (let u = 0; u < units.length; ++u) { if (unit_hex(u) === x) { if (is_axis_unit(u)) has_axis = true else has_allied = true } } return !has_axis && has_allied } function is_battle_hex(x) { if (!hex_exists[x]) return false let has_axis = false, has_allied = false for (let u = 0; u < units.length; ++u) { if (unit_hex(u) === x) { if (is_axis_unit(u)) has_axis = true else has_allied = true } } return has_axis && has_allied } function is_empty_hex(x) { if (!hex_exists[x]) return false let has_axis = false, has_allied = false for (let u = 0; u < units.length; ++u) { if (unit_hex(u) === x) { if (is_axis_unit(u)) has_axis = true else has_allied = true } } return !has_axis && !has_allied } function has_axis_unit(x) { if (!hex_exists[x]) return false for (let u = 0; u < units.length; ++u) if (unit_hex(u) === x) if (is_axis_unit(u)) return true return false } function has_allied_unit(x) { if (!hex_exists[x]) return false for (let u = 0; u < units.length; ++u) if (unit_hex(u) === x) if (is_allied_unit(u)) return true return false } function has_friendly_unit(x) { if (game.active === AXIS) return has_axis_unit(x) return has_allied_unit(x) } function has_enemy_unit(x) { if (game.active === ALLIED) return has_axis_unit(x) return has_allied_unit(x) } function claim_hexside_control(side) { if (game.active === AXIS) { set_add(game.axis_sides, side) set_delete(game.allied_sides, side) } else { set_add(game.allied_sides, side) set_delete(game.axis_sides, side) } } function release_hex_control(a) { // no longer a battle hex: release hexsides if possible set_delete(game.axis_hexes, a) set_delete(game.allied_hexes, a) for (let s = 0; s < 6; ++s) { let b = a + hexnext[s] if (b >= first_hex && b <= last_hex && hex_exists[b]) { if (!is_battle_hex(b)) { let side = to_side_id(a, b) set_delete(game.axis_sides, side) set_delete(game.allied_sides, side) } } } } function is_new_battle_hex(a) { if (is_battle_hex(a)) return !set_has(game.axis_hexes) && !set_has(game.allied_hexes) return false } function claim_hex_control_for_defender(a) { // a new battle hex: claim hex and hexsides for defender if (game.active === AXIS) set_add(game.allied_hexes, a) else set_add(game.axis_hexes, a) for (let s = 0; s < 6; ++s) { let b = a + hexnext[s] if (b >= first_hex && b <= last_hex && hex_exists[b]) { let side = to_side_id(a, b) if (side_limit[side] > 0) { if (game.active === AXIS) { if (!set_has(game.axis_sides, side)) set_add(game.allied_sides, side) } else { if (!set_has(game.allied_sides, side)) set_add(game.axis_sides, side) } } } } } function claim_stuff() { for (let x = first_hex; x <= last_hex; ++x) if (hex_exists[x]) if (is_new_battle_hex(x)) claim_hex_control_for_defender(x) } // === SUPPLY NETWORK === function is_side_unit(side, u) { if (side === AXIS) return is_axis_unit(u) return is_allied_unit(u) } function list_control_hexes(side) { let control = new Array(hexcount).fill(0) for (let u = 0; u < units.length; ++u) { if (is_side_unit(side, u)) { let x = unit_hex(u) if (x > 0 && x < hexdeploy) control[x] = 1 } } return control } function to_side(a, b, s) { if (s < 3) return a * 3 + s return b * 3 + s - 3 } function to_side_id(a, b) { if (a > b) { let c = b b = a a = c } if (a + hexnext[0] === b) return a * 3 + 0 else if (a + hexnext[1] === b) return a * 3 + 1 else if (a + hexnext[2] === b) return a * 3 + 2 throw new Error("not a hexside " + a + " to " + b); } function ind(d, msg, here, ...extra) { console.log(new Array(d).fill("-").join("") + msg, here, "("+hex_name[here]+")", extra.join(" ")) } var supply_visited, supply_friendly, supply_enemy, supply_src, supply_net, supply_line var trace_total var trace_highway var trace_chain function trace_supply_highway(here, d) { trace_highway++ ind(d, "> highway", here) // TODO: hoist to call sites to avoid function call overhead if (supply_src[here]) { ind(d, "! source highway", here) return true } let has_supply = false supply_visited[here] = true for (let s = 0; s < 6; ++s) { let next = here + hexnext[s] let side = to_side(here, next, s) if (side_limit[side] === 0) continue if (supply_visited[next]) continue if (supply_enemy[next]) continue let road = side_road[side] if (road === HIGHWAY) { if (supply_friendly[next]) { ind(d, "? highway head", next) if (trace_supply_chain(next, d+1, 0, 3)) { ind(d, "< highway chain", here, next) supply_line[side] = 1 has_supply = true } } else { if (trace_supply_highway(next, d+1)) { ind(d, "< highway", here, next) supply_line[side] = 1 has_supply = true } } } } supply_visited[here] = false if (has_supply) { supply_net[here] = 1 supply_src[here] = 1 } return has_supply } function trace_supply_chain(here, d, n, range) { ind(d, "> chain", here, n, range) trace_chain++ if (supply_src[here]) { ind(d, "! source chain", here) return true } let has_supply = false supply_visited[here] = true for (let s = 0; s < 6; ++s) { let next = here + hexnext[s] let side = to_side(here, next, s) if (side_limit[side] === 0) continue if (supply_visited[next]) continue if (supply_enemy[next]) continue let road = side_road[side] if (road === HIGHWAY) { ind(d, "? chain highway", next) if (supply_friendly[next]) { ind(d, "? chain highway head", next) if (trace_supply_chain(next, d+1, 0, 3)) { ind(d, "< highway chain", here, next) supply_line[side] = 1 has_supply = true } } else { if (trace_supply_highway(next, d+1)) { ind(d, "< chain highway", here, next) supply_line[side] = 1 has_supply = true } } } else { let next_range = min(range, SUPPLY_RANGE[road]) if (n + 1 <= next_range) { if (supply_friendly[next]) { ind(d, "? chain head", next) if (trace_supply_chain(next, d+1, 0, 3)) { ind(d, "< highway chain", here, next) supply_line[side] = 1 has_supply = true } } else { if (trace_supply_chain(next, d+1, n+1, next_range)) { ind(d, "< chain trail", here, next_range) supply_line[side] = 1 has_supply = true } } } } } supply_visited[here] = false if (has_supply) { supply_net[here] = 1 if (supply_friendly[here]) supply_src[here] = 1 } return has_supply } function trace_supply_network(start) { supply_visited = new Array(hexcount).fill(false) supply_net = new Array(hexcount).fill(0) supply_line = new Array(sidecount).fill(0) supply_src = new Array(hexcount).fill(0) supply_src[start] = 1 supply_net[start] = 1 console.log("=== SUPPLY NETWORK ===") // debug_hexes("FH", supply_friendly) // debug_hexes("EH", supply_enemy) // debug_hexes("SS1", supply_src) trace_total = 0 for (let x = first_hex; x <= last_hex; ++x) { if (supply_friendly[x]) { trace_highway = trace_chain = 0 ind(0, "START", x) trace_supply_chain(x, 0, 0, 3) console.log("END", trace_highway, trace_chain) trace_total += trace_highway + trace_chain } } console.log("VISITS", trace_total) debug_hexes("SS", supply_src) debug_hexes("SN", supply_net) } function update_axis_supply_network() { supply_friendly = list_control_hexes(AXIS) supply_enemy = list_control_hexes(ALLIED) trace_supply_network(EL_AGHEILA) game.axis_supply = supply_net game.axis_supply_line = supply_line } function update_allied_supply_network() { supply_friendly = list_control_hexes(ALLIED) supply_enemy = list_control_hexes(AXIS) trace_supply_network(ALEXANDRIA) game.allied_supply = supply_net game.allied_supply_line = supply_line } function update_supply_networks() { update_axis_supply_network() update_allied_supply_network() } function clear_supply_networks() { game.axis_supply = null game.axis_supply_line = null game.allied_supply = null game.allied_supply_line = null } // === MOVEMENT === const path_from = [ new Array(hexcount), new Array(hexcount), new Array(hexcount), null, new Array(hexcount) ] const path_cost = [ new Array(hexcount), new Array(hexcount), new Array(hexcount), null, new Array(hexcount) ] const path_valid = new Array(hexcount) const path_enemy = new Array(hexcount) function search_move(start, start_cost, start_road) { // recon=4, forced march=+1, rommel bonus=+1 let limit = 6 path_enemy.fill(0) for (let u = 0; u < units.length; ++u) { if (is_enemy_unit(u)) { let x = unit_hex(u) if (x >= first_hex && x <= last_hex) path_enemy[x] = 1 } } search_move_bfs(path_from[0], path_cost[0], start, start_cost, 0, limit) if (start_road >= 1) search_move_bfs(path_from[1], path_cost[1], start, start_cost, 1, limit + 1) if (start_road >= 2) search_move_bfs(path_from[2], path_cost[2], start, start_cost, 2, limit + 2) if (start_road >= 4) search_move_bfs(path_from[4], path_cost[4], start, start_cost, 4, limit + 4) let grid = new Array(hexcount).fill('-') for (let x = first_hex; x <= last_hex; ++x) { for (let speed = 4; speed >= 1; --speed) { if (path_cost[0][x] <= speed) grid[x] = speed if (path_cost[1][x] <= speed + 1) grid[x] = speed if (path_cost[2][x] <= speed + 2) grid[x] = speed if (path_cost[4][x] <= speed + 4) grid[x] = speed } } grid[start] = '@' debug_hexes2("reach", path_cost[0]) } // Breadth First Search function search_move_bfs(from, cost, start, start_cost, road, max_cost) { let queue = [ start << 4 | start_cost ] from.fill(0) cost.fill(15) cost[start] = start_cost while (queue.length > 0) { let item = queue.shift() let here = item >> 4 let here_cost = item & 15 let next_cost = here_cost + 1 for (let s = 0; s < 6; ++s) { let next = here + hexnext[s] // can't go off-map if (next < first_hex || next > last_hex || !hex_exists[next]) continue // already seen if (cost[next] < 15) continue let side = to_side(here, next, s) let max_side = side_limit[side] // can't cross this hexside if (max_side === 0) continue // must stay on road for current bonus if (side_road[side] < road) continue // check hexside limit if (path_enemy[next] && (game.side_limit[side] | 0) >= max_side) continue from[next] = here cost[next] = next_cost // must stop if (path_enemy[next]) continue // enough movement allowance to keep going if (next_cost < max_cost) queue.push(next << 4 | next_cost) } } } function can_move_to(to, road, speed) { // TODO: engagement & hexside limit if (road >= 4 && path_cost[4][to] <= speed + 4) return true if (road >= 2 && path_cost[2][to] <= speed + 2) return true if (road >= 1 && path_cost[1][to] <= speed + 1) return true if (path_cost[0][to] <= speed) return true return false } function can_move_from(from, road, speed) { // TODO: engagement & hexside limit return can_move_to(from, road, speed) } function can_force_march_to(to, road, speed) { if (road >= 4 && path_cost[4][to] <= speed + 5) return true if (road >= 2 && path_cost[2][to] <= speed + 3) return true if (road >= 1 && path_cost[1][to] <= speed + 2) return true if (path_cost[0][to] <= speed + 1) return true return false } function pick_path(to, road, speed) { let next_cost = 15, next_road = 0 if (path_cost[0][to] <= speed) { next_cost = path_cost[0][to] next_road = 0 } if (road >= 1 && path_cost[1][to] <= speed + 1) { if (path_cost[1][to] <= next_cost) { next_cost = path_cost[1][to] next_road = 1 } } if (road >= 2 && path_cost[2][to] <= speed + 2) { if (path_cost[2][to] <= next_cost) { next_cost = path_cost[2][to] next_road = 2 } } if (road >= 4 && path_cost[4][to] <= speed + 4) { if (path_cost[4][to] <= next_cost) { next_cost = path_cost[4][to] next_road = 4 } } return next_road } function pay_movement_cost(to, this_road, speed) { } function neighbor_has_friendly_unit(here) { for (let s = 0; s < 6; ++s) { let next = here + hexnext[s] if (next >= first_hex && next <= last_hex) if (has_friendly_unit(next)) return true } return false } function for_each_hex_and_adjacent_hex(here, fn) { fn(here) for (let s = 0; s < 6; ++s) { let next = here + hexnext[s] if (next >= first_hex && next <= last_hex && hex_exists[next]) fn(next) } } function for_each_friendly_unit_in_hex(x, fn) { for (let u = 0; u < units.length; ++u) if (is_friendly_unit(u) && unit_hex(u) === x) fn(u) } function max_speed_of_friendly_unit_in_hex(from) { let max = 0 for_each_friendly_unit_in_hex(from, u => { let s = unit_speed(u) if (s > max) max = s }) return max } function find_valid_regroup_destinations(from, rommel) { let speed = max_speed_of_friendly_unit_in_hex(from) if (speed > 0) { search_move(from, 0, 4) for (let x = first_hex; x <= last_hex; ++x) if (!path_valid[x]) if (can_move_to(x, 4, speed + rommel)) path_valid[x] = 1 } } // === TURN === // Supply check // Turn option // Movement // Combat // Blitz Movement // Blitz Combat // Final supply check // Reveal supply cards -> next player function end_player_turn() { // TODO: end when both pass if (game.phasing === AXIS) game.phasing = ALLIED else game.phasing = AXIS game.active = game.phasing goto_player_turn() } function goto_player_turn() { game.rommel = 0 game.from1 = game.from2 = 0 game.to1 = game.to2 = 0 goto_supply_check() } function goto_supply_check() { goto_turn_option() } function goto_turn_option() { game.state = 'turn_option' } states.turn_option = { inactive: "turn option", prompt() { view.prompt = "Select Turn Option" gen_action('basic') gen_action('offensive') gen_action('assault') gen_action('blitz') gen_action('pass') }, basic() { push_undo() game.turn_option = 'basic' goto_move_phase() }, offensive() { push_undo() game.turn_option = 'offensive' goto_move_phase() }, assault() { push_undo() game.turn_option = 'assault' goto_move_phase() }, blitz() { push_undo() game.turn_option = 'blitz' goto_move_phase() }, pass() { push_undo() game.turn_option = 'pass' goto_move_phase() }, } function goto_move_phase() { game.state = 'select_moves' if (game.active === AXIS) { // Automatically select Rommel Move for 1-move turn options if (game.turn_option !== 'offensive' && game.turn_option !== 'blitz') game.rommel = 1 } } states.select_moves = { inactive: "move phase", prompt() { if (game.turn_option === 'offensive') { if (game.from1) view.prompt = `Designate second offensive move.` else view.prompt = `Designate first offensive move.` } else { view.prompt = `Designate ${game.turn_option} move.` } gen_action('group') gen_action('regroup') }, group() { push_undo() game.state = 'group_move_from' }, regroup() { push_undo() game.state = 'regroup_move_command_point' }, } function gen_rommel_move() { if (game.active === AXIS) view.actions.rommel = game.rommel ? 0 : 1 } states.group_move_from = { inactive: "group move (from)", prompt() { view.prompt = `Group Move: Select hex to move from.` gen_rommel_move() for (let x = first_hex; x <= last_hex; ++x) { if (x === game.from1 && !game.to1) continue if (has_friendly_unit(x)) gen_action_hex(x) } }, rommel() { push_undo() if (game.from1 === 0) game.rommel = 1 else game.rommel = 2 }, hex(x) { push_undo() if (game.from1 === 0) game.from1 = x else game.from2 = x if (game.turn_option === 'offensive' && !game.from2) game.state = 'select_moves' else goto_move_who() }, } states.regroup_move_command_point = { inactive: "regroup move (command point)", prompt() { view.prompt = `Regroup Move: Designate the command point hex.` gen_rommel_move() for (let x = first_hex; x <= last_hex; ++x) { if (!is_enemy_hex(x)) { if (has_friendly_unit(x) || neighbor_has_friendly_unit(x)) gen_action_hex(x) } } }, rommel() { push_undo() if (game.from1 === 0) game.rommel = 1 else game.rommel = 2 }, hex(x) { push_undo() if (game.from1 === 0) game.from1 = x else game.from2 = x game.state = 'regroup_move_destination' }, } states.regroup_move_destination = { inactive: "regroup move (destination)", prompt() { view.prompt = `Regroup Move: Select destination hex.` gen_rommel_move() let cp, rommel = false if (game.from2 === 0) cp = game.from1, rommel = (game.rommel === 1 ? 1 : 0) else cp = game.from2, rommel = (game.rommel === 2 ? 1 : 0) path_valid.fill(0) for_each_hex_and_adjacent_hex(cp, x => { find_valid_regroup_destinations(x, rommel) }) for (let x = first_hex; x <= last_hex; ++x) if (path_valid[x]) gen_action_hex(x) }, rommel() { push_undo() if (game.from2 === 0) game.rommel = 1 else game.rommel = 2 }, hex(x) { push_undo() if (game.from2 === 0) game.to1 = x else game.to2 = x if (game.turn_option === 'offensive' && !game.from2) game.state = 'select_moves' else goto_move_who() }, } function goto_move_who() { if (game.rommel === 1) { if (game.from1 && game.to1) log(`Regroup move from ${game.from1} to ${game.to1} (Rommel).`) else if (game.from1) log(`Group move from ${game.from1} (Rommel).`) } else { if (game.from1 && game.to1) log(`Regroup move from ${game.from1} to ${game.to1}.`) else if (game.from1) log(`Group move from ${game.from1}.`) } if (game.rommel === 2) { if (game.from2 && game.to2) log(`Regroup move from ${game.from2} to ${game.to2} (Rommel).`) else if (game.from2) log(`Group move from ${game.from2} (Rommel).`) } else { if (game.from2 && game.to2) log(`Regroup move from ${game.from2} to ${game.to2}.`) else if (game.from2) log(`Group move from ${game.from2}.`) } game.state = 'move_who' } function gen_group_move_who(from) { for_each_friendly_unit_in_hex(from, u => { if (!is_unit_moved(u)) gen_action_unit(u) }) } function gen_regroup_move_who(command_point, destination, rommel) { search_move(destination, 0, 4) for_each_hex_and_adjacent_hex(command_point, x => { if (x !== destination) { for_each_friendly_unit_in_hex(x, u => { if (!is_unit_moved(u) && can_move_from(x, 4, unit_speed(u) + rommel)) gen_action_unit(u) }) } }) } states.move_who = { inactive: "move (who)", prompt() { view.prompt = `Move: Select unit to move.` if (game.from1) { if (game.to1) gen_regroup_move_who(game.from1, game.to1, game.rommel === 1 ? 1 : 0) else gen_group_move_who(game.from1) } if (game.from2) { if (game.to2) gen_regroup_move_who(game.from2, game.to2, game.rommel === 2 ? 1 : 0) else gen_group_move_who(game.from2) } // TODO: retreat gen_action('retreat') gen_action('end_move') }, unit(who) { push_undo() game.selected = [ who ] game.state = 'move_to' game.move_used = 0 game.move_road = 4 }, end_move() { clear_supply_networks() } } function rommel_group_move_bonus(from) { if (game.rommel === 1 && from === game.from1 && !game.to1) return 1 if (game.rommel === 2 && from === game.from2 && !game.to2) return 1 return 0 } function print_path(who, from, to, road) { let p = [ hex_name[to] ] while (to && to !== from) { to = path_from[road][to] p.unshift(hex_name[to]) } log(unit_name(who) + " moved " + p.join(", ") + ".") } function print_pathX(who, start, to, road) { let from = path_from[road][to] log(`M ${hex_name[from]} to ${hex_name[to]}`) while (from && from !== start) { to = from from = path_from[road][from] log(`M ${hex_name[from]} to ${hex_name[to]}`) } } function apply_move(move, who, from, to) { let speed = unit_speed(who) + (move === game.rommel ? 1 : 0) let road = pick_path(to, game.move_road, speed) print_path(who, unit_hex(who), to, road) game.move_road = road game.move_used = path_cost[road][to] set_unit_moved(who) set_unit_hex(who, to) pay_movement_cost(to, game.move_road, speed) if (is_battle_hex(to)) { let side = to_side_id(to, path_from[road][to]) log(`cross ${side} ${hex_name[to]}/${hex_name[path_from[road][to]]}`) if (game.side_limit[side]) game.side_limit[side] = 2 else game.side_limit[side] = 1 claim_hexside_control(side) if (is_new_battle_hex(to)) { claim_hex_control_for_defender(to) game.battles.push(to) } return true } if (game.move_used === speed + game.move_road) return true return false } function unit_speed_1(who) { return unit_speed(who) + (game.rommel === 1 ? 1 : 0) } function unit_speed_2(who) { return unit_speed(who) + (game.rommel === 2 ? 1 : 0) } function can_move_regroup_1(who, from, to) { if (to === game.to1 && is_hex_or_adjacent_to(from, game.from1)) if (can_move_to(game.to1, 4, unit_speed_1(who))) return true return false } function can_move_regroup_2(who, from, to) { if (to === game.to2 && is_hex_or_adjacent_to(from, game.from2)) if (can_move_to(game.to2, 4, unit_speed_2(who))) return true return false } function can_move_group_1(who, from, to) { if (from === game.from1 && !game.to1) if (can_move_to(to, game.move_road, unit_speed_1(who))) return true return false } function can_move_group_2(who, from, to) { if (from === game.from2 && !game.to2) if (can_move_to(to, game.move_road, unit_speed_2(who))) return true return false } states.move_to = { inactive: "move (to)", prompt() { view.prompt = `Move: Select where to move.` let who = game.selected[0] let from = unit_hex(who) search_move(from, 0, 4) if (from === game.from1 && !game.to1) for (let to = first_hex; to <= last_hex; ++to) if (to != from && hex_exists[to] && can_move_group_1(who, from, to)) gen_action_hex(to) if (from === game.from2 && !game.to2) for (let to = first_hex; to <= last_hex; ++to) if (to != from && hex_exists[to] && can_move_group_2(who, from, to)) gen_action_hex(to) if (can_move_regroup_1(who, from, game.to1)) gen_action_hex(game.to1) if (can_move_regroup_2(who, from, game.to2)) gen_action_hex(game.to2) }, hex(to) { push_undo() let who = game.selected[0] let from = unit_hex(who) search_move(from, 0, 4) if (can_move_group_1(who, from, to)) { log(`group moved ${who} to ${to}`) game.move_from = from if (apply_move(1, who, from, to)) stop_move(who) else game.state = 'group_move_to' return } if (can_move_group_2(who, from, to)) { log(`group moved ${who} to ${to}`) game.move_from = from if (apply_move(2, who, from, to)) stop_move(who) else game.state = 'group_move_to' return } if (can_move_regroup_1(who, from, to)) { log(`regrouped ${who} to ${to}`) apply_move(1, who, from, to) stop_move(who) game.state = 'move_who' return } if (can_move_regroup_2(who, from, to)) { log(`regrouped ${who} to ${to}`) apply_move(2, who, from, to) stop_move(who) game.state = 'move_who' return } }, } states.group_move_to = { inactive: "group move (to)", prompt() { view.prompt = `Group Move: Select where to move.` let who = game.selected[0] let from = unit_hex(who) if (game.move_used > 0) gen_action('stop') search_move(from, game.move_used, game.move_road) if (game.move_from === game.from1 && !game.to1) for (let to = first_hex; to <= last_hex; ++to) if (to != from && hex_exists[to] && can_move_group_1(who, game.move_from, to)) gen_action_hex(to) if (game.move_from === game.from2 && !game.to2) for (let to = first_hex; to <= last_hex; ++to) if (to != from && hex_exists[to] && can_move_group_2(who, game.move_from, to)) gen_action_hex(to) }, hex(to) { let who = game.selected[0] let from = unit_hex(who) search_move(from, game.move_used, game.move_road) if (can_move_group_1(who, game.move_from, to)) { log(`group continued ${who} to ${to}`) if (apply_move(1, who, from, to)) stop_move(who) return } if (can_move_group_2(who, game.move_from, to)) { log(`group continued ${who} to ${to}`) if (apply_move(2, who, from, to)) stop_move(who) return } }, stop() { let who = game.selected[0] stop_move(who) } } function stop_move(who) { set_unit_moved(who) game.move_from = 0 game.move_road = 4 game.move_used = 0 game.selected = [] game.state = 'move_who' } // === DEPLOYMENT === states.free_deployment = { inactive: "free deployment", prompt() { let scenario = SCENARIOS[game.scenario] let deploy = hexdeploy + scenario.start let done = true if (game.active === AXIS) view.prompt = "Axis Free Deployment" else view.prompt = "Allied Free Deployment" // TODO: first/last_axis_unit for (let u = 0; u < units.length; ++u) { if (is_friendly_unit(u)) { if (unit_hex(u) === deploy) { done = false gen_action_unit(u) } } } if (game.selected.length > 0) { let list if (game.active === AXIS) list = scenario.axis_deployment else list = scenario.allied_deployment // TODO: scenario.deployment_limit for (let i = 0; i < list.length; ++i) if (!is_enemy_hex(list[i])) gen_action_hex(list[i]) } if (done) gen_action_next() }, unit(u) { if (game.selected.includes(u)) remove_from_array(game.selected, u) else game.selected.push(u) }, hex(x) { push_undo() for (let i = 0; i < game.selected.length; ++i) { let u = game.selected[i] set_unit_hex(u, x) } game.selected.length = 0 }, next() { clear_undo() if (game.active === AXIS) game.active = ALLIED else end_free_deployment() } } function end_free_deployment() { game.phasing = game.first_player_turn game.active = game.phasing let scenario = SCENARIOS[game.scenario] deal_axis_supply_cards(scenario.axis_initial_supply) deal_allied_supply_cards(scenario.allied_initial_supply) // TODO: mulligan // No buildup first month // No initiative first month goto_player_turn() } // === SETUP === function find_axis_units(a) { let list = [] for (let u = 0; u < units.length; ++u) if (units[u].nationality !== 'allied' && units[u].appearance === a) list.push(u) return list } function find_allied_units(a) { let list = [] for (let u = 0; u < units.length; ++u) if (units[u].nationality === 'allied' && units[u].appearance === a) list.push(u) return list } function setup_reinforcements(m) { for (let u = 0; u < units.length; ++u) { if (units[u].appearance === m) { if (m === 'M') set_unit_hex(u, 4) else set_unit_hex(u, hexdeploy + (m > 10 ? m - 10 : m)) } } } function setup_units(where, steps, list) { if (where < 0) where = hexdeploy - where for (let u of list) { if (typeof u === 'string') u = find_unit(u) set_unit_hex(u, where) if (steps < 0) set_unit_steps(u, units[u].steps + steps) else if (steps > 0) set_unit_steps(u, steps) } } const SCENARIOS = { 1940: { year: 1940, start: 1, end: 6, axis_deployment: region_all, // XXX region_libya_and_sidi_omar, allied_deployment: region_all, // XXX region_egypt, axis_initial_supply: 6, allied_initial_supply: 3, special: { no_rommel_bonus: true, only_one_die_for_buildup: true, } }, "1941": { year: 1941, start: 1, end: 10, axis_deployment: [], allied_deployment: region_egypt_and_libya, axis_initial_supply: 6, allied_initial_supply: 6, }, "Crusader": { year: 1941, start: 8, end: 10, axis_deployment: region_libya_and_sidi_omar_and_sollum, allied_deployment: region_egypt_and_tobruk, axis_initial_supply: 10, allied_initial_supply: 12, deployment_limit: { [TOBRUK]: 5, }, special: { allies_first_turn: true, tobruk_minefield: true, } }, "Battleaxe": { year: 1941, start: 4, end: 10, axis_deployment: region_libya_except_tobruk, allied_deployment: region_egypt_and_tobruk, axis_initial_supply: 4, allied_initial_supply: 8, deployment_limit: { [TOBRUK]: 5, }, }, "1942": { year: 1942, start: 11, end: 20, axis_deployment: [ EL_AGHEILA, MERSA_BREGA ], allied_deployment: region_egypt_and_libya, axis_initial_supply: 5, allied_initial_supply: 5, deployment_limit: { [EL_AGHEILA]: 14, [MERSA_BREGA]: 10, }, }, "Gazala": { year: 1942, start: 14, end: 15, axis_deployment: regions["West Line"], allied_deployment: regions["East Line"], axis_initial_supply: 10, allied_initial_supply: 12, special: { gazala_pre_build: true, } }, "Pursuit to Alamein": { year: 1942, start: 15, end: 20, axis_deployment: regions["Libya"], allied_deployment: regions["Egypt"], axis_initial_supply: 8, allied_initial_supply: 8, }, "1941-42": { year: 1941, start: 1, end: 20, axis_deployment: [], allied_deployment: region_egypt_and_libya, axis_initial_supply: 6, allied_initial_supply: 6, }, } const SETUP = { "1940" (DEPLOY) { setup_units(DEPLOY, 0, [ "Pav", "Bre", "Tre", "Bol", "Sav", "Sab", "Fas", "Ita" ]) setup_units(-3, 0, [ "Ari", "Pis"]) setup_units(-5, 0, [ "Lit", "Cen"]) setup_units(DEPLOY, 0, [ "7/7", "7", "7/SG", "4IN/3m" ]) setup_units(-2, 0, [ "4IN/5", "4IN/11" ]) setup_units(-4, 0, [ "Matilda/A", "7/4", "4IN/7m", "/Tob" ]) }, "1941" (DEPLOY) { setup_units(EL_AGHEILA, 0, find_axis_units('S')) setup_reinforcements(3) setup_reinforcements(5) setup_reinforcements(7) setup_units(DEPLOY, 0, find_allied_units('S')) setup_units(TOBRUK, 0, find_allied_units('T')) setup_reinforcements(2) setup_reinforcements(4) setup_reinforcements(6) setup_reinforcements(8) setup_reinforcements(10) }, "Crusader" (DEPLOY) { setup_units(DEPLOY, 0, find_axis_units('S')) setup_units(DEPLOY, 0, find_axis_units(3)) setup_units(DEPLOY, 0, find_axis_units(5)) setup_units(DEPLOY, 0, find_axis_units(7)) setup_units(DEPLOY, 0, find_allied_units('S')) setup_units(DEPLOY, 0, find_allied_units('T')) setup_units(DEPLOY, 0, find_allied_units(2)) setup_units(DEPLOY, 0, find_allied_units(4)) setup_units(DEPLOY, 0, find_allied_units(6)) setup_units(DEPLOY, 0, find_allied_units(8)) setup_units(0, 0, [ "2/3", "2/SG", "9AU/20", "7AU/18" ]) setup_reinforcements(10) }, "Battleaxe" (DEPLOY) { setup_units(DEPLOY, 0, [ "21/5", "21/3", "15/33", "90/155", "15/115", "88mm/A" ]) setup_units(DEPLOY, -1, [ "21/104" ]) setup_units(DEPLOY, 0, [ "Ari", "Lit", "Pav", "Bre", "Tre", "Bol", "Ita" ]) setup_reinforcements(5) setup_reinforcements(7) setup_units(DEPLOY, 0, [ "4IN/3m", "9AU/20", "70/14+16", "70/23", "7/22G", "7/SG", "7AU/18", "Matilda/A", "/Tob", "/Pol", "7/7", "4IN/5", "4IN/7m", "4IN/11", "Matilda/B", "/1AT", "7/4", "7" ]) setup_reinforcements(6) setup_reinforcements(8) setup_reinforcements(10) }, "1942" (DEPLOY) { setup_units(DEPLOY, 0, [ "21/3", "15/33", "90/580", "90/sv288", "90/346", "88mm/A", ]) setup_units(DEPLOY, 1, [ "21/5", "15/8", "15/115", "90/155" ]) setup_units(DEPLOY, 1, [ "90/361", "90/200", "88mm/B", "50mm", "/104" ]) setup_units(DEPLOY, 0, [ "Lit" ]) setup_units(DEPLOY, -1, [ "Ari", "Tri", "Pav", "Bre", "Tre", "Bol", "Sab", "Ita" ]) setup_reinforcements(17) setup_reinforcements(19) setup_reinforcements('M') setup_units(TOBRUK, 0, [ "1/22", "/1AT", "Matilda/B", "1SA/2+5", "70/14+16", "1SA/1", "1SA/3", "2SA/4+6", "70/23", "/Tob", ]) setup_units(ALEXANDRIA, 1, [ "7/4", "/32AT", "2NZ/4", "2NZ/5", "2NZ/6", "7/SG", "4IN/5", "/Pol", ]) setup_reinforcements(12) setup_reinforcements(14) setup_reinforcements(16) setup_reinforcements(18) setup_reinforcements(20) }, "Gazala" (DEPLOY) { setup_units(DEPLOY, -1, [ "21/5", "15/8", "15/115", "90/155", "88mm/B", "50mm", "/104", ]) setup_units(DEPLOY, 0, [ "21/3", "15/33", "90/580", "90/sv288", "90/346", "90/361", "90/200", "88mm/A", ]) setup_units(DEPLOY, 0, [ "Lit" ]) setup_units(DEPLOY, -1, [ "Ari", "Tri", "Pav", "Bre", "Tre", "Bol", "Sab", "Ita" ]) setup_units(DEPLOY, 0, [ "Grant", "/1AT", "1/22", "Matilda/B", "1SA", "1/201G", "4IN/7m", "4IN/3m", "1/SG", "1SA/2+5", "70/14+16", "1SA/1", "1SA/3", "2SA/4+6", "70/23", "4IN/11", "5IN/29", "6#/A", "2#", "/Tob", ]) setup_units(ALEXANDRIA, 1, [ "1/2", "7/4", "/32AT", "2NZ/4", "2NZ/5", "2NZ/6", "7/SG", "7/22G", "4IN/5", "/Pol", ]) setup_units(ALEXANDRIA, 0, [ "10IN/161m", "8IN/18", "B", "FF/2", "5IN/9+10", "10IN/21+25", ]) }, "Pursuit to Alamein" (DEPLOY) { setup_units(DEPLOY, 0, [ "21/3", "15/33", ]) setup_units(DEPLOY, -1, [ "21/5", "15/8", "15/115", "90/346", "90/sv288", "90/361", "90/200", "88mm/A", "88mm/B", "50mm", ]) setup_units(DEPLOY, 3, [ "Ari", "Tri", "Pav", "Bre", "Tre", ]) setup_units(DEPLOY, 2, [ "Lit", "Bol", ]) setup_units(DEPLOY, 1, [ "Sab", "Ita", ]) setup_reinforcements(17) setup_reinforcements(19) setup_reinforcements('M') setup_units(DEPLOY, 0, [ "10IN/161m", "10IN/21+25", "70/14+16", "8IN/18", "B", ]) setup_units(DEPLOY, -1, [ "1/22", "7/4", "2NZ/4", "2NZ/5", "2NZ/6", "1SA/2+5", "1SA/1", "1SA/3", "70/23", "5IN/29", "6#/A", ]) setup_units(ALEXANDRIA, 1, [ "Grant", "1/2", "/1AT", "1SA", "7/SG", "1/SG", "FF/2", "5IN/9+10", "4IN/5", "4IN/11", "/Pol", "2#", ]) setup_reinforcements(16) setup_reinforcements(18) setup_reinforcements(20) }, "1941-42" (DEPLOY) { SETUP["1941"](-1) setup_reinforcements(11) setup_reinforcements(17) setup_reinforcements(19) setup_reinforcements('M') setup_reinforcements(12) setup_reinforcements(14) setup_reinforcements(16) setup_reinforcements(18) setup_reinforcements(20) }, } function setup(name) { let scenario = SCENARIOS[name] game.month = scenario.start SETUP[name](-scenario.start) game.active = 'Axis' game.state = 'free_deployment' game.selected = [] } // === PUBLIC FUNCTIONS === exports.roles = [ "Axis", "Allied" ] exports.scenarios = Object.keys(SCENARIOS) exports.setup = function (seed, scenario, options) { game = { seed: seed, log: [], undo: [], state: null, phasing: AXIS, active: AXIS, selected: null, scenario: scenario, month: 0, first_player_turn: AXIS, draw_pile: [ DUMMY_SUPPLY_COUNT, REAL_SUPPLY_COUNT ], axis_hand: [ 0, 0 ], allied_hand: [ 0, 0 ], units: new Array(units.length).fill(0), axis_minefields: [], allied_minefields: [], // supply networks axis_supply: null, axis_supply_line: null, allied_supply: null, allied_supply_line: null, // battle hexes (defender) axis_hexes: [], allied_hexes: [], // hexside control (for battle hexes) axis_sides: [], allied_sides: [], // current turn option and moves turn_option: null, side_limit: {}, battles: [], rommel: 0, from1: 0, to1: 0, from2: 0, to2: 0, // current group move state move_from: 0, move_used: 0, move_road: 4, } setup(scenario) return game } exports.view = function(state, current) { game = state // update_supply_networks() view = { month: game.month, units: game.units, axis_hexes: game.axis_hexes, allied_hexes: game.allied_hexes, axis_sides: game.axis_sides, allied_sides: game.allied_sides, selected: game.selected, // axis_supply: game.axis_supply, // axis_supply_line: game.axis_supply_line, // allied_supply: game.allied_supply, // allied_supply_line: game.allied_supply_line, } if (game.rommel) view.rommel = game.rommel if (game.from1) view.from1 = game.from1 if (game.from2) view.from2 = game.from2 if (game.to1) view.to1 = game.to1 if (game.to2) view.to2 = game.to2 return common_view(current) } function gen_action_next() { gen_action('next') } function gen_action_unit(u) { gen_action('unit', u) } function gen_action_hex(x) { gen_action('hex', x) } // === COMMON TEMPLATE === function random(n) { // https://www.ams.org/journals/mcom/1999-68-225/S0025-5718-99-00996-5/S0025-5718-99-00996-5.pdf return (game.seed = game.seed * 200105 % 34359738337) % n } function shuffle(deck) { for (let i = deck.length - 1; i > 0; --i) { let j = random(i + 1) let tmp = deck[j] deck[j] = deck[i] deck[i] = tmp } } // Sorted array treated as Set (for JSON) function set_index(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 m } return -1 } function set_has(set, item) { return set_index(set, item) >= 0 } 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.splice(a, 0, item) } function set_delete(set, item) { let i = set_index(set, item) if (i >= 0) set.splice(i, 1) } function set_clear(set) { set.length = 0 } function remove_from_array(array, item) { let i = array.indexOf(item) if (i >= 0) array.splice(i, 1) } function deep_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] = deep_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] = deep_copy(v) else copy[i] = v } return copy } } 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 = deep_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 } function clear_undo() { game.undo = [] } function logbr() { if (game.log.length > 0 && game.log[game.log.length-1] !== "") game.log.push("") } function log(msg) { game.log.push(msg) } function gen_action(action, argument) { 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 goto_game_over(result, victory) { game.state = 'game_over' game.active = "None" game.result = result game.victory = victory logbr() log(game.victory) } states.game_over = { get inactive() { return game.victory }, prompt() { view.prompt = game.victory } } exports.resign = function (state, current) { game = state if (game.state !== 'game_over') { for (let opponent of exports.roles) { if (opponent !== current) { goto_game_over(opponent, current + " resigned.") break } } } return game } exports.action = function (state, current, action, arg) { game = state // Object.seal(game) // XXX: don't allow adding properties let S = states[game.state] if (S && action in S) { S[action](arg, current) } else { if (action === 'undo' && game.undo && game.undo.length > 0) pop_undo() else throw new Error("Invalid action: " + action) } return game } function common_view(current) { view.log = game.log if (current === 'Observer' || game.active !== current) { let inactive = states[game.state].inactive || game.state view.prompt = `Waiting for ${game.active} \u2014 ${inactive}...` } else { view.actions = {} if (states[game.state]) states[game.state].prompt() else view.prompt = "Unknown state: " + game.state if (game.undo && game.undo.length > 0) view.actions.undo = 1 else view.actions.undo = 0 } return view }