Compare commits
1 Commits
4fc92cba82
...
320206118a
| Author | SHA1 | Date | |
|---|---|---|---|
| 320206118a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
.claude
|
|
||||||
target
|
target
|
||||||
/docker/data
|
/docker/data
|
||||||
/.idea
|
/.idea
|
||||||
|
|||||||
179
CLAUDE.md
179
CLAUDE.md
@@ -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
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
271
doc/API.md
271
doc/API.md
@@ -2,285 +2,158 @@
|
|||||||
|
|
||||||
## 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 }`
|
||||||
|
|
||||||
+ `PUT /api/tour/#tid` Modify a tournament
|
+ `PUT /api/tour/#tid` Modify a tournament
|
||||||
|
|
||||||
*input* json object for updated tournament (only id and updated fields required)
|
*input* json object for updated tournament (only id and updated fields required)
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
|
||||||
|
|
||||||
+ `DELETE /api/tour/#tid` Delete a tournament
|
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*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 }`
|
||||||
|
|
||||||
+ `PUT /api/tour/#tid/part/#pid` Modify a player registration
|
+ `PUT /api/tour/#tid/part/#pid` Modify a player 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/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* json object for new team
|
||||||
|
|
||||||
|
*output* `{ "success": true, "id": #tid }`
|
||||||
|
|
||||||
*input*
|
+ `PUT /api/tour/#tid/team/#tid` Modify a team registration
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "Team Name",
|
|
||||||
"playerIds": [1, 2, 3],
|
|
||||||
"final": true,
|
|
||||||
"mmsCorrection": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
*output* `{ "success": true, "id": #teamid }`
|
|
||||||
|
|
||||||
+ `PUT /api/tour/#tid/team/#teamid` 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 }`
|
||||||
|
|
||||||
|
|
||||||
## Pairing
|
## Pairing
|
||||||
|
|
||||||
+ `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* `{ "games": [ games... ], "pairables:" [ #pid, ... of players not skipping and not playing the round ], "unpairables": [ #pid, ... of players skipping the round ] }`
|
||||||
|
|
||||||
*output*
|
+ `POST /api/tour/#tip/pair/#n` Generate pairing for round #n and given players (or string "all") ; error if already generated for provided players
|
||||||
```json
|
|
||||||
{
|
|
||||||
"games": [ { "id": 1, "t": 1, "w": 2, "b": 3, "h": 0 }, ... ],
|
|
||||||
"pairables": [ 4, 5, ... ],
|
|
||||||
"unpairables": [ 6, 7, ... ]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `games`: existing pairings for the round
|
|
||||||
- `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
|
*input* `{ "id": #gid, "res": <result> }` with `res` being one of: `"w"`, `"b"`, `"="` (jigo), `"x"` (cancelled)
|
||||||
- `"="` - 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> }`
|
|
||||||
|
|
||||||
*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 }`
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
312
doc/model.md
312
doc/model.md
@@ -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 |
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
2
pom.xml
2
pom.xml
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"] = ""
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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":[[]]}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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]');
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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 / #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 / #if($black)#rank($black.rank)#end</div>
|
<div class="levels">#if($white)#rank($white.rank)#end / #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} #end</div>
|
<div class="handicap" data-value="$game.h">#if($game.h)h$game.h#{else} #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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user