Merge from master

This commit is contained in:
Claude Brisson
2024-01-23 19:41:13 +01:00
41 changed files with 836 additions and 193 deletions

View File

@@ -13,7 +13,7 @@ interface ApiHandler {
when (request.method) {
"GET" -> get(request, response)
"POST" -> post(request)
"PUT" -> put(request)
"PUT" -> put(request)
"DELETE" -> delete(request)
else -> notImplemented()
}

View File

@@ -16,7 +16,7 @@ interface PairgothApiHandler: ApiHandler {
fun Tournament<*>.dispatchEvent(event: Event, data: Json? = null) {
Event.dispatch(event, Json.Object("tournament" to id, "data" to data))
// when storage is not in memory, the tournament has to be persisted
if (event != Event.tournamentAdded && event != Event.tournamentDeleted && event != Event.gameUpdated)
if (event != Event.TournamentAdded && event != Event.TournamentDeleted)
Store.replaceTournament(this)
}

View File

@@ -7,7 +7,6 @@ import org.jeudego.pairgoth.model.Game
import org.jeudego.pairgoth.model.getID
import org.jeudego.pairgoth.model.toID
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.server.Event
import org.jeudego.pairgoth.server.Event.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@@ -21,9 +20,11 @@ object PairingHandler: PairgothApiHandler {
val playing = tournament.games(round).values.flatMap {
listOf(it.black, it.white)
}.toSet()
val unpairables = tournament.pairables.values.filter { it.skip.contains(round) }.sortedByDescending { it.rating }.map { it.id }.toJsonArray()
val pairables = tournament.pairables.values.filter { !it.skip.contains(round) && !playing.contains(it.id) }.sortedByDescending { it.rating }.map { it.id }.toJsonArray()
val games = tournament.games(round).values
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 games = tournament.games(round).values.sortedBy {
if (it.table == 0) Int.MAX_VALUE else it.table
}
return Json.Object(
"games" to games.map { it.toJson() }.toCollection(Json.MutableArray()),
"pairables" to pairables,
@@ -44,19 +45,20 @@ object PairingHandler: PairgothApiHandler {
}.toSet()
val pairables =
if (allPlayers)
tournament.pairables.values.filter { !it.skip.contains(round) && !playing.contains(it.id) }
tournament.pairables.values.filter { it.final && !it.skip.contains(round) && !playing.contains(it.id) }
else payload.map {
// CB - because of the '["all"]' map, conversion to int lands here... Better API syntax for 'all players'?
if (it is Number) it.toID() else badRequest("invalid pairable id: #$it")
}.map { id ->
tournament.pairables[id]?.also {
if (!it.final) badRequest("pairable #$id registration status is not final")
if (it.skip.contains(round)) badRequest("pairable #$id does not play round $round")
if (playing.contains(it.id)) badRequest("pairable #$id already plays round $round")
} ?: badRequest("invalid pairable id: #$id")
}
val games = tournament.pair(round, pairables)
val ret = games.map { it.toJson() }.toJsonArray()
tournament.dispatchEvent(gamesAdded, Json.Object("round" to round, "games" to ret))
tournament.dispatchEvent(GamesAdded, Json.Object("round" to round, "games" to ret))
return ret
}
@@ -71,20 +73,37 @@ object PairingHandler: PairgothApiHandler {
val playing = (tournament.games(round).values).filter { it.id != gameId }.flatMap {
listOf(it.black, it.white)
}.toSet()
if (game.result != Game.Result.UNKNOWN && (
game.black != payload.getInt("b") ||
game.white != payload.getInt("w") ||
game.handicap != payload.getInt("h")
)) badRequest("Game already has a result")
game.black = payload.getID("b") ?: badRequest("missing black player id")
game.white = payload.getID("w") ?: badRequest("missing white player id")
tournament.recomputeHdAndDUDD(round, game.id)
val previousTable = game.table;
// temporary
//payload.getInt("dudd")?.let { game.drawnUpDown = it }
val black = tournament.pairables[game.black] ?: badRequest("invalid black player id")
val white = tournament.pairables[game.black] ?: badRequest("invalid white player id")
if (!black.final) badRequest("black registration status is not final")
if (!white.final) badRequest("white registration status is not final")
if (black.skip.contains(round)) badRequest("black is not playing this round")
if (white.skip.contains(round)) badRequest("white is not playing this round")
if (playing.contains(black.id)) badRequest("black is already in another game")
if (playing.contains(white.id)) badRequest("white is already in another game")
if (payload.containsKey("h")) game.handicap = payload.getString("h")?.toIntOrNull() ?: badRequest("invalid handicap")
tournament.dispatchEvent(gameUpdated, Json.Object("round" to round, "game" to game.toJson()))
if (payload.containsKey("t")) {
game.table = payload.getString("t")?.toIntOrNull() ?: badRequest("invalid table number")
}
tournament.dispatchEvent(GameUpdated, Json.Object("round" to round, "game" to game.toJson()))
if (game.table != previousTable && tournament.renumberTables(round, game)) {
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)
}
@@ -102,7 +121,7 @@ object PairingHandler: PairgothApiHandler {
payload.forEach {
val id = (it as Number).toInt()
val game = tournament.games(round)[id] ?: throw Error("invalid game id")
if (game.result != Game.Result.UNKNOWN) {
if (game.result != Game.Result.UNKNOWN && game.black != 0 && game.white != 0) {
ApiHandler.logger.error("cannot unpair game id ${game.id}: it has a result")
// we'll only skip it
// throw Error("cannot unpair ")
@@ -111,7 +130,7 @@ object PairingHandler: PairgothApiHandler {
}
}
}
tournament.dispatchEvent(gamesDeleted, Json.Object("round" to round, "games" to payload))
tournament.dispatchEvent(GamesDeleted, Json.Object("round" to round, "games" to payload))
return Json.Object("success" to true)
}
}

View File

@@ -5,7 +5,6 @@ import com.republicate.kson.toJsonArray
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.Player
import org.jeudego.pairgoth.model.fromJson
import org.jeudego.pairgoth.server.Event
import org.jeudego.pairgoth.server.Event.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@@ -25,7 +24,7 @@ object PlayerHandler: PairgothApiHandler {
val payload = getObjectPayload(request)
val player = Player.fromJson(payload)
tournament.players[player.id] = player
tournament.dispatchEvent(playerAdded, player.toJson())
tournament.dispatchEvent(PlayerAdded, player.toJson())
return Json.Object("success" to true, "id" to player.id)
}
@@ -36,7 +35,7 @@ object PlayerHandler: PairgothApiHandler {
val payload = getObjectPayload(request)
val updated = Player.fromJson(payload, player)
tournament.players[updated.id] = updated
tournament.dispatchEvent(playerUpdated, player.toJson())
tournament.dispatchEvent(PlayerUpdated, player.toJson())
return Json.Object("success" to true)
}
@@ -44,7 +43,7 @@ object PlayerHandler: PairgothApiHandler {
val tournament = getTournament(request)
val id = getSubSelector(request)?.toIntOrNull() ?: badRequest("missing or invalid player selector")
tournament.players.remove(id) ?: badRequest("invalid player id")
tournament.dispatchEvent(playerDeleted, Json.Object("id" to id))
tournament.dispatchEvent(PlayerDeleted, Json.Object("id" to id))
return Json.Object("success" to true)
}
}

View File

@@ -24,7 +24,7 @@ object ResultsHandler: PairgothApiHandler {
val payload = getObjectPayload(request)
val game = tournament.games(round)[payload.getInt("id")] ?: badRequest("invalid game id")
game.result = Game.Result.fromSymbol(payload.getChar("result") ?: badRequest("missing result"))
tournament.dispatchEvent(Event.resultUpdated, Json.Object("round" to round, "data" to game))
tournament.dispatchEvent(Event.ResultUpdated, Json.Object("round" to round, "data" to game))
return Json.Object("success" to true)
}
}

View File

@@ -3,21 +3,27 @@ package org.jeudego.pairgoth.api
import com.republicate.kson.Json
import com.republicate.kson.toJsonArray
import org.jeudego.pairgoth.model.Criterion
import org.jeudego.pairgoth.model.Criterion.*
import org.jeudego.pairgoth.model.Game.Result.*
import org.jeudego.pairgoth.model.ID
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.adjustedTime
import org.jeudego.pairgoth.model.displayRank
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 java.io.PrintWriter
import java.time.format.DateTimeFormatter
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import kotlin.math.max
import kotlin.math.min
import org.jeudego.pairgoth.model.Criterion.*
import org.jeudego.pairgoth.model.Game.Result.*
import org.jeudego.pairgoth.model.ID
import org.jeudego.pairgoth.model.getID
import org.jeudego.pairgoth.model.TimeSystem.TimeSystemType.*
import java.text.DecimalFormat
object StandingsHandler: PairgothApiHandler {
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
@@ -78,12 +84,12 @@ object StandingsHandler: PairgothApiHandler {
DC -> nullMap
}
}
val pairables = tournament.pairables.values.map { it.toMutableJson() }
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" })
player["results"] = Json.MutableArray(List(round) { "0=" })
}
val sortedPairables = pairables.sortedWith { left, right ->
for (crit in criteria) {
@@ -114,38 +120,156 @@ object StandingsHandler: PairgothApiHandler {
val blackNum = black?.getInt("num") ?: 0
val whiteColor = if (black == null) "" else "w"
val blackColor = if (white == null) "" else "b"
val handicap = if (game.handicap == 0) "" else "/h${game.handicap}"
val handicap = if (game.handicap == 0) "" else "${game.handicap}"
assert(white != null || black != null)
if (white != null) {
val mark = when (game.result) {
UNKNOWN -> "?"
BLACK -> "-"
WHITE -> "+"
JIGO -> "="
CANCELLED -> "X"
BOTHWIN -> "++"
BOTHLOOSE -> "--"
BLACK, BOTHLOOSE -> "-"
WHITE, BOTHWIN -> "+"
JIGO, CANCELLED -> "="
}
val results = white.getArray("results") as Json.MutableArray
results[r - 1] = "$whiteColor$mark$blackNum$handicap"
results[r - 1] =
if (blackNum == 0) "0$mark"
else "$blackNum$mark/$whiteColor$handicap"
}
if (black != null) {
val mark = when (game.result) {
UNKNOWN -> "?"
BLACK -> "+"
WHITE -> "-"
JIGO -> "="
CANCELLED -> "X"
BOTHWIN -> "++"
BOTHLOOSE -> "--"
BLACK, BOTHWIN -> "+"
WHITE, BOTHLOOSE -> "-"
JIGO, CANCELLED -> "="
}
val results = black.getArray("results") as Json.MutableArray
results[r - 1] = "$blackColor$mark$whiteNum$handicap"
results[r - 1] =
if (whiteNum == 0) "0$mark"
else "$whiteNum$mark/$blackColor$handicap"
}
}
}
return sortedPairables.toJsonArray()
val accept = request.getHeader("Accept")?.substringBefore(";")
return when(accept) {
"application/json" -> sortedPairables.toJsonArray()
"application/egf" -> {
exportToEGFFormat(tournament, sortedPairables, neededCriteria, response.writer)
return null
}
"application/ffg" -> {
exportToFFGFormat(tournament, sortedPairables, response.writer)
return null
}
else -> ApiHandler.badRequest("invalid Accept header: $accept")
}
}
val nullMap = mapOf<ID, Double>()
private fun exportToEGFFormat(tournament: Tournament<*>, lines: List<Json.Object>, criteria: List<Criterion>, writer: PrintWriter) {
val mainTime = tournament.timeSystem.mainTime
val adjustedTime = tournament.timeSystem.adjustedTime()
val egfClass =
if (tournament.online) {
when (tournament.timeSystem.type) {
FISCHER ->
if (mainTime >= 1800 && adjustedTime >= 3000) "D"
else "X"
else ->
if (mainTime >= 2400 && adjustedTime >= 3000) "D"
else "X"
}
} else {
when (tournament.timeSystem.type) {
FISCHER ->
if (mainTime >= 2700 && adjustedTime >= 4500) "A"
else if (mainTime >= 1800 && adjustedTime >= 3000) "B"
else if (mainTime >= 1200 && adjustedTime >= 1800) "C"
else "X"
else ->
if (mainTime >= 3600 && adjustedTime >= 4500) "A"
else if (mainTime >= 2400 && adjustedTime >= 3000) "B"
else if (mainTime >= 1500 && adjustedTime >= 1800) "C"
else "X"
}
}
val ret =
"""
; CL[${egfClass}]
; EV[${tournament.name}]
; PC[${tournament.country.lowercase()},${tournament.location}]
; DT[${tournament.startDate},${tournament.endDate}]
; HA[${
if (tournament.pairing.type == PairingType.MAC_MAHON) "h${tournament.pairing.pairingParams.handicap.correction}"
else "h9"
}]
; KM[${tournament.komi}]
; TM[${tournament.timeSystem.adjustedTime() / 60}]
; CM[Generated by Pairgoth v0.1]
;
; Pl Name Rk Co Club ${ criteria.map { it.name.replace(Regex("(S?)O?(SOS|DOS)[MW]?"), "$1$2").padStart(7, ' ') }.joinToString(" ") }
${
lines.joinToString("\n") { player ->
"${
player.getString("num")!!.padStart(4, ' ')
} ${
"${player.getString("name")} ${player.getString("firstname")}".padEnd(30, ' ').take(30)
} ${
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
} ${
player.getString("country")!!.uppercase()
} ${
(player.getString("club") ?: "").padStart(4).take(4)
} ${
criteria.joinToString(" ") { numFormat.format(player.getDouble(it.name)!!).let { if (it.contains('.')) it else "$it " }.padStart(7, ' ') }
} ${
player.getArray("results")!!.map {
(it as String).padStart(8, ' ')
}.joinToString(" ")
}"
}
}
"""
writer.println(ret)
}
private fun exportToFFGFormat(tournament: Tournament<*>, lines: List<Json.Object>, writer: PrintWriter) {
// let's try in UTF-8
val ret =
""";name=${tournament.shortName}
;date=${frDate.format(tournament.startDate)}
;vill=${tournament.location}${if (tournament.online) "(online)" else ""}
;comm=${tournament.name}
;prog=Pairgoth v0.1
;time=${tournament.timeSystem.mainTime / 60}
;ta=${tournament.timeSystem.adjustedTime() / 60}
;size=${tournament.gobanSize}
;komi=${tournament.komi}
;
;Num Nom Prenom Niv Licence Club
${
lines.joinToString("\n") { player ->
"${
player.getString("num")!!.padStart(4, ' ')
} ${
"${player.getString("name")} ${player.getString("firstname")}".padEnd(24, ' ').take(24)
} ${
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
} ${
player.getString("ffg") ?: " "
} ${
(player.getString("club") ?: "").padStart(6).take(6)
} ${
player.getArray("results")!!.joinToString(" ") {
(it as String).replace("/", "").replace(Regex("(?<=[bw])$"), "0").padStart(7, ' ')
}
}"
}
}
"""
writer.println(ret)
}
private val numFormat = DecimalFormat("###0.#")
private val frDate: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
}

View File

@@ -4,7 +4,6 @@ import com.republicate.kson.Json
import com.republicate.kson.toJsonArray
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.TeamTournament
import org.jeudego.pairgoth.server.Event
import org.jeudego.pairgoth.server.Event.*
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@@ -26,7 +25,7 @@ object TeamHandler: PairgothApiHandler {
val payload = getObjectPayload(request)
val team = tournament.teamFromJson(payload)
tournament.teams[team.id] = team
tournament.dispatchEvent(teamAdded, team.toJson())
tournament.dispatchEvent(TeamAdded, team.toJson())
return Json.Object("success" to true, "id" to team.id)
}
@@ -38,7 +37,7 @@ object TeamHandler: PairgothApiHandler {
val payload = getObjectPayload(request)
val updated = tournament.teamFromJson(payload, team)
tournament.teams[updated.id] = updated
tournament.dispatchEvent(teamUpdated, team.toJson())
tournament.dispatchEvent(TeamUpdated, team.toJson())
return Json.Object("success" to true)
}
@@ -47,7 +46,7 @@ object TeamHandler: PairgothApiHandler {
if (tournament !is TeamTournament) badRequest("tournament is not a team tournament")
val id = getSubSelector(request)?.toIntOrNull() ?: badRequest("missing or invalid team selector")
tournament.teams.remove(id) ?: badRequest("invalid team id")
tournament.dispatchEvent(teamDeleted, Json.Object("id" to id))
tournament.dispatchEvent(TeamDeleted, Json.Object("id" to id))
return Json.Object("success" to true)
}
}

View File

@@ -11,7 +11,6 @@ import org.jeudego.pairgoth.model.fromJson
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.store.Store
import org.jeudego.pairgoth.server.ApiServlet
import org.jeudego.pairgoth.server.Event
import org.jeudego.pairgoth.server.Event.*
import org.w3c.dom.Element
import javax.servlet.http.HttpServletRequest
@@ -44,7 +43,7 @@ object TournamentHandler: PairgothApiHandler {
else -> badRequest("missing or invalid payload")
}
Store.addTournament(tournament)
tournament.dispatchEvent(tournamentAdded, tournament.toJson())
tournament.dispatchEvent(TournamentAdded, tournament.toJson())
return Json.Object("success" to true, "id" to tournament.id)
}
@@ -64,14 +63,14 @@ object TournamentHandler: PairgothApiHandler {
clear()
putAll(tournament.games(round))
}
updated.dispatchEvent(tournamentUpdated, updated.toJson())
updated.dispatchEvent(TournamentUpdated, updated.toJson())
return Json.Object("success" to true)
}
override fun delete(request: HttpServletRequest): Json {
val tournament = getTournament(request)
Store.deleteTournament(tournament)
tournament.dispatchEvent(tournamentDeleted, Json.Object("id" to tournament.id))
tournament.dispatchEvent(TournamentDeleted, Json.Object("id" to tournament.id))
return Json.Object("success" to true)
}
}

View File

@@ -2,7 +2,7 @@ package org.jeudego.pairgoth.ext
import jakarta.xml.bind.JAXBContext
import jakarta.xml.bind.JAXBElement
import kotlinx.datetime.LocalDate
import java.time.LocalDate
import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.opengotha.TournamentType
import org.jeudego.pairgoth.opengotha.ObjectFactory
@@ -13,7 +13,7 @@ import javax.xml.datatype.XMLGregorianCalendar
import kotlin.math.roundToInt
private const val MILLISECONDS_PER_DAY = 86400000
fun XMLGregorianCalendar.toLocalDate() = LocalDate(year, month, day)
fun XMLGregorianCalendar.toLocalDate() = LocalDate.of(year, month, day)
object OpenGotha {
@@ -114,10 +114,10 @@ object OpenGotha {
location = genParams.location,
online = genParams.isBInternet ?: false,
timeSystem = when (genParams.complementaryTimeSystem) {
"SUDDENDEATH" -> SuddenDeath(genParams.basicTime)
"STDBYOYOMI" -> StandardByoyomi(genParams.basicTime, genParams.stdByoYomiTime, 1) // no periods?
"CANBYOYOMI" -> CanadianByoyomi(genParams.basicTime, genParams.canByoYomiTime, genParams.nbMovesCanTime)
"FISCHER" -> FischerTime(genParams.basicTime, genParams.fischerTime)
"SUDDENDEATH" -> SuddenDeath(genParams.basicTime * 60)
"STDBYOYOMI" -> StandardByoyomi(genParams.basicTime * 60, genParams.stdByoYomiTime, 1) // no periods?
"CANBYOYOMI" -> CanadianByoyomi(genParams.basicTime * 60, genParams.canByoYomiTime, genParams.nbMovesCanTime)
"FISCHER" -> FischerTime(genParams.basicTime * 60, genParams.fischerTime)
else -> throw Error("missing byoyomi type")
},
pairing = when (handParams.hdCeiling) {
@@ -145,7 +145,8 @@ object OpenGotha {
rating = player.rating,
rank = Pairable.parseRank(player.rank),
country = player.country,
club = player.club
club = player.club,
final = "FIN" == player.registeringStatus
).also {
player.participating.toString().forEachIndexed { i,c ->
if (c == '0') it.skip.add(i + 1)
@@ -215,7 +216,9 @@ object OpenGotha {
player.displayRank()
}" rating="${
player.rating
}" ratingOrigin="" registeringStatus="FIN" smmsCorrection="0"/>"""
}" ratingOrigin="" registeringStatus="${
if (player.final) "FIN" else "PRE"
}" smmsCorrection="0"/>"""
}
}
</Players>
@@ -269,9 +272,9 @@ object OpenGotha {
}
</ByePlayer>
<TournamentParameterSet>
<GeneralParameterSet bInternet="${tournament.online}" basicTime="${tournament.timeSystem.mainTime}" beginDate="${tournament.startDate}" canByoYomiTime="${tournament.timeSystem.byoyomi}" complementaryTimeSystem="${when(tournament.timeSystem.type) {
<GeneralParameterSet bInternet="${tournament.online}" basicTime="${tournament.timeSystem.mainTime / 60}" beginDate="${tournament.startDate}" canByoYomiTime="${tournament.timeSystem.byoyomi}" complementaryTimeSystem="${when(tournament.timeSystem.type) {
TimeSystem.TimeSystemType.SUDDEN_DEATH -> "SUDDENDEATH"
TimeSystem.TimeSystemType.STANDARD -> "STDBYOYOMI"
TimeSystem.TimeSystemType.JAPANESE -> "STDBYOYOMI"
TimeSystem.TimeSystemType.CANADIAN -> "CANBYOYOMI"
TimeSystem.TimeSystemType.FISCHER -> "FISCHER"
} }" director="" endDate="${tournament.endDate}" fischerTime="${tournament.timeSystem.increment}" genCountNotPlayedGamesAsHalfPoint="false" genMMBar="${

View File

@@ -7,10 +7,10 @@ import java.util.*
// Pairable
sealed class Pairable(val id: ID, val name: String, open val rating: Int, open val rank: Int) {
sealed class Pairable(val id: ID, val name: String, open val rating: Int, open val rank: Int, val final: Boolean, val mmsCorrection: Int = 0) {
companion object {
val MIN_RANK: Int = -30 // 30k
val MAX_RANK: Int = 8 // 9D
const val MIN_RANK: Int = -30 // 30k
const val MAX_RANK: Int = 8 // 9D
}
abstract fun toJson(): Json.Object
abstract fun toMutableJson(): Json.MutableObject
@@ -26,7 +26,7 @@ sealed class Pairable(val id: ID, val name: String, open val rating: Int, open v
}
}
object ByePlayer: Pairable(0, "bye", 0, Int.MIN_VALUE) {
object ByePlayer: Pairable(0, "bye", 0, Int.MIN_VALUE, true) {
override fun toJson(): Json.Object {
throw Error("bye player should never be serialized")
}
@@ -70,8 +70,10 @@ class Player(
rating: Int,
rank: Int,
override var country: String,
override var club: String
): Pairable(id, name, rating, rank) {
override var club: String,
final: Boolean,
mmsCorrection: Int = 0
): Pairable(id, name, rating, rank, final, mmsCorrection) {
companion object
// used to store external IDs ("FFG" => FFG ID, "EGF" => EGF PIN, "AGA" => AGA ID ...)
val externalIds = mutableMapOf<DatabaseId, String>()
@@ -82,9 +84,11 @@ class Player(
"rating" to rating,
"rank" to rank,
"country" to country,
"club" to club
"club" to club,
"final" to final
).also { json ->
if (skip.isNotEmpty()) json["skip"] = Json.Array(skip)
if (mmsCorrection != 0) json["mmsCorrection"] = mmsCorrection
externalIds.forEach { (dbid, id) ->
json[dbid.key] = id
}
@@ -103,7 +107,9 @@ fun Player.Companion.fromJson(json: Json.Object, default: Player? = null) = Play
rating = json.getInt("rating") ?: default?.rating ?: badRequest("missing rating"),
rank = json.getInt("rank") ?: default?.rank ?: badRequest("missing rank"),
country = json.getString("country") ?: default?.country ?: badRequest("missing country"),
club = json.getString("club") ?: default?.club ?: badRequest("missing club")
club = json.getString("club") ?: default?.club ?: badRequest("missing club"),
final = json.getBoolean("final") ?: default?.final ?: true,
mmsCorrection = json.getInt("mmsCorrection") ?: default?.mmsCorrection ?: 0
).also { player ->
player.skip.clear()
json.getArray("skip")?.let {

View File

@@ -6,6 +6,7 @@ import org.jeudego.pairgoth.model.MainCritParams.SeedMethod.SPLIT_AND_SLIP
import org.jeudego.pairgoth.model.PairingType.*
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
import org.jeudego.pairgoth.pairing.solver.SwissSolver
import java.util.*
// base pairing parameters
data class BaseCritParams(
@@ -172,7 +173,7 @@ class Swiss(
): Pairing(SWISS, pairingParams, placementParams) {
companion object {}
override fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> {
return SwissSolver(round, tournament.historyBefore(round), pairables, pairingParams, placementParams).pair()
return SwissSolver(round, tournament.historyBefore(round), pairables, pairingParams, placementParams, tournament.usedTables(round)).pair()
}
}
@@ -201,7 +202,7 @@ class MacMahon(
): Pairing(MAC_MAHON, pairingParams, placementParams) {
companion object {}
override fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> {
return MacMahonSolver(round, tournament.historyBefore(round), pairables, pairingParams, placementParams, mmFloor, mmBar).pair()
return MacMahonSolver(round, tournament.historyBefore(round), pairables, pairingParams, placementParams, tournament.usedTables(round), mmFloor, mmBar).pair()
}
}

View File

@@ -1,7 +1,6 @@
package org.jeudego.pairgoth.model
import com.republicate.kson.Json
import org.jeudego.pairgoth.api.ApiHandler
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.TimeSystem.TimeSystemType.*
@@ -15,7 +14,7 @@ data class TimeSystem(
val stones: Int
) {
companion object {}
enum class TimeSystemType { CANADIAN, STANDARD, FISCHER, SUDDEN_DEATH }
enum class TimeSystemType { CANADIAN, JAPANESE, FISCHER, SUDDEN_DEATH }
}
fun CanadianByoyomi(mainTime: Int, byoyomi: Int, stones: Int) =
@@ -30,7 +29,7 @@ fun CanadianByoyomi(mainTime: Int, byoyomi: Int, stones: Int) =
fun StandardByoyomi(mainTime: Int, byoyomi: Int, periods: Int) =
TimeSystem(
type = STANDARD,
type = JAPANESE,
mainTime = mainTime,
increment = 0,
byoyomi = byoyomi,
@@ -86,9 +85,16 @@ fun TimeSystem.Companion.fromJson(json: Json.Object) =
fun TimeSystem.toJson() = when (type) {
TimeSystem.TimeSystemType.CANADIAN -> Json.Object("type" to type.name, "mainTime" to mainTime, "byoyomi" to byoyomi, "stones" to stones)
TimeSystem.TimeSystemType.STANDARD -> Json.Object("type" to type.name, "mainTime" to mainTime, "byoyomi" to byoyomi, "periods" to periods)
TimeSystem.TimeSystemType.JAPANESE -> Json.Object("type" to type.name, "mainTime" to mainTime, "byoyomi" to byoyomi, "periods" to periods)
TimeSystem.TimeSystemType.FISCHER ->
if (maxTime == Int.MAX_VALUE) Json.Object("type" to type.name, "mainTime" to mainTime, "increment" to increment)
else Json.Object("type" to type.name, "mainTime" to mainTime, "increment" to increment, "maxTime" to maxTime)
TimeSystem.TimeSystemType.SUDDEN_DEATH -> Json.Object("type" to type.name, "mainTime" to mainTime)
}
fun TimeSystem.adjustedTime() = when (type) {
TimeSystem.TimeSystemType.CANADIAN -> mainTime + 60 * byoyomi / stones
TimeSystem.TimeSystemType.JAPANESE -> mainTime + 45 * byoyomi
TimeSystem.TimeSystemType.FISCHER -> mainTime + 120 * increment
TimeSystem.TimeSystemType.SUDDEN_DEATH -> mainTime
}

View File

@@ -2,7 +2,9 @@ package org.jeudego.pairgoth.model
import com.republicate.kson.Json
import com.republicate.kson.toJsonArray
import kotlinx.datetime.LocalDate
// CB TODO - review
//import kotlinx.datetime.LocalDate
import java.time.LocalDate
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.pairing.HistoryHelper
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
@@ -10,6 +12,7 @@ import org.jeudego.pairgoth.pairing.solver.SwissSolver
import org.jeudego.pairgoth.store.Store
import kotlin.math.max
import kotlin.math.min
import java.util.*
import kotlin.math.roundToInt
sealed class Tournament <P: Pairable>(
@@ -76,9 +79,9 @@ sealed class Tournament <P: Pairable>(
// TODO cleaner solver instantiation
val history = historyBefore(round)
val solver = if (pairing is Swiss) {
SwissSolver(round, history, pairables.values.toList(), pairing.pairingParams, pairing.placementParams)
SwissSolver(round, history, pairables.values.toList(), pairing.pairingParams, pairing.placementParams, usedTables(round))
} else if (pairing is MacMahon) {
MacMahonSolver(round, history, pairables.values.toList(), pairing.pairingParams, pairing.placementParams, pairing.mmFloor, pairing.mmBar)
MacMahonSolver(round, history, pairables.values.toList(), pairing.pairingParams, pairing.placementParams, usedTables(round), pairing.mmFloor, pairing.mmBar)
} else throw Exception("Invalid tournament type")
// Recomputes DUDD and hd
@@ -88,6 +91,29 @@ sealed class Tournament <P: Pairable>(
game.drawnUpDown = solver.dudd(black, white)
game.handicap = solver.hd(black, white)
}
fun usedTables(round: Int): BitSet =
games(round).values.map { it.table }.fold(BitSet()) { acc, table ->
acc.set(table)
acc
}
fun renumberTables(round: Int, pivot: Game? = null): Boolean {
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 blackRank = pairables[game.black]?.rating ?: Int.MIN_VALUE
-(2 * whiteRank + 2 * blackRank) / 2
}.forEach { game ->
if (pivot != null && nextTable == pivot.table) {
++nextTable
}
changed = changed || game.table != nextTable
game.table = nextTable++
}
return changed
}
}
// standard tournament of individuals
@@ -133,7 +159,7 @@ class TeamTournament(
override val players = mutableMapOf<ID, Player>()
val teams: MutableMap<ID, Team> = _pairables
inner class Team(id: ID, name: String): Pairable(id, name, 0, 0) {
inner class Team(id: ID, name: String, final: Boolean): Pairable(id, name, 0, 0, final) {
val playerIds = mutableSetOf<ID>()
val teamPlayers: Set<Player> get() = playerIds.mapNotNull { players[id] }.toSet()
override val rating: Int get() = if (teamPlayers.isEmpty()) super.rating else (teamPlayers.sumOf { player -> player.rating.toDouble() } / players.size).roundToInt()
@@ -151,7 +177,8 @@ class TeamTournament(
fun teamFromJson(json: Json.Object, default: TeamTournament.Team? = null) = Team(
id = json.getInt("id") ?: default?.id ?: Store.nextPlayerId,
name = json.getString("name") ?: default?.name ?: badRequest("missing name")
name = json.getString("name") ?: default?.name ?: badRequest("missing name"),
final = json.getBoolean("final") ?: default?.final ?: badRequest("missing final")
).apply {
json.getArray("players")?.let { arr ->
arr.mapTo(playerIds) {
@@ -174,8 +201,8 @@ fun Tournament.Companion.fromJson(json: Json.Object, default: Tournament<*>? = n
type = type,
name = json.getString("name") ?: default?.name ?: badRequest("missing name"),
shortName = json.getString("shortName") ?: default?.shortName ?: badRequest("missing shortName"),
startDate = json.getLocalDate("startDate") ?: default?.startDate ?: badRequest("missing startDate"),
endDate = json.getLocalDate("endDate") ?: default?.endDate ?: badRequest("missing endDate"),
startDate = json.getString("startDate")?.let { LocalDate.parse(it) } ?: default?.startDate ?: badRequest("missing startDate"),
endDate = json.getString("endDate")?.let { LocalDate.parse(it) } ?: default?.endDate ?: badRequest("missing endDate"),
country = json.getString("country") ?: default?.country ?: badRequest("missing country"),
location = json.getString("location") ?: default?.location ?: badRequest("missing location"),
online = json.getBoolean("online") ?: default?.online ?: false,
@@ -192,8 +219,8 @@ fun Tournament.Companion.fromJson(json: Json.Object, default: Tournament<*>? = n
type = type,
name = json.getString("name") ?: default?.name ?: badRequest("missing name"),
shortName = json.getString("shortName") ?: default?.shortName ?: badRequest("missing shortName"),
startDate = json.getLocalDate("startDate") ?: default?.startDate ?: badRequest("missing startDate"),
endDate = json.getLocalDate("endDate") ?: default?.endDate ?: badRequest("missing endDate"),
startDate = json.getString("startDate")?.let { LocalDate.parse(it) } ?: default?.startDate ?: badRequest("missing startDate"),
endDate = json.getString("endDate")?.let { LocalDate.parse(it) } ?: default?.endDate ?: badRequest("missing endDate"),
country = json.getString("country") ?: default?.country ?: badRequest("missing country"),
location = json.getString("location") ?: default?.location ?: badRequest("missing location"),
online = json.getBoolean("online") ?: default?.online ?: false,

View File

@@ -136,11 +136,4 @@ abstract class BasePairingHelper(
open fun nameSort(p: Pairable, q: Pairable): Int {
return if (p.name > q.name) 1 else -1
}
val tables = history.mapTo(mutableListOf()) { games ->
games.map { it.table }.fold(BitSet()) { acc, table ->
acc.set(table)
acc
}
}
}

View File

@@ -24,6 +24,7 @@ sealed class BaseSolver(
pairables: List<Pairable>, // All pairables for this round, it may include the bye player
pairing: PairingParams,
placement: PlacementParams,
val usedTables: BitSet
) : BasePairingHelper(history, pairables, pairing, placement) {
companion object {
@@ -540,7 +541,6 @@ sealed class BaseSolver(
}
open fun games(black: Pairable, white: Pairable): List<Game> {
// CB TODO team of individuals pairing
val usedTables = tables.getOrNull(round - 1) ?: BitSet().also { tables.add(it) }
val table = if (black.id == 0 || white.id == 0) 0 else usedTables.nextClearBit(1)
usedTables.set(table)
return listOf(Game(id = Store.nextGameId, table = table, black = black.id, white = white.id, handicap = hd(white, black), drawnUpDown = dudd(black, white)))

View File

@@ -1,6 +1,7 @@
package org.jeudego.pairgoth.pairing.solver
import org.jeudego.pairgoth.model.*
import java.util.*
import kotlin.math.max
import kotlin.math.min
@@ -9,9 +10,9 @@ class MacMahonSolver(round: Int,
pairables: List<Pairable>,
pairingParams: PairingParams,
placementParams: PlacementParams,
usedTables: BitSet,
private val mmFloor: Int, private val mmBar: Int):
BaseSolver(round, history, pairables, pairingParams, placementParams) {
BaseSolver(round, history, pairables, pairingParams, placementParams, usedTables) {
override val scores: Map<ID, Double> by lazy {
require (mmBar > mmFloor) { "MMFloor is higher than MMBar" }
@@ -32,7 +33,7 @@ class MacMahonSolver(round: Int,
}
}
val Pairable.mmBase: Double get() = min(max(rank, mmFloor), mmBar) + mmsZero
val Pairable.mmBase: Double get() = min(max(rank, mmFloor), mmBar) + mmsZero + mmsCorrection
val Pairable.mms: Double get() = scores[id] ?: 0.0
// CB TODO - configurable criteria

View File

@@ -1,13 +1,16 @@
package org.jeudego.pairgoth.pairing.solver
import org.jeudego.pairgoth.model.*
import java.util.*
class SwissSolver(round: Int,
history: List<List<Game>>,
pairables: List<Pairable>,
pairingParams: PairingParams,
placementParams: PlacementParams):
BaseSolver(round, history, pairables, pairingParams, placementParams) {
placementParams: PlacementParams,
usedTables: BitSet
):
BaseSolver(round, history, pairables, pairingParams, placementParams, usedTables) {
// In a Swiss tournament the main criterion is the number of wins and already computed

View File

@@ -218,8 +218,12 @@ class ApiServlet: HttpServlet() {
"Missing 'Accept' header"
)
// CB TODO 1) a reference to a specific API call at this point is a code smell.
// 2) there will e other content types: .tou, .h9, .html
if (!isJson(accept) && (!isXml(accept) || !request.requestURI.matches(Regex("/api/tour/\\d+")))) throw ApiException(
// 2) there will be other content types: .tou, .h9, .html
if (!isJson(accept) &&
(!isXml(accept) || !request.requestURI.matches(Regex("/api/tour/\\d+"))) &&
(accept != "application/ffg" && accept != "application/egf" || !request.requestURI.matches(Regex("/api/tour/\\d+/standings/\\d+")))
) throw ApiException(
HttpServletResponse.SC_BAD_REQUEST,
"Invalid 'Accept' header"
)

View File

@@ -4,19 +4,20 @@ import info.macias.sse.events.MessageEvent
import java.util.concurrent.atomic.AtomicLong
enum class Event {
tournamentAdded,
tournamentUpdated,
tournamentDeleted,
playerAdded,
playerUpdated,
playerDeleted,
teamAdded,
teamUpdated,
teamDeleted,
gamesAdded,
gamesDeleted,
gameUpdated,
resultUpdated,
TournamentAdded,
TournamentUpdated,
TournamentDeleted,
PlayerAdded,
PlayerUpdated,
PlayerDeleted,
TeamAdded,
TeamUpdated,
TeamDeleted,
GamesAdded,
GamesDeleted,
GameUpdated,
ResultUpdated,
TablesRenumbered
;
companion object {