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 com.republicate.kson.toMutableJsonObject
import org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY import org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.ext.MacMahon39
import org.jeudego.pairgoth.ext.OpenGotha import org.jeudego.pairgoth.ext.OpenGotha
import org.jeudego.pairgoth.model.BaseCritParams import org.jeudego.pairgoth.model.BaseCritParams
import org.jeudego.pairgoth.model.TeamTournament import org.jeudego.pairgoth.model.TeamTournament
@@ -55,7 +56,7 @@ object TournamentHandler: PairgothApiHandler {
override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? { override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? {
val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) { val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) {
is Json.Object -> Tournament.fromJson(getObjectPayload(request)) 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") else -> badRequest("missing or invalid payload")
} }
tournament.recomputeDUDD() 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 package org.jeudego.pairgoth.test
import org.jeudego.pairgoth.ext.MacMahon39
import org.jeudego.pairgoth.ext.OpenGotha import org.jeudego.pairgoth.ext.OpenGotha
import org.jeudego.pairgoth.model.toJson import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.util.XmlUtils import org.jeudego.pairgoth.util.XmlUtils
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ImportExportTests: TestBase() { class ImportExportTests: TestBase() {
@@ -56,4 +58,73 @@ class ImportExportTests: TestBase() {
assertEquals(jsonTournament, jsonTournament2) 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>