summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTor Andersson <tor@ccxvii.net>2023-11-12 01:53:17 +0100
committerTor Andersson <tor@ccxvii.net>2023-11-12 13:18:04 +0100
commite8b02e17173feec741bd4475a299aa664346a41f (patch)
tree2ac9e9a81c5e413c75a33b09359fcaff6e29640c
parent31e0d18cc7b6d4e1ad185c2430bfbeff6a1c39a7 (diff)
downloadhammer-of-the-scots-e8b02e17173feec741bd4475a299aa664346a41f.tar.gz
Show last used border, border limits, and main attack.
Optimize border state representation (use maps instead of objects).
-rw-r--r--data.js52
-rw-r--r--play.css20
-rw-r--r--play.html1
-rw-r--r--play.js58
-rw-r--r--rules.js160
-rw-r--r--tools/borders.svg359
-rw-r--r--tools/genborders.js53
-rw-r--r--tools/makeborders.js32
8 files changed, 696 insertions, 39 deletions
diff --git a/data.js b/data.js
index 920e8c6..d1d6f8b 100644
--- a/data.js
+++ b/data.js
@@ -90,6 +90,58 @@ let AREAS = []
let BORDERS = []
+const BORDERS_XY = {
+ "England / Dunbar": {"x":1285,"y":1320},
+ "England / Annan": {"x":1065,"y":1630},
+ "England / Teviot": {"x":1210,"y":1495},
+ "Ross / Garmoran": {"x":505,"y":450},
+ "Ross / Moray": {"x":665,"y":455},
+ "Garmoran / Moray": {"x":550,"y":590},
+ "Garmoran / Lochaber": {"x":445,"y":670},
+ "Moray / Strathspey": {"x":860,"y":460},
+ "Moray / Lochaber": {"x":565,"y":665},
+ "Moray / Badenoch": {"x":715,"y":610},
+ "Strathspey / Buchan": {"x":1110,"y":430},
+ "Strathspey / Badenoch": {"x":880,"y":530},
+ "Buchan / Badenoch": {"x":990,"y":565},
+ "Buchan / Mar": {"x":1095,"y":605},
+ "Buchan / Angus": {"x":1240,"y":645},
+ "Lochaber / Badenoch": {"x":675,"y":730},
+ "Lochaber / Argyll": {"x":530,"y":860},
+ "Lochaber / Atholl": {"x":635,"y":855},
+ "Badenoch / Mar": {"x":904,"y":672},
+ "Badenoch / Atholl": {"x":730,"y":790},
+ "Mar / Angus": {"x":1035,"y":750},
+ "Mar / Atholl": {"x":835,"y":785},
+ "Angus / Atholl": {"x":880,"y":855},
+ "Angus / Fife": {"x":965,"y":900},
+ "Argyll / Atholl": {"x":585,"y":950},
+ "Argyll / Lennox": {"x":545,"y":1065},
+ "Atholl / Lennox": {"x":615,"y":1025},
+ "Atholl / Mentieth": {"x":690,"y":980},
+ "Atholl / Fife": {"x":845,"y":905},
+ "Lennox / Mentieth": {"x":725,"y":1185},
+ "Lennox / Carrick": {"x":625,"y":1310},
+ "Lennox / Lanark": {"x":725,"y":1260},
+ "Mentieth / Fife": {"x":880,"y":1060},
+ "Mentieth / Lanark": {"x":810,"y":1235},
+ "Mentieth / Lothian": {"x":900,"y":1215},
+ "Carrick / Lanark": {"x":790,"y":1450},
+ "Carrick / Galloway": {"x":680,"y":1556},
+ "Carrick / Annan": {"x":850,"y":1540},
+ "Lanark / Lothian": {"x":905,"y":1275},
+ "Lanark / Selkirk": {"x":922,"y":1377},
+ "Lanark / Annan": {"x":888,"y":1470},
+ "Lothian / Selkirk": {"x":1010,"y":1300},
+ "Lothian / Dunbar": {"x":1100,"y":1235},
+ "Selkirk / Dunbar": {"x":1115,"y":1310},
+ "Selkirk / Annan": {"x":980,"y":1472},
+ "Selkirk / Teviot": {"x":1080,"y":1405},
+ "Dunbar / Teviot": {"x":1195,"y":1335},
+ "Galloway / Annan": {"x":860,"y":1625},
+ "Annan / Teviot": {"x":1070,"y":1525},
+}
+
;(function () {
function border(A,B,T) {
A = area_index[A]
diff --git a/play.css b/play.css
index 8ac1672..e5a39f0 100644
--- a/play.css
+++ b/play.css
@@ -186,6 +186,25 @@ body.shift .block.known:hover {
z-index: 100;
}
+.border {
+ position: absolute;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ text-align: center;
+ line-height: 24px;
+ font-size: 16px;
+ font-weight: bold;
+ color: white;
+ background-color: #654;
+}
+
+.oldblocks .border.England { background-color: brown; }
+.oldblocks .border.Scotland { background-color: #06a; }
+
+.newblocks .border.England { background-color: #a12; }
+.newblocks .border.Scotland { background-color: #059; }
+
#blocks > .block {
position: absolute;
}
@@ -202,7 +221,6 @@ body.shift .block.known:hover {
box-shadow: 0 0 2px 1px #0002;
}
-
.oldblocks .block.England { border: 4px solid brown; background-color: brown; }
.oldblocks .block.Scotland { border: 4px solid #06a; background-color: #06a; }
diff --git a/play.html b/play.html
index bae7d80..52dd012 100644
--- a/play.html
+++ b/play.html
@@ -565,6 +565,7 @@ c50 53 55 80 28 143 -18 42 -21 62 -16 107 17 147 18 179 6 245 -15 91 -56
</svg>
<div id="turn" class="turn year_1297"></div>
+<div id="borders"></div>
<div id="blocks"></div>
<div id="offmap" style="visibility:hidden"></div>
</div>
diff --git a/play.js b/play.js
index 09493cd..e65c7d6 100644
--- a/play.js
+++ b/play.js
@@ -16,6 +16,22 @@ function set_has(set, item) {
return false
}
+function map_get(map, key, missing) {
+ let a = 0
+ let b = (map.length >> 1) - 1
+ while (a <= b) {
+ let m = (a + b) >> 1
+ let x = map[m<<1]
+ if (key < x)
+ b = m - 1
+ else if (key > x)
+ a = m + 1
+ else
+ return map[(m<<1)+1]
+ }
+ return missing
+}
+
const ENEMY = { Scotland: "England", England: "Scotland" }
const ENGLAND_BAG = area_index["E. Bag"]
@@ -70,6 +86,7 @@ let ui = {
cards: {},
card_backs: {},
areas: [],
+ borders: [],
blocks: [],
battle_menu: [],
battle_block: [],
@@ -383,6 +400,21 @@ function build_map() {
build_battle_block(b, block)
build_map_block(b, block)
}
+
+ for (let name in BORDERS_XY) {
+ let xy = BORDERS_XY[name]
+ let [a, b] = name.split(" / ")
+ a = area_index[a]
+ b = area_index[b]
+ let id = a * 100 + b
+ let e = document.createElement("div")
+ e.my_id = id
+ e.className = "hide"
+ e.style.left = (xy.x - 12) + "px"
+ e.style.top = (xy.y - 12) + "px"
+ ui.borders.push(e)
+ document.getElementById("borders").appendChild(e)
+ }
}
build_map()
@@ -612,6 +644,32 @@ function update_map() {
} else {
ui.areas[view.where].classList.add('battle')
}
+
+ for (let e of ui.borders) {
+ let u = map_get(view.last_used, e.my_id, 0)
+ let n = map_get(view.border_limit, e.my_id, "")
+ if (view.main_border && set_has(view.main_border, e.my_id))
+ n += "*"
+ switch (u) {
+ case 1:
+ e.className = "border Scotland"
+ e.textContent = n
+ break
+ case 2:
+ e.className = "border England"
+ e.textContent = n
+ break
+ case 0:
+ if (n) {
+ e.className = "border"
+ e.textContent = n
+ } else {
+ e.className = "hide"
+ e.textContent = ""
+ }
+ break
+ }
+ }
}
function update_cards() {
diff --git a/rules.js b/rules.js
index 0412cb8..392e5e0 100644
--- a/rules.js
+++ b/rules.js
@@ -19,6 +19,9 @@ const first_map_area = 3
const ENEMY = { Scotland: "England", England: "Scotland" }
+const PID = { "": 0, Scotland: 1, England: 2 }
+const UNPID = [ "", "Scotland", "England" ]
+
const OBSERVER = "Observer"
const BOTH = "Both"
const ENGLAND = "England"
@@ -389,7 +392,7 @@ function border_id(a, b) {
}
function border_was_last_used_by_enemy(from, to) {
- return game.last_used[border_id(from, to)] === ENEMY[game.active]
+ return map_get(game.last_used, border_id(from, to), 0) === PID[ENEMY[game.active]]
}
function border_type(a, b) {
@@ -397,11 +400,15 @@ function border_type(a, b) {
}
function border_limit(a, b) {
- return game.border_limit[border_id(a,b)] || 0
+ return map_get(game.border_limit, border_id(a,b), 0)
+}
+
+function set_border_limit(a, b, n) {
+ map_set(game.border_limit, border_id(a,b), n)
}
function reset_border_limits() {
- game.border_limit = {}
+ game.border_limit.length = 0
}
function count_friendly(where) {
@@ -576,13 +583,13 @@ function is_battle_reserve(b) {
}
function is_attacker(b) {
- if (game.location[b] === game.where && block_owner(b) === game.attacker[game.where])
+ if (game.location[b] === game.where && block_owner(b) === get_attacker(game.where))
return !set_has(game.reserves, b)
return false
}
function is_defender(b) {
- if (game.location[b] === game.where && block_owner(b) !== game.attacker[game.where])
+ if (game.location[b] === game.where && block_owner(b) !== get_attacker(game.where))
return !set_has(game.reserves, b)
return false
}
@@ -870,8 +877,8 @@ function start_game_turn() {
// Reset movement and attack tracking state
game.truce = false
reset_border_limits()
- game.last_used = {}
- game.attacker = {}
+ game.last_used = []
+ game.attacker = []
game.reserves = []
game.moved = []
@@ -1108,7 +1115,7 @@ function defect_nobles(list) {
log(name + " defected.")
who = swap_blocks(who)
if (is_contested_area(where))
- game.attacker[where] = block_owner(who)
+ set_attacker(where, block_owner(who))
}
}
resume_coronation()
@@ -1182,7 +1189,7 @@ states.herald = {
let where = game.location[who]
who = swap_blocks(who)
if (is_contested_area(where)) {
- game.attacker[where] = game.active
+ set_attacker(where, game.active)
start_battle(where, 'herald')
return
}
@@ -1373,7 +1380,7 @@ function end_pillage(where) {
game.where = NOWHERE
delete game.pillage
if (is_contested_area(where)) {
- game.attacker[where] = ENEMY[game.active]
+ set_attacker(where, ENEMY[game.active])
start_battle(where, 'pillage')
} else {
end_player_turn()
@@ -1455,12 +1462,36 @@ states.sea_move_to = {
// MOVE PHASE
+function get_attacker(x) {
+ return UNPID[map_get(game.attacker, x, 0)]
+}
+
+function set_attacker(x, who) {
+ return map_set(game.attacker, x, PID[who])
+}
+
+function main_border(to) {
+ return map_get(game.main_border, to, 0)
+}
+
+function main_origin(to) {
+ return map_get(game.main_origin, to, 0)
+}
+
+function set_main_border(to, x) {
+ map_set(game.main_border, to, x)
+}
+
+function set_main_origin(to, x) {
+ map_set(game.main_origin, to, x)
+}
+
function goto_move_phase(moves) {
game.state = 'move_who'
game.moves = moves
game.activated = []
- game.main_origin = {}
- game.main_border = {}
+ game.main_origin = []
+ game.main_border = []
game.turn_log = []
clear_undo()
}
@@ -1507,17 +1538,17 @@ states.move_who = {
function move_block(who, from, to) {
game.location[who] = to
- game.border_limit[border_id(from, to)] = border_limit(from, to) + 1
+ set_border_limit(from, to, border_limit(from, to) + 1)
game.distance ++
if (is_contested_area(to)) {
- game.last_used[border_id(from, to)] = game.active
- if (!game.attacker[to]) {
- game.attacker[to] = game.active
- game.main_border[to] = from
- game.main_origin[to] = game.origin
+ map_set(game.last_used, border_id(from, to), PID[game.active])
+ if (!get_attacker(to)) {
+ set_attacker(to, game.active)
+ set_main_border(to, from)
+ set_main_origin(to, game.origin)
return ATTACK_MARK
} else {
- if (game.attacker[to] !== game.active || game.main_border[to] !== from || game.main_origin[to] !== game.origin) {
+ if (get_attacker(to) !== game.active || main_border(to) !== from || main_origin(to) !== game.origin) {
set_add(game.reserves, who)
return RESERVE_MARK
} else {
@@ -1567,9 +1598,9 @@ states.move_where = {
game.location[game.who] = to
set_add(game.moved, game.who)
if (is_contested_area(to)) {
- if (!game.attacker[to]) {
+ if (!get_attacker(to)) {
game.turn_log.push([area_tag(from), area_tag(to) + ATTACK_MARK + " (Norse)"])
- game.attacker[to] = game.active
+ set_attacker(to, game.active)
} else {
game.turn_log.push([area_tag(from), area_tag(to) + RESERVE_MARK + " (Norse)"])
set_add(game.reserves, game.who)
@@ -1627,6 +1658,7 @@ function bring_on_reserves() {
}
function goto_battle_phase() {
+ reset_border_limits()
if (have_contested_areas()) {
game.active = game.p1
game.state = 'battle_phase'
@@ -1679,7 +1711,7 @@ function end_battle() {
reset_border_limits()
game.moved = []
- game.active = game.attacker[game.where]
+ game.active = get_attacker(game.where)
let victor = game.active
if (is_contested_area(game.where))
victor = ENEMY[game.active]
@@ -1719,7 +1751,7 @@ function goto_battle_round(new_battle_round) {
if (count_defenders() === 0) {
log("Defending main force was eliminated.")
log("Battlefield control changed.")
- game.attacker[game.where] = ENEMY[game.attacker[game.where]]
+ set_attacker(game.where, ENEMY[get_attacker(game.where)])
} else if (count_attackers() === 0) {
log("Attacking main force was eliminated.")
}
@@ -1780,7 +1812,7 @@ function battle_step(active, initiative, candidate) {
}
function pump_battle_step() {
- let attacker = game.attacker[game.where]
+ let attacker = get_attacker(game.where)
let defender = ENEMY[attacker]
if (battle_step(defender, 'A', is_defender)) return
@@ -1849,7 +1881,7 @@ function pass_with_block(b) {
}
function count_enemy_hp_in_battle() {
- let is_candidate = (game.active === game.attacker[game.where]) ? is_defender : is_attacker
+ let is_candidate = (game.active === get_attacker(game.where)) ? is_defender : is_attacker
let n = 0
for (let b = 0; b < block_count; ++b)
if (is_candidate(b))
@@ -1962,7 +1994,7 @@ function apply_hit(who) {
}
function list_victims(p) {
- let is_candidate = (p === game.attacker[game.where]) ? is_attacker : is_defender
+ let is_candidate = (p === get_attacker(game.where)) ? is_attacker : is_defender
let max = 0
for (let b = 0; b < block_count; ++b)
if (is_candidate(b) && game.steps[b] > max)
@@ -1994,7 +2026,7 @@ states.battle_hits = {
}
function goto_retreat() {
- game.active = game.attacker[game.where]
+ game.active = get_attacker(game.where)
if (is_contested_area(game.where)) {
game.state = 'retreat'
game.turn_log = []
@@ -2131,7 +2163,7 @@ states.retreat_in_battle = {
}
function goto_regroup() {
- game.active = game.attacker[game.where]
+ game.active = get_attacker(game.where)
if (is_enemy_area(game.where))
game.active = ENEMY[game.active]
game.state = 'regroup'
@@ -2157,22 +2189,22 @@ states.regroup = {
},
end_regroup: function () {
print_turn_log("regrouped")
- game.attacker[game.where] = null // XXX ???
+ set_attacker(game.where, "") // XXX ???
game.where = NOWHERE
clear_undo()
game.active = game.battle_active
delete game.battle_active
if (game.battle_reason === 'herald') {
delete game.battle_reason
- game.last_used = {}
+ game.last_used = []
end_player_turn()
} else if (game.battle_reason === 'pillage') {
delete game.battle_reason
- game.last_used = {}
+ game.last_used = []
end_player_turn()
} else if (game.battle_reason === 'coronation') {
delete game.battle_reason
- game.last_used = {}
+ game.last_used = []
resume_coronation()
} else {
delete game.battle_reason
@@ -3044,7 +3076,7 @@ function make_battle_view() {
flash: game.flash
}
- battle.title = game.attacker[game.where] + " attacks " + area_name(game.where)
+ battle.title = get_attacker(game.where) + " attacks " + area_name(game.where)
battle.title += " \u2014 round " + game.battle_round + " of 3"
function fill_cell(cell, owner, fn) {
@@ -3074,11 +3106,12 @@ exports.setup = function (seed, scenario, options) {
moved: [],
reserves: [],
- attacker: {},
- border_limit: {},
- last_used: {},
- main_border: {},
- main_origin: {},
+ attacker: [],
+ border_limit: [],
+ last_used: [],
+ main_border: [],
+ main_origin: [],
+
show_cards: 0,
who: NOBODY,
where: NOWHERE,
@@ -3167,9 +3200,17 @@ exports.view = function(state, current) {
location: game.location,
steps: game.steps,
moved: game.moved,
+ last_used: game.last_used,
+ border_limit: game.border_limit,
active: game.active,
}
+ if (game.main_border && game.main_border.length > 0) {
+ view.main_border = []
+ for (let i = 0; i < game.main_border.length; i += 2)
+ set_add(view.main_border, border_id(game.main_border[i+0], game.main_border[i+1]))
+ }
+
states[game.state].prompt(view, current)
if (states[game.state].show_battle)
@@ -3197,6 +3238,15 @@ function array_insert(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
}
@@ -3265,6 +3315,40 @@ function set_toggle(set, item) {
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)) {
diff --git a/tools/borders.svg b/tools/borders.svg
new file mode 100644
index 0000000..0e05b74
--- /dev/null
+++ b/tools/borders.svg
@@ -0,0 +1,359 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ xmlns:dc="http://purl.org/dc/elements/1.1/"
+ xmlns:cc="http://creativecommons.org/ns#"
+ xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+ xmlns:svg="http://www.w3.org/2000/svg"
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="1688"
+ height="1950"
+ version="1.1"
+ id="svg102"
+ sodipodi:docname="borders.svg"
+ inkscape:version="1.0.2 (e86c870879, 2021-01-15)">
+ <metadata
+ id="metadata108">
+ <rdf:RDF>
+ <cc:Work
+ rdf:about="">
+ <dc:format>image/svg+xml</dc:format>
+ <dc:type
+ rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
+ </cc:Work>
+ </rdf:RDF>
+ </metadata>
+ <defs
+ id="defs106" />
+ <sodipodi:namedview
+ pagecolor="#ffffff"
+ bordercolor="#666666"
+ borderopacity="1"
+ objecttolerance="10"
+ gridtolerance="10"
+ guidetolerance="10"
+ inkscape:pageopacity="0"
+ inkscape:pageshadow="2"
+ inkscape:window-width="640"
+ inkscape:window-height="480"
+ id="namedview104"
+ showgrid="true"
+ inkscape:snap-bbox="true"
+ inkscape:snap-text-baseline="true"
+ inkscape:snap-object-midpoints="true"
+ inkscape:zoom="6.3795364"
+ inkscape:cx="542.64371"
+ inkscape:cy="1069.5661"
+ inkscape:current-layer="svg102"
+ inkscape:document-rotation="0">
+ <inkscape:grid
+ type="xygrid"
+ id="grid110" />
+ </sodipodi:namedview>
+ <image
+ sodipodi:absref="/home/tor/src/rally/public/hammer-of-the-scots/map75.png"
+ xlink:href="../map75.png"
+ id="image2"
+ sodipodi:insensitive="true"
+ image-rendering="pixelated"
+ height="1950"
+ width="1688"
+ y="0"
+ x="0" />
+ <circle
+ inkscape:label="England / Dunbar"
+ cx="1285"
+ cy="1320"
+ r="16"
+ id="circle4" />
+ <circle
+ inkscape:label="England / Annan"
+ cx="1065"
+ cy="1630"
+ r="16"
+ id="circle6" />
+ <circle
+ inkscape:label="England / Teviot"
+ cx="1210"
+ cy="1495"
+ r="16"
+ id="circle8" />
+ <circle
+ inkscape:label="Ross / Garmoran"
+ cx="505"
+ cy="450"
+ r="16"
+ id="circle10" />
+ <circle
+ inkscape:label="Ross / Moray"
+ cx="665"
+ cy="455"
+ r="16"
+ id="circle12" />
+ <circle
+ inkscape:label="Garmoran / Moray"
+ cx="550"
+ cy="590"
+ r="16"
+ id="circle14" />
+ <circle
+ inkscape:label="Garmoran / Lochaber"
+ cx="445"
+ cy="670"
+ r="16"
+ id="circle16" />
+ <circle
+ inkscape:label="Moray / Strathspey"
+ cx="860"
+ cy="460"
+ r="16"
+ id="circle18" />
+ <circle
+ inkscape:label="Moray / Lochaber"
+ cx="565"
+ cy="665"
+ r="16"
+ id="circle20" />
+ <circle
+ inkscape:label="Moray / Badenoch"
+ cx="715"
+ cy="610"
+ r="16"
+ id="circle22" />
+ <circle
+ inkscape:label="Strathspey / Buchan"
+ cx="1110"
+ cy="430"
+ r="16"
+ id="circle24" />
+ <circle
+ inkscape:label="Strathspey / Badenoch"
+ cx="880"
+ cy="530"
+ r="16"
+ id="circle26" />
+ <circle
+ inkscape:label="Buchan / Badenoch"
+ cx="990"
+ cy="565"
+ r="16"
+ id="circle28" />
+ <circle
+ inkscape:label="Buchan / Mar"
+ cx="1095"
+ cy="605"
+ r="16"
+ id="circle30" />
+ <circle
+ inkscape:label="Buchan / Angus"
+ cx="1240"
+ cy="645"
+ r="16"
+ id="circle32" />
+ <circle
+ inkscape:label="Lochaber / Badenoch"
+ cx="675"
+ cy="730"
+ r="16"
+ id="circle34" />
+ <circle
+ inkscape:label="Lochaber / Argyll"
+ cx="530"
+ cy="860"
+ r="16"
+ id="circle36" />
+ <circle
+ inkscape:label="Lochaber / Atholl"
+ cx="635"
+ cy="855"
+ r="16"
+ id="circle38" />
+ <circle
+ inkscape:label="Badenoch / Mar"
+ cx="904"
+ cy="672"
+ r="16"
+ id="circle40" />
+ <circle
+ inkscape:label="Badenoch / Atholl"
+ cx="730"
+ cy="790"
+ r="16"
+ id="circle42" />
+ <circle
+ inkscape:label="Mar / Angus"
+ cx="1035"
+ cy="750"
+ r="16"
+ id="circle44" />
+ <circle
+ inkscape:label="Mar / Atholl"
+ cx="835"
+ cy="785"
+ r="16"
+ id="circle46" />
+ <circle
+ inkscape:label="Angus / Atholl"
+ cx="880"
+ cy="855"
+ r="16"
+ id="circle48" />
+ <circle
+ inkscape:label="Angus / Fife"
+ cx="965"
+ cy="900"
+ r="16"
+ id="circle50" />
+ <circle
+ inkscape:label="Argyll / Atholl"
+ cx="585"
+ cy="950"
+ r="16"
+ id="circle52" />
+ <circle
+ inkscape:label="Argyll / Lennox"
+ cx="545"
+ cy="1065"
+ r="16"
+ id="circle54" />
+ <circle
+ inkscape:label="Atholl / Lennox"
+ cx="615"
+ cy="1025"
+ r="16"
+ id="circle56" />
+ <circle
+ inkscape:label="Atholl / Mentieth"
+ cx="690"
+ cy="980"
+ r="16"
+ id="circle58" />
+ <circle
+ inkscape:label="Atholl / Fife"
+ cx="845"
+ cy="905"
+ r="16"
+ id="circle60" />
+ <circle
+ inkscape:label="Lennox / Mentieth"
+ cx="725"
+ cy="1185"
+ r="16"
+ id="circle62" />
+ <circle
+ inkscape:label="Lennox / Carrick"
+ cx="625"
+ cy="1310"
+ r="16"
+ id="circle64" />
+ <circle
+ inkscape:label="Lennox / Lanark"
+ cx="725"
+ cy="1260"
+ r="16"
+ id="circle66" />
+ <circle
+ inkscape:label="Mentieth / Fife"
+ cx="880"
+ cy="1060"
+ r="16"
+ id="circle68" />
+ <circle
+ inkscape:label="Mentieth / Lanark"
+ cx="810"
+ cy="1235"
+ r="16"
+ id="circle70" />
+ <circle
+ inkscape:label="Mentieth / Lothian"
+ cx="900"
+ cy="1215"
+ r="16"
+ id="circle72" />
+ <circle
+ inkscape:label="Carrick / Lanark"
+ cx="790"
+ cy="1450"
+ r="16"
+ id="circle74" />
+ <circle
+ inkscape:label="Carrick / Galloway"
+ cx="680"
+ cy="1556"
+ r="16"
+ id="circle76" />
+ <circle
+ inkscape:label="Carrick / Annan"
+ cx="850"
+ cy="1540"
+ r="16"
+ id="circle78" />
+ <circle
+ inkscape:label="Lanark / Lothian"
+ cx="905"
+ cy="1275"
+ r="16"
+ id="circle80" />
+ <circle
+ inkscape:label="Lanark / Selkirk"
+ cx="922"
+ cy="1377"
+ r="16"
+ id="circle82" />
+ <circle
+ inkscape:label="Lanark / Annan"
+ cx="888"
+ cy="1470"
+ r="16"
+ id="circle84" />
+ <circle
+ inkscape:label="Lothian / Selkirk"
+ cx="1010"
+ cy="1300"
+ r="16"
+ id="circle86" />
+ <circle
+ inkscape:label="Lothian / Dunbar"
+ cx="1100"
+ cy="1235"
+ r="16"
+ id="circle88" />
+ <circle
+ inkscape:label="Selkirk / Dunbar"
+ cx="1115"
+ cy="1310"
+ r="16"
+ id="circle90" />
+ <circle
+ inkscape:label="Selkirk / Annan"
+ cx="980"
+ cy="1472"
+ r="16"
+ id="circle92" />
+ <circle
+ inkscape:label="Selkirk / Teviot"
+ cx="1080"
+ cy="1405"
+ r="16"
+ id="circle94" />
+ <circle
+ inkscape:label="Dunbar / Teviot"
+ cx="1195"
+ cy="1335"
+ r="16"
+ id="circle96" />
+ <circle
+ inkscape:label="Galloway / Annan"
+ cx="860"
+ cy="1625"
+ r="16"
+ id="circle98" />
+ <circle
+ inkscape:label="Annan / Teviot"
+ cx="1070"
+ cy="1525"
+ r="16"
+ id="circle100" />
+</svg>
diff --git a/tools/genborders.js b/tools/genborders.js
new file mode 100644
index 0000000..9c50721
--- /dev/null
+++ b/tools/genborders.js
@@ -0,0 +1,53 @@
+const fs = require("fs")
+
+const { round, floor, ceil } = Math
+
+let output = {}
+let mode, name, x, y, w, h, cx, cy, rx, ry
+
+function flush() {
+ if (mode === 'circle') {
+ output[name] = { x: cx, y: cy }
+ }
+ x = y = w = h = cx = cy = rx = ry = 0
+ name = null
+}
+
+for (let line of fs.readFileSync("tools/borders.svg", "utf-8").split("\n")) {
+ line = line.trim()
+ if (line.startsWith("<rect")) {
+ flush()
+ mode = "rect"
+ x = y = w = h = 0
+ } else if (line.startsWith("<ellipse") || line.startsWith("<circle")) {
+ flush()
+ mode = "circle"
+ cx = cy = rx = ry = 0
+ } else if (line.startsWith('x="'))
+ x = round(Number(line.split('"')[1]))
+ else if (line.startsWith('y="'))
+ y = round(Number(line.split('"')[1]))
+ else if (line.startsWith('width="'))
+ w = round(Number(line.split('"')[1]))
+ else if (line.startsWith('height="'))
+ h = round(Number(line.split('"')[1]))
+ else if (line.startsWith('cx="'))
+ cx = round(Number(line.split('"')[1]))
+ else if (line.startsWith('cy="'))
+ cy = round(Number(line.split('"')[1]))
+ else if (line.startsWith('r="'))
+ rx = ry = round(Number(line.split('"')[1]))
+ else if (line.startsWith('rx="'))
+ rx = round(Number(line.split('"')[1]))
+ else if (line.startsWith('ry="'))
+ ry = round(Number(line.split('"')[1]))
+ else if (line.startsWith('inkscape:label="'))
+ name = line.split('"')[1]
+}
+
+flush()
+
+console.log("const BORDERS_XY = {")
+for (let key in output)
+ console.log("\t\"" + key + "\": " + JSON.stringify(output[key]) + ",")
+console.log("}")
diff --git a/tools/makeborders.js b/tools/makeborders.js
new file mode 100644
index 0000000..a54a36c
--- /dev/null
+++ b/tools/makeborders.js
@@ -0,0 +1,32 @@
+const print = console.log
+
+const data = require("../data.js")
+
+var w = 1688
+var h = 1950
+var m = "../map75.png"
+
+print(`<?xml version="1.0" encoding="UTF-8"?>
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+ xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+ width="${w}"
+ height="${h}"
+>
+<image xlink:href="${m}" x="0" y="0" width="${w}" height="${h}" image-rendering="pixelated" sodipodi:insensitive="true" />`)
+
+for (let id = 0; id < data.BORDERS.length; ++id) {
+ if (data.BORDERS[id]) {
+ let a = (id / 100) | 0
+ let b = id % 100
+ let x = (data.AREAS[a].x + data.AREAS[b].x) >> 1
+ let y = (data.AREAS[a].y + data.AREAS[b].y) >> 1
+ let label = data.AREAS[a].name + " / " + data.AREAS[b].name
+ print(`<circle inkscape:label="${label}" cx="${x}" cy="${y}" r="16"/>`)
+
+ }
+}
+
+print(`</svg>`)