Compare commits
25 Commits
b2f2e033dc
...
v0.23.0
| 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
|
||||
/docker/data
|
||||
/.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>
|
||||
<groupId>org.jeudego.pairgoth</groupId>
|
||||
<artifactId>engine-parent</artifactId>
|
||||
<version>0.20</version>
|
||||
<version>0.23</version>
|
||||
</parent>
|
||||
<artifactId>api-webapp</artifactId>
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ object StandingsHandler: PairgothApiHandler {
|
||||
"""
|
||||
; CL[${egfClass}]
|
||||
; EV[${tournament.name}]
|
||||
; PC[${tournament.country.lowercase()},${tournament.location}]
|
||||
; PC[${tournament.country.uppercase()},${tournament.location}]
|
||||
; DT[${tournament.startDate},${tournament.endDate}]
|
||||
; HA[${
|
||||
if (tournament.pairing.type == PairingType.MAC_MAHON) "h${tournament.pairing.pairingParams.handicap.correction}"
|
||||
@@ -123,7 +123,7 @@ object StandingsHandler: PairgothApiHandler {
|
||||
}]
|
||||
; KM[${tournament.komi}]
|
||||
; 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(" ") }
|
||||
${
|
||||
@@ -132,23 +132,24 @@ ${
|
||||
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)
|
||||
} ${
|
||||
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
|
||||
} ${
|
||||
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, ' ') }
|
||||
} ${
|
||||
player.getArray("results")!!.map {
|
||||
(it as String).padStart(8, ' ')
|
||||
}.joinToString(" ")
|
||||
}${
|
||||
player.getString("egf")?.let { if (it.length == 8) " |$it" else "" } ?: ""
|
||||
}"
|
||||
}
|
||||
}
|
||||
@@ -156,14 +157,12 @@ ${
|
||||
writer.println(ret)
|
||||
}
|
||||
|
||||
private fun String.toSnake(upper: Boolean = false): String {
|
||||
private fun String.toCapitals(): String {
|
||||
val sanitized = sanitizeISO()
|
||||
val parts = sanitized.trim().split(Regex("(?:\\s|\\xA0)+"))
|
||||
val snake = parts.joinToString("_") { part ->
|
||||
if (upper) part.uppercase(Locale.ROOT)
|
||||
else part.capitalize()
|
||||
return parts.joinToString("_") { part ->
|
||||
part.lowercase(Locale.ROOT).replaceFirstChar { it.titlecase(Locale.ROOT) }
|
||||
}
|
||||
return snake
|
||||
}
|
||||
|
||||
private fun String.sanitizeISO(): String {
|
||||
@@ -176,12 +175,13 @@ ${
|
||||
|
||||
private fun exportToFFGFormat(tournament: Tournament<*>, lines: List<Json.Object>, writer: PrintWriter) {
|
||||
val version = WebappManager.properties.getProperty("version")!!
|
||||
val ffgName = "${ffgDate.format(tournament.startDate)}-${tournament.location.lowercase(Locale.ROOT).sanitizeISO()}"
|
||||
val ret =
|
||||
""";name=${tournament.shortName}
|
||||
""";name=$ffgName
|
||||
;date=${frDate.format(tournament.startDate)}
|
||||
;vill=${tournament.location}${if (tournament.online) "(online)" else ""}
|
||||
;comm=${tournament.name}
|
||||
;prog=Pairgoth v0.1
|
||||
;prog=Pairgoth $version
|
||||
;time=${tournament.timeSystem.mainTime / 60}
|
||||
;ta=${tournament.timeSystem.adjustedTime() / 60}
|
||||
;size=${tournament.gobanSize}
|
||||
@@ -189,9 +189,12 @@ ${
|
||||
; Generated by Pairgoth $version
|
||||
; ${
|
||||
when (tournament.timeSystem.type) {
|
||||
CANADIAN -> "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"
|
||||
FISCHER -> "Fisher ${tournament.timeSystem.mainTime / 60} minutes + ${tournament.timeSystem.increment} seconds${ if (tournament.timeSystem.maxTime > 0) ", max ${tournament.timeSystem.maxTime / 60} minutes" else "" }"
|
||||
CANADIAN -> if (tournament.timeSystem.byoyomi > 0) "Canadian ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.stones} stones / ${tournament.timeSystem.byoyomi / 60} minutes"
|
||||
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -202,14 +205,14 @@ ${
|
||||
"${
|
||||
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, ' ')
|
||||
} ${
|
||||
player.getString("ffg") ?: " "
|
||||
} ${
|
||||
if (player.getString("country") == "FR")
|
||||
(player.getString("club") ?: "").toSnake().padEnd(4).take(4)
|
||||
(player.getString("club") ?: "").toCapitals().padEnd(4).take(4)
|
||||
else
|
||||
(player.getString("country") ?: "").padEnd(4).take(4)
|
||||
} ${
|
||||
@@ -267,4 +270,5 @@ ${
|
||||
|
||||
private val numFormat = DecimalFormat("###0.#")
|
||||
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 org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY
|
||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||
import org.jeudego.pairgoth.ext.MacMahon39
|
||||
import org.jeudego.pairgoth.ext.OpenGotha
|
||||
import org.jeudego.pairgoth.model.BaseCritParams
|
||||
import org.jeudego.pairgoth.model.TeamTournament
|
||||
@@ -55,7 +56,7 @@ object TournamentHandler: PairgothApiHandler {
|
||||
override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? {
|
||||
val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) {
|
||||
is Json.Object -> Tournament.fromJson(getObjectPayload(request))
|
||||
is Element -> OpenGotha.import(payload)
|
||||
is Element -> if (MacMahon39.isFormat(payload)) MacMahon39.import(payload) else OpenGotha.import(payload)
|
||||
else -> badRequest("missing or invalid payload")
|
||||
}
|
||||
tournament.recomputeDUDD()
|
||||
|
||||
@@ -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"),
|
||||
rank = json.getInt("rank") ?: default?.rank ?: badRequest("missing rank"),
|
||||
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)
|
||||
if (up == "UK") "GB" else up
|
||||
if (up == "GB") "UK" else up
|
||||
},
|
||||
club = json.getString("club") ?: default?.club ?: badRequest("missing club"),
|
||||
final = json.getBoolean("final") ?: default?.final ?: true,
|
||||
|
||||
@@ -87,6 +87,7 @@ data class GeographicalParams(
|
||||
val preferMMSDiffRatherThanSameClubsGroup: Int = 2, // Typically = 2
|
||||
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 avoidSameFamily: Boolean = false, // When enabled, avoid pairing players from the same club with the same family name
|
||||
) {
|
||||
companion object {
|
||||
val disabled = GeographicalParams(avoidSameGeo = 0.0)
|
||||
|
||||
@@ -66,12 +66,30 @@ abstract class BasePairingHelper(
|
||||
}
|
||||
|
||||
// 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 {
|
||||
pairables.groupingBy { it.club }.eachCount().values.maxOrNull()!!
|
||||
clubCounts.values.maxOrNull() ?: 0
|
||||
}
|
||||
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
|
||||
|
||||
@@ -443,14 +443,14 @@ sealed class Solver(
|
||||
|
||||
val geoMaxCost = pairing.geo.avoidSameGeo
|
||||
|
||||
val countryFactor: Int = if (legacyMode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold)
|
||||
preferMMSDiffRatherThanSameCountry
|
||||
else
|
||||
0
|
||||
val clubFactor: Int = if (legacyMode || biggestClubSize.toDouble() / pairables.size <= proportionMainClubThreshold)
|
||||
preferMMSDiffRatherThanSameClub
|
||||
else
|
||||
0
|
||||
// Country factor: in legacy mode or when no dominant country, use normal factor
|
||||
val countryFactor: Int = if (legacyMode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold)
|
||||
preferMMSDiffRatherThanSameCountry
|
||||
else
|
||||
0
|
||||
|
||||
// Club factor: always use the configured value
|
||||
val clubFactor: Int = preferMMSDiffRatherThanSameClub
|
||||
//val groupFactor: Int = preferMMSDiffRatherThanSameClubsGroup
|
||||
|
||||
// Same country
|
||||
@@ -463,27 +463,55 @@ sealed class Solver(
|
||||
// Same club and club group (TODO club group)
|
||||
var clubRatio = 0.0
|
||||
// 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 commonGroup = false // TODO
|
||||
|
||||
if (commonGroup && !commonClub) {
|
||||
// Local club adjustment (non-legacy mode only):
|
||||
// When a local club exists, we want to encourage local-vs-stranger pairings.
|
||||
// - Ist vs Ist: full bonus (treat as different clubs)
|
||||
// - Ist vs non-Ist: full bonus (different clubs, and mixing locals with visitors)
|
||||
// - non-Ist vs non-Ist (different clubs): half bonus (prefer local-stranger mixing)
|
||||
// - non-Ist vs non-Ist (same club): no bonus (normal same-club behavior)
|
||||
val p1Local = p1.isFromLocalClub()
|
||||
val p2Local = p2.isFromLocalClub()
|
||||
val bothStrangers = !legacyMode && hasLocalClub && !p1Local && !p2Local
|
||||
|
||||
val effectiveCommonClub: Boolean = if (!legacyMode && hasLocalClub && commonClub) {
|
||||
// Both from local club: treat as different clubs (effectiveCommonClub = false)
|
||||
// Both strangers from same club: normal same-club (effectiveCommonClub = true)
|
||||
// Mixed (one local, one stranger): treat as different (effectiveCommonClub = false)
|
||||
bothStrangers
|
||||
} else {
|
||||
commonClub
|
||||
}
|
||||
|
||||
if (commonGroup && !effectiveCommonClub) {
|
||||
clubRatio = if (clubFactor == 0) {
|
||||
0.0
|
||||
} else {
|
||||
clubFactor.toDouble() / 2.0 / 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 && !commonClub) {
|
||||
} else if (!commonGroup && !effectiveCommonClub) {
|
||||
clubRatio = if (clubFactor == 0) {
|
||||
0.0
|
||||
} 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() * 1.2 / placementScoreRange.toDouble()
|
||||
}
|
||||
}
|
||||
// else: effectiveCommonClub = true → clubRatio stays 0 (no bonus for same club)
|
||||
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
|
||||
val mainPart = max(countryRatio, clubRatio)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package org.jeudego.pairgoth.test
|
||||
|
||||
import org.jeudego.pairgoth.ext.MacMahon39
|
||||
import org.jeudego.pairgoth.ext.OpenGotha
|
||||
import org.jeudego.pairgoth.model.toJson
|
||||
import org.jeudego.pairgoth.util.XmlUtils
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.charset.StandardCharsets
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ImportExportTests: TestBase() {
|
||||
|
||||
@@ -56,4 +58,73 @@ class ImportExportTests: TestBase() {
|
||||
assertEquals(jsonTournament, jsonTournament2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `003 test macmahon39 import`() {
|
||||
getTestResources("macmahon39")?.forEach { file ->
|
||||
logger.info("===== Testing MacMahon 3.9 import: ${file.name} =====")
|
||||
val resource = file.readText(StandardCharsets.UTF_8)
|
||||
val root_xml = XmlUtils.parse(resource)
|
||||
|
||||
// Verify format detection
|
||||
assertTrue(MacMahon39.isFormat(root_xml), "File should be detected as MacMahon 3.9 format")
|
||||
|
||||
// Import tournament
|
||||
val tournament = MacMahon39.import(root_xml)
|
||||
|
||||
// Verify basic tournament data
|
||||
logger.info("Tournament name: ${tournament.name}")
|
||||
logger.info("Number of rounds: ${tournament.rounds}")
|
||||
logger.info("Number of players: ${tournament.pairables.size}")
|
||||
|
||||
assertEquals("Test MacMahon Tournament", tournament.name)
|
||||
assertEquals(3, tournament.rounds)
|
||||
assertEquals(4, tournament.pairables.size)
|
||||
|
||||
// Verify players
|
||||
val players = tournament.pairables.values.toList()
|
||||
val alice = players.find { it.name == "Smith" }
|
||||
val bob = players.find { it.name == "Jones" }
|
||||
val carol = players.find { it.name == "White" }
|
||||
val david = players.find { it.name == "Brown" }
|
||||
|
||||
assertTrue(alice != null, "Alice should exist")
|
||||
assertTrue(bob != null, "Bob should exist")
|
||||
assertTrue(carol != null, "Carol should exist")
|
||||
assertTrue(david != null, "David should exist")
|
||||
|
||||
assertEquals(2, alice!!.rank) // 3d = rank 2
|
||||
assertEquals(1, bob!!.rank) // 2d = rank 1
|
||||
assertEquals(0, carol!!.rank) // 1d = rank 0
|
||||
assertEquals(-1, david!!.rank) // 1k = rank -1
|
||||
|
||||
// Carol is super bar member
|
||||
assertEquals(1, carol.mmsCorrection)
|
||||
|
||||
// David skips round 2
|
||||
assertTrue(david.skip.contains(2), "David should skip round 2")
|
||||
|
||||
// Verify games
|
||||
val round1Games = tournament.games(1).values.toList()
|
||||
val round2Games = tournament.games(2).values.toList()
|
||||
|
||||
logger.info("Round 1 games: ${round1Games.size}")
|
||||
logger.info("Round 2 games: ${round2Games.size}")
|
||||
|
||||
assertEquals(2, round1Games.size)
|
||||
assertEquals(2, round2Games.size) // 1 regular game + 1 bye
|
||||
|
||||
// Test via API
|
||||
val resp = TestAPI.post("/api/tour", resource)
|
||||
val id = resp.asObject().getInt("id")
|
||||
logger.info("Imported tournament id: $id")
|
||||
|
||||
val apiTournament = TestAPI.get("/api/tour/$id").asObject()
|
||||
assertEquals("Test MacMahon Tournament", apiTournament.getString("name"))
|
||||
assertEquals(3, apiTournament.getInt("rounds"))
|
||||
|
||||
val apiPlayers = TestAPI.get("/api/tour/$id/part").asArray()
|
||||
assertEquals(4, apiPlayers.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<groupId>org.jeudego.pairgoth</groupId>
|
||||
<artifactId>engine-parent</artifactId>
|
||||
<version>0.20</version>
|
||||
<version>0.23</version>
|
||||
</parent>
|
||||
<artifactId>application</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
215
doc/API.md
215
doc/API.md
@@ -2,38 +2,73 @@
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
+ /api/tour GET POST Tournaments handling
|
||||
+ /api/tour/#tid GET PUT DELETE Tournaments handling
|
||||
+ /api/tour/#tid/part GET POST Registration handling
|
||||
+ /api/tour/#tid/part/#pid GET PUT DELETE Registration handling
|
||||
+ /api/tour/#tid/team GET POST 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/res/#rn GET PUT DELETE Results
|
||||
+ /api/tour/#tid/standings GET Standings
|
||||
+ /api/tour/#tid/stand/#rn GET Standings
|
||||
+ /api/tour GET POST Tournaments handling
|
||||
+ /api/tour/#tid GET PUT DELETE Tournaments handling
|
||||
+ /api/tour/#tid/part GET POST Registration handling
|
||||
+ /api/tour/#tid/part/#pid GET PUT DELETE Registration handling
|
||||
+ /api/tour/#tid/team GET POST 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/res/#rn GET PUT DELETE Results
|
||||
+ /api/tour/#tid/standings GET PUT Standings
|
||||
+ /api/tour/#tid/stand/#rn GET Standings
|
||||
+ /api/tour/#tid/explain/#rn GET Pairing explanation
|
||||
+ /api/token GET POST DELETE Authentication
|
||||
|
||||
## Tournament handling
|
||||
|
||||
+ `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
|
||||
|
||||
*output* json object for tournament #tid
|
||||
|
||||
Supports `Accept: application/xml` to get OpenGotha XML export.
|
||||
|
||||
+ `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 }`
|
||||
|
||||
@@ -43,19 +78,40 @@ POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success":
|
||||
|
||||
*output* `{ "success": true }`
|
||||
|
||||
+ `DELETE /api/tour/#tid` Delete a tournament
|
||||
|
||||
*output* `{ "success": true }`
|
||||
|
||||
## Players handling
|
||||
|
||||
+ `GET /api/tour/#tid/part` Get a list of registered 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
|
||||
|
||||
+ `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 }`
|
||||
|
||||
@@ -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
|
||||
|
||||
*input* `{ "id": #pid }`
|
||||
|
||||
*output* `{ "success": true }`
|
||||
|
||||
## Teams handling
|
||||
|
||||
For team tournaments (PAIRGO, RENGO2, RENGO3, TEAM2-5).
|
||||
|
||||
+ `GET /api/tour/#tid/team` Get a list of registered 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
|
||||
|
||||
*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)
|
||||
|
||||
*output* `{ "success": true }`
|
||||
|
||||
+ `DELETE /api/tour/#tid/team/#tid` Delete a team registration
|
||||
|
||||
*input* `{ "id": #tid }`
|
||||
+ `DELETE /api/tour/#tid/team/#teamid` Delete a team registration
|
||||
|
||||
*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
|
||||
|
||||
*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, ... ]`
|
||||
|
||||
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 }, ... ]`
|
||||
|
||||
+ `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> }`
|
||||
|
||||
For table renumbering:
|
||||
*input* `{ "renumber": <game_id or null>, "orderBy": "mms" | "table" }`
|
||||
|
||||
*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, ... ]`
|
||||
|
||||
Games with results already entered are skipped unless `"all"` is specified.
|
||||
|
||||
*output* `{ "success": true }`
|
||||
|
||||
## 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 }`
|
||||
|
||||
+ `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
|
||||
|
||||
+ `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 }, ... ]`
|
||||
where `<crit>` is the name of a criterium, among "score", "nbw", "mms", "sosm", "sososm", ...
|
||||
*output* `[ { "id": #pid, "place": place, "<crit>": value }, ... ]`
|
||||
|
||||
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
|
||||
|
||||
+ `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.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -44,7 +101,10 @@ webapp.context = /
|
||||
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.
|
||||
|
||||
@@ -56,28 +116,91 @@ api.context = /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.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 =
|
||||
smtp.host =
|
||||
ratings.path = ratings
|
||||
```
|
||||
|
||||
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.user =
|
||||
smtp.password =
|
||||
smtp.user = username
|
||||
smtp.password = password
|
||||
```
|
||||
|
||||
## logging
|
||||
## Logging
|
||||
|
||||
Logging configuration.
|
||||
|
||||
@@ -86,34 +209,48 @@ logger.level = info
|
||||
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
|
||||
|
||||
```
|
||||
ratings.<ratings>.file = ...
|
||||
```properties
|
||||
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.
|
||||
|
||||
### enable or disable ratings
|
||||
|
||||
Whether to display the EGF or FFG ratings button in the Add Player popup:
|
||||
|
||||
```
|
||||
ratings.<ratings>.enable = true | false
|
||||
**Server (API):**
|
||||
```properties
|
||||
env = prod
|
||||
mode = server
|
||||
auth = oauth
|
||||
auth.shared_secret = 1234567890abcdef
|
||||
api.port = 8085
|
||||
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
|
||||
|
||||
For simplicity, teams (pairgo, rengo) and teams of individuals (clubs championships) are not included.
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
|
||||
@@ -11,22 +9,23 @@ erDiagram
|
||||
|
||||
Tournament {
|
||||
int id
|
||||
string type
|
||||
Type type
|
||||
string name
|
||||
string shortName
|
||||
date startDate
|
||||
date endDate
|
||||
string director
|
||||
string country
|
||||
string location
|
||||
bool isOnline
|
||||
bool online
|
||||
int rounds
|
||||
int gobanSize
|
||||
string rules
|
||||
int komi
|
||||
Rules rules
|
||||
double komi
|
||||
}
|
||||
|
||||
TimeSystem {
|
||||
string type
|
||||
TimeSystemType type
|
||||
int mainTime
|
||||
int increment
|
||||
int maxTime
|
||||
@@ -37,18 +36,17 @@ erDiagram
|
||||
|
||||
Pairing {
|
||||
PairingType type
|
||||
BaseParams base
|
||||
MainParams main
|
||||
SecondaryParams secondary
|
||||
GeographicalParams geo
|
||||
HandicapParams handicap
|
||||
PlacementParams place
|
||||
PairingParams pairingParams
|
||||
PlacementParams placementParams
|
||||
}
|
||||
|
||||
Game {
|
||||
int id
|
||||
int table
|
||||
int handicap
|
||||
string result
|
||||
Result result
|
||||
int drawnUpDown
|
||||
bool forcedTable
|
||||
}
|
||||
|
||||
Player {
|
||||
@@ -58,13 +56,26 @@ erDiagram
|
||||
string country
|
||||
string club
|
||||
int rating
|
||||
string rank
|
||||
int rank
|
||||
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 {
|
||||
array criteria
|
||||
list criteria
|
||||
}
|
||||
|
||||
%% relationships
|
||||
@@ -72,9 +83,266 @@ erDiagram
|
||||
Tournament ||--|{ TimeSystem: "time system"
|
||||
Tournament ||--|{ Pairing: "pairing"
|
||||
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: "white"
|
||||
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>
|
||||
<groupId>org.jeudego.pairgoth</groupId>
|
||||
<artifactId>engine-parent</artifactId>
|
||||
<version>0.20</version>
|
||||
<version>0.23</version>
|
||||
</parent>
|
||||
<artifactId>pairgoth-common</artifactId>
|
||||
|
||||
|
||||
2
pom.xml
2
pom.xml
@@ -5,7 +5,7 @@
|
||||
|
||||
<groupId>org.jeudego.pairgoth</groupId>
|
||||
<artifactId>engine-parent</artifactId>
|
||||
<version>0.20</version>
|
||||
<version>0.23</version>
|
||||
<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 -->
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<parent>
|
||||
<groupId>org.jeudego.pairgoth</groupId>
|
||||
<artifactId>engine-parent</artifactId>
|
||||
<version>0.20</version>
|
||||
<version>0.23</version>
|
||||
</parent>
|
||||
<artifactId>view-webapp</artifactId>
|
||||
|
||||
@@ -113,6 +113,7 @@
|
||||
<include>index.css</include>
|
||||
<include>main.css</include>
|
||||
<include>tour.css</include>
|
||||
<include>explain.css</include>
|
||||
</includes>
|
||||
</resource>
|
||||
</webResources>
|
||||
|
||||
@@ -36,9 +36,6 @@ object EGFRatingsHandler: RatingsHandler(RatingsManager.Ratings.EGF) {
|
||||
if (adjusted < 0) "${-(adjusted - 99) / 100}k"
|
||||
else "${(adjusted + 100) / 100}d"
|
||||
}
|
||||
if ("UK" == player.getString("country")) {
|
||||
player["country"] = "GB"
|
||||
}
|
||||
// fix for missing firstnames
|
||||
if (player.getString("firstname") == null) {
|
||||
player["firstname"] = ""
|
||||
|
||||
@@ -39,8 +39,8 @@ class PlayerIndex {
|
||||
val stopChars = Regex("[_-]")
|
||||
}
|
||||
private final val directory: Directory = ByteBuffersDirectory(NoLockFactory.INSTANCE)
|
||||
private val reader by lazy { DirectoryReader.open(directory) }
|
||||
private val searcher by lazy { IndexSearcher(reader) }
|
||||
private lateinit var reader: DirectoryReader
|
||||
private lateinit var searcher: IndexSearcher
|
||||
|
||||
// helper functions
|
||||
fun Json.Object.field(key: String) = getString(key) ?: throw Error("missing $key")
|
||||
@@ -68,6 +68,9 @@ class PlayerIndex {
|
||||
++count
|
||||
}
|
||||
}
|
||||
// Refresh reader and searcher to see the new index
|
||||
reader = DirectoryReader.open(directory)
|
||||
searcher = IndexSearcher(reader)
|
||||
logger.info("indexed $count players")
|
||||
}
|
||||
|
||||
|
||||
@@ -21,8 +21,12 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
|
||||
companion object {
|
||||
private val delay = TimeUnit.HOURS.toMillis(1L)
|
||||
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
|
||||
open val active = true
|
||||
lateinit var players: Json.Array
|
||||
@@ -102,6 +106,11 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
|
||||
try {
|
||||
val request = Request.Builder()
|
||||
.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()
|
||||
|
||||
client.newCall(request).execute().use { response ->
|
||||
|
||||
@@ -56,7 +56,7 @@ object RatingsManager: Runnable {
|
||||
object Task: TimerTask() {
|
||||
override fun run() {
|
||||
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?
|
||||
if (ratingsFile == null) {
|
||||
ratings.fetchPlayers()
|
||||
@@ -64,36 +64,33 @@ object RatingsManager: Runnable {
|
||||
ratings.fetchPlayers(Paths.get("").resolve(ratingsFile).toFile())
|
||||
}
|
||||
}
|
||||
val updated = ratingsHandlers.values.filter { it.active }.map { it.updated() }.reduce { u1, u2 ->
|
||||
u1 or u2
|
||||
}
|
||||
if (updated) {
|
||||
try {
|
||||
updateLock.writeLock().lock()
|
||||
index.build(players)
|
||||
} finally {
|
||||
updateLock.writeLock().unlock()
|
||||
}
|
||||
}
|
||||
// Always update players and index together under the write lock
|
||||
// Index must be rebuilt every time since it stores array indices
|
||||
try {
|
||||
updateLock.writeLock().lock()
|
||||
players = newPlayers
|
||||
index.build(players)
|
||||
|
||||
// propagate French players license status from ffg to egf
|
||||
val licenseStatus = players.map { it -> it as Json.MutableObject }.filter {
|
||||
it["origin"] == "FFG"
|
||||
}.associate { player ->
|
||||
Pair(player.getString("ffg")!!, player.getString("license") ?: "-")
|
||||
}
|
||||
players.map { it -> it as Json.MutableObject }.filter {
|
||||
it["origin"] == "EGF" && it["country"] == "FR"
|
||||
}.forEach { player ->
|
||||
player.getString("egf")?.let { egf ->
|
||||
egf2ffg[egf]?.let { ffg ->
|
||||
licenseStatus[ffg]?.let {
|
||||
player["license"] = it
|
||||
// propagate French players license status from ffg to egf
|
||||
val licenseStatus = players.map { it -> it as Json.MutableObject }.filter {
|
||||
it["origin"] == "FFG"
|
||||
}.associate { player ->
|
||||
Pair(player.getString("ffg")!!, player.getString("license") ?: "-")
|
||||
}
|
||||
players.map { it -> it as Json.MutableObject }.filter {
|
||||
it["origin"] == "EGF" && it["country"] == "FR"
|
||||
}.forEach { player ->
|
||||
player.getString("egf")?.let { egf ->
|
||||
egf2ffg[egf]?.let { ffg ->
|
||||
licenseStatus[ffg]?.let {
|
||||
player["license"] = it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
updateLock.writeLock().unlock()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
logger.error("could not build or refresh index: ${e.javaClass.name} ${e.message}")
|
||||
logger.debug("could not build or refresh index", e)
|
||||
|
||||
@@ -95,7 +95,7 @@ class CountriesTool {
|
||||
"fj" to "Fiji",
|
||||
"fr" to "France",
|
||||
"ga" to "Gabon",
|
||||
"gb" to "United Kingdom",
|
||||
"uk" to "United Kingdom",
|
||||
"gd" to "Grenada",
|
||||
"ge" to "Georgia",
|
||||
"gg" to "Guernsey",
|
||||
|
||||
@@ -43,6 +43,11 @@ class ViewServlet : VelocityViewServlet() {
|
||||
}
|
||||
}
|
||||
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]
|
||||
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;
|
||||
}
|
||||
|
||||
#settings-modal {
|
||||
.setting {
|
||||
margin: 0.5em 0;
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen {
|
||||
#players-list {
|
||||
font-size: smaller;
|
||||
|
||||
@@ -44,6 +44,9 @@
|
||||
#translate('tour-menu.inc.html')
|
||||
#end
|
||||
<div id="header-right">
|
||||
<div id="settings" title="Settings">
|
||||
<i class="fa fa-cog"></i>
|
||||
</div>
|
||||
<div id="lang">
|
||||
<i class="$translate.flags[$request.lang] flag"></i>
|
||||
</div>
|
||||
@@ -84,6 +87,23 @@
|
||||
#end
|
||||
#end
|
||||
</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/tablesort-5.4.0/tablesort.min.js"></script>
|
||||
<script type="text/javascript" src="/lib/tablesort-5.4.0/sorts/tablesort.number.min.js"></script>
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
// Utilities
|
||||
|
||||
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) {
|
||||
let result = '';
|
||||
const charactersLength = characters.length;
|
||||
@@ -349,6 +364,21 @@ onLoad(() => {
|
||||
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()) {
|
||||
$("[title]").on('click', e => {
|
||||
let item = e.target.closest('[title]');
|
||||
|
||||
@@ -247,7 +247,8 @@ onLoad(() => {
|
||||
},
|
||||
geo: {
|
||||
mmsDiffCountry: form.val('mmsDiffCountry'),
|
||||
mmsDiffClub: form.val('mmsDiffClub')
|
||||
mmsDiffClub: form.val('mmsDiffClub'),
|
||||
avoidSameFamily: form.val('avoidSameFamily')
|
||||
},
|
||||
handicap: {
|
||||
useMMS: form.val('useMMS'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#macro(rank $rank)#if( $rank<0 )#set( $k = -$rank )${k}k#else#set( $d=$rank+1 )${d}d#end#end
|
||||
#if (!$tour)
|
||||
<div class="section">
|
||||
<h2 class="error">Invalid tournament id</h2>
|
||||
@@ -32,7 +33,9 @@
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th>Black</th>
|
||||
<th>Rank</th>
|
||||
<th>White</th>
|
||||
<th>Rank</th>
|
||||
<th>Handicap</th>
|
||||
<th>Komi</th>
|
||||
</tr>
|
||||
@@ -49,7 +52,9 @@
|
||||
<tr>
|
||||
<td>Table $table</td>
|
||||
<td>$black.name $!black.firstname</td>
|
||||
<td>#rank($black.rank)</td>
|
||||
<td>$white.name $!white.firstname</td>
|
||||
<td>#rank($white.rank)</td>
|
||||
<td>$game.h</td>
|
||||
<td>$komi</td>
|
||||
</tr>
|
||||
|
||||
@@ -69,15 +69,21 @@
|
||||
</button>
|
||||
</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)
|
||||
#set($white = $pmap[$game.w])
|
||||
#set($black = $pmap[$game.b])
|
||||
<div class="listitem game" data-id="$game.id">
|
||||
<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="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>
|
||||
#end
|
||||
<div class="handicap" data-value="$game.h">#if($game.h)h$game.h#{else} #end</div>
|
||||
</div>
|
||||
#end##
|
||||
@@ -98,8 +104,13 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tbl</th>
|
||||
#if($blackFirst)
|
||||
<th>Black</th>
|
||||
<th>White</th>
|
||||
#else
|
||||
<th>White</th>
|
||||
<th>Black</th>
|
||||
#end
|
||||
<th>Hd</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -109,8 +120,13 @@
|
||||
#set($black = $pmap[$game.b])
|
||||
<tr>
|
||||
<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($black)${black.name} $!{black.firstname} (#rank($black.rank) $!black.country $!black.club)#{else}BIP#end</td>
|
||||
#end
|
||||
<td>${game.h}</td>
|
||||
</tr>
|
||||
#end
|
||||
|
||||
@@ -142,6 +142,12 @@
|
||||
rather than pairing players of the same club.
|
||||
</label>
|
||||
</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 class="title"><i class="dropdown icon"></i>Handicap parameters</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -100,11 +100,12 @@
|
||||
#end
|
||||
#if($tour.type != 'INDIVIDUAL')
|
||||
#set($teamId = $tmap[$part.id])
|
||||
#set($teamName = false)
|
||||
#if($teamId)
|
||||
#set($teamName = $!pmap[$teamId].name)
|
||||
#end
|
||||
<td title="$esc.html($teamName)">
|
||||
$esc.html($utils.truncate($!teamName, 10))
|
||||
<td title="#if($teamName)$esc.html($teamName)#end">
|
||||
#if($teamName)$esc.html($utils.truncate($teamName, 10))#end
|
||||
</td>
|
||||
#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">
|
||||
<thead class="centered">
|
||||
<th data-sort-method="number">table</th>
|
||||
#if($blackFirst)
|
||||
<th>black</th>
|
||||
<th>white</th>
|
||||
#else
|
||||
<th>white</th>
|
||||
<th>black</th>
|
||||
#end
|
||||
<th>hd</th>
|
||||
<th>result</th>
|
||||
</thead>
|
||||
<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)
|
||||
#set($white = $plmap[$game.w])
|
||||
#set($black = $plmap[$game.b])
|
||||
#set($white = $resultsMap[$game.w])
|
||||
#set($black = $resultsMap[$game.b])
|
||||
#if($black && $white)
|
||||
<tr id="result-$game.id" data-id="$game.id">
|
||||
<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="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="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>
|
||||
#end
|
||||
#end
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<parent>
|
||||
<groupId>org.jeudego.pairgoth</groupId>
|
||||
<artifactId>engine-parent</artifactId>
|
||||
<version>0.20</version>
|
||||
<version>0.23</version>
|
||||
</parent>
|
||||
<artifactId>webserver</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
Reference in New Issue
Block a user