Compare commits
33 Commits
be18f159be
...
go-zveza
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc92cba82 | |||
|
|
4a4474873e | ||
|
|
dd95c48f0d | ||
|
|
9a379052e5 | ||
|
|
17697845fd | ||
|
|
147347fa6e | ||
|
|
617f715923 | ||
|
|
254bf6893f | ||
|
|
a256b9ad1f | ||
|
|
174b3adb53 | ||
|
|
4788ef7bc9 | ||
|
|
a6881d1276 | ||
|
|
e063f6c73c | ||
|
|
576be99952 | ||
|
|
4daa707f3e | ||
|
|
cbadb4d6bb | ||
|
|
67d8428b85 | ||
|
|
72f5fe540c | ||
|
|
935f53cf65 | ||
|
|
09c8e834f6 | ||
|
|
667b3e17da | ||
|
|
4113d76904 | ||
|
|
662f438cee | ||
|
|
8ca25ec421 | ||
|
|
f2059f7943 | ||
|
|
0cc34a1f84 | ||
|
|
c3cb5826a3 | ||
|
|
84ab78c461 | ||
|
|
d47d4fc8cc | ||
|
|
3d06588889 | ||
|
|
f704f3adb2 | ||
|
|
ecec6556d1 | ||
|
|
17bb013feb |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
.claude
|
||||||
target
|
target
|
||||||
/docker/data
|
/docker/data
|
||||||
/.idea
|
/.idea
|
||||||
@@ -8,3 +9,6 @@ target
|
|||||||
*.iml
|
*.iml
|
||||||
*~
|
*~
|
||||||
pairgoth.db
|
pairgoth.db
|
||||||
|
ratings
|
||||||
|
pairgoth
|
||||||
|
pairgoth.tar.gz
|
||||||
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
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# This is largely a mirror of the original, adapted for the Slovenian Go Association.
|
||||||
|
|
||||||
# Pairgoth
|
# Pairgoth
|
||||||
|
|
||||||
Welcome to Pairgoth, your Go Pairing Engine!
|
Welcome to Pairgoth, your Go Pairing Engine!
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.jeudego.pairgoth</groupId>
|
<groupId>org.jeudego.pairgoth</groupId>
|
||||||
<artifactId>engine-parent</artifactId>
|
<artifactId>engine-parent</artifactId>
|
||||||
<version>0.20</version>
|
<version>0.23</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>api-webapp</artifactId>
|
<artifactId>api-webapp</artifactId>
|
||||||
|
|
||||||
|
|||||||
@@ -7,61 +7,23 @@ import org.jeudego.pairgoth.model.MacMahon
|
|||||||
import org.jeudego.pairgoth.model.Pairable
|
import org.jeudego.pairgoth.model.Pairable
|
||||||
import org.jeudego.pairgoth.model.PairingType
|
import org.jeudego.pairgoth.model.PairingType
|
||||||
import org.jeudego.pairgoth.model.Player
|
import org.jeudego.pairgoth.model.Player
|
||||||
|
import org.jeudego.pairgoth.model.TeamTournament
|
||||||
import org.jeudego.pairgoth.model.Tournament
|
import org.jeudego.pairgoth.model.Tournament
|
||||||
import org.jeudego.pairgoth.model.getID
|
import org.jeudego.pairgoth.model.getID
|
||||||
import org.jeudego.pairgoth.model.historyBefore
|
import org.jeudego.pairgoth.model.historyBefore
|
||||||
import org.jeudego.pairgoth.pairing.HistoryHelper
|
import org.jeudego.pairgoth.pairing.HistoryHelper
|
||||||
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
|
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
|
||||||
import kotlin.math.floor
|
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.round
|
|
||||||
|
|
||||||
// TODO CB avoid code redundancy with solvers
|
|
||||||
|
|
||||||
fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = false): List<Json.Object> {
|
fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = false): List<Json.Object> {
|
||||||
|
|
||||||
fun Pairable.mmBase(): Double {
|
|
||||||
if (pairing !is MacMahon) throw Error("invalid call: tournament is not Mac Mahon")
|
|
||||||
return min(max(rank, pairing.mmFloor), pairing.mmBar) + MacMahonSolver.mmsZero + mmsCorrection
|
|
||||||
}
|
|
||||||
|
|
||||||
fun roundScore(score: Double): Double {
|
|
||||||
val epsilon = 0.00001
|
|
||||||
// Note: this works for now because we only have .0 and .5 fractional parts
|
|
||||||
return if (pairing.pairingParams.main.roundDownScore) floor(score + epsilon)
|
|
||||||
else round(2 * score) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frozen != null) {
|
if (frozen != null) {
|
||||||
return ArrayList(frozen!!.map { it -> it as Json.Object })
|
return ArrayList(frozen!!.map { it -> it as Json.Object })
|
||||||
}
|
}
|
||||||
|
|
||||||
// CB TODO - factorize history helper creation between here and solver classes
|
val history = historyHelper(round + 1)
|
||||||
val historyHelper = HistoryHelper(historyBefore(round + 1)) {
|
|
||||||
if (pairing.type == PairingType.SWISS) {
|
|
||||||
pairables.mapValues {
|
|
||||||
Pair(0.0, wins[it.key] ?: 0.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
pairables.mapValues {
|
|
||||||
it.value.let { pairable ->
|
|
||||||
val mmBase = pairable.mmBase()
|
|
||||||
val score = roundScore(mmBase +
|
|
||||||
(nbW(pairable) ?: 0.0) +
|
|
||||||
(1..round).map { round ->
|
|
||||||
if (playersPerRound.getOrNull(round - 1)?.contains(pairable.id) == true) 0.0 else 1.0
|
|
||||||
}.sum() * pairing.pairingParams.main.mmsValueAbsent)
|
|
||||||
Pair(
|
|
||||||
if (pairing.pairingParams.main.sosValueAbsentUseBase) mmBase
|
|
||||||
else roundScore(mmBase + round/2),
|
|
||||||
score
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val neededCriteria = ArrayList(pairing.placementParams.criteria)
|
val neededCriteria = ArrayList(pairing.placementParams.criteria)
|
||||||
if (!neededCriteria.contains(Criterion.NBW)) neededCriteria.add(Criterion.NBW)
|
if (!neededCriteria.contains(Criterion.NBW)) neededCriteria.add(Criterion.NBW)
|
||||||
if (!neededCriteria.contains(Criterion.RATING)) neededCriteria.add(Criterion.RATING)
|
if (!neededCriteria.contains(Criterion.RATING)) neededCriteria.add(Criterion.RATING)
|
||||||
@@ -72,24 +34,24 @@ fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = f
|
|||||||
Criterion.CATEGORY -> StandingsHandler.nullMap
|
Criterion.CATEGORY -> StandingsHandler.nullMap
|
||||||
Criterion.RANK -> pairables.mapValues { it.value.rank }
|
Criterion.RANK -> pairables.mapValues { it.value.rank }
|
||||||
Criterion.RATING -> pairables.mapValues { it.value.rating }
|
Criterion.RATING -> pairables.mapValues { it.value.rating }
|
||||||
Criterion.NBW -> historyHelper.wins
|
Criterion.NBW -> history.wins
|
||||||
Criterion.MMS -> historyHelper.mms
|
Criterion.MMS -> history.mms
|
||||||
Criterion.SCOREX -> historyHelper.scoresX
|
Criterion.SCOREX -> history.scoresX
|
||||||
Criterion.STS -> StandingsHandler.nullMap
|
Criterion.STS -> StandingsHandler.nullMap
|
||||||
Criterion.CPS -> StandingsHandler.nullMap
|
Criterion.CPS -> StandingsHandler.nullMap
|
||||||
|
|
||||||
Criterion.SOSW -> historyHelper.sos
|
Criterion.SOSW -> history.sos
|
||||||
Criterion.SOSWM1 -> historyHelper.sosm1
|
Criterion.SOSWM1 -> history.sosm1
|
||||||
Criterion.SOSWM2 -> historyHelper.sosm2
|
Criterion.SOSWM2 -> history.sosm2
|
||||||
Criterion.SODOSW -> historyHelper.sodos
|
Criterion.SODOSW -> history.sodos
|
||||||
Criterion.SOSOSW -> historyHelper.sosos
|
Criterion.SOSOSW -> history.sosos
|
||||||
Criterion.CUSSW -> if (round == 0) StandingsHandler.nullMap else historyHelper.cumScore
|
Criterion.CUSSW -> if (round == 0) StandingsHandler.nullMap else history.cumScore
|
||||||
Criterion.SOSM -> historyHelper.sos
|
Criterion.SOSM -> history.sos
|
||||||
Criterion.SOSMM1 -> historyHelper.sosm1
|
Criterion.SOSMM1 -> history.sosm1
|
||||||
Criterion.SOSMM2 -> historyHelper.sosm2
|
Criterion.SOSMM2 -> history.sosm2
|
||||||
Criterion.SODOSM -> historyHelper.sodos
|
Criterion.SODOSM -> history.sodos
|
||||||
Criterion.SOSOSM -> historyHelper.sosos
|
Criterion.SOSOSM -> history.sosos
|
||||||
Criterion.CUSSM -> historyHelper.cumScore
|
Criterion.CUSSM -> history.cumScore
|
||||||
|
|
||||||
Criterion.SOSTS -> StandingsHandler.nullMap
|
Criterion.SOSTS -> StandingsHandler.nullMap
|
||||||
|
|
||||||
@@ -100,14 +62,14 @@ fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = f
|
|||||||
Criterion.DC -> StandingsHandler.nullMap
|
Criterion.DC -> StandingsHandler.nullMap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val pairables = pairables.values.filter { includePreliminary || it.final }.map { it.toDetailedJson() }
|
val jsonPairables = pairables.values.filter { includePreliminary || it.final }.map { it.toDetailedJson() }
|
||||||
pairables.forEach { player ->
|
jsonPairables.forEach { player ->
|
||||||
for (crit in criteria) {
|
for (crit in criteria) {
|
||||||
player[crit.first] = crit.second[player.getID()] ?: 0.0
|
player[crit.first] = crit.second[player.getID()] ?: 0.0
|
||||||
}
|
}
|
||||||
player["results"] = Json.MutableArray(List(round) { "0=" })
|
player["results"] = Json.MutableArray(List(round) { "0=" })
|
||||||
}
|
}
|
||||||
val sortedPairables = pairables.sortedWith { left, right ->
|
val sortedPairables = jsonPairables.sortedWith { left, right ->
|
||||||
for (crit in criteria) {
|
for (crit in criteria) {
|
||||||
val lval = left.getDouble(crit.first) ?: 0.0
|
val lval = left.getDouble(crit.first) ?: 0.0
|
||||||
val rval = right.getDouble(crit.first) ?: 0.0
|
val rval = right.getDouble(crit.first) ?: 0.0
|
||||||
@@ -129,15 +91,16 @@ fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = f
|
|||||||
return sortedPairables
|
return sortedPairables
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Tournament<*>.populateStandings(sortedPairables: List<Json.Object>, round: Int = rounds) {
|
fun Tournament<*>.populateStandings(sortedEntries: List<Json.Object>, round: Int = rounds, individualStandings: Boolean) {
|
||||||
val sortedMap = sortedPairables.associateBy {
|
val sortedMap = sortedEntries.associateBy {
|
||||||
it.getID()!!
|
it.getID()!!
|
||||||
}
|
}
|
||||||
|
|
||||||
// refresh name, firstname, club and level
|
// refresh name, firstname, club and level
|
||||||
|
val refMap = if (individualStandings) players else pairables
|
||||||
sortedMap.forEach { (id, pairable) ->
|
sortedMap.forEach { (id, pairable) ->
|
||||||
val mutable = pairable as Json.MutableObject
|
val mutable = pairable as Json.MutableObject
|
||||||
pairables[id]?.let {
|
refMap[id]?.let {
|
||||||
mutable["name"] = it.name
|
mutable["name"] = it.name
|
||||||
if (it is Player) {
|
if (it is Player) {
|
||||||
mutable["firstname"] = it.firstname
|
mutable["firstname"] = it.firstname
|
||||||
@@ -150,7 +113,8 @@ fun Tournament<*>.populateStandings(sortedPairables: List<Json.Object>, round: I
|
|||||||
|
|
||||||
// fill result
|
// fill result
|
||||||
for (r in 1..round) {
|
for (r in 1..round) {
|
||||||
games(r).values.forEach { game ->
|
val roundGames = if (individualStandings) individualGames(r) else games(r)
|
||||||
|
roundGames.values.forEach { game ->
|
||||||
val white = if (game.white != 0) sortedMap[game.white] else null
|
val white = if (game.white != 0) sortedMap[game.white] else null
|
||||||
val black = if (game.black != 0) sortedMap[game.black] else null
|
val black = if (game.black != 0) sortedMap[game.black] else null
|
||||||
val whiteNum = white?.getInt("num") ?: 0
|
val whiteNum = white?.getInt("num") ?: 0
|
||||||
@@ -186,3 +150,54 @@ fun Tournament<*>.populateStandings(sortedPairables: List<Json.Object>, round: I
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun TeamTournament.getSortedTeamMembers(round: Int, includePreliminary: Boolean = false): List<Json.Object> {
|
||||||
|
|
||||||
|
val teamGames = historyBefore(round + 1)
|
||||||
|
val individualHistory = teamGames.map { roundTeamGames ->
|
||||||
|
roundTeamGames.flatMap { game -> individualGames[game.id]?.toList() ?: listOf() }
|
||||||
|
}
|
||||||
|
val historyHelper = HistoryHelper(individualHistory).apply {
|
||||||
|
scoresFactory = { wins }
|
||||||
|
}
|
||||||
|
val neededCriteria = mutableListOf(Criterion.NBW, Criterion.RATING)
|
||||||
|
val criteria = neededCriteria.map { crit ->
|
||||||
|
crit.name to when (crit) {
|
||||||
|
Criterion.NBW -> historyHelper.wins
|
||||||
|
Criterion.RANK -> pairables.mapValues { it.value.rank }
|
||||||
|
Criterion.RATING -> pairables.mapValues { it.value.rating }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val jsonPlayers = players.values.filter { includePreliminary || it.final }.map { it.toDetailedJson() }
|
||||||
|
jsonPlayers.forEach { player ->
|
||||||
|
for (crit in criteria) {
|
||||||
|
player[crit.first] = crit.second?.get(player.getID()) ?: 0.0
|
||||||
|
}
|
||||||
|
player["results"] = Json.MutableArray(List(round) { "0=" })
|
||||||
|
}
|
||||||
|
val sortedPlayers = jsonPlayers.sortedWith { left, right ->
|
||||||
|
for (crit in criteria) {
|
||||||
|
val lval = left.getDouble(crit.first) ?: 0.0
|
||||||
|
val rval = right.getDouble(crit.first) ?: 0.0
|
||||||
|
val cmp = lval.compareTo(rval)
|
||||||
|
if (cmp != 0) return@sortedWith -cmp
|
||||||
|
}
|
||||||
|
return@sortedWith 0
|
||||||
|
}.mapIndexed() { i, obj ->
|
||||||
|
obj.set("num", i+1)
|
||||||
|
}
|
||||||
|
var place = 1
|
||||||
|
sortedPlayers.groupBy { p ->
|
||||||
|
Triple(
|
||||||
|
criteria.getOrNull(0)?.first?.let { crit -> p.getDouble(crit) ?: 0.0 } ?: 0.0,
|
||||||
|
criteria.getOrNull(1)?.first?.let { crit -> p.getDouble(crit) ?: 0.0 } ?: 0.0,
|
||||||
|
criteria.getOrNull(2)?.first?.let { crit -> p.getDouble(crit) ?: 0.0 } ?: 0.0
|
||||||
|
)
|
||||||
|
}.forEach {
|
||||||
|
it.value.forEach { p -> p["place"] = place }
|
||||||
|
place += it.value.size
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedPlayers
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package org.jeudego.pairgoth.api
|
||||||
|
|
||||||
|
import com.republicate.kson.Json
|
||||||
|
import com.republicate.kson.toJsonArray
|
||||||
|
import com.republicate.kson.toJsonObject
|
||||||
|
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||||
|
import org.jeudego.pairgoth.model.toJson
|
||||||
|
import org.jeudego.pairgoth.pairing.solver.CollectingListener
|
||||||
|
import javax.servlet.http.HttpServletRequest
|
||||||
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
|
object ExplainHandler: PairgothApiHandler {
|
||||||
|
|
||||||
|
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
|
||||||
|
val tournament = getTournament(request)
|
||||||
|
val round = getSubSelector(request)?.toIntOrNull() ?: badRequest("invalid round number")
|
||||||
|
if (round > tournament.lastRound() + 1) badRequest("invalid round: previous round has not been played")
|
||||||
|
val paired = tournament.games(round).values.flatMap {
|
||||||
|
listOf(it.black, it.white)
|
||||||
|
}.filter {
|
||||||
|
it != 0
|
||||||
|
}.map {
|
||||||
|
tournament.pairables[it] ?: throw Error("Unknown pairable ID: $it")
|
||||||
|
}
|
||||||
|
val games = tournament.games(round).map { it.value }.toList()
|
||||||
|
// build the scores map by redoing the whole pairing
|
||||||
|
tournament.unpair(round)
|
||||||
|
val history = tournament.historyHelper(round)
|
||||||
|
val weightsCollector = CollectingListener()
|
||||||
|
tournament.pair(round, paired, false, weightsCollector)
|
||||||
|
val weights = weightsCollector.out
|
||||||
|
// Since weights are generally in two groups towards the min and the max,
|
||||||
|
// compute the max of the low group ("low") and the min of the high group ("high")
|
||||||
|
// to improve coloring.
|
||||||
|
// Total weights axis:
|
||||||
|
// ----[min]xxxx[low]----[middle]----[high]xxxx[max]---->
|
||||||
|
val min = weights.values.minOfOrNull { it.values.sum() } ?: 0.0
|
||||||
|
val max = weights.values.maxOfOrNull { it.values.sum() } ?: 0.0
|
||||||
|
val middle = (max - min) / 2.0
|
||||||
|
val low = weights.values.map { it.values.sum() }.filter { it < middle }.maxOrNull() ?: middle
|
||||||
|
val high = weights.values.map { it.values.sum() }.filter { it > middle }.minOrNull() ?: middle
|
||||||
|
val ret = Json.Object(
|
||||||
|
"paired" to paired.sortedByDescending { 1000 * (history.scores[it.id] ?: 0.0) + (history.sos[it.id] ?: 0.0) }.map {
|
||||||
|
it.toMutableJson().apply {
|
||||||
|
put("score", history.scores[it.id])
|
||||||
|
put("wins", history.wins[it.id])
|
||||||
|
put("sos", history.sos[it.id])
|
||||||
|
put("dudd", history.drawnUpDown[it.id])
|
||||||
|
}
|
||||||
|
}.toJsonArray(),
|
||||||
|
// "games" to games.map { it.toJson() }.toJsonArray(),
|
||||||
|
"games" to games.associateBy { "${it.white}-${it.black}" }.mapValues { it.value.toJson() }.toJsonObject(),
|
||||||
|
"weights" to weights.entries.map { (key, value) ->
|
||||||
|
Pair(
|
||||||
|
"${key.first}-${key.second}",
|
||||||
|
value.also {
|
||||||
|
it.put("total", it.values.sum())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}.toJsonObject(),
|
||||||
|
"min" to min,
|
||||||
|
"low" to low,
|
||||||
|
"high" to high,
|
||||||
|
"max" to max
|
||||||
|
)
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,10 @@ import org.jeudego.pairgoth.model.Tournament
|
|||||||
import org.jeudego.pairgoth.model.getID
|
import org.jeudego.pairgoth.model.getID
|
||||||
import org.jeudego.pairgoth.model.toID
|
import org.jeudego.pairgoth.model.toID
|
||||||
import org.jeudego.pairgoth.model.toJson
|
import org.jeudego.pairgoth.model.toJson
|
||||||
|
import org.jeudego.pairgoth.pairing.solver.LoggingListener
|
||||||
import org.jeudego.pairgoth.server.Event.*
|
import org.jeudego.pairgoth.server.Event.*
|
||||||
|
import java.io.FileWriter
|
||||||
|
import java.io.PrintWriter
|
||||||
import javax.servlet.http.HttpServletRequest
|
import javax.servlet.http.HttpServletRequest
|
||||||
import javax.servlet.http.HttpServletResponse
|
import javax.servlet.http.HttpServletResponse
|
||||||
|
|
||||||
@@ -67,7 +70,15 @@ object PairingHandler: PairgothApiHandler {
|
|||||||
if (playing.contains(it.id)) badRequest("pairable #$id already plays round $round")
|
if (playing.contains(it.id)) badRequest("pairable #$id already plays round $round")
|
||||||
} ?: badRequest("invalid pairable id: #$id")
|
} ?: badRequest("invalid pairable id: #$id")
|
||||||
}
|
}
|
||||||
val games = tournament.pair(round, pairables)
|
|
||||||
|
// POST pair/$round accepts a few parameters to help tests
|
||||||
|
val legacy = request.getParameter("legacy")?.toBoolean() ?: false
|
||||||
|
val weightsLogger = request.getParameter("weights_output")?.let {
|
||||||
|
val append = request.getParameter("append")?.toBoolean() ?: false
|
||||||
|
LoggingListener(PrintWriter(FileWriter(it, append)))
|
||||||
|
}
|
||||||
|
|
||||||
|
val games = tournament.pair(round, pairables, legacy, weightsLogger)
|
||||||
|
|
||||||
val ret = games.map { it.toJson() }.toJsonArray()
|
val ret = games.map { it.toJson() }.toJsonArray()
|
||||||
tournament.dispatchEvent(GamesAdded, request, Json.Object("round" to round, "games" to ret))
|
tournament.dispatchEvent(GamesAdded, request, Json.Object("round" to round, "games" to ret))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.jeudego.pairgoth.model.Criterion
|
|||||||
import org.jeudego.pairgoth.model.Criterion.*
|
import org.jeudego.pairgoth.model.Criterion.*
|
||||||
import org.jeudego.pairgoth.model.ID
|
import org.jeudego.pairgoth.model.ID
|
||||||
import org.jeudego.pairgoth.model.PairingType
|
import org.jeudego.pairgoth.model.PairingType
|
||||||
|
import org.jeudego.pairgoth.model.TeamTournament
|
||||||
import org.jeudego.pairgoth.model.Tournament
|
import org.jeudego.pairgoth.model.Tournament
|
||||||
import org.jeudego.pairgoth.model.adjustedTime
|
import org.jeudego.pairgoth.model.adjustedTime
|
||||||
import org.jeudego.pairgoth.model.displayRank
|
import org.jeudego.pairgoth.model.displayRank
|
||||||
@@ -27,10 +28,18 @@ object StandingsHandler: PairgothApiHandler {
|
|||||||
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
|
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
|
||||||
val tournament = getTournament(request)
|
val tournament = getTournament(request)
|
||||||
val round = getSubSelector(request)?.toIntOrNull() ?: tournament.rounds
|
val round = getSubSelector(request)?.toIntOrNull() ?: tournament.rounds
|
||||||
val includePreliminary = request.getParameter("include_preliminary")?.let { it.toBoolean() } ?: false
|
val includePreliminary = request.getParameter("include_preliminary")?.toBoolean() ?: false
|
||||||
|
|
||||||
val sortedPairables = tournament.getSortedPairables(round, includePreliminary)
|
val individualStandings = tournament is TeamTournament &&
|
||||||
tournament.populateStandings(sortedPairables, round)
|
tournament.type.individual &&
|
||||||
|
request.getParameter("individual_standings")?.toBoolean() == true
|
||||||
|
|
||||||
|
val sortedEntries = if (individualStandings) {
|
||||||
|
tournament.getSortedTeamMembers(round)
|
||||||
|
} else {
|
||||||
|
tournament.getSortedPairables(round, includePreliminary)
|
||||||
|
}
|
||||||
|
tournament.populateStandings(sortedEntries, round, individualStandings)
|
||||||
|
|
||||||
val acceptHeader = request.getHeader("Accept") as String?
|
val acceptHeader = request.getHeader("Accept") as String?
|
||||||
val accept = acceptHeader?.substringBefore(";")
|
val accept = acceptHeader?.substringBefore(";")
|
||||||
@@ -44,7 +53,7 @@ object StandingsHandler: PairgothApiHandler {
|
|||||||
PrintWriter(OutputStreamWriter(response.outputStream, encoding))
|
PrintWriter(OutputStreamWriter(response.outputStream, encoding))
|
||||||
}
|
}
|
||||||
return when (accept) {
|
return when (accept) {
|
||||||
"application/json" -> sortedPairables.toJsonArray()
|
"application/json" -> sortedEntries.toJsonArray()
|
||||||
"application/egf" -> {
|
"application/egf" -> {
|
||||||
response.contentType = "text/plain;charset=${encoding}"
|
response.contentType = "text/plain;charset=${encoding}"
|
||||||
val neededCriteria = ArrayList(tournament.pairing.placementParams.criteria)
|
val neededCriteria = ArrayList(tournament.pairing.placementParams.criteria)
|
||||||
@@ -52,19 +61,19 @@ object StandingsHandler: PairgothApiHandler {
|
|||||||
if (neededCriteria.first() == SCOREX) {
|
if (neededCriteria.first() == SCOREX) {
|
||||||
neededCriteria.add(1, MMS)
|
neededCriteria.add(1, MMS)
|
||||||
}
|
}
|
||||||
exportToEGFFormat(tournament, sortedPairables, neededCriteria, writer)
|
exportToEGFFormat(tournament, sortedEntries, neededCriteria, writer)
|
||||||
writer.flush()
|
writer.flush()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
"application/ffg" -> {
|
"application/ffg" -> {
|
||||||
response.contentType = "text/plain;charset=${encoding}"
|
response.contentType = "text/plain;charset=${encoding}"
|
||||||
exportToFFGFormat(tournament, sortedPairables, writer)
|
exportToFFGFormat(tournament, sortedEntries, writer)
|
||||||
writer.flush()
|
writer.flush()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
"text/csv" -> {
|
"text/csv" -> {
|
||||||
response.contentType = "text/csv;charset=${encoding}"
|
response.contentType = "text/csv;charset=${encoding}"
|
||||||
exportToCSVFormat(tournament, sortedPairables, writer)
|
exportToCSVFormat(tournament, sortedEntries, writer)
|
||||||
writer.flush()
|
writer.flush()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -105,7 +114,7 @@ object StandingsHandler: PairgothApiHandler {
|
|||||||
"""
|
"""
|
||||||
; CL[${egfClass}]
|
; CL[${egfClass}]
|
||||||
; EV[${tournament.name}]
|
; EV[${tournament.name}]
|
||||||
; PC[${tournament.country.lowercase()},${tournament.location}]
|
; PC[${tournament.country.uppercase()},${tournament.location}]
|
||||||
; DT[${tournament.startDate},${tournament.endDate}]
|
; DT[${tournament.startDate},${tournament.endDate}]
|
||||||
; HA[${
|
; HA[${
|
||||||
if (tournament.pairing.type == PairingType.MAC_MAHON) "h${tournament.pairing.pairingParams.handicap.correction}"
|
if (tournament.pairing.type == PairingType.MAC_MAHON) "h${tournament.pairing.pairingParams.handicap.correction}"
|
||||||
@@ -114,7 +123,7 @@ object StandingsHandler: PairgothApiHandler {
|
|||||||
}]
|
}]
|
||||||
; KM[${tournament.komi}]
|
; KM[${tournament.komi}]
|
||||||
; TM[${tournament.timeSystem.adjustedTime() / 60}]
|
; TM[${tournament.timeSystem.adjustedTime() / 60}]
|
||||||
; CM[Generated by Pairgoth v0.1]
|
; CM[Generated by Pairgoth ${WebappManager.properties.getProperty("version")}]
|
||||||
;
|
;
|
||||||
; Pl Name Rk Co Club ${ criteria.map { it.name.replace(Regex("(S?)O?(SOS|DOS)[MW]?"), "$1$2").padStart(7, ' ') }.joinToString(" ") }
|
; Pl Name Rk Co Club ${ criteria.map { it.name.replace(Regex("(S?)O?(SOS|DOS)[MW]?"), "$1$2").padStart(7, ' ') }.joinToString(" ") }
|
||||||
${
|
${
|
||||||
@@ -123,23 +132,24 @@ ${
|
|||||||
player.getString("num")!!.padStart(4, ' ')
|
player.getString("num")!!.padStart(4, ' ')
|
||||||
} ${
|
} ${
|
||||||
"${
|
"${
|
||||||
player.getString("name")?.toSnake(true)
|
player.getString("name")?.toCapitals()
|
||||||
|
|
||||||
} ${
|
} ${
|
||||||
player.getString("firstname")?.toSnake() ?: ""
|
player.getString("firstname")?.toCapitals() ?: ""
|
||||||
}".padEnd(30, ' ').take(30)
|
}".padEnd(30, ' ').take(30)
|
||||||
} ${
|
} ${
|
||||||
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
|
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
|
||||||
} ${
|
} ${
|
||||||
player.getString("country")?.uppercase() ?: ""
|
player.getString("country")?.uppercase() ?: ""
|
||||||
} ${
|
} ${
|
||||||
(player.getString("club") ?: "").toSnake().padStart(4).take(4)
|
(player.getString("club") ?: "").toCapitals().padStart(4).take(4)
|
||||||
} ${
|
} ${
|
||||||
criteria.joinToString(" ") { numFormat.format(player.getDouble(it.name)!!).let { if (it.contains('.')) it else "$it " }.padStart(7, ' ') }
|
criteria.joinToString(" ") { numFormat.format(player.getDouble(it.name)!!).let { if (it.contains('.')) it else "$it " }.padStart(7, ' ') }
|
||||||
} ${
|
} ${
|
||||||
player.getArray("results")!!.map {
|
player.getArray("results")!!.map {
|
||||||
(it as String).padStart(8, ' ')
|
(it as String).padStart(8, ' ')
|
||||||
}.joinToString(" ")
|
}.joinToString(" ")
|
||||||
|
}${
|
||||||
|
player.getString("egf")?.let { if (it.length == 8) " |$it" else "" } ?: ""
|
||||||
}"
|
}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,14 +157,12 @@ ${
|
|||||||
writer.println(ret)
|
writer.println(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.toSnake(upper: Boolean = false): String {
|
private fun String.toCapitals(): String {
|
||||||
val sanitized = sanitizeISO()
|
val sanitized = sanitizeISO()
|
||||||
val parts = sanitized.trim().split(Regex("(?:\\s|\\xA0)+"))
|
val parts = sanitized.trim().split(Regex("(?:\\s|\\xA0)+"))
|
||||||
val snake = parts.joinToString("_") { part ->
|
return parts.joinToString("_") { part ->
|
||||||
if (upper) part.uppercase(Locale.ROOT)
|
part.lowercase(Locale.ROOT).replaceFirstChar { it.titlecase(Locale.ROOT) }
|
||||||
else part.capitalize()
|
|
||||||
}
|
}
|
||||||
return snake
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.sanitizeISO(): String {
|
private fun String.sanitizeISO(): String {
|
||||||
@@ -167,12 +175,13 @@ ${
|
|||||||
|
|
||||||
private fun exportToFFGFormat(tournament: Tournament<*>, lines: List<Json.Object>, writer: PrintWriter) {
|
private fun exportToFFGFormat(tournament: Tournament<*>, lines: List<Json.Object>, writer: PrintWriter) {
|
||||||
val version = WebappManager.properties.getProperty("version")!!
|
val version = WebappManager.properties.getProperty("version")!!
|
||||||
|
val ffgName = "${ffgDate.format(tournament.startDate)}-${tournament.location.lowercase(Locale.ROOT).sanitizeISO()}"
|
||||||
val ret =
|
val ret =
|
||||||
""";name=${tournament.shortName}
|
""";name=$ffgName
|
||||||
;date=${frDate.format(tournament.startDate)}
|
;date=${frDate.format(tournament.startDate)}
|
||||||
;vill=${tournament.location}${if (tournament.online) "(online)" else ""}
|
;vill=${tournament.location}${if (tournament.online) "(online)" else ""}
|
||||||
;comm=${tournament.name}
|
;comm=${tournament.name}
|
||||||
;prog=Pairgoth v0.1
|
;prog=Pairgoth $version
|
||||||
;time=${tournament.timeSystem.mainTime / 60}
|
;time=${tournament.timeSystem.mainTime / 60}
|
||||||
;ta=${tournament.timeSystem.adjustedTime() / 60}
|
;ta=${tournament.timeSystem.adjustedTime() / 60}
|
||||||
;size=${tournament.gobanSize}
|
;size=${tournament.gobanSize}
|
||||||
@@ -180,9 +189,12 @@ ${
|
|||||||
; Generated by Pairgoth $version
|
; Generated by Pairgoth $version
|
||||||
; ${
|
; ${
|
||||||
when (tournament.timeSystem.type) {
|
when (tournament.timeSystem.type) {
|
||||||
CANADIAN -> "Canadian ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.stones} stones / ${tournament.timeSystem.byoyomi / 60} minutes"
|
CANADIAN -> if (tournament.timeSystem.byoyomi > 0) "Canadian ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.stones} stones / ${tournament.timeSystem.byoyomi / 60} minutes"
|
||||||
JAPANESE -> "Japanese ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.periods} periods of ${tournament.timeSystem.byoyomi / 60} minutes"
|
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
|
||||||
FISCHER -> "Fisher ${tournament.timeSystem.mainTime / 60} minutes + ${tournament.timeSystem.increment} seconds${ if (tournament.timeSystem.maxTime > 0) ", max ${tournament.timeSystem.maxTime / 60} minutes" else "" }"
|
JAPANESE -> if (tournament.timeSystem.byoyomi > 0) "Japanese ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.periods} periods of ${tournament.timeSystem.byoyomi / 60} minutes"
|
||||||
|
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
|
||||||
|
FISCHER -> if (tournament.timeSystem.increment > 0) "Fisher ${tournament.timeSystem.mainTime / 60} minutes + ${tournament.timeSystem.increment} seconds${ if (tournament.timeSystem.maxTime > 0) ", max ${tournament.timeSystem.maxTime / 60} minutes" else "" }"
|
||||||
|
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
|
||||||
SUDDEN_DEATH -> "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
|
SUDDEN_DEATH -> "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,14 +205,14 @@ ${
|
|||||||
"${
|
"${
|
||||||
player.getString("num")!!.padStart(4, ' ')
|
player.getString("num")!!.padStart(4, ' ')
|
||||||
} ${
|
} ${
|
||||||
"${player.getString("name")?.toSnake(true)} ${player.getString("firstname")?.toSnake() ?: ""}".padEnd(24, ' ').take(24)
|
"${player.getString("name")?.toCapitals()} ${player.getString("firstname")?.toCapitals() ?: ""}".padEnd(24, ' ').take(24)
|
||||||
} ${
|
} ${
|
||||||
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
|
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
|
||||||
} ${
|
} ${
|
||||||
player.getString("ffg") ?: " "
|
player.getString("ffg") ?: " "
|
||||||
} ${
|
} ${
|
||||||
if (player.getString("country") == "FR")
|
if (player.getString("country") == "FR")
|
||||||
(player.getString("club") ?: "").toSnake().padEnd(4).take(4)
|
(player.getString("club") ?: "").toCapitals().padEnd(4).take(4)
|
||||||
else
|
else
|
||||||
(player.getString("country") ?: "").padEnd(4).take(4)
|
(player.getString("country") ?: "").padEnd(4).take(4)
|
||||||
} ${
|
} ${
|
||||||
@@ -258,4 +270,5 @@ ${
|
|||||||
|
|
||||||
private val numFormat = DecimalFormat("###0.#")
|
private val numFormat = DecimalFormat("###0.#")
|
||||||
private val frDate: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
|
private val frDate: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
|
||||||
|
private val ffgDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyMMdd")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.republicate.kson.toJsonObject
|
|||||||
import com.republicate.kson.toMutableJsonObject
|
import com.republicate.kson.toMutableJsonObject
|
||||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY
|
import org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY
|
||||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||||
|
import org.jeudego.pairgoth.ext.MacMahon39
|
||||||
import org.jeudego.pairgoth.ext.OpenGotha
|
import org.jeudego.pairgoth.ext.OpenGotha
|
||||||
import org.jeudego.pairgoth.model.BaseCritParams
|
import org.jeudego.pairgoth.model.BaseCritParams
|
||||||
import org.jeudego.pairgoth.model.TeamTournament
|
import org.jeudego.pairgoth.model.TeamTournament
|
||||||
@@ -55,7 +56,7 @@ object TournamentHandler: PairgothApiHandler {
|
|||||||
override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? {
|
override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? {
|
||||||
val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) {
|
val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) {
|
||||||
is Json.Object -> Tournament.fromJson(getObjectPayload(request))
|
is Json.Object -> Tournament.fromJson(getObjectPayload(request))
|
||||||
is Element -> OpenGotha.import(payload)
|
is Element -> if (MacMahon39.isFormat(payload)) MacMahon39.import(payload) else OpenGotha.import(payload)
|
||||||
else -> badRequest("missing or invalid payload")
|
else -> badRequest("missing or invalid payload")
|
||||||
}
|
}
|
||||||
tournament.recomputeDUDD()
|
tournament.recomputeDUDD()
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package org.jeudego.pairgoth.ext
|
||||||
|
|
||||||
|
import org.jeudego.pairgoth.model.*
|
||||||
|
import org.jeudego.pairgoth.store.nextGameId
|
||||||
|
import org.jeudego.pairgoth.store.nextPlayerId
|
||||||
|
import org.jeudego.pairgoth.store.nextTournamentId
|
||||||
|
import org.w3c.dom.Element
|
||||||
|
import org.w3c.dom.Node
|
||||||
|
import org.w3c.dom.NodeList
|
||||||
|
import java.time.LocalDate
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MacMahon 3.9 format import support
|
||||||
|
* Ported from OpenGothaCustom (https://bitbucket.org/kamyszyn/opengothacustom)
|
||||||
|
*/
|
||||||
|
object MacMahon39 {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the XML element is in MacMahon 3.9 format
|
||||||
|
*/
|
||||||
|
fun isFormat(element: Element): Boolean {
|
||||||
|
val tournament = element.getElementsByTagName("Tournament").item(0) ?: return false
|
||||||
|
val typeVersion = tournament.attributes?.getNamedItem("typeversion")?.nodeValue
|
||||||
|
return typeVersion != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a MacMahon 3.9 format tournament
|
||||||
|
*/
|
||||||
|
fun import(element: Element): Tournament<*> {
|
||||||
|
val tournamentEl = element.getElementsByTagName("Tournament").item(0) as? Element
|
||||||
|
?: throw Error("No Tournament element found")
|
||||||
|
|
||||||
|
// Parse tournament settings
|
||||||
|
val name = extractValue("Name", tournamentEl, "Tournament")
|
||||||
|
val numberOfRounds = extractValue("NumberOfRounds", tournamentEl, "5").toInt()
|
||||||
|
val mmBarStr = extractValue("UpperMacMahonBarLevel", tournamentEl, "1d")
|
||||||
|
val mmFloorStr = extractValue("LowerMacMahonBarLevel", tournamentEl, "30k")
|
||||||
|
val isMMBar = extractValue("UpperMacMahonBar", tournamentEl, "true") == "true"
|
||||||
|
val isMMFloor = extractValue("LowerMacMahonBar", tournamentEl, "true") == "true"
|
||||||
|
val handicapUsed = extractValue("HandicapUsed", tournamentEl, "false").equals("true", ignoreCase = true)
|
||||||
|
val handicapByRank = extractValue("HandicapByLevel", tournamentEl, "false").equals("true", ignoreCase = true)
|
||||||
|
val handicapBelowStr = extractValue("HandicapBelowLevel", tournamentEl, "30k")
|
||||||
|
val isHandicapBelow = extractValue("HandicapBelow", tournamentEl, "true").equals("true", ignoreCase = true)
|
||||||
|
val handicapCorrectionStr = extractValue("HandicapAdjustmentValue", tournamentEl, "0")
|
||||||
|
val isHandicapReduction = extractValue("HandicapAdjustment", tournamentEl, "true").equals("true", ignoreCase = true)
|
||||||
|
val handicapCeilingStr = extractValue("HandicapLimitValue", tournamentEl, "9")
|
||||||
|
val isHandicapLimit = extractValue("HandicapLimit", tournamentEl, "true").equals("true", ignoreCase = true)
|
||||||
|
|
||||||
|
// Parse placement criteria from Walllist
|
||||||
|
val walllistEl = element.getElementsByTagName("Walllist").item(0) as? Element
|
||||||
|
val breakers = if (walllistEl != null) extractValues("ShortName", walllistEl) else listOf("Score", "SOS", "SOSOS")
|
||||||
|
|
||||||
|
// Determine effective values
|
||||||
|
val mmBar = if (isMMBar) parseRank(mmBarStr) else 8 // 9d
|
||||||
|
val mmFloor = if (isMMFloor) parseRank(mmFloorStr) else -30 // 30k
|
||||||
|
val handicapBelow = if (isHandicapBelow) parseRank(handicapBelowStr) else 8 // 9d
|
||||||
|
val handicapCorrection = if (isHandicapReduction) -1 * handicapCorrectionStr.toInt() else 0
|
||||||
|
val handicapCeiling = when {
|
||||||
|
!handicapUsed -> 0
|
||||||
|
!isHandicapLimit -> 30
|
||||||
|
else -> handicapCeilingStr.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pairing parameters
|
||||||
|
val pairingParams = PairingParams(
|
||||||
|
base = BaseCritParams(),
|
||||||
|
main = MainCritParams(),
|
||||||
|
secondary = SecondaryCritParams(),
|
||||||
|
geo = GeographicalParams(),
|
||||||
|
handicap = HandicapParams(
|
||||||
|
useMMS = !handicapByRank,
|
||||||
|
rankThreshold = handicapBelow,
|
||||||
|
correction = handicapCorrection,
|
||||||
|
ceiling = handicapCeiling
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create placement parameters from breakers
|
||||||
|
val placementCrit = breakers.take(6).mapNotNull { translateBreaker(it, breakers.firstOrNull() == "Points") }.toTypedArray()
|
||||||
|
val placementParams = PlacementParams(crit = if (placementCrit.isEmpty()) arrayOf(Criterion.MMS, Criterion.SOSM, Criterion.SOSOSM) else placementCrit)
|
||||||
|
|
||||||
|
// Create tournament
|
||||||
|
val tournament = StandardTournament(
|
||||||
|
id = nextTournamentId,
|
||||||
|
type = Tournament.Type.INDIVIDUAL,
|
||||||
|
name = name,
|
||||||
|
shortName = name.take(20),
|
||||||
|
startDate = LocalDate.now(),
|
||||||
|
endDate = LocalDate.now(),
|
||||||
|
director = "",
|
||||||
|
country = "",
|
||||||
|
location = "",
|
||||||
|
online = false,
|
||||||
|
timeSystem = SuddenDeath(3600), // Default: 1 hour sudden death
|
||||||
|
pairing = MacMahon(
|
||||||
|
pairingParams = pairingParams,
|
||||||
|
placementParams = placementParams,
|
||||||
|
mmFloor = mmFloor,
|
||||||
|
mmBar = mmBar
|
||||||
|
),
|
||||||
|
rounds = numberOfRounds
|
||||||
|
)
|
||||||
|
|
||||||
|
// Parse players
|
||||||
|
val playerIdMap = mutableMapOf<String, ID>()
|
||||||
|
val goPlayers = element.getElementsByTagName("GoPlayer")
|
||||||
|
for (i in 0 until goPlayers.length) {
|
||||||
|
val playerEl = goPlayers.item(i) as? Element ?: continue
|
||||||
|
val parentEl = playerEl.parentNode as? Element ?: continue
|
||||||
|
|
||||||
|
val mm39Id = extractValue("Id", parentEl, "1")
|
||||||
|
val egfPin = extractValue("EgdPin", playerEl, "").let { if (it.length < 8) "" else it }
|
||||||
|
val firstname = extractValue("FirstName", playerEl, " ")
|
||||||
|
val surname = extractValue("Surname", playerEl, " ")
|
||||||
|
val club = extractValue("Club", playerEl, "")
|
||||||
|
val country = extractValue("Country", playerEl, "").uppercase()
|
||||||
|
val rankStr = extractValue("GoLevel", playerEl, "30k")
|
||||||
|
val rank = parseRank(rankStr)
|
||||||
|
val ratingStr = extractValue("Rating", playerEl, "-901")
|
||||||
|
val rating = ratingStr.toInt()
|
||||||
|
val superBarMember = extractValue("SuperBarMember", parentEl, "false") == "true"
|
||||||
|
val preliminary = extractValue("PreliminaryRegistration", parentEl, "false") == "true"
|
||||||
|
|
||||||
|
val player = Player(
|
||||||
|
id = nextPlayerId,
|
||||||
|
name = surname,
|
||||||
|
firstname = firstname,
|
||||||
|
rating = rating,
|
||||||
|
rank = rank,
|
||||||
|
country = if (country == "GB") "UK" else country,
|
||||||
|
club = club,
|
||||||
|
final = !preliminary,
|
||||||
|
mmsCorrection = if (superBarMember) 1 else 0
|
||||||
|
).also {
|
||||||
|
if (egfPin.isNotEmpty()) {
|
||||||
|
it.externalIds[DatabaseId.EGF] = egfPin
|
||||||
|
}
|
||||||
|
// Parse not playing rounds
|
||||||
|
val notPlayingRounds = extractValues("NotPlayingInRound", parentEl)
|
||||||
|
for (roundStr in notPlayingRounds) {
|
||||||
|
val round = roundStr.toIntOrNull() ?: continue
|
||||||
|
it.skip.add(round)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playerIdMap[mm39Id] = player.id
|
||||||
|
tournament.players[player.id] = player
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse games (pairings)
|
||||||
|
val pairings = element.getElementsByTagName("Pairing")
|
||||||
|
for (i in 0 until pairings.length) {
|
||||||
|
val pairingEl = pairings.item(i) as? Element ?: continue
|
||||||
|
val parentEl = pairingEl.parentNode as? Element ?: continue
|
||||||
|
|
||||||
|
val isByeGame = extractValue("PairingWithBye", pairingEl, "false").equals("true", ignoreCase = true)
|
||||||
|
val roundNumber = extractValue("RoundNumber", parentEl, "1").toInt()
|
||||||
|
val boardNumber = extractValue("BoardNumber", pairingEl, "${i + 1}").toInt()
|
||||||
|
|
||||||
|
if (isByeGame) {
|
||||||
|
// Bye player
|
||||||
|
val blackId = extractValue("Black", pairingEl, "")
|
||||||
|
val playerId = playerIdMap[blackId] ?: continue
|
||||||
|
val game = Game(
|
||||||
|
id = nextGameId,
|
||||||
|
table = 0,
|
||||||
|
white = playerId,
|
||||||
|
black = 0,
|
||||||
|
result = Game.Result.WHITE
|
||||||
|
)
|
||||||
|
tournament.games(roundNumber)[game.id] = game
|
||||||
|
} else {
|
||||||
|
// Regular game
|
||||||
|
val whiteId = extractValue("White", pairingEl, "")
|
||||||
|
val blackId = extractValue("Black", pairingEl, "")
|
||||||
|
val whitePId = playerIdMap[whiteId] ?: continue
|
||||||
|
val blackPId = playerIdMap[blackId] ?: continue
|
||||||
|
val handicap = extractValue("Handicap", pairingEl, "0").toInt()
|
||||||
|
val resultStr = extractValue("Result", pairingEl, "?-?")
|
||||||
|
val resultByRef = extractValue("ResultByReferee", pairingEl, "false").equals("true", ignoreCase = true)
|
||||||
|
|
||||||
|
val game = Game(
|
||||||
|
id = nextGameId,
|
||||||
|
table = boardNumber,
|
||||||
|
white = whitePId,
|
||||||
|
black = blackPId,
|
||||||
|
handicap = handicap,
|
||||||
|
result = parseResult(resultStr, resultByRef)
|
||||||
|
)
|
||||||
|
tournament.games(roundNumber)[game.id] = game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tournament
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
private fun extractValue(tag: String, element: Element, default: String): String {
|
||||||
|
return try {
|
||||||
|
val nodes = element.getElementsByTagName(tag).item(0)?.childNodes
|
||||||
|
nodes?.item(0)?.nodeValue ?: default
|
||||||
|
} catch (e: Exception) {
|
||||||
|
default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractValues(tag: String, element: Element): List<String> {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
try {
|
||||||
|
val nodeList = element.getElementsByTagName(tag)
|
||||||
|
for (i in 0 until minOf(nodeList.length, 20)) {
|
||||||
|
val nodes = nodeList.item(i)?.childNodes
|
||||||
|
nodes?.item(0)?.nodeValue?.let { result.add(it) }
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRank(rankStr: String): Int {
|
||||||
|
val regex = Regex("(\\d+)([kKdD])")
|
||||||
|
val match = regex.matchEntire(rankStr) ?: return -20
|
||||||
|
val (num, letter) = match.destructured
|
||||||
|
val level = num.toIntOrNull() ?: return -20
|
||||||
|
return when (letter.lowercase()) {
|
||||||
|
"k" -> -level
|
||||||
|
"d" -> level - 1
|
||||||
|
else -> -20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseResult(resultStr: String, byRef: Boolean): Game.Result {
|
||||||
|
// MM39 result format: "1-0" (white wins), "0-1" (black wins), etc.
|
||||||
|
// The format uses black-first convention (first number is black's score)
|
||||||
|
return when (resultStr.removeSuffix("!")) {
|
||||||
|
"0-1" -> Game.Result.WHITE
|
||||||
|
"1-0" -> Game.Result.BLACK
|
||||||
|
"\u00BD-\u00BD" -> Game.Result.JIGO
|
||||||
|
"0-0" -> Game.Result.BOTHLOOSE
|
||||||
|
"1-1" -> Game.Result.BOTHWIN
|
||||||
|
else -> Game.Result.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translateBreaker(breaker: String, swiss: Boolean): Criterion? {
|
||||||
|
return when (breaker) {
|
||||||
|
"Points" -> Criterion.NBW
|
||||||
|
"Score", "ScoreX" -> Criterion.MMS
|
||||||
|
"SOS" -> if (swiss) Criterion.SOSW else Criterion.SOSM
|
||||||
|
"SOSOS" -> if (swiss) Criterion.SOSOSW else Criterion.SOSOSM
|
||||||
|
"SODOS" -> if (swiss) Criterion.SODOSW else Criterion.SODOSM
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,9 +107,9 @@ fun Player.Companion.fromJson(json: Json.Object, default: Player? = null) = Play
|
|||||||
rating = json.getInt("rating") ?: default?.rating ?: badRequest("missing rating"),
|
rating = json.getInt("rating") ?: default?.rating ?: badRequest("missing rating"),
|
||||||
rank = json.getInt("rank") ?: default?.rank ?: badRequest("missing rank"),
|
rank = json.getInt("rank") ?: default?.rank ?: badRequest("missing rank"),
|
||||||
country = ( json.getString("country") ?: default?.country ?: badRequest("missing country") ).let {
|
country = ( json.getString("country") ?: default?.country ?: badRequest("missing country") ).let {
|
||||||
// EGC uses UK, while FFG and browser language use GB
|
// normalize to UK (EGF uses UK, ISO uses GB)
|
||||||
val up = it.uppercase(Locale.ROOT)
|
val up = it.uppercase(Locale.ROOT)
|
||||||
if (up == "UK") "GB" else up
|
if (up == "GB") "UK" else up
|
||||||
},
|
},
|
||||||
club = json.getString("club") ?: default?.club ?: badRequest("missing club"),
|
club = json.getString("club") ?: default?.club ?: badRequest("missing club"),
|
||||||
final = json.getBoolean("final") ?: default?.final ?: true,
|
final = json.getBoolean("final") ?: default?.final ?: true,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import com.republicate.kson.Json
|
|||||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||||
import org.jeudego.pairgoth.model.MainCritParams.SeedMethod.SPLIT_AND_SLIP
|
import org.jeudego.pairgoth.model.MainCritParams.SeedMethod.SPLIT_AND_SLIP
|
||||||
import org.jeudego.pairgoth.model.PairingType.*
|
import org.jeudego.pairgoth.model.PairingType.*
|
||||||
import org.jeudego.pairgoth.pairing.solver.BaseSolver
|
import org.jeudego.pairgoth.pairing.HistoryHelper
|
||||||
|
import org.jeudego.pairgoth.pairing.solver.Solver
|
||||||
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
|
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
|
||||||
|
import org.jeudego.pairgoth.pairing.solver.PairingListener
|
||||||
import org.jeudego.pairgoth.pairing.solver.SwissSolver
|
import org.jeudego.pairgoth.pairing.solver.SwissSolver
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ data class GeographicalParams(
|
|||||||
val preferMMSDiffRatherThanSameClubsGroup: Int = 2, // Typically = 2
|
val preferMMSDiffRatherThanSameClubsGroup: Int = 2, // Typically = 2
|
||||||
val preferMMSDiffRatherThanSameClub: Int = 3, // Typically = 3
|
val preferMMSDiffRatherThanSameClub: Int = 3, // Typically = 3
|
||||||
val proportionMainClubThreshold: Double = 0.4, // If the biggest club has a proportion of players higher than this, the secondary criterium is not applied
|
val proportionMainClubThreshold: Double = 0.4, // If the biggest club has a proportion of players higher than this, the secondary criterium is not applied
|
||||||
|
val avoidSameFamily: Boolean = false, // When enabled, avoid pairing players from the same club with the same family name
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val disabled = GeographicalParams(avoidSameGeo = 0.0)
|
val disabled = GeographicalParams(avoidSameGeo = 0.0)
|
||||||
@@ -131,28 +134,22 @@ sealed class Pairing(
|
|||||||
val pairingParams: PairingParams,
|
val pairingParams: PairingParams,
|
||||||
val placementParams: PlacementParams) {
|
val placementParams: PlacementParams) {
|
||||||
companion object {}
|
companion object {}
|
||||||
abstract fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): BaseSolver
|
internal abstract fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): Solver
|
||||||
fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> {
|
internal fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>, legacyMode: Boolean = false, listener: PairingListener? = null): List<Game> {
|
||||||
return solver(tournament, round, pairables).pair()
|
return solver(tournament, round, pairables)
|
||||||
|
.also { solver ->
|
||||||
|
solver.legacyMode = legacyMode
|
||||||
|
listener?.let {
|
||||||
|
solver.pairingListener = listener
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pair()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Tournament<*>.historyBefore(round: Int) =
|
internal fun Tournament<*>.historyBefore(round: Int) =
|
||||||
(1 until min(round, lastRound() + 1)).map { games(it).values.toList() }
|
(1 until min(round, lastRound() + 1)).map { games(it).values.toList() }
|
||||||
|
|
||||||
/*private fun Tournament<*>.historyBefore(round: Int) : List<List<Game>> {
|
|
||||||
println("Welcome to tournament.historyBefore !")
|
|
||||||
println("lastround and round = "+lastRound().toString()+" "+round.toString())
|
|
||||||
println((1 until round).map { it })
|
|
||||||
println((1 until round).map { games(it).values.toList() })
|
|
||||||
if (lastRound() == 1){
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return (1 until round).map { games(it).values.toList() }
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
|
|
||||||
class Swiss(
|
class Swiss(
|
||||||
pairingParams: PairingParams = PairingParams(
|
pairingParams: PairingParams = PairingParams(
|
||||||
base = BaseCritParams(),
|
base = BaseCritParams(),
|
||||||
@@ -175,7 +172,7 @@ class Swiss(
|
|||||||
): Pairing(SWISS, pairingParams, placementParams) {
|
): Pairing(SWISS, pairingParams, placementParams) {
|
||||||
companion object {}
|
companion object {}
|
||||||
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>) =
|
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>) =
|
||||||
SwissSolver(round, tournament.rounds, tournament.historyBefore(round), pairables, tournament.pairables, pairingParams, placementParams, tournament.usedTables(round))
|
SwissSolver(round, tournament.rounds, HistoryHelper(tournament.historyBefore(round)), pairables, tournament.pairables, pairingParams, placementParams, tournament.usedTables(round))
|
||||||
}
|
}
|
||||||
|
|
||||||
class MacMahon(
|
class MacMahon(
|
||||||
@@ -203,14 +200,14 @@ class MacMahon(
|
|||||||
): Pairing(MAC_MAHON, pairingParams, placementParams) {
|
): Pairing(MAC_MAHON, pairingParams, placementParams) {
|
||||||
companion object {}
|
companion object {}
|
||||||
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>) =
|
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>) =
|
||||||
MacMahonSolver(round, tournament.rounds, tournament.historyBefore(round), pairables, tournament.pairables, pairingParams, placementParams, tournament.usedTables(round), mmFloor, mmBar)
|
MacMahonSolver(round, tournament.rounds, HistoryHelper(tournament.historyBefore(round)), pairables, tournament.pairables, pairingParams, placementParams, tournament.usedTables(round), mmFloor, mmBar)
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoundRobin(
|
class RoundRobin(
|
||||||
pairingParams: PairingParams = PairingParams(),
|
pairingParams: PairingParams = PairingParams(),
|
||||||
placementParams: PlacementParams = PlacementParams(Criterion.NBW, Criterion.RATING)
|
placementParams: PlacementParams = PlacementParams(Criterion.NBW, Criterion.RATING)
|
||||||
): Pairing(ROUND_ROBIN, pairingParams, placementParams) {
|
): Pairing(ROUND_ROBIN, pairingParams, placementParams) {
|
||||||
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): BaseSolver {
|
override fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): Solver {
|
||||||
TODO("not implemented")
|
TODO("not implemented")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import com.republicate.kson.toJsonObject
|
|||||||
//import kotlinx.datetime.LocalDate
|
//import kotlinx.datetime.LocalDate
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
|
||||||
import org.jeudego.pairgoth.api.ApiHandler.Companion.logger
|
import org.jeudego.pairgoth.pairing.HistoryHelper
|
||||||
|
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
|
||||||
|
import org.jeudego.pairgoth.pairing.solver.PairingListener
|
||||||
import org.jeudego.pairgoth.store.nextGameId
|
import org.jeudego.pairgoth.store.nextGameId
|
||||||
import org.jeudego.pairgoth.store.nextPlayerId
|
import org.jeudego.pairgoth.store.nextPlayerId
|
||||||
import org.jeudego.pairgoth.store.nextTournamentId
|
import org.jeudego.pairgoth.store.nextTournamentId
|
||||||
@@ -15,8 +17,10 @@ import org.jeudego.pairgoth.util.MutableBiMultiMap
|
|||||||
import org.jeudego.pairgoth.util.mutableBiMultiMapOf
|
import org.jeudego.pairgoth.util.mutableBiMultiMapOf
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import java.util.regex.Pattern
|
|
||||||
import kotlin.collections.get
|
import kotlin.collections.get
|
||||||
|
import kotlin.math.floor
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.round
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
sealed class Tournament <P: Pairable>(
|
sealed class Tournament <P: Pairable>(
|
||||||
@@ -61,7 +65,7 @@ sealed class Tournament <P: Pairable>(
|
|||||||
var frozen: Json.Array? = null
|
var frozen: Json.Array? = null
|
||||||
|
|
||||||
// pairing
|
// pairing
|
||||||
open fun pair(round: Int, pairables: List<Pairable>): List<Game> {
|
open fun pair(round: Int, pairables: List<Pairable>, legacyMode: Boolean = false, listener: PairingListener? = null): List<Game> {
|
||||||
// Minimal check on round number.
|
// Minimal check on round number.
|
||||||
// CB TODO - the complete check should verify, for each player, that he was either non pairable or implied in the previous round
|
// CB TODO - the complete check should verify, for each player, that he was either non pairable or implied in the previous round
|
||||||
if (round > games.size + 1) badRequest("previous round not paired")
|
if (round > games.size + 1) badRequest("previous round not paired")
|
||||||
@@ -69,7 +73,7 @@ sealed class Tournament <P: Pairable>(
|
|||||||
val evenPairables =
|
val evenPairables =
|
||||||
if (pairables.size % 2 == 0) pairables
|
if (pairables.size % 2 == 0) pairables
|
||||||
else pairables.toMutableList().also { it.add(ByePlayer) }
|
else pairables.toMutableList().also { it.add(ByePlayer) }
|
||||||
return pairing.pair(this, round, evenPairables).also { newGames ->
|
return pairing.pair(this, round, evenPairables, legacyMode, listener).also { newGames ->
|
||||||
if (games.size < round) games.add(mutableMapOf())
|
if (games.size < round) games.add(mutableMapOf())
|
||||||
games[round - 1].putAll( newGames.associateBy { it.id } )
|
games[round - 1].putAll( newGames.associateBy { it.id } )
|
||||||
}
|
}
|
||||||
@@ -96,14 +100,17 @@ sealed class Tournament <P: Pairable>(
|
|||||||
|
|
||||||
fun lastRound() = max(1, games.size)
|
fun lastRound() = max(1, games.size)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute DUDD for a specific game
|
||||||
|
*/
|
||||||
fun recomputeDUDD(round: Int, gameID: ID) {
|
fun recomputeDUDD(round: Int, gameID: ID) {
|
||||||
// Instantiate solver with game history
|
// Instantiate solver with game history
|
||||||
val solver = pairing.solver(this, round, pairables.values.toList())
|
val solver = pairing.solver(this, round, emptyList())
|
||||||
|
|
||||||
// Recomputes DUDD and hd
|
// Recomputes DUDD and hd
|
||||||
val game = games(round)[gameID]!!
|
val game = games(round)[gameID]!!
|
||||||
val white = solver.pairables.find { p-> p.id == game.white }!!
|
val white = pairables[game.white]!!
|
||||||
val black = solver.pairables.find { p-> p.id == game.black }!!
|
val black = pairables[game.black]!!
|
||||||
game.drawnUpDown = solver.dudd(black, white)
|
game.drawnUpDown = solver.dudd(black, white)
|
||||||
game.handicap = solver.hd(white = white, black = black)
|
game.handicap = solver.hd(white = white, black = black)
|
||||||
}
|
}
|
||||||
@@ -115,17 +122,16 @@ sealed class Tournament <P: Pairable>(
|
|||||||
fun recomputeDUDD(round: Int) {
|
fun recomputeDUDD(round: Int) {
|
||||||
if (pairables.isEmpty() || games(1).isEmpty()) return;
|
if (pairables.isEmpty() || games(1).isEmpty()) return;
|
||||||
// Instantiate solver with game history
|
// Instantiate solver with game history
|
||||||
val solver = pairing.solver(this, round, pairables.values.toList())
|
val solver = pairing.solver(this, round, emptyList())
|
||||||
for (game in games(round).values) {
|
for (game in games(round).values) {
|
||||||
if (game.black != 0 && game.white != 0) {
|
if (game.black != 0 && game.white != 0) {
|
||||||
val white = solver.pairables.find { p-> p.id == game.white }!!
|
val white = pairables[game.white]!!
|
||||||
val black = solver.pairables.find { p-> p.id == game.black }!!
|
val black = pairables[game.black]!!
|
||||||
game.drawnUpDown = solver.dudd(black, white)
|
game.drawnUpDown = solver.dudd(black, white)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recompute DUDD for all rounds
|
* Recompute DUDD for all rounds
|
||||||
*/
|
*/
|
||||||
@@ -208,6 +214,22 @@ sealed class Tournament <P: Pairable>(
|
|||||||
}
|
}
|
||||||
return excluded
|
return excluded
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun roundScore(score: Double): Double {
|
||||||
|
val epsilon = 0.00001
|
||||||
|
// Note: this works for now because we only have .0 and .5 fractional parts
|
||||||
|
return if (pairing.pairingParams.main.roundDownScore) floor(score + epsilon)
|
||||||
|
else round(2 * score) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Pairable.mmBase(): Double {
|
||||||
|
if (pairing !is MacMahon) throw Error("invalid call: tournament is not Mac Mahon")
|
||||||
|
return min(max(rank, pairing.mmFloor), pairing.mmBar) + MacMahonSolver.mmsZero + mmsCorrection
|
||||||
|
}
|
||||||
|
|
||||||
|
fun historyHelper(round: Int): HistoryHelper {
|
||||||
|
return pairing.solver(this, round, emptyList()).history
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// standard tournament of individuals
|
// standard tournament of individuals
|
||||||
@@ -263,7 +285,7 @@ class TeamTournament(
|
|||||||
override fun individualGames(round: Int): Map<ID, Game> {
|
override fun individualGames(round: Int): Map<ID, Game> {
|
||||||
val teamGames = games(round)
|
val teamGames = games(round)
|
||||||
return if (type.individual) {
|
return if (type.individual) {
|
||||||
return teamGames.values.flatMap { game ->
|
teamGames.values.flatMap { game ->
|
||||||
if (game.white == 0 || game.black == 0 ) listOf()
|
if (game.white == 0 || game.black == 0 ) listOf()
|
||||||
else individualGames[game.id]?.toList() ?: listOf()
|
else individualGames[game.id]?.toList() ?: listOf()
|
||||||
}.associateBy { it.id }
|
}.associateBy { it.id }
|
||||||
@@ -272,8 +294,8 @@ class TeamTournament(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pair(round: Int, pairables: List<Pairable>) =
|
override fun pair(round: Int, pairables: List<Pairable>, legacyMode: Boolean, listener: PairingListener?) =
|
||||||
super.pair(round, pairables).also { games ->
|
super.pair(round, pairables, legacyMode, listener).also { games ->
|
||||||
if (type.individual) {
|
if (type.individual) {
|
||||||
games.forEach { game ->
|
games.forEach { game ->
|
||||||
pairIndividualGames(round, game)
|
pairIndividualGames(round, game)
|
||||||
|
|||||||
@@ -5,26 +5,17 @@ import org.jeudego.pairgoth.model.*
|
|||||||
abstract class BasePairingHelper(
|
abstract class BasePairingHelper(
|
||||||
val round: Int,
|
val round: Int,
|
||||||
val totalRounds: Int,
|
val totalRounds: Int,
|
||||||
history: List<List<Game>>, // History of all games played for each round
|
val history: HistoryHelper, // Digested history of all games played for each round
|
||||||
var pairables: List<Pairable>, // All pairables for this round, it may include the bye player
|
var pairables: List<Pairable>, // All pairables for this round, it may include the bye player
|
||||||
val pairablesMap: Map<ID, Pairable>, // Map of all known pairables for this tournament
|
|
||||||
val pairing: PairingParams,
|
val pairing: PairingParams,
|
||||||
val placement: PlacementParams,
|
val placement: PlacementParams,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
abstract val scores: Map<ID, Pair<Double, Double>>
|
|
||||||
abstract val scoresX: Map<ID, Double>
|
|
||||||
val historyHelper =
|
|
||||||
if (pairables.first().let { it is TeamTournament.Team && it.teamOfIndividuals }) TeamOfIndividualsHistoryHelper(
|
|
||||||
history
|
|
||||||
) { scores }
|
|
||||||
else HistoryHelper(history) { scores }
|
|
||||||
|
|
||||||
// Extend pairables with members from all rounds
|
// Extend pairables with members from all rounds
|
||||||
|
|
||||||
// The main criterion that will be used to define the groups should be defined by subclasses
|
// The main criterion that will be used to define the groups should be defined by subclasses
|
||||||
// SOS and variants will be computed based on this score
|
// SOS and variants will be computed based on this score
|
||||||
val Pairable.main: Double get() = scores[id]?.second ?: 0.0
|
val Pairable.main: Double get() = score ?: 0.0
|
||||||
abstract val mainLimits: Pair<Double, Double>
|
abstract val mainLimits: Pair<Double, Double>
|
||||||
|
|
||||||
// pairables sorted using overloadable sort function
|
// pairables sorted using overloadable sort function
|
||||||
@@ -75,37 +66,55 @@ abstract class BasePairingHelper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// number of players in the biggest club and the biggest country
|
// number of players in the biggest club and the biggest country
|
||||||
// this can be used to disable geocost if there is a majority of players from the same country or club
|
// this can be used to adjust geocost if there is a majority of players from the same country or club
|
||||||
|
private val clubCounts by lazy {
|
||||||
|
pairables.groupingBy { it.club?.take(4)?.uppercase() }.eachCount()
|
||||||
|
}
|
||||||
protected val biggestClubSize by lazy {
|
protected val biggestClubSize by lazy {
|
||||||
pairables.groupingBy { it.club }.eachCount().values.maxOrNull()!!
|
clubCounts.values.maxOrNull() ?: 0
|
||||||
}
|
}
|
||||||
protected val biggestCountrySize by lazy {
|
protected val biggestCountrySize by lazy {
|
||||||
pairables.groupingBy { it.club }.eachCount().values.maxOrNull()!!
|
pairables.groupingBy { it.country }.eachCount().values.maxOrNull() ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local club detection: a club is "local" if it has more than the threshold proportion of players
|
||||||
|
protected val localClub: String? by lazy {
|
||||||
|
val threshold = pairing.geo.proportionMainClubThreshold
|
||||||
|
clubCounts.entries.find { (_, count) ->
|
||||||
|
count.toDouble() / pairables.size > threshold
|
||||||
|
}?.key
|
||||||
|
}
|
||||||
|
protected val hasLocalClub: Boolean get() = localClub != null
|
||||||
|
|
||||||
|
// Check if a player belongs to the local club
|
||||||
|
protected fun Pairable.isFromLocalClub(): Boolean {
|
||||||
|
val local = localClub ?: return false
|
||||||
|
return club?.take(4)?.uppercase() == local
|
||||||
}
|
}
|
||||||
|
|
||||||
// already paired players map
|
// already paired players map
|
||||||
protected fun Pairable.played(other: Pairable) = historyHelper.playedTogether(this, other)
|
protected fun Pairable.played(other: Pairable) = history.playedTogether(this, other)
|
||||||
|
|
||||||
// color balance (nw - nb)
|
// color balance (nw - nb)
|
||||||
protected val Pairable.colorBalance: Int get() = historyHelper.colorBalance(this) ?: 0
|
protected val Pairable.colorBalance: Int get() = history.colorBalance[id] ?: 0
|
||||||
|
|
||||||
protected val Pairable.group: Int get() = _groups[id]!!
|
protected val Pairable.group: Int get() = _groups[id]!!
|
||||||
|
|
||||||
protected val Pairable.drawnUpDown: Pair<Int, Int> get() = historyHelper.drawnUpDown(this) ?: Pair(0, 0)
|
protected val Pairable.drawnUpDown: Pair<Int, Int> get() = history.drawnUpDown[id] ?: Pair(0, 0)
|
||||||
|
|
||||||
protected val Pairable.nbBye: Int get() = historyHelper.nbPlayedWithBye(this) ?: 0
|
protected val Pairable.nbBye: Int get() = history.nbPlayedWithBye(this) ?: 0
|
||||||
|
|
||||||
// score (number of wins)
|
val Pairable.score: Double get() = history.scores[id] ?: 0.0
|
||||||
val Pairable.nbW: Double get() = historyHelper.nbW(this) ?: 0.0
|
val Pairable.scoreX: Double get() = history.scoresX[id] ?: 0.0
|
||||||
|
val Pairable.nbW: Double get() = history.wins[id] ?: 0.0
|
||||||
val Pairable.sos: Double get() = historyHelper.sos[id] ?: 0.0
|
val Pairable.sos: Double get() = history.sos[id] ?: 0.0
|
||||||
val Pairable.sosm1: Double get() = historyHelper.sosm1[id] ?: 0.0
|
val Pairable.sosm1: Double get() = history.sosm1[id] ?: 0.0
|
||||||
val Pairable.sosm2: Double get() = historyHelper.sosm2[id] ?: 0.0
|
val Pairable.sosm2: Double get() = history.sosm2[id] ?: 0.0
|
||||||
val Pairable.sosos: Double get() = historyHelper.sosos[id] ?: 0.0
|
val Pairable.sosos: Double get() = history.sosos[id] ?: 0.0
|
||||||
val Pairable.sodos: Double get() = historyHelper.sodos[id] ?: 0.0
|
val Pairable.sodos: Double get() = history.sodos[id] ?: 0.0
|
||||||
val Pairable.cums: Double get() = historyHelper.cumScore[id] ?: 0.0
|
val Pairable.cums: Double get() = history.cumScore[id] ?: 0.0
|
||||||
fun Pairable.missedRounds(): Int = (1 until round).map { round ->
|
fun Pairable.missedRounds(): Int = (1 until round).map { round ->
|
||||||
if (historyHelper.playersPerRound.getOrNull(round - 1)
|
if (history.playersPerRound.getOrNull(round - 1)
|
||||||
?.contains(id) == true
|
?.contains(id) == true
|
||||||
) 0 else 1
|
) 0 else 1
|
||||||
}.sum()
|
}.sum()
|
||||||
|
|||||||
@@ -2,12 +2,22 @@ package org.jeudego.pairgoth.pairing
|
|||||||
|
|
||||||
import org.jeudego.pairgoth.model.*
|
import org.jeudego.pairgoth.model.*
|
||||||
import org.jeudego.pairgoth.model.Game.Result.*
|
import org.jeudego.pairgoth.model.Game.Result.*
|
||||||
import org.jeudego.pairgoth.model.TeamTournament.Team
|
import org.jeudego.pairgoth.pairing.solver.Solver
|
||||||
|
|
||||||
|
typealias ScoreMap = Map<ID, Double>
|
||||||
|
typealias ScoreMapFactory = () -> ScoreMap
|
||||||
|
|
||||||
open class HistoryHelper(
|
open class HistoryHelper(
|
||||||
protected val history: List<List<Game>>,
|
protected val history: List<List<Game>>
|
||||||
// scoresGetter() returns Pair(sos value for missed rounds, score) where score is nbw for Swiss, mms for MM, ...
|
) {
|
||||||
scoresGetter: HistoryHelper.()-> Map<ID, Pair<Double, Double>>) {
|
|
||||||
|
lateinit var scoresFactory: ScoreMapFactory
|
||||||
|
lateinit var scoresXFactory: ScoreMapFactory
|
||||||
|
lateinit var missedRoundsSosFactory: ScoreMapFactory
|
||||||
|
|
||||||
|
val scores by lazy { scoresFactory() }
|
||||||
|
val scoresX by lazy { scoresXFactory() }
|
||||||
|
val missedRoundsSos by lazy { missedRoundsSosFactory() }
|
||||||
|
|
||||||
private val Game.blackScore get() = when (result) {
|
private val Game.blackScore get() = when (result) {
|
||||||
BLACK, BOTHWIN -> 1.0
|
BLACK, BOTHWIN -> 1.0
|
||||||
@@ -19,23 +29,10 @@ open class HistoryHelper(
|
|||||||
else -> 0.0
|
else -> 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
private val scores by lazy {
|
|
||||||
scoresGetter()
|
|
||||||
}
|
|
||||||
|
|
||||||
val scoresX by lazy {
|
|
||||||
scoresGetter().mapValues { entry ->
|
|
||||||
entry.value.first + (wins[entry.key] ?: 0.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic helper functions
|
// Generic helper functions
|
||||||
open fun playedTogether(p1: Pairable, p2: Pairable) = paired.contains(Pair(p1.id, p2.id))
|
open fun playedTogether(p1: Pairable, p2: Pairable) = paired.contains(Pair(p1.id, p2.id))
|
||||||
open fun colorBalance(p: Pairable) = colorBalance[p.id]
|
open fun colorBalance(p: Pairable) = colorBalance[p.id]
|
||||||
open fun nbPlayedWithBye(p: Pairable) = nbPlayedWithBye[p.id]
|
open fun nbPlayedWithBye(p: Pairable) = nbPlayedWithBye[p.id]
|
||||||
open fun nbW(p: Pairable) = wins[p.id]
|
|
||||||
|
|
||||||
fun drawnUpDown(p: Pairable) = drawnUpDown[p.id]
|
|
||||||
|
|
||||||
protected val paired: Set<Pair<ID, ID>> by lazy {
|
protected val paired: Set<Pair<ID, ID>> by lazy {
|
||||||
(history.flatten().map { game ->
|
(history.flatten().map { game ->
|
||||||
@@ -47,7 +44,7 @@ open class HistoryHelper(
|
|||||||
|
|
||||||
// Returns the number of games played as white minus the number of games played as black
|
// Returns the number of games played as white minus the number of games played as black
|
||||||
// Only count games without handicap
|
// Only count games without handicap
|
||||||
private val colorBalance: Map<ID, Int> by lazy {
|
val colorBalance: Map<ID, Int> by lazy {
|
||||||
history.flatten().filter { game ->
|
history.flatten().filter { game ->
|
||||||
game.handicap == 0
|
game.handicap == 0
|
||||||
}.filter { game ->
|
}.filter { game ->
|
||||||
@@ -72,14 +69,14 @@ open class HistoryHelper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set of all implied players for each round (warning: does comprise games with BIP)
|
// Set of all implied players for each round
|
||||||
val playersPerRound: List<Set<ID>> by lazy {
|
val playersPerRound: List<Set<ID>> by lazy {
|
||||||
history.map {
|
history.map { roundGames ->
|
||||||
it.fold(mutableSetOf<ID>()) { acc, next ->
|
roundGames.flatMap {
|
||||||
if(next.white != 0) acc.add(next.white)
|
game -> listOf(game.white, game.black)
|
||||||
if (next.black != 0) acc.add(next.black)
|
}.filter { id ->
|
||||||
acc
|
id != ByePlayer.id
|
||||||
}
|
}.toSet()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,67 +97,89 @@ open class HistoryHelper(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// define mms to be a synonym of scores
|
// define mms to be a synonym of scores
|
||||||
val mms by lazy { scores.mapValues { it -> it.value.second } }
|
val mms by lazy { scores }
|
||||||
|
|
||||||
val sos by lazy {
|
val sos by lazy {
|
||||||
|
// SOS for played games against a real opponent or BIP
|
||||||
val historySos = (history.flatten().map { game ->
|
val historySos = (history.flatten().map { game ->
|
||||||
Pair(
|
Pair(
|
||||||
game.black,
|
game.black,
|
||||||
if (game.white == 0) scores[game.black]?.first ?: 0.0
|
if (game.white == 0) missedRoundsSos[game.black] ?: 0.0
|
||||||
else scores[game.white]?.second?.let { it - game.handicap } ?: 0.0
|
else scores[game.white]?.let { it - game.handicap } ?: 0.0
|
||||||
)
|
)
|
||||||
} + history.flatten().map { game ->
|
} + history.flatten().map { game ->
|
||||||
Pair(
|
Pair(
|
||||||
game.white,
|
game.white,
|
||||||
if (game.black == 0) scores[game.white]?.first ?: 0.0
|
if (game.black == 0) missedRoundsSos[game.white] ?: 0.0
|
||||||
else scores[game.black]?.second?.let { it + game.handicap } ?: 0.0
|
else scores[game.black]?.let { it + game.handicap } ?: 0.0
|
||||||
)
|
)
|
||||||
}).groupingBy {
|
}).groupingBy {
|
||||||
it.first
|
it.first
|
||||||
}.fold(0.0) { acc, next ->
|
}.fold(0.0) { acc, next ->
|
||||||
acc + next.second
|
acc + next.second
|
||||||
}
|
}
|
||||||
|
// plus SOS for missed rounds
|
||||||
scores.mapValues { (id, pair) ->
|
missedRoundsSos.mapValues { (id, pseudoSos) ->
|
||||||
(historySos[id] ?: 0.0) + playersPerRound.sumOf {
|
(historySos[id] ?: 0.0) + playersPerRound.sumOf {
|
||||||
if (it.contains(id)) 0.0 else pair.first
|
if (it.contains(id)) 0.0 else pseudoSos
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sos-1
|
// sos-1
|
||||||
val sosm1 by lazy {
|
val sosm1 by lazy {
|
||||||
|
// SOS for played games against a real opponent or BIP
|
||||||
(history.flatten().map { game ->
|
(history.flatten().map { game ->
|
||||||
Pair(game.black, scores[game.white]?.second?.let { it - game.handicap } ?: 0.0)
|
Pair(
|
||||||
|
game.black,
|
||||||
|
if (game.white == 0) missedRoundsSos[game.black] ?: 0.0
|
||||||
|
else scores[game.white]?.let { it - game.handicap } ?: 0.0
|
||||||
|
)
|
||||||
} + history.flatten().map { game ->
|
} + history.flatten().map { game ->
|
||||||
Pair(game.white, scores[game.black]?.second?.let { it + game.handicap } ?: 0.0)
|
Pair(
|
||||||
|
game.white,
|
||||||
|
if (game.black == 0) missedRoundsSos[game.white] ?: 0.0
|
||||||
|
else scores[game.black]?.let { it + game.handicap } ?: 0.0
|
||||||
|
)
|
||||||
}).groupBy {
|
}).groupBy {
|
||||||
it.first
|
it.first
|
||||||
}.mapValues { (id, pairs) ->
|
}.mapValues { (id, pairs) ->
|
||||||
val oppScores = pairs.map { it.second }.sortedDescending()
|
val oppScores = pairs.map { it.second }.sortedDescending()
|
||||||
|
// minus greatest SOS
|
||||||
oppScores.sum() - (oppScores.firstOrNull() ?: 0.0) +
|
oppScores.sum() - (oppScores.firstOrNull() ?: 0.0) +
|
||||||
|
// plus SOS for missed rounds
|
||||||
playersPerRound.sumOf { players ->
|
playersPerRound.sumOf { players ->
|
||||||
if (players.contains(id)) 0.0
|
if (players.contains(id)) 0.0
|
||||||
else scores[id]?.first ?: 0.0
|
else missedRoundsSos[id] ?: 0.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sos-2
|
// sos-2
|
||||||
val sosm2 by lazy {
|
val sosm2 by lazy {
|
||||||
|
// SOS for played games against a real opponent or BIP
|
||||||
(history.flatten().map { game ->
|
(history.flatten().map { game ->
|
||||||
Pair(game.black, scores[game.white]?.second?.let { it - game.handicap } ?: 0.0)
|
Pair(
|
||||||
|
game.black,
|
||||||
|
if (game.white == 0) missedRoundsSos[game.black] ?: 0.0
|
||||||
|
else scores[game.white]?.let { it - game.handicap } ?: 0.0
|
||||||
|
)
|
||||||
} + history.flatten().map { game ->
|
} + history.flatten().map { game ->
|
||||||
Pair(game.white, scores[game.black]?.second?.let { it + game.handicap } ?: 0.0)
|
Pair(
|
||||||
|
game.white,
|
||||||
|
if (game.black == 0) missedRoundsSos[game.white] ?: 0.0
|
||||||
|
else scores[game.black]?.let { it + game.handicap } ?: 0.0
|
||||||
|
)
|
||||||
}).groupBy {
|
}).groupBy {
|
||||||
it.first
|
it.first
|
||||||
}.mapValues { (id, pairs) ->
|
}.mapValues { (id, pairs) ->
|
||||||
val oppScores = pairs.map { it.second }.sorted()
|
val oppScores = pairs.map { it.second }.sortedDescending()
|
||||||
|
// minus two greatest SOS
|
||||||
oppScores.sum() - oppScores.getOrElse(0) { 0.0 } - oppScores.getOrElse(1) { 0.0 } +
|
oppScores.sum() - oppScores.getOrElse(0) { 0.0 } - oppScores.getOrElse(1) { 0.0 } +
|
||||||
|
// plus SOS for missed rounds
|
||||||
playersPerRound.sumOf { players ->
|
playersPerRound.sumOf { players ->
|
||||||
if (players.contains(id)) 0.0
|
if (players.contains(id)) 0.0
|
||||||
else scores[id]?.first ?: 0.0
|
else missedRoundsSos[id] ?: 0.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,16 +189,17 @@ open class HistoryHelper(
|
|||||||
(history.flatten().filter { game ->
|
(history.flatten().filter { game ->
|
||||||
game.white != 0 // Remove games against byePlayer
|
game.white != 0 // Remove games against byePlayer
|
||||||
}.map { game ->
|
}.map { game ->
|
||||||
Pair(game.black, if (game.result == Game.Result.BLACK) scores[game.white]?.second?.let { it - game.handicap } ?: 0.0 else 0.0)
|
Pair(game.black, if (game.result == Game.Result.BLACK) scores[game.white]?.let { it - game.handicap } ?: 0.0 else 0.0)
|
||||||
} + history.flatten().filter { game ->
|
} + history.flatten().filter { game ->
|
||||||
game.white != 0 // Remove games against byePlayer
|
game.white != 0 // Remove games against byePlayer
|
||||||
}.map { game ->
|
}.map { game ->
|
||||||
Pair(game.white, if (game.result == Game.Result.WHITE) scores[game.black]?.second?.let { it + game.handicap } ?: 0.0 else 0.0)
|
Pair(game.white, if (game.result == Game.Result.WHITE) scores[game.black]?.let { it + game.handicap } ?: 0.0 else 0.0)
|
||||||
}).groupingBy { it.first }.fold(0.0) { acc, next ->
|
}).groupingBy { it.first }.fold(0.0) { acc, next ->
|
||||||
acc + next.second
|
acc + next.second
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// sosos
|
// sosos
|
||||||
val sosos by lazy {
|
val sosos by lazy {
|
||||||
val currentRound = history.size
|
val currentRound = history.size
|
||||||
@@ -193,9 +213,9 @@ open class HistoryHelper(
|
|||||||
acc + next.second
|
acc + next.second
|
||||||
}
|
}
|
||||||
|
|
||||||
scores.mapValues { (id, pair) ->
|
missedRoundsSos.mapValues { (id, missedRoundSos) ->
|
||||||
(historySosos[id] ?: 0.0) + playersPerRound.sumOf {
|
(historySosos[id] ?: 0.0) + playersPerRound.sumOf {
|
||||||
if (it.contains(id)) 0.0 else pair.first * currentRound
|
if (it.contains(id)) 0.0 else missedRoundSos * currentRound
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -241,11 +261,4 @@ open class HistoryHelper(
|
|||||||
else null
|
else null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// CB TODO - a big problem with the current naive implementation is that the team score is -for now- the sum of team members individual scores
|
|
||||||
|
|
||||||
class TeamOfIndividualsHistoryHelper(history: List<List<Game>>, scoresGetter: () -> Map<ID, Pair<Double, Double>>):
|
|
||||||
HistoryHelper(history, { scoresGetter() }) {
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package org.jeudego.pairgoth.pairing
|
package org.jeudego.pairgoth.pairing
|
||||||
|
|
||||||
import org.jeudego.pairgoth.model.Pairable
|
import org.jeudego.pairgoth.model.Pairable
|
||||||
import org.jeudego.pairgoth.pairing.solver.BaseSolver
|
|
||||||
|
|
||||||
fun detRandom(max: Double, p1: Pairable, p2: Pairable, symmetric: Boolean): Double {
|
fun detRandom(max: Double, p1: Pairable, p2: Pairable, symmetric: Boolean): Double {
|
||||||
var inverse = false
|
var inverse = false
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.jeudego.pairgoth.pairing.solver
|
package org.jeudego.pairgoth.pairing.solver
|
||||||
|
|
||||||
import org.jeudego.pairgoth.model.*
|
import org.jeudego.pairgoth.model.*
|
||||||
|
import org.jeudego.pairgoth.pairing.HistoryHelper
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -8,46 +9,41 @@ import kotlin.math.roundToInt
|
|||||||
|
|
||||||
class MacMahonSolver(round: Int,
|
class MacMahonSolver(round: Int,
|
||||||
totalRounds: Int,
|
totalRounds: Int,
|
||||||
history: List<List<Game>>,
|
history: HistoryHelper,
|
||||||
pairables: List<Pairable>,
|
pairables: List<Pairable>,
|
||||||
pairablesMap: Map<ID, Pairable>,
|
allPairablesMap: Map<ID, Pairable>,
|
||||||
pairingParams: PairingParams,
|
pairingParams: PairingParams,
|
||||||
placementParams: PlacementParams,
|
placementParams: PlacementParams,
|
||||||
usedTables: BitSet,
|
usedTables: BitSet,
|
||||||
private val mmFloor: Int, private val mmBar: Int) :
|
private val mmFloor: Int, private val mmBar: Int) :
|
||||||
BaseSolver(round, totalRounds, history, pairables, pairablesMap, pairingParams, placementParams, usedTables) {
|
Solver(round, totalRounds, history, pairables, allPairablesMap, pairingParams, placementParams, usedTables) {
|
||||||
|
|
||||||
override val scores: Map<ID, Pair<Double, Double>> by lazy {
|
override fun mainScoreMapFactory() =
|
||||||
require (mmBar > mmFloor) { "MMFloor is higher than MMBar" }
|
allPairablesMap.mapValues { (id, pairable) ->
|
||||||
pairablesMap.mapValues {
|
roundScore(pairable.mmBase +
|
||||||
it.value.let { pairable ->
|
pairable.nbW +
|
||||||
val score = roundScore(pairable.mmBase +
|
pairable.missedRounds() * pairing.main.mmsValueAbsent)
|
||||||
pairable.nbW +
|
}
|
||||||
pairable.missedRounds() * pairingParams.main.mmsValueAbsent)
|
|
||||||
Pair(
|
override fun scoreXMapFactory() =
|
||||||
if (pairingParams.main.sosValueAbsentUseBase) pairable.mmBase
|
allPairablesMap.mapValues { (id, pairable) ->
|
||||||
else roundScore(pairable.mmBase + round/2),
|
roundScore(pairable.mmBase + pairable.nbW)
|
||||||
score
|
}
|
||||||
)
|
|
||||||
|
override fun missedRoundSosMapFactory() =
|
||||||
|
allPairablesMap.mapValues { (id, pairable) ->
|
||||||
|
if (pairing.main.sosValueAbsentUseBase) {
|
||||||
|
pairable.mmBase
|
||||||
|
} else {
|
||||||
|
roundScore(pairable.mmBase + round/2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
override val scoresX: Map<ID, Double> by lazy {
|
|
||||||
require (mmBar > mmFloor) { "MMFloor is higher than MMBar" }
|
|
||||||
pairablesMap.mapValues {
|
|
||||||
it.value.let { pairable ->
|
|
||||||
roundScore(pairable.mmBase + pairable.nbW)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun computeWeightForBye(p: Pairable): Double{
|
override fun computeWeightForBye(p: Pairable): Double{
|
||||||
return 2*scores[p.id]!!.second
|
return 2 * p.score
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun SecondaryCritParams.apply(p1: Pairable, p2: Pairable): Double {
|
override fun SecondaryCritParams.playersMeetCriteria(p1: Pairable, p2: Pairable): Int {
|
||||||
|
|
||||||
// playersMeetCriteria = 0 : No player is above thresholds -> apply the full weight
|
// playersMeetCriteria = 0 : No player is above thresholds -> apply the full weight
|
||||||
// playersMeetCriteria = 1 : 1 player is above thresholds -> apply half the weight
|
// playersMeetCriteria = 1 : 1 player is above thresholds -> apply half the weight
|
||||||
// playersMeetCriteria = 2 : Both players are above thresholds -> do not apply weight
|
// playersMeetCriteria = 2 : Both players are above thresholds -> do not apply weight
|
||||||
@@ -63,14 +59,14 @@ class MacMahonSolver(round: Int,
|
|||||||
if (2 * p1.nbW >= nbw2Threshold
|
if (2 * p1.nbW >= nbw2Threshold
|
||||||
// check if STARTING MMS is above MM bar (OpenGotha v3.52 behavior)
|
// check if STARTING MMS is above MM bar (OpenGotha v3.52 behavior)
|
||||||
|| barThresholdActive && (p1.mmBase >= mmBar - Pairable.MIN_RANK)
|
|| barThresholdActive && (p1.mmBase >= mmBar - Pairable.MIN_RANK)
|
||||||
|| p1.mms >= rankSecThreshold - Pairable.MIN_RANK) playersMeetCriteria++
|
|| p1.mms >= rankSecThreshold - Pairable.MIN_RANK) playersMeetCriteria++
|
||||||
|
|
||||||
if (2 * p2.nbW >= nbw2Threshold
|
if (2 * p2.nbW >= nbw2Threshold
|
||||||
// check if STARTING MMS is above MM bar (OpenGotha v3.52 behavior)
|
// check if STARTING MMS is above MM bar (OpenGotha v3.52 behavior)
|
||||||
|| barThresholdActive && (p2.mmBase >= mmBar - Pairable.MIN_RANK)
|
|| barThresholdActive && (p2.mmBase >= mmBar - Pairable.MIN_RANK)
|
||||||
|| p2.mms >= rankSecThreshold - Pairable.MIN_RANK) playersMeetCriteria++
|
|| p2.mms >= rankSecThreshold - Pairable.MIN_RANK) playersMeetCriteria++
|
||||||
|
|
||||||
return pairing.geo.apply(p1, p2, playersMeetCriteria)
|
return playersMeetCriteria
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun HandicapParams.pseudoRank(pairable: Pairable): Int {
|
override fun HandicapParams.pseudoRank(pairable: Pairable): Int {
|
||||||
@@ -84,8 +80,7 @@ class MacMahonSolver(round: Int,
|
|||||||
// mmBase: starting Mac-Mahon score of the pairable
|
// mmBase: starting Mac-Mahon score of the pairable
|
||||||
val Pairable.mmBase: Double get() = min(max(rank, mmFloor), mmBar) + mmsZero + mmsCorrection
|
val Pairable.mmBase: Double get() = min(max(rank, mmFloor), mmBar) + mmsZero + mmsCorrection
|
||||||
// mms: current Mac-Mahon score of the pairable
|
// mms: current Mac-Mahon score of the pairable
|
||||||
val Pairable.mms: Double get() = scores[id]?.second ?: 0.0
|
val Pairable.mms: Double get() = score
|
||||||
val Pairable.scoreX: Double get() = scoresX[id] ?: 0.0
|
|
||||||
|
|
||||||
// CB TODO - configurable criteria
|
// CB TODO - configurable criteria
|
||||||
val mainScoreMin = mmFloor + PLA_SMMS_SCORE_MIN - Pairable.MIN_RANK
|
val mainScoreMin = mmFloor + PLA_SMMS_SCORE_MIN - Pairable.MIN_RANK
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package org.jeudego.pairgoth.pairing.solver
|
||||||
|
|
||||||
|
import org.jeudego.pairgoth.model.ID
|
||||||
|
import org.jeudego.pairgoth.model.Pairable
|
||||||
|
import org.jeudego.pairgoth.model.Tournament
|
||||||
|
import java.io.PrintWriter
|
||||||
|
|
||||||
|
interface PairingListener {
|
||||||
|
|
||||||
|
fun start(round: Int) {}
|
||||||
|
fun startPair(white: Pairable, black: Pairable) {}
|
||||||
|
fun endPair(white: Pairable, black: Pairable) {}
|
||||||
|
fun addWeight(name: String, weight: Double)
|
||||||
|
fun end() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoggingListener(val out: PrintWriter) : PairingListener {
|
||||||
|
|
||||||
|
var currentOpenGothaWeight: Double = 0.0
|
||||||
|
|
||||||
|
override fun start(round: Int) {
|
||||||
|
out.println("Round $round")
|
||||||
|
out.println("Costs")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun startPair(white: Pairable, black: Pairable) {
|
||||||
|
currentOpenGothaWeight = 0.0
|
||||||
|
out.println("Player1Name=${white.fullName()}")
|
||||||
|
out.println("Player2Name=${black.fullName()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addWeight(name: String, weight: Double) {
|
||||||
|
// Try hard to stay in sync with current reference files of OpenGotha conformance tests
|
||||||
|
val key = when (name) {
|
||||||
|
// TODO - Change to propagate to test reference files
|
||||||
|
"baseColorBalance" -> "baseBWBalance"
|
||||||
|
// Pairgoth-specific part of the color balance, not considered in conformance tests
|
||||||
|
"secColorBalance" -> return
|
||||||
|
else -> name
|
||||||
|
}
|
||||||
|
val value = when (name) {
|
||||||
|
// TODO - This cost is always zero in reference files, seems unused
|
||||||
|
"secHandi" -> 0.0
|
||||||
|
else -> weight
|
||||||
|
}
|
||||||
|
currentOpenGothaWeight += value
|
||||||
|
out.println("${key}Cost=$value")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun endPair(white: Pairable, black: Pairable) {
|
||||||
|
out.println("totalCost=$currentOpenGothaWeight")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun end() {
|
||||||
|
out.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollectingListener() : PairingListener {
|
||||||
|
|
||||||
|
val out = mutableMapOf<Pair<ID, ID>, MutableMap<String, Double>>()
|
||||||
|
var white: Pairable? = null
|
||||||
|
var black: Pairable? = null
|
||||||
|
|
||||||
|
override fun startPair(white: Pairable, black: Pairable) {
|
||||||
|
this.white = white
|
||||||
|
this.black = black
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addWeight(name: String, weight: Double) {
|
||||||
|
val key = Pair(white!!.id, black!!.id)
|
||||||
|
val weights = out.computeIfAbsent(key) { mutableMapOf() }
|
||||||
|
weights[name] = weight
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import org.jeudego.pairgoth.model.*
|
|||||||
import org.jeudego.pairgoth.model.MainCritParams.DrawUpDown
|
import org.jeudego.pairgoth.model.MainCritParams.DrawUpDown
|
||||||
import org.jeudego.pairgoth.model.MainCritParams.SeedMethod.*
|
import org.jeudego.pairgoth.model.MainCritParams.SeedMethod.*
|
||||||
import org.jeudego.pairgoth.pairing.BasePairingHelper
|
import org.jeudego.pairgoth.pairing.BasePairingHelper
|
||||||
|
import org.jeudego.pairgoth.pairing.HistoryHelper
|
||||||
import org.jeudego.pairgoth.pairing.detRandom
|
import org.jeudego.pairgoth.pairing.detRandom
|
||||||
import org.jeudego.pairgoth.pairing.nonDetRandom
|
import org.jeudego.pairgoth.pairing.nonDetRandom
|
||||||
import org.jeudego.pairgoth.store.nextGameId
|
import org.jeudego.pairgoth.store.nextGameId
|
||||||
@@ -18,26 +19,48 @@ import java.text.DecimalFormat
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.*
|
import kotlin.math.*
|
||||||
|
|
||||||
sealed class BaseSolver(
|
sealed class Solver(
|
||||||
round: Int,
|
round: Int,
|
||||||
totalRounds: Int,
|
totalRounds: Int,
|
||||||
history: List<List<Game>>, // History of all games played for each round
|
history: HistoryHelper, // Digested history of all games played for each round
|
||||||
pairables: List<Pairable>, // All pairables for this round, it may include the bye player
|
pairables: List<Pairable>, // Pairables to pair together
|
||||||
pairablesMap: Map<ID, Pairable>, // Map of all known pairables in this tournament
|
val allPairablesMap: Map<ID, Pairable>, // Map of all known pairables
|
||||||
pairing: PairingParams,
|
pairing: PairingParams,
|
||||||
placement: PlacementParams,
|
placement: PlacementParams,
|
||||||
val usedTables: BitSet
|
val usedTables: BitSet
|
||||||
) : BasePairingHelper(round, totalRounds, history, pairables, pairablesMap, pairing, placement) {
|
) : BasePairingHelper(round, totalRounds, history, pairables, pairing, placement) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val rand = Random(/* seed from properties - TODO */)
|
val rand = Random(/* seed from properties - TODO */)
|
||||||
// Used in tests
|
|
||||||
var weightsLogger: PrintWriter? = null
|
|
||||||
var legacy_mode = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For tests and explain feature
|
||||||
|
var legacyMode = false
|
||||||
|
var pairingListener: PairingListener? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
history.scoresFactory = this::mainScoreMapFactory
|
||||||
|
history.scoresXFactory = this::scoreXMapFactory
|
||||||
|
history.missedRoundsSosFactory = this::missedRoundSosMapFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main score map factory (NBW for Swiss, MMS for MacMahon, ...).
|
||||||
|
*/
|
||||||
|
abstract fun mainScoreMapFactory(): Map<ID, Double>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScoreX map factory (NBW for Swiss, MMSBase + MMS for MacMahon, ...).
|
||||||
|
*/
|
||||||
|
abstract fun scoreXMapFactory(): Map<ID, Double>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SOS for missed rounds factory (0 for Swiss, mmBase or mmBase+rounds/2 for MacMahon depending on pairing option sosValueAbsentUseBase)
|
||||||
|
*/
|
||||||
|
abstract fun missedRoundSosMapFactory(): Map<ID, Double>
|
||||||
|
|
||||||
open fun openGothaWeight(p1: Pairable, p2: Pairable) =
|
open fun openGothaWeight(p1: Pairable, p2: Pairable) =
|
||||||
1.0 + // 1 is minimum value because 0 means "no matching allowed"
|
1.0 + // 1 is the minimum value because 0 means "no matching allowed"
|
||||||
pairing.base.apply(p1, p2) +
|
pairing.base.apply(p1, p2) +
|
||||||
pairing.main.apply(p1, p2) +
|
pairing.main.apply(p1, p2) +
|
||||||
pairing.secondary.apply(p1, p2)
|
pairing.secondary.apply(p1, p2)
|
||||||
@@ -49,20 +72,26 @@ sealed class BaseSolver(
|
|||||||
else 0.0
|
else 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun weight(p1: Pairable, p2: Pairable) =
|
open fun weight(p1: Pairable, p2: Pairable): Double {
|
||||||
openGothaWeight(p1, p2) +
|
pairingListener?.startPair(p1, p2)
|
||||||
pairgothBlackWhite(p1, p2) +
|
return (
|
||||||
// pairing.base.applyByeWeight(p1, p2) +
|
openGothaWeight(p1, p2) +
|
||||||
pairing.handicap.color(p1, p2)
|
pairgothBlackWhite(p1, p2).also { pairingListener?.addWeight("secColorBalance", it) } +
|
||||||
|
// pairing.base.applyByeWeight(p1, p2) +
|
||||||
|
pairing.handicap.color(p1, p2).also { pairingListener?.addWeight("secHandi", it) }
|
||||||
|
).also {
|
||||||
|
pairingListener?.endPair(p1, p2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
open fun computeWeightForBye(p: Pairable): Double{
|
open fun computeWeightForBye(p: Pairable): Double {
|
||||||
// The weightForBye function depends on the system type (Mac-Mahon or Swiss), default value is 0.0
|
// The weightForBye function depends on the system type (Mac-Mahon or Swiss), default value is 0.0
|
||||||
return 0.0
|
return 0.0
|
||||||
}
|
}
|
||||||
|
|
||||||
fun pair(): List<Game> {
|
fun pair(): List<Game> {
|
||||||
// check that at this stage, we have an even number of pairables
|
// check that at this stage, we have an even number of pairables
|
||||||
// The BYE player should have been added beforehand to make a number of pairables even.
|
// The BYE player should have been added beforehand to make the number of pairables even.
|
||||||
if (pairables.size % 2 != 0) throw Error("expecting an even number of pairables")
|
if (pairables.size % 2 != 0) throw Error("expecting an even number of pairables")
|
||||||
val builder = GraphBuilder(SimpleDirectedWeightedGraph<Pairable, DefaultWeightedEdge>(DefaultWeightedEdge::class.java))
|
val builder = GraphBuilder(SimpleDirectedWeightedGraph<Pairable, DefaultWeightedEdge>(DefaultWeightedEdge::class.java))
|
||||||
|
|
||||||
@@ -70,21 +99,18 @@ sealed class BaseSolver(
|
|||||||
val logger = LoggerFactory.getLogger("debug")
|
val logger = LoggerFactory.getLogger("debug")
|
||||||
val debug = false
|
val debug = false
|
||||||
|
|
||||||
weightsLogger?.apply {
|
pairingListener?.start(round)
|
||||||
this.println("Round $round")
|
|
||||||
this.println("Costs")
|
|
||||||
}
|
|
||||||
|
|
||||||
var chosenByePlayer: Pairable = ByePlayer
|
var chosenByePlayer: Pairable = ByePlayer
|
||||||
// Choose bye player and remove from pairables
|
// Choose bye player and remove from pairables
|
||||||
if (ByePlayer in nameSortedPairables){
|
if (ByePlayer in nameSortedPairables){
|
||||||
nameSortedPairables.remove(ByePlayer)
|
nameSortedPairables.remove(ByePlayer)
|
||||||
var minWeight = 1000.0*round + (Pairable.MAX_RANK - Pairable.MIN_RANK) + 1;
|
var minWeight = 1000.0 * round + (Pairable.MAX_RANK - Pairable.MIN_RANK) + 1;
|
||||||
var weightForBye : Double
|
var weightForBye : Double
|
||||||
var byePlayerIndex = 0
|
var byePlayerIndex = 0
|
||||||
for (p in nameSortedPairables){
|
for (p in nameSortedPairables){
|
||||||
weightForBye = computeWeightForBye(p)
|
weightForBye = computeWeightForBye(p)
|
||||||
if (p.id in historyHelper.byePlayers) weightForBye += 1000
|
if (p.id in history.byePlayers) weightForBye += 1000
|
||||||
if (weightForBye <= minWeight){
|
if (weightForBye <= minWeight){
|
||||||
minWeight = weightForBye
|
minWeight = weightForBye
|
||||||
chosenByePlayer = p
|
chosenByePlayer = p
|
||||||
@@ -102,21 +128,6 @@ sealed class BaseSolver(
|
|||||||
val q = nameSortedPairables[j]
|
val q = nameSortedPairables[j]
|
||||||
weight(p, q).let { if (it != Double.NaN) builder.addEdge(p, q, it/1e6) }
|
weight(p, q).let { if (it != Double.NaN) builder.addEdge(p, q, it/1e6) }
|
||||||
weight(q, p).let { if (it != Double.NaN) builder.addEdge(q, p, it/1e6) }
|
weight(q, p).let { if (it != Double.NaN) builder.addEdge(q, p, it/1e6) }
|
||||||
weightsLogger?.apply {
|
|
||||||
this.println("Player1Name=${p.fullName()}")
|
|
||||||
this.println("Player2Name=${q.fullName()}")
|
|
||||||
this.println("baseDuplicateGameCost=${dec.format(pairing.base.avoidDuplicatingGames(p, q))}")
|
|
||||||
this.println("baseRandomCost=${dec.format(pairing.base.applyRandom(p, q))}")
|
|
||||||
this.println("baseBWBalanceCost=${dec.format(pairing.base.applyColorBalance(p, q))}")
|
|
||||||
this.println("mainCategoryCost=${dec.format(pairing.main.avoidMixingCategory(p, q))}")
|
|
||||||
this.println("mainScoreDiffCost=${dec.format(pairing.main.minimizeScoreDifference(p, q))}")
|
|
||||||
this.println("mainDUDDCost=${dec.format(pairing.main.applyDUDD(p, q))}")
|
|
||||||
this.println("mainSeedCost=${dec.format(pairing.main.applySeeding(p, q))}")
|
|
||||||
this.println("secHandiCost=${dec.format(pairing.handicap.handicap(p, q))}")
|
|
||||||
this.println("secGeoCost=${dec.format(pairing.secondary.apply(p, q))}")
|
|
||||||
this.println("totalCost=${dec.format(openGothaWeight(p,q))}")
|
|
||||||
//File(WEIGHTS_FILE).appendText("ByeCost="+dec.format(pairing.base.applyByeWeight(p,q))+"\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val graph = builder.build()
|
val graph = builder.build()
|
||||||
@@ -131,6 +142,8 @@ sealed class BaseSolver(
|
|||||||
// add game for ByePlayer
|
// add game for ByePlayer
|
||||||
if (chosenByePlayer != ByePlayer) result += Game(id = nextGameId, table = 0, white = chosenByePlayer.id, black = ByePlayer.id, result = Game.Result.fromSymbol('w'))
|
if (chosenByePlayer != ByePlayer) result += Game(id = nextGameId, table = 0, white = chosenByePlayer.id, black = ByePlayer.id, result = Game.Result.fromSymbol('w'))
|
||||||
|
|
||||||
|
pairingListener?.end()
|
||||||
|
|
||||||
if (debug) {
|
if (debug) {
|
||||||
var sumOfWeights = 0.0
|
var sumOfWeights = 0.0
|
||||||
|
|
||||||
@@ -144,8 +157,8 @@ sealed class BaseSolver(
|
|||||||
for (p in sortedPairables) {
|
for (p in sortedPairables) {
|
||||||
logger.info(String.format("%-20s", p.name.substring(0, min(p.name.length, 18)))
|
logger.info(String.format("%-20s", p.name.substring(0, min(p.name.length, 18)))
|
||||||
+ " " + String.format("%-4s", p.id)
|
+ " " + String.format("%-4s", p.id)
|
||||||
+ " " + String.format("%-4s", scores[p.id]?.first)
|
+ " " + String.format("%-4s", history.missedRoundsSos[p.id])
|
||||||
+ " " + String.format("%-4s", scores[p.id]?.second)
|
+ " " + String.format("%-4s", history.scores[p.id])
|
||||||
+ " " + String.format("%-4s", p.sos)
|
+ " " + String.format("%-4s", p.sos)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -194,11 +207,11 @@ sealed class BaseSolver(
|
|||||||
var score = 0.0
|
var score = 0.0
|
||||||
// Base Criterion 1 : Avoid Duplicating Game
|
// Base Criterion 1 : Avoid Duplicating Game
|
||||||
// Did p1 and p2 already play ?
|
// Did p1 and p2 already play ?
|
||||||
score += avoidDuplicatingGames(p1, p2)
|
score += avoidDuplicatingGames(p1, p2).also { pairingListener?.addWeight("baseDuplicateGame", it) }
|
||||||
// Base Criterion 2 : Random
|
// Base Criterion 2 : Random
|
||||||
score += applyRandom(p1, p2)
|
score += applyRandom(p1, p2).also { pairingListener?.addWeight("baseRandom", it) }
|
||||||
// Base Criterion 3 : Balance W and B
|
// Base Criterion 3 : Balance W and B
|
||||||
score += applyColorBalance(p1, p2)
|
score += applyColorBalance(p1, p2).also { pairingListener?.addWeight("baseColorBalance", it) }
|
||||||
|
|
||||||
return score
|
return score
|
||||||
}
|
}
|
||||||
@@ -258,16 +271,16 @@ sealed class BaseSolver(
|
|||||||
var score = 0.0
|
var score = 0.0
|
||||||
|
|
||||||
// Main criterion 1 avoid mixing category is moved to Swiss with category
|
// Main criterion 1 avoid mixing category is moved to Swiss with category
|
||||||
score += avoidMixingCategory(p1, p2)
|
score += avoidMixingCategory(p1, p2).also { pairingListener?.addWeight("mainCategory", it) }
|
||||||
|
|
||||||
// Main criterion 2 minimize score difference
|
// Main criterion 2 minimize score difference
|
||||||
score += minimizeScoreDifference(p1, p2)
|
score += minimizeScoreDifference(p1, p2).also { pairingListener?.addWeight("mainScoreDiff", it) }
|
||||||
|
|
||||||
// Main criterion 3 If different groups, make a directed Draw-up/Draw-down
|
// Main criterion 3 If different groups, make a directed Draw-up/Draw-down
|
||||||
score += applyDUDD(p1, p2)
|
score += applyDUDD(p1, p2).also { pairingListener?.addWeight("mainDUDD", it) }
|
||||||
|
|
||||||
// Main criterion 4 seeding
|
// Main criterion 4 seeding
|
||||||
score += applySeeding(p1, p2)
|
score += applySeeding(p1, p2).also { pairingListener?.addWeight("mainSeed", it) }
|
||||||
|
|
||||||
return score
|
return score
|
||||||
}
|
}
|
||||||
@@ -389,7 +402,7 @@ sealed class BaseSolver(
|
|||||||
val randRange = maxSeedingWeight * 0.2
|
val randRange = maxSeedingWeight * 0.2
|
||||||
// for old tests to pass
|
// for old tests to pass
|
||||||
val rand =
|
val rand =
|
||||||
if (legacy_mode && p1.fullName() > p2.fullName()) {
|
if (legacyMode && p1.fullName() > p2.fullName()) {
|
||||||
// for old tests to pass
|
// for old tests to pass
|
||||||
detRandom(randRange, p2, p1, false)
|
detRandom(randRange, p2, p1, false)
|
||||||
} else {
|
} else {
|
||||||
@@ -405,8 +418,7 @@ sealed class BaseSolver(
|
|||||||
return Math.round(score).toDouble()
|
return Math.round(score).toDouble()
|
||||||
}
|
}
|
||||||
|
|
||||||
open fun SecondaryCritParams.apply(p1: Pairable, p2: Pairable): Double {
|
open fun SecondaryCritParams.playersMeetCriteria(p1: Pairable, p2: Pairable): Int {
|
||||||
|
|
||||||
// playersMeetCriteria = 0 : No player is above thresholds -> apply secondary criteria
|
// playersMeetCriteria = 0 : No player is above thresholds -> apply secondary criteria
|
||||||
// playersMeetCriteria = 1 : 1 player is above thresholds -> apply half the weight
|
// playersMeetCriteria = 1 : 1 player is above thresholds -> apply half the weight
|
||||||
// playersMeetCriteria = 2 : Both players are above thresholds -> apply the full weight
|
// playersMeetCriteria = 2 : Both players are above thresholds -> apply the full weight
|
||||||
@@ -419,7 +431,11 @@ sealed class BaseSolver(
|
|||||||
if (2*p1.nbW >= nbw2Threshold) playersMeetCriteria++
|
if (2*p1.nbW >= nbw2Threshold) playersMeetCriteria++
|
||||||
if (2*p2.nbW >= nbw2Threshold) playersMeetCriteria++
|
if (2*p2.nbW >= nbw2Threshold) playersMeetCriteria++
|
||||||
|
|
||||||
return pairing.geo.apply(p1, p2, playersMeetCriteria)
|
return playersMeetCriteria
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SecondaryCritParams.apply(p1: Pairable, p2: Pairable): Double {
|
||||||
|
return pairing.geo.apply(p1, p2, playersMeetCriteria(p1, p2))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun GeographicalParams.apply(p1: Pairable, p2: Pairable, playersMeetCriteria: Int): Double {
|
fun GeographicalParams.apply(p1: Pairable, p2: Pairable, playersMeetCriteria: Int): Double {
|
||||||
@@ -427,14 +443,14 @@ sealed class BaseSolver(
|
|||||||
|
|
||||||
val geoMaxCost = pairing.geo.avoidSameGeo
|
val geoMaxCost = pairing.geo.avoidSameGeo
|
||||||
|
|
||||||
val countryFactor: Int = if (legacy_mode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold)
|
// Country factor: in legacy mode or when no dominant country, use normal factor
|
||||||
preferMMSDiffRatherThanSameCountry
|
val countryFactor: Int = if (legacyMode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold)
|
||||||
else
|
preferMMSDiffRatherThanSameCountry
|
||||||
0
|
else
|
||||||
val clubFactor: Int = if (legacy_mode || biggestClubSize.toDouble() / pairables.size <= proportionMainClubThreshold)
|
0
|
||||||
preferMMSDiffRatherThanSameClub
|
|
||||||
else
|
// Club factor: always use the configured value
|
||||||
0
|
val clubFactor: Int = preferMMSDiffRatherThanSameClub
|
||||||
//val groupFactor: Int = preferMMSDiffRatherThanSameClubsGroup
|
//val groupFactor: Int = preferMMSDiffRatherThanSameClubsGroup
|
||||||
|
|
||||||
// Same country
|
// Same country
|
||||||
@@ -447,27 +463,55 @@ sealed class BaseSolver(
|
|||||||
// Same club and club group (TODO club group)
|
// Same club and club group (TODO club group)
|
||||||
var clubRatio = 0.0
|
var clubRatio = 0.0
|
||||||
// To match OpenGotha, only do a case insensitive comparison of the first four characters.
|
// To match OpenGotha, only do a case insensitive comparison of the first four characters.
|
||||||
// But obviously, there is a margin of improvement here towards some way of normalizing clubs.
|
|
||||||
val commonClub = p1.club?.take(4)?.uppercase() == p2.club?.take(4)?.uppercase()
|
val commonClub = p1.club?.take(4)?.uppercase() == p2.club?.take(4)?.uppercase()
|
||||||
val commonGroup = false // TODO
|
val commonGroup = false // TODO
|
||||||
|
|
||||||
if (commonGroup && !commonClub) {
|
// Local club adjustment (non-legacy mode only):
|
||||||
|
// When a local club exists, we want to encourage local-vs-stranger pairings.
|
||||||
|
// - Ist vs Ist: full bonus (treat as different clubs)
|
||||||
|
// - Ist vs non-Ist: full bonus (different clubs, and mixing locals with visitors)
|
||||||
|
// - non-Ist vs non-Ist (different clubs): half bonus (prefer local-stranger mixing)
|
||||||
|
// - non-Ist vs non-Ist (same club): no bonus (normal same-club behavior)
|
||||||
|
val p1Local = p1.isFromLocalClub()
|
||||||
|
val p2Local = p2.isFromLocalClub()
|
||||||
|
val bothStrangers = !legacyMode && hasLocalClub && !p1Local && !p2Local
|
||||||
|
|
||||||
|
val effectiveCommonClub: Boolean = if (!legacyMode && hasLocalClub && commonClub) {
|
||||||
|
// Both from local club: treat as different clubs (effectiveCommonClub = false)
|
||||||
|
// Both strangers from same club: normal same-club (effectiveCommonClub = true)
|
||||||
|
// Mixed (one local, one stranger): treat as different (effectiveCommonClub = false)
|
||||||
|
bothStrangers
|
||||||
|
} else {
|
||||||
|
commonClub
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commonGroup && !effectiveCommonClub) {
|
||||||
clubRatio = if (clubFactor == 0) {
|
clubRatio = if (clubFactor == 0) {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
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) {
|
clubRatio = if (clubFactor == 0) {
|
||||||
0.0
|
0.0
|
||||||
} else {
|
} else {
|
||||||
clubFactor.toDouble() * 1.2 / placementScoreRange.toDouble()
|
val factor = if (bothStrangers) 0.5 else 1.0 // Half bonus for stranger-vs-stranger
|
||||||
|
factor * clubFactor.toDouble() * 1.2 / placementScoreRange.toDouble()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// else: effectiveCommonClub = true → clubRatio stays 0 (no bonus for same club)
|
||||||
clubRatio = min(clubRatio, 1.0)
|
clubRatio = min(clubRatio, 1.0)
|
||||||
|
|
||||||
// TODO Same family
|
// Same family: when enabled and players are from the same club, check if they have the same surname
|
||||||
|
// If so, remove the bonus to avoid pairing family members (even if local club logic gave them a bonus)
|
||||||
|
if (avoidSameFamily && commonClub) {
|
||||||
|
val sameFamily = p1.name.uppercase() == p2.name.uppercase()
|
||||||
|
if (sameFamily) {
|
||||||
|
clubRatio = 0.0 // No bonus for same family within same club
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// compute geoRatio
|
// compute geoRatio
|
||||||
val mainPart = max(countryRatio, clubRatio)
|
val mainPart = max(countryRatio, clubRatio)
|
||||||
@@ -489,7 +533,7 @@ sealed class BaseSolver(
|
|||||||
2 -> geoMaxCost
|
2 -> geoMaxCost
|
||||||
1 -> 0.5 * (geoNominalCost + geoMaxCost)
|
1 -> 0.5 * (geoNominalCost + geoMaxCost)
|
||||||
else -> geoNominalCost
|
else -> geoNominalCost
|
||||||
}
|
}.also { pairingListener?.addWeight("secGeo", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handicap functions
|
// Handicap functions
|
||||||
@@ -537,7 +581,7 @@ sealed class BaseSolver(
|
|||||||
} else if (p1.colorBalance < p2.colorBalance) {
|
} else if (p1.colorBalance < p2.colorBalance) {
|
||||||
score = 1.0
|
score = 1.0
|
||||||
} else { // choose color from a det random
|
} else { // choose color from a det random
|
||||||
if (detRandom(1.0, p1, p2, false) === 0.0) {
|
if (detRandom(1.0, p1, p2, false) == 0.0) {
|
||||||
score = 1.0
|
score = 1.0
|
||||||
} else {
|
} else {
|
||||||
score = -1.0
|
score = -1.0
|
||||||
@@ -1,31 +1,35 @@
|
|||||||
package org.jeudego.pairgoth.pairing.solver
|
package org.jeudego.pairgoth.pairing.solver
|
||||||
|
|
||||||
import org.jeudego.pairgoth.model.*
|
import org.jeudego.pairgoth.model.*
|
||||||
|
import org.jeudego.pairgoth.pairing.HistoryHelper
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
class SwissSolver(round: Int,
|
class SwissSolver(round: Int,
|
||||||
totalRounds: Int,
|
totalRounds: Int,
|
||||||
history: List<List<Game>>,
|
history: HistoryHelper,
|
||||||
pairables: List<Pairable>,
|
pairables: List<Pairable>,
|
||||||
pairablesMap: Map<ID, Pairable>,
|
allPairablesMap: Map<ID, Pairable>,
|
||||||
pairingParams: PairingParams,
|
pairingParams: PairingParams,
|
||||||
placementParams: PlacementParams,
|
placementParams: PlacementParams,
|
||||||
usedTables: BitSet
|
usedTables: BitSet
|
||||||
):
|
):
|
||||||
BaseSolver(round, totalRounds, history, pairables, pairablesMap, pairingParams, placementParams, usedTables) {
|
Solver(round, totalRounds, history, pairables, allPairablesMap, pairingParams, placementParams, usedTables) {
|
||||||
|
|
||||||
// In a Swiss tournament the main criterion is the number of wins and already computed
|
override fun mainScoreMapFactory() =
|
||||||
|
allPairablesMap.mapValues { (id, pairable) ->
|
||||||
override val scores by lazy {
|
history.wins[id] ?: 0.0
|
||||||
pairablesMap.mapValues {
|
}
|
||||||
Pair(0.0, historyHelper.wins[it.value.id] ?: 0.0)
|
|
||||||
|
override fun scoreXMapFactory() = mainScoreMapFactory()
|
||||||
|
|
||||||
|
override fun missedRoundSosMapFactory() =
|
||||||
|
allPairablesMap.mapValues { (id, pairable) ->
|
||||||
|
0.0
|
||||||
}
|
}
|
||||||
}
|
|
||||||
override val scoresX: Map<ID, Double> get() = scores.mapValues { it.value.second }
|
|
||||||
|
|
||||||
override val mainLimits = Pair(0.0, round - 1.0)
|
override val mainLimits = Pair(0.0, round - 1.0)
|
||||||
|
|
||||||
override fun computeWeightForBye(p: Pairable): Double{
|
override fun computeWeightForBye(p: Pairable): Double{
|
||||||
return p.rank + 40*p.main
|
return p.rank + 40 * p.main
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.github.benmanes.caffeine.cache.Caffeine
|
|||||||
import com.republicate.kson.Json
|
import com.republicate.kson.Json
|
||||||
import org.apache.commons.io.input.BOMInputStream
|
import org.apache.commons.io.input.BOMInputStream
|
||||||
import org.jeudego.pairgoth.api.ApiHandler
|
import org.jeudego.pairgoth.api.ApiHandler
|
||||||
|
import org.jeudego.pairgoth.api.ExplainHandler
|
||||||
import org.jeudego.pairgoth.api.PairingHandler
|
import org.jeudego.pairgoth.api.PairingHandler
|
||||||
import org.jeudego.pairgoth.api.PlayerHandler
|
import org.jeudego.pairgoth.api.PlayerHandler
|
||||||
import org.jeudego.pairgoth.api.ResultsHandler
|
import org.jeudego.pairgoth.api.ResultsHandler
|
||||||
@@ -99,6 +100,7 @@ class ApiServlet: HttpServlet() {
|
|||||||
if ("token" == selector) TokenHandler
|
if ("token" == selector) TokenHandler
|
||||||
else when (subEntity) {
|
else when (subEntity) {
|
||||||
null -> TournamentHandler
|
null -> TournamentHandler
|
||||||
|
"explain" -> ExplainHandler
|
||||||
"part" -> PlayerHandler
|
"part" -> PlayerHandler
|
||||||
"pair" -> PairingHandler
|
"pair" -> PairingHandler
|
||||||
"res" -> ResultsHandler
|
"res" -> ResultsHandler
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
package org.jeudego.pairgoth.test
|
package org.jeudego.pairgoth.test
|
||||||
|
|
||||||
import com.republicate.kson.Json
|
import com.republicate.kson.Json
|
||||||
import org.jeudego.pairgoth.pairing.solver.BaseSolver
|
import org.jeudego.pairgoth.pairing.solver.Solver
|
||||||
import org.jeudego.pairgoth.model.Game
|
|
||||||
import org.jeudego.pairgoth.test.PairingTests.Companion.compare_weights
|
import org.jeudego.pairgoth.test.PairingTests.Companion.compare_weights
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.io.FileWriter
|
import java.io.FileWriter
|
||||||
import java.io.PrintWriter
|
import java.io.PrintWriter
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import kotlin.test.assertEquals
|
|
||||||
import kotlin.test.assertNotEquals
|
import kotlin.test.assertNotEquals
|
||||||
import kotlin.test.assertNotNull
|
import kotlin.test.assertNotNull
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
@@ -22,24 +20,22 @@ class BOSP2024Test: TestBase() {
|
|||||||
)!!.asObject()
|
)!!.asObject()
|
||||||
val resp = TestAPI.post("/api/tour", tournament).asObject()
|
val resp = TestAPI.post("/api/tour", tournament).asObject()
|
||||||
val tourId = resp.getInt("id")
|
val tourId = resp.getInt("id")
|
||||||
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("bosp2024-weights.txt")))
|
val outputFile = getOutputFile("bosp2024-weights.txt")
|
||||||
|
TestAPI.post("/api/tour/$tourId/pair/3?legacy=true&weights_output=$outputFile", Json.Array("all")).asArray()
|
||||||
BaseSolver.legacy_mode = true
|
|
||||||
TestAPI.post("/api/tour/$tourId/pair/3", Json.Array("all")).asArray()
|
|
||||||
|
|
||||||
// compare weights
|
// compare weights
|
||||||
assertTrue(compare_weights(getOutputFile("bosp2024-weights.txt"), getTestFile("opengotha/bosp2024/bosp2024_weights_R3.txt")), "Not matching opengotha weights for BOSP test")
|
assertTrue(compare_weights(outputFile, getTestFile("opengotha/bosp2024/bosp2024_weights_R3.txt")), "Not matching opengotha weights for BOSP test")
|
||||||
TestAPI.delete("/api/tour/$tourId/pair/3", Json.Array("all"))
|
TestAPI.delete("/api/tour/$tourId/pair/3", Json.Array("all"))
|
||||||
|
|
||||||
BaseSolver.legacy_mode = false
|
|
||||||
val games = TestAPI.post("/api/tour/$tourId/pair/3", Json.Array("all")).asArray()
|
val games = TestAPI.post("/api/tour/$tourId/pair/3", Json.Array("all")).asArray()
|
||||||
// Aksut Husrev is ID 18
|
// Aksut Husrev is ID 18
|
||||||
val solved = games.map { it as Json.Object }.filter { game ->
|
val solved = games.map { it as Json.Object }.firstOrNull { game ->
|
||||||
// build the two-elements set of players ids
|
// build the two-elements set of players ids
|
||||||
val players = game.entries.filter { (k, v) -> k == "b" || k == "w" }.map { (k, v) -> (v as Number).toInt() }.toSet()
|
val players =
|
||||||
|
game.entries.filter { (k, v) -> k == "b" || k == "w" }.map { (k, v) -> (v as Number).toInt() }.toSet()
|
||||||
// keep game with Aksut Husrev
|
// keep game with Aksut Husrev
|
||||||
players.contains(18)
|
players.contains(18)
|
||||||
}.firstOrNull()
|
}
|
||||||
|
|
||||||
assertNotNull(solved)
|
assertNotNull(solved)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package org.jeudego.pairgoth.test
|
package org.jeudego.pairgoth.test
|
||||||
|
|
||||||
|
import org.jeudego.pairgoth.ext.MacMahon39
|
||||||
import org.jeudego.pairgoth.ext.OpenGotha
|
import org.jeudego.pairgoth.ext.OpenGotha
|
||||||
import org.jeudego.pairgoth.model.toJson
|
import org.jeudego.pairgoth.model.toJson
|
||||||
import org.jeudego.pairgoth.util.XmlUtils
|
import org.jeudego.pairgoth.util.XmlUtils
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
class ImportExportTests: TestBase() {
|
class ImportExportTests: TestBase() {
|
||||||
|
|
||||||
@@ -56,4 +58,73 @@ class ImportExportTests: TestBase() {
|
|||||||
assertEquals(jsonTournament, jsonTournament2)
|
assertEquals(jsonTournament, jsonTournament2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `003 test macmahon39 import`() {
|
||||||
|
getTestResources("macmahon39")?.forEach { file ->
|
||||||
|
logger.info("===== Testing MacMahon 3.9 import: ${file.name} =====")
|
||||||
|
val resource = file.readText(StandardCharsets.UTF_8)
|
||||||
|
val root_xml = XmlUtils.parse(resource)
|
||||||
|
|
||||||
|
// Verify format detection
|
||||||
|
assertTrue(MacMahon39.isFormat(root_xml), "File should be detected as MacMahon 3.9 format")
|
||||||
|
|
||||||
|
// Import tournament
|
||||||
|
val tournament = MacMahon39.import(root_xml)
|
||||||
|
|
||||||
|
// Verify basic tournament data
|
||||||
|
logger.info("Tournament name: ${tournament.name}")
|
||||||
|
logger.info("Number of rounds: ${tournament.rounds}")
|
||||||
|
logger.info("Number of players: ${tournament.pairables.size}")
|
||||||
|
|
||||||
|
assertEquals("Test MacMahon Tournament", tournament.name)
|
||||||
|
assertEquals(3, tournament.rounds)
|
||||||
|
assertEquals(4, tournament.pairables.size)
|
||||||
|
|
||||||
|
// Verify players
|
||||||
|
val players = tournament.pairables.values.toList()
|
||||||
|
val alice = players.find { it.name == "Smith" }
|
||||||
|
val bob = players.find { it.name == "Jones" }
|
||||||
|
val carol = players.find { it.name == "White" }
|
||||||
|
val david = players.find { it.name == "Brown" }
|
||||||
|
|
||||||
|
assertTrue(alice != null, "Alice should exist")
|
||||||
|
assertTrue(bob != null, "Bob should exist")
|
||||||
|
assertTrue(carol != null, "Carol should exist")
|
||||||
|
assertTrue(david != null, "David should exist")
|
||||||
|
|
||||||
|
assertEquals(2, alice!!.rank) // 3d = rank 2
|
||||||
|
assertEquals(1, bob!!.rank) // 2d = rank 1
|
||||||
|
assertEquals(0, carol!!.rank) // 1d = rank 0
|
||||||
|
assertEquals(-1, david!!.rank) // 1k = rank -1
|
||||||
|
|
||||||
|
// Carol is super bar member
|
||||||
|
assertEquals(1, carol.mmsCorrection)
|
||||||
|
|
||||||
|
// David skips round 2
|
||||||
|
assertTrue(david.skip.contains(2), "David should skip round 2")
|
||||||
|
|
||||||
|
// Verify games
|
||||||
|
val round1Games = tournament.games(1).values.toList()
|
||||||
|
val round2Games = tournament.games(2).values.toList()
|
||||||
|
|
||||||
|
logger.info("Round 1 games: ${round1Games.size}")
|
||||||
|
logger.info("Round 2 games: ${round2Games.size}")
|
||||||
|
|
||||||
|
assertEquals(2, round1Games.size)
|
||||||
|
assertEquals(2, round2Games.size) // 1 regular game + 1 bye
|
||||||
|
|
||||||
|
// Test via API
|
||||||
|
val resp = TestAPI.post("/api/tour", resource)
|
||||||
|
val id = resp.asObject().getInt("id")
|
||||||
|
logger.info("Imported tournament id: $id")
|
||||||
|
|
||||||
|
val apiTournament = TestAPI.get("/api/tour/$id").asObject()
|
||||||
|
assertEquals("Test MacMahon Tournament", apiTournament.getString("name"))
|
||||||
|
assertEquals(3, apiTournament.getInt("rounds"))
|
||||||
|
|
||||||
|
val apiPlayers = TestAPI.get("/api/tour/$id/part").asArray()
|
||||||
|
assertEquals(4, apiPlayers.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
api-webapp/src/test/kotlin/LocalClubTest.kt
Normal file
108
api-webapp/src/test/kotlin/LocalClubTest.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package org.jeudego.pairgoth.test
|
||||||
|
|
||||||
|
import com.republicate.kson.Json
|
||||||
|
import org.jeudego.pairgoth.model.ID
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test for local club behavior in geographical pairing criteria.
|
||||||
|
*
|
||||||
|
* When a club has more than 40% of players (proportionMainClubThreshold),
|
||||||
|
* it's considered the "local club" and geographical penalties are adjusted:
|
||||||
|
* - Two players from the local club: no club penalty
|
||||||
|
* - Two "strangers" (not from local club) with same club: half penalty
|
||||||
|
* - Players from different clubs: no bonus (like when threshold exceeded)
|
||||||
|
*/
|
||||||
|
class LocalClubTest : TestBase() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Tournament with MacMahon pairing to test geographical criteria
|
||||||
|
val localClubTournament = Json.Object(
|
||||||
|
"type" to "INDIVIDUAL",
|
||||||
|
"name" to "Local Club Test",
|
||||||
|
"shortName" to "local-club-test",
|
||||||
|
"startDate" to "2024-01-01",
|
||||||
|
"endDate" to "2024-01-01",
|
||||||
|
"country" to "FR",
|
||||||
|
"location" to "Test Location",
|
||||||
|
"online" to false,
|
||||||
|
"timeSystem" to Json.Object(
|
||||||
|
"type" to "SUDDEN_DEATH",
|
||||||
|
"mainTime" to 3600
|
||||||
|
),
|
||||||
|
"rounds" to 1,
|
||||||
|
"pairing" to Json.Object(
|
||||||
|
"type" to "MAC_MAHON",
|
||||||
|
"mmFloor" to -20,
|
||||||
|
"mmBar" to 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to create a player
|
||||||
|
fun player(name: String, firstname: String, rating: Int, rank: Int, club: String, country: String = "FR") = Json.Object(
|
||||||
|
"name" to name,
|
||||||
|
"firstname" to firstname,
|
||||||
|
"rating" to rating,
|
||||||
|
"rank" to rank,
|
||||||
|
"country" to country,
|
||||||
|
"club" to club,
|
||||||
|
"final" to true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `local club detection with more than 40 percent`() {
|
||||||
|
// Create tournament
|
||||||
|
var resp = TestAPI.post("/api/tour", localClubTournament).asObject()
|
||||||
|
val tourId = resp.getInt("id") ?: throw Error("tournament creation failed")
|
||||||
|
|
||||||
|
// Add 10 players: 5 from "LocalClub" (50% > 40% threshold),
|
||||||
|
// 2 strangers from "VisitorA", 2 strangers from "VisitorB", 1 from "Solo"
|
||||||
|
val playerIds = mutableListOf<ID>()
|
||||||
|
|
||||||
|
// 5 local club players (50%) - all same rank to be in same group
|
||||||
|
for (i in 1..5) {
|
||||||
|
resp = TestAPI.post("/api/tour/$tourId/part", player("Local$i", "Player", 100, -10, "LocalClub")).asObject()
|
||||||
|
assertTrue(resp.getBoolean("success")!!)
|
||||||
|
playerIds.add(resp.getInt("id")!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 visitors from VisitorA club
|
||||||
|
for (i in 1..2) {
|
||||||
|
resp = TestAPI.post("/api/tour/$tourId/part", player("VisitorA$i", "Player", 100, -10, "VisitorA")).asObject()
|
||||||
|
assertTrue(resp.getBoolean("success")!!)
|
||||||
|
playerIds.add(resp.getInt("id")!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2 visitors from VisitorB club
|
||||||
|
for (i in 1..2) {
|
||||||
|
resp = TestAPI.post("/api/tour/$tourId/part", player("VisitorB$i", "Player", 100, -10, "VisitorB")).asObject()
|
||||||
|
assertTrue(resp.getBoolean("success")!!)
|
||||||
|
playerIds.add(resp.getInt("id")!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1 solo player
|
||||||
|
resp = TestAPI.post("/api/tour/$tourId/part", player("Solo", "Player", 100, -10, "SoloClub")).asObject()
|
||||||
|
assertTrue(resp.getBoolean("success")!!)
|
||||||
|
playerIds.add(resp.getInt("id")!!)
|
||||||
|
|
||||||
|
assertEquals(10, playerIds.size, "Should have 10 players")
|
||||||
|
|
||||||
|
// Generate pairing with weights output
|
||||||
|
val outputFile = getOutputFile("local-club-weights.txt")
|
||||||
|
val games = TestAPI.post("/api/tour/$tourId/pair/1?weights_output=$outputFile", Json.Array("all")).asArray()
|
||||||
|
|
||||||
|
// Verify we got 5 games (10 players / 2)
|
||||||
|
assertEquals(5, games.size, "Should have 5 games")
|
||||||
|
|
||||||
|
// Read and verify the weights file exists
|
||||||
|
assertTrue(outputFile.exists(), "Weights file should exist")
|
||||||
|
|
||||||
|
// The key verification is that the test completes without errors
|
||||||
|
// and that local club players can be paired together
|
||||||
|
// (The BOSP2024 test verifies the detailed behavior matches expected DUDD outcomes)
|
||||||
|
logger.info("Local club test completed successfully with ${games.size} games")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
package org.jeudego.pairgoth.test
|
package org.jeudego.pairgoth.test
|
||||||
|
|
||||||
import com.republicate.kson.Json
|
import com.republicate.kson.Json
|
||||||
import org.jeudego.pairgoth.pairing.solver.BaseSolver
|
|
||||||
import org.jeudego.pairgoth.test.PairingTests.Companion.compare_weights
|
import org.jeudego.pairgoth.test.PairingTests.Companion.compare_weights
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.io.FileWriter
|
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertTrue
|
||||||
@@ -19,8 +16,8 @@ class MalavasiTest: TestBase() {
|
|||||||
)!!.asObject()
|
)!!.asObject()
|
||||||
val resp = TestAPI.post("/api/tour", tournament).asObject()
|
val resp = TestAPI.post("/api/tour", tournament).asObject()
|
||||||
val tourId = resp.getInt("id")
|
val tourId = resp.getInt("id")
|
||||||
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("malavasi-weights.txt")))
|
val outputFile = getOutputFile("malavasi-weights.txt")
|
||||||
val games = TestAPI.post("/api/tour/$tourId/pair/2", Json.Array("all")).asArray()
|
val games = TestAPI.post("/api/tour/$tourId/pair/2?weights_output=$outputFile", Json.Array("all")).asArray()
|
||||||
// Oceane is ID 548, Valentine 549
|
// Oceane is ID 548, Valentine 549
|
||||||
val buggy = games.map { it as Json.Object }.filter { game ->
|
val buggy = games.map { it as Json.Object }.filter { game ->
|
||||||
// build the two-elements set of players ids
|
// build the two-elements set of players ids
|
||||||
@@ -33,6 +30,6 @@ class MalavasiTest: TestBase() {
|
|||||||
assertEquals(2, buggy.size)
|
assertEquals(2, buggy.size)
|
||||||
|
|
||||||
// compare weights
|
// compare weights
|
||||||
assertTrue(compare_weights(getOutputFile("malavasi-weights.txt"), getTestFile("opengotha/malavasi/malavasi_weights_R2.txt")), "Not matching opengotha weights for Malavasi test")
|
assertTrue(compare_weights(outputFile, getTestFile("opengotha/malavasi/malavasi_weights_R2.txt")), "Not matching opengotha weights for Malavasi test")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ package org.jeudego.pairgoth.test
|
|||||||
|
|
||||||
import com.republicate.kson.Json
|
import com.republicate.kson.Json
|
||||||
import org.jeudego.pairgoth.model.*
|
import org.jeudego.pairgoth.model.*
|
||||||
import org.jeudego.pairgoth.pairing.solver.BaseSolver
|
import org.jeudego.pairgoth.pairing.solver.Solver
|
||||||
import org.jeudego.pairgoth.store.MemoryStore
|
import org.jeudego.pairgoth.store.MemoryStore
|
||||||
import org.jeudego.pairgoth.store.lastPlayerId
|
import org.jeudego.pairgoth.store.lastPlayerId
|
||||||
import org.junit.jupiter.api.BeforeEach
|
import org.junit.jupiter.api.BeforeEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileWriter
|
|
||||||
import java.io.PrintWriter
|
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -59,7 +57,6 @@ class PairingTests: TestBase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun compare_weights(file1: File, file2: File, skipSeeding: Boolean = false):Boolean {
|
fun compare_weights(file1: File, file2: File, skipSeeding: Boolean = false):Boolean {
|
||||||
BaseSolver.weightsLogger!!.flush()
|
|
||||||
// Maps to store name pairs and costs
|
// Maps to store name pairs and costs
|
||||||
val map1 = create_weights_map(file1)
|
val map1 = create_weights_map(file1)
|
||||||
val map2 = create_weights_map(file2)
|
val map2 = create_weights_map(file2)
|
||||||
@@ -165,6 +162,7 @@ class PairingTests: TestBase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun test_from_XML(name: String, forcePairing:List<Int>) {
|
fun test_from_XML(name: String, forcePairing:List<Int>) {
|
||||||
|
// Let pairgoth use the legacy asymmetric detRandom()
|
||||||
test_from_XML_internal(name, forcePairing, true)
|
test_from_XML_internal(name, forcePairing, true)
|
||||||
// Non-legacy tests inhibited for now: pairings differ for Toulouse and SimpleMM
|
// Non-legacy tests inhibited for now: pairings differ for Toulouse and SimpleMM
|
||||||
// test_from_XML_internal(name, forcePairing, false)
|
// test_from_XML_internal(name, forcePairing, false)
|
||||||
@@ -172,11 +170,10 @@ class PairingTests: TestBase() {
|
|||||||
|
|
||||||
fun test_from_XML_internal(name: String, forcePairing:List<Int>, legacy: Boolean) {
|
fun test_from_XML_internal(name: String, forcePairing:List<Int>, legacy: Boolean) {
|
||||||
// Let pairgoth use the legacy asymmetric detRandom()
|
// Let pairgoth use the legacy asymmetric detRandom()
|
||||||
BaseSolver.legacy_mode = legacy
|
|
||||||
// read tournament with pairing
|
// read tournament with pairing
|
||||||
val file = getTestFile("opengotha/pairings/$name.xml")
|
val tourFile = getTestFile("opengotha/pairings/$name.xml")
|
||||||
logger.info("read from file $file")
|
logger.info("read from file $tourFile")
|
||||||
val resource = file.readText(StandardCharsets.UTF_8)
|
val resource = tourFile.readText(StandardCharsets.UTF_8)
|
||||||
var resp = TestAPI.post("/api/tour", resource)
|
var resp = TestAPI.post("/api/tour", resource)
|
||||||
val id = resp.asObject().getInt("id")
|
val id = resp.asObject().getInt("id")
|
||||||
val tournament = TestAPI.get("/api/tour/$id").asObject()
|
val tournament = TestAPI.get("/api/tour/$id").asObject()
|
||||||
@@ -203,15 +200,15 @@ class PairingTests: TestBase() {
|
|||||||
for (round in 1..tournament.getInt("rounds")!!) {
|
for (round in 1..tournament.getInt("rounds")!!) {
|
||||||
val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round-1], players)
|
val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round-1], players)
|
||||||
|
|
||||||
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
|
val outputFile = getOutputFile("weights.txt")
|
||||||
// Call Pairgoth pairing solver to generate games
|
// Call Pairgoth pairing solver to generate games
|
||||||
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
|
games = TestAPI.post("/api/tour/$id/pair/$round?legacy=$legacy&weights_output=$outputFile", Json.Array("all")).asArray()
|
||||||
logger.info("sumOfWeightOG = " + dec.format(sumOfWeightsOG))
|
logger.info("sumOfWeightOG = " + dec.format(sumOfWeightsOG))
|
||||||
logger.info("games for round $round: {}", games.toString())
|
logger.info("games for round $round: {}", games.toString())
|
||||||
|
|
||||||
// Compare weights with OpenGotha if legacy mode
|
// Compare weights with OpenGotha if legacy mode
|
||||||
if (legacy) {
|
if (legacy) {
|
||||||
assertTrue(compare_weights(getOutputFile("weights.txt"), getTestFile("opengotha/$name/$name"+"_weights_R$round.txt")), "Not matching opengotha weights for round $round")
|
assertTrue(compare_weights(outputFile, getTestFile("opengotha/$name/$name"+"_weights_R$round.txt")), "Not matching opengotha weights for round $round")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (round in forcePairing) {
|
if (round in forcePairing) {
|
||||||
@@ -223,7 +220,7 @@ class PairingTests: TestBase() {
|
|||||||
val gameOG = pairingsOG[round - 1].getJson(i)!!.asObject()// ["r"] as String?
|
val gameOG = pairingsOG[round - 1].getJson(i)!!.asObject()// ["r"] as String?
|
||||||
val whiteId = gameOG["w"] as Long?
|
val whiteId = gameOG["w"] as Long?
|
||||||
val blackId = gameOG["b"] as Long?
|
val blackId = gameOG["b"] as Long?
|
||||||
TestAPI.put("/api/tour/$id/pair/$round", Json.parse("""{"id":$gameID,"w":$whiteId,"b":$blackId}""")).asObject()
|
TestAPI.put("/api/tour/$id/pair/$round?legacy=$legacy&weights_output=$outputFile&append=true", Json.parse("""{"id":$gameID,"w":$whiteId,"b":$blackId}""")).asObject()
|
||||||
}
|
}
|
||||||
games = TestAPI.get("/api/tour/$id/res/$round").asArray()
|
games = TestAPI.get("/api/tour/$id/res/$round").asArray()
|
||||||
}
|
}
|
||||||
@@ -273,11 +270,10 @@ class PairingTests: TestBase() {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `SwissTest simpleSwiss`() {
|
fun `SwissTest simpleSwiss`() {
|
||||||
BaseSolver.legacy_mode = true
|
|
||||||
// read tournament with pairing
|
// read tournament with pairing
|
||||||
var file = getTestFile("opengotha/pairings/simpleswiss.xml")
|
var tourFile = getTestFile("opengotha/pairings/simpleswiss.xml")
|
||||||
logger.info("read from file $file")
|
logger.info("read from file $tourFile")
|
||||||
val resource = file.readText(StandardCharsets.UTF_8)
|
val resource = tourFile.readText(StandardCharsets.UTF_8)
|
||||||
var resp = TestAPI.post("/api/tour", resource)
|
var resp = TestAPI.post("/api/tour", resource)
|
||||||
val id = resp.asObject().getInt("id")
|
val id = resp.asObject().getInt("id")
|
||||||
val tournament = TestAPI.get("/api/tour/$id").asObject()
|
val tournament = TestAPI.get("/api/tour/$id").asObject()
|
||||||
@@ -315,10 +311,10 @@ class PairingTests: TestBase() {
|
|||||||
var firstGameID: Int
|
var firstGameID: Int
|
||||||
|
|
||||||
for (round in 1..7) {
|
for (round in 1..7) {
|
||||||
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
|
val outputFile = getOutputFile("weights.txt")
|
||||||
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
|
games = TestAPI.post("/api/tour/$id/pair/$round?legacy=true&weights_output=$outputFile", Json.Array("all")).asArray()
|
||||||
logger.info("games for round $round: {}", games.toString().slice(0..50) + "...")
|
logger.info("games for round $round: {}", games.toString().slice(0..50) + "...")
|
||||||
assertTrue(compare_weights(getOutputFile("weights.txt"), getTestFile("opengotha/simpleswiss/simpleswiss_weights_R$round.txt")), "Not matching opengotha weights for round $round")
|
assertTrue(compare_weights(outputFile, getTestFile("opengotha/simpleswiss/simpleswiss_weights_R$round.txt")), "Not matching opengotha weights for round $round")
|
||||||
assertTrue(compare_games(games, Json.parse(pairingsOG[round - 1])!!.asArray()),"pairings for round $round differ")
|
assertTrue(compare_games(games, Json.parse(pairingsOG[round - 1])!!.asArray()),"pairings for round $round differ")
|
||||||
logger.info("Pairings for round $round match OpenGotha")
|
logger.info("Pairings for round $round match OpenGotha")
|
||||||
|
|
||||||
@@ -354,12 +350,12 @@ class PairingTests: TestBase() {
|
|||||||
@Test
|
@Test
|
||||||
fun `SwissTest KPMCSplitbug`() {
|
fun `SwissTest KPMCSplitbug`() {
|
||||||
// Let pairgoth use the legacy asymmetric detRandom()
|
// Let pairgoth use the legacy asymmetric detRandom()
|
||||||
BaseSolver.legacy_mode = true
|
val legacy = true
|
||||||
// read tournament with pairing
|
// read tournament with pairing
|
||||||
val name = "20240921-KPMC-Splitbug"
|
val name = "20240921-KPMC-Splitbug"
|
||||||
val file = getTestFile("opengotha/pairings/$name.xml")
|
val tourFile = getTestFile("opengotha/pairings/$name.xml")
|
||||||
logger.info("read from file $file")
|
logger.info("read from file $tourFile")
|
||||||
val resource = file.readText(StandardCharsets.UTF_8)
|
val resource = tourFile.readText(StandardCharsets.UTF_8)
|
||||||
var resp = TestAPI.post("/api/tour", resource)
|
var resp = TestAPI.post("/api/tour", resource)
|
||||||
val id = resp.asObject().getInt("id")
|
val id = resp.asObject().getInt("id")
|
||||||
val tournament = TestAPI.get("/api/tour/$id").asObject()
|
val tournament = TestAPI.get("/api/tour/$id").asObject()
|
||||||
@@ -387,13 +383,13 @@ class PairingTests: TestBase() {
|
|||||||
|
|
||||||
var games: Json.Array
|
var games: Json.Array
|
||||||
var firstGameID: Int
|
var firstGameID: Int
|
||||||
|
val outputFile = getOutputFile("weights.txt")
|
||||||
|
|
||||||
for (round in minRound..maxRound) {
|
for (round in minRound..maxRound) {
|
||||||
val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round - minRound], players)
|
val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round - minRound], players)
|
||||||
|
|
||||||
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
|
|
||||||
// Call Pairgoth pairing solver to generate games
|
// Call Pairgoth pairing solver to generate games
|
||||||
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
|
games = TestAPI.post("/api/tour/$id/pair/$round?legacy=$legacy&weights_ouput=$outputFile&append=${round > 1}", Json.Array("all")).asArray()
|
||||||
|
|
||||||
logger.info("sumOfWeightOG = " + dec.format(sumOfWeightsOG))
|
logger.info("sumOfWeightOG = " + dec.format(sumOfWeightsOG))
|
||||||
logger.info("games for round $round: {}", games.toString().slice(0..50) + "...")
|
logger.info("games for round $round: {}", games.toString().slice(0..50) + "...")
|
||||||
@@ -401,7 +397,7 @@ class PairingTests: TestBase() {
|
|||||||
// Compare weights with OpenGotha
|
// Compare weights with OpenGotha
|
||||||
assertTrue(
|
assertTrue(
|
||||||
compare_weights(
|
compare_weights(
|
||||||
getOutputFile("weights.txt"),
|
outputFile,
|
||||||
getTestFile("opengotha/$name/$name" + "_weights_R$round.txt")
|
getTestFile("opengotha/$name/$name" + "_weights_R$round.txt")
|
||||||
), "Not matching opengotha weights for round $round"
|
), "Not matching opengotha weights for round $round"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import org.jeudego.pairgoth.server.SSEServlet
|
|||||||
import org.jeudego.pairgoth.server.WebappManager
|
import org.jeudego.pairgoth.server.WebappManager
|
||||||
import org.mockito.kotlin.*
|
import org.mockito.kotlin.*
|
||||||
import java.io.*
|
import java.io.*
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLDecoder
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import javax.servlet.ReadListener
|
import javax.servlet.ReadListener
|
||||||
@@ -21,20 +23,45 @@ object TestAPI {
|
|||||||
|
|
||||||
fun Any?.toUnit() = Unit
|
fun Any?.toUnit() = Unit
|
||||||
|
|
||||||
|
fun parseURL(url: String): Pair<String, Map<String, String>> {
|
||||||
|
val qm = url.indexOf('?')
|
||||||
|
if (qm == -1) {
|
||||||
|
return url to emptyMap()
|
||||||
|
}
|
||||||
|
val uri = url.substring(0, qm)
|
||||||
|
val params = url.substring(qm + 1)
|
||||||
|
.split('&')
|
||||||
|
.map { it.split('=') }
|
||||||
|
.mapNotNull {
|
||||||
|
when (it.size) {
|
||||||
|
1 -> it[0].decodeUTF8() to ""
|
||||||
|
2 -> it[0].decodeUTF8() to it[1].decodeUTF8()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.toMap()
|
||||||
|
return uri to params
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.decodeUTF8() = URLDecoder.decode(this, "UTF-8") // decode page=%22ABC%22 to page="ABC"
|
||||||
|
|
||||||
private val apiServlet = ApiServlet()
|
private val apiServlet = ApiServlet()
|
||||||
private val sseServlet = SSEServlet()
|
private val sseServlet = SSEServlet()
|
||||||
|
|
||||||
private fun <T> testRequest(reqMethod: String, uri: String, accept: String = "application/json", payload: T? = null): String {
|
private fun <T> testRequest(reqMethod: String, url: String, accept: String = "application/json", payload: T? = null): String {
|
||||||
|
|
||||||
WebappManager.properties["auth"] = "none"
|
WebappManager.properties["auth"] = "none"
|
||||||
WebappManager.properties["store"] = "memory"
|
WebappManager.properties["store"] = "memory"
|
||||||
WebappManager.properties["webapp.env"] = "test"
|
WebappManager.properties["webapp.env"] = "test"
|
||||||
|
|
||||||
|
val (uri, parameters) = parseURL(url)
|
||||||
|
|
||||||
// mock request
|
// mock request
|
||||||
val myHeaderNames = if (reqMethod == "GET") emptyList() else listOf("Content-Type")
|
val myHeaderNames = if (reqMethod == "GET") emptyList() else listOf("Content-Type")
|
||||||
val selector = argumentCaptor<String>()
|
val selector = argumentCaptor<String>()
|
||||||
val subSelector = argumentCaptor<String>()
|
val subSelector = argumentCaptor<String>()
|
||||||
val reqPayload = argumentCaptor<String>()
|
val reqPayload = argumentCaptor<String>()
|
||||||
|
val parameter = argumentCaptor<String>()
|
||||||
val myInputStream = payload?.let { DelegatingServletInputStream(payload.toString().byteInputStream(StandardCharsets.UTF_8)) }
|
val myInputStream = payload?.let { DelegatingServletInputStream(payload.toString().byteInputStream(StandardCharsets.UTF_8)) }
|
||||||
val myReader = payload?.let { BufferedReader(StringReader(payload.toString())) }
|
val myReader = payload?.let { BufferedReader(StringReader(payload.toString())) }
|
||||||
val req = mock<HttpServletRequest> {
|
val req = mock<HttpServletRequest> {
|
||||||
@@ -59,6 +86,7 @@ object TestAPI {
|
|||||||
}
|
}
|
||||||
on { headerNames } doReturn Collections.enumeration(myHeaderNames)
|
on { headerNames } doReturn Collections.enumeration(myHeaderNames)
|
||||||
on { getHeader(eq("Accept")) } doReturn accept
|
on { getHeader(eq("Accept")) } doReturn accept
|
||||||
|
on { getParameter(parameter.capture()) } doAnswer { parameters[parameter.lastValue] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// mock response
|
// mock response
|
||||||
@@ -77,7 +105,7 @@ object TestAPI {
|
|||||||
"DELETE" -> apiServlet.doDelete(req, resp)
|
"DELETE" -> apiServlet.doDelete(req, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buffer.toString() ?: throw Error("no response payload")
|
return buffer.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun get(uri: String): Json = Json.parse(testRequest<Void>("GET", uri)) ?: throw Error("no payload")
|
fun get(uri: String): Json = Json.parse(testRequest<Void>("GET", uri)) ?: throw Error("no payload")
|
||||||
|
|||||||
132
api-webapp/src/test/resources/macmahon39/sample-tournament.xml
Normal file
132
api-webapp/src/test/resources/macmahon39/sample-tournament.xml
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- Sample MacMahon 3.9 format tournament file for testing -->
|
||||||
|
<TournamentData typeversion="3.9">
|
||||||
|
<Tournament typeversion="3.9">
|
||||||
|
<Name>Test MacMahon Tournament</Name>
|
||||||
|
<NumberOfRounds>3</NumberOfRounds>
|
||||||
|
<UpperMacMahonBar>true</UpperMacMahonBar>
|
||||||
|
<UpperMacMahonBarLevel>1d</UpperMacMahonBarLevel>
|
||||||
|
<LowerMacMahonBar>true</LowerMacMahonBar>
|
||||||
|
<LowerMacMahonBarLevel>20k</LowerMacMahonBarLevel>
|
||||||
|
<RatingDeterminesRank>false</RatingDeterminesRank>
|
||||||
|
<HandicapUsed>false</HandicapUsed>
|
||||||
|
<HandicapBelow>true</HandicapBelow>
|
||||||
|
<HandicapBelowLevel>30k</HandicapBelowLevel>
|
||||||
|
<HandicapAdjustment>true</HandicapAdjustment>
|
||||||
|
<HandicapAdjustmentValue>0</HandicapAdjustmentValue>
|
||||||
|
<HandicapLimit>true</HandicapLimit>
|
||||||
|
<HandicapLimitValue>9</HandicapLimitValue>
|
||||||
|
<HandicapByLevel>false</HandicapByLevel>
|
||||||
|
</Tournament>
|
||||||
|
|
||||||
|
<Walllist>
|
||||||
|
<Criterion><ShortName>Score</ShortName></Criterion>
|
||||||
|
<Criterion><ShortName>SOS</ShortName></Criterion>
|
||||||
|
<Criterion><ShortName>SOSOS</ShortName></Criterion>
|
||||||
|
</Walllist>
|
||||||
|
|
||||||
|
<Playerlist>
|
||||||
|
<Player>
|
||||||
|
<Id>1</Id>
|
||||||
|
<PreliminaryRegistration>false</PreliminaryRegistration>
|
||||||
|
<SuperBarMember>false</SuperBarMember>
|
||||||
|
<GoPlayer>
|
||||||
|
<FirstName>Alice</FirstName>
|
||||||
|
<Surname>Smith</Surname>
|
||||||
|
<Club>Paris</Club>
|
||||||
|
<Country>FR</Country>
|
||||||
|
<GoLevel>3d</GoLevel>
|
||||||
|
<Rating>2200</Rating>
|
||||||
|
<EgdPin>12345678</EgdPin>
|
||||||
|
</GoPlayer>
|
||||||
|
</Player>
|
||||||
|
<Player>
|
||||||
|
<Id>2</Id>
|
||||||
|
<PreliminaryRegistration>false</PreliminaryRegistration>
|
||||||
|
<SuperBarMember>false</SuperBarMember>
|
||||||
|
<GoPlayer>
|
||||||
|
<FirstName>Bob</FirstName>
|
||||||
|
<Surname>Jones</Surname>
|
||||||
|
<Club>Lyon</Club>
|
||||||
|
<Country>FR</Country>
|
||||||
|
<GoLevel>2d</GoLevel>
|
||||||
|
<Rating>2100</Rating>
|
||||||
|
<EgdPin>23456789</EgdPin>
|
||||||
|
</GoPlayer>
|
||||||
|
</Player>
|
||||||
|
<Player>
|
||||||
|
<Id>3</Id>
|
||||||
|
<PreliminaryRegistration>false</PreliminaryRegistration>
|
||||||
|
<SuperBarMember>true</SuperBarMember>
|
||||||
|
<GoPlayer>
|
||||||
|
<FirstName>Carol</FirstName>
|
||||||
|
<Surname>White</Surname>
|
||||||
|
<Club>Berlin</Club>
|
||||||
|
<Country>DE</Country>
|
||||||
|
<GoLevel>1d</GoLevel>
|
||||||
|
<Rating>2000</Rating>
|
||||||
|
<EgdPin>34567890</EgdPin>
|
||||||
|
</GoPlayer>
|
||||||
|
</Player>
|
||||||
|
<Player>
|
||||||
|
<Id>4</Id>
|
||||||
|
<PreliminaryRegistration>true</PreliminaryRegistration>
|
||||||
|
<SuperBarMember>false</SuperBarMember>
|
||||||
|
<NotPlayingInRound>2</NotPlayingInRound>
|
||||||
|
<GoPlayer>
|
||||||
|
<FirstName>David</FirstName>
|
||||||
|
<Surname>Brown</Surname>
|
||||||
|
<Club>London</Club>
|
||||||
|
<Country>UK</Country>
|
||||||
|
<GoLevel>1k</GoLevel>
|
||||||
|
<Rating>1900</Rating>
|
||||||
|
<EgdPin>45678901</EgdPin>
|
||||||
|
</GoPlayer>
|
||||||
|
</Player>
|
||||||
|
</Playerlist>
|
||||||
|
|
||||||
|
<Roundlist>
|
||||||
|
<Round>
|
||||||
|
<RoundNumber>1</RoundNumber>
|
||||||
|
<Pairing>
|
||||||
|
<BoardNumber>1</BoardNumber>
|
||||||
|
<PairingWithBye>false</PairingWithBye>
|
||||||
|
<White>1</White>
|
||||||
|
<Black>2</Black>
|
||||||
|
<Handicap>0</Handicap>
|
||||||
|
<Result>1-0</Result>
|
||||||
|
<ResultByReferee>false</ResultByReferee>
|
||||||
|
</Pairing>
|
||||||
|
<Pairing>
|
||||||
|
<BoardNumber>2</BoardNumber>
|
||||||
|
<PairingWithBye>false</PairingWithBye>
|
||||||
|
<White>3</White>
|
||||||
|
<Black>4</Black>
|
||||||
|
<Handicap>0</Handicap>
|
||||||
|
<Result>0-1</Result>
|
||||||
|
<ResultByReferee>false</ResultByReferee>
|
||||||
|
</Pairing>
|
||||||
|
</Round>
|
||||||
|
<Round>
|
||||||
|
<RoundNumber>2</RoundNumber>
|
||||||
|
<Pairing>
|
||||||
|
<BoardNumber>1</BoardNumber>
|
||||||
|
<PairingWithBye>false</PairingWithBye>
|
||||||
|
<White>2</White>
|
||||||
|
<Black>1</Black>
|
||||||
|
<Handicap>0</Handicap>
|
||||||
|
<Result>0-1</Result>
|
||||||
|
<ResultByReferee>false</ResultByReferee>
|
||||||
|
</Pairing>
|
||||||
|
<Pairing>
|
||||||
|
<BoardNumber>0</BoardNumber>
|
||||||
|
<PairingWithBye>true</PairingWithBye>
|
||||||
|
<White>0</White>
|
||||||
|
<Black>3</Black>
|
||||||
|
<Handicap>0</Handicap>
|
||||||
|
<Result>0-1</Result>
|
||||||
|
<ResultByReferee>false</ResultByReferee>
|
||||||
|
</Pairing>
|
||||||
|
</Round>
|
||||||
|
</Roundlist>
|
||||||
|
</TournamentData>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.jeudego.pairgoth</groupId>
|
<groupId>org.jeudego.pairgoth</groupId>
|
||||||
<artifactId>engine-parent</artifactId>
|
<artifactId>engine-parent</artifactId>
|
||||||
<version>0.20</version>
|
<version>0.23</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>application</artifactId>
|
<artifactId>application</artifactId>
|
||||||
<packaging>pom</packaging>
|
<packaging>pom</packaging>
|
||||||
|
|||||||
23
build.sh
Executable file
23
build.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
mkdir pairgoth
|
||||||
|
cp application/target/pairgoth-engine.jar pairgoth/
|
||||||
|
|
||||||
|
echo "#!/bin/bash" > pairgoth/run.sh
|
||||||
|
echo "./jdk-11.0.28+6-jre/bin/java -jar pairgoth-engine.jar" >> pairgoth/run.sh
|
||||||
|
|
||||||
|
echo "#!/bin/bash" > pairgoth/get_java.sh
|
||||||
|
echo "wget https://github.com/adoptium/temurin11-binaries/releases/download/jdk-11.0.28%2B6/OpenJDK11U-jre_x64_linux_hotspot_11.0.28_6.tar.gz" >> pairgoth/get_java.sh
|
||||||
|
echo "tar -xzf OpenJDK11U-jre_x64_linux_hotspot_11.0.28_6.tar.gz" >> pairgoth/get_java.sh
|
||||||
|
|
||||||
|
chmod +x pairgoth/run.sh
|
||||||
|
chmod +x pairgoth/get_java.sh
|
||||||
|
|
||||||
|
|
||||||
|
echo "" > pairgoth/pairgoth.properties
|
||||||
|
echo "webapp.env = prod" >> pairgoth/pairgoth.properties
|
||||||
|
echo "webapp.url = http://localhost:8080" >> pairgoth/pairgoth.properties
|
||||||
|
echo "auth = none" >> pairgoth/pairgoth.properties
|
||||||
|
echo "logger.level = info" >> pairgoth/pairgoth.properties
|
||||||
|
echo "rating.ffg.enable = false" >> pairgoth/pairgoth.properties
|
||||||
|
echo "webapp.protocol = http" >> pairgoth/pairgoth.properties
|
||||||
|
|
||||||
|
tar -czvf pairgoth.tar.gz pairgoth/
|
||||||
215
doc/API.md
215
doc/API.md
@@ -2,38 +2,73 @@
|
|||||||
|
|
||||||
## General remarks
|
## General remarks
|
||||||
|
|
||||||
The API expects an `Accept` header of `application/json`, with no encoding or an `UTF-8` encoding. Exceptions are some export operations which can have different MIME types to specify the expected format.
|
The API expects an `Accept` header of `application/json`, with no encoding or an `UTF-8` encoding. Exceptions are some export operations which can have different MIME types to specify the expected format:
|
||||||
|
- `application/json` - JSON output (default)
|
||||||
|
- `application/xml` - OpenGotha XML export
|
||||||
|
- `application/egf` - EGF format
|
||||||
|
- `application/ffg` - FFG format
|
||||||
|
- `text/csv` - CSV format
|
||||||
|
|
||||||
GET requests return either an array or an object, as specified below.
|
GET requests return either an array or an object, as specified below.
|
||||||
|
|
||||||
POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success": true }` (with an optional `"id"` field for some POST requests), or and invalid HTTP code and (for some errors) the body `{ "success": false, "error": <error message> }`.
|
POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success": true }` (with an optional `"id"` field for some POST requests), or an invalid HTTP code and (for some errors) the body `{ "success": false, "error": <error message> }`.
|
||||||
|
|
||||||
|
All POST/PUT/DELETE requests use read/write locks for concurrency. GET requests use read locks.
|
||||||
|
|
||||||
|
When authentication is enabled, all requests require an `Authorization` header.
|
||||||
|
|
||||||
## Synopsis
|
## Synopsis
|
||||||
|
|
||||||
+ /api/tour GET POST Tournaments handling
|
+ /api/tour GET POST Tournaments handling
|
||||||
+ /api/tour/#tid GET PUT DELETE Tournaments handling
|
+ /api/tour/#tid GET PUT DELETE Tournaments handling
|
||||||
+ /api/tour/#tid/part GET POST Registration handling
|
+ /api/tour/#tid/part GET POST Registration handling
|
||||||
+ /api/tour/#tid/part/#pid GET PUT DELETE Registration handling
|
+ /api/tour/#tid/part/#pid GET PUT DELETE Registration handling
|
||||||
+ /api/tour/#tid/team GET POST Team handling
|
+ /api/tour/#tid/team GET POST Team handling
|
||||||
+ /api/tour/#tid/team/#tid GET PUT DELETE Team handling
|
+ /api/tour/#tid/team/#tid GET PUT DELETE Team handling
|
||||||
+ /api/tour/#tid/pair/#rn GET POST PUT DELETE Pairing
|
+ /api/tour/#tid/pair/#rn GET POST PUT DELETE Pairing
|
||||||
+ /api/tour/#tid/res/#rn GET PUT DELETE Results
|
+ /api/tour/#tid/res/#rn GET PUT DELETE Results
|
||||||
+ /api/tour/#tid/standings GET Standings
|
+ /api/tour/#tid/standings GET PUT Standings
|
||||||
+ /api/tour/#tid/stand/#rn GET Standings
|
+ /api/tour/#tid/stand/#rn GET Standings
|
||||||
|
+ /api/tour/#tid/explain/#rn GET Pairing explanation
|
||||||
|
+ /api/token GET POST DELETE Authentication
|
||||||
|
|
||||||
## Tournament handling
|
## Tournament handling
|
||||||
|
|
||||||
+ `GET /api/tour` Get a list of known tournaments ids
|
+ `GET /api/tour` Get a list of known tournaments ids
|
||||||
|
|
||||||
*output* json map (id towards shortName) of known tournaments (subject to change)
|
*output* json map (id towards shortName) of known tournaments
|
||||||
|
|
||||||
+ `GET /api/tour/#tid` Get the details of tournament #tid
|
+ `GET /api/tour/#tid` Get the details of tournament #tid
|
||||||
|
|
||||||
*output* json object for tournament #tid
|
*output* json object for tournament #tid
|
||||||
|
|
||||||
|
Supports `Accept: application/xml` to get OpenGotha XML export.
|
||||||
|
|
||||||
+ `POST /api/tour` Create a new tournament
|
+ `POST /api/tour` Create a new tournament
|
||||||
|
|
||||||
*input* json object for new tournament (see `Tournament.fromJson` in the sources)
|
*input* json object for new tournament, or OpenGotha XML with `Content-Type: application/xml`
|
||||||
|
|
||||||
|
Tournament JSON structure:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "INDIVIDUAL",
|
||||||
|
"name": "Tournament Name",
|
||||||
|
"shortName": "TN",
|
||||||
|
"startDate": "2024-01-15",
|
||||||
|
"endDate": "2024-01-16",
|
||||||
|
"country": "fr",
|
||||||
|
"location": "Paris",
|
||||||
|
"online": false,
|
||||||
|
"rounds": 5,
|
||||||
|
"gobanSize": 19,
|
||||||
|
"rules": "FRENCH",
|
||||||
|
"komi": 7.5,
|
||||||
|
"timeSystem": { ... },
|
||||||
|
"pairing": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tournament types: `INDIVIDUAL`, `PAIRGO`, `RENGO2`, `RENGO3`, `TEAM2`, `TEAM3`, `TEAM4`, `TEAM5`
|
||||||
|
|
||||||
*output* `{ "success": true, "id": #tid }`
|
*output* `{ "success": true, "id": #tid }`
|
||||||
|
|
||||||
@@ -43,19 +78,40 @@ POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success":
|
|||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
|
+ `DELETE /api/tour/#tid` Delete a tournament
|
||||||
|
|
||||||
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
## Players handling
|
## Players handling
|
||||||
|
|
||||||
+ `GET /api/tour/#tid/part` Get a list of registered players
|
+ `GET /api/tour/#tid/part` Get a list of registered players
|
||||||
|
|
||||||
*output* json array of known players
|
*output* json array of known players
|
||||||
|
|
||||||
+ `GET /api/tour/#tid/part/#pid` Get regitration details for player #pid
|
+ `GET /api/tour/#tid/part/#pid` Get registration details for player #pid
|
||||||
|
|
||||||
*output* json object for player #pid
|
*output* json object for player #pid
|
||||||
|
|
||||||
+ `POST /api/tour/#tid/part` Register a new player
|
+ `POST /api/tour/#tid/part` Register a new player
|
||||||
|
|
||||||
*input* `{ "name":"..." , "firstname":"..." , "rating":<rating> , "rank":<rank> , "country":"XX" [ , "club":"Xxxx" ] [ , "final":true/false ] [ , "mmsCorrection":0 ] }`
|
*input*
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Lastname",
|
||||||
|
"firstname": "Firstname",
|
||||||
|
"rating": 1500,
|
||||||
|
"rank": -5,
|
||||||
|
"country": "FR",
|
||||||
|
"club": "Club Name",
|
||||||
|
"final": true,
|
||||||
|
"mmsCorrection": 0,
|
||||||
|
"egfId": "12345678",
|
||||||
|
"ffgId": "12345",
|
||||||
|
"agaId": "12345"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rank values: -30 (30k) to 8 (9D). Rating in EGF-style (100 = 1 stone).
|
||||||
|
|
||||||
*output* `{ "success": true, "id": #pid }`
|
*output* `{ "success": true, "id": #pid }`
|
||||||
|
|
||||||
@@ -67,35 +123,41 @@ POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success":
|
|||||||
|
|
||||||
+ `DELETE /api/tour/#tid/part/#pid` Delete a player registration
|
+ `DELETE /api/tour/#tid/part/#pid` Delete a player registration
|
||||||
|
|
||||||
*input* `{ "id": #pid }`
|
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
## Teams handling
|
## Teams handling
|
||||||
|
|
||||||
|
For team tournaments (PAIRGO, RENGO2, RENGO3, TEAM2-5).
|
||||||
|
|
||||||
+ `GET /api/tour/#tid/team` Get a list of registered teams
|
+ `GET /api/tour/#tid/team` Get a list of registered teams
|
||||||
|
|
||||||
*output* json array of known teams
|
*output* json array of known teams
|
||||||
|
|
||||||
+ `GET /api/tour/#tid/team/#tid` Get regitration details for team #tid
|
+ `GET /api/tour/#tid/team/#teamid` Get registration details for team #teamid
|
||||||
|
|
||||||
*output* json object for team #tid
|
*output* json object for team #teamid
|
||||||
|
|
||||||
+ `POST /api/tour/#tid/team` Register a new team
|
+ `POST /api/tour/#tid/team` Register a new team
|
||||||
|
|
||||||
*input* json object for new team
|
*input*
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Team Name",
|
||||||
|
"playerIds": [1, 2, 3],
|
||||||
|
"final": true,
|
||||||
|
"mmsCorrection": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
*output* `{ "success": true, "id": #tid }`
|
*output* `{ "success": true, "id": #teamid }`
|
||||||
|
|
||||||
+ `PUT /api/tour/#tid/team/#tid` Modify a team registration
|
+ `PUT /api/tour/#tid/team/#teamid` Modify a team registration
|
||||||
|
|
||||||
*input* json object for updated registration (only id and updated fields required)
|
*input* json object for updated registration (only id and updated fields required)
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
+ `DELETE /api/tour/#tid/team/#tid` Delete a team registration
|
+ `DELETE /api/tour/#tid/team/#teamid` Delete a team registration
|
||||||
|
|
||||||
*input* `{ "id": #tid }`
|
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
@@ -104,56 +166,121 @@ POST, PUT and DELETE requests return either the 200 HTTP code with `{ "success":
|
|||||||
|
|
||||||
+ `GET /api/tour/#tid/pair/#rn` Get pairable players for round #rn
|
+ `GET /api/tour/#tid/pair/#rn` Get pairable players for round #rn
|
||||||
|
|
||||||
*output* `{ "games": [ games... ], "pairables:" [ #pid, ... of players not skipping and not playing the round ], "unpairables": [ #pid, ... of players skipping the round ] }`
|
*output*
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"games": [ { "id": 1, "t": 1, "w": 2, "b": 3, "h": 0 }, ... ],
|
||||||
|
"pairables": [ 4, 5, ... ],
|
||||||
|
"unpairables": [ 6, 7, ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
+ `POST /api/tour/#tip/pair/#n` Generate pairing for round #n and given players (or string "all") ; error if already generated for provided players
|
- `games`: existing pairings for the round
|
||||||
|
- `pairables`: player IDs available for pairing (not skipping, not already paired)
|
||||||
|
- `unpairables`: player IDs skipping the round
|
||||||
|
|
||||||
|
+ `POST /api/tour/#tid/pair/#rn` Generate pairing for round #rn
|
||||||
|
|
||||||
*input* `[ "all" ]` or `[ #pid, ... ]`
|
*input* `[ "all" ]` or `[ #pid, ... ]`
|
||||||
|
|
||||||
|
Optional query parameters:
|
||||||
|
- `legacy=true` - Use legacy pairing algorithm
|
||||||
|
- `weights_output=<file>` - Output weights to file for debugging
|
||||||
|
- `append=true` - Append to weights output file
|
||||||
|
|
||||||
*output* `[ { "id": #gid, "t": table, "w": #wpid, "b": #bpid, "h": handicap }, ... ]`
|
*output* `[ { "id": #gid, "t": table, "w": #wpid, "b": #bpid, "h": handicap }, ... ]`
|
||||||
|
|
||||||
+ `PUT /api/tour/#tip/pair/#n` Manual pairing (with optional handicap)
|
+ `PUT /api/tour/#tid/pair/#rn` Manual pairing or table renumbering
|
||||||
|
|
||||||
|
For manual pairing:
|
||||||
*input* `{ "id": #gid, "w": #wpid, "b": #bpid, "h": <handicap> }`
|
*input* `{ "id": #gid, "w": #wpid, "b": #bpid, "h": <handicap> }`
|
||||||
|
|
||||||
|
For table renumbering:
|
||||||
|
*input* `{ "renumber": <game_id or null>, "orderBy": "mms" | "table" }`
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
+ `DELETE /api/tour/#tip/pair/#n` Delete pairing for round #n and given players (or string "all") ; games with results entered are skipped
|
+ `DELETE /api/tour/#tid/pair/#rn` Delete pairing for round #rn
|
||||||
|
|
||||||
*input* `[ "all" ]` or `[ #gid, ... ]`
|
*input* `[ "all" ]` or `[ #gid, ... ]`
|
||||||
|
|
||||||
|
Games with results already entered are skipped unless `"all"` is specified.
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
## Results
|
## Results
|
||||||
|
|
||||||
+ `GET /api/tour/#tip/res/#rn` Get results for round #rn
|
+ `GET /api/tour/#tid/res/#rn` Get results for round #rn
|
||||||
|
|
||||||
*output* `[ { "id": #gid, "res": <result> } ]` with `res` being one of: `"w"`, `"b"`, `"="` (jigo), `"x"` (cancelled),`"?"` (unknown), `"#"` (both win), or `"0"` (both loose).
|
*output* `[ { "id": #gid, "res": <result> }, ... ]`
|
||||||
|
|
||||||
+ `PUT /api/tour/#tip/res/#rn` Save a result (or put it back to unknown)
|
Result codes:
|
||||||
|
- `"w"` - White won
|
||||||
|
- `"b"` - Black won
|
||||||
|
- `"="` - Jigo (draw)
|
||||||
|
- `"X"` - Cancelled
|
||||||
|
- `"?"` - Unknown (not yet played)
|
||||||
|
- `"#"` - Both win (unusual)
|
||||||
|
- `"0"` - Both lose (unusual)
|
||||||
|
|
||||||
*input* `{ "id": #gid, "res": <result> }` with `res` being one of: `"w"`, `"b"`, `"="` (jigo), `"x"` (cancelled)
|
+ `PUT /api/tour/#tid/res/#rn` Save a result
|
||||||
|
|
||||||
|
*input* `{ "id": #gid, "res": <result> }`
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
+ `DELETE /api/tour/#tip/res/#rn` Clear all results (put back all results to unknown)
|
+ `DELETE /api/tour/#tid/res/#rn` Clear all results for round
|
||||||
|
|
||||||
*input* none
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
*output* `{ "success": true }`
|
|
||||||
|
|
||||||
## Standings
|
## Standings
|
||||||
|
|
||||||
+ `GET /api/tour/#tid/stand/#rn` Get standings after round #rn (or initial standings for round '0')
|
+ `GET /api/tour/#tid/standings` Get standings after final round
|
||||||
|
|
||||||
*output* `[ { "id": #pid, "place": place, "<crit>": double }, ... ]`
|
*output* `[ { "id": #pid, "place": place, "<crit>": value }, ... ]`
|
||||||
where `<crit>` is the name of a criterium, among "score", "nbw", "mms", "sosm", "sososm", ...
|
|
||||||
|
Supports multiple output formats via Accept header:
|
||||||
|
- `application/json` - JSON (default)
|
||||||
|
- `application/egf` - EGF format
|
||||||
|
- `application/ffg` - FFG format
|
||||||
|
- `text/csv` - CSV format
|
||||||
|
|
||||||
|
Optional query parameters:
|
||||||
|
- `include_preliminary=true` - Include preliminary standings
|
||||||
|
- `individual_standings=true` - For team tournaments with individual scoring
|
||||||
|
|
||||||
|
+ `GET /api/tour/#tid/stand/#rn` Get standings after round #rn
|
||||||
|
|
||||||
|
Use round `0` for initial standings.
|
||||||
|
|
||||||
|
*output* `[ { "id": #pid, "place": place, "<crit>": value }, ... ]`
|
||||||
|
|
||||||
|
Criteria names include: `nbw`, `mms`, `sts`, `cps`, `sosw`, `sosm`, `sososw`, `sososm`, `sodosw`, `sodosm`, `cussw`, `cussm`, `dc`, `sdc`, `ext`, `exr`, etc.
|
||||||
|
|
||||||
|
+ `PUT /api/tour/#tid/standings` Freeze/lock standings
|
||||||
|
|
||||||
|
*output* `{ "success": true }`
|
||||||
|
|
||||||
|
## Pairing explanation
|
||||||
|
|
||||||
|
+ `GET /api/tour/#tid/explain/#rn` Get detailed pairing criteria weights for round #rn
|
||||||
|
|
||||||
|
*output* Detailed pairing weight analysis and criteria breakdown
|
||||||
|
|
||||||
|
Used for debugging and understanding pairing decisions.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
+ `GET /api/token` Get the token of the currently logged user, or give an error.
|
+ `GET /api/token` Check authentication status
|
||||||
|
|
||||||
+ `POST /api/token` Create an access token. Expects an authentication json object.
|
*output* Token information for the currently logged user, or error if not authenticated.
|
||||||
|
|
||||||
+ `DELETE /api/token` Delete the token of the currently logged user.
|
+ `POST /api/token` Create an access token
|
||||||
|
|
||||||
|
*input* Authentication credentials (format depends on auth mode)
|
||||||
|
|
||||||
|
*output* `{ "success": true, "token": "..." }`
|
||||||
|
|
||||||
|
+ `DELETE /api/token` Logout / revoke token
|
||||||
|
|
||||||
|
*output* `{ "success": true }`
|
||||||
|
|||||||
@@ -2,39 +2,96 @@
|
|||||||
|
|
||||||
Pairgoth general configuration is done using the `pairgoth.properties` file in the installation folder.
|
Pairgoth general configuration is done using the `pairgoth.properties` file in the installation folder.
|
||||||
|
|
||||||
## environment
|
Properties are loaded in this order (later overrides earlier):
|
||||||
|
|
||||||
Controls the running environment: `dev` for development, `prod` for distributed instances.
|
1. Default properties embedded in WAR/JAR
|
||||||
|
2. User properties file (`./pairgoth.properties`) in current working directory
|
||||||
|
3. System properties prefixed with `pairgoth.` (command-line: `-Dpairgoth.key=value`)
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Controls the running environment.
|
||||||
|
|
||||||
```
|
```
|
||||||
env = prod
|
env = prod
|
||||||
```
|
```
|
||||||
|
|
||||||
## mode
|
Values:
|
||||||
|
- `dev` - Development mode: enables CORS headers and additional logging
|
||||||
|
- `prod` - Production: for distributed instances
|
||||||
|
|
||||||
Running mode: `standalone`, `client` or `server`.
|
## Mode
|
||||||
|
|
||||||
|
Running mode for the application.
|
||||||
|
|
||||||
```
|
```
|
||||||
mode = standalone
|
mode = standalone
|
||||||
```
|
```
|
||||||
|
|
||||||
## authentication
|
Values:
|
||||||
|
- `standalone` - Both web and API in a single process (default for jar execution)
|
||||||
|
- `server` - API only
|
||||||
|
- `client` - Web UI only (connects to remote API)
|
||||||
|
|
||||||
Authentication: `none`, `sesame` for a shared unique password, `oauth` for email and/or oauth accounts.
|
## Authentication
|
||||||
|
|
||||||
|
Authentication method for the application.
|
||||||
|
|
||||||
```
|
```
|
||||||
auth = none
|
auth = none
|
||||||
```
|
```
|
||||||
|
|
||||||
When running in client or server mode, if `auth` is not `none`, the following extra property is needed:
|
Values:
|
||||||
|
- `none` - No authentication required
|
||||||
|
- `sesame` - Shared unique password
|
||||||
|
- `oauth` - Email and/or OAuth accounts
|
||||||
|
|
||||||
|
### Shared secret
|
||||||
|
|
||||||
|
When running in client or server mode with authentication enabled:
|
||||||
|
|
||||||
```
|
```
|
||||||
auth.shared_secret = <16 ascii characters string>
|
auth.shared_secret = <16 ascii characters string>
|
||||||
```
|
```
|
||||||
|
|
||||||
## webapp connector
|
This secret is shared between API and View webapps. Auto-generated in standalone mode.
|
||||||
|
|
||||||
Pairgoth webapp connector configuration.
|
### Sesame password
|
||||||
|
|
||||||
|
When using sesame authentication:
|
||||||
|
|
||||||
|
```
|
||||||
|
auth.sesame = <password>
|
||||||
|
```
|
||||||
|
|
||||||
|
## OAuth configuration
|
||||||
|
|
||||||
|
When using OAuth authentication:
|
||||||
|
|
||||||
|
```
|
||||||
|
oauth.providers = ffg,google,facebook
|
||||||
|
```
|
||||||
|
|
||||||
|
Comma-separated list of enabled providers: `ffg`, `facebook`, `google`, `instagram`, `twitter`
|
||||||
|
|
||||||
|
For each enabled provider, configure credentials:
|
||||||
|
|
||||||
|
```
|
||||||
|
oauth.<provider>.client_id = <client_id>
|
||||||
|
oauth.<provider>.secret = <client_secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
oauth.ffg.client_id = your-ffg-client-id
|
||||||
|
oauth.ffg.secret = your-ffg-client-secret
|
||||||
|
oauth.google.client_id = your-google-client-id
|
||||||
|
oauth.google.secret = your-google-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webapp connector
|
||||||
|
|
||||||
|
Pairgoth webapp (UI) connector configuration.
|
||||||
|
|
||||||
```
|
```
|
||||||
webapp.protocol = http
|
webapp.protocol = http
|
||||||
@@ -44,7 +101,10 @@ webapp.context = /
|
|||||||
webapp.external.url = http://localhost:8080
|
webapp.external.url = http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
## api connector
|
- `webapp.host` (or `webapp.interface`) - Hostname/interface to bind to
|
||||||
|
- `webapp.external.url` - External URL for OAuth redirects and client configuration
|
||||||
|
|
||||||
|
## API connector
|
||||||
|
|
||||||
Pairgoth API connector configuration.
|
Pairgoth API connector configuration.
|
||||||
|
|
||||||
@@ -56,28 +116,91 @@ api.context = /api
|
|||||||
api.external.url = http://localhost:8085/api
|
api.external.url = http://localhost:8085/api
|
||||||
```
|
```
|
||||||
|
|
||||||
## store
|
Note: In standalone mode, API port defaults to 8080 and context to `/api/tour`.
|
||||||
|
|
||||||
Persistent storage for tournaments, `memory` (mainly used for tests) or `file`.
|
## SSL/TLS configuration
|
||||||
|
|
||||||
|
For HTTPS connections:
|
||||||
|
|
||||||
|
```
|
||||||
|
webapp.ssl.key = path/to/localhost.key
|
||||||
|
webapp.ssl.cert = path/to/localhost.crt
|
||||||
|
webapp.ssl.pass = <key passphrase>
|
||||||
|
```
|
||||||
|
|
||||||
|
Supports `jar:` URLs for embedded resources.
|
||||||
|
|
||||||
|
## Store
|
||||||
|
|
||||||
|
Persistent storage for tournaments.
|
||||||
|
|
||||||
```
|
```
|
||||||
store = file
|
store = file
|
||||||
store.file.path = tournamentfiles
|
store.file.path = tournamentfiles
|
||||||
```
|
```
|
||||||
|
|
||||||
## smtp
|
Values for `store`:
|
||||||
|
- `file` - Persistent XML files (default)
|
||||||
|
- `memory` - RAM-based (mainly for tests)
|
||||||
|
|
||||||
SMTP configuration. Not yet functional.
|
The `store.file.path` is relative to the current working directory.
|
||||||
|
|
||||||
|
## Ratings
|
||||||
|
|
||||||
|
### Ratings directory
|
||||||
|
|
||||||
```
|
```
|
||||||
smtp.sender =
|
ratings.path = ratings
|
||||||
smtp.host =
|
```
|
||||||
|
|
||||||
|
Directory for caching downloaded ratings files.
|
||||||
|
|
||||||
|
### Rating sources
|
||||||
|
|
||||||
|
For each rating source (`aga`, `egf`, `ffg`):
|
||||||
|
|
||||||
|
```
|
||||||
|
ratings.<source> = <url or file path>
|
||||||
|
```
|
||||||
|
|
||||||
|
If not set, ratings are auto-downloaded from the default URL. Set to a local file path to freeze ratings at a specific date.
|
||||||
|
|
||||||
|
Example to freeze EGF ratings:
|
||||||
|
```
|
||||||
|
ratings.egf = ratings/EGF-20240115.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable/disable ratings
|
||||||
|
|
||||||
|
```
|
||||||
|
ratings.<source>.enable = true | false
|
||||||
|
```
|
||||||
|
|
||||||
|
Whether to display the rating source button in the Add Player popup.
|
||||||
|
|
||||||
|
```
|
||||||
|
ratings.<source>.show = true | false
|
||||||
|
```
|
||||||
|
|
||||||
|
Whether to show player IDs from this rating source on the registration page.
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
- For tournaments in France: FFG enabled and shown by default
|
||||||
|
- Otherwise: all disabled by default
|
||||||
|
|
||||||
|
## SMTP
|
||||||
|
|
||||||
|
SMTP configuration for email notifications. Not yet functional.
|
||||||
|
|
||||||
|
```
|
||||||
|
smtp.sender = sender@example.com
|
||||||
|
smtp.host = smtp.example.com
|
||||||
smtp.port = 587
|
smtp.port = 587
|
||||||
smtp.user =
|
smtp.user = username
|
||||||
smtp.password =
|
smtp.password = password
|
||||||
```
|
```
|
||||||
|
|
||||||
## logging
|
## Logging
|
||||||
|
|
||||||
Logging configuration.
|
Logging configuration.
|
||||||
|
|
||||||
@@ -86,34 +209,48 @@ logger.level = info
|
|||||||
logger.format = [%level] %ip [%logger] %message
|
logger.format = [%level] %ip [%logger] %message
|
||||||
```
|
```
|
||||||
|
|
||||||
## ratings
|
Log levels: `trace`, `debug`, `info`, `warn`, `error`
|
||||||
|
|
||||||
Ratings configuration. `<ratings>` stands for `egf` or `ffg` in the following.
|
Format placeholders: `%level`, `%ip`, `%logger`, `%message`
|
||||||
|
|
||||||
### freeze ratings date
|
## Example configurations
|
||||||
|
|
||||||
If the following property is given:
|
### Standalone development
|
||||||
|
|
||||||
```
|
```properties
|
||||||
ratings.<ratings>.file = ...
|
env = dev
|
||||||
|
mode = standalone
|
||||||
|
auth = none
|
||||||
|
store = file
|
||||||
|
store.file.path = tournamentfiles
|
||||||
|
logger.level = trace
|
||||||
```
|
```
|
||||||
|
|
||||||
then the given ratings file will be used (it must use the Pairgoth ratings json format). If not, the corresponding ratings will be automatically downloaded and stored into `ratings/EGF-yyyymmdd.json` or `ratings/FFG-yyyymmdd.json`.
|
### Client-server deployment
|
||||||
|
|
||||||
The typical use case, for a big tournament lasting several days or a congress, is to let Pairgoth download the latest expected ratings, then to add this property to freeze the ratings at a specific date.
|
**Server (API):**
|
||||||
|
```properties
|
||||||
### enable or disable ratings
|
env = prod
|
||||||
|
mode = server
|
||||||
Whether to display the EGF or FFG ratings button in the Add Player popup:
|
auth = oauth
|
||||||
|
auth.shared_secret = 1234567890abcdef
|
||||||
```
|
api.port = 8085
|
||||||
ratings.<ratings>.enable = true | false
|
store = file
|
||||||
|
store.file.path = /var/tournaments
|
||||||
|
logger.level = info
|
||||||
```
|
```
|
||||||
|
|
||||||
Whether to show the ratings player IDs on the registration page:
|
**Client (Web UI):**
|
||||||
|
```properties
|
||||||
|
env = prod
|
||||||
|
mode = client
|
||||||
|
auth = oauth
|
||||||
|
auth.shared_secret = 1234567890abcdef
|
||||||
|
oauth.providers = ffg,google
|
||||||
|
oauth.ffg.client_id = your-ffg-id
|
||||||
|
oauth.ffg.secret = your-ffg-secret
|
||||||
|
oauth.google.client_id = your-google-id
|
||||||
|
oauth.google.secret = your-google-secret
|
||||||
|
webapp.port = 8080
|
||||||
|
api.external.url = http://api-server:8085/api
|
||||||
```
|
```
|
||||||
ratings.<ratings>.show = true | false
|
|
||||||
```
|
|
||||||
|
|
||||||
For a tournament in France, both are true for `ffg` by default, false otherwise.
|
|
||||||
|
|||||||
306
doc/model.md
306
doc/model.md
@@ -1,9 +1,7 @@
|
|||||||
# PairGoth model
|
# Pairgoth Model
|
||||||
|
|
||||||
## Entity Relationship Diagram
|
## Entity Relationship Diagram
|
||||||
|
|
||||||
For simplicity, teams (pairgo, rengo) and teams of individuals (clubs championships) are not included.
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
erDiagram
|
erDiagram
|
||||||
|
|
||||||
@@ -11,22 +9,23 @@ erDiagram
|
|||||||
|
|
||||||
Tournament {
|
Tournament {
|
||||||
int id
|
int id
|
||||||
string type
|
Type type
|
||||||
string name
|
string name
|
||||||
string shortName
|
string shortName
|
||||||
date startDate
|
date startDate
|
||||||
date endDate
|
date endDate
|
||||||
|
string director
|
||||||
string country
|
string country
|
||||||
string location
|
string location
|
||||||
bool isOnline
|
bool online
|
||||||
int rounds
|
int rounds
|
||||||
int gobanSize
|
int gobanSize
|
||||||
string rules
|
Rules rules
|
||||||
int komi
|
double komi
|
||||||
}
|
}
|
||||||
|
|
||||||
TimeSystem {
|
TimeSystem {
|
||||||
string type
|
TimeSystemType type
|
||||||
int mainTime
|
int mainTime
|
||||||
int increment
|
int increment
|
||||||
int maxTime
|
int maxTime
|
||||||
@@ -37,18 +36,17 @@ erDiagram
|
|||||||
|
|
||||||
Pairing {
|
Pairing {
|
||||||
PairingType type
|
PairingType type
|
||||||
BaseParams base
|
PairingParams pairingParams
|
||||||
MainParams main
|
PlacementParams placementParams
|
||||||
SecondaryParams secondary
|
|
||||||
GeographicalParams geo
|
|
||||||
HandicapParams handicap
|
|
||||||
PlacementParams place
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Game {
|
Game {
|
||||||
|
int id
|
||||||
int table
|
int table
|
||||||
int handicap
|
int handicap
|
||||||
string result
|
Result result
|
||||||
|
int drawnUpDown
|
||||||
|
bool forcedTable
|
||||||
}
|
}
|
||||||
|
|
||||||
Player {
|
Player {
|
||||||
@@ -58,13 +56,26 @@ erDiagram
|
|||||||
string country
|
string country
|
||||||
string club
|
string club
|
||||||
int rating
|
int rating
|
||||||
string rank
|
int rank
|
||||||
bool final
|
bool final
|
||||||
array skip
|
int mmsCorrection
|
||||||
|
set skip
|
||||||
|
map externalIds
|
||||||
|
}
|
||||||
|
|
||||||
|
Team {
|
||||||
|
int id
|
||||||
|
string name
|
||||||
|
set playerIds
|
||||||
|
int rating
|
||||||
|
int rank
|
||||||
|
bool final
|
||||||
|
int mmsCorrection
|
||||||
|
set skip
|
||||||
}
|
}
|
||||||
|
|
||||||
Standings {
|
Standings {
|
||||||
array criteria
|
list criteria
|
||||||
}
|
}
|
||||||
|
|
||||||
%% relationships
|
%% relationships
|
||||||
@@ -72,9 +83,266 @@ erDiagram
|
|||||||
Tournament ||--|{ TimeSystem: "time system"
|
Tournament ||--|{ TimeSystem: "time system"
|
||||||
Tournament ||--|{ Pairing: "pairing"
|
Tournament ||--|{ Pairing: "pairing"
|
||||||
Tournament ||--|{ Game: "round"
|
Tournament ||--|{ Game: "round"
|
||||||
Tournament }o--|{ Player: "participate(round)"
|
Tournament }o--|{ Player: "players"
|
||||||
|
Tournament }o--|{ Team: "teams"
|
||||||
|
Team }o--|{ Player: "members"
|
||||||
Game ||--|| Player: "black"
|
Game ||--|| Player: "black"
|
||||||
Game ||--|| Player: "white"
|
Game ||--|| Player: "white"
|
||||||
Player }|--|| Standings: "position"
|
Player }|--|| Standings: "position"
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Tournament
|
||||||
|
|
||||||
|
Sealed class hierarchy for different tournament formats.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | int | Tournament identifier |
|
||||||
|
| type | Type | Tournament format |
|
||||||
|
| name | string | Full tournament name |
|
||||||
|
| shortName | string | Abbreviated name |
|
||||||
|
| startDate | date | Start date |
|
||||||
|
| endDate | date | End date |
|
||||||
|
| director | string | Tournament director |
|
||||||
|
| country | string | Country code (default: "fr") |
|
||||||
|
| location | string | Venue location |
|
||||||
|
| online | bool | Is online tournament |
|
||||||
|
| rounds | int | Total number of rounds |
|
||||||
|
| gobanSize | int | Board size (default: 19) |
|
||||||
|
| rules | Rules | Scoring rules |
|
||||||
|
| komi | double | Komi value (default: 7.5) |
|
||||||
|
| timeSystem | TimeSystem | Time control |
|
||||||
|
| pairing | Pairing | Pairing system |
|
||||||
|
| tablesExclusion | list | Table exclusion rules per round |
|
||||||
|
|
||||||
|
### Tournament Types
|
||||||
|
|
||||||
|
| Type | Players/Team | Description |
|
||||||
|
|------|--------------|-------------|
|
||||||
|
| INDIVIDUAL | 1 | Individual players |
|
||||||
|
| PAIRGO | 2 | Pair Go (alternating) |
|
||||||
|
| RENGO2 | 2 | Rengo with 2 players |
|
||||||
|
| RENGO3 | 3 | Rengo with 3 players |
|
||||||
|
| TEAM2 | 2 | Team with 2 boards |
|
||||||
|
| TEAM3 | 3 | Team with 3 boards |
|
||||||
|
| TEAM4 | 4 | Team with 4 boards |
|
||||||
|
| TEAM5 | 5 | Team with 5 boards |
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- `AGA` - American Go Association
|
||||||
|
- `FRENCH` - French Go Association
|
||||||
|
- `JAPANESE` - Japanese rules
|
||||||
|
- `CHINESE` - Chinese rules
|
||||||
|
|
||||||
|
## Player
|
||||||
|
|
||||||
|
Individual tournament participant.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | int | Player identifier |
|
||||||
|
| name | string | Last name |
|
||||||
|
| firstname | string | First name |
|
||||||
|
| country | string | Country code |
|
||||||
|
| club | string | Club affiliation |
|
||||||
|
| rating | int | EGF-style rating |
|
||||||
|
| rank | int | Rank (-30=30k to 8=9D) |
|
||||||
|
| final | bool | Is registration confirmed |
|
||||||
|
| mmsCorrection | int | MacMahon score correction |
|
||||||
|
| skip | set | Skipped round numbers |
|
||||||
|
| externalIds | map | External IDs (AGA, EGF, FFG) |
|
||||||
|
|
||||||
|
## Team
|
||||||
|
|
||||||
|
Team participant (for team tournaments).
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | int | Team identifier |
|
||||||
|
| name | string | Team name |
|
||||||
|
| playerIds | set | Member player IDs |
|
||||||
|
| rating | int | Computed from members |
|
||||||
|
| rank | int | Computed from members |
|
||||||
|
| final | bool | Is registration confirmed |
|
||||||
|
| mmsCorrection | int | MacMahon score correction |
|
||||||
|
| skip | set | Skipped round numbers |
|
||||||
|
|
||||||
|
## Game
|
||||||
|
|
||||||
|
Single game in a round.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | int | Game identifier |
|
||||||
|
| table | int | Table number (0 = unpaired) |
|
||||||
|
| white | int | White player ID (0 = bye) |
|
||||||
|
| black | int | Black player ID (0 = bye) |
|
||||||
|
| handicap | int | Handicap stones |
|
||||||
|
| result | Result | Game outcome |
|
||||||
|
| drawnUpDown | int | DUDD value |
|
||||||
|
| forcedTable | bool | Is table manually assigned |
|
||||||
|
|
||||||
|
### Result
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| ? | Unknown (not yet played) |
|
||||||
|
| w | White won |
|
||||||
|
| b | Black won |
|
||||||
|
| = | Jigo (draw) |
|
||||||
|
| X | Cancelled |
|
||||||
|
| # | Both win (unusual) |
|
||||||
|
| 0 | Both lose (unusual) |
|
||||||
|
|
||||||
|
## TimeSystem
|
||||||
|
|
||||||
|
Time control configuration.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| type | TimeSystemType | System type |
|
||||||
|
| mainTime | int | Main time in seconds |
|
||||||
|
| increment | int | Fischer increment |
|
||||||
|
| maxTime | int | Fischer max time |
|
||||||
|
| byoyomi | int | Byoyomi time per period |
|
||||||
|
| periods | int | Number of byoyomi periods |
|
||||||
|
| stones | int | Stones per period (Canadian) |
|
||||||
|
|
||||||
|
### TimeSystemType
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| CANADIAN | Canadian byoyomi |
|
||||||
|
| JAPANESE | Japanese byoyomi |
|
||||||
|
| FISCHER | Fischer increment |
|
||||||
|
| SUDDEN_DEATH | No overtime |
|
||||||
|
|
||||||
|
## Pairing
|
||||||
|
|
||||||
|
Pairing system configuration.
|
||||||
|
|
||||||
|
### Pairing Types
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| SWISS | Swiss system |
|
||||||
|
| MAC_MAHON | MacMahon system |
|
||||||
|
| ROUND_ROBIN | Round robin (not implemented) |
|
||||||
|
|
||||||
|
### MacMahon-specific
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| mmFloor | int | MacMahon floor (default: -20 = 20k) |
|
||||||
|
| mmBar | int | MacMahon bar (default: 0 = 1D) |
|
||||||
|
|
||||||
|
### Base Parameters
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| nx1 | Concavity curve factor (0.0-1.0) |
|
||||||
|
| dupWeight | Duplicate game avoidance weight |
|
||||||
|
| random | Randomization factor |
|
||||||
|
| deterministic | Deterministic pairing |
|
||||||
|
| colorBalanceWeight | Color balance importance |
|
||||||
|
| byeWeight | Bye assignment weight |
|
||||||
|
|
||||||
|
### Main Parameters
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| categoriesWeight | Avoid mixing categories |
|
||||||
|
| scoreWeight | Minimize score differences |
|
||||||
|
| drawUpDownWeight | Draw-up/draw-down weighting |
|
||||||
|
| compensateDrawUpDown | Enable DUDD compensation |
|
||||||
|
| drawUpDownUpperMode | TOP, MIDDLE, or BOTTOM |
|
||||||
|
| drawUpDownLowerMode | TOP, MIDDLE, or BOTTOM |
|
||||||
|
| seedingWeight | Seeding importance |
|
||||||
|
| lastRoundForSeedSystem1 | Round cutoff for system 1 |
|
||||||
|
| seedSystem1 | First seeding method |
|
||||||
|
| seedSystem2 | Second seeding method |
|
||||||
|
| mmsValueAbsent | MMS for absent players |
|
||||||
|
| roundDownScore | Floor vs round scores |
|
||||||
|
|
||||||
|
### Seed Methods
|
||||||
|
|
||||||
|
- `SPLIT_AND_FOLD`
|
||||||
|
- `SPLIT_AND_RANDOM`
|
||||||
|
- `SPLIT_AND_SLIP`
|
||||||
|
|
||||||
|
### Secondary Parameters
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| barThresholdActive | Don't apply below bar |
|
||||||
|
| rankSecThreshold | Rank limit for criteria |
|
||||||
|
| nbWinsThresholdActive | Score threshold |
|
||||||
|
| defSecCrit | Secondary criteria weight |
|
||||||
|
|
||||||
|
### Geographical Parameters
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| avoidSameGeo | Avoid same region |
|
||||||
|
| preferMMSDiffRatherThanSameCountry | Country preference |
|
||||||
|
| preferMMSDiffRatherThanSameClubsGroup | Club group preference |
|
||||||
|
| preferMMSDiffRatherThanSameClub | Club preference |
|
||||||
|
|
||||||
|
### Handicap Parameters
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| weight | Handicap minimization weight |
|
||||||
|
| useMMS | Use MMS vs rank |
|
||||||
|
| rankThreshold | Rank threshold |
|
||||||
|
| correction | Handicap reduction |
|
||||||
|
| ceiling | Max handicap stones |
|
||||||
|
|
||||||
|
## Placement Criteria
|
||||||
|
|
||||||
|
Tiebreak criteria for standings, in order of priority.
|
||||||
|
|
||||||
|
### Score-based
|
||||||
|
|
||||||
|
| Criterion | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| NBW | Number of wins |
|
||||||
|
| MMS | MacMahon score |
|
||||||
|
| STS | Strasbourg score |
|
||||||
|
| CPS | Cup score |
|
||||||
|
| SCOREX | Congress score |
|
||||||
|
|
||||||
|
### Opponent-based (W = wins, M = MMS)
|
||||||
|
|
||||||
|
| Criterion | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| SOSW / SOSM | Sum of opponent scores |
|
||||||
|
| SOSWM1 / SOSMM1 | SOS minus worst |
|
||||||
|
| SOSWM2 / SOSMM2 | SOS minus two worst |
|
||||||
|
| SODOSW / SODOSM | Sum of defeated opponent scores |
|
||||||
|
| SOSOSW / SOSOSM | Sum of opponent SOS |
|
||||||
|
| CUSSW / CUSSM | Cumulative score sum |
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
| Criterion | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| CATEGORY | Player category |
|
||||||
|
| RANK | Player rank |
|
||||||
|
| RATING | Player rating |
|
||||||
|
| DC | Direct confrontation |
|
||||||
|
| SDC | Simplified direct confrontation |
|
||||||
|
| EXT | Exploits attempted |
|
||||||
|
| EXR | Exploits successful |
|
||||||
|
|
||||||
|
## External Databases
|
||||||
|
|
||||||
|
Player IDs can be linked to external rating databases:
|
||||||
|
|
||||||
|
| Database | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| AGA | American Go Association |
|
||||||
|
| EGF | European Go Federation |
|
||||||
|
| FFG | French Go Association |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.jeudego.pairgoth</groupId>
|
<groupId>org.jeudego.pairgoth</groupId>
|
||||||
<artifactId>engine-parent</artifactId>
|
<artifactId>engine-parent</artifactId>
|
||||||
<version>0.20</version>
|
<version>0.23</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>pairgoth-common</artifactId>
|
<artifactId>pairgoth-common</artifactId>
|
||||||
|
|
||||||
|
|||||||
2
pom.xml
2
pom.xml
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<groupId>org.jeudego.pairgoth</groupId>
|
<groupId>org.jeudego.pairgoth</groupId>
|
||||||
<artifactId>engine-parent</artifactId>
|
<artifactId>engine-parent</artifactId>
|
||||||
<version>0.20</version>
|
<version>0.23</version>
|
||||||
<packaging>pom</packaging>
|
<packaging>pom</packaging>
|
||||||
|
|
||||||
<!-- CB: Temporary add my repository, while waiting for SSE Java server module author to incorporate my PR or for me to fork it -->
|
<!-- CB: Temporary add my repository, while waiting for SSE Java server module author to incorporate my PR or for me to fork it -->
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.jeudego.pairgoth</groupId>
|
<groupId>org.jeudego.pairgoth</groupId>
|
||||||
<artifactId>engine-parent</artifactId>
|
<artifactId>engine-parent</artifactId>
|
||||||
<version>0.20</version>
|
<version>0.23</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>view-webapp</artifactId>
|
<artifactId>view-webapp</artifactId>
|
||||||
|
|
||||||
@@ -113,6 +113,7 @@
|
|||||||
<include>index.css</include>
|
<include>index.css</include>
|
||||||
<include>main.css</include>
|
<include>main.css</include>
|
||||||
<include>tour.css</include>
|
<include>tour.css</include>
|
||||||
|
<include>explain.css</include>
|
||||||
</includes>
|
</includes>
|
||||||
</resource>
|
</resource>
|
||||||
</webResources>
|
</webResources>
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ object EGFRatingsHandler: RatingsHandler(RatingsManager.Ratings.EGF) {
|
|||||||
if (adjusted < 0) "${-(adjusted - 99) / 100}k"
|
if (adjusted < 0) "${-(adjusted - 99) / 100}k"
|
||||||
else "${(adjusted + 100) / 100}d"
|
else "${(adjusted + 100) / 100}d"
|
||||||
}
|
}
|
||||||
if ("UK" == player.getString("country")) {
|
|
||||||
player["country"] = "GB"
|
|
||||||
}
|
|
||||||
// fix for missing firstnames
|
// fix for missing firstnames
|
||||||
if (player.getString("firstname") == null) {
|
if (player.getString("firstname") == null) {
|
||||||
player["firstname"] = ""
|
player["firstname"] = ""
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ class PlayerIndex {
|
|||||||
val stopChars = Regex("[_-]")
|
val stopChars = Regex("[_-]")
|
||||||
}
|
}
|
||||||
private final val directory: Directory = ByteBuffersDirectory(NoLockFactory.INSTANCE)
|
private final val directory: Directory = ByteBuffersDirectory(NoLockFactory.INSTANCE)
|
||||||
private val reader by lazy { DirectoryReader.open(directory) }
|
private lateinit var reader: DirectoryReader
|
||||||
private val searcher by lazy { IndexSearcher(reader) }
|
private lateinit var searcher: IndexSearcher
|
||||||
|
|
||||||
// helper functions
|
// helper functions
|
||||||
fun Json.Object.field(key: String) = getString(key) ?: throw Error("missing $key")
|
fun Json.Object.field(key: String) = getString(key) ?: throw Error("missing $key")
|
||||||
@@ -68,6 +68,9 @@ class PlayerIndex {
|
|||||||
++count
|
++count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Refresh reader and searcher to see the new index
|
||||||
|
reader = DirectoryReader.open(directory)
|
||||||
|
searcher = IndexSearcher(reader)
|
||||||
logger.info("indexed $count players")
|
logger.info("indexed $count players")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
|
|||||||
companion object {
|
companion object {
|
||||||
private val delay = TimeUnit.HOURS.toMillis(1L)
|
private val delay = TimeUnit.HOURS.toMillis(1L)
|
||||||
private val ymd = DateTimeFormatter.ofPattern("yyyyMMdd")
|
private val ymd = DateTimeFormatter.ofPattern("yyyyMMdd")
|
||||||
|
private const val USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||||
}
|
}
|
||||||
private val client = OkHttpClient()
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
abstract val defaultURL: URL
|
abstract val defaultURL: URL
|
||||||
open val active = true
|
open val active = true
|
||||||
lateinit var players: Json.Array
|
lateinit var players: Json.Array
|
||||||
@@ -102,6 +106,11 @@ abstract class RatingsHandler(val origin: RatingsManager.Ratings) {
|
|||||||
try {
|
try {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
.header("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
// Don't set Accept-Encoding - let OkHttp handle compression transparently
|
||||||
|
.header("Connection", "keep-alive")
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
client.newCall(request).execute().use { response ->
|
client.newCall(request).execute().use { response ->
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ object RatingsManager: Runnable {
|
|||||||
object Task: TimerTask() {
|
object Task: TimerTask() {
|
||||||
override fun run() {
|
override fun run() {
|
||||||
try {
|
try {
|
||||||
players = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings ->
|
val newPlayers = ratingsHandlers.values.filter { it.active }.flatMapTo(Json.MutableArray()) { ratings ->
|
||||||
val ratingsFile = WebappManager.properties.getProperty("ratings.${ratings.origin.name.lowercase()}") as String?
|
val ratingsFile = WebappManager.properties.getProperty("ratings.${ratings.origin.name.lowercase()}") as String?
|
||||||
if (ratingsFile == null) {
|
if (ratingsFile == null) {
|
||||||
ratings.fetchPlayers()
|
ratings.fetchPlayers()
|
||||||
@@ -64,36 +64,33 @@ object RatingsManager: Runnable {
|
|||||||
ratings.fetchPlayers(Paths.get("").resolve(ratingsFile).toFile())
|
ratings.fetchPlayers(Paths.get("").resolve(ratingsFile).toFile())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val updated = ratingsHandlers.values.filter { it.active }.map { it.updated() }.reduce { u1, u2 ->
|
// Always update players and index together under the write lock
|
||||||
u1 or u2
|
// Index must be rebuilt every time since it stores array indices
|
||||||
}
|
try {
|
||||||
if (updated) {
|
updateLock.writeLock().lock()
|
||||||
try {
|
players = newPlayers
|
||||||
updateLock.writeLock().lock()
|
index.build(players)
|
||||||
index.build(players)
|
|
||||||
} finally {
|
|
||||||
updateLock.writeLock().unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// propagate French players license status from ffg to egf
|
// propagate French players license status from ffg to egf
|
||||||
val licenseStatus = players.map { it -> it as Json.MutableObject }.filter {
|
val licenseStatus = players.map { it -> it as Json.MutableObject }.filter {
|
||||||
it["origin"] == "FFG"
|
it["origin"] == "FFG"
|
||||||
}.associate { player ->
|
}.associate { player ->
|
||||||
Pair(player.getString("ffg")!!, player.getString("license") ?: "-")
|
Pair(player.getString("ffg")!!, player.getString("license") ?: "-")
|
||||||
}
|
}
|
||||||
players.map { it -> it as Json.MutableObject }.filter {
|
players.map { it -> it as Json.MutableObject }.filter {
|
||||||
it["origin"] == "EGF" && it["country"] == "FR"
|
it["origin"] == "EGF" && it["country"] == "FR"
|
||||||
}.forEach { player ->
|
}.forEach { player ->
|
||||||
player.getString("egf")?.let { egf ->
|
player.getString("egf")?.let { egf ->
|
||||||
egf2ffg[egf]?.let { ffg ->
|
egf2ffg[egf]?.let { ffg ->
|
||||||
licenseStatus[ffg]?.let {
|
licenseStatus[ffg]?.let {
|
||||||
player["license"] = it
|
player["license"] = it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
updateLock.writeLock().unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.error("could not build or refresh index: ${e.javaClass.name} ${e.message}")
|
logger.error("could not build or refresh index: ${e.javaClass.name} ${e.message}")
|
||||||
logger.debug("could not build or refresh index", e)
|
logger.debug("could not build or refresh index", e)
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class CountriesTool {
|
|||||||
"fj" to "Fiji",
|
"fj" to "Fiji",
|
||||||
"fr" to "France",
|
"fr" to "France",
|
||||||
"ga" to "Gabon",
|
"ga" to "Gabon",
|
||||||
"gb" to "United Kingdom",
|
"uk" to "United Kingdom",
|
||||||
"gd" to "Grenada",
|
"gd" to "Grenada",
|
||||||
"ge" to "Georgia",
|
"ge" to "Georgia",
|
||||||
"gg" to "Guernsey",
|
"gg" to "Guernsey",
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ class ViewServlet : VelocityViewServlet() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
val lang = request.getAttribute("lang") as String
|
val lang = request.getAttribute("lang") as String
|
||||||
|
|
||||||
|
// User preferences - read from cookie
|
||||||
|
val blackFirst = request.cookies?.find { it.name == "blackFirst" }?.value == "true"
|
||||||
|
context.put("blackFirst", blackFirst)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
val menu = menuEntries!![uri]
|
val menu = menuEntries!![uri]
|
||||||
var title: String? = null
|
var title: String? = null
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"id":4,"type":"INDIVIDUAL","name":"Template","shortName":"20251111-Template","startDate":"2025-11-11","endDate":"2025-11-11","director":"","country":"si","location":"Template","online":false,"komi":6.5,"rules":"JAPANESE","gobanSize":19,"timeSystem":{"type":"CANADIAN","mainTime":2400,"byoyomi":300,"stones":15},"rounds":5,"pairing":{"type":"MAC_MAHON","base":{"nx1":0.5,"dupWeight":5.0E14,"random":0.0,"deterministic":true,"colorBalanceWeight":1000000.0},"main":{"catWeight":2.0E13,"scoreWeight":1.0E11,"upDownWeight":1.0E8,"upDownCompensate":true,"upDownLowerMode":"MIDDLE","upDownUpperMode":"MIDDLE","maximizeSeeding":5000000.0,"firstSeedLastRound":2,"firstSeed":"SPLIT_AND_RANDOM","secondSeed":"SPLIT_AND_FOLD","firstSeedAddCrit":"RATING","secondSeedAddCrit":"NONE","mmsValueAbsent":0.5,"roundDownScore":true,"sosValueAbsentUseBase":true},"secondary":{"barThreshold":true,"rankThreshold":0,"winsThreshold":true,"secWeight":1.0E11},"geo":{"weight":1.0E11,"mmsDiffCountry":1,"mmsDiffClubGroup":3,"mmsDiffClub":3},"handicap":{"weight":0.0,"useMMS":false,"threshold":8,"correction":2,"ceiling":9},"placement":["MMS","SOSM","SOSOSM"],"mmFloor":-20,"mmBar":0},"players":[],"games":[[]]}
|
||||||
143
view-webapp/src/main/sass/explain.scss
Normal file
143
view-webapp/src/main/sass/explain.scss
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
@layer pairgoth {
|
||||||
|
/* explain section */
|
||||||
|
|
||||||
|
#pairing-table-wrapper {
|
||||||
|
padding: 8em 2em 1em 2em;
|
||||||
|
}
|
||||||
|
#pairing-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: max-content;
|
||||||
|
thead {
|
||||||
|
//border-collapse: collapse;
|
||||||
|
tr {
|
||||||
|
&:first-child {
|
||||||
|
min-height: 8rem;
|
||||||
|
th:first-child {
|
||||||
|
background: linear-gradient(to right top, #ffffff 0%,#ffffff 49.9%,#000000 50%,#000000 51%,#ffffff 51.1%,#ffffff 100%);
|
||||||
|
position: relative;
|
||||||
|
min-width: 8rem;
|
||||||
|
label.top-right {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
label.bottom-left {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th:not(:first-child) {
|
||||||
|
z-index: 5;
|
||||||
|
width: 55px;
|
||||||
|
height: 140px;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
position: relative;
|
||||||
|
> div {
|
||||||
|
transform: translate(35px, 51px) rotate(315deg);
|
||||||
|
width: 30px;
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
> span {
|
||||||
|
background-color: rgba(0, 0, 255, 0.2) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
> span {
|
||||||
|
border-bottom: 1px solid gray;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
> pre {
|
||||||
|
display: none;
|
||||||
|
font-size: smaller;
|
||||||
|
line-height: 1em;
|
||||||
|
text-align: left;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
top: 100%;
|
||||||
|
left: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
> pre {
|
||||||
|
display: block;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
tr {
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
padding-right: 1em;
|
||||||
|
position: relative;
|
||||||
|
> pre {
|
||||||
|
display: none;
|
||||||
|
font-size: smaller;
|
||||||
|
line-height: 1em;
|
||||||
|
text-align: left;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
top: 100%;
|
||||||
|
left: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: rgba(0, 0, 255, 0.2) !important;
|
||||||
|
pre {
|
||||||
|
display: block;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
height: 55px;
|
||||||
|
width: 55px;
|
||||||
|
border: solid 1px gray;
|
||||||
|
position: relative;
|
||||||
|
&.game::after {
|
||||||
|
position: absolute;
|
||||||
|
content: "X";
|
||||||
|
color: blue;
|
||||||
|
text-align: center;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
.weights {
|
||||||
|
display: none;
|
||||||
|
font-size: smaller;
|
||||||
|
line-height: 1em;
|
||||||
|
text-align: left;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
top: 100%;
|
||||||
|
left: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 255, 0.7) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
.weights {
|
||||||
|
display: block;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#captures {
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -56,6 +56,10 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
/* header, center, footer */
|
/* header, center, footer */
|
||||||
|
|
||||||
#header {
|
#header {
|
||||||
@@ -519,10 +523,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#logout {
|
#logout, #settings {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#settings-modal {
|
||||||
|
.setting {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media screen {
|
@media screen {
|
||||||
#players-list {
|
#players-list {
|
||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
@@ -566,7 +582,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* TODO - plenty of those elements could just use the .noprint class */
|
/* TODO - plenty of those elements could just use the .noprint class */
|
||||||
#header, #logo, #lang, .steps, #filter-box, #reglist-mode, #footer, #unpairables, #pairing-buttons, button, #standings-params, #logout, .pairing-stats, .result-sheets, .toggle, #overview, .tables-exclusion, .button, .noprint {
|
#header, #logo, #lang, .steps, #filter-box, #reglist-mode, #footer, #unpairables, #pairing-buttons, button, #standings-params, #logout, .pairing-stats, .pairing-post-actions, .toggle, #overview, .tables-exclusion, .button, .noprint {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -377,6 +377,9 @@
|
|||||||
width: 6em;
|
width: 6em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
&:empty + .pairing-post-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#print-pairables {
|
#print-pairables {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -418,8 +421,10 @@
|
|||||||
padding: 0.2em 0.8em;
|
padding: 0.2em 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-sheets {
|
.pairing-post-actions {
|
||||||
margin-top: 0.2em;
|
margin-top: 0.2em;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-pairing-actions {
|
.bottom-pairing-actions {
|
||||||
|
|||||||
@@ -44,6 +44,9 @@
|
|||||||
#translate('tour-menu.inc.html')
|
#translate('tour-menu.inc.html')
|
||||||
#end
|
#end
|
||||||
<div id="header-right">
|
<div id="header-right">
|
||||||
|
<div id="settings" title="Settings">
|
||||||
|
<i class="fa fa-cog"></i>
|
||||||
|
</div>
|
||||||
<div id="lang">
|
<div id="lang">
|
||||||
<i class="$translate.flags[$request.lang] flag"></i>
|
<i class="$translate.flags[$request.lang] flag"></i>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +87,23 @@
|
|||||||
#end
|
#end
|
||||||
#end
|
#end
|
||||||
</div>
|
</div>
|
||||||
|
<div id="settings-modal" class="popup">
|
||||||
|
<div class="popup-body">
|
||||||
|
<div class="popup-header">Settings</div>
|
||||||
|
<div class="popup-content">
|
||||||
|
<div class="setting">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="pref-black-first" #if($blackFirst)checked#end />
|
||||||
|
Display games as "Black vs White" (instead of "White vs Black")
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="popup-footer">
|
||||||
|
<button class="ui button" id="settings-save">Save</button>
|
||||||
|
<button class="ui button gray close">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="text/javascript" src="/lib/store2-2.14.2.min.js"></script>
|
<script type="text/javascript" src="/lib/store2-2.14.2.min.js"></script>
|
||||||
<script type="text/javascript" src="/lib/tablesort-5.4.0/tablesort.min.js"></script>
|
<script type="text/javascript" src="/lib/tablesort-5.4.0/tablesort.min.js"></script>
|
||||||
<script type="text/javascript" src="/lib/tablesort-5.4.0/sorts/tablesort.number.min.js"></script>
|
<script type="text/javascript" src="/lib/tablesort-5.4.0/sorts/tablesort.number.min.js"></script>
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ unpairable, non disponibles,
|
|||||||
supports the implémente le système d’appariement
|
supports the implémente le système d’appariement
|
||||||
white blanc
|
white blanc
|
||||||
White Blanc
|
White Blanc
|
||||||
white vs. black blanc vs. Noir
|
white vs. black Blanc vs. Noir
|
||||||
confirmed. confirmé(s).
|
confirmed. confirmé(s).
|
||||||
Note that login to this instance is reserved to French federation actors plus several external people at our discretion. Send us La connexion à cette instance est réservée aux acteurs de la FFG et à quelques personnes extérieures, à notre discrétion. Envoyez-nous
|
Note that login to this instance is reserved to French federation actors plus several external people at our discretion. Send us La connexion à cette instance est réservée aux acteurs de la FFG et à quelques personnes extérieures, à notre discrétion. Envoyez-nous
|
||||||
yyyymmdd-city aaaammjj-ville
|
yyyymmdd-city aaaammjj-ville
|
||||||
|
|||||||
115
view-webapp/src/main/webapp/explain.html
Normal file
115
view-webapp/src/main/webapp/explain.html
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#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>
|
||||||
|
</div>
|
||||||
|
#stop
|
||||||
|
#end
|
||||||
|
#set($round = $math.toInteger($!params.round))
|
||||||
|
#if(!$round)
|
||||||
|
#set($round = 1)
|
||||||
|
#else
|
||||||
|
#set($round = $math.min($math.max($round, 1), $tour.rounds))
|
||||||
|
#end
|
||||||
|
#if($tour.type == 'INDIVIDUAL' || $tour.type.startsWith('TEAM'))
|
||||||
|
#set($parts = $api.get("tour/${params.id}/part"))
|
||||||
|
#else
|
||||||
|
#set($parts = $api.get("tour/${params.id}/team"))
|
||||||
|
#end
|
||||||
|
#set($pmap = $utils.toMap($parts))
|
||||||
|
#set($roundPairing = $api.get("tour/${params.id}/pair/$round"))
|
||||||
|
#if($roundPairing.error)
|
||||||
|
<script type="text/javascript">
|
||||||
|
onLoad(() => {
|
||||||
|
showError("$roundPairing.error")
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
#stop
|
||||||
|
#end
|
||||||
|
#set($explain = $api.get("tour/${params.id}/explain/$round"))
|
||||||
|
<div id="pairing-table-wrapper">
|
||||||
|
<table id="pairing-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<label class="top-right">white</label>
|
||||||
|
<label class="bottom-left">black</label>
|
||||||
|
</th>
|
||||||
|
#foreach($white in $explain.paired)
|
||||||
|
<th data-white="$white.id">
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
$white.name $white.firstname
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre>$white.toPrettyString()</pre>
|
||||||
|
</th>
|
||||||
|
#end
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
#foreach($black in $explain.paired)
|
||||||
|
<tr>
|
||||||
|
<th data-black="$black.id">
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
$black.name $black.firstname
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre>$black.toPrettyString()</pre>
|
||||||
|
</th>
|
||||||
|
#foreach($white in $explain.paired)
|
||||||
|
#if($white.id != $black.id)
|
||||||
|
#set($key = "$white.id-$black.id")
|
||||||
|
#set($weights = $explain.weights[$key])
|
||||||
|
#if($weights)
|
||||||
|
#set($toMax = $explain.max - $weights.total)
|
||||||
|
#set($toMin = $weights.total - $explain.min)
|
||||||
|
#if ($toMax > $toMin)
|
||||||
|
## total is close to min
|
||||||
|
#set($percent = ($weights.total - $explain.min) / ($explain.low - $explain.min) * 40)
|
||||||
|
#else
|
||||||
|
## total is close to max
|
||||||
|
#set($percent = 60 + 40 * (1 - ($explain.max - $weights.total) / ($explain.max - $explain.high)) )
|
||||||
|
#end
|
||||||
|
#end
|
||||||
|
#set($game = $explain.games[$key])
|
||||||
|
<td data-wb="$white.id-$black.id" #if($game)class="game"#end #if($weights)style="background-color: color-mix(in srgb, rgb(0 255 0) ${percent}%, rgb(255 0 0));"#end>
|
||||||
|
<div class="weights">
|
||||||
|
<pre>#if($weights)$weights.toPrettyString()#{else}Bye Player#end</pre>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
#else
|
||||||
|
<td></td>
|
||||||
|
#end
|
||||||
|
#end
|
||||||
|
</tr>
|
||||||
|
#end
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="captures"></div>
|
||||||
|
<script type="text/javascript">
|
||||||
|
// #[[
|
||||||
|
onLoad(() => {
|
||||||
|
$('#header').hide();
|
||||||
|
$('td').on('click', e => {
|
||||||
|
const td = e.target.closest('td');
|
||||||
|
const ids = td.data('wb')?.split(/-/);
|
||||||
|
if (ids) {
|
||||||
|
const white = $(`th[data-white="${ids[0]}"] div span`)?.text();
|
||||||
|
const black = $(`th[data-white="${ids[1]}"] div span`)?.text();
|
||||||
|
const weights = td.find('.weights pre').text();
|
||||||
|
const captures = $('#captures')[0];
|
||||||
|
captures.insertAdjacentHTML('beforeend', `<div>${white} vs ${black}<pre>${weights}</pre></div>`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
$('th[data-white], th[data-black]').on('click', e => {
|
||||||
|
const th = e.target.closest('th');
|
||||||
|
const name = th.find('span').text();
|
||||||
|
const info = th.find('pre').text();
|
||||||
|
captures.insertAdjacentHTML('beforeend', `<div>${name}<pre>${info}</pre></div>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// ]]#
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,21 @@
|
|||||||
// Utilities
|
// Utilities
|
||||||
|
|
||||||
const characters ='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
const characters ='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
|
||||||
|
// User preferences
|
||||||
|
const prefs = {
|
||||||
|
get: function(key) {
|
||||||
|
return store('prefs.' + key);
|
||||||
|
},
|
||||||
|
set: function(key, value) {
|
||||||
|
store('prefs.' + key, value);
|
||||||
|
},
|
||||||
|
getAll: function() {
|
||||||
|
return {
|
||||||
|
blackFirst: this.get('blackFirst') || false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
function randomString(length) {
|
function randomString(length) {
|
||||||
let result = '';
|
let result = '';
|
||||||
const charactersLength = characters.length;
|
const charactersLength = characters.length;
|
||||||
@@ -349,6 +364,21 @@ onLoad(() => {
|
|||||||
if (!dialog) close_modal();
|
if (!dialog) close_modal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Settings modal handlers
|
||||||
|
$('#settings').on('click', e => {
|
||||||
|
modal('settings-modal');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#settings-save').on('click', e => {
|
||||||
|
let blackFirst = $('#pref-black-first')[0].checked;
|
||||||
|
prefs.set('blackFirst', blackFirst);
|
||||||
|
// Set cookie for server-side rendering (expires in 1 year)
|
||||||
|
document.cookie = `blackFirst=${blackFirst}; path=/; max-age=31536000; SameSite=Lax`;
|
||||||
|
close_modal();
|
||||||
|
// Reload page to apply new preference
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
if (isTouchDevice()) {
|
if (isTouchDevice()) {
|
||||||
$("[title]").on('click', e => {
|
$("[title]").on('click', e => {
|
||||||
let item = e.target.closest('[title]');
|
let item = e.target.closest('[title]');
|
||||||
|
|||||||
@@ -247,7 +247,8 @@ onLoad(() => {
|
|||||||
},
|
},
|
||||||
geo: {
|
geo: {
|
||||||
mmsDiffCountry: form.val('mmsDiffCountry'),
|
mmsDiffCountry: form.val('mmsDiffCountry'),
|
||||||
mmsDiffClub: form.val('mmsDiffClub')
|
mmsDiffClub: form.val('mmsDiffClub'),
|
||||||
|
avoidSameFamily: form.val('avoidSameFamily')
|
||||||
},
|
},
|
||||||
handicap: {
|
handicap: {
|
||||||
useMMS: form.val('useMMS'),
|
useMMS: form.val('useMMS'),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<h2 class="error">Invalid tournament id</h2>
|
<h2 class="error">Invalid tournament id</h2>
|
||||||
</div>
|
</div>
|
||||||
|
#stop
|
||||||
#end
|
#end
|
||||||
#set($round = $math.toInteger($!params.round))
|
#set($round = $math.toInteger($!params.round))
|
||||||
#if(!$round)
|
#if(!$round)
|
||||||
@@ -27,60 +28,40 @@
|
|||||||
#stop
|
#stop
|
||||||
#end
|
#end
|
||||||
#set($games = $utils.removeBye($roundPairing.games))
|
#set($games = $utils.removeBye($roundPairing.games))
|
||||||
#set($pages = ($games.size() + 3) / 4)
|
|
||||||
#set($items = $pages * 4)
|
|
||||||
#foreach($i in [1..$items])
|
|
||||||
#set($j = ($i - 1) / 4 + (($i - 1) % 4) * $pages)
|
|
||||||
#if($j < $games.size())
|
|
||||||
#set($game = $games[$j])
|
|
||||||
#set($white = $pmap[$game.w])
|
|
||||||
#set($black = $pmap[$game.b])
|
|
||||||
#else
|
|
||||||
#set($game = { 't': 'xxx', 'h': 'xxx' })
|
|
||||||
#set($white = { 'name': 'xxx', 'firstname': 'xxx', 'rank': -99, 'country': 'XX', 'club': 'xxx' })
|
|
||||||
#set($black = { 'name': 'xxx', 'firstname': 'xxx', 'rank': -99, 'country': 'XX', 'club': 'xxx' })
|
|
||||||
#end
|
|
||||||
#if($foreach.index % 4 == 0)
|
|
||||||
<div class="page">
|
|
||||||
#end
|
|
||||||
|
|
||||||
<div class="page-item">
|
<table>
|
||||||
<div class="title">$tour.name</div>
|
<tr>
|
||||||
<div class="subtitle"></div>
|
<th>Table</th>
|
||||||
<div class="details">
|
<th>Black</th>
|
||||||
<div>Table $game.t</div>
|
<th>Rank</th>
|
||||||
|
<th>White</th>
|
||||||
|
<th>Rank</th>
|
||||||
|
<th>Handicap</th>
|
||||||
|
<th>Komi</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
#set($numOfPlay = $games.size() - 1)
|
||||||
|
#foreach($i in [0..$numOfPlay])
|
||||||
|
|
||||||
|
#set($game = $games[$i])
|
||||||
|
#set($white = $pmap[$game.w])
|
||||||
|
#set($black = $pmap[$game.b])
|
||||||
#set($komi = $tour.komi)
|
#set($komi = $tour.komi)
|
||||||
#if($game.h) #set($komi = $komi - $math.floor($komi)) #end
|
#if($game.h) #set($komi = $komi - $math.floor($komi)) #end
|
||||||
<div>Handicap $game.h ‐ Komi $komi</div>
|
#set($table = $i + 1)
|
||||||
<div>Round $round</div>
|
<tr>
|
||||||
</div>
|
<td>Table $table</td>
|
||||||
<div class="instructions">
|
<td>$black.name $!black.firstname</td>
|
||||||
Surround winner's name or ½-½
|
<td>#rank($black.rank)</td>
|
||||||
</div>
|
<td>$white.name $!white.firstname</td>
|
||||||
<div class="players">
|
<td>#rank($white.rank)</td>
|
||||||
<div class="white player">
|
<td>$game.h</td>
|
||||||
<div class="color">White</div>
|
<td>$komi</td>
|
||||||
<div class="name">$white.name $!white.firstname #rank($white.rank)<br/>#if($white.country)($white.country.toUpperCase()#if($white.club), $white.club#end)#end</div>
|
</tr>
|
||||||
## <div class="pin">$white.egf</div>
|
|
||||||
</div>
|
|
||||||
<div class="equal">½-½</div>
|
|
||||||
<div class="black player">
|
|
||||||
<div class="color">Black</div>
|
|
||||||
<div class="name">$black.name $!black.firstname #rank($black.rank)<br/>#if($black.country)($black.country.toUpperCase()#if($black.club), $black.club#end)#end</div>
|
|
||||||
## <div class="pin">$black.egf</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="signatures">
|
|
||||||
<div class="signature">Signature:</div>
|
|
||||||
<div class="equal"> </div>
|
|
||||||
<div class="signature">Signature:</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
#if($foreach.index % 4 == 3)
|
|
||||||
</div>
|
|
||||||
#end
|
|
||||||
#end
|
#end
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
onLoad(() => {
|
onLoad(() => {
|
||||||
|
|||||||
@@ -69,21 +69,30 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div id="paired" class="multi-select" title="white vs. black">
|
<div id="paired" class="multi-select" title="#if($blackFirst)black vs. white#{else}white vs. black#end">##
|
||||||
#foreach($game in $games)
|
#foreach($game in $games)
|
||||||
#set($white = $pmap[$game.w])
|
#set($white = $pmap[$game.w])
|
||||||
#set($black = $pmap[$game.b])
|
#set($black = $pmap[$game.b])
|
||||||
<div class="listitem game" data-id="$game.id">
|
<div class="listitem game" data-id="$game.id">
|
||||||
<div class="table" data-value="$game.t">${game.t}.</div>
|
<div class="table" data-value="$game.t">${game.t}.</div>
|
||||||
|
#if($blackFirst)
|
||||||
|
<div class="black" data-id="$game.b">#if($black)$black.name#if($black.firstname) $black.firstname#end#{else}BIP#end</div>
|
||||||
|
<div class="levels">#if($black)#rank($black.rank)#end / #if($white)#rank($white.rank)#end</div>
|
||||||
|
<div class="white" data-id="$game.w">#if($white)$white.name#if($white.firstname) $white.firstname#end#{else}BIP#end</div>
|
||||||
|
#else
|
||||||
<div class="white" data-id="$game.w">#if($white)$white.name#if($white.firstname) $white.firstname#end#{else}BIP#end</div>
|
<div class="white" data-id="$game.w">#if($white)$white.name#if($white.firstname) $white.firstname#end#{else}BIP#end</div>
|
||||||
<div class="levels">#if($white)#rank($white.rank)#end / #if($black)#rank($black.rank)#end</div>
|
<div class="levels">#if($white)#rank($white.rank)#end / #if($black)#rank($black.rank)#end</div>
|
||||||
<div class="black" data-id="$game.b">#if($black)$black.name#if($black.firstname) $black.firstname#end#{else}BIP#end</div>
|
<div class="black" data-id="$game.b">#if($black)$black.name#if($black.firstname) $black.firstname#end#{else}BIP#end</div>
|
||||||
|
#end
|
||||||
<div class="handicap" data-value="$game.h">#if($game.h)h$game.h#{else} #end</div>
|
<div class="handicap" data-value="$game.h">#if($game.h)h$game.h#{else} #end</div>
|
||||||
</div>
|
</div>
|
||||||
#end
|
#end##
|
||||||
</div>
|
#* *#</div>
|
||||||
#if(!$tour.type.startsWith('TEAM'))
|
#if(!$tour.type.startsWith('TEAM'))
|
||||||
<div class="result-sheets"><a href="result-sheets?id=${tour.id}&round=${round}" target="_blank" class="ui mini floating icon button">result sheets <i class="fa fa-external-link"></i></a></div>
|
<div class="pairing-post-actions">
|
||||||
|
<a href="result-sheets?id=${tour.id}&round=${round}" target="_blank" class="ui mini floating icon button">result sheets <i class="fa fa-external-link"></i></a>
|
||||||
|
<a href="explain?id=${tour.id}&round=${round}" target="_blank" class="ui mini floating icon button">explain pairing <i class="fa fa-external-link"></i></a>
|
||||||
|
</div>
|
||||||
#end
|
#end
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,8 +104,13 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Tbl</th>
|
<th>Tbl</th>
|
||||||
|
#if($blackFirst)
|
||||||
|
<th>Black</th>
|
||||||
|
<th>White</th>
|
||||||
|
#else
|
||||||
<th>White</th>
|
<th>White</th>
|
||||||
<th>Black</th>
|
<th>Black</th>
|
||||||
|
#end
|
||||||
<th>Hd</th>
|
<th>Hd</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -106,8 +120,13 @@
|
|||||||
#set($black = $pmap[$game.b])
|
#set($black = $pmap[$game.b])
|
||||||
<tr>
|
<tr>
|
||||||
<td class="t" data-table="${game.t}">${game.t}</td>
|
<td class="t" data-table="${game.t}">${game.t}</td>
|
||||||
|
#if($blackFirst)
|
||||||
|
<td class="left">#if($black)${black.name} $!{black.firstname} (#rank($black.rank) $!black.country $!black.club)#{else}BIP#end</td>
|
||||||
|
<td class="left">#if($white)${white.name} $!{white.firstname} (#rank($white.rank) $!white.country $!white.club)#{else}BIP#end</td>
|
||||||
|
#else
|
||||||
<td class="left">#if($white)${white.name} $!{white.firstname} (#rank($white.rank) $!white.country $!white.club)#{else}BIP#end</td>
|
<td class="left">#if($white)${white.name} $!{white.firstname} (#rank($white.rank) $!white.country $!white.club)#{else}BIP#end</td>
|
||||||
<td class="left">#if($black)${black.name} $!{black.firstname} (#rank($black.rank) $!black.country $!black.club)#{else}BIP#end</td>
|
<td class="left">#if($black)${black.name} $!{black.firstname} (#rank($black.rank) $!black.country $!black.club)#{else}BIP#end</td>
|
||||||
|
#end
|
||||||
<td>${game.h}</td>
|
<td>${game.h}</td>
|
||||||
</tr>
|
</tr>
|
||||||
#end
|
#end
|
||||||
|
|||||||
@@ -142,6 +142,12 @@
|
|||||||
rather than pairing players of the same club.
|
rather than pairing players of the same club.
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="avoidSameFamily" value="true" #if($tour.pairing.geo.avoidSameFamily) checked #end/>
|
||||||
|
avoid pairing players from the same club with the same family name
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="title"><i class="dropdown icon"></i>Handicap parameters</div>
|
<div class="title"><i class="dropdown icon"></i>Handicap parameters</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
|||||||
@@ -100,11 +100,12 @@
|
|||||||
#end
|
#end
|
||||||
#if($tour.type != 'INDIVIDUAL')
|
#if($tour.type != 'INDIVIDUAL')
|
||||||
#set($teamId = $tmap[$part.id])
|
#set($teamId = $tmap[$part.id])
|
||||||
|
#set($teamName = false)
|
||||||
#if($teamId)
|
#if($teamId)
|
||||||
#set($teamName = $!pmap[$teamId].name)
|
#set($teamName = $!pmap[$teamId].name)
|
||||||
#end
|
#end
|
||||||
<td title="$esc.html($teamName)">
|
<td title="#if($teamName)$esc.html($teamName)#end">
|
||||||
$esc.html($utils.truncate($!teamName, 10))
|
#if($teamName)$esc.html($utils.truncate($teamName, 10))#end
|
||||||
</td>
|
</td>
|
||||||
#end
|
#end
|
||||||
<td class="participating" data-sort="#if($part.skip)$part.skip.size()/part.skip#{else}0#end">
|
<td class="participating" data-sort="#if($part.skip)$part.skip.size()/part.skip#{else}0#end">
|
||||||
|
|||||||
@@ -18,23 +18,39 @@
|
|||||||
<table id="results-table" class="ui celled striped table">
|
<table id="results-table" class="ui celled striped table">
|
||||||
<thead class="centered">
|
<thead class="centered">
|
||||||
<th data-sort-method="number">table</th>
|
<th data-sort-method="number">table</th>
|
||||||
|
#if($blackFirst)
|
||||||
|
<th>black</th>
|
||||||
|
<th>white</th>
|
||||||
|
#else
|
||||||
<th>white</th>
|
<th>white</th>
|
||||||
<th>black</th>
|
<th>black</th>
|
||||||
|
#end
|
||||||
<th>hd</th>
|
<th>hd</th>
|
||||||
<th>result</th>
|
<th>result</th>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
#set($dispRst = {'?':'?', 'w':'1-0', 'b':'0-1', '=':'½-½', 'X':'X', '#':'1-1', '0':'0-0'})
|
#set($dispRst = {'?':'?', 'w':'1-0', 'b':'0-1', '=':'½-½', 'X':'X', '#':'1-1', '0':'0-0'})
|
||||||
|
#set($dispRstInv = {'?':'?', 'w':'0-1', 'b':'1-0', '=':'½-½', 'X':'X', '#':'1-1', '0':'0-0'})
|
||||||
|
## For PAIRGO/RENGO, games are team games - use pmap (teams). For others, use plmap (players).
|
||||||
|
#set($resultsMap = $plmap)
|
||||||
|
#if($tour.type == 'PAIRGO' || $tour.type.startsWith('RENGO'))
|
||||||
|
#set($resultsMap = $pmap)
|
||||||
|
#end
|
||||||
#foreach($game in $individualGames)
|
#foreach($game in $individualGames)
|
||||||
#set($white = $plmap[$game.w])
|
#set($white = $resultsMap[$game.w])
|
||||||
#set($black = $plmap[$game.b])
|
#set($black = $resultsMap[$game.b])
|
||||||
#if($black && $white)
|
#if($black && $white)
|
||||||
<tr id="result-$game.id" data-id="$game.id">
|
<tr id="result-$game.id" data-id="$game.id">
|
||||||
<td data-sort="$game.t">${game.t}.</td>
|
<td data-sort="$game.t">${game.t}.</td>
|
||||||
|
#if($blackFirst)
|
||||||
|
<td class="black player #if($game.r == 'b' || $game.r == '#') winner #elseif($game.r == 'w' || $game.r == '0') looser #end" data-id="$black.id" data-sort="$black.name#if($black.firstname) $black.firstname#end"><span>#if($black)$black.name#if($black.firstname) $black.firstname#end #rank($black.rank)#{else}BIP#end</span></td>
|
||||||
|
<td class="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="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>
|
<td class="black player #if($game.r == 'b' || $game.r == '#') winner #elseif($game.r == 'w' || $game.r == '0') looser #end" data-id="$black.id" data-sort="$black.name#if($black.firstname) $black.firstname#end"><span>#if($black)$black.name#if($black.firstname) $black.firstname#end #rank($black.rank)#{else}BIP#end</span></td>
|
||||||
|
#end
|
||||||
<td class="handicap centered">$!game.h</td>
|
<td class="handicap centered">$!game.h</td>
|
||||||
<td class="result centered" data-sort="$game.r" data-result="$game.r">$dispRst[$game.r]</td>
|
<td class="result centered" data-sort="$game.r" data-result="$game.r">#if($blackFirst)$dispRstInv[$game.r]#{else}$dispRst[$game.r]#end</td>
|
||||||
</tr>
|
</tr>
|
||||||
#end
|
#end
|
||||||
#end
|
#end
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
#if($tour.type.startsWith('TEAM'))
|
||||||
|
<div class="strong">Team Standings</div>
|
||||||
|
#end
|
||||||
<div id="standings-container" class="roundbox">
|
<div id="standings-container" class="roundbox">
|
||||||
#set($standings = $api.get("tour/${params.id}/standings/$round"))
|
#set($standings = $api.get("tour/${params.id}/standings/$round"))
|
||||||
#if($standings.isObject() && ($standings.error || $standings.message))
|
#if($standings.isObject() && ($standings.error || $standings.message))
|
||||||
@@ -105,6 +108,78 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
#if($tour.type.startsWith('TEAM'))
|
||||||
|
<div class="strong">Individual Standings</div>
|
||||||
|
<div id="individual-standings-container" class="roundbox">
|
||||||
|
#set($indvstandings = $api.get("tour/${params.id}/standings/$round?individual_standings=true"))
|
||||||
|
#if($indvstandings.isObject() && ($indvstandings.error || $indvstandings.message))
|
||||||
|
#if($indvstandings.error)
|
||||||
|
#set($error = $indvstandings.error)
|
||||||
|
#else
|
||||||
|
#set($error = $indvstandings.message)
|
||||||
|
#end
|
||||||
|
<script type="text/javascript">
|
||||||
|
onLoad(() => {
|
||||||
|
showError("$error")
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
#set($indvstandings = [])
|
||||||
|
#end
|
||||||
|
#set($indvsmap = {})
|
||||||
|
#foreach($part in $indvstandings)
|
||||||
|
#set($indvsmap[$part.num] = $part)
|
||||||
|
#end
|
||||||
|
<table id="individual-standings-table" class="ui striped table">
|
||||||
|
<thead>
|
||||||
|
<th>Num</th>
|
||||||
|
<th>Plc</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Rank</th>
|
||||||
|
<th>Ctr</th>
|
||||||
|
<th>Nbw</th>
|
||||||
|
#foreach($r in [1..$round])
|
||||||
|
<th>R$r</th>
|
||||||
|
#end
|
||||||
|
#set($indvcriteres = ['NBW'])
|
||||||
|
#foreach($crit in $indvcriteres)
|
||||||
|
<th>$crit</th>
|
||||||
|
#end
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
#foreach($part in $indvstandings)
|
||||||
|
<tr data-id="$part.id">
|
||||||
|
<td>$part.num</td>
|
||||||
|
<td>$part.place</td>
|
||||||
|
<td>$esc.html($part.name)#if($part.firstname) $esc.html($part.firstname)#end</td>
|
||||||
|
<td data-sort="$part.rank">#rank($part.rank)</td>
|
||||||
|
<td>#if($part.country)$part.country#end</td>
|
||||||
|
<td>$number.format('0.#', $part.NBW)</td>
|
||||||
|
#set($mx = $round - 1)
|
||||||
|
#foreach($r in [0..$mx])
|
||||||
|
#set($rst = $part.results[$r])
|
||||||
|
#set($opp_num = $math.toLong($rst))
|
||||||
|
#if($opp_num)
|
||||||
|
#set($opponent = $!indvsmap[$opp_num])
|
||||||
|
#else
|
||||||
|
#set($opponent = false)
|
||||||
|
#end
|
||||||
|
#if($rst.contains('+'))
|
||||||
|
#set($rst = "<b>$rst</b>")
|
||||||
|
#elseif($rst.contains('-'))
|
||||||
|
#set($rst = "<i>$rst</i>")
|
||||||
|
#end
|
||||||
|
<td class="nobreak game-result" #if($opponent)title="$esc.html($opponent.name)#if($opponent.firstname) $esc.html($opponent.firstname)#end #rank($opponent.rank)#if($opponent.country) $opponent.country#end"#end>$rst</td>
|
||||||
|
#end
|
||||||
|
#foreach($crit in $indvcriteres)
|
||||||
|
#set($value = "$number.format('0.#', $part[$crit])")
|
||||||
|
<td data-sort="$value">$value.replace('.5', '½')</td>
|
||||||
|
#end
|
||||||
|
</tr>
|
||||||
|
#end
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
#end
|
||||||
<div class="right form-actions">
|
<div class="right form-actions">
|
||||||
#if(!$tour.frozen && $round == $tour.rounds)
|
#if(!$tour.frozen && $round == $tour.rounds)
|
||||||
<button id="freeze" class="ui orange floating right labeled icon button">
|
<button id="freeze" class="ui orange floating right labeled icon button">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.jeudego.pairgoth</groupId>
|
<groupId>org.jeudego.pairgoth</groupId>
|
||||||
<artifactId>engine-parent</artifactId>
|
<artifactId>engine-parent</artifactId>
|
||||||
<version>0.20</version>
|
<version>0.23</version>
|
||||||
</parent>
|
</parent>
|
||||||
<artifactId>webserver</artifactId>
|
<artifactId>webserver</artifactId>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|||||||
Reference in New Issue
Block a user