summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--designs.js784
-rw-r--r--public/docs/index.html4
-rw-r--r--public/docs/tips.html2
-rw-r--r--public/docs/tournaments.html81
-rw-r--r--public/join.js14
-rw-r--r--public/style.css23
-rw-r--r--schema.sql249
-rw-r--r--server.js780
-rw-r--r--views/about.pug13
-rw-r--r--views/games_active.pug7
-rw-r--r--views/games_list.pug48
-rw-r--r--views/games_public.pug2
-rw-r--r--views/head.pug69
-rw-r--r--views/header.pug1
-rw-r--r--views/info.pug2
-rw-r--r--views/tm_active.pug24
-rw-r--r--views/tm_finished.pug18
-rw-r--r--views/tm_list.pug28
-rw-r--r--views/tm_pool.pug159
-rw-r--r--views/tm_seed.pug100
-rw-r--r--views/user.pug7
21 files changed, 2367 insertions, 48 deletions
diff --git a/designs.js b/designs.js
new file mode 100644
index 0000000..79fdccd
--- /dev/null
+++ b/designs.js
@@ -0,0 +1,784 @@
+"use strict"
+
+const designs = module.exports = {}
+
+// Coin-change knapsack problem: sort players into pools.
+
+const knapsacks = {}
+
+function make_coin_change_table(frobenius_number, min, max) {
+ let start = frobenius_number + 1
+ let min_sack = knapsacks[min + "/" + max] = { start, add: min, min, max }
+ let max_sack = knapsacks[max + "/" + min] = { start, add: max, min, max }
+ for (let target = start; target < start + max; ++target) {
+ found_target:
+ for (let a = 0; a <= target/min; ++a) {
+ for (let b = 0; b <= target/max; ++b) {
+ if (a * min + b * max === target) {
+ let min_p = min_sack[target] = []
+ let max_p = max_sack[target] = []
+ for (let i = 0; i < b; ++i)
+ max_p.push(max)
+ for (let i = 0; i < a; ++i) {
+ min_p.push(min)
+ max_p.push(min)
+ }
+ for (let i = 0; i < b; ++i)
+ min_p.push(max)
+ break found_target
+ }
+ }
+ }
+ }
+}
+
+make_coin_change_table(5, 3, 4)
+make_coin_change_table(7, 3, 5)
+make_coin_change_table(11, 4, 5)
+make_coin_change_table(17, 4, 7)
+make_coin_change_table(31, 5, 9)
+
+knapsacks["3/4/5"] = {
+ start: 3,
+ add: 4,
+ min: 3,
+ max: 5,
+ 3: [ 3 ],
+ 4: [ 4 ],
+ 5: [ 5 ],
+ 6: [ 3, 3 ],
+ 7: [ 4, 3 ],
+}
+
+designs.pool_players_using_knapsack = function (players, sack_name, zig) {
+ let sack = knapsacks[sack_name]
+ if (!sack)
+ throw new Error("invalid knapsack configuration: " + sack_name)
+
+ let n = players.length
+ if (n < sack.start)
+ throw Error("not enough players!")
+
+ // allocate pools
+ let pools = []
+ while (n >= sack.start + sack.max) {
+ pools.push(new Array(sack.add))
+ n -= sack.add
+ }
+ for (let size of sack[n])
+ pools.push(new Array(size))
+
+ // seed pools with players
+ if (zig) {
+ for (let i = 0; players.length > 0; ++i)
+ for (let pool of pools)
+ if (i < pool.length)
+ pool[i] = players.shift()
+ } else {
+ for (let pool of pools)
+ for (let i = 0; i < pool.length; ++i)
+ pool[i] = players.shift()
+ }
+
+ return pools
+}
+
+designs.pool_players = function (players, max_size, zig) {
+ let n = players.length
+ if (n < 2)
+ throw Error("not enough players!")
+
+ let pool_count = Math.ceil(n / max_size)
+ let pool_size = Math.floor(n / pool_count)
+ let odd_count = n - pool_size * pool_count;
+
+ // allocate pools
+ let pools = []
+ for (let i = 0; i < odd_count; ++i)
+ pools.push(new Array(pool_size + 1))
+ for (let i = odd_count; i < pool_count; ++i)
+ pools.push(new Array(pool_size))
+
+ // seed pools with players
+ if (zig) {
+ for (let i = 0; players.length > 0; ++i)
+ for (let pool of pools)
+ if (i < pool.length)
+ pool[i] = players.shift()
+ } else {
+ for (let pool of pools)
+ for (let i = 0; i < pool.length; ++i)
+ pool[i] = players.shift()
+ }
+
+ return pools
+}
+
+// Various block designs suitable for tournament scheduling.
+
+designs.concurrent_round_robin = function (v) {
+ // Compute simple pairings for an odd number of players.
+ // Each player meets every other player.
+ // Each player plays both sides every round.
+ if ((v & 1) === 0)
+ throw new Error("invalid number of players for concurrent round robin (must be odd)")
+ let n = (v - 1) / 2
+ let table = []
+ for (let r = 1; r <= n; ++r) {
+ let round = []
+ for (let x = 0; x < v; ++x) {
+ let y = (x + r) % v
+ round.push( [ x, y ] )
+ }
+ table.push(round)
+ }
+ return table
+}
+
+designs.berger_table = function (v) {
+ // Compute Berger tables using Richard Schurig's algorithm.
+ // Note: We skip the byes for odd player counts.
+ const odd = v & 1,
+ n = odd ? v + 1 : v,
+ n1 = n - 1,
+ n2 = n / 2
+ let table = []
+ let x = 0
+ for (let round = 0; round < n1; ++round) {
+ let pairs = []
+ for (let i = odd; i < n2; ++i) {
+ let a = (x + i) % n1
+ let b = (x + n1 - i) % n1
+ if (i === 0) {
+ if (round & 1)
+ a = n1
+ else
+ b = n1
+ }
+ pairs.push([ a, b ])
+ }
+ x += n2
+ table.push(pairs)
+ }
+ return table
+}
+
+designs.double_berger_table = function (v) {
+ let table = designs.berger_table(v)
+
+ let n = table.length
+ for (let i = 0; i < n; ++i)
+ table.push(table[i].map(([a,b])=>[b,a]))
+
+ // Reverse the order of the last two rounds in the first cycle to avoid
+ // three consecutive games with the same color.
+ let swap = table[n-2]
+ table[n-2] = table[n-1]
+ table[n-1] = swap
+
+ return table
+}
+
+designs.double_berger_table_flat = function (v) {
+ let one = designs.berger_table(v)
+ let two = one.map(row => row.map(([a,b])=>[b,a]))
+
+ // Reverse the order of the last two rounds in the first cycle to avoid
+ // three consecutive games with the same color.
+ let n = one.length
+ let swap = one[n-2]
+ one[n-2] = one[n-1]
+ one[n-1] = swap
+
+ return [ one.flat(), two.flat() ]
+}
+
+designs.resolvable_bibd = function (v, k) {
+ switch (k) {
+ case 3:
+ switch (v) {
+ case 9: return designs.resolvable_bibd_9_3_1
+ case 15: return designs.resolvable_bibd_15_3_1
+ case 21: return designs.resolvable_bibd_21_3_1
+ case 27: return designs.resolvable_bibd_27_3_1
+ case 33: return designs.resolvable_bibd_33_3_1
+ case 39: return designs.resolvable_bibd_39_3_1
+ case 45: return designs.resolvable_bibd_45_3_1
+ case 51: return designs.resolvable_bibd_51_3_1
+ }
+ break
+ case 4:
+ switch (v) {
+ case 16: return designs.resolvable_bibd_16_4_1
+ case 28: return designs.resolvable_bibd_28_4_1
+ case 40: return designs.resolvable_bibd_40_4_1
+ case 52: return designs.resolvable_bibd_52_4_1
+ }
+ break
+ case 5:
+ switch (v) {
+ case 25: return designs.resolvable_bibd_25_5_1
+ }
+ break
+ }
+ return null
+}
+
+designs.youden_square = function (v, k) {
+ switch (k) {
+ case 3:
+ switch (v) {
+ case 3: return designs.youden_square_3_3_3
+ case 4: return designs.youden_square_4_3_2
+ case 7: return designs.youden_square_7_3_1
+ }
+ break
+ case 4:
+ switch (v) {
+ case 4: return designs.youden_square_4_4_4
+ case 5: return designs.youden_square_5_4_3
+ case 7: return designs.youden_square_7_4_2
+ case 13: return designs.youden_square_13_4_1
+ }
+ break
+ case 5:
+ switch (v) {
+ case 5: return designs.youden_square_5_5_5
+ case 6: return designs.youden_square_6_5_4
+ case 11: return designs.youden_square_11_5_2
+ case 21: return designs.youden_square_21_5_1
+ }
+ break
+ case 6:
+ switch (v) {
+ case 6: return designs.youden_square_6_6_6
+ case 7: return designs.youden_square_7_6_5
+ case 11: return designs.youden_square_11_6_3
+ case 16: return designs.bibd_16_6_2
+ case 31: return designs.youden_square_31_6_1
+ }
+ break
+ }
+ return null
+}
+
+// Resolvabled balanced incomplete block designs RBIBD(V,K,lambda).
+//
+// The 3-player designs are Kirkman Triple Systems where v = 3 (mod 6)).
+// The 4-player designs are resolvable Steiner Quadruple Systems where v = 4 (mod 12).
+//
+// These have been arranged so that within each set of K rows, each player sits
+// in each position once. Play K rounds to sit in each position once, 2K rounds to
+// sit twice in each position, etc.
+//
+// The final row is needed to meet every player, but then everyone will sit in
+// one position an extra time.
+
+designs.resolvable_bibd_9_3_1 = [
+ [[0,1,5],[2,7,6],[3,8,4]],
+ [[8,6,1],[7,5,3],[4,0,2]],
+ [[1,4,7],[6,3,0],[5,2,8]],
+ [[4,5,6],[0,7,8],[1,2,3]]
+]
+
+designs.resolvable_bibd_15_3_1 = [
+ [[0,14,7],[9,3,1],[11,6,2],[4,5,8],[10,12,13]],
+ [[8,1,14],[2,4,10],[12,0,3],[5,9,6],[13,7,11]],
+ [[14,2,9],[3,11,5],[1,13,4],[6,10,0],[7,8,12]],
+ [[14,10,3],[12,4,6],[7,2,5],[0,1,11],[8,9,13]],
+ [[4,11,14],[13,5,0],[6,3,8],[2,12,1],[10,7,9]],
+ [[5,14,12],[1,6,7],[9,0,4],[3,13,2],[11,8,10]],
+ [[6,13,14],[0,2,8],[1,5,10],[3,4,7],[9,11,12]]
+]
+
+designs.resolvable_bibd_21_3_1 = [
+ [[14,7,0],[1,4,2],[9,8,11],[15,18,16],[3,13,19],[17,12,6],[20,10,5]],
+ [[8,1,15],[2,5,3],[12,9,10],[19,16,17],[4,20,7],[13,0,18],[11,6,14]],
+ [[16,2,9],[6,3,4],[10,11,13],[18,17,20],[5,14,8],[7,19,1],[0,15,12]],
+ [[10,17,3],[0,4,5],[12,11,7],[18,14,19],[15,9,6],[2,20,8],[1,13,16]],
+ [[11,18,4],[5,6,1],[8,12,13],[20,19,15],[16,10,0],[9,3,14],[7,2,17]],
+ [[19,5,12],[6,0,2],[13,7,9],[14,16,20],[17,1,11],[4,15,10],[3,8,18]],
+ [[20,13,6],[0,3,1],[7,8,10],[15,14,17],[12,2,18],[11,16,5],[19,9,4]],
+ [[18,1,9],[10,19,2],[3,11,20],[14,4,12],[13,5,15],[6,7,16],[17,0,8]],
+ [[2,15,11],[16,12,3],[4,17,13],[5,18,7],[8,6,19],[9,20,0],[1,10,14]],
+ [[4,8,16],[5,9,17],[6,10,18],[0,11,19],[1,12,20],[2,13,14],[3,7,15]]
+]
+
+designs.resolvable_bibd_27_3_1 = [
+ [[13,0,26],[4,1,22],[2,18,8],[12,14,3],[15,6,11],[9,10,16],[7,5,19],[25,23,17],[20,24,21]],
+ [[14,26,1],[5,2,23],[3,19,9],[0,4,15],[16,7,12],[17,11,10],[6,8,20],[18,13,24],[22,21,25]],
+ [[26,15,2],[24,3,6],[10,20,4],[1,16,5],[8,17,0],[11,12,18],[21,9,7],[19,25,14],[23,22,13]],
+ [[26,3,16],[4,25,7],[11,21,5],[2,6,17],[9,18,1],[12,0,19],[8,22,10],[13,20,15],[14,24,23]],
+ [[17,26,4],[5,8,13],[22,12,6],[3,7,18],[10,19,2],[0,1,20],[23,9,11],[21,16,14],[25,15,24]],
+ [[18,5,26],[6,14,9],[7,23,0],[19,4,8],[20,11,3],[1,2,21],[24,10,12],[15,17,22],[16,13,25]],
+ [[19,26,6],[10,15,7],[24,1,8],[5,9,20],[21,4,12],[2,3,22],[11,0,25],[16,18,23],[14,13,17]],
+ [[20,7,26],[8,11,16],[9,25,2],[6,21,10],[22,5,0],[3,23,4],[1,12,13],[17,19,24],[18,14,15]],
+ [[26,8,21],[12,17,9],[13,10,3],[7,22,11],[23,6,1],[4,24,5],[0,2,14],[25,20,18],[15,16,19]],
+ [[26,22,9],[0,18,10],[11,14,4],[8,23,12],[2,7,24],[25,5,6],[1,15,3],[21,13,19],[20,16,17]],
+ [[23,10,26],[19,11,1],[5,12,15],[9,24,0],[3,25,8],[13,6,7],[16,4,2],[14,20,22],[18,17,21]],
+ [[24,26,11],[12,2,20],[6,0,16],[10,1,25],[4,9,13],[7,8,14],[17,3,5],[15,21,23],[22,19,18]],
+ [[12,25,26],[0,3,21],[1,7,17],[2,11,13],[5,10,14],[8,9,15],[4,6,18],[16,22,24],[19,20,23]]
+]
+
+designs.resolvable_bibd_33_3_1 = [
+ [[3,5,7],[6,2,4],[17,9,25],[8,24,16],[31,11,21],[30,10,20],[27,13,23],[26,12,22],[29,15,19],[28,18,14],[32,0,1]],
+ [[1,6,5],[4,7,0],[9,23,29],[22,28,8],[11,19,27],[10,26,18],[13,17,31],[16,30,12],[25,21,15],[20,14,24],[2,32,3]],
+ [[7,1,2],[0,3,6],[19,31,9],[18,8,30],[23,25,11],[24,22,10],[21,29,13],[12,20,28],[15,27,17],[14,16,26],[5,4,32]],
+ [[2,5,0],[3,1,4],[9,27,21],[26,20,8],[29,17,11],[28,10,16],[25,13,19],[24,12,18],[15,31,23],[14,22,30],[6,7,32]],
+ [[1,24,17],[0,16,25],[23,3,28],[22,2,29],[30,19,5],[18,4,31],[7,21,26],[20,6,27],[11,15,13],[10,14,12],[8,32,9]],
+ [[21,30,1],[31,0,20],[19,26,3],[27,18,2],[5,23,24],[4,25,22],[17,28,7],[16,29,6],[13,9,14],[12,8,15],[32,11,10]],
+ [[26,1,23],[0,27,22],[30,3,17],[16,2,31],[21,28,5],[20,4,29],[19,7,24],[6,25,18],[9,15,10],[14,11,8],[12,13,32]],
+ [[1,19,28],[29,18,0],[3,24,21],[2,20,25],[17,5,26],[27,16,4],[7,23,30],[22,31,6],[8,10,13],[11,9,12],[15,32,14]],
+ [[25,8,1],[24,0,9],[31,12,3],[13,30,2],[5,14,27],[4,26,15],[10,29,7],[28,6,11],[23,21,19],[18,22,20],[32,17,16]],
+ [[14,29,1],[0,15,28],[10,3,27],[11,2,26],[8,31,5],[4,9,30],[12,25,7],[6,13,24],[17,21,22],[16,23,20],[32,18,19]],
+ [[1,10,31],[30,0,11],[3,14,25],[2,24,15],[5,12,29],[28,4,13],[7,27,8],[26,6,9],[23,17,18],[19,22,16],[21,20,32]],
+ [[27,1,12],[13,26,0],[29,8,3],[9,28,2],[25,5,10],[24,11,4],[31,7,14],[15,30,6],[18,16,21],[20,19,17],[22,32,23]],
+ [[0,8,17],[1,16,9],[21,2,14],[15,20,3],[10,4,23],[22,5,11],[12,19,6],[7,18,13],[31,27,29],[26,28,30],[24,25,32]],
+ [[23,0,12],[13,22,1],[2,10,19],[3,11,18],[14,17,4],[16,15,5],[6,21,8],[20,9,7],[29,30,25],[28,31,24],[27,32,26]],
+ [[19,14,0],[18,1,15],[8,23,2],[9,3,22],[4,12,21],[5,13,20],[17,6,10],[11,7,16],[25,26,31],[30,24,27],[32,29,28]],
+ [[0,10,21],[1,11,20],[2,12,17],[3,13,16],[4,8,19],[5,9,18],[6,14,23],[7,15,22],[24,26,29],[25,27,28],[30,31,32]]
+]
+
+designs.resolvable_bibd_39_3_1 = [
+ [[38,0,19],[33,1,8],[28,2,16],[37,4,13],[18,22,7],[25,14,17],[15,31,9],[11,21,12],[3,5,23],[27,10,6],[35,36,24],[32,29,34],[20,26,30]],
+ [[1,20,38],[34,9,2],[29,17,3],[14,19,5],[23,8,0],[26,18,15],[16,32,10],[12,13,22],[24,6,4],[7,11,28],[36,25,37],[30,35,33],[21,27,31]],
+ [[2,38,21],[10,3,35],[4,30,18],[6,15,20],[9,24,1],[0,16,27],[17,33,11],[13,23,14],[5,7,25],[8,12,29],[19,37,26],[31,34,36],[22,28,32]],
+ [[22,38,3],[11,36,4],[5,0,31],[16,7,21],[10,2,25],[1,28,17],[34,12,18],[15,24,14],[26,8,6],[30,13,9],[27,20,19],[35,32,37],[29,23,33]],
+ [[23,4,38],[12,37,5],[6,1,32],[8,17,22],[3,26,11],[18,29,2],[13,35,0],[25,15,16],[7,9,27],[14,31,10],[20,21,28],[33,19,36],[24,30,34]],
+ [[38,5,24],[19,6,13],[2,33,7],[9,18,23],[4,27,12],[0,3,30],[36,14,1],[17,16,26],[28,10,8],[32,11,15],[21,22,29],[37,34,20],[31,25,35]],
+ [[6,38,25],[20,14,7],[3,8,34],[0,24,10],[5,13,28],[1,4,31],[37,2,15],[18,17,27],[9,29,11],[12,33,16],[22,30,23],[21,19,35],[32,36,26]],
+ [[7,26,38],[8,15,21],[4,35,9],[25,11,1],[14,6,29],[2,32,5],[19,16,3],[28,0,18],[30,10,12],[13,34,17],[31,23,24],[36,22,20],[27,37,33]],
+ [[38,27,8],[16,9,22],[10,5,36],[26,12,2],[15,7,30],[33,3,6],[17,20,4],[29,1,0],[11,31,13],[35,18,14],[24,25,32],[23,21,37],[34,28,19]],
+ [[38,9,28],[10,17,23],[6,11,37],[27,13,3],[16,31,8],[4,34,7],[5,18,21],[1,2,30],[32,14,12],[0,36,15],[33,26,25],[19,22,24],[20,29,35]],
+ [[29,10,38],[11,24,18],[7,12,19],[14,28,4],[9,32,17],[35,8,5],[22,0,6],[2,3,31],[13,15,33],[37,1,16],[26,27,34],[25,23,20],[21,30,36]],
+ [[30,38,11],[12,25,0],[8,20,13],[15,5,29],[18,33,10],[36,6,9],[23,7,1],[3,4,32],[34,16,14],[17,19,2],[28,35,27],[24,21,26],[31,37,22]],
+ [[38,12,31],[1,26,13],[21,9,14],[6,16,30],[11,34,0],[7,37,10],[8,2,24],[33,5,4],[17,15,35],[3,18,20],[28,29,36],[27,25,22],[32,19,23]],
+ [[13,38,32],[2,14,27],[10,22,15],[31,17,7],[12,35,1],[19,8,11],[25,3,9],[5,6,34],[16,36,18],[0,4,21],[29,30,37],[26,23,28],[20,24,33]],
+ [[14,33,38],[15,28,3],[23,11,16],[18,32,8],[36,13,2],[9,20,12],[4,10,26],[35,7,6],[37,0,17],[22,1,5],[30,31,19],[24,27,29],[34,21,25]],
+ [[38,34,15],[4,16,29],[24,12,17],[0,33,9],[3,37,14],[10,21,13],[11,27,5],[36,7,8],[19,1,18],[6,23,2],[32,20,31],[30,25,28],[22,26,35]],
+ [[16,35,38],[5,17,30],[13,18,25],[1,10,34],[15,4,19],[14,11,22],[28,6,12],[8,9,37],[2,0,20],[7,24,3],[33,32,21],[31,29,26],[23,36,27]],
+ [[17,38,36],[18,31,6],[26,14,0],[35,2,11],[20,5,16],[12,15,23],[29,13,7],[9,19,10],[21,3,1],[25,8,4],[34,22,33],[27,30,32],[37,28,24]],
+ [[18,37,38],[0,7,32],[1,15,27],[3,12,36],[6,17,21],[13,16,24],[8,14,30],[10,11,20],[2,4,22],[5,9,26],[23,34,35],[28,31,33],[19,25,29]]
+]
+
+designs.resolvable_bibd_45_3_1 = [
+ [[23,37,3],[22,2,36],[17,43,5],[4,16,42],[25,7,33],[24,32,6],[9,19,41],[18,40,8],[27,39,11],[26,38,10],[21,13,35],[34,20,12],[31,29,15],[14,30,28],[0,1,44]],
+ [[1,36,23],[37,22,0],[39,5,25],[38,4,24],[7,31,19],[6,18,30],[35,9,27],[8,26,34],[43,11,21],[10,42,20],[15,41,13],[12,14,40],[29,33,17],[32,28,16],[44,3,2]],
+ [[42,17,1],[16,0,43],[3,25,38],[2,24,39],[41,27,7],[40,6,26],[33,21,9],[20,8,32],[11,15,37],[36,10,14],[13,23,31],[30,12,22],[19,35,29],[28,34,18],[5,44,4]],
+ [[25,32,1],[24,0,33],[30,3,19],[18,31,2],[27,40,5],[4,26,41],[43,15,9],[8,14,42],[23,11,35],[22,34,10],[39,13,17],[16,38,12],[21,37,29],[20,28,36],[7,44,6]],
+ [[1,19,40],[0,41,18],[3,27,34],[35,2,26],[32,5,21],[33,20,4],[15,42,7],[14,6,43],[11,17,31],[10,30,16],[13,25,37],[36,12,24],[29,23,39],[28,22,38],[9,8,44]],
+ [[38,1,27],[26,39,0],[42,21,3],[2,43,20],[5,36,15],[37,4,14],[34,7,23],[6,35,22],[17,9,30],[31,16,8],[19,33,13],[12,18,32],[41,29,25],[40,24,28],[44,10,11]],
+ [[21,1,34],[20,35,0],[3,40,15],[14,41,2],[30,23,5],[4,31,22],[17,38,7],[6,16,39],[25,9,36],[8,24,37],[32,19,11],[10,18,33],[27,29,43],[42,28,26],[13,12,44]],
+ [[1,30,29],[28,0,31],[12,3,41],[2,13,40],[5,37,10],[36,11,4],[7,43,8],[9,42,6],[35,25,17],[16,34,24],[19,39,21],[18,20,38],[23,33,27],[22,26,32],[15,44,14]],
+ [[43,4,1],[0,5,42],[29,32,3],[33,2,28],[39,7,12],[38,6,13],[31,10,9],[11,8,30],[34,15,25],[24,14,35],[37,27,19],[26,36,18],[41,21,23],[40,22,20],[44,17,16]],
+ [[1,41,8],[9,0,40],[6,3,31],[7,2,30],[29,5,34],[28,4,35],[11,12,33],[32,13,10],[21,15,38],[14,20,39],[17,27,36],[26,37,16],[43,23,25],[24,42,22],[44,18,19]],
+ [[12,35,1],[0,34,13],[10,43,3],[42,11,2],[8,33,5],[4,9,32],[36,7,29],[37,28,6],[15,39,18],[19,38,14],[23,40,17],[16,22,41],[31,25,27],[30,26,24],[20,21,44]],
+ [[2,1,37],[3,36,0],[5,31,12],[13,30,4],[35,10,7],[34,6,11],[38,29,9],[39,8,28],[27,32,15],[33,14,26],[41,17,20],[40,16,21],[25,19,42],[18,24,43],[22,44,23]],
+ [[6,1,33],[32,0,7],[3,4,39],[2,38,5],[9,37,12],[13,36,8],[29,40,11],[41,10,28],[16,35,15],[14,17,34],[22,43,19],[18,23,42],[27,21,30],[31,26,20],[25,44,24]],
+ [[10,39,1],[0,11,38],[8,3,35],[34,9,2],[5,41,6],[4,7,40],[42,29,13],[28,12,43],[15,33,22],[23,32,14],[37,18,17],[36,19,16],[24,31,21],[20,30,25],[26,27,44]],
+ [[1,14,31],[30,15,0],[33,16,3],[17,2,32],[35,5,18],[19,34,4],[7,20,37],[21,6,36],[39,22,9],[38,8,23],[11,24,41],[40,25,10],[43,13,26],[12,42,27],[44,28,29]],
+ [[0,14,29],[1,28,15],[19,6,2],[18,3,7],[4,23,12],[13,5,22],[17,8,10],[16,11,9],[27,20,24],[26,25,21],[32,35,36],[39,42,34],[38,33,40],[41,43,37],[30,44,31]],
+ [[25,0,6],[24,7,1],[2,29,16],[3,17,28],[21,4,8],[5,9,20],[10,12,19],[11,13,18],[22,27,14],[23,15,26],[34,37,38],[36,41,30],[42,40,35],[31,39,43],[44,32,33]],
+ [[12,21,0],[20,1,13],[8,2,27],[9,26,3],[29,18,4],[28,19,5],[6,10,23],[7,22,11],[14,16,25],[15,24,17],[40,36,39],[43,38,32],[37,30,42],[33,31,41],[35,34,44]],
+ [[0,23,2],[22,3,1],[4,10,15],[5,11,14],[6,20,29],[28,21,7],[8,12,25],[9,13,24],[18,27,16],[17,26,19],[41,38,42],[40,31,34],[30,32,39],[35,43,33],[36,37,44]],
+ [[10,0,27],[26,1,11],[25,2,4],[3,24,5],[12,6,17],[16,7,13],[29,22,8],[23,28,9],[14,18,21],[15,19,20],[43,40,30],[42,33,36],[32,34,41],[37,35,31],[39,44,38]],
+ [[19,8,0],[1,9,18],[2,15,12],[13,14,3],[27,4,6],[7,5,26],[24,29,10],[11,25,28],[20,16,23],[21,17,22],[31,42,32],[38,30,35],[34,36,43],[33,39,37],[44,41,40]],
+ [[0,4,17],[1,5,16],[2,10,21],[3,11,20],[6,8,15],[7,9,14],[12,26,29],[13,27,28],[18,22,25],[19,23,24],[30,33,34],[32,37,40],[31,36,38],[35,39,41],[42,43,44]]
+]
+
+designs.resolvable_bibd_51_3_1 = [
+ [[25,0,50],[24,42,4],[43,1,5],[6,44,2],[7,45,3],[26,8,12],[9,27,13],[28,14,10],[15,11,29],[20,34,16],[35,17,21],[22,18,36],[19,23,37],[30,38,46],[47,39,31],[32,40,48],[49,41,33]],
+ [[50,26,1],[11,46,22],[27,24,7],[38,15,23],[18,35,19],[17,9,32],[5,48,0],[12,43,6],[4,36,14],[3,10,30],[37,21,20],[8,2,39],[16,13,42],[45,49,25],[33,31,40],[44,29,41],[34,47,28]],
+ [[2,50,27],[29,19,17],[23,12,47],[1,28,8],[39,16,24],[48,20,15],[10,33,18],[0,6,49],[13,7,44],[14,5,35],[31,4,11],[21,22,38],[40,3,9],[36,37,43],[46,25,26],[41,32,34],[42,30,45]],
+ [[50,3,28],[1,10,31],[30,18,20],[48,13,24],[2,29,9],[43,17,14],[16,49,21],[11,19,34],[26,0,7],[8,46,4],[15,6,36],[12,5,32],[39,22,23],[41,45,40],[38,37,44],[25,27,47],[33,42,35]],
+ [[29,4,50],[23,34,10],[32,11,2],[21,31,19],[49,14,1],[36,8,3],[18,44,15],[22,26,17],[35,20,12],[0,24,43],[9,47,5],[37,7,16],[6,33,13],[40,30,27],[42,41,46],[45,39,38],[28,25,48]],
+ [[5,50,30],[14,2,25],[24,35,11],[3,12,33],[20,32,22],[13,15,49],[4,9,37],[19,16,45],[27,23,18],[7,21,29],[44,1,0],[10,48,6],[17,38,8],[34,36,26],[31,28,41],[47,43,42],[46,40,39]],
+ [[50,31,6],[18,47,21],[25,3,15],[36,1,12],[4,34,13],[24,23,40],[14,26,16],[5,10,38],[46,17,20],[9,19,41],[22,8,30],[0,2,45],[49,11,7],[39,33,28],[35,27,37],[29,32,42],[43,48,44]],
+ [[32,7,50],[8,45,14],[19,22,48],[16,25,4],[13,37,2],[21,44,5],[1,41,24],[27,15,17],[6,39,11],[12,18,49],[42,20,10],[23,9,31],[3,0,46],[47,35,26],[34,40,29],[38,28,36],[30,43,33]],
+ [[33,50,8],[44,4,3],[15,46,9],[20,49,23],[17,5,25],[7,14,34],[45,6,22],[2,42,1],[28,16,18],[31,12,0],[26,13,19],[11,21,43],[10,24,32],[40,38,47],[48,36,27],[41,30,35],[37,29,39]],
+ [[50,34,9],[11,38,18],[45,5,4],[10,47,16],[26,21,24],[30,6,19],[15,35,8],[7,23,46],[2,3,43],[40,1,17],[0,13,32],[27,14,20],[12,22,44],[33,29,25],[48,39,41],[28,49,37],[42,36,31]],
+ [[35,10,50],[22,43,13],[39,19,12],[6,46,5],[17,11,48],[3,37,1],[20,31,7],[9,16,36],[24,8,47],[32,4,23],[41,18,2],[14,33,0],[21,15,28],[44,45,27],[25,30,34],[49,40,42],[38,26,29]],
+ [[36,50,11],[16,12,30],[23,44,14],[13,20,40],[47,7,6],[18,9,39],[4,2,38],[8,32,21],[37,17,10],[1,27,22],[5,24,33],[19,42,3],[34,0,15],[29,48,49],[46,28,45],[31,25,35],[43,41,26]],
+ [[50,12,37],[8,0,27],[13,31,17],[45,15,24],[21,41,14],[7,18,42],[10,40,19],[39,3,5],[9,33,22],[44,16,11],[23,28,2],[34,6,1],[4,20,43],[35,48,38],[49,26,30],[29,46,47],[32,25,36]],
+ [[38,50,13],[37,5,15],[28,9,0],[18,14,32],[16,1,46],[25,22,10],[19,43,8],[41,11,20],[40,4,6],[33,21,23],[17,45,12],[24,29,3],[2,7,35],[42,34,44],[36,49,39],[31,27,26],[30,47,48]],
+ [[14,39,50],[3,17,49],[6,38,16],[0,10,29],[15,19,33],[5,2,31],[11,23,25],[20,44,9],[12,42,21],[48,8,7],[22,24,34],[46,13,18],[1,30,4],[47,36,41],[43,35,45],[26,37,40],[27,32,28]],
+ [[50,15,40],[2,33,20],[18,4,26],[39,17,7],[11,0,30],[29,16,22],[32,6,3],[24,12,25],[45,10,21],[5,13,28],[8,49,9],[23,1,35],[19,47,14],[43,34,31],[42,48,37],[44,46,36],[38,41,27]],
+ [[41,50,16],[0,20,39],[3,21,34],[27,19,5],[40,8,18],[28,11,12],[30,23,17],[4,7,33],[13,25,1],[15,22,42],[6,14,29],[9,26,10],[36,24,2],[48,31,46],[35,44,32],[49,38,43],[37,45,47]],
+ [[17,42,50],[1,9,48],[21,40,0],[22,35,4],[20,28,6],[46,2,19],[12,29,13],[31,18,24],[34,5,8],[14,3,38],[16,43,23],[7,30,15],[10,27,11],[25,37,41],[47,32,49],[33,36,45],[26,39,44]],
+ [[18,43,50],[7,40,12],[49,10,2],[0,22,41],[36,23,5],[21,6,27],[20,3,47],[13,14,30],[32,19,1],[11,45,9],[39,4,15],[44,17,24],[8,31,16],[28,35,29],[42,25,38],[26,33,48],[34,37,46]],
+ [[19,50,44],[6,9,35],[41,13,8],[3,26,11],[23,0,42],[38,24,20],[22,7,28],[4,48,21],[14,15,31],[2,47,17],[46,12,10],[16,5,40],[45,1,18],[37,32,33],[30,29,36],[25,39,43],[27,34,49]],
+ [[50,20,45],[24,28,19],[10,36,7],[9,42,14],[12,27,4],[35,16,0],[1,21,39],[29,8,23],[5,49,22],[15,2,26],[48,18,3],[47,11,13],[17,41,6],[43,46,32],[33,38,34],[31,30,37],[40,44,25]],
+ [[21,50,46],[5,7,41],[29,1,20],[37,8,11],[43,15,10],[45,23,13],[17,0,36],[40,2,22],[30,24,9],[6,25,18],[16,27,3],[49,4,19],[48,12,14],[28,42,26],[47,44,33],[39,35,34],[32,31,38]],
+ [[50,22,47],[15,16,32],[8,6,42],[2,21,30],[38,9,12],[11,33,1],[46,14,24],[0,18,37],[3,41,23],[10,13,39],[7,19,25],[4,28,17],[20,26,5],[31,49,44],[27,29,43],[34,45,48],[36,40,35]],
+ [[23,48,50],[13,36,21],[33,17,16],[9,43,7],[22,3,31],[41,10,4],[12,34,2],[1,47,15],[19,38,0],[24,37,6],[14,11,40],[25,20,8],[18,5,29],[42,39,27],[26,32,45],[44,30,28],[35,46,49]],
+ [[24,49,50],[6,23,26],[14,22,37],[17,18,34],[8,10,44],[0,4,47],[5,11,42],[3,13,35],[2,16,48],[19,20,36],[1,7,38],[12,15,41],[9,21,25],[30,32,39],[28,40,43],[27,33,46],[29,31,45]]
+]
+
+designs.resolvable_bibd_16_4_1 = [
+ [[1,4,7,8],[12,13,6,9],[2,3,14,11],[0,5,10,15]],
+ [[8,2,9,0],[13,7,5,14],[3,12,4,10],[6,11,15,1]],
+ [[5,9,1,3],[10,14,8,6],[11,0,13,4],[7,15,12,2]],
+ [[4,6,2,5],[9,10,11,7],[14,1,0,12],[15,8,3,13]],
+ [[0,3,6,7],[5,8,11,12],[1,2,10,13],[4,9,14,15]]
+]
+
+designs.resolvable_bibd_28_4_1 = [
+ [[15,11,8,4],[17,20,13,24],[26,2,22,6],[16,12,1,5],[21,14,25,10],[3,23,7,19],[9,0,18,27]],
+ [[12,13,2,7],[22,16,11,21],[20,4,3,25],[5,17,0,15],[24,26,9,14],[8,6,23,18],[19,10,27,1]],
+ [[7,1,15,9],[10,18,24,16],[25,19,6,0],[13,8,14,3],[23,22,12,17],[4,21,5,26],[11,27,20,2]],
+ [[6,5,10,13],[14,15,19,22],[1,24,4,23],[2,9,16,8],[18,25,17,11],[0,7,26,20],[27,3,21,12]],
+ [[0,8,12,10],[19,9,21,17],[1,3,18,26],[14,7,11,6],[15,23,20,16],[2,5,24,25],[4,27,13,22]],
+ [[6,16,17,3],[25,12,26,15],[8,24,7,21],[11,13,0,1],[10,22,9,20],[18,4,19,2],[27,14,5,23]],
+ [[5,11,3,9],[12,20,14,18],[23,21,2,0],[7,17,10,4],[13,26,16,19],[22,1,25,8],[24,15,6,27]],
+ [[17,2,1,14],[26,10,23,11],[20,19,8,5],[9,6,4,12],[21,18,15,13],[3,0,22,24],[16,25,27,7]],
+ [[0,4,14,16],[9,13,23,25],[5,7,18,22],[2,3,10,15],[11,12,19,24],[1,6,20,21],[8,17,26,27]]
+]
+
+designs.resolvable_bibd_40_4_1 = [
+ [[1,12,21,18],[14,25,34,31],[8,5,27,38],[2,16,23,11],[15,24,29,36],[3,10,28,37],[4,19,20,9],[22,17,32,33],[35,7,30,6],[26,0,39,13]],
+ [[0,2,22,19],[32,13,35,15],[9,6,26,28],[24,3,17,12],[30,37,25,16],[38,11,4,29],[5,21,10,20],[34,33,18,23],[36,31,8,7],[27,14,1,39]],
+ [[23,20,3,1],[16,36,33,14],[10,29,7,27],[25,18,0,4],[31,38,13,17],[12,26,5,30],[11,22,6,21],[19,34,24,35],[37,8,9,32],[39,28,15,2]],
+ [[21,4,2,24],[17,15,37,34],[28,30,11,8],[13,1,19,5],[18,32,14,26],[6,27,31,0],[7,23,12,22],[20,35,36,25],[33,9,38,10],[29,39,16,3]],
+ [[3,5,25,22],[18,38,35,16],[29,12,9,31],[2,20,6,14],[15,27,33,19],[7,1,32,28],[8,0,23,24],[13,21,37,36],[10,26,11,34],[4,17,39,30]],
+ [[23,6,13,4],[19,36,26,17],[30,10,0,32],[21,3,15,7],[34,16,28,20],[33,8,29,2],[1,9,24,25],[38,14,22,37],[12,35,27,11],[5,31,18,39]],
+ [[24,7,14,5],[37,18,20,27],[11,33,31,1],[16,22,4,8],[35,29,17,21],[9,34,30,3],[25,13,2,10],[26,15,38,23],[36,28,12,0],[39,32,19,6]],
+ [[6,25,8,15],[28,19,21,38],[32,2,34,12],[17,23,5,9],[22,30,36,18],[31,4,10,35],[14,11,3,13],[27,24,16,26],[0,37,1,29],[20,39,7,33]],
+ [[16,13,7,9],[22,20,26,29],[33,3,35,0],[24,6,18,10],[19,37,31,23],[11,32,36,5],[14,15,4,12],[28,17,25,27],[2,1,30,38],[34,39,21,8]],
+ [[10,8,14,17],[27,21,23,30],[1,4,34,36],[7,19,11,25],[32,24,38,20],[37,33,12,6],[15,0,5,16],[18,29,13,28],[26,2,3,31],[35,9,39,22]],
+ [[9,18,15,11],[31,28,22,24],[5,35,37,2],[20,12,8,13],[21,25,33,26],[38,34,0,7],[6,16,17,1],[29,30,19,14],[3,27,32,4],[23,36,10,39]],
+ [[12,10,16,19],[25,23,29,32],[36,38,6,3],[0,14,9,21],[13,22,27,34],[8,26,1,35],[17,7,2,18],[30,31,20,15],[4,5,28,33],[39,11,24,37]],
+ [[0,11,17,20],[13,24,30,33],[4,7,26,37],[1,10,15,22],[14,23,28,35],[2,9,27,36],[3,8,18,19],[16,21,31,32],[5,6,29,34],[12,25,38,39]]
+]
+
+designs.resolvable_bibd_52_4_1 = [
+ [[30,16,1,21],[47,38,33,18],[50,4,35,13],[22,3,14,29],[20,39,31,46],[12,48,5,37],[19,32,9,8],[36,26,25,49],[43,15,42,2],[28,23,7,10],[27,24,40,45],[6,41,11,44],[0,34,17,51]],
+ [[2,0,22,31],[17,19,39,48],[14,5,36,34],[4,30,15,23],[40,21,32,47],[13,49,6,38],[33,10,20,9],[26,37,27,50],[16,43,44,3],[11,29,8,24],[25,46,28,41],[42,7,45,12],[35,18,51,1]],
+ [[3,1,23,32],[49,20,18,40],[15,6,37,35],[5,31,24,16],[41,22,48,33],[7,14,50,39],[10,17,21,11],[34,28,38,27],[45,44,4,0],[9,12,30,25],[29,47,26,42],[46,8,13,43],[51,2,19,36]],
+ [[24,33,2,4],[21,50,41,19],[38,36,16,7],[32,25,0,6],[23,42,49,17],[8,40,34,15],[18,11,12,22],[39,35,29,28],[1,45,46,5],[31,13,10,26],[48,27,43,30],[44,9,47,14],[37,51,3,20]],
+ [[5,25,17,3],[20,22,34,42],[39,8,37,0],[1,7,33,26],[18,24,50,43],[16,41,35,9],[13,12,19,23],[36,40,30,29],[2,47,6,46],[11,27,32,14],[28,44,49,31],[45,15,10,48],[21,4,51,38]],
+ [[6,26,18,4],[23,35,43,21],[38,9,40,1],[27,17,8,2],[19,34,25,44],[10,42,0,36],[14,13,24,20],[30,37,31,41],[48,3,7,47],[12,33,15,28],[50,32,29,45],[49,46,16,11],[51,5,22,39]],
+ [[7,19,27,5],[22,36,44,24],[41,39,2,10],[3,28,9,18],[26,45,20,35],[43,1,11,37],[25,14,21,15],[31,38,42,32],[4,49,48,8],[17,29,13,16],[33,30,46,34],[47,0,12,50],[40,6,23,51]],
+ [[8,20,28,6],[37,23,45,25],[42,11,3,40],[29,10,4,19],[46,21,36,27],[44,2,38,12],[15,16,26,22],[32,43,39,33],[9,50,5,49],[0,18,14,30],[35,31,47,17],[34,48,1,13],[24,51,41,7]],
+ [[21,7,9,29],[26,46,24,38],[12,4,43,41],[11,20,5,30],[37,47,28,22],[45,13,3,39],[23,16,27,0],[44,17,33,40],[50,34,6,10],[19,31,1,15],[32,48,18,36],[35,2,49,14],[25,51,8,42]],
+ [[10,30,22,8],[39,27,25,47],[13,5,42,44],[6,21,12,31],[38,29,23,48],[14,40,46,4],[1,24,0,28],[41,18,17,45],[34,11,35,7],[16,32,20,2],[49,33,37,19],[3,36,15,50],[9,43,26,51]],
+ [[31,23,11,9],[48,28,40,26],[43,14,45,6],[7,22,32,13],[30,49,39,24],[47,15,41,5],[29,25,2,1],[46,42,19,18],[8,35,36,12],[0,3,21,33],[17,38,50,20],[4,37,16,34],[51,10,44,27]],
+ [[24,12,10,32],[27,41,29,49],[15,44,7,46],[33,8,14,23],[40,50,31,25],[42,6,48,16],[2,26,30,3],[20,19,47,43],[36,9,13,37],[22,1,4,17],[18,39,34,21],[5,0,38,35],[28,45,51,11]],
+ [[11,13,25,33],[30,50,28,42],[16,47,8,45],[24,15,9,17],[32,26,41,34],[0,49,7,43],[4,27,31,3],[44,21,20,48],[10,14,37,38],[5,23,2,18],[22,19,35,40],[36,6,1,39],[51,12,46,29]],
+ [[12,17,14,26],[34,43,29,31],[48,46,0,9],[25,16,18,10],[42,35,33,27],[1,44,50,8],[28,4,5,32],[49,45,21,22],[38,39,11,15],[19,24,3,6],[41,20,36,23],[37,2,40,7],[13,30,47,51]],
+ [[27,18,15,13],[35,32,30,44],[47,10,49,1],[26,0,19,11],[17,36,43,28],[45,9,34,2],[29,33,6,5],[50,22,23,46],[39,40,12,16],[7,25,4,20],[21,37,42,24],[3,8,38,41],[31,48,51,14]],
+ [[14,28,16,19],[33,31,45,36],[2,11,48,50],[20,1,27,12],[18,29,44,37],[46,3,10,35],[6,7,17,30],[23,34,24,47],[40,41,13,0],[8,5,26,21],[43,38,22,25],[9,42,39,4],[15,51,32,49]],
+ [[0,15,20,29],[17,32,37,46],[3,12,34,49],[2,13,21,28],[19,30,38,45],[4,11,36,47],[7,8,18,31],[24,25,35,48],[1,14,41,42],[6,9,22,27],[23,26,39,44],[5,10,40,43],[16,33,50,51]]
+]
+
+// TODO: balance the order of sitting (players 17 and 22 sit wrong in positions 0 and 1)
+designs.resolvable_bibd_25_5_1 =
+[[[15,0,20,10,5],[21,16,11,1,6],[12,22,17,2,7],[8,23,13,3,18],[4,9,14,19,24]],[[16,24,0,17,3],[10,2,1,13,9],[5,21,4,8,22],[6,14,7,18,15],[23,20,12,11,19]],[[9,6,23,22,0],[17,1,18,20,4],[11,3,2,5,14],[7,19,10,16,8],[24,15,21,12,13]],[[0,18,19,21,2],[1,5,24,7,23],[3,10,6,4,12],[17,8,9,15,11],[13,22,16,14,20]],[[14,12,8,0,1],[2,4,15,23,16],[20,7,3,9,21],[19,13,5,6,17],[18,11,22,24,10]],
+[[0, 4, 7, 11, 13], [22, 3, 15, 19, 1], [2, 6, 8, 20, 24], [5, 9, 12, 16, 18], [10, 17, 14, 21, 23]]
+]
+
+
+// Youden square designs.
+//
+// Each player will sit in each position once and meet every other player lambda times.
+//
+
+designs.youden_square_3_3_3 = [
+ [0,1,2],
+ [1,2,0],
+ [2,0,1],
+]
+
+designs.youden_square_4_3_2 = [
+ [0,1,2],
+ [1,2,3],
+ [2,3,0],
+ [3,0,1],
+]
+
+designs.youden_square_7_3_1 = [
+ [0,1,3],
+ [1,2,4],
+ [2,3,5],
+ [3,4,6],
+ [4,5,0],
+ [5,6,1],
+ [6,0,2],
+]
+
+designs.youden_square_4_4_4 = [
+ [0,1,2,3],
+ [1,2,3,0],
+ [2,3,0,1],
+ [3,0,1,2],
+]
+
+designs.youden_square_5_4_3 = [
+ [0,1,2,3],
+ [1,2,3,4],
+ [2,3,4,0],
+ [3,4,0,1],
+ [4,0,1,2],
+]
+
+designs.youden_square_7_4_2 = [
+ [0,1,3,6],
+ [1,2,4,0],
+ [2,3,5,1],
+ [3,4,6,2],
+ [4,5,0,3],
+ [5,6,1,4],
+ [6,0,2,5],
+]
+
+designs.youden_square_13_4_1 = [
+ [0,1,3,9],
+ [1,2,4,10],
+ [2,3,5,11],
+ [3,4,6,12],
+ [4,5,7,0],
+ [5,6,8,1],
+ [6,7,9,2],
+ [7,8,10,3],
+ [8,9,11,4],
+ [9,10,12,5],
+ [10,11,0,6],
+ [11,12,1,7],
+ [12,0,2,8],
+]
+
+designs.youden_square_5_5_5 = [
+ [0,1,2,3,4],
+ [1,2,3,4,0],
+ [2,3,4,0,1],
+ [3,4,0,1,2],
+ [4,0,1,2,3],
+]
+
+designs.youden_square_6_5_4 = [
+ [0,1,2,3,4],
+ [1,2,3,4,5],
+ [2,3,4,5,0],
+ [3,4,5,0,1],
+ [4,5,0,1,2],
+ [5,0,1,2,3],
+]
+
+designs.youden_square_11_5_2 = [
+ [0,1,2,4,7],
+ [1,2,3,5,8],
+ [2,3,4,6,9],
+ [3,4,5,7,10],
+ [4,5,6,8,0],
+ [5,6,7,9,1],
+ [6,7,8,10,2],
+ [7,8,9,0,3],
+ [8,9,10,1,4],
+ [9,10,0,2,5],
+ [10,0,1,3,6],
+]
+
+designs.youden_square_21_5_1 = [
+ [0,1,4,14,16],
+ [1,2,5,15,17],
+ [2,3,6,16,18],
+ [3,4,7,17,19],
+ [4,5,8,18,20],
+ [5,6,9,19,0],
+ [6,7,10,20,1],
+ [7,8,11,0,2],
+ [8,9,12,1,3],
+ [9,10,13,2,4],
+ [10,11,14,3,5],
+ [11,12,15,4,6],
+ [12,13,16,5,7],
+ [13,14,17,6,8],
+ [14,15,18,7,9],
+ [15,16,19,8,10],
+ [16,17,20,9,11],
+ [17,18,0,10,12],
+ [18,19,1,11,13],
+ [19,20,2,12,14],
+ [20,0,3,13,15],
+]
+
+designs.youden_square_6_6_6 = [
+ [0,1,2,3,4,5],
+ [1,2,3,4,5,0],
+ [2,3,4,5,0,1],
+ [3,4,5,0,1,2],
+ [4,5,0,1,2,3],
+ [5,0,1,2,3,4],
+]
+
+designs.youden_square_7_6_5 = [
+ [0,1,2,3,4,5],
+ [1,2,3,4,5,6],
+ [2,3,4,5,6,0],
+ [3,4,5,6,0,1],
+ [4,5,6,0,1,2],
+ [5,6,0,1,2,3],
+ [6,0,1,2,3,4],
+]
+
+designs.youden_square_11_6_3 = [
+ [0,1,2,4,5,7],
+ [1,2,3,5,6,8],
+ [2,3,4,6,7,9],
+ [3,4,5,7,8,10],
+ [4,5,6,8,9,0],
+ [5,6,7,9,10,1],
+ [6,7,8,10,0,2],
+ [7,8,9,0,1,3],
+ [8,9,10,1,2,4],
+ [9,10,0,2,3,5],
+ [10,0,1,3,4,6],
+]
+
+designs.youden_square_31_6_1 = [
+ [0,1,3,8,12,18],
+ [1,2,4,9,13,19],
+ [2,3,5,10,14,20],
+ [3,4,6,11,15,21],
+ [4,5,7,12,16,22],
+ [5,6,8,13,17,23],
+ [6,7,9,14,18,24],
+ [7,8,10,15,19,25],
+ [8,9,11,16,20,26],
+ [9,10,12,17,21,27],
+ [10,11,13,18,22,28],
+ [11,12,14,19,23,29],
+ [12,13,15,20,24,30],
+ [13,14,16,21,25,0],
+ [14,15,17,22,26,1],
+ [15,16,18,23,27,2],
+ [16,17,19,24,28,3],
+ [17,18,20,25,29,4],
+ [18,19,21,26,30,5],
+ [19,20,22,27,0,6],
+ [20,21,23,28,1,7],
+ [21,22,24,29,2,8],
+ [22,23,25,30,3,9],
+ [23,24,26,0,4,10],
+ [24,25,27,1,5,11],
+ [25,26,28,2,6,12],
+ [26,27,29,3,7,13],
+ [27,28,30,4,8,14],
+ [28,29,0,5,9,15],
+ [29,30,1,6,10,16],
+ [30,0,2,7,11,17],
+]
+
+designs.youden_square_7_7_7 = [
+ [0,1,2,3,4,5,6],
+ [1,2,3,4,5,6,0],
+ [2,3,4,5,6,0,1],
+ [3,4,5,6,0,1,2],
+ [4,5,6,0,1,2,3],
+ [5,6,0,1,2,3,4],
+ [6,0,1,2,3,4,5],
+]
+
+designs.youden_square_8_7_6 = [
+ [0,1,2,3,4,5,6],
+ [1,2,3,4,5,6,7],
+ [2,3,4,5,6,7,0],
+ [3,4,5,6,7,0,1],
+ [4,5,6,7,0,1,2],
+ [5,6,7,0,1,2,3],
+ [6,7,0,1,2,3,4],
+ [7,0,1,2,3,4,5],
+]
+
+designs.youden_square_15_7_3 = [
+ [0,1,2,4,5,8,10],
+ [1,2,3,5,6,9,11],
+ [2,3,4,6,7,10,12],
+ [3,4,5,7,8,11,13],
+ [4,5,6,8,9,12,14],
+ [5,6,7,9,10,13,0],
+ [6,7,8,10,11,14,1],
+ [7,8,9,11,12,0,2],
+ [8,9,10,12,13,1,3],
+ [9,10,11,13,14,2,4],
+ [10,11,12,14,0,3,5],
+ [11,12,13,0,1,4,6],
+ [12,13,14,1,2,5,7],
+ [13,14,0,2,3,6,8],
+ [14,0,1,3,4,7,9],
+]
+
+// Other designs.
+
+// sit 2x - meet 1x
+designs.bibd_13_3_1 = [
+ [0,1,6],
+ [0,3,7],
+ [1,3,4],
+ [1,10,8],
+ [2,6,3],
+ [2,7,1],
+ [3,9,10],
+ [3,12,5],
+ [4,0,8],
+ [4,5,10],
+ [5,2,0],
+ [5,6,11],
+ [6,4,9],
+ [6,8,12],
+ [7,10,6],
+ [7,11,4],
+ [8,7,5],
+ [8,11,3],
+ [9,5,1],
+ [9,8,2],
+ [10,2,11],
+ [10,12,0],
+ [11,0,9],
+ [11,1,12],
+ [12,4,2],
+ [12,9,7],
+]
+
+// sit 2x - meet 3x
+designs.bibd_9_4_3 = [
+ [0,2,8,7],
+ [0,6,2,5],
+ [1,2,3,5],
+ [1,6,4,3],
+ [2,1,6,7],
+ [2,1,8,4],
+ [3,5,0,8],
+ [3,8,2,6],
+ [4,0,3,2],
+ [4,7,6,0],
+ [5,0,1,4],
+ [5,4,7,2],
+ [6,5,7,3],
+ [6,8,1,0],
+ [7,3,0,1],
+ [7,3,4,8],
+ [8,4,5,6],
+ [8,7,5,1],
+]
+
+// sit 1x - meet 2x
+designs.bibd_16_6_2 = [
+ [0,12,15,5,8,7],
+ [1,2,0,15,11,10],
+ [2,6,7,9,5,11],
+ [3,13,9,1,15,5],
+ [4,3,5,12,10,2],
+ [5,14,6,0,4,1],
+ [6,8,2,4,13,15],
+ [7,11,4,8,1,3],
+ [8,9,1,10,6,12],
+ [9,0,8,3,2,14],
+ [10,7,13,6,3,0],
+ [11,15,3,14,12,6],
+ [12,4,11,13,0,9],
+ [13,5,10,11,14,8],
+ [14,1,12,2,7,13],
+ [15,10,14,7,9,4],
+]
+
+// vim:set nowrap:
diff --git a/public/docs/index.html b/public/docs/index.html
index f983b89..9adfdbb 100644
--- a/public/docs/index.html
+++ b/public/docs/index.html
@@ -18,13 +18,11 @@ server instance and how to create new modules.
<ul>
<li><a href="tips.html">Tips &amp; tricks.</a>
+<li><a href="tournaments.html">Tournaments</a>
</ul>
<h2>For developers</h2>
-<p>
-Before you start to develop a module, you will need to run a local instance of the server.
-
<ul>
<li><a href="install.html">Installing the server.</a>
<li><a href="production.html">Running a public server.</a>
diff --git a/public/docs/tips.html b/public/docs/tips.html
index 518ebe5..5a2b25a 100644
--- a/public/docs/tips.html
+++ b/public/docs/tips.html
@@ -68,7 +68,7 @@ Use <kbd>Enter</kbd> and <kbd>Escape</kbd> to quickly open and close the chat bo
<p>
The <img src="/images/cycle.svg"> button appears when the game is over.
-Use this to quickly start a rematch with the same players.
+Use this to quickly start a rematch with the same players, or to go to the tournament score page.
<p>
The <img src="/images/earth-africa-europe.svg"> button hides all counters and markers.
diff --git a/public/docs/tournaments.html b/public/docs/tournaments.html
new file mode 100644
index 0000000..6122194
--- /dev/null
+++ b/public/docs/tournaments.html
@@ -0,0 +1,81 @@
+<!doctype html>
+<meta name="viewport" content="width=device-width">
+<title>Tournaments</title>
+<link rel="stylesheet" href="style.css">
+<body>
+<article>
+
+<h1>
+Automated Tournaments
+</h1>
+
+<p>
+These are the tournaments that are managed by the computer.
+
+<p>
+To play in one of these tournaments, simply register and wait.
+Once enough players have joined, the tournaments will kick off automatically.
+Make sure you've turned on notifications so you don't miss the start!
+
+<hr>
+
+<h3>Time Control</h3>
+
+<p>
+All tournament games use time control.
+<p>
+If you let a tournament game time out, you will not be allowed to join future tournaments!
+
+<h3>Rounds</h3>
+
+<p>
+Tournaments are round-robin, where each player meets every other player.
+Everyone plays each side the same number of times.
+There may be a few exceptions to these rules in 3+ player games at certain
+player counts, but the system attempts to make the pairings as fair as possible.
+
+<p>
+With sequential rounds you will only play one game at a time.
+
+<p>
+With concurrent rounds you will play several games simultaneously;
+but never the same side in more than one game at a time.
+
+<h3>Points</h3>
+<p>
+Victories are worth 2 points; ties and shared victories are worth 1 point.
+The
+<a href="https://en.wikipedia.org/wiki/Sonneborn%E2%80%93Berger_score">Sonneborn-Berger</a>
+score is used to break ties.
+
+<h3>Levels</h3>
+
+<p>
+Some tournaments may have multiple levels. If you win a tournament at one
+level, you will automatically be queued for the next level up.
+
+<hr>
+
+<h2>
+Mini Cup
+</h2>
+
+<p>
+This is a small and fast tournament format for casual play.
+The mini cup starts as soon as the required number of players have
+entered.
+You can play any number of mini cups.
+
+<p>
+The mini cup games use fast (3+ moves/day) time control.
+
+<hr>
+
+<h2>
+Championship
+</h2>
+
+<p>
+To be done...
+
+<hr>
diff --git a/public/join.js b/public/join.js
index 75e7aca..b2c667f 100644
--- a/public/join.js
+++ b/public/join.js
@@ -327,13 +327,19 @@ function create_game_list() {
let table = create_element(window.game_info, "table")
let list = create_element(table, "tbody")
- if (game.scenario !== "Standard")
- create_game_list_item(list, "Scenario", game.scenario)
- create_game_list_item(list, "Options", format_options(game.options))
+ if (game.pool_name) {
+ create_game_list_item(list, "Tournament", `<a href="/tm/pool/${game.pool_name}">${game.pool_name}</a>`)
+ } else {
+ if (game.scenario !== "Standard")
+ create_game_list_item(list, "Scenario", game.scenario)
+ create_game_list_item(list, "Options", format_options(game.options))
+ }
+
if (game.pace > 0)
create_game_list_item(list, "Pace", PACE_TEXT[game.pace])
- create_game_list_item(list, "Notice", game.notice)
+ if (!game.pool_name)
+ create_game_list_item(list, "Notice", game.notice)
if (game.status === 0) {
if (game.owner_id)
diff --git a/public/style.css b/public/style.css
index 0228687..0792dd1 100644
--- a/public/style.css
+++ b/public/style.css
@@ -241,9 +241,20 @@ table.wide {
}
table.half {
- min-width: 50%;
+ width: 100%;
+ max-width: 400px;
}
+table.seeds td:nth-child(2) { text-align: right; }
+table.pools td:nth-child(2) { text-align: right; }
+table.seeds td:nth-child(3) { width: 24px; text-align: right; }
+table.pools td:nth-child(3) { width: 24px; text-align: right; }
+
+table.pools a { color: var(--color-black); text-decoration: none; }
+table.pools a:hover { color: var(--color-blue); text-decoration: underline; }
+table.seeds a { color: var(--color-black); text-decoration: none; }
+table.seeds a:hover { color: var(--color-blue); text-decoration: underline; }
+
thead, th {
background-color: var(--color-table-head);
}
@@ -268,6 +279,7 @@ table.striped tr:nth-child(2n) {
td.r, th.r { text-align: right; }
td.w, th.w { white-space: nowrap; }
+td.n, th.n { width: 0px; white-space: nowrap; text-align: right; }
/* FORUM AND MESSAGE POSTS */
@@ -309,6 +321,15 @@ div.body img {
margin: 16px 0;
}
+.tour_list {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(0, 400px));
+ gap: 24px;
+ margin: 16px 0;
+}
+
+.tour_list table { margin: 0 }
+
.game_item {
border: var(--thin-border);
box-shadow: var(--drop-shadow);
diff --git a/schema.sql b/schema.sql
index 751d423..ee52bee 100644
--- a/schema.sql
+++ b/schema.sql
@@ -440,12 +440,15 @@ create view game_view as
select
games.*,
titles.title_name,
- owner.name as owner_name
+ owner.name as owner_name,
+ tm_pools.pool_name
from
games
join titles using(title_id)
left join users as owner
on owner.user_id = games.owner_id
+ left join tm_rounds using(game_id)
+ left join tm_pools using(pool_id)
;
drop view if exists game_view_public;
@@ -497,6 +500,7 @@ drop view if exists time_control_view;
create view time_control_view as
select
game_id,
+ user_id,
role,
is_opposed
from
@@ -558,6 +562,228 @@ create view game_export_view as
from games as outer
;
+-- Tournaments --
+
+create table if not exists tm_seeds (
+ seed_id integer primary key,
+ seed_name text unique,
+
+ title_id text,
+ scenario text,
+ options text,
+ player_count integer,
+
+ pace integer default 2,
+
+ pool_size integer default 3,
+ round_count integer default 4,
+ is_concurrent boolean default 1,
+
+ level_count integer default 1,
+
+ is_open boolean default 1
+);
+
+create table if not exists tm_banned (
+ user_id integer primary key,
+ time datetime default current_timestamp
+);
+
+create table if not exists tm_queue (
+ user_id integer,
+ seed_id integer,
+ level integer,
+ is_paused boolean default 0,
+ time datetime default current_timestamp,
+ primary key (user_id, seed_id, level)
+);
+
+create table if not exists tm_pools (
+ pool_id integer primary key,
+ seed_id integer,
+ level integer,
+ is_finished boolean,
+ start_date datetime,
+ finish_date datetime,
+ pool_name text unique
+);
+
+create table if not exists tm_rounds (
+ game_id integer primary key,
+ pool_id integer,
+ round integer
+);
+
+create index if not exists tm_rounds_pool_idx on tm_rounds(pool_id);
+
+create table if not exists tm_results (
+ pool_id integer,
+ user_id integer,
+ points integer,
+ son integer,
+ primary key (pool_id, user_id)
+);
+
+create table if not exists tm_winners (
+ pool_id integer,
+ user_id integer
+);
+
+create index if not exists tm_winners_pool_idx on tm_winners(pool_id);
+
+drop view if exists tm_pool_active_view;
+create view tm_pool_active_view as
+ select
+ tm_pools.*,
+ sum(status > 1) || ' / ' || count(1) as status
+ from
+ tm_pools
+ left join tm_rounds using(pool_id)
+ left join games using(game_id)
+ where
+ not is_finished
+ group by
+ pool_id
+ order by
+ pool_name
+;
+
+drop view if exists tm_pool_finished_view;
+create view tm_pool_finished_view as
+ select
+ tm_pools.*,
+ group_concat(name) as status
+ from
+ tm_pools
+ left join tm_winners using(pool_id)
+ left join users using(user_id)
+ where
+ is_finished
+ group by
+ pool_id
+ order by
+ pool_name
+;
+
+drop view if exists tm_pool_view;
+create view tm_pool_view as
+ select * from tm_pool_active_view
+ union all
+ select * from tm_pool_finished_view
+;
+
+drop trigger if exists tm_trigger_update_results;
+create trigger tm_trigger_update_results after update of result on games when new.is_match
+begin
+ -- each player scores
+ update players
+ set score = (
+ case
+ when new.result is null then null
+ when new.result = role then 2
+ when new.result = 'Draw' then 1
+ when instr(new.result, role) then 1
+ else 0
+ end
+ )
+ where
+ players.game_id = new.game_id
+ ;
+
+ -- Neustadtl Sonneborn–Berger tie-breaker
+ insert or replace into
+ tm_results (pool_id, user_id, points, son)
+ with
+ pts_cte as (
+ select
+ pool_id,
+ user_id,
+ sum(coalesce(score, 0)) as points
+ from
+ tm_rounds
+ join games using(game_id)
+ join players using(game_id)
+ where
+ pool_id = ( select pool_id from tm_rounds where game_id = new.game_id )
+ group by
+ user_id
+ ),
+ son_cte as (
+ select
+ rr.pool_id,
+ p1.user_id,
+ sum(
+ case
+ when p1.score > p2.score then
+ pp.points * 2
+ when p1.score = p2.score then
+ pp.points
+ else
+ 0
+ end
+ ) as son
+ from
+ tm_rounds as rr
+ join games using(game_id)
+ join players as p1 using(game_id)
+ join players as p2 using(game_id)
+ join pts_cte pp on rr.pool_id = pp.pool_id and p2.user_id = pp.user_id
+ where
+ rr.pool_id = ( select pool_id from tm_rounds where game_id = new.game_id )
+ and p1.user_id != p2.user_id
+ group by
+ p1.user_id
+ )
+ select
+ pool_id, user_id, points, son
+ from
+ pts_cte
+ join son_cte using(pool_id, user_id)
+ ;
+
+end;
+
+drop trigger if exists tm_trigger_update_winners;
+create trigger tm_trigger_update_winners after update of is_finished on tm_pools when new.is_finished
+begin
+ delete from tm_winners where pool_id = new.pool_id;
+ insert into
+ tm_winners ( pool_id, user_id )
+ with
+ tt as (
+ select
+ round_count as threshold
+ from
+ tm_seeds
+ where
+ seed_id = ( select seed_id from tm_pools where pool_id = new.pool_id )
+ ),
+ aa as (
+ select
+ max(points) as max_points
+ from
+ tm_results
+ where
+ pool_id = new.pool_id
+ ),
+ bb as (
+ select
+ max_points,
+ max(son) as max_son
+ from
+ tm_results, aa
+ where
+ pool_id = new.pool_id and points = max_points
+ )
+ select
+ pool_id, user_id
+ from
+ tm_results, bb, tt
+ where
+ pool_id = new.pool_id and points > threshold and points = max_points and son = max_son
+ ;
+end;
+
-- Trigger to update player counts when players join and part games
drop trigger if exists trigger_join_game;
@@ -601,26 +827,6 @@ begin
games.game_id = old.game_id;
end;
--- Trigger to update player score when game ends.
-
-drop trigger if exists trigger_update_score;
-create trigger trigger_update_score after update of result on games
-begin
- update players
- set score = (
- case
- when new.result is null then null
- when new.result = role then 2
- when new.result = 'Draw' then 1
- when instr(new.result, role) then 1
- else 0
- end
- )
- where
- players.game_id = new.game_id
- ;
-end;
-
-- Trigger to track time spent!
drop trigger if exists trigger_time_used_update;
@@ -673,6 +879,7 @@ begin
delete from posts where author_id = old.user_id;
delete from threads where author_id = old.user_id;
delete from game_chat where user_id = old.user_id;
+ delete from tm_queue where user_id = old.user_id;
delete from players where user_id = old.user_id and game_id in (select game_id from games where status = 0);
update games set owner_id = 0 where owner_id = old.user_id;
end;
diff --git a/server.js b/server.js
index 1e4a2bd..5caf238 100644
--- a/server.js
+++ b/server.js
@@ -95,6 +95,29 @@ function set_has(set, item) {
return false
}
+// see Object.groupBy
+function object_group_by(items, callback) {
+ let groups = {}
+ if (typeof callback === "function") {
+ for (let item of items) {
+ let key = callback(item)
+ if (key in groups)
+ groups[key].push(item)
+ else
+ groups[key] = [ item ]
+ }
+ } else {
+ for (let item of items) {
+ let key = item[callback]
+ if (key in groups)
+ groups[key].push(item)
+ else
+ groups[key] = [ item ]
+ }
+ }
+ return groups
+}
+
/*
* Notification mail setup.
*/
@@ -179,6 +202,11 @@ app.locals.ENABLE_FORUM = process.env.FORUM | 0
app.locals.EMOJI_PRIVATE = "\u{1F512}" // or 512
app.locals.EMOJI_MATCH = "\u{1f3c6}"
+app.locals.TM_ICON_QUEUE = "\u{1f465}"
+app.locals.TM_ICON_TICKET = "\u{1f3ab}"
+app.locals.TM_ICON_ACTIVE = "\u{1f3c1}"
+app.locals.TM_ICON_FINISHED = "\u{1f3c6}"
+
app.locals.PACE_ICON = [
"", // none
"\u{26a1}", // blitz
@@ -193,6 +221,8 @@ app.locals.PACE_TEXT = [
"1+ moves per day",
]
+app.locals.human_date = human_date
+
app.set("x-powered-by", false)
app.set("etag", false)
app.set("view engine", "pug")
@@ -719,12 +749,14 @@ app.get("/user/:who_name", function (req, res) {
who.atime = human_date(who.atime)
let games = QUERY_LIST_PUBLIC_GAMES_OF_USER.all({ user_id: who.user_id })
annotate_games(games, 0, null)
+ let active_pools = TM_POOL_LIST_USER_ACTIVE.all(who.user_id)
+ let finished_pools = TM_POOL_LIST_USER_RECENT_FINISHED.all(who.user_id)
let relation = 0
if (req.user)
relation = SQL_SELECT_RELATION.get(req.user.user_id, who.user_id) | 0
- res.render("user.pug", { user: req.user, who, relation, games })
+ res.render("user.pug", { user: req.user, who, relation, games, active_pools, finished_pools })
} else {
- return res.status(404).send("Invalid user name.")
+ return res.status(404).send("User not found.")
}
})
@@ -1320,7 +1352,6 @@ const SQL_SELECT_SNAP = SQL("select * from game_snap where game_id = ? and snap_
const SQL_SELECT_SNAP_STATE = SQL("select state from game_snap where game_id = ? and snap_id = ?").pluck()
const SQL_SELECT_SNAP_COUNT = SQL("select max(snap_id) from game_snap where game_id=?").pluck()
-
const SQL_DELETE_GAME_SNAP = SQL("delete from game_snap where game_id=? and snap_id > ?")
const SQL_DELETE_GAME_REPLAY = SQL("delete from game_replay where game_id=? and replay_id > ?")
@@ -1613,9 +1644,22 @@ app.get("/games/active", must_be_logged_in, function (req, res) {
let games = QUERY_LIST_ACTIVE_GAMES_OF_USER.all({ user_id })
let unread = SQL_SELECT_UNREAD_CHAT_GAMES.all(user_id)
annotate_games(games, user_id, unread)
+
+ let seeds = TM_SEED_LIST_USER.all(user_id)
+ let active_pools = TM_POOL_LIST_USER_ACTIVE.all(user_id)
+ let finished_pools = TM_POOL_LIST_USER_RECENT_FINISHED.all(user_id)
+
res.render("games_active.pug", { user: req.user, who: req.user, games, seeds, active_pools, finished_pools })
})
+app.get("/tm/active", must_be_logged_in, function (req, res) {
+ let user_id = req.user.user_id
+ let seeds = TM_SEED_LIST_USER.all(user_id)
+ let active_pools = TM_POOL_LIST_USER_ACTIVE.all(user_id)
+ let finished_pools = TM_POOL_LIST_USER_RECENT_FINISHED.all(user_id)
+ res.render("tm_active.pug", { user: req.user, who: req.user, seeds, active_pools, finished_pools })
+})
+
app.get("/games/finished", must_be_logged_in, function (req, res) {
let games = QUERY_LIST_FINISHED_GAMES_OF_USER.all({ user_id: req.user.user_id })
let unread = SQL_SELECT_UNREAD_CHAT_GAMES.all(req.user.user_id)
@@ -1623,12 +1667,27 @@ app.get("/games/finished", must_be_logged_in, function (req, res) {
res.render("games_finished.pug", { user: req.user, who: req.user, games })
})
+app.get("/tm/finished", must_be_logged_in, function (req, res) {
+ let pools = TM_POOL_LIST_USER_ALL_FINISHED.all(req.user.user_id)
+ res.render("tm_finished.pug", { user: req.user, who: req.user, pools })
+})
+
app.get("/games/finished/:who_name", function (req, res) {
let who = SQL_SELECT_USER_BY_NAME.get(req.params.who_name)
if (who) {
let games = QUERY_LIST_FINISHED_GAMES_OF_USER.all({ user_id: who.user_id })
annotate_games(games, 0, null)
- res.render("games_finished.pug", { user: req.user, who: who, games: games })
+ res.render("games_finished.pug", { user: req.user, who, games })
+ } else {
+ return res.status(404).send("Invalid user name.")
+ }
+})
+
+app.get("/tm/finished/:who_name", function (req, res) {
+ let who = SQL_SELECT_USER_BY_NAME.get(req.params.who_name)
+ if (who) {
+ let pools = TM_POOL_LIST_USER_ALL_FINISHED.all(who.user_id)
+ res.render("tm_finished.pug", { user: req.user, who, pools })
} else {
return res.status(404).send("Invalid user name.")
}
@@ -1680,6 +1739,10 @@ function get_title_page(req, res, title_id) {
annotate_games(active_games, user_id, unread)
annotate_games(finished_games, user_id, unread)
+ let seeds = TM_SEED_LIST_TITLE.all(user_id, title_id)
+ let active_pools = TM_POOL_LIST_TITLE_ACTIVE.all(title_id)
+ let finished_pools = TM_POOL_LIST_TITLE_FINISHED.all(title_id)
+
res.render("info.pug", {
user: req.user,
title: title,
@@ -1687,6 +1750,9 @@ function get_title_page(req, res, title_id) {
replacement_games,
active_games,
finished_games,
+ seeds,
+ active_pools,
+ finished_pools,
})
}
@@ -1806,6 +1872,11 @@ function insert_rematch_players(old_game_id, new_game_id, req_user_id, order) {
app.get("/rematch/:old_game_id", must_be_logged_in, function (req, res) {
let old_game_id = req.params.old_game_id | 0
+
+ let pool_name = TM_FIND_POOL_NAME.get(old_game_id)
+ if (pool_name)
+ return res.redirect("/tm/pool/" + pool_name)
+
let magic = "\u{1F503} " + old_game_id
let new_game_id = SQL_SELECT_REMATCH.get(magic)
if (new_game_id)
@@ -1990,7 +2061,7 @@ app.post("/api/invite/:game_id/:role/:user", must_be_logged_in, function (req, r
res.send("User not found.")
else if (user_id === req.user.user_id)
res.send("You cannot invite yourself!")
- else
+ else
do_join(res, game_id, role, user_id, null, 1)
})
@@ -2538,6 +2609,7 @@ const QUERY_PURGE_FINISHED_GAMES = SQL(`
games
where
status > 1
+ and not is_match
and ( not is_opposed or moves < player_count * 3 )
and julianday(mtime) < julianday('now', '-10 days')
`)
@@ -2601,6 +2673,11 @@ function time_control_ticker() {
if (item.is_opposed) {
console.log("TIMED OUT GAME:", item.game_id, item.role)
do_resign(item.game_id, item.role, "timed out")
+ if (item.is_match) {
+ console.log("BANNED FROM TOURNAMENTS:", item.user_id)
+ TM_INSERT_BANNED.run(item.user_id)
+ TM_DELETE_QUEUE_ALL.run(item.user_id)
+ }
} else {
console.log("TIMED OUT GAME:", item.game_id, item.role, "(solo)")
SQL_DELETE_GAME.run(item.game_id)
@@ -2613,6 +2690,699 @@ setInterval(time_control_ticker, 13 * 60 * 1000)
setTimeout(time_control_ticker, 13 * 1000)
/*
+ * TOURNAMENTS
+ */
+
+const designs = require("./designs.js")
+
+const TM_INSERT_BANNED = SQL("insert into tm_banned (user_id, time) values (?, datetime())")
+const TM_DELETE_QUEUE_ALL = SQL("delete from tm_queue where user_id=?")
+
+const TM_MAY_JOIN_ANY_SEED = SQL(`
+ select ( select notify and is_verified from users where user_id=@user_id )
+ or ( select exists ( select 1 from webhooks where user_id=@user_id and error is null ) )
+ or ( select exists ( select 1 from ratings where user_id=@user_id ) )
+ as may_join
+`).pluck()
+
+const TM_MAY_JOIN_SEED = SQL(`
+ select ( select not exists ( select 1 from tm_banned where user_id=@user_id ) )
+ and ( select coalesce(is_open, 0) as may_join from tm_seeds where seed_id=@seed_id )
+`).pluck()
+
+function may_join_any_seed(user_id) {
+ return DEBUG || TM_MAY_JOIN_ANY_SEED.get({user_id})
+}
+
+function may_join_seed(user_id, seed_id) {
+ return TM_MAY_JOIN_SEED.get({user_id,seed_id})
+}
+
+const TM_SEED_LIST_ALL = SQL(`
+ select
+ tm_seeds.*,
+ sum(level is 1) as queue_size,
+ sum(user_id is ?) as is_queued
+ from tm_seeds left join tm_queue using(seed_id)
+ group by seed_id
+ order by seed_name
+`)
+
+const TM_SEED_LIST_TITLE = SQL(`
+ select
+ tm_seeds.*,
+ sum(level is 1) as queue_size,
+ sum(user_id is ?) as is_queued
+ from tm_seeds left join tm_queue using(seed_id)
+ where title_id = ?
+ group by seed_id
+ order by seed_name
+`)
+
+const TM_SEED_LIST_USER = SQL(`
+ select
+ tm_seeds.*,
+ sum(level is 1) as queue_size,
+ sum(user_id is ?) as is_queued
+ from tm_seeds left join tm_queue using(seed_id)
+ group by seed_id
+ having is_queued
+ order by seed_name
+`)
+
+const TM_POOL_LIST_USER_ACTIVE = SQL(`
+ select * from tm_pool_active_view
+ where not is_finished and pool_id in (
+ select pool_id
+ from tm_rounds
+ join players using(game_id)
+ where user_id = ?
+ )
+`)
+
+const TM_POOL_LIST_USER_RECENT_FINISHED = SQL(`
+ select * from tm_pool_finished_view
+ where
+ finish_date > date('now', '-14 days')
+ and pool_id in (
+ select pool_id
+ from tm_rounds
+ join players using(game_id)
+ where user_id = ?
+ )
+`)
+
+const TM_POOL_LIST_USER_ALL_FINISHED = SQL(`
+ select * from tm_pool_finished_view
+ where
+ pool_id in (
+ select pool_id
+ from tm_rounds
+ join players using(game_id)
+ where user_id = ?
+ )
+`)
+
+const TM_POOL_LIST_TITLE_ACTIVE = SQL(`
+ select tm_pool_active_view.* from tm_pool_active_view join tm_seeds using(seed_id)
+ where tm_seeds.title_id = ?
+`)
+
+const TM_POOL_LIST_TITLE_FINISHED = SQL(`
+ select tm_pool_finished_view.* from tm_pool_finished_view join tm_seeds using(seed_id)
+ where tm_seeds.title_id = ? and finish_date > date('now', '-14 days')
+`)
+
+const TM_POOL_LIST_SEED_ACTIVE = SQL("select * from tm_pool_active_view where seed_id = ?")
+const TM_POOL_LIST_SEED_FINISHED = SQL("select * from tm_pool_finished_view where seed_id = ?")
+
+const TM_SELECT_QUEUE_BLACKLIST = SQL("select me, you from contacts join tm_queue q on q.user_id=me or q.user_id=you where relation < 0 and seed_id=? and level=?")
+const TM_SELECT_QUEUE_NAMES = SQL("select user_id, name, level from tm_queue join users using(user_id) where seed_id=? and level=? order by time")
+const TM_SELECT_QUEUE = SQL("select user_id from tm_queue where seed_id=? and level=? order by time desc").pluck()
+const TM_DELETE_QUEUE = SQL("delete from tm_queue where user_id=? and seed_id=? and level=?")
+const TM_INSERT_QUEUE = SQL("insert into tm_queue (user_id, seed_id, level) values (?,?,?)")
+
+const TM_SELECT_SEED = SQL("select * from tm_seeds where seed_id = ?")
+const TM_SELECT_SEED_BY_NAME = SQL("select * from tm_seeds where seed_name = ?")
+const TM_SELECT_POOL_BY_NAME = SQL("select * from tm_pools where pool_name=?")
+
+const TM_INSERT_POOL = SQL("insert into tm_pools (seed_id, level, is_finished, start_date, pool_name) values (?,?,0,datetime(),?) returning pool_id").pluck()
+const TM_INSERT_ROUND = SQL("insert into tm_rounds (game_id, pool_id, round) values (?,?,?)")
+
+const TM_UPDATE_POOL_FINISHED = SQL("update tm_pools set is_finished=1, finish_date=datetime() where pool_id=?")
+
+const TM_FIND_POOL_NAME = SQL("select pool_name from tm_rounds join tm_pools using(pool_id) where game_id=?").pluck()
+const TM_FIND_NEXT_POOL_NUMBER = SQL("select 1 + count(1) from tm_pools where seed_id = ? and level = ?").pluck()
+
+const TM_SELECT_GAMES = SQL(`
+ select
+ tm_rounds.*,
+ games.status,
+ games.moves,
+ json_group_object(role, name) as role_names,
+ json_group_object(role, score) as role_scores
+ from
+ tm_rounds
+ left join games using(game_id)
+ left join players using(game_id)
+ left join users using(user_id)
+ where
+ pool_id=?
+ group by
+ game_id
+`)
+
+const TM_SELECT_WINNERS = SQL("select * from tm_winners where pool_id = ?")
+
+const TM_SELECT_PLAYERS_2P = SQL(`
+ with
+ score_cte as (
+ select
+ pool_id,
+ u1.user_id as user_id,
+ u1.name as name,
+ u2.name as opponent,
+ json_group_array(json_array(game_id, p1.score)) as result
+ from
+ tm_rounds
+ left join players as p1 using(game_id)
+ left join players as p2 using(game_id)
+ left join users as u1 on u1.user_id=p1.user_id
+ left join users as u2 on u2.user_id=p2.user_id
+ where
+ pool_id = ?
+ and p1.user_id != p2.user_id
+ group by u1.name, u2.name
+ )
+ select
+ name,
+ json_group_object(opponent, json(result)) as result,
+ coalesce(points, 0) as points,
+ coalesce(son, 0) as son
+ from
+ score_cte
+ left join tm_results using(pool_id, user_id)
+ group by
+ user_id
+ order by
+ points desc, son desc, name
+`)
+
+const TM_SELECT_PLAYERS_MP = SQL(`
+ select
+ name,
+ json_group_array(json_array(game_id, score)) as result,
+ coalesce(points, 0) as points,
+ coalesce(son, 0) as son
+ from
+ tm_rounds
+ left join games using(game_id)
+ left join players using(game_id)
+ left join users using(user_id)
+ left join tm_results using(pool_id, user_id)
+ where
+ pool_id = ?
+ group by
+ user_id
+ order by
+ points desc, son desc, name
+`)
+
+const TM_FIND_NEXT_GAME_TO_START = SQL(`
+ with
+ user_busy as (
+ select
+ pool_id, round, user_id, role
+ from
+ tm_rounds
+ join games using(game_id)
+ join players using(game_id)
+ where
+ status = 1
+ ),
+ next_round as (
+ select
+ pool_id,
+ round,
+ coalesce(
+ lag( sum(status < 2) = 0 ) over ( partition by pool_id order by round ),
+ 1
+ ) as is_round_ready
+ from
+ tm_rounds
+ join games using(game_id)
+ group by
+ pool_id, round
+ ),
+ next_game as (
+ select
+ pool_id,
+ games.game_id,
+ games.title_id,
+ games.scenario,
+ games.options,
+ sum(
+ exists (
+ select 1 from user_busy
+ where user_busy.pool_id = tm_rounds.pool_id
+ and user_busy.round = tm_rounds.round
+ and user_busy.user_id = players.user_id
+ and user_busy.role = players.role
+ )
+ ) = 0 as is_user_ready
+ from
+ next_round
+ join tm_rounds using(pool_id, round)
+ join games using(game_id)
+ join players using(game_id)
+ where
+ status = 0 and is_round_ready
+ group by
+ game_id
+ having
+ is_user_ready
+ )
+ select
+ pool_id, game_id, title_id, scenario, options
+ from
+ next_game
+ limit 1
+`)
+
+const TM_SELECT_ENDED_POOLS = SQL(`
+ select
+ pool_id, seed_id, level, pool_name, level_count
+ from
+ tm_pools
+ join tm_seeds using(seed_id)
+ join tm_rounds using(pool_id)
+ join games using(game_id)
+ where
+ not is_finished
+ group by
+ pool_id
+ having
+ sum(status < 2) = 0
+`)
+
+const TM_SELECT_SEED_READY_MINI_CUP = SQL(`
+ select
+ seed_id, level
+ from
+ tm_seeds
+ join tm_queue using(seed_id)
+ where
+ is_open and seed_name like 'mc.%'
+ and julianday(time) < julianday('now', '-30 seconds')
+ group by
+ seed_id, level
+ having
+ count(1) >= pool_size
+`)
+
+app.get("/tm/list", function (req, res) {
+ let seeds = TM_SEED_LIST_ALL.all(req.user ? req.user.user_id : 0)
+ let seeds_by_title = object_group_by(seeds, "title_id")
+ res.render("tm_list.pug", { user: req.user, seeds, seeds_by_title })
+})
+
+app.get("/tm/seed/:seed_name", function (req, res) {
+ let seed_name = req.params.seed_name
+ let seed = TM_SELECT_SEED_BY_NAME.get(seed_name)
+ if (!seed)
+ return res.status(404).send("Tournament seed not found.")
+ let seed_id = seed.seed_id
+ let queues = []
+ for (let level = 1; level <= seed.level_count; ++level)
+ queues[level-1] = TM_SELECT_QUEUE_NAMES.all(seed_id, level)
+
+ let active_pools = TM_POOL_LIST_SEED_ACTIVE.all(seed_id)
+ let finished_pools = TM_POOL_LIST_SEED_FINISHED.all(seed_id)
+
+ let error = null
+ let may_register = false
+ if (req.user && seed.is_open) {
+ if (!may_join_any_seed(req.user.user_id))
+ error = "Please verify your mail address and enable notifications to join tournaments."
+ else if (!may_join_seed(req.user.user_id, seed_id))
+ error = "You may not register for this tournament."
+ else
+ may_register = true
+ }
+
+ res.render("tm_seed.pug", { user: req.user, error, may_register, seed, queues, active_pools, finished_pools })
+})
+
+app.get("/tm/pool/:pool_name", function (req, res) {
+ let pool_name = req.params.pool_name
+ let pool = TM_SELECT_POOL_BY_NAME.get(pool_name)
+ if (!pool)
+ return res.status(404).send("Tournament pool not found.")
+ let pool_id = pool.pool_id
+ let seed = TM_SELECT_SEED.get(pool.seed_id)
+ let roles = get_game_roles(seed.title_id, seed.scenario, seed.options)
+ let players
+ if (seed.player_count === 2)
+ players = TM_SELECT_PLAYERS_2P.all(pool_id)
+ else
+ players = TM_SELECT_PLAYERS_MP.all(pool_id)
+ let games = TM_SELECT_GAMES.all(pool_id)
+ let games_by_round = object_group_by(games, "round")
+ res.render("tm_pool.pug", { user: req.user, seed, pool, roles, players, games_by_round })
+})
+
+app.post("/api/tm/register/:seed_id", must_be_logged_in, function (req, res) {
+ let seed_id = req.params.seed_id | 0
+ let user_id = req.user.user_id
+ if (!may_join_any_seed(user_id))
+ return res.status(401).send("You may not join any tournaments right now.")
+ if (!may_join_seed(user_id, seed_id))
+ return res.status(401).send("You may not join this tournament.")
+ TM_INSERT_QUEUE.run(user_id, seed_id, 1)
+ return res.redirect(req.headers.referer)
+})
+
+app.post("/api/tm/withdraw/:seed_id/:level", must_be_logged_in, function (req, res) {
+ let seed_id = req.params.seed_id | 0
+ let level = req.params.level | 0
+ let user_id = req.user.user_id
+ TM_DELETE_QUEUE.run(user_id, seed_id, level)
+ return res.redirect(req.headers.referer)
+})
+
+app.post("/api/tm/start/:seed_id/:level", must_be_administrator, function (req, res) {
+ let seed_id = req.params.seed_id | 0
+ let level = req.params.level | 0
+ start_tournament_seed(seed_id, level)
+ tm_start_ready_games()
+ return res.redirect(req.headers.referer)
+})
+
+function make_pools(seed, players) {
+ let v = players.length
+ let k = seed.player_count
+ let n = seed.round_count
+
+ if (k === 2) {
+ if (n === 4) {
+ if (v % 5 === 0)
+ return designs.pool_players(players, 5)
+ if (v % 3 === 0)
+ return designs.pool_players(players, 3)
+ if (v > 7)
+ return designs.pool_players_using_knapsack(players, "5/3")
+ }
+
+ if (n === 6) {
+ if (v % 7 === 0)
+ return designs.pool_players(players, 7)
+ if (v % 4 === 0)
+ return designs.pool_players(players, 4)
+ if (v > 17)
+ return designs.pool_players_using_knapsack(players, "7/4")
+ }
+
+ if (n === 8) {
+ if (v % 9 === 0)
+ return designs.pool_players(players, 9)
+ if (v % 5 === 0)
+ return designs.pool_players(players, 5)
+ if (v > 31)
+ return designs.pool_players_using_knapsack(players, "9/5")
+ }
+
+ if (v % (n+1) === 0)
+ return designs.pool_players(players, n+1)
+
+ throw new Error("cannot create pools for this player/rounds configuration")
+
+ if (v > n+1)
+ return designs.pool_players(players, n+1)
+
+ return [ players ]
+ }
+
+ if (k === 3) {
+ // youden squares
+ if (v % 7 === 0) return designs.pool_players(players, 7)
+ // kirkman triple systems
+ if (v % 9 === 0) return designs.pool_players(players, 9)
+ if (v % 15 === 0) return designs.pool_players(players, 15)
+ if (v % 21 === 0) return designs.pool_players(players, 21)
+ if (v % 27 === 0) return designs.pool_players(players, 27)
+ if (v % 33 === 0) return designs.pool_players(players, 33)
+ if (v % 39 === 0) return designs.pool_players(players, 39)
+ if (v % 45 === 0) return designs.pool_players(players, 45)
+ if (v % 51 === 0) return designs.pool_players(players, 51)
+ // misc bibd
+ if (v % 13 === 0 && n == 6)
+ return designs.pool_players(players, 13)
+ }
+
+ if (k === 4) {
+ // youden squares
+ if (v % 7 === 0) return designs.pool_players(players, 7)
+ if (v % 13 === 0) return designs.pool_players(players, 13)
+ // steiner quadrilateral systems
+ if (v % 16 === 0) return designs.pool_players(players, 16)
+ if (v % 28 === 0) return designs.pool_players(players, 28)
+ if (v % 40 === 0) return designs.pool_players(players, 40)
+ if (v % 52 === 0) return designs.pool_players(players, 52)
+ // misc bibd
+ if (v % 9 === 0 && n == 8)
+ return designs.pool_players(players, 9)
+ }
+
+ if (k === 5) {
+ // youden squares
+ if (v % 11 === 0) return designs.pool_players(players, 11)
+ if (v % 21 === 0) return designs.pool_players(players, 21)
+ // resolvable bibd
+ if (v % 25 === 0) return designs.pool_players(players, 25)
+ }
+
+ if (k === 6) {
+ // youden squares / bibd
+ if (v % 11 === 0) return designs.pool_players(players, 11)
+ if (v % 16 === 0) return designs.pool_players(players, 16)
+ if (v % 31 === 0) return designs.pool_players(players, 31)
+ }
+
+ throw new Error("cannot create pools for this player count")
+}
+
+function make_rounds(seed, players) {
+ let v = players.length
+ let k = seed.player_count
+ let n = seed.round_count
+ let rounds
+ if (seed.is_concurrent)
+ rounds = make_concurrent_rounds(v, k, n)
+ else
+ rounds = make_sequential_rounds(v, k, n)
+ return rounds.map(r => r.map(m => m.map(p => players[p])))
+}
+
+function make_concurrent_rounds(v, k, n) {
+ if (k === 2) {
+ if (v - 1 <= n / 2)
+ return [ designs.double_berger_table(v).flat() ]
+ else if (v & 1)
+ return [ designs.concurrent_round_robin(v).flat() ]
+ else
+ return [ designs.berger_table(v).flat() ]
+ }
+
+ let bibd = designs.youden_square(v, k)
+ if (bibd)
+ return [ bibd ]
+
+ let rbibd = designs.resolvable_bibd(v, k)
+ if (rbibd)
+ return rbibd.slice(0, n).flat()
+
+ throw new Error("cannot create rounds for this configuration")
+}
+
+function make_sequential_rounds(v, k, n) {
+ if (k === 2) {
+ if (v - 1 <= n / 2)
+ return designs.double_berger_table(v)
+ else
+ return designs.berger_table(v)
+ }
+
+ let rbibd = designs.resolvable_bibd(v, k)
+ if (rbibd)
+ return rbibd.slice(0, n)
+
+ throw new Error("cannot create rounds for this configuration")
+}
+
+function create_tournament(seed, level, players) {
+ let pools = make_pools(seed, players)
+ for (let i = 0; i < pools.length; ++i)
+ create_tournament_pool(seed, level, pools[i])
+}
+
+function create_tournament_pool(seed, level, players) {
+ let rounds = make_rounds(seed, players)
+
+ let pool_name = seed.seed_name + "." + level + "." + TM_FIND_NEXT_POOL_NUMBER.get(seed.seed_id, level)
+
+ let pool_id = TM_INSERT_POOL.get(seed.seed_id, level, pool_name)
+
+ console.log("TM POOL", pool_name, players.length, "players", rounds.length, "rounds")
+
+ for (let p of players) {
+ TM_DELETE_QUEUE.run(p, seed.seed_id, level)
+ }
+
+ for (let i = 0; i < rounds.length; ++i) {
+ for (let match of rounds[i]) {
+ create_tournament_game(seed, pool_id, i+1, pool_name, match)
+ }
+ }
+}
+
+function create_tournament_game(seed, pool_id, round, pool_name, players) {
+ if (players.length !== seed.player_count)
+ throw new Error("player count mismatch in tournament setup")
+
+ let roles = get_game_roles(seed.title_id, seed.scenario, parse_game_options(seed.options))
+ if (players.length !== roles.length)
+ throw new Error("player count mismatch in tournament setup")
+
+ let game_id = SQL_INSERT_GAME.get(
+ 0, // owner
+ seed.title_id,
+ seed.scenario,
+ seed.options,
+ seed.player_count,
+ 2, // pace
+ 0, // is_private
+ 0, // is_random
+ pool_name, // notice
+ 1 // is_match
+ )
+
+ for (let i = 0; i < players.length; ++i)
+ SQL_INSERT_PLAYER_ROLE.run(game_id, roles[i], players[i], 0)
+
+ TM_INSERT_ROUND.run(game_id, pool_id, round)
+
+ return game_id
+}
+
+function filter_queue_through_blacklist(queue, count, blacklist) {
+ function can_add_player(pool, b) {
+ for (let a of pool) {
+ for (let {me, you} of blacklist) {
+ if (me === a && you === b)
+ return false
+ if (me === b && you === a)
+ return false
+ }
+ }
+ return true
+ }
+
+ function rec(output, input) {
+ for (;;) {
+ if (output.length === count)
+ return output
+ if (input.length === 0)
+ return false
+ let a = input.pop()
+ if (can_add_player(output, a)) {
+ output.push(a)
+ if (rec(output, input.slice()))
+ return output
+ output.pop()
+ }
+ }
+ }
+
+ return rec([], queue)
+}
+
+function start_tournament_seed_mc(seed_id, level) {
+ let seed = TM_SELECT_SEED.get(seed_id)
+ let queue = TM_SELECT_QUEUE.all(seed_id, level)
+ let blacklist = TM_SELECT_QUEUE_BLACKLIST.all(seed_id, level)
+
+ console.log("TM SPAWN SEED (MC)", seed.seed_name, level, queue.length)
+
+ let players = filter_queue_through_blacklist(queue, seed.pool_size, blacklist)
+ if (!players)
+ throw new Error("Too many blacklisted players to form pool!")
+
+ SQL_BEGIN.run()
+ try {
+ shuffle(players)
+ create_tournament(seed, level, players)
+ SQL_COMMIT.run()
+ } catch (err) {
+ console.log(err)
+ } finally {
+ if (db.inTransaction)
+ SQL_ROLLBACK.run()
+ }
+}
+
+function start_tournament_seed(seed_id, level) {
+ let seed = TM_SELECT_SEED.get(seed_id)
+
+ if (seed.seed_name.startsWith("mc."))
+ return start_tournament_seed_mc(seed_id, level)
+
+ let queue = TM_SELECT_QUEUE.all(seed_id, level)
+ console.log("TM SPAWN SEED", seed.seed_name, level, queue.length)
+
+ shuffle(queue)
+
+ SQL_BEGIN.run()
+ try {
+ create_tournament(seed, level, queue)
+ SQL_COMMIT.run()
+ } finally {
+ if (db.inTransaction)
+ SQL_ROLLBACK.run()
+ }
+}
+
+function tm_reap_pools() {
+ // reap pools that are finished (and promote winners)
+ let ended = TM_SELECT_ENDED_POOLS.all()
+ for (let item of ended) {
+ console.log("TM POOL - END", item.pool_name)
+ SQL_BEGIN.run()
+ try {
+ TM_UPDATE_POOL_FINISHED.run(item.pool_id)
+ if (item.level < item.level_count) {
+ let winners = TM_SELECT_WINNERS.all(item.pool_id)
+ for (let user_id of winners)
+ TM_INSERT_QUEUE.run(user_id, item.seed_id, item.level + 1)
+ }
+ SQL_COMMIT.run()
+ } finally {
+ if (db.inTransaction)
+ SQL_ROLLBACK.run()
+ }
+ }
+}
+
+function tm_start_ready_seeds() {
+ // start seeds that are ready
+ for (let item of TM_SELECT_SEED_READY_MINI_CUP.all())
+ start_tournament_seed_mc(item.seed_id, item.level)
+}
+
+function tm_start_ready_games() {
+ // start games that are ready
+ for (;;) {
+ let game = TM_FIND_NEXT_GAME_TO_START.get()
+ if (game)
+ start_game(game)
+ else
+ break
+ }
+}
+
+function tournament_ticker() {
+ try {
+ tm_reap_pools()
+ tm_start_ready_seeds()
+ tm_start_ready_games()
+ } catch (err) {
+ console.log(err)
+ }
+}
+
+setTimeout(tournament_ticker, 19 * 1000)
+setInterval(tournament_ticker, 97 * 1000)
+
+/*
* GAME SERVER
*/
diff --git a/views/about.pug b/views/about.pug
index b19a45e..f2f9964 100644
--- a/views/about.pug
+++ b/views/about.pug
@@ -11,17 +11,20 @@ html
p.
Here you can play board games online with other players.
- You can invite your friends or look in the waiting room to see whether someone wants to play.
+ You can invite your friends, compete in tournaments, or look in the public room to see whether someone wants to play.
Your opponents don't have to be online, but if they are you can play live.
p.
Registration and use is free, and there are no ads.
- p.
- Please read the <a href="/docs/tips.html">Tips &amp; Tricks</a> before playing!
+ p!= process.env.SITE_INVITE
- p.
- Read the developer <a href="/docs/">documentation</a> if you want to create modules.
+ h2 About
+
+ ul
+ li Read the <a href="/docs/tips.html">Tips &amp; Tricks</a> before playing!
+ li Read the <a href="/docs/tournaments.html">tournament information</a> before joining a tournament.
+ li Study the <a href="/docs/">developer documentation</a> if you want to create modules.
p.
The source code is available on #[a(href="https://git.rally-the-troops.com/") git.rally-the-troops.com].
diff --git a/views/games_active.pug b/views/games_active.pug
index 76ac030..eaa1076 100644
--- a/views/games_active.pug
+++ b/views/games_active.pug
@@ -25,6 +25,11 @@ html
p
a(href="/create") Create a new game
+ +tourlist(seeds, active_pools, finished_pools)
+
+ p
+ a(href="/tm/list") Join a tournament
+
if move_games.length > 0
h2 Move
+gamelist(move_games)
@@ -47,3 +52,5 @@ html
p
a(href="/games/finished") All your finished games
+ br
+ a(href="/tm/finished") All your finished tournaments
diff --git a/views/games_list.pug b/views/games_list.pug
new file mode 100644
index 0000000..52c14d9
--- /dev/null
+++ b/views/games_list.pug
@@ -0,0 +1,48 @@
+//- vim:ts=4:sw=4:
+doctype html
+html
+ head
+ include head
+ +social(SITE_NAME, "Play historic board games on the web.")
+ meta(name="keywords" content="wargames, war games, block games")
+ title= SITE_NAME
+ style.
+ .list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(146px, 1fr));
+ grid-auto-rows: 190px;
+ gap: 8px;
+ }
+ .list a {
+ display: grid;
+ grid-template-rows: 146px 40px;
+ text-align: center;
+ font-family: var(--font-small);
+ font-size: 14px;
+ line-height: 20px;
+ text-wrap: balance;
+ gap: 4px;
+ }
+ .list img {
+ display: block;
+ margin: auto auto;
+ min-width: 108px;
+ min-height: 108px;
+ max-width: 144px;
+ max-height: 144px;
+ background-color: silver;
+ box-shadow: var(--drop-shadow);
+ border: var(--thin-border);
+ }
+ body
+ include header
+ article.wide
+
+ h1 Games
+
+ div.list
+ each title in TITLE_LIST
+ unless title.is_hidden
+ a.black(href="/"+title.title_id)
+ img(src="/"+title.title_id+"/thumbnail.jpg")
+ div= title.title_name
diff --git a/views/games_public.pug b/views/games_public.pug
index cf102f0..54591cd 100644
--- a/views/games_public.pug
+++ b/views/games_public.pug
@@ -9,7 +9,7 @@ html
body
include header
article.wide
- h1 Public Games
+ h1 Public room
h2 Open
if open_games.length > 0
diff --git a/views/head.pug b/views/head.pug
index 4262408..66e3f58 100644
--- a/views/head.pug
+++ b/views/head.pug
@@ -102,14 +102,18 @@ mixin gamelist(list,hide_title=0)
div.game_main
div.game_info
- if item.notice
+ if item.is_match
+ i
+ a(href="/tm/pool/"+item.notice)= item.notice
+ else if item.notice
i= item.notice
else
i= pace_text
- if item.scenario !== "Standard" && item.scenario !== "Historical" && item.scenario.length > 2
- div Scenario: #{item.scenario}
- unless item.human_options === "None"
- div Options: #{item.human_options}
+ unless item.is_match
+ if item.scenario !== "Standard" && item.scenario !== "Historical" && item.scenario.length > 2
+ div Scenario: #{item.scenario}
+ unless item.human_options === "None"
+ div Options: #{item.human_options}
if item.player_names
div Players: !{item.player_names}
else
@@ -135,3 +139,58 @@ mixin gamelist(list,hide_title=0)
unless hide_title
a(href=`/${item.title_id}`)
img(src=`/${item.title_id}/thumbnail.jpg`)
+
+mixin seedlist(list, title)
+ if list && list.length > 0
+ table.half.seeds
+ if title
+ thead
+ tr
+ th= title
+ td
+ td= TM_ICON_QUEUE
+ tbody
+ each seed in list
+ tr
+ td
+ a(href="/tm/seed/" + seed.seed_name)= seed.seed_name
+ td.n #{seed.queue_size}
+ if user && seed.is_queued
+ td= TM_ICON_TICKET
+ else
+ td
+
+mixin poollist(list, title, icon)
+ if list && list.length > 0
+ table.half.pools
+ if title
+ thead
+ tr
+ th= title
+ td= icon
+ tbody
+ each pool in list
+ tr
+ td.w
+ a(href="/tm/pool/" + pool.pool_name)= pool.pool_name
+ if pool.is_finished
+ if pool.status
+ td!= pool.status.split(",").map(p => `<a class="black" href="/user/${p}">${p}</a>`).join(", ")
+ else
+ td Nobody
+ else
+ td= pool.status
+
+mixin tourlist(seeds, pools, fin)
+ if (seeds && seeds.length > 0) || (pools && pools.length > 0) || (fin && fin.length > 0)
+ h2 Tournaments
+ div.tour_list
+ if seeds && seeds.length > 0
+ div
+ +seedlist(seeds, "Registrations")
+ if pools && pools.length > 0
+ div
+ +poollist(pools, "Active", TM_ICON_ACTIVE)
+ if fin && fin.length > 0
+ div
+ +poollist(fin, "Finished", TM_ICON_FINISHED)
diff --git a/views/header.pug b/views/header.pug
index 9ff1263..6df51e6 100644
--- a/views/header.pug
+++ b/views/header.pug
@@ -9,6 +9,7 @@ header
if user
if ENABLE_FORUM
a(href="/forum") Forum
+ a(href="/tm/list") Tournaments
a(href="/games/public") Public
if user.waiting > 0
a(href="/games/active") Games (#{user.waiting})
diff --git a/views/info.pug b/views/info.pug
index 3e80fb1..2ffd8f0 100644
--- a/views/info.pug
+++ b/views/info.pug
@@ -28,6 +28,8 @@ html
p
a(href="/create/"+title.title_id) Create a new game
+ +tourlist(seeds, active_pools, finished_pools)
+
if active_games.length > 0
h2 Recently active
+gamelist(active_games, true)
diff --git a/views/tm_active.pug b/views/tm_active.pug
new file mode 100644
index 0000000..cc821b2
--- /dev/null
+++ b/views/tm_active.pug
@@ -0,0 +1,24 @@
+//- vim:ts=4:sw=4:
+doctype html
+html
+ head
+ include head
+ title= SITE_NAME
+ body
+ include header
+ article.wide
+ h1 Your Tournaments
+
+ if seeds && seeds.length > 0
+ +seedlist(seeds, "Registrations")
+ p
+ a(href="/tm/list") Join a tournament
+
+ if active_pools && active_pools.length > 0
+ div
+ +poollist(active_pools, "Active", TM_ICON_ACTIVE)
+ div
+ if finished_pools && finished_pools.length > 0
+ +poollist(finished_pools, "Finished", TM_ICON_FINISHED)
+ p
+ a(href="/tm/finished") All your finished tournaments
diff --git a/views/tm_finished.pug b/views/tm_finished.pug
new file mode 100644
index 0000000..efe193d
--- /dev/null
+++ b/views/tm_finished.pug
@@ -0,0 +1,18 @@
+//- vim:ts=4:sw=4:
+doctype html
+html
+ head
+ include head
+ title= SITE_NAME
+ body
+ include header
+ article.wide
+ if user && user.user_id === who.user_id
+ h1 Your finished tournaments
+ else
+ h1 #{who.name}&rsquo;s finished tournaments
+
+ if pools.length > 0
+ +poollist(pools, TM_ICON_FINISHED)
+ else
+ p Nothing here.
diff --git a/views/tm_list.pug b/views/tm_list.pug
new file mode 100644
index 0000000..41945fc
--- /dev/null
+++ b/views/tm_list.pug
@@ -0,0 +1,28 @@
+//- vim:ts=4:sw=4:
+doctype html
+html
+ head
+ include head
+ title Tournaments
+ body
+ include header
+ article
+ h1 Tournaments
+
+ p See <a href="/docs/tournaments.html">tournament information</a>.
+
+ if 0
+ dl
+ each seeds, title_id in seeds_by_title
+ dt= TITLE_NAME[title_id]
+ each seed in seeds
+ dd
+ a(href="/tm/seed/" + seed.seed_name)= seed.seed_name
+ | (#{seed.queue_size}/#{seed.pool_size})
+ if user && seed.is_queued
+ | &#x1f3ab;
+ if 0
+ each seeds, title_id in seeds_by_title
+ +seedlist(seeds, TITLE_NAME[title_id])
+ if 1
+ +seedlist(seeds, "Mini Cup")
diff --git a/views/tm_pool.pug b/views/tm_pool.pug
new file mode 100644
index 0000000..d801375
--- /dev/null
+++ b/views/tm_pool.pug
@@ -0,0 +1,159 @@
+//- vim:ts=4:sw=4:
+doctype html
+html
+ head
+ include head
+ title= pool.pool_name
+ style.
+ @media (max-width: 500px) {
+ table {
+ font-family: var(--font-widget);
+ font-size: 12px;
+ line-height: 16px;
+ }
+ }
+ td, th { padding: 2px 6px; }
+ table.wide tbody tr:hover { background-color: #0001 }
+ tr.hr { padding: 0; border-bottom: 1px solid black }
+ td.c { text-align: center }
+ td.g { color: gray }
+ a.gray { text-decoration: none; color: gray }
+ div.thumb {
+ float: right;
+ }
+ div.thumb img {
+ max-width: 60px;
+ max-height: 72px;
+ margin: 4px 0 4px 4px;
+ border: var(--thin-border);
+ box-shadow: var(--drop-shadow);
+ }
+ #pool_info td { padding: 2px 10px }
+ #pool_info td:first-child { width: 80px }
+ #pool_info tr:first-child td { padding-top: 5px }
+ #pool_info tr:last-child td { padding-bottom: 5px }
+ body
+ include header
+ article
+ div.thumb
+ a(href="/"+seed.title_id)
+ img(src="/"+seed.title_id+"/thumbnail.jpg")
+
+ h2= pool.pool_name
+
+ table.half#pool_info
+ tr
+ td Tournament
+ td
+ a(href="/tm/seed/" + seed.seed_name)= seed.seed_name
+ tr
+ td Started
+ td= human_date(pool.start_date)
+ tr
+ td Finished
+ if pool.finish_date
+ td= human_date(pool.finish_date)
+
+ if seed.player_count === 2
+ table.wide
+ thead
+ tr
+ td.n
+ td
+ each row, ix in players
+ td.n.c= ix+1
+ td.n Pts
+ td.n Son
+ tbody
+ each row, rx in players
+ - var result = JSON.parse(row.result)
+ tr
+ td= rx+1
+ td
+ if row.name
+ <a class="black" href="/user/#{row.name}">#{row.name}</a>
+ else
+ | null
+ each col in players
+ if row.name === col.name
+ td
+ else
+ td.w.c
+ if result[col.name]
+ each gs, ix in result[col.name]
+ if ix > 0
+ | &nbsp;
+ if gs[1] === null
+ a.black(href="/join/" + gs[0]) &minus;
+ else
+ a.black(href="/join/" + gs[0])= gs[1]
+ td.r= row.points
+ td.r.g= row.son
+
+ else
+ - var n = JSON.parse(players[0].result).length
+ table.wide
+ thead
+ tr
+ td.n
+ td
+ - var i = 0
+ while i < n
+ td.n.c= i+1
+ - ++i
+ td.n.r Pts
+ td.n.r Son
+ tbody
+ each row, rx in players
+ - var result = JSON.parse(row.result)
+ tr
+ td= rx+1
+ td
+ if row.name
+ <a class="black" href="/user/#{row.name}">#{row.name}</a>
+ else
+ | null
+ each gs in result
+ td.c
+ if gs[1] === null
+ a.black(href="/join/" + gs[0]) &minus;
+ else
+ a.black(href="/join/" + gs[0])= gs[1]
+ td.r= row.points
+ td.r.g= row.son
+
+ table.wide
+ thead
+ tr
+ td Game
+ each role in roles
+ td= role
+ td.n.r Result
+ td.n.r Moves
+ tbody
+ each group,ix in games_by_round
+ if ix > 1
+ tr.hr
+ each game in group
+ - var role_names = JSON.parse(game.role_names)
+ - var role_scores = JSON.parse(game.role_scores)
+ tr
+ td.n
+ a.black(href="/join/" + game.game_id)= "#" + game.game_id
+ each role in roles
+ - var p = role_names[role]
+ td
+ a.black(href="/user/"+p)= p
+
+ if game.status > 1
+ td.w.r
+ each role, ix in roles
+ if ix > 0
+ | &nbsp;:&nbsp;
+ | #{role_scores[role]}
+ else
+ td.r
+ if game.status > 0
+ td.r= game.moves
+ else
+ td.r
diff --git a/views/tm_seed.pug b/views/tm_seed.pug
new file mode 100644
index 0000000..683029f
--- /dev/null
+++ b/views/tm_seed.pug
@@ -0,0 +1,100 @@
+//- vim:ts=4:sw=4:
+doctype html
+html
+ head
+ include head
+ title= seed.seed_name
+ style.
+ div.thumb {
+ float: right;
+ }
+ div.thumb img {
+ max-width: 60px;
+ max-height: 72px;
+ margin: 4px 0 4px 4px;
+ border: var(--thin-border);
+ box-shadow: var(--drop-shadow);
+ }
+ #seed_info td { padding: 2px 10px }
+ #seed_info td:first-child { width: 80px }
+ #seed_info tr:first-child td { padding-top: 5px }
+ #seed_info tr:last-child td { padding-bottom: 5px }
+ table { margin: 1em 0 0.5em 0 }
+ body
+ include header
+ article
+ div.thumb
+ a(href="/"+seed.title_id)
+ img(src="/"+seed.title_id+"/thumbnail.jpg")
+
+ h1= seed.seed_name
+
+ if error
+ p.error= error
+
+ table#seed_info.half
+ tr
+ td Format
+ td
+ a(href="/docs/tournaments.html") Mini Cup
+ if seed.scenario !== "Standard"
+ tr
+ td Scenario
+ td #{seed.scenario}
+ if seed.pace
+ tr
+ td Pace
+ td= PACE_ICON[seed.pace]
+ tr
+ td Players
+ td #{seed.pool_size}
+ tr
+ td Rounds
+ if (seed.is_concurrent)
+ td #{seed.round_count} concurrent
+ else
+ td #{seed.round_count} sequential
+
+ if seed.is_open
+ each queue,ix in queues
+ table.half
+ thead
+ tr
+ if seed.level_count > 1
+ th Level #{ix+1}
+ else
+ th Registered
+ td.r #{queue.length} / #{seed.pool_size}
+ tbody
+ tr
+ if queue.length > 0
+ td(colspan=2)!= queue.map(p => `<a class="black" href="/user/${p.name}">${p.name}</a>`).join(", ")
+ else
+ td Nobody
+
+ if user
+ if ix === 0
+ if may_register
+ if !queue.find(p => p.user_id === user.user_id)
+ form(method="post" action="/api/tm/register/" + seed.seed_id)
+ button(type="submit") Register
+ button(disabled) Withdraw
+ else
+ div
+ button(disabled) Register
+ button(disabled) Withdraw
+
+ if queue.find(p => p.user_id === user.user_id)
+ form(method="post" action="/api/tm/withdraw/" + seed.seed_id + "/" + (ix+1))
+ button(disabled) Register
+ button(type="submit") Withdraw
+
+ if user.user_id === 1
+ if queue.length >= seed.pool_size
+ form(method="post" action="/api/tm/start/" + seed.seed_id + "/" + (ix+1))
+ button(type="submit") Start
+ else
+ p <a href="/login">Login</a> or <a href="/signup">sign up</a> to register.
+
+ +poollist(active_pools, "Active", TM_ICON_ACTIVE)
+ +poollist(finished_pools, "Finished", TM_ICON_FINISHED)
diff --git a/views/user.pug b/views/user.pug
index ce5b5f0..06fa91d 100644
--- a/views/user.pug
+++ b/views/user.pug
@@ -40,10 +40,12 @@ html
else
a(href="/contacts/add-friend/"+who.name) Add to friends
br
- a(href="/contacts/add-enemy/"+who.name) Blacklist user
+ a(href="/contacts/add-enemy/"+who.name) Add to blacklist
+
+ +tourlist(null, active_pools, finished_pools)
if open_games.length > 0
- h2 Open
+ h2 Invitations
+gamelist(open_games)
if active_games.length > 0
@@ -63,6 +65,7 @@ html
+gamelist(finished_games)
p <a href="/games/finished/#{who.name}">All #{who.name}'s finished games</a>
+ p <a href="/tm/finished/#{who.name}">All #{who.name}'s finished tournaments</a>
if user && user.user_id === 1
if who.is_banned