Compare commits
25 Commits
320206118a
...
go-zveza
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc92cba82 | |||
|
|
4a4474873e | ||
|
|
dd95c48f0d | ||
|
|
9a379052e5 | ||
|
|
17697845fd | ||
|
|
147347fa6e | ||
|
|
617f715923 | ||
|
|
254bf6893f | ||
|
|
a256b9ad1f | ||
|
|
174b3adb53 | ||
|
|
4788ef7bc9 | ||
|
|
a6881d1276 | ||
|
|
e063f6c73c | ||
|
|
576be99952 | ||
|
|
4daa707f3e | ||
|
|
cbadb4d6bb | ||
|
|
67d8428b85 | ||
|
|
72f5fe540c | ||
|
|
935f53cf65 | ||
|
|
09c8e834f6 | ||
|
|
667b3e17da | ||
|
|
4113d76904 | ||
|
|
662f438cee | ||
|
|
8ca25ec421 | ||
|
|
f2059f7943 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
.claude
|
||||||
target
|
target
|
||||||
/docker/data
|
/docker/data
|
||||||
/.idea
|
/.idea
|
||||||
|
|||||||
179
CLAUDE.md
Normal file
179
CLAUDE.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 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.20</version>
|
<version>0.23</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.lowercase()},${tournament.location}]
|
; PC[${tournament.country.uppercase()},${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 v0.1]
|
; CM[Generated by Pairgoth ${WebappManager.properties.getProperty("version")}]
|
||||||
;
|
;
|
||||||
; 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,23 +132,24 @@ ${
|
|||||||
player.getString("num")!!.padStart(4, ' ')
|
player.getString("num")!!.padStart(4, ' ')
|
||||||
} ${
|
} ${
|
||||||
"${
|
"${
|
||||||
player.getString("name")?.toSnake()
|
player.getString("name")?.toCapitals()
|
||||||
|
|
||||||
} ${
|
} ${
|
||||||
player.getString("firstname")?.toSnake() ?: ""
|
player.getString("firstname")?.toCapitals() ?: ""
|
||||||
}".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") ?: "").toSnake().padStart(4).take(4)
|
(player.getString("club") ?: "").toCapitals().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 "" } ?: ""
|
||||||
}"
|
}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,14 +157,12 @@ ${
|
|||||||
writer.println(ret)
|
writer.println(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toSnake(upper: Boolean = false): String {
|
private fun String.toCapitals(): String {
|
||||||
val sanitized = sanitizeISO()
|
val sanitized = sanitizeISO()
|
||||||
val parts = sanitized.trim().split(Regex("(?:\\s|\\xA0)+"))
|
val parts = sanitized.trim().split(Regex("(?:\\s|\\xA0)+"))
|
||||||
val snake = parts.joinToString("_") { part ->
|
return parts.joinToString("_") { part ->
|
||||||
if (upper) part.uppercase(Locale.ROOT)
|
part.lowercase(Locale.ROOT).replaceFirstChar { it.titlecase(Locale.ROOT) }
|
||||||
else part.capitalize()
|
|
||||||
}
|
}
|
||||||
return snake
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.sanitizeISO(): String {
|
private fun String.sanitizeISO(): String {
|
||||||
@@ -176,12 +175,13 @@ ${
|
|||||||
|
|
||||||
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=${tournament.shortName}
|
""";name=$ffgName
|
||||||
;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 v0.1
|
;prog=Pairgoth $version
|
||||||
;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,9 +189,12 @@ ${
|
|||||||
; Generated by Pairgoth $version
|
; Generated by Pairgoth $version
|
||||||
; ${
|
; ${
|
||||||
when (tournament.timeSystem.type) {
|
when (tournament.timeSystem.type) {
|
||||||
CANADIAN -> "Canadian ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.stones} stones / ${tournament.timeSystem.byoyomi / 60} minutes"
|
CANADIAN -> if (tournament.timeSystem.byoyomi > 0) "Canadian ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.stones} stones / ${tournament.timeSystem.byoyomi / 60} minutes"
|
||||||
JAPANESE -> "Japanese ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.periods} periods of ${tournament.timeSystem.byoyomi / 60} minutes"
|
else "Sudden death ${tournament.timeSystem.mainTime / 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 "" }"
|
JAPANESE -> if (tournament.timeSystem.byoyomi > 0) "Japanese ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.periods} periods of ${tournament.timeSystem.byoyomi / 60} minutes"
|
||||||
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,14 +205,14 @@ ${
|
|||||||
"${
|
"${
|
||||||
player.getString("num")!!.padStart(4, ' ')
|
player.getString("num")!!.padStart(4, ' ')
|
||||||
} ${
|
} ${
|
||||||
"${player.getString("name")?.toSnake(true)} ${player.getString("firstname")?.toSnake() ?: ""}".padEnd(24, ' ').take(24)
|
"${player.getString("name")?.toCapitals()} ${player.getString("firstname")?.toCapitals() ?: ""}".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") ?: "").toSnake().padEnd(4).take(4)
|
(player.getString("club") ?: "").toCapitals().padEnd(4).take(4)
|
||||||
else
|
else
|
||||||
(player.getString("country") ?: "").padEnd(4).take(4)
|
(player.getString("country") ?: "").padEnd(4).take(4)
|
||||||
} ${
|
} ${
|
||||||
@@ -267,4 +270,5 @@ ${
|
|||||||
|
|
||||||
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,6 +5,7 @@ import com.republicate.kson.toJsonObject
|
|||||||
import com.republicate.kson.toMutableJsonObject
|
import com.republicate.kson.toMutableJsonObject
|
||||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY
|
import org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY
|
||||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||||
|
import org.jeudego.pairgoth.ext.MacMahon39
|
||||||
import org.jeudego.pairgoth.ext.OpenGotha
|
import org.jeudego.pairgoth.ext.OpenGotha
|
||||||
import org.jeudego.pairgoth.model.BaseCritParams
|
import org.jeudego.pairgoth.model.BaseCritParams
|
||||||
import org.jeudego.pairgoth.model.TeamTournament
|
import org.jeudego.pairgoth.model.TeamTournament
|
||||||
@@ -55,7 +56,7 @@ object TournamentHandler: PairgothApiHandler {
|
|||||||
override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? {
|
override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? {
|
||||||
val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) {
|
val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) {
|
||||||
is Json.Object -> Tournament.fromJson(getObjectPayload(request))
|
is Json.Object -> Tournament.fromJson(getObjectPayload(request))
|
||||||
is Element -> OpenGotha.import(payload)
|
is Element -> if (MacMahon39.isFormat(payload)) MacMahon39.import(payload) else OpenGotha.import(payload)
|
||||||
else -> badRequest("missing or invalid payload")
|
else -> badRequest("missing or invalid payload")
|
||||||
}
|
}
|
||||||
tournament.recomputeDUDD()
|
tournament.recomputeDUDD()
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package org.jeudego.pairgoth.ext
|
||||||
|
|
||||||
|
import org.jeudego.pairgoth.model.*
|
||||||
|
import org.jeudego.pairgoth.store.nextGameId
|
||||||
|
import org.jeudego.pairgoth.store.nextPlayerId
|
||||||
|
import org.jeudego.pairgoth.store.nextTournamentId
|
||||||
|
import org.w3c.dom.Element
|
||||||
|
import org.w3c.dom.Node
|
||||||
|
import org.w3c.dom.NodeList
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MacMahon 3.9 format import support
|
||||||
|
* Ported from OpenGothaCustom (https://bitbucket.org/kamyszyn/opengothacustom)
|
||||||
|
*/
|
||||||
|
object MacMahon39 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the XML element is in MacMahon 3.9 format
|
||||||
|
*/
|
||||||
|
fun isFormat(element: Element): Boolean {
|
||||||
|
val tournament = element.getElementsByTagName("Tournament").item(0) ?: return false
|
||||||
|
val typeVersion = tournament.attributes?.getNamedItem("typeversion")?.nodeValue
|
||||||
|
return typeVersion != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a MacMahon 3.9 format tournament
|
||||||
|
*/
|
||||||
|
fun import(element: Element): Tournament<*> {
|
||||||
|
val tournamentEl = element.getElementsByTagName("Tournament").item(0) as? Element
|
||||||
|
?: throw Error("No Tournament element found")
|
||||||
|
|
||||||
|
// Parse tournament settings
|
||||||
|
val name = extractValue("Name", tournamentEl, "Tournament")
|
||||||
|
val numberOfRounds = extractValue("NumberOfRounds", tournamentEl, "5").toInt()
|
||||||
|
val mmBarStr = extractValue("UpperMacMahonBarLevel", tournamentEl, "1d")
|
||||||
|
val mmFloorStr = extractValue("LowerMacMahonBarLevel", tournamentEl, "30k")
|
||||||
|
val isMMBar = extractValue("UpperMacMahonBar", tournamentEl, "true") == "true"
|
||||||
|
val isMMFloor = extractValue("LowerMacMahonBar", tournamentEl, "true") == "true"
|
||||||
|
val handicapUsed = extractValue("HandicapUsed", tournamentEl, "false").equals("true", ignoreCase = true)
|
||||||
|
val handicapByRank = extractValue("HandicapByLevel", tournamentEl, "false").equals("true", ignoreCase = true)
|
||||||
|
val handicapBelowStr = extractValue("HandicapBelowLevel", tournamentEl, "30k")
|
||||||
|
val isHandicapBelow = extractValue("HandicapBelow", tournamentEl, "true").equals("true", ignoreCase = true)
|
||||||
|
val handicapCorrectionStr = extractValue("HandicapAdjustmentValue", tournamentEl, "0")
|
||||||
|
val isHandicapReduction = extractValue("HandicapAdjustment", tournamentEl, "true").equals("true", ignoreCase = true)
|
||||||
|
val handicapCeilingStr = extractValue("HandicapLimitValue", tournamentEl, "9")
|
||||||
|
val isHandicapLimit = extractValue("HandicapLimit", tournamentEl, "true").equals("true", ignoreCase = true)
|
||||||
|
|
||||||
|
// Parse placement criteria from Walllist
|
||||||
|
val walllistEl = element.getElementsByTagName("Walllist").item(0) as? Element
|
||||||
|
val breakers = if (walllistEl != null) extractValues("ShortName", walllistEl) else listOf("Score", "SOS", "SOSOS")
|
||||||
|
|
||||||
|
// Determine effective values
|
||||||
|
val mmBar = if (isMMBar) parseRank(mmBarStr) else 8 // 9d
|
||||||
|
val mmFloor = if (isMMFloor) parseRank(mmFloorStr) else -30 // 30k
|
||||||
|
val handicapBelow = if (isHandicapBelow) parseRank(handicapBelowStr) else 8 // 9d
|
||||||
|
val handicapCorrection = if (isHandicapReduction) -1 * handicapCorrectionStr.toInt() else 0
|
||||||
|
val handicapCeiling = when {
|
||||||
|
!handicapUsed -> 0
|
||||||
|
!isHandicapLimit -> 30
|
||||||
|
else -> handicapCeilingStr.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pairing parameters
|
||||||
|
val pairingParams = PairingParams(
|
||||||
|
base = BaseCritParams(),
|
||||||
|
main = MainCritParams(),
|
||||||
|
secondary = SecondaryCritParams(),
|
||||||
|
geo = GeographicalParams(),
|
||||||
|
handicap = HandicapParams(
|
||||||
|
useMMS = !handicapByRank,
|
||||||
|
rankThreshold = handicapBelow,
|
||||||
|
correction = handicapCorrection,
|
||||||
|
ceiling = handicapCeiling
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create placement parameters from breakers
|
||||||
|
val placementCrit = breakers.take(6).mapNotNull { translateBreaker(it, breakers.firstOrNull() == "Points") }.toTypedArray()
|
||||||
|
val placementParams = PlacementParams(crit = if (placementCrit.isEmpty()) arrayOf(Criterion.MMS, Criterion.SOSM, Criterion.SOSOSM) else placementCrit)
|
||||||
|
|
||||||
|
// Create tournament
|
||||||
|
val tournament = StandardTournament(
|
||||||
|
id = nextTournamentId,
|
||||||
|
type = Tournament.Type.INDIVIDUAL,
|
||||||
|
name = name,
|
||||||
|
shortName = name.take(20),
|
||||||
|
startDate = LocalDate.now(),
|
||||||
|
endDate = LocalDate.now(),
|
||||||
|
director = "",
|
||||||
|
country = "",
|
||||||
|
location = "",
|
||||||
|
online = false,
|
||||||
|
timeSystem = SuddenDeath(3600), // Default: 1 hour sudden death
|
||||||
|
pairing = MacMahon(
|
||||||
|
pairingParams = pairingParams,
|
||||||
|
placementParams = placementParams,
|
||||||
|
mmFloor = mmFloor,
|
||||||
|
mmBar = mmBar
|
||||||
|
),
|
||||||
|
rounds = numberOfRounds
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse players
|
||||||
|
val playerIdMap = mutableMapOf<String, ID>()
|
||||||
|
val goPlayers = element.getElementsByTagName("GoPlayer")
|
||||||
|
for (i in 0 until goPlayers.length) {
|
||||||
|
val playerEl = goPlayers.item(i) as? Element ?: continue
|
||||||
|
val parentEl = playerEl.parentNode as? Element ?: continue
|
||||||
|
|
||||||
|
val mm39Id = extractValue("Id", parentEl, "1")
|
||||||
|
val egfPin = extractValue("EgdPin", playerEl, "").let { if (it.length < 8) "" else it }
|
||||||
|
val firstname = extractValue("FirstName", playerEl, " ")
|
||||||
|
val surname = extractValue("Surname", playerEl, " ")
|
||||||
|
val club = extractValue("Club", playerEl, "")
|
||||||
|
val country = extractValue("Country", playerEl, "").uppercase()
|
||||||
|
val rankStr = extractValue("GoLevel", playerEl, "30k")
|
||||||
|
val rank = parseRank(rankStr)
|
||||||
|
val ratingStr = extractValue("Rating", playerEl, "-901")
|
||||||
|
val rating = ratingStr.toInt()
|
||||||
|
val superBarMember = extractValue("SuperBarMember", parentEl, "false") == "true"
|
||||||
|
val preliminary = extractValue("PreliminaryRegistration", parentEl, "false") == "true"
|
||||||
|
|
||||||
|
val player = Player(
|
||||||
|
id = nextPlayerId,
|
||||||
|
name = surname,
|
||||||
|
firstname = firstname,
|
||||||
|
rating = rating,
|
||||||
|
rank = rank,
|
||||||
|
country = if (country == "GB") "UK" else country,
|
||||||
|
club = club,
|
||||||
|
final = !preliminary,
|
||||||
|
mmsCorrection = if (superBarMember) 1 else 0
|
||||||
|
).also {
|
||||||
|
if (egfPin.isNotEmpty()) {
|
||||||
|
it.externalIds[DatabaseId.EGF] = egfPin
|
||||||
|
}
|
||||||
|
// Parse not playing rounds
|
||||||
|
val notPlayingRounds = extractValues("NotPlayingInRound", parentEl)
|
||||||
|
for (roundStr in notPlayingRounds) {
|
||||||
|
val round = roundStr.toIntOrNull() ?: continue
|
||||||
|
it.skip.add(round)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playerIdMap[mm39Id] = player.id
|
||||||
|
tournament.players[player.id] = player
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse games (pairings)
|
||||||
|
val pairings = element.getElementsByTagName("Pairing")
|
||||||
|
for (i in 0 until pairings.length) {
|
||||||
|
val pairingEl = pairings.item(i) as? Element ?: continue
|
||||||
|
val parentEl = pairingEl.parentNode as? Element ?: continue
|
||||||
|
|
||||||
|
val isByeGame = extractValue("PairingWithBye", pairingEl, "false").equals("true", ignoreCase = true)
|
||||||
|
val roundNumber = extractValue("RoundNumber", parentEl, "1").toInt()
|
||||||
|
val boardNumber = extractValue("BoardNumber", pairingEl, "${i + 1}").toInt()
|
||||||
|
|
||||||
|
if (isByeGame) {
|
||||||
|
// Bye player
|
||||||
|
val blackId = extractValue("Black", pairingEl, "")
|
||||||
|
val playerId = playerIdMap[blackId] ?: continue
|
||||||
|
val game = Game(
|
||||||
|
id = nextGameId,
|
||||||
|
table = 0,
|
||||||
|
white = playerId,
|
||||||
|
black = 0,
|
||||||
|
result = Game.Result.WHITE
|
||||||
|
)
|
||||||
|
tournament.games(roundNumber)[game.id] = game
|
||||||
|
} else {
|
||||||
|
// Regular game
|
||||||
|
val whiteId = extractValue("White", pairingEl, "")
|
||||||
|
val blackId = extractValue("Black", pairingEl, "")
|
||||||
|
val whitePId = playerIdMap[whiteId] ?: continue
|
||||||
|
val blackPId = playerIdMap[blackId] ?: continue
|
||||||
|
val handicap = extractValue("Handicap", pairingEl, "0").toInt()
|
||||||
|
val resultStr = extractValue("Result", pairingEl, "?-?")
|
||||||
|
val resultByRef = extractValue("ResultByReferee", pairingEl, "false").equals("true", ignoreCase = true)
|
||||||
|
|
||||||
|
val game = Game(
|
||||||
|
id = nextGameId,
|
||||||
|
table = boardNumber,
|
||||||
|
white = whitePId,
|
||||||
|
black = blackPId,
|
||||||
|
handicap = handicap,
|
||||||
|
result = parseResult(resultStr, resultByRef)
|
||||||
|
)
|
||||||
|
tournament.games(roundNumber)[game.id] = game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tournament
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
private fun extractValue(tag: String, element: Element, default: String): String {
|
||||||
|
return try {
|
||||||
|
val nodes = element.getElementsByTagName(tag).item(0)?.childNodes
|
||||||
|
nodes?.item(0)?.nodeValue ?: default
|
||||||
|
} catch (e: Exception) {
|
||||||
|
default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractValues(tag: String, element: Element): List<String> {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
try {
|
||||||
|
val nodeList = element.getElementsByTagName(tag)
|
||||||
|
for (i in 0 until minOf(nodeList.length, 20)) {
|
||||||
|
val nodes = nodeList.item(i)?.childNodes
|
||||||
|
nodes?.item(0)?.nodeValue?.let { result.add(it) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRank(rankStr: String): Int {
|
||||||
|
val regex = Regex("(\\d+)([kKdD])")
|
||||||
|
val match = regex.matchEntire(rankStr) ?: return -20
|
||||||
|
val (num, letter) = match.destructured
|
||||||
|
val level = num.toIntOrNull() ?: return -20
|
||||||
|
return when (letter.lowercase()) {
|
||||||
|
"k" -> -level
|
||||||
|
"d" -> level - 1
|
||||||
|
else -> -20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseResult(resultStr: String, byRef: Boolean): Game.Result {
|
||||||
|
// MM39 result format: "1-0" (white wins), "0-1" (black wins), etc.
|
||||||
|
// The format uses black-first convention (first number is black's score)
|
||||||
|
return when (resultStr.removeSuffix("!")) {
|
||||||
|
"0-1" -> Game.Result.WHITE
|
||||||
|
"1-0" -> Game.Result.BLACK
|
||||||
|
"\u00BD-\u00BD" -> Game.Result.JIGO
|
||||||
|
"0-0" -> Game.Result.BOTHLOOSE
|
||||||
|
"1-1" -> Game.Result.BOTHWIN
|
||||||
|
else -> Game.Result.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translateBreaker(breaker: String, swiss: Boolean): Criterion? {
|
||||||
|
return when (breaker) {
|
||||||
|
"Points" -> Criterion.NBW
|
||||||
|
"Score", "ScoreX" -> Criterion.MMS
|
||||||
|
"SOS" -> if (swiss) Criterion.SOSW else Criterion.SOSM
|
||||||
|
"SOSOS" -> if (swiss) Criterion.SOSOSW else Criterion.SOSOSM
|
||||||
|
"SODOS" -> if (swiss) Criterion.SODOSW else Criterion.SODOSM
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
// EGC uses UK, while FFG and browser language use GB
|
// normalize to UK (EGF uses UK, ISO uses GB)
|
||||||
val up = it.uppercase(Locale.ROOT)
|
val up = it.uppercase(Locale.ROOT)
|
||||||
if (up == "UK") "GB" else up
|
if (up == "GB") "UK" 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,6 +87,7 @@ 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,12 +66,30 @@ 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 disable geocost if there is a majority of players from the same country or club
|
// this can be used to adjust 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 {
|
||||||
pairables.groupingBy { it.club }.eachCount().values.maxOrNull()!!
|
clubCounts.values.maxOrNull() ?: 0
|
||||||
}
|
}
|
||||||
protected val biggestCountrySize by lazy {
|
protected val biggestCountrySize by lazy {
|
||||||
pairables.groupingBy { it.club }.eachCount().values.maxOrNull()!!
|
pairables.groupingBy { it.country }.eachCount().values.maxOrNull() ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,27 +463,55 @@ 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
|
||||||
|
|
||||||
if (commonGroup && !commonClub) {
|
// Local club adjustment (non-legacy mode only):
|
||||||
clubRatio = if (clubFactor == 0) {
|
// When a local club exists, we want to encourage local-vs-stranger pairings.
|
||||||
0.0
|
// - 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 {
|
} else {
|
||||||
clubFactor.toDouble() / 2.0 / placementScoreRange.toDouble()
|
commonClub
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (!commonGroup && !commonClub) {
|
if (commonGroup && !effectiveCommonClub) {
|
||||||
clubRatio = if (clubFactor == 0) {
|
clubRatio = if (clubFactor == 0) {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
clubFactor.toDouble() * 1.2 / placementScoreRange.toDouble()
|
val factor = if (bothStrangers) 0.5 else 1.0 // Half bonus for stranger-vs-stranger
|
||||||
|
factor * clubFactor.toDouble() / 2.0 / placementScoreRange.toDouble()
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (!commonGroup && !effectiveCommonClub) {
|
||||||
|
clubRatio = if (clubFactor == 0) {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
val factor = if (bothStrangers) 0.5 else 1.0 // Half bonus for stranger-vs-stranger
|
||||||
|
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)
|
||||||
|
|
||||||
// TODO Same family
|
// Same family: when enabled and players are from the same club, check if they have the same surname
|
||||||
|
// 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,11 +1,13 @@
|
|||||||
package org.jeudego.pairgoth.test
|
package org.jeudego.pairgoth.test
|
||||||
|
|
||||||
|
import org.jeudego.pairgoth.ext.MacMahon39
|
||||||
import org.jeudego.pairgoth.ext.OpenGotha
|
import org.jeudego.pairgoth.ext.OpenGotha
|
||||||
import org.jeudego.pairgoth.model.toJson
|
import org.jeudego.pairgoth.model.toJson
|
||||||
import org.jeudego.pairgoth.util.XmlUtils
|
import org.jeudego.pairgoth.util.XmlUtils
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class ImportExportTests: TestBase() {
|
class ImportExportTests: TestBase() {
|
||||||
|
|
||||||
@@ -56,4 +58,73 @@ class ImportExportTests: TestBase() {
|
|||||||
assertEquals(jsonTournament, jsonTournament2)
|
assertEquals(jsonTournament, jsonTournament2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `003 test macmahon39 import`() {
|
||||||
|
getTestResources("macmahon39")?.forEach { file ->
|
||||||
|
logger.info("===== Testing MacMahon 3.9 import: ${file.name} =====")
|
||||||
|
val resource = file.readText(StandardCharsets.UTF_8)
|
||||||
|
val root_xml = XmlUtils.parse(resource)
|
||||||
|
|
||||||
|
// Verify format detection
|
||||||
|
assertTrue(MacMahon39.isFormat(root_xml), "File should be detected as MacMahon 3.9 format")
|
||||||
|
|
||||||
|
// Import tournament
|
||||||
|
val tournament = MacMahon39.import(root_xml)
|
||||||
|
|
||||||
|
// Verify basic tournament data
|
||||||
|
logger.info("Tournament name: ${tournament.name}")
|
||||||
|
logger.info("Number of rounds: ${tournament.rounds}")
|
||||||
|
logger.info("Number of players: ${tournament.pairables.size}")
|
||||||
|
|
||||||
|
assertEquals("Test MacMahon Tournament", tournament.name)
|
||||||
|
assertEquals(3, tournament.rounds)
|
||||||
|
assertEquals(4, tournament.pairables.size)
|
||||||
|
|
||||||
|
// Verify players
|
||||||
|
val players = tournament.pairables.values.toList()
|
||||||
|
val alice = players.find { it.name == "Smith" }
|
||||||
|
val bob = players.find { it.name == "Jones" }
|
||||||
|
val carol = players.find { it.name == "White" }
|
||||||
|
val david = players.find { it.name == "Brown" }
|
||||||
|
|
||||||
|
assertTrue(alice != null, "Alice should exist")
|
||||||
|
assertTrue(bob != null, "Bob should exist")
|
||||||
|
assertTrue(carol != null, "Carol should exist")
|
||||||
|
assertTrue(david != null, "David should exist")
|
||||||
|
|
||||||
|
assertEquals(2, alice!!.rank) // 3d = rank 2
|
||||||
|
assertEquals(1, bob!!.rank) // 2d = rank 1
|
||||||
|
assertEquals(0, carol!!.rank) // 1d = rank 0
|
||||||
|
assertEquals(-1, david!!.rank) // 1k = rank -1
|
||||||
|
|
||||||
|
// Carol is super bar member
|
||||||
|
assertEquals(1, carol.mmsCorrection)
|
||||||
|
|
||||||
|
// David skips round 2
|
||||||
|
assertTrue(david.skip.contains(2), "David should skip round 2")
|
||||||
|
|
||||||
|
// Verify games
|
||||||
|
val round1Games = tournament.games(1).values.toList()
|
||||||
|
val round2Games = tournament.games(2).values.toList()
|
||||||
|
|
||||||
|
logger.info("Round 1 games: ${round1Games.size}")
|
||||||
|
logger.info("Round 2 games: ${round2Games.size}")
|
||||||
|
|
||||||
|
assertEquals(2, round1Games.size)
|
||||||
|
assertEquals(2, round2Games.size) // 1 regular game + 1 bye
|
||||||
|
|
||||||
|
// Test via API
|
||||||
|
val resp = TestAPI.post("/api/tour", resource)
|
||||||
|
val id = resp.asObject().getInt("id")
|
||||||
|
logger.info("Imported tournament id: $id")
|
||||||
|
|
||||||
|
val apiTournament = TestAPI.get("/api/tour/$id").asObject()
|
||||||
|
assertEquals("Test MacMahon Tournament", apiTournament.getString("name"))
|
||||||
|
assertEquals(3, apiTournament.getInt("rounds"))
|
||||||
|
|
||||||
|
val apiPlayers = TestAPI.get("/api/tour/$id/part").asArray()
|
||||||
|
assertEquals(4, apiPlayers.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
api-webapp/src/test/kotlin/LocalClubTest.kt
Normal file
108
api-webapp/src/test/kotlin/LocalClubTest.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
132
api-webapp/src/test/resources/macmahon39/sample-tournament.xml
Normal file
132
api-webapp/src/test/resources/macmahon39/sample-tournament.xml
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Sample MacMahon 3.9 format tournament file for testing -->
|
||||||
|
<TournamentData typeversion="3.9">
|
||||||
|
<Tournament typeversion="3.9">
|
||||||
|
<Name>Test MacMahon Tournament</Name>
|
||||||
|
<NumberOfRounds>3</NumberOfRounds>
|
||||||
|
<UpperMacMahonBar>true</UpperMacMahonBar>
|
||||||
|
<UpperMacMahonBarLevel>1d</UpperMacMahonBarLevel>
|
||||||
|
<LowerMacMahonBar>true</LowerMacMahonBar>
|
||||||
|
<LowerMacMahonBarLevel>20k</LowerMacMahonBarLevel>
|
||||||
|
<RatingDeterminesRank>false</RatingDeterminesRank>
|
||||||
|
<HandicapUsed>false</HandicapUsed>
|
||||||
|
<HandicapBelow>true</HandicapBelow>
|
||||||
|
<HandicapBelowLevel>30k</HandicapBelowLevel>
|
||||||
|
<HandicapAdjustment>true</HandicapAdjustment>
|
||||||
|
<HandicapAdjustmentValue>0</HandicapAdjustmentValue>
|
||||||
|
<HandicapLimit>true</HandicapLimit>
|
||||||
|
<HandicapLimitValue>9</HandicapLimitValue>
|
||||||
|
<HandicapByLevel>false</HandicapByLevel>
|
||||||
|
</Tournament>
|
||||||
|
|
||||||
|
<Walllist>
|
||||||
|
<Criterion><ShortName>Score</ShortName></Criterion>
|
||||||
|
<Criterion><ShortName>SOS</ShortName></Criterion>
|
||||||
|
<Criterion><ShortName>SOSOS</ShortName></Criterion>
|
||||||
|
</Walllist>
|
||||||
|
|
||||||
|
<Playerlist>
|
||||||
|
<Player>
|
||||||
|
<Id>1</Id>
|
||||||
|
<PreliminaryRegistration>false</PreliminaryRegistration>
|
||||||
|
<SuperBarMember>false</SuperBarMember>
|
||||||
|
<GoPlayer>
|
||||||
|
<FirstName>Alice</FirstName>
|
||||||
|
<Surname>Smith</Surname>
|
||||||
|
<Club>Paris</Club>
|
||||||
|
<Country>FR</Country>
|
||||||
|
<GoLevel>3d</GoLevel>
|
||||||
|
<Rating>2200</Rating>
|
||||||
|
<EgdPin>12345678</EgdPin>
|
||||||
|
</GoPlayer>
|
||||||
|
</Player>
|
||||||
|
<Player>
|
||||||
|
<Id>2</Id>
|
||||||
|
<PreliminaryRegistration>false</PreliminaryRegistration>
|
||||||
|
<SuperBarMember>false</SuperBarMember>
|
||||||
|
<GoPlayer>
|
||||||
|
<FirstName>Bob</FirstName>
|
||||||
|
<Surname>Jones</Surname>
|
||||||
|
<Club>Lyon</Club>
|
||||||
|
<Country>FR</Country>
|
||||||
|
<GoLevel>2d</GoLevel>
|
||||||
|
<Rating>2100</Rating>
|
||||||
|
<EgdPin>23456789</EgdPin>
|
||||||
|
</GoPlayer>
|
||||||
|
</Player>
|
||||||
|
<Player>
|
||||||
|
<Id>3</Id>
|
||||||
|
<PreliminaryRegistration>false</PreliminaryRegistration>
|
||||||
|
<SuperBarMember>true</SuperBarMember>
|
||||||
|
<GoPlayer>
|
||||||
|
<FirstName>Carol</FirstName>
|
||||||
|
<Surname>White</Surname>
|
||||||
|
<Club>Berlin</Club>
|
||||||
|
<Country>DE</Country>
|
||||||
|
<GoLevel>1d</GoLevel>
|
||||||
|
<Rating>2000</Rating>
|
||||||
|
<EgdPin>34567890</EgdPin>
|
||||||
|
</GoPlayer>
|
||||||
|
</Player>
|
||||||
|
<Player>
|
||||||
|
<Id>4</Id>
|
||||||
|
<PreliminaryRegistration>true</PreliminaryRegistration>
|
||||||
|
<SuperBarMember>false</SuperBarMember>
|
||||||
|
<NotPlayingInRound>2</NotPlayingInRound>
|
||||||
|
<GoPlayer>
|
||||||
|
<FirstName>David</FirstName>
|
||||||
|
<Surname>Brown</Surname>
|
||||||
|
<Club>London</Club>
|
||||||
|
<Country>UK</Country>
|
||||||
|
<GoLevel>1k</GoLevel>
|
||||||
|
<Rating>1900</Rating>
|
||||||
|
<EgdPin>45678901</EgdPin>
|
||||||
|
</GoPlayer>
|
||||||
|
</Player>
|
||||||
|
</Playerlist>
|
||||||
|
|
||||||
|
<Roundlist>
|
||||||
|
<Round>
|
||||||
|
<RoundNumber>1</RoundNumber>
|
||||||
|
<Pairing>
|
||||||
|
<BoardNumber>1</BoardNumber>
|
||||||
|
<PairingWithBye>false</PairingWithBye>
|
||||||
|
<White>1</White>
|
||||||
|
<Black>2</Black>
|
||||||
|
<Handicap>0</Handicap>
|
||||||
|
<Result>1-0</Result>
|
||||||
|
<ResultByReferee>false</ResultByReferee>
|
||||||
|
</Pairing>
|
||||||
|
<Pairing>
|
||||||
|
<BoardNumber>2</BoardNumber>
|
||||||
|
<PairingWithBye>false</PairingWithBye>
|
||||||
|
<White>3</White>
|
||||||
|
<Black>4</Black>
|
||||||
|
<Handicap>0</Handicap>
|
||||||
|
<Result>0-1</Result>
|
||||||
|
<ResultByReferee>false</ResultByReferee>
|
||||||
|
</Pairing>
|
||||||
|
</Round>
|
||||||
|
<Round>
|
||||||
|
<RoundNumber>2</RoundNumber>
|
||||||
|
<Pairing>
|
||||||
|
<BoardNumber>1</BoardNumber>
|
||||||
|
<PairingWithBye>false</PairingWithBye>
|
||||||
|
<White>2</White>
|
||||||
|
<Black>1</Black>
|
||||||
|
<Handicap>0</Handicap>
|
||||||
|
<Result>0-1</Result>
|
||||||
|
<ResultByReferee>false</ResultByReferee>
|
||||||
|
</Pairing>
|
||||||
|
<Pairing>
|
||||||
|
<BoardNumber>0</BoardNumber>
|
||||||
|
<PairingWithBye>true</PairingWithBye>
|
||||||
|
<White>0</White>
|
||||||
|
<Black>3</Black>
|
||||||
|
<Handicap>0</Handicap>
|
||||||
|
<Result>0-1</Result>
|
||||||
|
<ResultByReferee>false</ResultByReferee>
|
||||||
|
</Pairing>
|
||||||
|
</Round>
|
||||||
|
</Roundlist>
|
||||||
|
</TournamentData>
|
||||||
@@ -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.20</version>
|
<version>0.23</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>application</artifactId>
|
<artifactId>application</artifactId>
|
||||||
<packaging>pom</packaging>
|
<packaging>pom</packaging>
|
||||||
|
|||||||
195
doc/API.md
195
doc/API.md
@@ -2,11 +2,20 @@
|
|||||||
|
|
||||||
## 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 and 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 an 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
|
||||||
|
|
||||||
@@ -18,22 +27,48 @@ POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success":
|
|||||||
+ /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 Standings
|
+ /api/tour/#tid/standings GET PUT 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 (subject to change)
|
*output* json map (id towards shortName) of known tournaments
|
||||||
|
|
||||||
+ `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 (see `Tournament.fromJson` in the sources)
|
*input* json object for new tournament, or OpenGotha XML with `Content-Type: application/xml`
|
||||||
|
|
||||||
|
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 }`
|
||||||
|
|
||||||
@@ -43,19 +78,40 @@ POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success":
|
|||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
|
+ `DELETE /api/tour/#tid` Delete a tournament
|
||||||
|
|
||||||
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
## Players handling
|
## Players handling
|
||||||
|
|
||||||
+ `GET /api/tour/#tid/part` Get a list of registered players
|
+ `GET /api/tour/#tid/part` Get a list of registered players
|
||||||
|
|
||||||
*output* json array of known players
|
*output* json array of known players
|
||||||
|
|
||||||
+ `GET /api/tour/#tid/part/#pid` Get regitration details for player #pid
|
+ `GET /api/tour/#tid/part/#pid` Get registration 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* `{ "name":"..." , "firstname":"..." , "rating":<rating> , "rank":<rank> , "country":"XX" [ , "club":"Xxxx" ] [ , "final":true/false ] [ , "mmsCorrection":0 ] }`
|
*input*
|
||||||
|
```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 }`
|
||||||
|
|
||||||
@@ -67,35 +123,41 @@ POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success":
|
|||||||
|
|
||||||
+ `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/#tid` Get regitration details for team #tid
|
+ `GET /api/tour/#tid/team/#teamid` Get registration details for team #teamid
|
||||||
|
|
||||||
*output* json object for team #tid
|
*output* json object for team #teamid
|
||||||
|
|
||||||
+ `POST /api/tour/#tid/team` Register a new team
|
+ `POST /api/tour/#tid/team` Register a new team
|
||||||
|
|
||||||
*input* json object for new team
|
*input*
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Team Name",
|
||||||
|
"playerIds": [1, 2, 3],
|
||||||
|
"final": true,
|
||||||
|
"mmsCorrection": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
*output* `{ "success": true, "id": #tid }`
|
*output* `{ "success": true, "id": #teamid }`
|
||||||
|
|
||||||
+ `PUT /api/tour/#tid/team/#tid` Modify a team registration
|
+ `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/#tid` Delete a team registration
|
+ `DELETE /api/tour/#tid/team/#teamid` Delete a team registration
|
||||||
|
|
||||||
*input* `{ "id": #tid }`
|
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
@@ -104,56 +166,121 @@ POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success":
|
|||||||
|
|
||||||
+ `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*
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"games": [ { "id": 1, "t": 1, "w": 2, "b": 3, "h": 0 }, ... ],
|
||||||
|
"pairables": [ 4, 5, ... ],
|
||||||
|
"unpairables": [ 6, 7, ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
+ `POST /api/tour/#tip/pair/#n` Generate pairing for round #n and given players (or string "all") ; error if already generated for provided players
|
- `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/#tip/pair/#n` Manual pairing (with optional handicap)
|
+ `PUT /api/tour/#tid/pair/#rn` Manual pairing or table renumbering
|
||||||
|
|
||||||
|
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/#tip/pair/#n` Delete pairing for round #n and given players (or string "all") ; games with results entered are skipped
|
+ `DELETE /api/tour/#tid/pair/#rn` Delete pairing for round #rn
|
||||||
|
|
||||||
*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/#tip/res/#rn` Get results for round #rn
|
+ `GET /api/tour/#tid/res/#rn` Get results for round #rn
|
||||||
|
|
||||||
*output* `[ { "id": #gid, "res": <result> } ]` with `res` being one of: `"w"`, `"b"`, `"="` (jigo), `"x"` (cancelled),`"?"` (unknown), `"#"` (both win), or `"0"` (both loose).
|
*output* `[ { "id": #gid, "res": <result> }, ... ]`
|
||||||
|
|
||||||
+ `PUT /api/tour/#tip/res/#rn` Save a result (or put it back to unknown)
|
Result codes:
|
||||||
|
- `"w"` - White won
|
||||||
|
- `"b"` - Black won
|
||||||
|
- `"="` - Jigo (draw)
|
||||||
|
- `"X"` - Cancelled
|
||||||
|
- `"?"` - Unknown (not yet played)
|
||||||
|
- `"#"` - Both win (unusual)
|
||||||
|
- `"0"` - Both lose (unusual)
|
||||||
|
|
||||||
*input* `{ "id": #gid, "res": <result> }` with `res` being one of: `"w"`, `"b"`, `"="` (jigo), `"x"` (cancelled)
|
+ `PUT /api/tour/#tid/res/#rn` Save a result
|
||||||
|
|
||||||
|
*input* `{ "id": #gid, "res": <result> }`
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
+ `DELETE /api/tour/#tip/res/#rn` Clear all results (put back all results to unknown)
|
+ `DELETE /api/tour/#tid/res/#rn` Clear all results for round
|
||||||
|
|
||||||
*input* none
|
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
## Standings
|
## Standings
|
||||||
|
|
||||||
+ `GET /api/tour/#tid/stand/#rn` Get standings after round #rn (or initial standings for round '0')
|
+ `GET /api/tour/#tid/standings` Get standings after final round
|
||||||
|
|
||||||
*output* `[ { "id": #pid, "place": place, "<crit>": double }, ... ]`
|
*output* `[ { "id": #pid, "place": place, "<crit>": value }, ... ]`
|
||||||
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` Get the token of the currently logged user, or give an error.
|
+ `GET /api/token` Check authentication status
|
||||||
|
|
||||||
+ `POST /api/token` Create an access token. Expects an authentication json object.
|
*output* Token information for the currently logged user, or error if not authenticated.
|
||||||
|
|
||||||
+ `DELETE /api/token` Delete the token of the currently logged user.
|
+ `POST /api/token` Create an access token
|
||||||
|
|
||||||
|
*input* Authentication credentials (format depends on auth mode)
|
||||||
|
|
||||||
|
*output* `{ "success": true, "token": "..." }`
|
||||||
|
|
||||||
|
+ `DELETE /api/token` Logout / revoke token
|
||||||
|
|
||||||
|
*output* `{ "success": true }`
|
||||||
|
|||||||
@@ -2,39 +2,96 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
## environment
|
Properties are loaded in this order (later overrides earlier):
|
||||||
|
|
||||||
Controls the running environment: `dev` for development, `prod` for distributed instances.
|
1. Default properties embedded in WAR/JAR
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## mode
|
Values:
|
||||||
|
- `dev` - Development mode: enables CORS headers and additional logging
|
||||||
|
- `prod` - Production: for distributed instances
|
||||||
|
|
||||||
Running mode: `standalone`, `client` or `server`.
|
## Mode
|
||||||
|
|
||||||
|
Running mode for the application.
|
||||||
|
|
||||||
```
|
```
|
||||||
mode = standalone
|
mode = standalone
|
||||||
```
|
```
|
||||||
|
|
||||||
## authentication
|
Values:
|
||||||
|
- `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: `none`, `sesame` for a shared unique password, `oauth` for email and/or oauth accounts.
|
## Authentication
|
||||||
|
|
||||||
|
Authentication method for the application.
|
||||||
|
|
||||||
```
|
```
|
||||||
auth = none
|
auth = none
|
||||||
```
|
```
|
||||||
|
|
||||||
When running in client or server mode, if `auth` is not `none`, the following extra property is needed:
|
Values:
|
||||||
|
- `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>
|
||||||
```
|
```
|
||||||
|
|
||||||
## webapp connector
|
This secret is shared between API and View webapps. Auto-generated in standalone mode.
|
||||||
|
|
||||||
Pairgoth webapp connector configuration.
|
### Sesame password
|
||||||
|
|
||||||
|
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
|
||||||
@@ -44,7 +101,10 @@ webapp.context = /
|
|||||||
webapp.external.url = http://localhost:8080
|
webapp.external.url = http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
## api connector
|
- `webapp.host` (or `webapp.interface`) - Hostname/interface to bind to
|
||||||
|
- `webapp.external.url` - External URL for OAuth redirects and client configuration
|
||||||
|
|
||||||
|
## API connector
|
||||||
|
|
||||||
Pairgoth API connector configuration.
|
Pairgoth API connector configuration.
|
||||||
|
|
||||||
@@ -56,28 +116,91 @@ api.context = /api
|
|||||||
api.external.url = http://localhost:8085/api
|
api.external.url = http://localhost:8085/api
|
||||||
```
|
```
|
||||||
|
|
||||||
## store
|
Note: In standalone mode, API port defaults to 8080 and context to `/api/tour`.
|
||||||
|
|
||||||
Persistent storage for tournaments, `memory` (mainly used for tests) or `file`.
|
## SSL/TLS configuration
|
||||||
|
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## smtp
|
Values for `store`:
|
||||||
|
- `file` - Persistent XML files (default)
|
||||||
|
- `memory` - RAM-based (mainly for tests)
|
||||||
|
|
||||||
SMTP configuration. Not yet functional.
|
The `store.file.path` is relative to the current working directory.
|
||||||
|
|
||||||
|
## Ratings
|
||||||
|
|
||||||
|
### Ratings directory
|
||||||
|
|
||||||
```
|
```
|
||||||
smtp.sender =
|
ratings.path = ratings
|
||||||
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 =
|
smtp.user = username
|
||||||
smtp.password =
|
smtp.password = password
|
||||||
```
|
```
|
||||||
|
|
||||||
## logging
|
## Logging
|
||||||
|
|
||||||
Logging configuration.
|
Logging configuration.
|
||||||
|
|
||||||
@@ -86,34 +209,48 @@ logger.level = info
|
|||||||
logger.format = [%level] %ip [%logger] %message
|
logger.format = [%level] %ip [%logger] %message
|
||||||
```
|
```
|
||||||
|
|
||||||
## ratings
|
Log levels: `trace`, `debug`, `info`, `warn`, `error`
|
||||||
|
|
||||||
Ratings configuration. `<ratings>` stands for `egf` or `ffg` in the following.
|
Format placeholders: `%level`, `%ip`, `%logger`, `%message`
|
||||||
|
|
||||||
### freeze ratings date
|
## Example configurations
|
||||||
|
|
||||||
If the following property is given:
|
### Standalone development
|
||||||
|
|
||||||
```
|
```properties
|
||||||
ratings.<ratings>.file = ...
|
env = dev
|
||||||
|
mode = standalone
|
||||||
|
auth = none
|
||||||
|
store = file
|
||||||
|
store.file.path = tournamentfiles
|
||||||
|
logger.level = trace
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
### Client-server deployment
|
||||||
|
|
||||||
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.
|
**Server (API):**
|
||||||
|
```properties
|
||||||
### enable or disable ratings
|
env = prod
|
||||||
|
mode = server
|
||||||
Whether to display the EGF or FFG ratings button in the Add Player popup:
|
auth = oauth
|
||||||
|
auth.shared_secret = 1234567890abcdef
|
||||||
```
|
api.port = 8085
|
||||||
ratings.<ratings>.enable = true | false
|
store = file
|
||||||
|
store.file.path = /var/tournaments
|
||||||
|
logger.level = info
|
||||||
```
|
```
|
||||||
|
|
||||||
Whether to show the ratings player IDs on the registration page:
|
**Client (Web UI):**
|
||||||
|
```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.
|
|
||||||
|
|||||||
306
doc/model.md
306
doc/model.md
@@ -1,9 +1,7 @@
|
|||||||
# 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
|
||||||
|
|
||||||
@@ -11,22 +9,23 @@ erDiagram
|
|||||||
|
|
||||||
Tournament {
|
Tournament {
|
||||||
int id
|
int id
|
||||||
string type
|
Type 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 isOnline
|
bool online
|
||||||
int rounds
|
int rounds
|
||||||
int gobanSize
|
int gobanSize
|
||||||
string rules
|
Rules rules
|
||||||
int komi
|
double komi
|
||||||
}
|
}
|
||||||
|
|
||||||
TimeSystem {
|
TimeSystem {
|
||||||
string type
|
TimeSystemType type
|
||||||
int mainTime
|
int mainTime
|
||||||
int increment
|
int increment
|
||||||
int maxTime
|
int maxTime
|
||||||
@@ -37,18 +36,17 @@ erDiagram
|
|||||||
|
|
||||||
Pairing {
|
Pairing {
|
||||||
PairingType type
|
PairingType type
|
||||||
BaseParams base
|
PairingParams pairingParams
|
||||||
MainParams main
|
PlacementParams placementParams
|
||||||
SecondaryParams secondary
|
|
||||||
GeographicalParams geo
|
|
||||||
HandicapParams handicap
|
|
||||||
PlacementParams place
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Game {
|
Game {
|
||||||
|
int id
|
||||||
int table
|
int table
|
||||||
int handicap
|
int handicap
|
||||||
string result
|
Result result
|
||||||
|
int drawnUpDown
|
||||||
|
bool forcedTable
|
||||||
}
|
}
|
||||||
|
|
||||||
Player {
|
Player {
|
||||||
@@ -58,13 +56,26 @@ erDiagram
|
|||||||
string country
|
string country
|
||||||
string club
|
string club
|
||||||
int rating
|
int rating
|
||||||
string rank
|
int rank
|
||||||
bool final
|
bool final
|
||||||
array skip
|
int mmsCorrection
|
||||||
|
set skip
|
||||||
|
map externalIds
|
||||||
|
}
|
||||||
|
|
||||||
|
Team {
|
||||||
|
int id
|
||||||
|
string name
|
||||||
|
set playerIds
|
||||||
|
int rating
|
||||||
|
int rank
|
||||||
|
bool final
|
||||||
|
int mmsCorrection
|
||||||
|
set skip
|
||||||
}
|
}
|
||||||
|
|
||||||
Standings {
|
Standings {
|
||||||
array criteria
|
list criteria
|
||||||
}
|
}
|
||||||
|
|
||||||
%% relationships
|
%% relationships
|
||||||
@@ -72,9 +83,266 @@ erDiagram
|
|||||||
Tournament ||--|{ TimeSystem: "time system"
|
Tournament ||--|{ TimeSystem: "time system"
|
||||||
Tournament ||--|{ Pairing: "pairing"
|
Tournament ||--|{ Pairing: "pairing"
|
||||||
Tournament ||--|{ Game: "round"
|
Tournament ||--|{ Game: "round"
|
||||||
Tournament }o--|{ Player: "participate(round)"
|
Tournament }o--|{ Player: "players"
|
||||||
|
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.20</version>
|
<version>0.23</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.20</version>
|
<version>0.23</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.20</version>
|
<version>0.23</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>view-webapp</artifactId>
|
<artifactId>view-webapp</artifactId>
|
||||||
|
|
||||||
@@ -113,6 +113,7 @@
|
|||||||
<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,9 +36,6 @@ 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 val reader by lazy { DirectoryReader.open(directory) }
|
private lateinit var reader: DirectoryReader
|
||||||
private val searcher by lazy { IndexSearcher(reader) }
|
private lateinit var searcher: IndexSearcher
|
||||||
|
|
||||||
// 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,6 +68,9 @@ 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,8 +21,12 @@ 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()
|
private val client = OkHttpClient.Builder()
|
||||||
|
.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
|
||||||
@@ -102,6 +106,11 @@ 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 {
|
||||||
players = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings ->
|
val newPlayers = 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,17 +64,12 @@ object RatingsManager: Runnable {
|
|||||||
ratings.fetchPlayers(Paths.get("").resolve(ratingsFile).toFile())
|
ratings.fetchPlayers(Paths.get("").resolve(ratingsFile).toFile())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val updated = ratingsHandlers.values.filter { it.active }.map { it.updated() }.reduce { u1, u2 ->
|
// Always update players and index together under the write lock
|
||||||
u1 or u2
|
// Index must be rebuilt every time since it stores array indices
|
||||||
}
|
|
||||||
if (updated) {
|
|
||||||
try {
|
try {
|
||||||
updateLock.writeLock().lock()
|
updateLock.writeLock().lock()
|
||||||
|
players = newPlayers
|
||||||
index.build(players)
|
index.build(players)
|
||||||
} finally {
|
|
||||||
updateLock.writeLock().unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// propagate French players license status from ffg to egf
|
// propagate French players license status from ffg to egf
|
||||||
val licenseStatus = players.map { it -> it as Json.MutableObject }.filter {
|
val licenseStatus = players.map { it -> it as Json.MutableObject }.filter {
|
||||||
@@ -93,7 +88,9 @@ object RatingsManager: Runnable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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",
|
||||||
"gb" to "United Kingdom",
|
"uk" to "United Kingdom",
|
||||||
"gd" to "Grenada",
|
"gd" to "Grenada",
|
||||||
"ge" to "Georgia",
|
"ge" to "Georgia",
|
||||||
"gg" to "Guernsey",
|
"gg" to "Guernsey",
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"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,10 +523,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#logout {
|
#logout, #settings {
|
||||||
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,6 +44,9 @@
|
|||||||
#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>
|
||||||
@@ -84,6 +87,23 @@
|
|||||||
#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,6 +1,21 @@
|
|||||||
// 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;
|
||||||
@@ -349,6 +364,21 @@ 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,7 +247,8 @@ 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,15 +69,21 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div id="paired" class="multi-select" title="white vs. black">##
|
<div id="paired" class="multi-select" title="#if($blackFirst)black vs. white#{else}white vs. black#end">##
|
||||||
#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##
|
||||||
@@ -98,8 +104,13 @@
|
|||||||
<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>
|
||||||
@@ -109,8 +120,13 @@
|
|||||||
#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,6 +142,12 @@
|
|||||||
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,11 +100,12 @@
|
|||||||
#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="$esc.html($teamName)">
|
<td title="#if($teamName)$esc.html($teamName)#end">
|
||||||
$esc.html($utils.truncate($!teamName, 10))
|
#if($teamName)$esc.html($utils.truncate($teamName, 10))#end
|
||||||
</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,23 +18,39 @@
|
|||||||
<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':'0-1', 'b':'1-0', '=':'½-½', 'X':'X', '#':'1-1', '0':'0-0'})
|
#set($dispRst = {'?':'?', 'w':'1-0', 'b':'0-1', '=':'½-½', '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 = $plmap[$game.w])
|
#set($white = $resultsMap[$game.w])
|
||||||
#set($black = $plmap[$game.b])
|
#set($black = $resultsMap[$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">$dispRst[$game.r]</td>
|
<td class="result centered" data-sort="$game.r" data-result="$game.r">#if($blackFirst)$dispRstInv[$game.r]#{else}$dispRst[$game.r]#end</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.20</version>
|
<version>0.23</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>webserver</artifactId>
|
<artifactId>webserver</artifactId>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|||||||
Reference in New Issue
Block a user