From a6881d12762c5f2a8a288ce49ee7fbb4a6f57f51 Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Sat, 29 Nov 2025 15:29:33 +0100 Subject: [PATCH] Add MacMahon 3.9 import support - Add MacMahon39.kt parser for MM39 tournament format - Auto-detect MM39 format in tournament import - Import players, games, bye players, and tournament parameters - Uses default values for time system and director since MM39 lacks those --- .../jeudego/pairgoth/api/TournamentHandler.kt | 3 +- .../org/jeudego/pairgoth/ext/MacMahon39.kt | 258 ++++++++++++++++++ .../src/test/kotlin/ImportExportTests.kt | 71 +++++ .../macmahon39/sample-tournament.xml | 132 +++++++++ 4 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 api-webapp/src/main/kotlin/org/jeudego/pairgoth/ext/MacMahon39.kt create mode 100644 api-webapp/src/test/resources/macmahon39/sample-tournament.xml diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt index dd1c35d..5a4d3db 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/TournamentHandler.kt @@ -5,6 +5,7 @@ import com.republicate.kson.toJsonObject import com.republicate.kson.toMutableJsonObject import org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest +import org.jeudego.pairgoth.ext.MacMahon39 import org.jeudego.pairgoth.ext.OpenGotha import org.jeudego.pairgoth.model.BaseCritParams import org.jeudego.pairgoth.model.TeamTournament @@ -55,7 +56,7 @@ object TournamentHandler: PairgothApiHandler { override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? { val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) { is Json.Object -> Tournament.fromJson(getObjectPayload(request)) - is Element -> OpenGotha.import(payload) + is Element -> if (MacMahon39.isFormat(payload)) MacMahon39.import(payload) else OpenGotha.import(payload) else -> badRequest("missing or invalid payload") } tournament.recomputeDUDD() diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/ext/MacMahon39.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/ext/MacMahon39.kt new file mode 100644 index 0000000..2f4ed32 --- /dev/null +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/ext/MacMahon39.kt @@ -0,0 +1,258 @@ +package org.jeudego.pairgoth.ext + +import org.jeudego.pairgoth.model.* +import org.jeudego.pairgoth.store.nextGameId +import org.jeudego.pairgoth.store.nextPlayerId +import org.jeudego.pairgoth.store.nextTournamentId +import org.w3c.dom.Element +import org.w3c.dom.Node +import org.w3c.dom.NodeList +import java.time.LocalDate + +/** + * MacMahon 3.9 format import support + * Ported from OpenGothaCustom (https://bitbucket.org/kamyszyn/opengothacustom) + */ +object MacMahon39 { + + /** + * Check if the XML element is in MacMahon 3.9 format + */ + fun isFormat(element: Element): Boolean { + val tournament = element.getElementsByTagName("Tournament").item(0) ?: return false + val typeVersion = tournament.attributes?.getNamedItem("typeversion")?.nodeValue + return typeVersion != null + } + + /** + * Import a MacMahon 3.9 format tournament + */ + fun import(element: Element): Tournament<*> { + val tournamentEl = element.getElementsByTagName("Tournament").item(0) as? Element + ?: throw Error("No Tournament element found") + + // Parse tournament settings + val name = extractValue("Name", tournamentEl, "Tournament") + val numberOfRounds = extractValue("NumberOfRounds", tournamentEl, "5").toInt() + val mmBarStr = extractValue("UpperMacMahonBarLevel", tournamentEl, "1d") + val mmFloorStr = extractValue("LowerMacMahonBarLevel", tournamentEl, "30k") + val isMMBar = extractValue("UpperMacMahonBar", tournamentEl, "true") == "true" + val isMMFloor = extractValue("LowerMacMahonBar", tournamentEl, "true") == "true" + val handicapUsed = extractValue("HandicapUsed", tournamentEl, "false").equals("true", ignoreCase = true) + val handicapByRank = extractValue("HandicapByLevel", tournamentEl, "false").equals("true", ignoreCase = true) + val handicapBelowStr = extractValue("HandicapBelowLevel", tournamentEl, "30k") + val isHandicapBelow = extractValue("HandicapBelow", tournamentEl, "true").equals("true", ignoreCase = true) + val handicapCorrectionStr = extractValue("HandicapAdjustmentValue", tournamentEl, "0") + val isHandicapReduction = extractValue("HandicapAdjustment", tournamentEl, "true").equals("true", ignoreCase = true) + val handicapCeilingStr = extractValue("HandicapLimitValue", tournamentEl, "9") + val isHandicapLimit = extractValue("HandicapLimit", tournamentEl, "true").equals("true", ignoreCase = true) + + // Parse placement criteria from Walllist + val walllistEl = element.getElementsByTagName("Walllist").item(0) as? Element + val breakers = if (walllistEl != null) extractValues("ShortName", walllistEl) else listOf("Score", "SOS", "SOSOS") + + // Determine effective values + val mmBar = if (isMMBar) parseRank(mmBarStr) else 8 // 9d + val mmFloor = if (isMMFloor) parseRank(mmFloorStr) else -30 // 30k + val handicapBelow = if (isHandicapBelow) parseRank(handicapBelowStr) else 8 // 9d + val handicapCorrection = if (isHandicapReduction) -1 * handicapCorrectionStr.toInt() else 0 + val handicapCeiling = when { + !handicapUsed -> 0 + !isHandicapLimit -> 30 + else -> handicapCeilingStr.toInt() + } + + // Create pairing parameters + val pairingParams = PairingParams( + base = BaseCritParams(), + main = MainCritParams(), + secondary = SecondaryCritParams(), + geo = GeographicalParams(), + handicap = HandicapParams( + useMMS = !handicapByRank, + rankThreshold = handicapBelow, + correction = handicapCorrection, + ceiling = handicapCeiling + ) + ) + + // Create placement parameters from breakers + val placementCrit = breakers.take(6).mapNotNull { translateBreaker(it, breakers.firstOrNull() == "Points") }.toTypedArray() + val placementParams = PlacementParams(crit = if (placementCrit.isEmpty()) arrayOf(Criterion.MMS, Criterion.SOSM, Criterion.SOSOSM) else placementCrit) + + // Create tournament + val tournament = StandardTournament( + id = nextTournamentId, + type = Tournament.Type.INDIVIDUAL, + name = name, + shortName = name.take(20), + startDate = LocalDate.now(), + endDate = LocalDate.now(), + director = "", + country = "", + location = "", + online = false, + timeSystem = SuddenDeath(3600), // Default: 1 hour sudden death + pairing = MacMahon( + pairingParams = pairingParams, + placementParams = placementParams, + mmFloor = mmFloor, + mmBar = mmBar + ), + rounds = numberOfRounds + ) + + // Parse players + val playerIdMap = mutableMapOf() + val goPlayers = element.getElementsByTagName("GoPlayer") + for (i in 0 until goPlayers.length) { + val playerEl = goPlayers.item(i) as? Element ?: continue + val parentEl = playerEl.parentNode as? Element ?: continue + + val mm39Id = extractValue("Id", parentEl, "1") + val egfPin = extractValue("EgdPin", playerEl, "").let { if (it.length < 8) "" else it } + val firstname = extractValue("FirstName", playerEl, " ") + val surname = extractValue("Surname", playerEl, " ") + val club = extractValue("Club", playerEl, "") + val country = extractValue("Country", playerEl, "").uppercase() + val rankStr = extractValue("GoLevel", playerEl, "30k") + val rank = parseRank(rankStr) + val ratingStr = extractValue("Rating", playerEl, "-901") + val rating = ratingStr.toInt() + val superBarMember = extractValue("SuperBarMember", parentEl, "false") == "true" + val preliminary = extractValue("PreliminaryRegistration", parentEl, "false") == "true" + + val player = Player( + id = nextPlayerId, + name = surname, + firstname = firstname, + rating = rating, + rank = rank, + country = if (country == "GB") "UK" else country, + club = club, + final = !preliminary, + mmsCorrection = if (superBarMember) 1 else 0 + ).also { + if (egfPin.isNotEmpty()) { + it.externalIds[DatabaseId.EGF] = egfPin + } + // Parse not playing rounds + val notPlayingRounds = extractValues("NotPlayingInRound", parentEl) + for (roundStr in notPlayingRounds) { + val round = roundStr.toIntOrNull() ?: continue + it.skip.add(round) + } + } + + playerIdMap[mm39Id] = player.id + tournament.players[player.id] = player + } + + // Parse games (pairings) + val pairings = element.getElementsByTagName("Pairing") + for (i in 0 until pairings.length) { + val pairingEl = pairings.item(i) as? Element ?: continue + val parentEl = pairingEl.parentNode as? Element ?: continue + + val isByeGame = extractValue("PairingWithBye", pairingEl, "false").equals("true", ignoreCase = true) + val roundNumber = extractValue("RoundNumber", parentEl, "1").toInt() + val boardNumber = extractValue("BoardNumber", pairingEl, "${i + 1}").toInt() + + if (isByeGame) { + // Bye player + val blackId = extractValue("Black", pairingEl, "") + val playerId = playerIdMap[blackId] ?: continue + val game = Game( + id = nextGameId, + table = 0, + white = playerId, + black = 0, + result = Game.Result.WHITE + ) + tournament.games(roundNumber)[game.id] = game + } else { + // Regular game + val whiteId = extractValue("White", pairingEl, "") + val blackId = extractValue("Black", pairingEl, "") + val whitePId = playerIdMap[whiteId] ?: continue + val blackPId = playerIdMap[blackId] ?: continue + val handicap = extractValue("Handicap", pairingEl, "0").toInt() + val resultStr = extractValue("Result", pairingEl, "?-?") + val resultByRef = extractValue("ResultByReferee", pairingEl, "false").equals("true", ignoreCase = true) + + val game = Game( + id = nextGameId, + table = boardNumber, + white = whitePId, + black = blackPId, + handicap = handicap, + result = parseResult(resultStr, resultByRef) + ) + tournament.games(roundNumber)[game.id] = game + } + } + + return tournament + } + + // Helper functions + + private fun extractValue(tag: String, element: Element, default: String): String { + return try { + val nodes = element.getElementsByTagName(tag).item(0)?.childNodes + nodes?.item(0)?.nodeValue ?: default + } catch (e: Exception) { + default + } + } + + private fun extractValues(tag: String, element: Element): List { + val result = mutableListOf() + try { + val nodeList = element.getElementsByTagName(tag) + for (i in 0 until minOf(nodeList.length, 20)) { + val nodes = nodeList.item(i)?.childNodes + nodes?.item(0)?.nodeValue?.let { result.add(it) } + } + } catch (e: Exception) { + // ignore + } + return result + } + + private fun parseRank(rankStr: String): Int { + val regex = Regex("(\\d+)([kKdD])") + val match = regex.matchEntire(rankStr) ?: return -20 + val (num, letter) = match.destructured + val level = num.toIntOrNull() ?: return -20 + return when (letter.lowercase()) { + "k" -> -level + "d" -> level - 1 + else -> -20 + } + } + + private fun parseResult(resultStr: String, byRef: Boolean): Game.Result { + // MM39 result format: "1-0" (white wins), "0-1" (black wins), etc. + // The format uses black-first convention (first number is black's score) + return when (resultStr.removeSuffix("!")) { + "0-1" -> Game.Result.WHITE + "1-0" -> Game.Result.BLACK + "\u00BD-\u00BD" -> Game.Result.JIGO + "0-0" -> Game.Result.BOTHLOOSE + "1-1" -> Game.Result.BOTHWIN + else -> Game.Result.UNKNOWN + } + } + + private fun translateBreaker(breaker: String, swiss: Boolean): Criterion? { + return when (breaker) { + "Points" -> Criterion.NBW + "Score", "ScoreX" -> Criterion.MMS + "SOS" -> if (swiss) Criterion.SOSW else Criterion.SOSM + "SOSOS" -> if (swiss) Criterion.SOSOSW else Criterion.SOSOSM + "SODOS" -> if (swiss) Criterion.SODOSW else Criterion.SODOSM + else -> null + } + } +} diff --git a/api-webapp/src/test/kotlin/ImportExportTests.kt b/api-webapp/src/test/kotlin/ImportExportTests.kt index 8fe2c4d..2313482 100644 --- a/api-webapp/src/test/kotlin/ImportExportTests.kt +++ b/api-webapp/src/test/kotlin/ImportExportTests.kt @@ -1,11 +1,13 @@ package org.jeudego.pairgoth.test +import org.jeudego.pairgoth.ext.MacMahon39 import org.jeudego.pairgoth.ext.OpenGotha import org.jeudego.pairgoth.model.toJson import org.jeudego.pairgoth.util.XmlUtils import org.junit.jupiter.api.Test import java.nio.charset.StandardCharsets import kotlin.test.assertEquals +import kotlin.test.assertTrue class ImportExportTests: TestBase() { @@ -56,4 +58,73 @@ class ImportExportTests: TestBase() { assertEquals(jsonTournament, jsonTournament2) } } + + @Test + fun `003 test macmahon39 import`() { + getTestResources("macmahon39")?.forEach { file -> + logger.info("===== Testing MacMahon 3.9 import: ${file.name} =====") + val resource = file.readText(StandardCharsets.UTF_8) + val root_xml = XmlUtils.parse(resource) + + // Verify format detection + assertTrue(MacMahon39.isFormat(root_xml), "File should be detected as MacMahon 3.9 format") + + // Import tournament + val tournament = MacMahon39.import(root_xml) + + // Verify basic tournament data + logger.info("Tournament name: ${tournament.name}") + logger.info("Number of rounds: ${tournament.rounds}") + logger.info("Number of players: ${tournament.pairables.size}") + + assertEquals("Test MacMahon Tournament", tournament.name) + assertEquals(3, tournament.rounds) + assertEquals(4, tournament.pairables.size) + + // Verify players + val players = tournament.pairables.values.toList() + val alice = players.find { it.name == "Smith" } + val bob = players.find { it.name == "Jones" } + val carol = players.find { it.name == "White" } + val david = players.find { it.name == "Brown" } + + assertTrue(alice != null, "Alice should exist") + assertTrue(bob != null, "Bob should exist") + assertTrue(carol != null, "Carol should exist") + assertTrue(david != null, "David should exist") + + assertEquals(2, alice!!.rank) // 3d = rank 2 + assertEquals(1, bob!!.rank) // 2d = rank 1 + assertEquals(0, carol!!.rank) // 1d = rank 0 + assertEquals(-1, david!!.rank) // 1k = rank -1 + + // Carol is super bar member + assertEquals(1, carol.mmsCorrection) + + // David skips round 2 + assertTrue(david.skip.contains(2), "David should skip round 2") + + // Verify games + val round1Games = tournament.games(1).values.toList() + val round2Games = tournament.games(2).values.toList() + + logger.info("Round 1 games: ${round1Games.size}") + logger.info("Round 2 games: ${round2Games.size}") + + assertEquals(2, round1Games.size) + assertEquals(2, round2Games.size) // 1 regular game + 1 bye + + // Test via API + val resp = TestAPI.post("/api/tour", resource) + val id = resp.asObject().getInt("id") + logger.info("Imported tournament id: $id") + + val apiTournament = TestAPI.get("/api/tour/$id").asObject() + assertEquals("Test MacMahon Tournament", apiTournament.getString("name")) + assertEquals(3, apiTournament.getInt("rounds")) + + val apiPlayers = TestAPI.get("/api/tour/$id/part").asArray() + assertEquals(4, apiPlayers.size) + } + } } diff --git a/api-webapp/src/test/resources/macmahon39/sample-tournament.xml b/api-webapp/src/test/resources/macmahon39/sample-tournament.xml new file mode 100644 index 0000000..dbb5b81 --- /dev/null +++ b/api-webapp/src/test/resources/macmahon39/sample-tournament.xml @@ -0,0 +1,132 @@ + + + + + Test MacMahon Tournament + 3 + true + 1d + true + 20k + false + false + true + 30k + true + 0 + true + 9 + false + + + + Score + SOS + SOSOS + + + + + 1 + false + false + + Alice + Smith + Paris + FR + 3d + 2200 + 12345678 + + + + 2 + false + false + + Bob + Jones + Lyon + FR + 2d + 2100 + 23456789 + + + + 3 + false + true + + Carol + White + Berlin + DE + 1d + 2000 + 34567890 + + + + 4 + true + false + 2 + + David + Brown + London + UK + 1k + 1900 + 45678901 + + + + + + + 1 + + 1 + false + 1 + 2 + 0 + 1-0 + false + + + 2 + false + 3 + 4 + 0 + 0-1 + false + + + + 2 + + 1 + false + 2 + 1 + 0 + 0-1 + false + + + 0 + true + 0 + 3 + 0 + 0-1 + false + + + +