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
This commit is contained in:
Claude Brisson
2025-11-29 15:29:33 +01:00
parent e063f6c73c
commit a6881d1276
4 changed files with 463 additions and 1 deletions

View File

@@ -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()

View File

@@ -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<String, ID>()
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<String> {
val result = mutableListOf<String>()
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
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Sample MacMahon 3.9 format tournament file for testing -->
<TournamentData typeversion="3.9">
<Tournament typeversion="3.9">
<Name>Test MacMahon Tournament</Name>
<NumberOfRounds>3</NumberOfRounds>
<UpperMacMahonBar>true</UpperMacMahonBar>
<UpperMacMahonBarLevel>1d</UpperMacMahonBarLevel>
<LowerMacMahonBar>true</LowerMacMahonBar>
<LowerMacMahonBarLevel>20k</LowerMacMahonBarLevel>
<RatingDeterminesRank>false</RatingDeterminesRank>
<HandicapUsed>false</HandicapUsed>
<HandicapBelow>true</HandicapBelow>
<HandicapBelowLevel>30k</HandicapBelowLevel>
<HandicapAdjustment>true</HandicapAdjustment>
<HandicapAdjustmentValue>0</HandicapAdjustmentValue>
<HandicapLimit>true</HandicapLimit>
<HandicapLimitValue>9</HandicapLimitValue>
<HandicapByLevel>false</HandicapByLevel>
</Tournament>
<Walllist>
<Criterion><ShortName>Score</ShortName></Criterion>
<Criterion><ShortName>SOS</ShortName></Criterion>
<Criterion><ShortName>SOSOS</ShortName></Criterion>
</Walllist>
<Playerlist>
<Player>
<Id>1</Id>
<PreliminaryRegistration>false</PreliminaryRegistration>
<SuperBarMember>false</SuperBarMember>
<GoPlayer>
<FirstName>Alice</FirstName>
<Surname>Smith</Surname>
<Club>Paris</Club>
<Country>FR</Country>
<GoLevel>3d</GoLevel>
<Rating>2200</Rating>
<EgdPin>12345678</EgdPin>
</GoPlayer>
</Player>
<Player>
<Id>2</Id>
<PreliminaryRegistration>false</PreliminaryRegistration>
<SuperBarMember>false</SuperBarMember>
<GoPlayer>
<FirstName>Bob</FirstName>
<Surname>Jones</Surname>
<Club>Lyon</Club>
<Country>FR</Country>
<GoLevel>2d</GoLevel>
<Rating>2100</Rating>
<EgdPin>23456789</EgdPin>
</GoPlayer>
</Player>
<Player>
<Id>3</Id>
<PreliminaryRegistration>false</PreliminaryRegistration>
<SuperBarMember>true</SuperBarMember>
<GoPlayer>
<FirstName>Carol</FirstName>
<Surname>White</Surname>
<Club>Berlin</Club>
<Country>DE</Country>
<GoLevel>1d</GoLevel>
<Rating>2000</Rating>
<EgdPin>34567890</EgdPin>
</GoPlayer>
</Player>
<Player>
<Id>4</Id>
<PreliminaryRegistration>true</PreliminaryRegistration>
<SuperBarMember>false</SuperBarMember>
<NotPlayingInRound>2</NotPlayingInRound>
<GoPlayer>
<FirstName>David</FirstName>
<Surname>Brown</Surname>
<Club>London</Club>
<Country>UK</Country>
<GoLevel>1k</GoLevel>
<Rating>1900</Rating>
<EgdPin>45678901</EgdPin>
</GoPlayer>
</Player>
</Playerlist>
<Roundlist>
<Round>
<RoundNumber>1</RoundNumber>
<Pairing>
<BoardNumber>1</BoardNumber>
<PairingWithBye>false</PairingWithBye>
<White>1</White>
<Black>2</Black>
<Handicap>0</Handicap>
<Result>1-0</Result>
<ResultByReferee>false</ResultByReferee>
</Pairing>
<Pairing>
<BoardNumber>2</BoardNumber>
<PairingWithBye>false</PairingWithBye>
<White>3</White>
<Black>4</Black>
<Handicap>0</Handicap>
<Result>0-1</Result>
<ResultByReferee>false</ResultByReferee>
</Pairing>
</Round>
<Round>
<RoundNumber>2</RoundNumber>
<Pairing>
<BoardNumber>1</BoardNumber>
<PairingWithBye>false</PairingWithBye>
<White>2</White>
<Black>1</Black>
<Handicap>0</Handicap>
<Result>0-1</Result>
<ResultByReferee>false</ResultByReferee>
</Pairing>
<Pairing>
<BoardNumber>0</BoardNumber>
<PairingWithBye>true</PairingWithBye>
<White>0</White>
<Black>3</Black>
<Handicap>0</Handicap>
<Result>0-1</Result>
<ResultByReferee>false</ResultByReferee>
</Pairing>
</Round>
</Roundlist>
</TournamentData>