Compare commits

..

1 Commits

Author SHA1 Message Date
320206118a Done 2025-10-08 20:14:20 +02:00
36 changed files with 220 additions and 1668 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,3 @@
.claude
target target
/docker/data /docker/data
/.idea /.idea

179
CLAUDE.md
View File

@@ -1,179 +0,0 @@
# Pairgoth Project
## Purpose
**Pairgoth** is a modern Go tournament pairing engine - successor to OpenGotha. It manages tournaments using Swiss and MacMahon pairing systems, handling player registration, automatic pairing, results entry, and standings calculation.
**Version:** 0.20 | **License:** org.jeudego (French Go Association)
## Tech Stack
- **Backend:** Kotlin 2.1 + Maven + Jetty 10
- **Frontend:** Fomantic UI CSS 2.9.2 + Vanilla JavaScript (no jQuery/React)
- **Templates:** Apache Velocity 2.4
- **Storage:** File-based XML (no database required)
- **JDK:** 11+
## Project Structure
```
pairgoth/
├── pairgoth-common/ # Shared utilities (JSON, XML, crypto, logging)
├── api-webapp/ # REST API backend (port 8085)
│ └── model/ # Domain: Tournament, Player, Game, Pairing
│ └── pairing/ # Solvers: Swiss, MacMahon algorithms
│ └── store/ # Persistence: File/Memory storage
│ └── api/ # Handlers: Tournament, Player, Results, etc.
│ └── ext/ # OpenGotha import/export
├── view-webapp/ # Web UI frontend (port 8080)
│ └── webapp/js/ # Vanilla JS: domhelper, api, main, tour-*.inc
│ └── webapp/sass/ # Styles: main, tour, explain, index
│ └── templates/ # Velocity: index, tour, explain, login
│ └── kotlin/ # Servlets, OAuth, Ratings integration
├── webserver/ # Standalone Jetty launcher
├── application/ # Final JAR packaging
└── docker/ # Container deployment
```
## Architecture
### Dual-Webapp Pattern
```
[Browser] <--8080--> [view-webapp] <--8085--> [api-webapp]
│ │ │
Velocity HTML ApiClient.kt REST JSON
+ vanilla JS + FileStore
```
- **api-webapp** - Pure REST API, business logic, pairing engine
- **view-webapp** - Web UI, proxies API calls, handles auth/i18n/ratings
### Key Architectural Decisions
1. **No JS Framework** - 2200 lines of vanilla JS vs typical 50KB+ bundle
2. **Fomantic CSS Only** - Using CSS framework without its jQuery-dependent JS
3. **CSS @layer** - Clean cascade: `semantic` layer < `pairgoth` layer
4. **File Storage** - XML files for portability, no database setup needed
5. **Read/Write Locks** - Simple concurrency on API servlet
6. **SSE Events** - Real-time updates via Server-Sent Events
## Domain Model
```
Tournament (sealed class)
├── IndividualTournament
├── PairTournament
├── TeamTournament
└── RengoTournament
Player → Pairable (interface)
Game { white, black, result, handicap }
Pairing { Swiss | MacMahon }
TimeSystem { ByoYomi | SuddenDeath | Canadian | Fischer }
Rules { French | Japanese | AGA | Chinese }
```
## Pairing Engine
Location: `api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/`
- **SwissSolver** - Swiss system pairing algorithm
- **MacMahonSolver** - MacMahon bands system
- **HistoryHelper** - Criteria: wins, SOS, SOSOS, colors, CUSS, etc.
- **PairingListener** - Progress callbacks for UI
## Key Files
| Purpose | Path |
|---------|------|
| DOM utilities | `view-webapp/.../js/domhelper.js` |
| API client | `view-webapp/.../js/api.js` |
| Core UI | `view-webapp/.../js/main.js` |
| Main styles | `view-webapp/.../sass/main.scss` |
| Tournament model | `api-webapp/.../model/Tournament.kt` |
| Swiss solver | `api-webapp/.../pairing/solver/SwissSolver.kt` |
| API router | `api-webapp/.../server/ApiServlet.kt` |
| App launcher | `webserver/.../application/Pairgoth.kt` |
## Build & Run
```bash
# Build
mvn clean package
# Run standalone (both webapps)
java -jar application/target/pairgoth-engine.jar
# Or separate:
# API: localhost:8085/api
# UI: localhost:8080/
```
## Configuration
File: `pairgoth.properties` (user) or `pairgoth.default.properties` (defaults)
```properties
webapp.port = 8080
api.port = 8085
store = file # file | memory
store.file.path = tournamentfiles
auth = none # none | oauth | sesame
```
## Frontend Patterns
### State via CSS Classes
- `.active` - tabs, accordions, visible elements
- `.shown` - modals/popups
- `.hidden` / `.disabled` / `.selected` / `.dimmed`
### Component Communication
```javascript
// Custom events
box.dispatchEvent(new CustomEvent('listitem-dblclk', { detail: id }));
// jQuery-like API (domhelper.js)
$('.item').addClass('active').on('click', handler);
```
### API Integration
```javascript
api.getJson('/tour/123/players')
.then(players => render(players))
.catch(err => error(err));
```
## External Integrations
- **Ratings:** FFG (French), EGF (European), AGA (Australian)
- **OAuth:** FFG, Google, Facebook, Twitter, Instagram
- **Import/Export:** OpenGotha XML format compatibility
## i18n
Translations in `view-webapp/.../WEB-INF/translations/`
- English (default)
- French (fr)
- German (de)
- Korean (ko)
## Current Work
### User Preferences (feature/user-preferences branch)
Implemented "black vs white" display order option:
- Gear icon in header opens settings modal
- Preference stored in cookie (`blackFirst`) for server-side Velocity rendering
- localStorage backup via store2 (`prefs.blackFirst`)
- Velocity conditionals in tour-pairing.inc.html, tour-results.inc.html, result-sheets.html
- ViewServlet reads cookie and sets `$blackFirst` in Velocity context
Files modified:
- `view-webapp/.../layouts/standard.html` - gear icon + settings modal
- `view-webapp/.../sass/main.scss` - settings modal styles
- `view-webapp/.../js/main.js` - prefs object + modal handlers + cookie set
- `view-webapp/.../kotlin/.../ViewServlet.kt` - read blackFirst cookie
- `view-webapp/.../tour-pairing.inc.html` - `#if($blackFirst)` conditionals
- `view-webapp/.../tour-results.inc.html` - `#if($blackFirst)` conditionals + inverted result display
- `view-webapp/.../result-sheets.html` - `#if($blackFirst)` conditionals

View File

@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.jeudego.pairgoth</groupId> <groupId>org.jeudego.pairgoth</groupId>
<artifactId>engine-parent</artifactId> <artifactId>engine-parent</artifactId>
<version>0.23</version> <version>0.20</version>
</parent> </parent>
<artifactId>api-webapp</artifactId> <artifactId>api-webapp</artifactId>

View File

@@ -114,7 +114,7 @@ object StandingsHandler: PairgothApiHandler {
""" """
; CL[${egfClass}] ; CL[${egfClass}]
; EV[${tournament.name}] ; EV[${tournament.name}]
; PC[${tournament.country.uppercase()},${tournament.location}] ; PC[${tournament.country.lowercase()},${tournament.location}]
; DT[${tournament.startDate},${tournament.endDate}] ; DT[${tournament.startDate},${tournament.endDate}]
; HA[${ ; HA[${
if (tournament.pairing.type == PairingType.MAC_MAHON) "h${tournament.pairing.pairingParams.handicap.correction}" if (tournament.pairing.type == PairingType.MAC_MAHON) "h${tournament.pairing.pairingParams.handicap.correction}"
@@ -123,7 +123,7 @@ object StandingsHandler: PairgothApiHandler {
}] }]
; KM[${tournament.komi}] ; KM[${tournament.komi}]
; TM[${tournament.timeSystem.adjustedTime() / 60}] ; TM[${tournament.timeSystem.adjustedTime() / 60}]
; CM[Generated by Pairgoth ${WebappManager.properties.getProperty("version")}] ; 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(" ") } ; Pl Name Rk Co Club ${ criteria.map { it.name.replace(Regex("(S?)O?(SOS|DOS)[MW]?"), "$1$2").padStart(7, ' ') }.joinToString(" ") }
${ ${
@@ -132,24 +132,23 @@ ${
player.getString("num")!!.padStart(4, ' ') player.getString("num")!!.padStart(4, ' ')
} ${ } ${
"${ "${
player.getString("name")?.toCapitals() player.getString("name")?.toSnake()
} ${ } ${
player.getString("firstname")?.toCapitals() ?: "" player.getString("firstname")?.toSnake() ?: ""
}".padEnd(30, ' ').take(30) }".padEnd(30, ' ').take(30)
} ${ } ${
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ') displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
} ${ } ${
player.getString("country")?.uppercase() ?: "" player.getString("country")?.uppercase() ?: ""
} ${ } ${
(player.getString("club") ?: "").toCapitals().padStart(4).take(4) (player.getString("club") ?: "").toSnake().padStart(4).take(4)
} ${ } ${
criteria.joinToString(" ") { numFormat.format(player.getDouble(it.name)!!).let { if (it.contains('.')) it else "$it " }.padStart(7, ' ') } criteria.joinToString(" ") { numFormat.format(player.getDouble(it.name)!!).let { if (it.contains('.')) it else "$it " }.padStart(7, ' ') }
} ${ } ${
player.getArray("results")!!.map { player.getArray("results")!!.map {
(it as String).padStart(8, ' ') (it as String).padStart(8, ' ')
}.joinToString(" ") }.joinToString(" ")
}${
player.getString("egf")?.let { if (it.length == 8) " |$it" else "" } ?: ""
}" }"
} }
} }
@@ -157,12 +156,14 @@ ${
writer.println(ret) writer.println(ret)
} }
private fun String.toCapitals(): String { private fun String.toSnake(upper: Boolean = false): String {
val sanitized = sanitizeISO() val sanitized = sanitizeISO()
val parts = sanitized.trim().split(Regex("(?:\\s|\\xA0)+")) val parts = sanitized.trim().split(Regex("(?:\\s|\\xA0)+"))
return parts.joinToString("_") { part -> val snake = parts.joinToString("_") { part ->
part.lowercase(Locale.ROOT).replaceFirstChar { it.titlecase(Locale.ROOT) } if (upper) part.uppercase(Locale.ROOT)
else part.capitalize()
} }
return snake
} }
private fun String.sanitizeISO(): String { private fun String.sanitizeISO(): String {
@@ -175,13 +176,12 @@ ${
private fun exportToFFGFormat(tournament: Tournament<*>, lines: List<Json.Object>, writer: PrintWriter) { private fun exportToFFGFormat(tournament: Tournament<*>, lines: List<Json.Object>, writer: PrintWriter) {
val version = WebappManager.properties.getProperty("version")!! val version = WebappManager.properties.getProperty("version")!!
val ffgName = "${ffgDate.format(tournament.startDate)}-${tournament.location.lowercase(Locale.ROOT).sanitizeISO()}"
val ret = val ret =
""";name=$ffgName """;name=${tournament.shortName}
;date=${frDate.format(tournament.startDate)} ;date=${frDate.format(tournament.startDate)}
;vill=${tournament.location}${if (tournament.online) "(online)" else ""} ;vill=${tournament.location}${if (tournament.online) "(online)" else ""}
;comm=${tournament.name} ;comm=${tournament.name}
;prog=Pairgoth $version ;prog=Pairgoth v0.1
;time=${tournament.timeSystem.mainTime / 60} ;time=${tournament.timeSystem.mainTime / 60}
;ta=${tournament.timeSystem.adjustedTime() / 60} ;ta=${tournament.timeSystem.adjustedTime() / 60}
;size=${tournament.gobanSize} ;size=${tournament.gobanSize}
@@ -189,12 +189,9 @@ ${
; Generated by Pairgoth $version ; Generated by Pairgoth $version
; ${ ; ${
when (tournament.timeSystem.type) { when (tournament.timeSystem.type) {
CANADIAN -> if (tournament.timeSystem.byoyomi > 0) "Canadian ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.stones} stones / ${tournament.timeSystem.byoyomi / 60} minutes" CANADIAN -> "Canadian ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.stones} stones / ${tournament.timeSystem.byoyomi / 60} minutes"
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes" JAPANESE -> "Japanese ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.periods} periods of ${tournament.timeSystem.byoyomi / 60} minutes"
JAPANESE -> if (tournament.timeSystem.byoyomi > 0) "Japanese ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.periods} periods of ${tournament.timeSystem.byoyomi / 60} minutes" FISCHER -> "Fisher ${tournament.timeSystem.mainTime / 60} minutes + ${tournament.timeSystem.increment} seconds${ if (tournament.timeSystem.maxTime > 0) ", max ${tournament.timeSystem.maxTime / 60} minutes" else "" }"
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
FISCHER -> if (tournament.timeSystem.increment > 0) "Fisher ${tournament.timeSystem.mainTime / 60} minutes + ${tournament.timeSystem.increment} seconds${ if (tournament.timeSystem.maxTime > 0) ", max ${tournament.timeSystem.maxTime / 60} minutes" else "" }"
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
SUDDEN_DEATH -> "Sudden death ${tournament.timeSystem.mainTime / 60} minutes" SUDDEN_DEATH -> "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
} }
} }
@@ -205,14 +202,14 @@ ${
"${ "${
player.getString("num")!!.padStart(4, ' ') player.getString("num")!!.padStart(4, ' ')
} ${ } ${
"${player.getString("name")?.toCapitals()} ${player.getString("firstname")?.toCapitals() ?: ""}".padEnd(24, ' ').take(24) "${player.getString("name")?.toSnake(true)} ${player.getString("firstname")?.toSnake() ?: ""}".padEnd(24, ' ').take(24)
} ${ } ${
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ') displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
} ${ } ${
player.getString("ffg") ?: " " player.getString("ffg") ?: " "
} ${ } ${
if (player.getString("country") == "FR") if (player.getString("country") == "FR")
(player.getString("club") ?: "").toCapitals().padEnd(4).take(4) (player.getString("club") ?: "").toSnake().padEnd(4).take(4)
else else
(player.getString("country") ?: "").padEnd(4).take(4) (player.getString("country") ?: "").padEnd(4).take(4)
} ${ } ${
@@ -270,5 +267,4 @@ ${
private val numFormat = DecimalFormat("###0.#") private val numFormat = DecimalFormat("###0.#")
private val frDate: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy") private val frDate: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
private val ffgDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyMMdd")
} }

View File

@@ -5,7 +5,6 @@ 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
@@ -56,7 +55,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 -> if (MacMahon39.isFormat(payload)) MacMahon39.import(payload) else OpenGotha.import(payload) is Element -> OpenGotha.import(payload)
else -> badRequest("missing or invalid payload") else -> badRequest("missing or invalid payload")
} }
tournament.recomputeDUDD() tournament.recomputeDUDD()

View File

@@ -1,258 +0,0 @@
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

@@ -107,9 +107,9 @@ fun Player.Companion.fromJson(json: Json.Object, default: Player? = null) = Play
rating = json.getInt("rating") ?: default?.rating ?: badRequest("missing rating"), rating = json.getInt("rating") ?: default?.rating ?: badRequest("missing rating"),
rank = json.getInt("rank") ?: default?.rank ?: badRequest("missing rank"), rank = json.getInt("rank") ?: default?.rank ?: badRequest("missing rank"),
country = ( json.getString("country") ?: default?.country ?: badRequest("missing country") ).let { country = ( json.getString("country") ?: default?.country ?: badRequest("missing country") ).let {
// normalize to UK (EGF uses UK, ISO uses GB) // EGC uses UK, while FFG and browser language use GB
val up = it.uppercase(Locale.ROOT) val up = it.uppercase(Locale.ROOT)
if (up == "GB") "UK" else up if (up == "UK") "GB" else up
}, },
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, final = json.getBoolean("final") ?: default?.final ?: true,

View File

@@ -87,7 +87,6 @@ data class GeographicalParams(
val preferMMSDiffRatherThanSameClubsGroup: Int = 2, // Typically = 2 val preferMMSDiffRatherThanSameClubsGroup: Int = 2, // Typically = 2
val preferMMSDiffRatherThanSameClub: Int = 3, // Typically = 3 val preferMMSDiffRatherThanSameClub: Int = 3, // Typically = 3
val proportionMainClubThreshold: Double = 0.4, // If the biggest club has a proportion of players higher than this, the secondary criterium is not applied val proportionMainClubThreshold: Double = 0.4, // If the biggest club has a proportion of players higher than this, the secondary criterium is not applied
val avoidSameFamily: Boolean = false, // When enabled, avoid pairing players from the same club with the same family name
) { ) {
companion object { companion object {
val disabled = GeographicalParams(avoidSameGeo = 0.0) val disabled = GeographicalParams(avoidSameGeo = 0.0)

View File

@@ -66,30 +66,12 @@ abstract class BasePairingHelper(
} }
// number of players in the biggest club and the biggest country // number of players in the biggest club and the biggest country
// this can be used to adjust geocost if there is a majority of players from the same country or club // this can be used to disable geocost if there is a majority of players from the same country or club
private val clubCounts by lazy {
pairables.groupingBy { it.club?.take(4)?.uppercase() }.eachCount()
}
protected val biggestClubSize by lazy { protected val biggestClubSize by lazy {
clubCounts.values.maxOrNull() ?: 0 pairables.groupingBy { it.club }.eachCount().values.maxOrNull()!!
} }
protected val biggestCountrySize by lazy { protected val biggestCountrySize by lazy {
pairables.groupingBy { it.country }.eachCount().values.maxOrNull() ?: 0 pairables.groupingBy { it.club }.eachCount().values.maxOrNull()!!
}
// Local club detection: a club is "local" if it has more than the threshold proportion of players
protected val localClub: String? by lazy {
val threshold = pairing.geo.proportionMainClubThreshold
clubCounts.entries.find { (_, count) ->
count.toDouble() / pairables.size > threshold
}?.key
}
protected val hasLocalClub: Boolean get() = localClub != null
// Check if a player belongs to the local club
protected fun Pairable.isFromLocalClub(): Boolean {
val local = localClub ?: return false
return club?.take(4)?.uppercase() == local
} }
// already paired players map // already paired players map

View File

@@ -443,14 +443,14 @@ sealed class Solver(
val geoMaxCost = pairing.geo.avoidSameGeo val geoMaxCost = pairing.geo.avoidSameGeo
// Country factor: in legacy mode or when no dominant country, use normal factor val countryFactor: Int = if (legacyMode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold)
val countryFactor: Int = if (legacyMode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold) preferMMSDiffRatherThanSameCountry
preferMMSDiffRatherThanSameCountry else
else 0
0 val clubFactor: Int = if (legacyMode || biggestClubSize.toDouble() / pairables.size <= proportionMainClubThreshold)
preferMMSDiffRatherThanSameClub
// Club factor: always use the configured value else
val clubFactor: Int = preferMMSDiffRatherThanSameClub 0
//val groupFactor: Int = preferMMSDiffRatherThanSameClubsGroup //val groupFactor: Int = preferMMSDiffRatherThanSameClubsGroup
// Same country // Same country
@@ -463,55 +463,27 @@ sealed class Solver(
// Same club and club group (TODO club group) // Same club and club group (TODO club group)
var clubRatio = 0.0 var clubRatio = 0.0
// To match OpenGotha, only do a case insensitive comparison of the first four characters. // To match OpenGotha, only do a case insensitive comparison of the first four characters.
// But obviously, there is a margin of improvement here towards some way of normalizing clubs.
val commonClub = p1.club?.take(4)?.uppercase() == p2.club?.take(4)?.uppercase() val commonClub = p1.club?.take(4)?.uppercase() == p2.club?.take(4)?.uppercase()
val commonGroup = false // TODO val commonGroup = false // TODO
// Local club adjustment (non-legacy mode only): if (commonGroup && !commonClub) {
// When a local club exists, we want to encourage local-vs-stranger pairings.
// - Ist vs Ist: full bonus (treat as different clubs)
// - Ist vs non-Ist: full bonus (different clubs, and mixing locals with visitors)
// - non-Ist vs non-Ist (different clubs): half bonus (prefer local-stranger mixing)
// - non-Ist vs non-Ist (same club): no bonus (normal same-club behavior)
val p1Local = p1.isFromLocalClub()
val p2Local = p2.isFromLocalClub()
val bothStrangers = !legacyMode && hasLocalClub && !p1Local && !p2Local
val effectiveCommonClub: Boolean = if (!legacyMode && hasLocalClub && commonClub) {
// Both from local club: treat as different clubs (effectiveCommonClub = false)
// Both strangers from same club: normal same-club (effectiveCommonClub = true)
// Mixed (one local, one stranger): treat as different (effectiveCommonClub = false)
bothStrangers
} else {
commonClub
}
if (commonGroup && !effectiveCommonClub) {
clubRatio = if (clubFactor == 0) { clubRatio = if (clubFactor == 0) {
0.0 0.0
} else { } else {
val factor = if (bothStrangers) 0.5 else 1.0 // Half bonus for stranger-vs-stranger clubFactor.toDouble() / 2.0 / placementScoreRange.toDouble()
factor * clubFactor.toDouble() / 2.0 / placementScoreRange.toDouble()
} }
} else if (!commonGroup && !effectiveCommonClub) { } else if (!commonGroup && !commonClub) {
clubRatio = if (clubFactor == 0) { clubRatio = if (clubFactor == 0) {
0.0 0.0
} else { } else {
val factor = if (bothStrangers) 0.5 else 1.0 // Half bonus for stranger-vs-stranger clubFactor.toDouble() * 1.2 / placementScoreRange.toDouble()
factor * clubFactor.toDouble() * 1.2 / placementScoreRange.toDouble()
} }
} }
// else: effectiveCommonClub = true → clubRatio stays 0 (no bonus for same club)
clubRatio = min(clubRatio, 1.0) clubRatio = min(clubRatio, 1.0)
// Same family: when enabled and players are from the same club, check if they have the same surname // TODO Same family
// If so, remove the bonus to avoid pairing family members (even if local club logic gave them a bonus)
if (avoidSameFamily && commonClub) {
val sameFamily = p1.name.uppercase() == p2.name.uppercase()
if (sameFamily) {
clubRatio = 0.0 // No bonus for same family within same club
}
}
// compute geoRatio // compute geoRatio
val mainPart = max(countryRatio, clubRatio) val mainPart = max(countryRatio, clubRatio)

View File

@@ -1,13 +1,11 @@
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() {
@@ -58,73 +56,4 @@ 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

@@ -1,108 +0,0 @@
package org.jeudego.pairgoth.test
import com.republicate.kson.Json
import org.jeudego.pairgoth.model.ID
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Test for local club behavior in geographical pairing criteria.
*
* When a club has more than 40% of players (proportionMainClubThreshold),
* it's considered the "local club" and geographical penalties are adjusted:
* - Two players from the local club: no club penalty
* - Two "strangers" (not from local club) with same club: half penalty
* - Players from different clubs: no bonus (like when threshold exceeded)
*/
class LocalClubTest : TestBase() {
companion object {
// Tournament with MacMahon pairing to test geographical criteria
val localClubTournament = Json.Object(
"type" to "INDIVIDUAL",
"name" to "Local Club Test",
"shortName" to "local-club-test",
"startDate" to "2024-01-01",
"endDate" to "2024-01-01",
"country" to "FR",
"location" to "Test Location",
"online" to false,
"timeSystem" to Json.Object(
"type" to "SUDDEN_DEATH",
"mainTime" to 3600
),
"rounds" to 1,
"pairing" to Json.Object(
"type" to "MAC_MAHON",
"mmFloor" to -20,
"mmBar" to 0
)
)
// Helper to create a player
fun player(name: String, firstname: String, rating: Int, rank: Int, club: String, country: String = "FR") = Json.Object(
"name" to name,
"firstname" to firstname,
"rating" to rating,
"rank" to rank,
"country" to country,
"club" to club,
"final" to true
)
}
@Test
fun `local club detection with more than 40 percent`() {
// Create tournament
var resp = TestAPI.post("/api/tour", localClubTournament).asObject()
val tourId = resp.getInt("id") ?: throw Error("tournament creation failed")
// Add 10 players: 5 from "LocalClub" (50% > 40% threshold),
// 2 strangers from "VisitorA", 2 strangers from "VisitorB", 1 from "Solo"
val playerIds = mutableListOf<ID>()
// 5 local club players (50%) - all same rank to be in same group
for (i in 1..5) {
resp = TestAPI.post("/api/tour/$tourId/part", player("Local$i", "Player", 100, -10, "LocalClub")).asObject()
assertTrue(resp.getBoolean("success")!!)
playerIds.add(resp.getInt("id")!!)
}
// 2 visitors from VisitorA club
for (i in 1..2) {
resp = TestAPI.post("/api/tour/$tourId/part", player("VisitorA$i", "Player", 100, -10, "VisitorA")).asObject()
assertTrue(resp.getBoolean("success")!!)
playerIds.add(resp.getInt("id")!!)
}
// 2 visitors from VisitorB club
for (i in 1..2) {
resp = TestAPI.post("/api/tour/$tourId/part", player("VisitorB$i", "Player", 100, -10, "VisitorB")).asObject()
assertTrue(resp.getBoolean("success")!!)
playerIds.add(resp.getInt("id")!!)
}
// 1 solo player
resp = TestAPI.post("/api/tour/$tourId/part", player("Solo", "Player", 100, -10, "SoloClub")).asObject()
assertTrue(resp.getBoolean("success")!!)
playerIds.add(resp.getInt("id")!!)
assertEquals(10, playerIds.size, "Should have 10 players")
// Generate pairing with weights output
val outputFile = getOutputFile("local-club-weights.txt")
val games = TestAPI.post("/api/tour/$tourId/pair/1?weights_output=$outputFile", Json.Array("all")).asArray()
// Verify we got 5 games (10 players / 2)
assertEquals(5, games.size, "Should have 5 games")
// Read and verify the weights file exists
assertTrue(outputFile.exists(), "Weights file should exist")
// The key verification is that the test completes without errors
// and that local club players can be paired together
// (The BOSP2024 test verifies the detailed behavior matches expected DUDD outcomes)
logger.info("Local club test completed successfully with ${games.size} games")
}
}

View File

@@ -1,132 +0,0 @@
<?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>

View File

@@ -4,7 +4,7 @@
<parent> <parent>
<groupId>org.jeudego.pairgoth</groupId> <groupId>org.jeudego.pairgoth</groupId>
<artifactId>engine-parent</artifactId> <artifactId>engine-parent</artifactId>
<version>0.23</version> <version>0.20</version>
</parent> </parent>
<artifactId>application</artifactId> <artifactId>application</artifactId>
<packaging>pom</packaging> <packaging>pom</packaging>

View File

@@ -2,73 +2,38 @@
## General remarks ## General remarks
The API expects an `Accept` header of `application/json`, with no encoding or an `UTF-8` encoding. Exceptions are some export operations which can have different MIME types to specify the expected format: The API expects an `Accept` header of `application/json`, with no encoding or an `UTF-8` encoding. Exceptions are some export operations which can have different MIME types to specify the expected format.
- `application/json` - JSON output (default)
- `application/xml` - OpenGotha XML export
- `application/egf` - EGF format
- `application/ffg` - FFG format
- `text/csv` - CSV format
GET requests return either an array or an object, as specified below. GET requests return either an array or an object, as specified below.
POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success": true }` (with an optional `"id"` field for some POST requests), or an invalid HTTP code and (for some errors) the body `{ "success": false, "error": <error message> }`. POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success": true }` (with an optional `"id"` field for some POST requests), or and invalid HTTP code and (for some errors) the body `{ "success": false, "error": <error message> }`.
All POST/PUT/DELETE requests use read/write locks for concurrency. GET requests use read locks.
When authentication is enabled, all requests require an `Authorization` header.
## Synopsis ## Synopsis
+ /api/tour GET POST Tournaments handling + /api/tour GET POST Tournaments handling
+ /api/tour/#tid GET PUT DELETE Tournaments handling + /api/tour/#tid GET PUT DELETE Tournaments handling
+ /api/tour/#tid/part GET POST Registration handling + /api/tour/#tid/part GET POST Registration handling
+ /api/tour/#tid/part/#pid GET PUT DELETE Registration handling + /api/tour/#tid/part/#pid GET PUT DELETE Registration handling
+ /api/tour/#tid/team GET POST Team handling + /api/tour/#tid/team GET POST Team handling
+ /api/tour/#tid/team/#tid GET PUT DELETE Team handling + /api/tour/#tid/team/#tid GET PUT DELETE Team handling
+ /api/tour/#tid/pair/#rn GET POST PUT DELETE Pairing + /api/tour/#tid/pair/#rn GET POST PUT DELETE Pairing
+ /api/tour/#tid/res/#rn GET PUT DELETE Results + /api/tour/#tid/res/#rn GET PUT DELETE Results
+ /api/tour/#tid/standings GET PUT Standings + /api/tour/#tid/standings GET Standings
+ /api/tour/#tid/stand/#rn GET Standings + /api/tour/#tid/stand/#rn GET Standings
+ /api/tour/#tid/explain/#rn GET Pairing explanation
+ /api/token GET POST DELETE Authentication
## Tournament handling ## Tournament handling
+ `GET /api/tour` Get a list of known tournaments ids + `GET /api/tour` Get a list of known tournaments ids
*output* json map (id towards shortName) of known tournaments *output* json map (id towards shortName) of known tournaments (subject to change)
+ `GET /api/tour/#tid` Get the details of tournament #tid + `GET /api/tour/#tid` Get the details of tournament #tid
*output* json object for tournament #tid *output* json object for tournament #tid
Supports `Accept: application/xml` to get OpenGotha XML export.
+ `POST /api/tour` Create a new tournament + `POST /api/tour` Create a new tournament
*input* json object for new tournament, or OpenGotha XML with `Content-Type: application/xml` *input* json object for new tournament (see `Tournament.fromJson` in the sources)
Tournament JSON structure:
```json
{
"type": "INDIVIDUAL",
"name": "Tournament Name",
"shortName": "TN",
"startDate": "2024-01-15",
"endDate": "2024-01-16",
"country": "fr",
"location": "Paris",
"online": false,
"rounds": 5,
"gobanSize": 19,
"rules": "FRENCH",
"komi": 7.5,
"timeSystem": { ... },
"pairing": { ... }
}
```
Tournament types: `INDIVIDUAL`, `PAIRGO`, `RENGO2`, `RENGO3`, `TEAM2`, `TEAM3`, `TEAM4`, `TEAM5`
*output* `{ "success": true, "id": #tid }` *output* `{ "success": true, "id": #tid }`
@@ -78,40 +43,19 @@ When authentication is enabled, all requests require an `Authorization` header.
*output* `{ "success": true }` *output* `{ "success": true }`
+ `DELETE /api/tour/#tid` Delete a tournament
*output* `{ "success": true }`
## Players handling ## Players handling
+ `GET /api/tour/#tid/part` Get a list of registered players + `GET /api/tour/#tid/part` Get a list of registered players
*output* json array of known players *output* json array of known players
+ `GET /api/tour/#tid/part/#pid` Get registration details for player #pid + `GET /api/tour/#tid/part/#pid` Get regitration details for player #pid
*output* json object for player #pid *output* json object for player #pid
+ `POST /api/tour/#tid/part` Register a new player + `POST /api/tour/#tid/part` Register a new player
*input* *input* `{ "name":"..." , "firstname":"..." , "rating":<rating> , "rank":<rank> , "country":"XX" [ , "club":"Xxxx" ] [ , "final":true/false ] [ , "mmsCorrection":0 ] }`
```json
{
"name": "Lastname",
"firstname": "Firstname",
"rating": 1500,
"rank": -5,
"country": "FR",
"club": "Club Name",
"final": true,
"mmsCorrection": 0,
"egfId": "12345678",
"ffgId": "12345",
"agaId": "12345"
}
```
Rank values: -30 (30k) to 8 (9D). Rating in EGF-style (100 = 1 stone).
*output* `{ "success": true, "id": #pid }` *output* `{ "success": true, "id": #pid }`
@@ -123,41 +67,35 @@ When authentication is enabled, all requests require an `Authorization` header.
+ `DELETE /api/tour/#tid/part/#pid` Delete a player registration + `DELETE /api/tour/#tid/part/#pid` Delete a player registration
*input* `{ "id": #pid }`
*output* `{ "success": true }` *output* `{ "success": true }`
## Teams handling ## Teams handling
For team tournaments (PAIRGO, RENGO2, RENGO3, TEAM2-5).
+ `GET /api/tour/#tid/team` Get a list of registered teams + `GET /api/tour/#tid/team` Get a list of registered teams
*output* json array of known teams *output* json array of known teams
+ `GET /api/tour/#tid/team/#teamid` Get registration details for team #teamid + `GET /api/tour/#tid/team/#tid` Get regitration details for team #tid
*output* json object for team #teamid *output* json object for team #tid
+ `POST /api/tour/#tid/team` Register a new team + `POST /api/tour/#tid/team` Register a new team
*input* *input* json object for new team
```json
{
"name": "Team Name",
"playerIds": [1, 2, 3],
"final": true,
"mmsCorrection": 0
}
```
*output* `{ "success": true, "id": #teamid }` *output* `{ "success": true, "id": #tid }`
+ `PUT /api/tour/#tid/team/#teamid` Modify a team registration + `PUT /api/tour/#tid/team/#tid` Modify a team registration
*input* json object for updated registration (only id and updated fields required) *input* json object for updated registration (only id and updated fields required)
*output* `{ "success": true }` *output* `{ "success": true }`
+ `DELETE /api/tour/#tid/team/#teamid` Delete a team registration + `DELETE /api/tour/#tid/team/#tid` Delete a team registration
*input* `{ "id": #tid }`
*output* `{ "success": true }` *output* `{ "success": true }`
@@ -166,121 +104,56 @@ For team tournaments (PAIRGO, RENGO2, RENGO3, TEAM2-5).
+ `GET /api/tour/#tid/pair/#rn` Get pairable players for round #rn + `GET /api/tour/#tid/pair/#rn` Get pairable players for round #rn
*output* *output* `{ "games": [ games... ], "pairables:" [ #pid, ... of players not skipping and not playing the round ], "unpairables": [ #pid, ... of players skipping the round ] }`
```json
{
"games": [ { "id": 1, "t": 1, "w": 2, "b": 3, "h": 0 }, ... ],
"pairables": [ 4, 5, ... ],
"unpairables": [ 6, 7, ... ]
}
```
- `games`: existing pairings for the round + `POST /api/tour/#tip/pair/#n` Generate pairing for round #n and given players (or string "all") ; error if already generated for provided players
- `pairables`: player IDs available for pairing (not skipping, not already paired)
- `unpairables`: player IDs skipping the round
+ `POST /api/tour/#tid/pair/#rn` Generate pairing for round #rn
*input* `[ "all" ]` or `[ #pid, ... ]` *input* `[ "all" ]` or `[ #pid, ... ]`
Optional query parameters:
- `legacy=true` - Use legacy pairing algorithm
- `weights_output=<file>` - Output weights to file for debugging
- `append=true` - Append to weights output file
*output* `[ { "id": #gid, "t": table, "w": #wpid, "b": #bpid, "h": handicap }, ... ]` *output* `[ { "id": #gid, "t": table, "w": #wpid, "b": #bpid, "h": handicap }, ... ]`
+ `PUT /api/tour/#tid/pair/#rn` Manual pairing or table renumbering + `PUT /api/tour/#tip/pair/#n` Manual pairing (with optional handicap)
For manual pairing:
*input* `{ "id": #gid, "w": #wpid, "b": #bpid, "h": <handicap> }` *input* `{ "id": #gid, "w": #wpid, "b": #bpid, "h": <handicap> }`
For table renumbering:
*input* `{ "renumber": <game_id or null>, "orderBy": "mms" | "table" }`
*output* `{ "success": true }` *output* `{ "success": true }`
+ `DELETE /api/tour/#tid/pair/#rn` Delete pairing for round #rn + `DELETE /api/tour/#tip/pair/#n` Delete pairing for round #n and given players (or string "all") ; games with results entered are skipped
*input* `[ "all" ]` or `[ #gid, ... ]` *input* `[ "all" ]` or `[ #gid, ... ]`
Games with results already entered are skipped unless `"all"` is specified.
*output* `{ "success": true }` *output* `{ "success": true }`
## Results ## Results
+ `GET /api/tour/#tid/res/#rn` Get results for round #rn + `GET /api/tour/#tip/res/#rn` Get results for round #rn
*output* `[ { "id": #gid, "res": <result> }, ... ]` *output* `[ { "id": #gid, "res": <result> } ]` with `res` being one of: `"w"`, `"b"`, `"="` (jigo), `"x"` (cancelled),`"?"` (unknown), `"#"` (both win), or `"0"` (both loose).
Result codes: + `PUT /api/tour/#tip/res/#rn` Save a result (or put it back to unknown)
- `"w"` - White won
- `"b"` - Black won
- `"="` - Jigo (draw)
- `"X"` - Cancelled
- `"?"` - Unknown (not yet played)
- `"#"` - Both win (unusual)
- `"0"` - Both lose (unusual)
+ `PUT /api/tour/#tid/res/#rn` Save a result *input* `{ "id": #gid, "res": <result> }` with `res` being one of: `"w"`, `"b"`, `"="` (jigo), `"x"` (cancelled)
*input* `{ "id": #gid, "res": <result> }`
*output* `{ "success": true }` *output* `{ "success": true }`
+ `DELETE /api/tour/#tid/res/#rn` Clear all results for round + `DELETE /api/tour/#tip/res/#rn` Clear all results (put back all results to unknown)
*output* `{ "success": true }` *input* none
*output* `{ "success": true }`
## Standings ## Standings
+ `GET /api/tour/#tid/standings` Get standings after final round + `GET /api/tour/#tid/stand/#rn` Get standings after round #rn (or initial standings for round '0')
*output* `[ { "id": #pid, "place": place, "<crit>": value }, ... ]` *output* `[ { "id": #pid, "place": place, "<crit>": double }, ... ]`
where `<crit>` is the name of a criterium, among "score", "nbw", "mms", "sosm", "sososm", ...
Supports multiple output formats via Accept header:
- `application/json` - JSON (default)
- `application/egf` - EGF format
- `application/ffg` - FFG format
- `text/csv` - CSV format
Optional query parameters:
- `include_preliminary=true` - Include preliminary standings
- `individual_standings=true` - For team tournaments with individual scoring
+ `GET /api/tour/#tid/stand/#rn` Get standings after round #rn
Use round `0` for initial standings.
*output* `[ { "id": #pid, "place": place, "<crit>": value }, ... ]`
Criteria names include: `nbw`, `mms`, `sts`, `cps`, `sosw`, `sosm`, `sososw`, `sososm`, `sodosw`, `sodosm`, `cussw`, `cussm`, `dc`, `sdc`, `ext`, `exr`, etc.
+ `PUT /api/tour/#tid/standings` Freeze/lock standings
*output* `{ "success": true }`
## Pairing explanation
+ `GET /api/tour/#tid/explain/#rn` Get detailed pairing criteria weights for round #rn
*output* Detailed pairing weight analysis and criteria breakdown
Used for debugging and understanding pairing decisions.
## Authentication ## Authentication
+ `GET /api/token` Check authentication status + `GET /api/token` Get the token of the currently logged user, or give an error.
*output* Token information for the currently logged user, or error if not authenticated. + `POST /api/token` Create an access token. Expects an authentication json object.
+ `POST /api/token` Create an access token + `DELETE /api/token` Delete the token of the currently logged user.
*input* Authentication credentials (format depends on auth mode)
*output* `{ "success": true, "token": "..." }`
+ `DELETE /api/token` Logout / revoke token
*output* `{ "success": true }`

View File

@@ -2,96 +2,39 @@
Pairgoth general configuration is done using the `pairgoth.properties` file in the installation folder. Pairgoth general configuration is done using the `pairgoth.properties` file in the installation folder.
Properties are loaded in this order (later overrides earlier): ## environment
1. Default properties embedded in WAR/JAR Controls the running environment: `dev` for development, `prod` for distributed instances.
2. User properties file (`./pairgoth.properties`) in current working directory
3. System properties prefixed with `pairgoth.` (command-line: `-Dpairgoth.key=value`)
## Environment
Controls the running environment.
``` ```
env = prod env = prod
``` ```
Values: ## mode
- `dev` - Development mode: enables CORS headers and additional logging
- `prod` - Production: for distributed instances
## Mode Running mode: `standalone`, `client` or `server`.
Running mode for the application.
``` ```
mode = standalone mode = standalone
``` ```
Values: ## authentication
- `standalone` - Both web and API in a single process (default for jar execution)
- `server` - API only
- `client` - Web UI only (connects to remote API)
## Authentication Authentication: `none`, `sesame` for a shared unique password, `oauth` for email and/or oauth accounts.
Authentication method for the application.
``` ```
auth = none auth = none
``` ```
Values: When running in client or server mode, if `auth` is not `none`, the following extra property is needed:
- `none` - No authentication required
- `sesame` - Shared unique password
- `oauth` - Email and/or OAuth accounts
### Shared secret
When running in client or server mode with authentication enabled:
``` ```
auth.shared_secret = <16 ascii characters string> auth.shared_secret = <16 ascii characters string>
``` ```
This secret is shared between API and View webapps. Auto-generated in standalone mode. ## webapp connector
### Sesame password Pairgoth webapp connector configuration.
When using sesame authentication:
```
auth.sesame = <password>
```
## OAuth configuration
When using OAuth authentication:
```
oauth.providers = ffg,google,facebook
```
Comma-separated list of enabled providers: `ffg`, `facebook`, `google`, `instagram`, `twitter`
For each enabled provider, configure credentials:
```
oauth.<provider>.client_id = <client_id>
oauth.<provider>.secret = <client_secret>
```
Example:
```
oauth.ffg.client_id = your-ffg-client-id
oauth.ffg.secret = your-ffg-client-secret
oauth.google.client_id = your-google-client-id
oauth.google.secret = your-google-client-secret
```
## Webapp connector
Pairgoth webapp (UI) connector configuration.
``` ```
webapp.protocol = http webapp.protocol = http
@@ -101,10 +44,7 @@ webapp.context = /
webapp.external.url = http://localhost:8080 webapp.external.url = http://localhost:8080
``` ```
- `webapp.host` (or `webapp.interface`) - Hostname/interface to bind to ## api connector
- `webapp.external.url` - External URL for OAuth redirects and client configuration
## API connector
Pairgoth API connector configuration. Pairgoth API connector configuration.
@@ -116,91 +56,28 @@ api.context = /api
api.external.url = http://localhost:8085/api api.external.url = http://localhost:8085/api
``` ```
Note: In standalone mode, API port defaults to 8080 and context to `/api/tour`. ## store
## SSL/TLS configuration Persistent storage for tournaments, `memory` (mainly used for tests) or `file`.
For HTTPS connections:
```
webapp.ssl.key = path/to/localhost.key
webapp.ssl.cert = path/to/localhost.crt
webapp.ssl.pass = <key passphrase>
```
Supports `jar:` URLs for embedded resources.
## Store
Persistent storage for tournaments.
``` ```
store = file store = file
store.file.path = tournamentfiles store.file.path = tournamentfiles
``` ```
Values for `store`: ## smtp
- `file` - Persistent XML files (default)
- `memory` - RAM-based (mainly for tests)
The `store.file.path` is relative to the current working directory. SMTP configuration. Not yet functional.
## Ratings
### Ratings directory
``` ```
ratings.path = ratings smtp.sender =
``` smtp.host =
Directory for caching downloaded ratings files.
### Rating sources
For each rating source (`aga`, `egf`, `ffg`):
```
ratings.<source> = <url or file path>
```
If not set, ratings are auto-downloaded from the default URL. Set to a local file path to freeze ratings at a specific date.
Example to freeze EGF ratings:
```
ratings.egf = ratings/EGF-20240115.json
```
### Enable/disable ratings
```
ratings.<source>.enable = true | false
```
Whether to display the rating source button in the Add Player popup.
```
ratings.<source>.show = true | false
```
Whether to show player IDs from this rating source on the registration page.
Defaults:
- For tournaments in France: FFG enabled and shown by default
- Otherwise: all disabled by default
## SMTP
SMTP configuration for email notifications. Not yet functional.
```
smtp.sender = sender@example.com
smtp.host = smtp.example.com
smtp.port = 587 smtp.port = 587
smtp.user = username smtp.user =
smtp.password = password smtp.password =
``` ```
## Logging ## logging
Logging configuration. Logging configuration.
@@ -209,48 +86,34 @@ logger.level = info
logger.format = [%level] %ip [%logger] %message logger.format = [%level] %ip [%logger] %message
``` ```
Log levels: `trace`, `debug`, `info`, `warn`, `error` ## ratings
Format placeholders: `%level`, `%ip`, `%logger`, `%message` Ratings configuration. `<ratings>` stands for `egf` or `ffg` in the following.
## Example configurations ### freeze ratings date
### Standalone development If the following property is given:
```properties ```
env = dev ratings.<ratings>.file = ...
mode = standalone
auth = none
store = file
store.file.path = tournamentfiles
logger.level = trace
``` ```
### Client-server deployment then the given ratings file will be used (it must use the Pairgoth ratings json format). If not, the corresponding ratings will be automatically downloaded and stored into `ratings/EGF-yyyymmdd.json` or `ratings/FFG-yyyymmdd.json`.
**Server (API):** The typical use case, for a big tournament lasting several days or a congress, is to let Pairgoth download the latest expected ratings, then to add this property to freeze the ratings at a specific date.
```properties
env = prod ### enable or disable ratings
mode = server
auth = oauth Whether to display the EGF or FFG ratings button in the Add Player popup:
auth.shared_secret = 1234567890abcdef
api.port = 8085 ```
store = file ratings.<ratings>.enable = true | false
store.file.path = /var/tournaments
logger.level = info
``` ```
**Client (Web UI):** Whether to show the ratings player IDs on the registration page:
```properties
env = prod
mode = client
auth = oauth
auth.shared_secret = 1234567890abcdef
oauth.providers = ffg,google
oauth.ffg.client_id = your-ffg-id
oauth.ffg.secret = your-ffg-secret
oauth.google.client_id = your-google-id
oauth.google.secret = your-google-secret
webapp.port = 8080
api.external.url = http://api-server:8085/api
``` ```
ratings.<ratings>.show = true | false
```
For a tournament in France, both are true for `ffg` by default, false otherwise.

View File

@@ -1,7 +1,9 @@
# Pairgoth Model # PairGoth model
## Entity Relationship Diagram ## Entity Relationship Diagram
For simplicity, teams (pairgo, rengo) and teams of individuals (clubs championships) are not included.
```mermaid ```mermaid
erDiagram erDiagram
@@ -9,23 +11,22 @@ erDiagram
Tournament { Tournament {
int id int id
Type type string type
string name string name
string shortName string shortName
date startDate date startDate
date endDate date endDate
string director
string country string country
string location string location
bool online bool isOnline
int rounds int rounds
int gobanSize int gobanSize
Rules rules string rules
double komi int komi
} }
TimeSystem { TimeSystem {
TimeSystemType type string type
int mainTime int mainTime
int increment int increment
int maxTime int maxTime
@@ -36,17 +37,18 @@ erDiagram
Pairing { Pairing {
PairingType type PairingType type
PairingParams pairingParams BaseParams base
PlacementParams placementParams MainParams main
SecondaryParams secondary
GeographicalParams geo
HandicapParams handicap
PlacementParams place
} }
Game { Game {
int id
int table int table
int handicap int handicap
Result result string result
int drawnUpDown
bool forcedTable
} }
Player { Player {
@@ -56,26 +58,13 @@ erDiagram
string country string country
string club string club
int rating int rating
int rank string rank
bool final bool final
int mmsCorrection array skip
set skip
map externalIds
}
Team {
int id
string name
set playerIds
int rating
int rank
bool final
int mmsCorrection
set skip
} }
Standings { Standings {
list criteria array criteria
} }
%% relationships %% relationships
@@ -83,266 +72,9 @@ erDiagram
Tournament ||--|{ TimeSystem: "time system" Tournament ||--|{ TimeSystem: "time system"
Tournament ||--|{ Pairing: "pairing" Tournament ||--|{ Pairing: "pairing"
Tournament ||--|{ Game: "round" Tournament ||--|{ Game: "round"
Tournament }o--|{ Player: "players" Tournament }o--|{ Player: "participate(round)"
Tournament }o--|{ Team: "teams"
Team }o--|{ Player: "members"
Game ||--|| Player: "black" Game ||--|| Player: "black"
Game ||--|| Player: "white" Game ||--|| Player: "white"
Player }|--|| Standings: "position" Player }|--|| Standings: "position"
``` ```
## Tournament
Sealed class hierarchy for different tournament formats.
| Field | Type | Description |
|-------|------|-------------|
| id | int | Tournament identifier |
| type | Type | Tournament format |
| name | string | Full tournament name |
| shortName | string | Abbreviated name |
| startDate | date | Start date |
| endDate | date | End date |
| director | string | Tournament director |
| country | string | Country code (default: "fr") |
| location | string | Venue location |
| online | bool | Is online tournament |
| rounds | int | Total number of rounds |
| gobanSize | int | Board size (default: 19) |
| rules | Rules | Scoring rules |
| komi | double | Komi value (default: 7.5) |
| timeSystem | TimeSystem | Time control |
| pairing | Pairing | Pairing system |
| tablesExclusion | list | Table exclusion rules per round |
### Tournament Types
| Type | Players/Team | Description |
|------|--------------|-------------|
| INDIVIDUAL | 1 | Individual players |
| PAIRGO | 2 | Pair Go (alternating) |
| RENGO2 | 2 | Rengo with 2 players |
| RENGO3 | 3 | Rengo with 3 players |
| TEAM2 | 2 | Team with 2 boards |
| TEAM3 | 3 | Team with 3 boards |
| TEAM4 | 4 | Team with 4 boards |
| TEAM5 | 5 | Team with 5 boards |
### Rules
- `AGA` - American Go Association
- `FRENCH` - French Go Association
- `JAPANESE` - Japanese rules
- `CHINESE` - Chinese rules
## Player
Individual tournament participant.
| Field | Type | Description |
|-------|------|-------------|
| id | int | Player identifier |
| name | string | Last name |
| firstname | string | First name |
| country | string | Country code |
| club | string | Club affiliation |
| rating | int | EGF-style rating |
| rank | int | Rank (-30=30k to 8=9D) |
| final | bool | Is registration confirmed |
| mmsCorrection | int | MacMahon score correction |
| skip | set | Skipped round numbers |
| externalIds | map | External IDs (AGA, EGF, FFG) |
## Team
Team participant (for team tournaments).
| Field | Type | Description |
|-------|------|-------------|
| id | int | Team identifier |
| name | string | Team name |
| playerIds | set | Member player IDs |
| rating | int | Computed from members |
| rank | int | Computed from members |
| final | bool | Is registration confirmed |
| mmsCorrection | int | MacMahon score correction |
| skip | set | Skipped round numbers |
## Game
Single game in a round.
| Field | Type | Description |
|-------|------|-------------|
| id | int | Game identifier |
| table | int | Table number (0 = unpaired) |
| white | int | White player ID (0 = bye) |
| black | int | Black player ID (0 = bye) |
| handicap | int | Handicap stones |
| result | Result | Game outcome |
| drawnUpDown | int | DUDD value |
| forcedTable | bool | Is table manually assigned |
### Result
| Code | Description |
|------|-------------|
| ? | Unknown (not yet played) |
| w | White won |
| b | Black won |
| = | Jigo (draw) |
| X | Cancelled |
| # | Both win (unusual) |
| 0 | Both lose (unusual) |
## TimeSystem
Time control configuration.
| Field | Type | Description |
|-------|------|-------------|
| type | TimeSystemType | System type |
| mainTime | int | Main time in seconds |
| increment | int | Fischer increment |
| maxTime | int | Fischer max time |
| byoyomi | int | Byoyomi time per period |
| periods | int | Number of byoyomi periods |
| stones | int | Stones per period (Canadian) |
### TimeSystemType
| Type | Description |
|------|-------------|
| CANADIAN | Canadian byoyomi |
| JAPANESE | Japanese byoyomi |
| FISCHER | Fischer increment |
| SUDDEN_DEATH | No overtime |
## Pairing
Pairing system configuration.
### Pairing Types
| Type | Description |
|------|-------------|
| SWISS | Swiss system |
| MAC_MAHON | MacMahon system |
| ROUND_ROBIN | Round robin (not implemented) |
### MacMahon-specific
| Field | Type | Description |
|-------|------|-------------|
| mmFloor | int | MacMahon floor (default: -20 = 20k) |
| mmBar | int | MacMahon bar (default: 0 = 1D) |
### Base Parameters
| Parameter | Description |
|-----------|-------------|
| nx1 | Concavity curve factor (0.0-1.0) |
| dupWeight | Duplicate game avoidance weight |
| random | Randomization factor |
| deterministic | Deterministic pairing |
| colorBalanceWeight | Color balance importance |
| byeWeight | Bye assignment weight |
### Main Parameters
| Parameter | Description |
|-----------|-------------|
| categoriesWeight | Avoid mixing categories |
| scoreWeight | Minimize score differences |
| drawUpDownWeight | Draw-up/draw-down weighting |
| compensateDrawUpDown | Enable DUDD compensation |
| drawUpDownUpperMode | TOP, MIDDLE, or BOTTOM |
| drawUpDownLowerMode | TOP, MIDDLE, or BOTTOM |
| seedingWeight | Seeding importance |
| lastRoundForSeedSystem1 | Round cutoff for system 1 |
| seedSystem1 | First seeding method |
| seedSystem2 | Second seeding method |
| mmsValueAbsent | MMS for absent players |
| roundDownScore | Floor vs round scores |
### Seed Methods
- `SPLIT_AND_FOLD`
- `SPLIT_AND_RANDOM`
- `SPLIT_AND_SLIP`
### Secondary Parameters
| Parameter | Description |
|-----------|-------------|
| barThresholdActive | Don't apply below bar |
| rankSecThreshold | Rank limit for criteria |
| nbWinsThresholdActive | Score threshold |
| defSecCrit | Secondary criteria weight |
### Geographical Parameters
| Parameter | Description |
|-----------|-------------|
| avoidSameGeo | Avoid same region |
| preferMMSDiffRatherThanSameCountry | Country preference |
| preferMMSDiffRatherThanSameClubsGroup | Club group preference |
| preferMMSDiffRatherThanSameClub | Club preference |
### Handicap Parameters
| Parameter | Description |
|-----------|-------------|
| weight | Handicap minimization weight |
| useMMS | Use MMS vs rank |
| rankThreshold | Rank threshold |
| correction | Handicap reduction |
| ceiling | Max handicap stones |
## Placement Criteria
Tiebreak criteria for standings, in order of priority.
### Score-based
| Criterion | Description |
|-----------|-------------|
| NBW | Number of wins |
| MMS | MacMahon score |
| STS | Strasbourg score |
| CPS | Cup score |
| SCOREX | Congress score |
### Opponent-based (W = wins, M = MMS)
| Criterion | Description |
|-----------|-------------|
| SOSW / SOSM | Sum of opponent scores |
| SOSWM1 / SOSMM1 | SOS minus worst |
| SOSWM2 / SOSMM2 | SOS minus two worst |
| SODOSW / SODOSM | Sum of defeated opponent scores |
| SOSOSW / SOSOSM | Sum of opponent SOS |
| CUSSW / CUSSM | Cumulative score sum |
### Other
| Criterion | Description |
|-----------|-------------|
| CATEGORY | Player category |
| RANK | Player rank |
| RATING | Player rating |
| DC | Direct confrontation |
| SDC | Simplified direct confrontation |
| EXT | Exploits attempted |
| EXR | Exploits successful |
## External Databases
Player IDs can be linked to external rating databases:
| Database | Description |
|----------|-------------|
| AGA | American Go Association |
| EGF | European Go Federation |
| FFG | French Go Association |

View File

@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.jeudego.pairgoth</groupId> <groupId>org.jeudego.pairgoth</groupId>
<artifactId>engine-parent</artifactId> <artifactId>engine-parent</artifactId>
<version>0.23</version> <version>0.20</version>
</parent> </parent>
<artifactId>pairgoth-common</artifactId> <artifactId>pairgoth-common</artifactId>

View File

@@ -5,7 +5,7 @@
<groupId>org.jeudego.pairgoth</groupId> <groupId>org.jeudego.pairgoth</groupId>
<artifactId>engine-parent</artifactId> <artifactId>engine-parent</artifactId>
<version>0.23</version> <version>0.20</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<!-- CB: Temporary add my repository, while waiting for SSE Java server module author to incorporate my PR or for me to fork it --> <!-- CB: Temporary add my repository, while waiting for SSE Java server module author to incorporate my PR or for me to fork it -->

View File

@@ -7,7 +7,7 @@
<parent> <parent>
<groupId>org.jeudego.pairgoth</groupId> <groupId>org.jeudego.pairgoth</groupId>
<artifactId>engine-parent</artifactId> <artifactId>engine-parent</artifactId>
<version>0.23</version> <version>0.20</version>
</parent> </parent>
<artifactId>view-webapp</artifactId> <artifactId>view-webapp</artifactId>
@@ -113,7 +113,6 @@
<include>index.css</include> <include>index.css</include>
<include>main.css</include> <include>main.css</include>
<include>tour.css</include> <include>tour.css</include>
<include>explain.css</include>
</includes> </includes>
</resource> </resource>
</webResources> </webResources>

View File

@@ -36,6 +36,9 @@ object EGFRatingsHandler: RatingsHandler(RatingsManager.Ratings.EGF) {
if (adjusted < 0) "${-(adjusted - 99) / 100}k" if (adjusted < 0) "${-(adjusted - 99) / 100}k"
else "${(adjusted + 100) / 100}d" else "${(adjusted + 100) / 100}d"
} }
if ("UK" == player.getString("country")) {
player["country"] = "GB"
}
// fix for missing firstnames // fix for missing firstnames
if (player.getString("firstname") == null) { if (player.getString("firstname") == null) {
player["firstname"] = "" player["firstname"] = ""

View File

@@ -39,8 +39,8 @@ class PlayerIndex {
val stopChars = Regex("[_-]") val stopChars = Regex("[_-]")
} }
private final val directory: Directory = ByteBuffersDirectory(NoLockFactory.INSTANCE) private final val directory: Directory = ByteBuffersDirectory(NoLockFactory.INSTANCE)
private lateinit var reader: DirectoryReader private val reader by lazy { DirectoryReader.open(directory) }
private lateinit var searcher: IndexSearcher private val searcher by lazy { IndexSearcher(reader) }
// helper functions // helper functions
fun Json.Object.field(key: String) = getString(key) ?: throw Error("missing $key") fun Json.Object.field(key: String) = getString(key) ?: throw Error("missing $key")
@@ -68,9 +68,6 @@ class PlayerIndex {
++count ++count
} }
} }
// Refresh reader and searcher to see the new index
reader = DirectoryReader.open(directory)
searcher = IndexSearcher(reader)
logger.info("indexed $count players") logger.info("indexed $count players")
} }

View File

@@ -21,12 +21,8 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
companion object { companion object {
private val delay = TimeUnit.HOURS.toMillis(1L) private val delay = TimeUnit.HOURS.toMillis(1L)
private val ymd = DateTimeFormatter.ofPattern("yyyyMMdd") private val ymd = DateTimeFormatter.ofPattern("yyyyMMdd")
private const val USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
} }
private val client = OkHttpClient.Builder() private val client = OkHttpClient()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
abstract val defaultURL: URL abstract val defaultURL: URL
open val active = true open val active = true
lateinit var players: Json.Array lateinit var players: Json.Array
@@ -106,11 +102,6 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
try { try {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
// Don't set Accept-Encoding - let OkHttp handle compression transparently
.header("Connection", "keep-alive")
.build() .build()
client.newCall(request).execute().use { response -> client.newCall(request).execute().use { response ->

View File

@@ -56,7 +56,7 @@ object RatingsManager: Runnable {
object Task: TimerTask() { object Task: TimerTask() {
override fun run() { override fun run() {
try { try {
val newPlayers = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings -> players = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings ->
val ratingsFile = WebappManager.properties.getProperty("ratings.${ratings.origin.name.lowercase()}") as String? val ratingsFile = WebappManager.properties.getProperty("ratings.${ratings.origin.name.lowercase()}") as String?
if (ratingsFile == null) { if (ratingsFile == null) {
ratings.fetchPlayers() ratings.fetchPlayers()
@@ -64,33 +64,36 @@ object RatingsManager: Runnable {
ratings.fetchPlayers(Paths.get("").resolve(ratingsFile).toFile()) ratings.fetchPlayers(Paths.get("").resolve(ratingsFile).toFile())
} }
} }
// Always update players and index together under the write lock val updated = ratingsHandlers.values.filter { it.active }.map { it.updated() }.reduce { u1, u2 ->
// Index must be rebuilt every time since it stores array indices u1 or u2
try { }
updateLock.writeLock().lock() if (updated) {
players = newPlayers try {
index.build(players) updateLock.writeLock().lock()
index.build(players)
// propagate French players license status from ffg to egf } finally {
val licenseStatus = players.map { it -> it as Json.MutableObject }.filter { updateLock.writeLock().unlock()
it["origin"] == "FFG"
}.associate { player ->
Pair(player.getString("ffg")!!, player.getString("license") ?: "-")
} }
players.map { it -> it as Json.MutableObject }.filter { }
it["origin"] == "EGF" && it["country"] == "FR"
}.forEach { player -> // propagate French players license status from ffg to egf
player.getString("egf")?.let { egf -> val licenseStatus = players.map { it -> it as Json.MutableObject }.filter {
egf2ffg[egf]?.let { ffg -> it["origin"] == "FFG"
licenseStatus[ffg]?.let { }.associate { player ->
player["license"] = it Pair(player.getString("ffg")!!, player.getString("license") ?: "-")
} }
players.map { it -> it as Json.MutableObject }.filter {
it["origin"] == "EGF" && it["country"] == "FR"
}.forEach { player ->
player.getString("egf")?.let { egf ->
egf2ffg[egf]?.let { ffg ->
licenseStatus[ffg]?.let {
player["license"] = it
} }
} }
} }
} finally {
updateLock.writeLock().unlock()
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.error("could not build or refresh index: ${e.javaClass.name} ${e.message}") logger.error("could not build or refresh index: ${e.javaClass.name} ${e.message}")
logger.debug("could not build or refresh index", e) logger.debug("could not build or refresh index", e)

View File

@@ -95,7 +95,7 @@ class CountriesTool {
"fj" to "Fiji", "fj" to "Fiji",
"fr" to "France", "fr" to "France",
"ga" to "Gabon", "ga" to "Gabon",
"uk" to "United Kingdom", "gb" to "United Kingdom",
"gd" to "Grenada", "gd" to "Grenada",
"ge" to "Georgia", "ge" to "Georgia",
"gg" to "Guernsey", "gg" to "Guernsey",

View File

@@ -43,11 +43,6 @@ class ViewServlet : VelocityViewServlet() {
} }
} }
val lang = request.getAttribute("lang") as String val lang = request.getAttribute("lang") as String
// User preferences - read from cookie
val blackFirst = request.cookies?.find { it.name == "blackFirst" }?.value == "true"
context.put("blackFirst", blackFirst)
/* /*
val menu = menuEntries!![uri] val menu = menuEntries!![uri]
var title: String? = null var title: String? = null

View File

@@ -1 +0,0 @@
{"id":4,"type":"INDIVIDUAL","name":"Template","shortName":"20251111-Template","startDate":"2025-11-11","endDate":"2025-11-11","director":"","country":"si","location":"Template","online":false,"komi":6.5,"rules":"JAPANESE","gobanSize":19,"timeSystem":{"type":"CANADIAN","mainTime":2400,"byoyomi":300,"stones":15},"rounds":5,"pairing":{"type":"MAC_MAHON","base":{"nx1":0.5,"dupWeight":5.0E14,"random":0.0,"deterministic":true,"colorBalanceWeight":1000000.0},"main":{"catWeight":2.0E13,"scoreWeight":1.0E11,"upDownWeight":1.0E8,"upDownCompensate":true,"upDownLowerMode":"MIDDLE","upDownUpperMode":"MIDDLE","maximizeSeeding":5000000.0,"firstSeedLastRound":2,"firstSeed":"SPLIT_AND_RANDOM","secondSeed":"SPLIT_AND_FOLD","firstSeedAddCrit":"RATING","secondSeedAddCrit":"NONE","mmsValueAbsent":0.5,"roundDownScore":true,"sosValueAbsentUseBase":true},"secondary":{"barThreshold":true,"rankThreshold":0,"winsThreshold":true,"secWeight":1.0E11},"geo":{"weight":1.0E11,"mmsDiffCountry":1,"mmsDiffClubGroup":3,"mmsDiffClub":3},"handicap":{"weight":0.0,"useMMS":false,"threshold":8,"correction":2,"ceiling":9},"placement":["MMS","SOSM","SOSOSM"],"mmFloor":-20,"mmBar":0},"players":[],"games":[[]]}

View File

@@ -523,22 +523,10 @@
} }
} }
#logout, #settings { #logout {
cursor: pointer; cursor: pointer;
} }
#settings-modal {
.setting {
margin: 0.5em 0;
label {
display: flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
}
}
}
@media screen { @media screen {
#players-list { #players-list {
font-size: smaller; font-size: smaller;

View File

@@ -44,9 +44,6 @@
#translate('tour-menu.inc.html') #translate('tour-menu.inc.html')
#end #end
<div id="header-right"> <div id="header-right">
<div id="settings" title="Settings">
<i class="fa fa-cog"></i>
</div>
<div id="lang"> <div id="lang">
<i class="$translate.flags[$request.lang] flag"></i> <i class="$translate.flags[$request.lang] flag"></i>
</div> </div>
@@ -87,23 +84,6 @@
#end #end
#end #end
</div> </div>
<div id="settings-modal" class="popup">
<div class="popup-body">
<div class="popup-header">Settings</div>
<div class="popup-content">
<div class="setting">
<label>
<input type="checkbox" id="pref-black-first" #if($blackFirst)checked#end />
Display games as "Black vs White" (instead of "White vs Black")
</label>
</div>
</div>
<div class="popup-footer">
<button class="ui button" id="settings-save">Save</button>
<button class="ui button gray close">Cancel</button>
</div>
</div>
</div>
<script type="text/javascript" src="/lib/store2-2.14.2.min.js"></script> <script type="text/javascript" src="/lib/store2-2.14.2.min.js"></script>
<script type="text/javascript" src="/lib/tablesort-5.4.0/tablesort.min.js"></script> <script type="text/javascript" src="/lib/tablesort-5.4.0/tablesort.min.js"></script>
<script type="text/javascript" src="/lib/tablesort-5.4.0/sorts/tablesort.number.min.js"></script> <script type="text/javascript" src="/lib/tablesort-5.4.0/sorts/tablesort.number.min.js"></script>

View File

@@ -1,21 +1,6 @@
// Utilities // Utilities
const characters ='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; const characters ='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
// User preferences
const prefs = {
get: function(key) {
return store('prefs.' + key);
},
set: function(key, value) {
store('prefs.' + key, value);
},
getAll: function() {
return {
blackFirst: this.get('blackFirst') || false
};
}
};
function randomString(length) { function randomString(length) {
let result = ''; let result = '';
const charactersLength = characters.length; const charactersLength = characters.length;
@@ -364,21 +349,6 @@ onLoad(() => {
if (!dialog) close_modal(); if (!dialog) close_modal();
}); });
// Settings modal handlers
$('#settings').on('click', e => {
modal('settings-modal');
});
$('#settings-save').on('click', e => {
let blackFirst = $('#pref-black-first')[0].checked;
prefs.set('blackFirst', blackFirst);
// Set cookie for server-side rendering (expires in 1 year)
document.cookie = `blackFirst=${blackFirst}; path=/; max-age=31536000; SameSite=Lax`;
close_modal();
// Reload page to apply new preference
window.location.reload();
});
if (isTouchDevice()) { if (isTouchDevice()) {
$("[title]").on('click', e => { $("[title]").on('click', e => {
let item = e.target.closest('[title]'); let item = e.target.closest('[title]');

View File

@@ -247,8 +247,7 @@ onLoad(() => {
}, },
geo: { geo: {
mmsDiffCountry: form.val('mmsDiffCountry'), mmsDiffCountry: form.val('mmsDiffCountry'),
mmsDiffClub: form.val('mmsDiffClub'), mmsDiffClub: form.val('mmsDiffClub')
avoidSameFamily: form.val('avoidSameFamily')
}, },
handicap: { handicap: {
useMMS: form.val('useMMS'), useMMS: form.val('useMMS'),

View File

@@ -69,21 +69,15 @@
</button> </button>
</div> </div>
<div> <div>
<div id="paired" class="multi-select" title="#if($blackFirst)black vs. white#{else}white vs. black#end">## <div id="paired" class="multi-select" title="white vs. black">##
#foreach($game in $games) #foreach($game in $games)
#set($white = $pmap[$game.w]) #set($white = $pmap[$game.w])
#set($black = $pmap[$game.b]) #set($black = $pmap[$game.b])
<div class="listitem game" data-id="$game.id"> <div class="listitem game" data-id="$game.id">
<div class="table" data-value="$game.t">${game.t}.</div> <div class="table" data-value="$game.t">${game.t}.</div>
#if($blackFirst)
<div class="black" data-id="$game.b">#if($black)$black.name#if($black.firstname) $black.firstname#end#{else}BIP#end</div>
<div class="levels">#if($black)#rank($black.rank)#end&nbsp;/&nbsp;#if($white)#rank($white.rank)#end</div>
<div class="white" data-id="$game.w">#if($white)$white.name#if($white.firstname) $white.firstname#end#{else}BIP#end</div>
#else
<div class="white" data-id="$game.w">#if($white)$white.name#if($white.firstname) $white.firstname#end#{else}BIP#end</div> <div class="white" data-id="$game.w">#if($white)$white.name#if($white.firstname) $white.firstname#end#{else}BIP#end</div>
<div class="levels">#if($white)#rank($white.rank)#end&nbsp;/&nbsp;#if($black)#rank($black.rank)#end</div> <div class="levels">#if($white)#rank($white.rank)#end&nbsp;/&nbsp;#if($black)#rank($black.rank)#end</div>
<div class="black" data-id="$game.b">#if($black)$black.name#if($black.firstname) $black.firstname#end#{else}BIP#end</div> <div class="black" data-id="$game.b">#if($black)$black.name#if($black.firstname) $black.firstname#end#{else}BIP#end</div>
#end
<div class="handicap" data-value="$game.h">#if($game.h)h$game.h#{else}&nbsp;#end</div> <div class="handicap" data-value="$game.h">#if($game.h)h$game.h#{else}&nbsp;#end</div>
</div> </div>
#end## #end##
@@ -104,13 +98,8 @@
<thead> <thead>
<tr> <tr>
<th>Tbl</th> <th>Tbl</th>
#if($blackFirst)
<th>Black</th>
<th>White</th>
#else
<th>White</th> <th>White</th>
<th>Black</th> <th>Black</th>
#end
<th>Hd</th> <th>Hd</th>
</tr> </tr>
</thead> </thead>
@@ -120,13 +109,8 @@
#set($black = $pmap[$game.b]) #set($black = $pmap[$game.b])
<tr> <tr>
<td class="t" data-table="${game.t}">${game.t}</td> <td class="t" data-table="${game.t}">${game.t}</td>
#if($blackFirst)
<td class="left">#if($black)${black.name} $!{black.firstname} (#rank($black.rank) $!black.country $!black.club)#{else}BIP#end</td>
<td class="left">#if($white)${white.name} $!{white.firstname} (#rank($white.rank) $!white.country $!white.club)#{else}BIP#end</td>
#else
<td class="left">#if($white)${white.name} $!{white.firstname} (#rank($white.rank) $!white.country $!white.club)#{else}BIP#end</td> <td class="left">#if($white)${white.name} $!{white.firstname} (#rank($white.rank) $!white.country $!white.club)#{else}BIP#end</td>
<td class="left">#if($black)${black.name} $!{black.firstname} (#rank($black.rank) $!black.country $!black.club)#{else}BIP#end</td> <td class="left">#if($black)${black.name} $!{black.firstname} (#rank($black.rank) $!black.country $!black.club)#{else}BIP#end</td>
#end
<td>${game.h}</td> <td>${game.h}</td>
</tr> </tr>
#end #end

View File

@@ -142,12 +142,6 @@
rather than pairing players of the same club. rather than pairing players of the same club.
</label> </label>
</div> </div>
<div class="field">
<label>
<input type="checkbox" name="avoidSameFamily" value="true" #if($tour.pairing.geo.avoidSameFamily) checked #end/>
avoid pairing players from the same club with the same family name
</label>
</div>
</div> </div>
<div class="title"><i class="dropdown icon"></i>Handicap parameters</div> <div class="title"><i class="dropdown icon"></i>Handicap parameters</div>
<div class="content"> <div class="content">

View File

@@ -100,12 +100,11 @@
#end #end
#if($tour.type != 'INDIVIDUAL') #if($tour.type != 'INDIVIDUAL')
#set($teamId = $tmap[$part.id]) #set($teamId = $tmap[$part.id])
#set($teamName = false)
#if($teamId) #if($teamId)
#set($teamName = $!pmap[$teamId].name) #set($teamName = $!pmap[$teamId].name)
#end #end
<td title="#if($teamName)$esc.html($teamName)#end"> <td title="$esc.html($teamName)">
#if($teamName)$esc.html($utils.truncate($teamName, 10))#end $esc.html($utils.truncate($!teamName, 10))
</td> </td>
#end #end
<td class="participating" data-sort="#if($part.skip)$part.skip.size()/part.skip#{else}0#end"> <td class="participating" data-sort="#if($part.skip)$part.skip.size()/part.skip#{else}0#end">

View File

@@ -18,39 +18,23 @@
<table id="results-table" class="ui celled striped table"> <table id="results-table" class="ui celled striped table">
<thead class="centered"> <thead class="centered">
<th data-sort-method="number">table</th> <th data-sort-method="number">table</th>
#if($blackFirst)
<th>black</th> <th>black</th>
<th>white</th> <th>white</th>
#else
<th>white</th>
<th>black</th>
#end
<th>hd</th> <th>hd</th>
<th>result</th> <th>result</th>
</thead> </thead>
<tbody> <tbody>
#set($dispRst = {'?':'?', 'w':'1-0', 'b':'0-1', '=':'½-½', 'X':'X', '#':'1-1', '0':'0-0'}) #set($dispRst = {'?':'?', 'w':'0-1', 'b':'1-0', '=':'½-½', 'X':'X', '#':'1-1', '0':'0-0'})
#set($dispRstInv = {'?':'?', 'w':'0-1', 'b':'1-0', '=':'½-½', 'X':'X', '#':'1-1', '0':'0-0'})
## For PAIRGO/RENGO, games are team games - use pmap (teams). For others, use plmap (players).
#set($resultsMap = $plmap)
#if($tour.type == 'PAIRGO' || $tour.type.startsWith('RENGO'))
#set($resultsMap = $pmap)
#end
#foreach($game in $individualGames) #foreach($game in $individualGames)
#set($white = $resultsMap[$game.w]) #set($white = $plmap[$game.w])
#set($black = $resultsMap[$game.b]) #set($black = $plmap[$game.b])
#if($black && $white) #if($black && $white)
<tr id="result-$game.id" data-id="$game.id"> <tr id="result-$game.id" data-id="$game.id">
<td data-sort="$game.t">${game.t}.</td> <td data-sort="$game.t">${game.t}.</td>
#if($blackFirst)
<td class="black player #if($game.r == 'b' || $game.r == '#') winner #elseif($game.r == 'w' || $game.r == '0') looser #end" data-id="$black.id" data-sort="$black.name#if($black.firstname) $black.firstname#end"><span>#if($black)$black.name#if($black.firstname) $black.firstname#end #rank($black.rank)#{else}BIP#end</span></td> <td class="black player #if($game.r == 'b' || $game.r == '#') winner #elseif($game.r == 'w' || $game.r == '0') looser #end" data-id="$black.id" data-sort="$black.name#if($black.firstname) $black.firstname#end"><span>#if($black)$black.name#if($black.firstname) $black.firstname#end #rank($black.rank)#{else}BIP#end</span></td>
<td class="white player #if($game.r == 'w' || $game.r == '#') winner #elseif($game.r == 'b' || $game.r == '0') looser #end" data-id="$white.id" data-sort="$white.name#if($white.firstname) $white.firstname#end"><span>#if($white)$white.name#if($white.firstname) $white.firstname#end #rank($white.rank)#{else}BIP#end</span></td> <td class="white player #if($game.r == 'w' || $game.r == '#') winner #elseif($game.r == 'b' || $game.r == '0') looser #end" data-id="$white.id" data-sort="$white.name#if($white.firstname) $white.firstname#end"><span>#if($white)$white.name#if($white.firstname) $white.firstname#end #rank($white.rank)#{else}BIP#end</span></td>
#else
<td class="white player #if($game.r == 'w' || $game.r == '#') winner #elseif($game.r == 'b' || $game.r == '0') looser #end" data-id="$white.id" data-sort="$white.name#if($white.firstname) $white.firstname#end"><span>#if($white)$white.name#if($white.firstname) $white.firstname#end #rank($white.rank)#{else}BIP#end</span></td>
<td class="black player #if($game.r == 'b' || $game.r == '#') winner #elseif($game.r == 'w' || $game.r == '0') looser #end" data-id="$black.id" data-sort="$black.name#if($black.firstname) $black.firstname#end"><span>#if($black)$black.name#if($black.firstname) $black.firstname#end #rank($black.rank)#{else}BIP#end</span></td>
#end
<td class="handicap centered">$!game.h</td> <td class="handicap centered">$!game.h</td>
<td class="result centered" data-sort="$game.r" data-result="$game.r">#if($blackFirst)$dispRstInv[$game.r]#{else}$dispRst[$game.r]#end</td> <td class="result centered" data-sort="$game.r" data-result="$game.r">$dispRst[$game.r]</td>
</tr> </tr>
#end #end
#end #end

View File

@@ -4,7 +4,7 @@
<parent> <parent>
<groupId>org.jeudego.pairgoth</groupId> <groupId>org.jeudego.pairgoth</groupId>
<artifactId>engine-parent</artifactId> <artifactId>engine-parent</artifactId>
<version>0.23</version> <version>0.20</version>
</parent> </parent>
<artifactId>webserver</artifactId> <artifactId>webserver</artifactId>
<packaging>jar</packaging> <packaging>jar</packaging>