Tables reordering (and use pseudo-ranks for table level), plus some code reorg
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
package org.jeudego.pairgoth.api
|
||||||
|
|
||||||
|
import com.republicate.kson.Json
|
||||||
|
import org.jeudego.pairgoth.model.Criterion
|
||||||
|
import org.jeudego.pairgoth.model.MacMahon
|
||||||
|
import org.jeudego.pairgoth.model.Pairable
|
||||||
|
import org.jeudego.pairgoth.model.PairingType
|
||||||
|
import org.jeudego.pairgoth.model.Tournament
|
||||||
|
import org.jeudego.pairgoth.model.getID
|
||||||
|
import org.jeudego.pairgoth.model.historyBefore
|
||||||
|
import org.jeudego.pairgoth.pairing.HistoryHelper
|
||||||
|
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
// TODO CB avoid code redundancy with solvers
|
||||||
|
|
||||||
|
fun Tournament<*>.mmBase(pairable: Pairable): Double {
|
||||||
|
if (pairing !is MacMahon) throw Error("invalid call: tournament is not Mac Mahon")
|
||||||
|
return min(max(pairable.rank, pairing.mmFloor), pairing.mmBar) + MacMahonSolver.mmsZero + pairable.mmsCorrection
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Tournament<*>.getSortedPairables(round: Int): List<Json.Object> {
|
||||||
|
val historyHelper = HistoryHelper(historyBefore(round + 1)) {
|
||||||
|
if (pairing.type == PairingType.SWISS) wins
|
||||||
|
else pairables.mapValues {
|
||||||
|
it.value.let {
|
||||||
|
pairable ->
|
||||||
|
mmBase(pairable) +
|
||||||
|
(nbW(pairable) ?: 0.0) + // TODO take tournament parameter into account
|
||||||
|
(1..round).map { round ->
|
||||||
|
if (playersPerRound.getOrNull(round - 1)?.contains(pairable.id) == true) 0 else 1
|
||||||
|
}.sum() * pairing.pairingParams.main.mmsValueAbsent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val neededCriteria = ArrayList(pairing.placementParams.criteria)
|
||||||
|
if (!neededCriteria.contains(Criterion.NBW)) neededCriteria.add(Criterion.NBW)
|
||||||
|
if (!neededCriteria.contains(Criterion.RATING)) neededCriteria.add(Criterion.RATING)
|
||||||
|
val criteria = neededCriteria.map { crit ->
|
||||||
|
crit.name to when (crit) {
|
||||||
|
Criterion.NONE -> StandingsHandler.nullMap
|
||||||
|
Criterion.CATEGORY -> StandingsHandler.nullMap
|
||||||
|
Criterion.RANK -> pairables.mapValues { it.value.rank }
|
||||||
|
Criterion.RATING -> pairables.mapValues { it.value.rating }
|
||||||
|
Criterion.NBW -> historyHelper.wins
|
||||||
|
Criterion.MMS -> historyHelper.mms
|
||||||
|
Criterion.STS -> StandingsHandler.nullMap
|
||||||
|
Criterion.CPS -> StandingsHandler.nullMap
|
||||||
|
|
||||||
|
Criterion.SOSW -> historyHelper.sos
|
||||||
|
Criterion.SOSWM1 -> historyHelper.sosm1
|
||||||
|
Criterion.SOSWM2 -> historyHelper.sosm2
|
||||||
|
Criterion.SODOSW -> historyHelper.sodos
|
||||||
|
Criterion.SOSOSW -> historyHelper.sosos
|
||||||
|
Criterion.CUSSW -> historyHelper.cumScore
|
||||||
|
Criterion.SOSM -> historyHelper.sos
|
||||||
|
Criterion.SOSMM1 -> historyHelper.sosm1
|
||||||
|
Criterion.SOSMM2 -> historyHelper.sosm2
|
||||||
|
Criterion.SODOSM -> historyHelper.sodos
|
||||||
|
Criterion.SOSOSM -> historyHelper.sosos
|
||||||
|
Criterion.CUSSM -> historyHelper.cumScore
|
||||||
|
|
||||||
|
Criterion.SOSTS -> StandingsHandler.nullMap
|
||||||
|
|
||||||
|
Criterion.EXT -> StandingsHandler.nullMap
|
||||||
|
Criterion.EXR -> StandingsHandler.nullMap
|
||||||
|
|
||||||
|
Criterion.SDC -> StandingsHandler.nullMap
|
||||||
|
Criterion.DC -> StandingsHandler.nullMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pairables = pairables.values.filter { it.final }.map { it.toMutableJson() }
|
||||||
|
pairables.forEach { player ->
|
||||||
|
for (crit in criteria) {
|
||||||
|
player[crit.first] = crit.second[player.getID()] ?: 0.0
|
||||||
|
}
|
||||||
|
player["results"] = Json.MutableArray(List(round) { "0=" })
|
||||||
|
}
|
||||||
|
val sortedPairables = pairables.sortedWith { left, right ->
|
||||||
|
for (crit in criteria) {
|
||||||
|
val lval = left.getDouble(crit.first) ?: 0.0
|
||||||
|
val rval = right.getDouble(crit.first) ?: 0.0
|
||||||
|
val cmp = lval.compareTo(rval)
|
||||||
|
if (cmp != 0) return@sortedWith -cmp
|
||||||
|
}
|
||||||
|
return@sortedWith 0
|
||||||
|
}.mapIndexed() { i, obj ->
|
||||||
|
obj.set("num", i+1)
|
||||||
|
}
|
||||||
|
var place = 1
|
||||||
|
sortedPairables.groupBy { p ->
|
||||||
|
Triple(p.getDouble(criteria[0].first) ?: 0.0, p.getDouble(criteria[1].first) ?: 0.0, criteria.getOrNull(2)?.let { p.getDouble(it.first) ?: 0.0 })
|
||||||
|
}.forEach {
|
||||||
|
it.value.forEach { p -> p["place"] = place }
|
||||||
|
place += it.value.size
|
||||||
|
}
|
||||||
|
return sortedPairables
|
||||||
|
}
|
@@ -4,6 +4,7 @@ import com.republicate.kson.Json
|
|||||||
import com.republicate.kson.toJsonArray
|
import com.republicate.kson.toJsonArray
|
||||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||||
import org.jeudego.pairgoth.model.Game
|
import org.jeudego.pairgoth.model.Game
|
||||||
|
import org.jeudego.pairgoth.model.PairingType
|
||||||
import org.jeudego.pairgoth.model.getID
|
import org.jeudego.pairgoth.model.getID
|
||||||
import org.jeudego.pairgoth.model.toID
|
import org.jeudego.pairgoth.model.toID
|
||||||
import org.jeudego.pairgoth.model.toJson
|
import org.jeudego.pairgoth.model.toJson
|
||||||
@@ -20,7 +21,7 @@ object PairingHandler: PairgothApiHandler {
|
|||||||
val playing = tournament.games(round).values.flatMap {
|
val playing = tournament.games(round).values.flatMap {
|
||||||
listOf(it.black, it.white)
|
listOf(it.black, it.white)
|
||||||
}.toSet()
|
}.toSet()
|
||||||
val unpairables = tournament.pairables.values.filter { !it.final || it.skip.contains(round) }.sortedByDescending { it.rating }.map { it.id }.toJsonArray()
|
val unpairables = tournament.pairables.values.filter { it.final && it.skip.contains(round) }.sortedByDescending { it.rating }.map { it.id }.toJsonArray()
|
||||||
val pairables = tournament.pairables.values.filter { it.final && !it.skip.contains(round) && !playing.contains(it.id) }.sortedByDescending { it.rating }.map { it.id }.toJsonArray()
|
val pairables = tournament.pairables.values.filter { it.final && !it.skip.contains(round) && !playing.contains(it.id) }.sortedByDescending { it.rating }.map { it.id }.toJsonArray()
|
||||||
val games = tournament.games(round).values.sortedBy {
|
val games = tournament.games(round).values.sortedBy {
|
||||||
if (it.table == 0) Int.MAX_VALUE else it.table
|
if (it.table == 0) Int.MAX_VALUE else it.table
|
||||||
@@ -68,6 +69,7 @@ object PairingHandler: PairgothApiHandler {
|
|||||||
// only allow last round (if players have not been paired in the last round, it *may* be possible to be more laxist...)
|
// only allow last round (if players have not been paired in the last round, it *may* be possible to be more laxist...)
|
||||||
if (round != tournament.lastRound()) badRequest("cannot edit pairings in other rounds but the last")
|
if (round != tournament.lastRound()) badRequest("cannot edit pairings in other rounds but the last")
|
||||||
val payload = getObjectPayload(request)
|
val payload = getObjectPayload(request)
|
||||||
|
if (payload.containsKey("id")) {
|
||||||
val gameId = payload.getInt("id") ?: badRequest("invalid game id")
|
val gameId = payload.getInt("id") ?: badRequest("invalid game id")
|
||||||
val game = tournament.games(round)[gameId] ?: badRequest("invalid game id")
|
val game = tournament.games(round)[gameId] ?: badRequest("invalid game id")
|
||||||
val playing = (tournament.games(round).values).filter { it.id != gameId }.flatMap {
|
val playing = (tournament.games(round).values).filter { it.id != gameId }.flatMap {
|
||||||
@@ -98,7 +100,36 @@ object PairingHandler: PairgothApiHandler {
|
|||||||
game.table = payload.getString("t")?.toIntOrNull() ?: badRequest("invalid table number")
|
game.table = payload.getString("t")?.toIntOrNull() ?: badRequest("invalid table number")
|
||||||
}
|
}
|
||||||
tournament.dispatchEvent(GameUpdated, Json.Object("round" to round, "game" to game.toJson()))
|
tournament.dispatchEvent(GameUpdated, Json.Object("round" to round, "game" to game.toJson()))
|
||||||
if (game.table != previousTable && tournament.renumberTables(round, game)) {
|
if (game.table != previousTable) {
|
||||||
|
val sortedPairables = tournament.getSortedPairables(round)
|
||||||
|
val sortedMap = sortedPairables.associateBy {
|
||||||
|
it.getID()!!
|
||||||
|
}
|
||||||
|
val changed = tournament.renumberTables(round, game) { game ->
|
||||||
|
val whitePosition = sortedMap[game.white]?.getInt("num") ?: Int.MIN_VALUE
|
||||||
|
val blackPosition = sortedMap[game.black]?.getInt("num") ?: Int.MIN_VALUE
|
||||||
|
(whitePosition + blackPosition)
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
val games = tournament.games(round).values.sortedBy {
|
||||||
|
if (it.table == 0) Int.MAX_VALUE else it.table
|
||||||
|
}
|
||||||
|
tournament.dispatchEvent(TablesRenumbered, Json.Object("round" to round, "games" to games.map { it.toJson() }.toCollection(Json.MutableArray())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Json.Object("success" to true)
|
||||||
|
} else {
|
||||||
|
// without id, it's a table renumbering
|
||||||
|
val sortedPairables = tournament.getSortedPairables(round)
|
||||||
|
val sortedMap = sortedPairables.associateBy {
|
||||||
|
it.getID()!!
|
||||||
|
}
|
||||||
|
val changed = tournament.renumberTables(round, null) { game ->
|
||||||
|
val whitePosition = sortedMap[game.white]?.getInt("num") ?: Int.MIN_VALUE
|
||||||
|
val blackPosition = sortedMap[game.black]?.getInt("num") ?: Int.MIN_VALUE
|
||||||
|
(whitePosition + blackPosition)
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
val games = tournament.games(round).values.sortedBy {
|
val games = tournament.games(round).values.sortedBy {
|
||||||
if (it.table == 0) Int.MAX_VALUE else it.table
|
if (it.table == 0) Int.MAX_VALUE else it.table
|
||||||
}
|
}
|
||||||
@@ -106,6 +137,7 @@ object PairingHandler: PairgothApiHandler {
|
|||||||
}
|
}
|
||||||
return Json.Object("success" to true)
|
return Json.Object("success" to true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun delete(request: HttpServletRequest): Json {
|
override fun delete(request: HttpServletRequest): Json {
|
||||||
val tournament = getTournament(request)
|
val tournament = getTournament(request)
|
||||||
|
@@ -30,88 +30,11 @@ object StandingsHandler: PairgothApiHandler {
|
|||||||
val tournament = getTournament(request)
|
val tournament = getTournament(request)
|
||||||
val round = getSubSelector(request)?.toIntOrNull() ?: ApiHandler.badRequest("invalid round number")
|
val round = getSubSelector(request)?.toIntOrNull() ?: ApiHandler.badRequest("invalid round number")
|
||||||
|
|
||||||
fun mmBase(pairable: Pairable): Double {
|
val sortedPairables = tournament.getSortedPairables(round)
|
||||||
if (tournament.pairing !is MacMahon) throw Error("invalid call: tournament is not Mac Mahon")
|
|
||||||
return min(max(pairable.rank, tournament.pairing.mmFloor), tournament.pairing.mmBar) + MacMahonSolver.mmsZero + pairable.mmsCorrection
|
|
||||||
}
|
|
||||||
|
|
||||||
// CB avoid code redundancy with solvers
|
|
||||||
val historyHelper = HistoryHelper(tournament.historyBefore(round + 1)) {
|
|
||||||
if (tournament.pairing.type == PairingType.SWISS) wins
|
|
||||||
else tournament.pairables.mapValues {
|
|
||||||
it.value.let {
|
|
||||||
pairable ->
|
|
||||||
mmBase(pairable) +
|
|
||||||
(nbW(pairable) ?: 0.0) + // TODO take tournament parameter into account
|
|
||||||
(1..round).map { round ->
|
|
||||||
if (playersPerRound.getOrNull(round - 1)?.contains(pairable.id) == true) 0 else 1
|
|
||||||
}.sum() * tournament.pairing.pairingParams.main.mmsValueAbsent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val neededCriteria = ArrayList(tournament.pairing.placementParams.criteria)
|
|
||||||
if (!neededCriteria.contains(NBW)) neededCriteria.add(NBW)
|
|
||||||
val criteria = neededCriteria.map { crit ->
|
|
||||||
crit.name to when (crit) {
|
|
||||||
NONE -> nullMap
|
|
||||||
CATEGORY -> nullMap
|
|
||||||
RANK -> tournament.pairables.mapValues { it.value.rank }
|
|
||||||
RATING -> tournament.pairables.mapValues { it.value.rating }
|
|
||||||
NBW -> historyHelper.wins
|
|
||||||
MMS -> historyHelper.mms
|
|
||||||
STS -> nullMap
|
|
||||||
CPS -> nullMap
|
|
||||||
|
|
||||||
SOSW -> historyHelper.sos
|
|
||||||
SOSWM1 -> historyHelper.sosm1
|
|
||||||
SOSWM2 -> historyHelper.sosm2
|
|
||||||
SODOSW -> historyHelper.sodos
|
|
||||||
SOSOSW -> historyHelper.sosos
|
|
||||||
CUSSW -> historyHelper.cumScore
|
|
||||||
SOSM -> historyHelper.sos
|
|
||||||
SOSMM1 -> historyHelper.sosm1
|
|
||||||
SOSMM2 -> historyHelper.sosm2
|
|
||||||
SODOSM -> historyHelper.sodos
|
|
||||||
SOSOSM -> historyHelper.sosos
|
|
||||||
CUSSM -> historyHelper.cumScore
|
|
||||||
|
|
||||||
SOSTS -> nullMap
|
|
||||||
|
|
||||||
EXT -> nullMap
|
|
||||||
EXR -> nullMap
|
|
||||||
|
|
||||||
SDC -> nullMap
|
|
||||||
DC -> nullMap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val pairables = tournament.pairables.values.filter { it.final }.map { it.toMutableJson() }
|
|
||||||
pairables.forEach { player ->
|
|
||||||
for (crit in criteria) {
|
|
||||||
player[crit.first] = crit.second[player.getID()] ?: 0.0
|
|
||||||
}
|
|
||||||
player["results"] = Json.MutableArray(List(round) { "0=" })
|
|
||||||
}
|
|
||||||
val sortedPairables = pairables.sortedWith { left, right ->
|
|
||||||
for (crit in criteria) {
|
|
||||||
val lval = left.getDouble(crit.first) ?: 0.0
|
|
||||||
val rval = right.getDouble(crit.first) ?: 0.0
|
|
||||||
val cmp = lval.compareTo(rval)
|
|
||||||
if (cmp != 0) return@sortedWith -cmp
|
|
||||||
}
|
|
||||||
return@sortedWith 0
|
|
||||||
}.mapIndexed() { i, obj ->
|
|
||||||
obj.set("num", i+1)
|
|
||||||
}
|
|
||||||
val sortedMap = sortedPairables.associateBy {
|
val sortedMap = sortedPairables.associateBy {
|
||||||
it.getID()!!
|
it.getID()!!
|
||||||
}
|
}
|
||||||
var place = 1
|
|
||||||
sortedPairables.groupBy { p ->
|
|
||||||
Triple(p.getDouble(criteria[0].first) ?: 0.0, p.getDouble(criteria[1].first) ?: 0.0, p.getDouble(criteria[2].first) ?: 0.0)
|
|
||||||
}.forEach {
|
|
||||||
it.value.forEach { p -> p["place"] = place }
|
|
||||||
place += it.value.size
|
|
||||||
}
|
|
||||||
for (r in 1..round) {
|
for (r in 1..round) {
|
||||||
tournament.games(r).values.forEach { game ->
|
tournament.games(r).values.forEach { game ->
|
||||||
val white = if (game.white != 0) sortedMap[game.white] else null
|
val white = if (game.white != 0) sortedMap[game.white] else null
|
||||||
@@ -152,6 +75,8 @@ object StandingsHandler: PairgothApiHandler {
|
|||||||
return when(accept) {
|
return when(accept) {
|
||||||
"application/json" -> sortedPairables.toJsonArray()
|
"application/json" -> sortedPairables.toJsonArray()
|
||||||
"application/egf" -> {
|
"application/egf" -> {
|
||||||
|
val neededCriteria = ArrayList(tournament.pairing.placementParams.criteria)
|
||||||
|
if (!neededCriteria.contains(NBW)) neededCriteria.add(NBW)
|
||||||
exportToEGFFormat(tournament, sortedPairables, neededCriteria, response.writer)
|
exportToEGFFormat(tournament, sortedPairables, neededCriteria, response.writer)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
@@ -6,12 +6,10 @@ import com.republicate.kson.toJsonArray
|
|||||||
//import kotlinx.datetime.LocalDate
|
//import kotlinx.datetime.LocalDate
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||||
import org.jeudego.pairgoth.pairing.HistoryHelper
|
|
||||||
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
|
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
|
||||||
import org.jeudego.pairgoth.pairing.solver.SwissSolver
|
import org.jeudego.pairgoth.pairing.solver.SwissSolver
|
||||||
import org.jeudego.pairgoth.store.Store
|
import org.jeudego.pairgoth.store.Store
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -98,14 +96,16 @@ sealed class Tournament <P: Pairable>(
|
|||||||
acc
|
acc
|
||||||
}
|
}
|
||||||
|
|
||||||
fun renumberTables(round: Int, pivot: Game? = null): Boolean {
|
private fun defaultGameOrderBy(game: Game): Int {
|
||||||
var changed = false
|
|
||||||
var nextTable = 1
|
|
||||||
games(round).values.filter{ game -> pivot?.let { pivot.id != game.id } ?: true }.sortedBy { game ->
|
|
||||||
val whiteRank = pairables[game.white]?.rating ?: Int.MIN_VALUE
|
val whiteRank = pairables[game.white]?.rating ?: Int.MIN_VALUE
|
||||||
val blackRank = pairables[game.black]?.rating ?: Int.MIN_VALUE
|
val blackRank = pairables[game.black]?.rating ?: Int.MIN_VALUE
|
||||||
-(2 * whiteRank + 2 * blackRank) / 2
|
return -(whiteRank + blackRank)
|
||||||
}.forEach { game ->
|
}
|
||||||
|
|
||||||
|
fun renumberTables(round: Int, pivot: Game? = null, orderBY: (Game) -> Int = ::defaultGameOrderBy): Boolean {
|
||||||
|
var changed = false
|
||||||
|
var nextTable = 1
|
||||||
|
games(round).values.filter{ game -> pivot?.let { pivot.id != game.id } ?: true }.sortedBy(orderBY).forEach { game ->
|
||||||
if (pivot != null && nextTable == pivot.table) {
|
if (pivot != null && nextTable == pivot.table) {
|
||||||
++nextTable
|
++nextTable
|
||||||
}
|
}
|
||||||
|
@@ -85,6 +85,7 @@ Register Inscrire
|
|||||||
Registration Inscriptions
|
Registration Inscriptions
|
||||||
Rengo with 2 players teams Rengo par équipes de 2
|
Rengo with 2 players teams Rengo par équipes de 2
|
||||||
Rengo with 3 players team Rengo par équipes de 3
|
Rengo with 3 players team Rengo par équipes de 3
|
||||||
|
Renumber Renuméroter
|
||||||
Required field Champs requis
|
Required field Champs requis
|
||||||
Reset Mac Mahon groups Réinitialiser les groupes Mac Mahon
|
Reset Mac Mahon groups Réinitialiser les groupes Mac Mahon
|
||||||
Results Résultats
|
Results Résultats
|
||||||
|
@@ -18,6 +18,15 @@ function unpair(games) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renumberTables() {
|
||||||
|
api.putJson(`tour/${tour_id}/pair/${activeRound}`, {})
|
||||||
|
.then(rst => {
|
||||||
|
if (rst !== 'error') {
|
||||||
|
document.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function editGame(game) {
|
function editGame(game) {
|
||||||
let t = game.find('.table');
|
let t = game.find('.table');
|
||||||
let w = game.find('.white');
|
let w = game.find('.white');
|
||||||
@@ -115,6 +124,9 @@ onLoad(()=>{
|
|||||||
}
|
}
|
||||||
unpair(games);
|
unpair(games);
|
||||||
});
|
});
|
||||||
|
$('#renumber-tables').on('click', e => {
|
||||||
|
renumberTables();
|
||||||
|
});
|
||||||
$('#pairing-form [name]').on('input', e => {
|
$('#pairing-form [name]').on('input', e => {
|
||||||
$('#update-pairing').removeClass('disabled');
|
$('#update-pairing').removeClass('disabled');
|
||||||
});
|
});
|
||||||
|
@@ -47,6 +47,10 @@
|
|||||||
<i class="angle double left icon"></i>
|
<i class="angle double left icon"></i>
|
||||||
Unpair
|
Unpair
|
||||||
</button>
|
</button>
|
||||||
|
<button id="renumber-tables" class="ui right labeled icon floating button">
|
||||||
|
<i class="sync alternate icon"></i>
|
||||||
|
Renumber
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="paired" class="multi-select" title="white vs. black">
|
<div id="paired" class="multi-select" title="white vs. black">
|
||||||
#foreach($game in $games)
|
#foreach($game in $games)
|
||||||
|
Reference in New Issue
Block a user