Compare commits

...

33 Commits

Author SHA1 Message Date
4fc92cba82 tmp 2025-12-10 22:51:23 +01:00
Claude Brisson
4a4474873e Bump to 0.23 2025-11-30 11:25:46 +01:00
Claude Brisson
dd95c48f0d Fix results display for PAIRGO and RENGO tournaments
For PAIRGO/RENGO, games are team games (w/b are team IDs), not
individual player games. Use pmap (teams map) instead of plmap
(players map) to look up participants.
2025-11-30 11:24:34 +01:00
Claude Brisson
9a379052e5 Add user preference for black vs white display order
- Gear icon in header opens settings modal
- Preference stored in cookie for server-side Velocity rendering
- ViewServlet reads blackFirst cookie into Velocity context
- Velocity conditionals in pairing, results, and result-sheets templates
2025-11-30 10:54:52 +01:00
Claude Brisson
17697845fd Implement avoidSameFamily geographic criterion
When enabled, avoid pairing players from the same club who have
the same family name (surname). Uses existing player.name field.
2025-11-29 22:00:31 +01:00
Claude Brisson
147347fa6e Bump to 0.22 2025-11-29 21:36:52 +01:00
Claude Brisson
617f715923 Fix null teamName in team tournament registration view
Handle case where player is registered but not yet assigned to a team.
2025-11-29 21:31:48 +01:00
Claude Brisson
254bf6893f Local club: half bonus for stranger-vs-stranger different clubs
When local club exists, prefer local-stranger pairings over stranger-stranger:
- Ist vs Ist: full bonus
- Ist vs non-Ist: full bonus
- non-Ist vs non-Ist (different clubs): half bonus
- non-Ist vs non-Ist (same club): no bonus
2025-11-29 18:51:05 +01:00
Claude Brisson
a256b9ad1f Merge feature/local-club-pairing: nuanced local club geographic criteria 2025-11-29 18:14:14 +01:00
Claude Brisson
174b3adb53 Refactor local club geographic criteria with nuanced bonuses
- Fix bug: biggestCountrySize used club instead of country
- Add local club detection (>40% threshold)
- When local club exists (non-legacy mode):
  * Local club members paired together: get FULL different-club bonus
  * Ist vs non-Ist (different clubs): normal bonus
  * Strangers from same visiting club: no bonus (normal same-club)
- Legacy mode unchanged for test compatibility
- Add LocalClubTest for local club behavior verification
2025-11-29 18:13:00 +01:00
Claude Brisson
4788ef7bc9 Add EGF PIN to h9 export
Append EGF PIN at end of player line with | separator (e.g. |12345678)
when the player has a valid 8-character EGF PIN
2025-11-29 16:57:33 +01:00
Claude Brisson
a6881d1276 Add MacMahon 3.9 import support
- Add MacMahon39.kt parser for MM39 tournament format
- Auto-detect MM39 format in tournament import
- Import players, games, bye players, and tournament parameters
- Uses default values for time system and director since MM39 lacks those
2025-11-29 16:52:05 +01:00
Claude Brisson
e063f6c73c Bump to version 0.21 2025-11-29 13:49:20 +01:00
Claude Brisson
576be99952 FFG export: treat zero byoyomi/increment as sudden death
When byoyomi=0 (Japanese/Canadian) or increment=0 (Fischer),
display "Sudden death" instead of absurd "3 periods of 0 minutes"
2025-11-29 13:36:32 +01:00
Claude Brisson
4daa707f3e Normalize country code to UK instead of GB
- CountriesTool: use 'uk' key for United Kingdom
- EGFRatingsHandler: keep UK as-is (no conversion to GB)
- Pairable: convert GB to UK (inverse of previous behavior)
2025-11-29 12:30:15 +01:00
Claude Brisson
cbadb4d6bb Fix race condition in player search index synchronization
players array and index are now updated atomically under write lock.
Index is rebuilt every time since it stores array indices into players.
License status propagation also moved inside write lock.
2025-11-29 12:02:36 +01:00
Claude Brisson
67d8428b85 Fix ratings fetch: don't request brotli compression
OkHttp doesn't support brotli decompression. When we explicitly set
Accept-Encoding header, OkHttp disables its transparent decompression.

Solution: remove explicit Accept-Encoding header and let OkHttp handle
compression automatically (it adds gzip and transparently decompresses).

Also simplified the request headers (removed unused Sec-Fetch-* headers).
2025-11-29 11:23:50 +01:00
Claude Brisson
72f5fe540c Revert "Fix ratings fetch: remove brotli Accept-Encoding header"
This reverts commit 935f53cf65.
2025-11-29 11:10:33 +01:00
Claude Brisson
935f53cf65 Fix ratings fetch: remove brotli Accept-Encoding header
OkHttp doesn't support brotli decompression, so when servers return
brotli-compressed content, it was being read as raw binary garbage.
Removed explicit Accept-Encoding to let OkHttp handle compression
(it automatically adds gzip/deflate which it can decompress).

Also removed unnecessary Sec-Fetch-* headers.
2025-11-29 11:08:48 +01:00
Claude Brisson
09c8e834f6 Fix FFG/EGF export format issues
- FFG .tou name field: use AAMMJJ-ville format (2-digit year, hyphen,
  lowercase city without accents), e.g., "250830-marseille"
- EGF .h country code: use uppercase PC[FR,...] instead of PC[fr,...]
- Unify version numbers: use actual version from properties everywhere
  instead of hardcoded "v0.1"
2025-11-29 10:54:40 +01:00
Claude Brisson
667b3e17da Fix EGF/FFG export name case: Title_Case instead of UPPERCASE
toCapitals() properly capitalizes each word part (e.g., "ACIKGOZ"
-> "Acikgoz", "alen ibrahim" -> "Alen_Ibrahim").

Format: "Last_Name First_Name" with underscores joining multi-part names.
2025-11-29 10:34:06 +01:00
Claude Brisson
4113d76904 Fix stale Lucene reader after ratings index rebuild
The reader and searcher were lazily initialized once, so after
rebuild() created a new index, searches still used the old reader
pointing to outdated document IDs. When those IDs were used to
index into the new players array, wrong entries were returned.

Now reader/searcher are refreshed after each build().
2025-11-29 10:21:31 +01:00
Claude Brisson
662f438cee Update documentation for API, configuration, and model
API.md:
- Add export formats (JSON, XML, EGF, FFG, CSV)
- Document /explain endpoint for pairing analysis
- Add PUT /standings for freezing
- Improve parameter documentation
- Fix typos (regitration -> registration, #tip -> #tid)

configuration.md:
- Add property loading hierarchy
- Document SSL/TLS configuration
- Add OAuth provider configuration
- Add ratings.path property
- Include example configurations

model.md:
- Complete entity diagram with Team, external IDs
- Document all tournament types (PAIRGO, RENGO, TEAM)
- Add TimeSystem types and parameters
- Document all pairing parameters
- List all 39 tiebreak criteria
- Add external database (AGA, EGF, FFG) documentation
2025-11-29 08:05:53 +01:00
Claude Brisson
8ca25ec421 Use nice HTTP headers when querying ratings 2025-11-28 15:35:10 +01:00
Claude Brisson
f2059f7943 Add missing dependency to standalone 2025-08-16 12:21:33 +02:00
Claude Brisson
0cc34a1f84 Do not display pairing action buttons when pairing is empty 2025-07-25 05:19:19 +02:00
Claude Brisson
c3cb5826a3 Explain: fix a color inversion in heat map 2025-07-25 04:59:58 +02:00
Claude Brisson
84ab78c461 Bugfixing explain 2025-07-24 20:38:49 +02:00
Claude Brisson
d47d4fc8cc Beta version of explain page 2025-07-24 19:45:13 +02:00
Claude Brisson
3d06588889 Use a PairingListener class to collect or print weights, avoid computing twice the weights during tests 2025-07-24 15:05:51 +02:00
Claude Brisson
f704f3adb2 Code cleaning: fix previous commit, simplify HistoryHelper creation 2025-07-24 14:14:03 +02:00
Claude Brisson
ecec6556d1 Code cleaning: move history helper creation in tournament class, factorize main score function 2025-07-22 19:08:29 +02:00
Claude Brisson
17bb013feb Display individual standings below team standings 2025-06-11 11:03:58 +02:00
58 changed files with 2634 additions and 599 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.claude
target
/docker/data
/.idea
@@ -8,3 +9,6 @@ target
*.iml
*~
pairgoth.db
ratings
pairgoth
pairgoth.tar.gz

179
CLAUDE.md Normal file
View 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

View File

@@ -1,3 +1,5 @@
# This is largely a mirror of the original, adapted for the Slovenian Go Association.
# Pairgoth
Welcome to Pairgoth, your Go Pairing Engine!

View File

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

View File

@@ -7,61 +7,23 @@ import org.jeudego.pairgoth.model.MacMahon
import org.jeudego.pairgoth.model.Pairable
import org.jeudego.pairgoth.model.PairingType
import org.jeudego.pairgoth.model.Player
import org.jeudego.pairgoth.model.TeamTournament
import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.model.getID
import org.jeudego.pairgoth.model.historyBefore
import org.jeudego.pairgoth.pairing.HistoryHelper
import org.jeudego.pairgoth.pairing.solver.MacMahonSolver
import kotlin.math.floor
import kotlin.math.max
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 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) {
return ArrayList(frozen!!.map { it -> it as Json.Object })
}
// CB TODO - factorize history helper creation between here and solver classes
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 history = historyHelper(round + 1)
val neededCriteria = ArrayList(pairing.placementParams.criteria)
if (!neededCriteria.contains(Criterion.NBW)) neededCriteria.add(Criterion.NBW)
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.RANK -> pairables.mapValues { it.value.rank }
Criterion.RATING -> pairables.mapValues { it.value.rating }
Criterion.NBW -> historyHelper.wins
Criterion.MMS -> historyHelper.mms
Criterion.SCOREX -> historyHelper.scoresX
Criterion.NBW -> history.wins
Criterion.MMS -> history.mms
Criterion.SCOREX -> history.scoresX
Criterion.STS -> StandingsHandler.nullMap
Criterion.CPS -> StandingsHandler.nullMap
Criterion.SOSW -> historyHelper.sos
Criterion.SOSWM1 -> historyHelper.sosm1
Criterion.SOSWM2 -> historyHelper.sosm2
Criterion.SODOSW -> historyHelper.sodos
Criterion.SOSOSW -> historyHelper.sosos
Criterion.CUSSW -> if (round == 0) StandingsHandler.nullMap else historyHelper.cumScore
Criterion.SOSM -> historyHelper.sos
Criterion.SOSMM1 -> historyHelper.sosm1
Criterion.SOSMM2 -> historyHelper.sosm2
Criterion.SODOSM -> historyHelper.sodos
Criterion.SOSOSM -> historyHelper.sosos
Criterion.CUSSM -> historyHelper.cumScore
Criterion.SOSW -> history.sos
Criterion.SOSWM1 -> history.sosm1
Criterion.SOSWM2 -> history.sosm2
Criterion.SODOSW -> history.sodos
Criterion.SOSOSW -> history.sosos
Criterion.CUSSW -> if (round == 0) StandingsHandler.nullMap else history.cumScore
Criterion.SOSM -> history.sos
Criterion.SOSMM1 -> history.sosm1
Criterion.SOSMM2 -> history.sosm2
Criterion.SODOSM -> history.sodos
Criterion.SOSOSM -> history.sosos
Criterion.CUSSM -> history.cumScore
Criterion.SOSTS -> StandingsHandler.nullMap
@@ -100,14 +62,14 @@ fun Tournament<*>.getSortedPairables(round: Int, includePreliminary: Boolean = f
Criterion.DC -> StandingsHandler.nullMap
}
}
val pairables = pairables.values.filter { includePreliminary || it.final }.map { it.toDetailedJson() }
pairables.forEach { player ->
val jsonPairables = pairables.values.filter { includePreliminary || it.final }.map { it.toDetailedJson() }
jsonPairables.forEach { player ->
for (crit in criteria) {
player[crit.first] = crit.second[player.getID()] ?: 0.0
}
player["results"] = Json.MutableArray(List(round) { "0=" })
}
val sortedPairables = pairables.sortedWith { left, right ->
val sortedPairables = jsonPairables.sortedWith { left, right ->
for (crit in criteria) {
val lval = left.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
}
fun Tournament<*>.populateStandings(sortedPairables: List<Json.Object>, round: Int = rounds) {
val sortedMap = sortedPairables.associateBy {
fun Tournament<*>.populateStandings(sortedEntries: List<Json.Object>, round: Int = rounds, individualStandings: Boolean) {
val sortedMap = sortedEntries.associateBy {
it.getID()!!
}
// refresh name, firstname, club and level
val refMap = if (individualStandings) players else pairables
sortedMap.forEach { (id, pairable) ->
val mutable = pairable as Json.MutableObject
pairables[id]?.let {
refMap[id]?.let {
mutable["name"] = it.name
if (it is Player) {
mutable["firstname"] = it.firstname
@@ -150,7 +113,8 @@ fun Tournament<*>.populateStandings(sortedPairables: List<Json.Object>, round: I
// fill result
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 black = if (game.black != 0) sortedMap[game.black] else null
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
}

View File

@@ -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
}
}

View File

@@ -13,7 +13,10 @@ import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.model.getID
import org.jeudego.pairgoth.model.toID
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.pairing.solver.LoggingListener
import org.jeudego.pairgoth.server.Event.*
import java.io.FileWriter
import java.io.PrintWriter
import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
@@ -67,7 +70,15 @@ object PairingHandler: PairgothApiHandler {
if (playing.contains(it.id)) badRequest("pairable #$id already plays round $round")
} ?: 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()
tournament.dispatchEvent(GamesAdded, request, Json.Object("round" to round, "games" to ret))

View File

@@ -6,6 +6,7 @@ import org.jeudego.pairgoth.model.Criterion
import org.jeudego.pairgoth.model.Criterion.*
import org.jeudego.pairgoth.model.ID
import org.jeudego.pairgoth.model.PairingType
import org.jeudego.pairgoth.model.TeamTournament
import org.jeudego.pairgoth.model.Tournament
import org.jeudego.pairgoth.model.adjustedTime
import org.jeudego.pairgoth.model.displayRank
@@ -27,10 +28,18 @@ object StandingsHandler: PairgothApiHandler {
override fun get(request: HttpServletRequest, response: HttpServletResponse): Json? {
val tournament = getTournament(request)
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)
tournament.populateStandings(sortedPairables, round)
val individualStandings = tournament is TeamTournament &&
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 accept = acceptHeader?.substringBefore(";")
@@ -44,7 +53,7 @@ object StandingsHandler: PairgothApiHandler {
PrintWriter(OutputStreamWriter(response.outputStream, encoding))
}
return when (accept) {
"application/json" -> sortedPairables.toJsonArray()
"application/json" -> sortedEntries.toJsonArray()
"application/egf" -> {
response.contentType = "text/plain;charset=${encoding}"
val neededCriteria = ArrayList(tournament.pairing.placementParams.criteria)
@@ -52,19 +61,19 @@ object StandingsHandler: PairgothApiHandler {
if (neededCriteria.first() == SCOREX) {
neededCriteria.add(1, MMS)
}
exportToEGFFormat(tournament, sortedPairables, neededCriteria, writer)
exportToEGFFormat(tournament, sortedEntries, neededCriteria, writer)
writer.flush()
return null
}
"application/ffg" -> {
response.contentType = "text/plain;charset=${encoding}"
exportToFFGFormat(tournament, sortedPairables, writer)
exportToFFGFormat(tournament, sortedEntries, writer)
writer.flush()
return null
}
"text/csv" -> {
response.contentType = "text/csv;charset=${encoding}"
exportToCSVFormat(tournament, sortedPairables, writer)
exportToCSVFormat(tournament, sortedEntries, writer)
writer.flush()
return null
}
@@ -105,7 +114,7 @@ object StandingsHandler: PairgothApiHandler {
"""
; CL[${egfClass}]
; EV[${tournament.name}]
; PC[${tournament.country.lowercase()},${tournament.location}]
; PC[${tournament.country.uppercase()},${tournament.location}]
; DT[${tournament.startDate},${tournament.endDate}]
; HA[${
if (tournament.pairing.type == PairingType.MAC_MAHON) "h${tournament.pairing.pairingParams.handicap.correction}"
@@ -114,7 +123,7 @@ object StandingsHandler: PairgothApiHandler {
}]
; KM[${tournament.komi}]
; TM[${tournament.timeSystem.adjustedTime() / 60}]
; CM[Generated by Pairgoth v0.1]
; CM[Generated by Pairgoth ${WebappManager.properties.getProperty("version")}]
;
; Pl Name Rk Co Club ${ criteria.map { it.name.replace(Regex("(S?)O?(SOS|DOS)[MW]?"), "$1$2").padStart(7, ' ') }.joinToString(" ") }
${
@@ -123,23 +132,24 @@ ${
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)
} ${
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
} ${
player.getString("country")?.uppercase() ?: ""
} ${
(player.getString("club") ?: "").toSnake().padStart(4).take(4)
(player.getString("club") ?: "").toCapitals().padStart(4).take(4)
} ${
criteria.joinToString(" ") { numFormat.format(player.getDouble(it.name)!!).let { if (it.contains('.')) it else "$it " }.padStart(7, ' ') }
} ${
player.getArray("results")!!.map {
(it as String).padStart(8, ' ')
}.joinToString(" ")
}${
player.getString("egf")?.let { if (it.length == 8) " |$it" else "" } ?: ""
}"
}
}
@@ -147,14 +157,12 @@ ${
writer.println(ret)
}
private fun String.toSnake(upper: Boolean = false): String {
private fun String.toCapitals(): String {
val sanitized = sanitizeISO()
val parts = sanitized.trim().split(Regex("(?:\\s|\\xA0)+"))
val snake = parts.joinToString("_") { part ->
if (upper) part.uppercase(Locale.ROOT)
else part.capitalize()
return parts.joinToString("_") { part ->
part.lowercase(Locale.ROOT).replaceFirstChar { it.titlecase(Locale.ROOT) }
}
return snake
}
private fun String.sanitizeISO(): String {
@@ -167,12 +175,13 @@ ${
private fun exportToFFGFormat(tournament: Tournament<*>, lines: List<Json.Object>, writer: PrintWriter) {
val version = WebappManager.properties.getProperty("version")!!
val ffgName = "${ffgDate.format(tournament.startDate)}-${tournament.location.lowercase(Locale.ROOT).sanitizeISO()}"
val ret =
""";name=${tournament.shortName}
""";name=$ffgName
;date=${frDate.format(tournament.startDate)}
;vill=${tournament.location}${if (tournament.online) "(online)" else ""}
;comm=${tournament.name}
;prog=Pairgoth v0.1
;prog=Pairgoth $version
;time=${tournament.timeSystem.mainTime / 60}
;ta=${tournament.timeSystem.adjustedTime() / 60}
;size=${tournament.gobanSize}
@@ -180,9 +189,12 @@ ${
; Generated by Pairgoth $version
; ${
when (tournament.timeSystem.type) {
CANADIAN -> "Canadian ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.stones} stones / ${tournament.timeSystem.byoyomi / 60} minutes"
JAPANESE -> "Japanese ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.periods} periods of ${tournament.timeSystem.byoyomi / 60} minutes"
FISCHER -> "Fisher ${tournament.timeSystem.mainTime / 60} minutes + ${tournament.timeSystem.increment} seconds${ if (tournament.timeSystem.maxTime > 0) ", max ${tournament.timeSystem.maxTime / 60} minutes" else "" }"
CANADIAN -> if (tournament.timeSystem.byoyomi > 0) "Canadian ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.stones} stones / ${tournament.timeSystem.byoyomi / 60} minutes"
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
JAPANESE -> if (tournament.timeSystem.byoyomi > 0) "Japanese ${tournament.timeSystem.mainTime / 60} minutes, ${tournament.timeSystem.periods} periods of ${tournament.timeSystem.byoyomi / 60} minutes"
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
FISCHER -> if (tournament.timeSystem.increment > 0) "Fisher ${tournament.timeSystem.mainTime / 60} minutes + ${tournament.timeSystem.increment} seconds${ if (tournament.timeSystem.maxTime > 0) ", max ${tournament.timeSystem.maxTime / 60} minutes" else "" }"
else "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
SUDDEN_DEATH -> "Sudden death ${tournament.timeSystem.mainTime / 60} minutes"
}
}
@@ -193,14 +205,14 @@ ${
"${
player.getString("num")!!.padStart(4, ' ')
} ${
"${player.getString("name")?.toSnake(true)} ${player.getString("firstname")?.toSnake() ?: ""}".padEnd(24, ' ').take(24)
"${player.getString("name")?.toCapitals()} ${player.getString("firstname")?.toCapitals() ?: ""}".padEnd(24, ' ').take(24)
} ${
displayRank(player.getInt("rank")!!).uppercase().padStart(3, ' ')
} ${
player.getString("ffg") ?: " "
} ${
if (player.getString("country") == "FR")
(player.getString("club") ?: "").toSnake().padEnd(4).take(4)
(player.getString("club") ?: "").toCapitals().padEnd(4).take(4)
else
(player.getString("country") ?: "").padEnd(4).take(4)
} ${
@@ -258,4 +270,5 @@ ${
private val numFormat = DecimalFormat("###0.#")
private val frDate: DateTimeFormatter = DateTimeFormatter.ofPattern("dd/MM/yyyy")
private val ffgDate: DateTimeFormatter = DateTimeFormatter.ofPattern("yyMMdd")
}

View File

@@ -5,6 +5,7 @@ import com.republicate.kson.toJsonObject
import com.republicate.kson.toMutableJsonObject
import org.jeudego.pairgoth.api.ApiHandler.Companion.PAYLOAD_KEY
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.ext.MacMahon39
import org.jeudego.pairgoth.ext.OpenGotha
import org.jeudego.pairgoth.model.BaseCritParams
import org.jeudego.pairgoth.model.TeamTournament
@@ -55,7 +56,7 @@ object TournamentHandler: PairgothApiHandler {
override fun post(request: HttpServletRequest, response: HttpServletResponse): Json? {
val tournament = when (val payload = request.getAttribute(PAYLOAD_KEY)) {
is Json.Object -> Tournament.fromJson(getObjectPayload(request))
is Element -> OpenGotha.import(payload)
is Element -> if (MacMahon39.isFormat(payload)) MacMahon39.import(payload) else OpenGotha.import(payload)
else -> badRequest("missing or invalid payload")
}
tournament.recomputeDUDD()

View File

@@ -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
}
}
}

View File

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

View File

@@ -4,8 +4,10 @@ import com.republicate.kson.Json
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.MainCritParams.SeedMethod.SPLIT_AND_SLIP
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.PairingListener
import org.jeudego.pairgoth.pairing.solver.SwissSolver
import kotlin.math.min
@@ -85,6 +87,7 @@ data class GeographicalParams(
val preferMMSDiffRatherThanSameClubsGroup: Int = 2, // Typically = 2
val preferMMSDiffRatherThanSameClub: Int = 3, // Typically = 3
val proportionMainClubThreshold: Double = 0.4, // If the biggest club has a proportion of players higher than this, the secondary criterium is not applied
val avoidSameFamily: Boolean = false, // When enabled, avoid pairing players from the same club with the same family name
) {
companion object {
val disabled = GeographicalParams(avoidSameGeo = 0.0)
@@ -131,28 +134,22 @@ sealed class Pairing(
val pairingParams: PairingParams,
val placementParams: PlacementParams) {
companion object {}
abstract fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): BaseSolver
fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> {
return solver(tournament, round, pairables).pair()
internal abstract fun solver(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): Solver
internal fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>, legacyMode: Boolean = false, listener: PairingListener? = null): List<Game> {
return solver(tournament, round, pairables)
.also { solver ->
solver.legacyMode = legacyMode
listener?.let {
solver.pairingListener = listener
}
}
.pair()
}
}
internal fun Tournament<*>.historyBefore(round: Int) =
(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(
pairingParams: PairingParams = PairingParams(
base = BaseCritParams(),
@@ -175,7 +172,7 @@ class Swiss(
): Pairing(SWISS, pairingParams, placementParams) {
companion object {}
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(
@@ -203,14 +200,14 @@ class MacMahon(
): Pairing(MAC_MAHON, pairingParams, placementParams) {
companion object {}
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(
pairingParams: PairingParams = PairingParams(),
placementParams: PlacementParams = PlacementParams(Criterion.NBW, Criterion.RATING)
): 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")
}
}

View File

@@ -7,7 +7,9 @@ import com.republicate.kson.toJsonObject
//import kotlinx.datetime.LocalDate
import java.time.LocalDate
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.nextPlayerId
import org.jeudego.pairgoth.store.nextTournamentId
@@ -15,8 +17,10 @@ import org.jeudego.pairgoth.util.MutableBiMultiMap
import org.jeudego.pairgoth.util.mutableBiMultiMapOf
import kotlin.math.max
import java.util.*
import java.util.regex.Pattern
import kotlin.collections.get
import kotlin.math.floor
import kotlin.math.min
import kotlin.math.round
import kotlin.math.roundToInt
sealed class Tournament <P: Pairable>(
@@ -61,7 +65,7 @@ sealed class Tournament <P: Pairable>(
var frozen: Json.Array? = null
// 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.
// 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")
@@ -69,7 +73,7 @@ sealed class Tournament <P: Pairable>(
val evenPairables =
if (pairables.size % 2 == 0) pairables
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())
games[round - 1].putAll( newGames.associateBy { it.id } )
}
@@ -96,14 +100,17 @@ sealed class Tournament <P: Pairable>(
fun lastRound() = max(1, games.size)
/**
* Recompute DUDD for a specific game
*/
fun recomputeDUDD(round: Int, gameID: ID) {
// 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
val game = games(round)[gameID]!!
val white = solver.pairables.find { p-> p.id == game.white }!!
val black = solver.pairables.find { p-> p.id == game.black }!!
val white = pairables[game.white]!!
val black = pairables[game.black]!!
game.drawnUpDown = solver.dudd(black, white)
game.handicap = solver.hd(white = white, black = black)
}
@@ -115,17 +122,16 @@ sealed class Tournament <P: Pairable>(
fun recomputeDUDD(round: Int) {
if (pairables.isEmpty() || games(1).isEmpty()) return;
// 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) {
if (game.black != 0 && game.white != 0) {
val white = solver.pairables.find { p-> p.id == game.white }!!
val black = solver.pairables.find { p-> p.id == game.black }!!
val white = pairables[game.white]!!
val black = pairables[game.black]!!
game.drawnUpDown = solver.dudd(black, white)
}
}
}
/**
* Recompute DUDD for all rounds
*/
@@ -208,6 +214,22 @@ sealed class Tournament <P: Pairable>(
}
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
@@ -263,7 +285,7 @@ class TeamTournament(
override fun individualGames(round: Int): Map<ID, Game> {
val teamGames = games(round)
return if (type.individual) {
return teamGames.values.flatMap { game ->
teamGames.values.flatMap { game ->
if (game.white == 0 || game.black == 0 ) listOf()
else individualGames[game.id]?.toList() ?: listOf()
}.associateBy { it.id }
@@ -272,8 +294,8 @@ class TeamTournament(
}
}
override fun pair(round: Int, pairables: List<Pairable>) =
super.pair(round, pairables).also { games ->
override fun pair(round: Int, pairables: List<Pairable>, legacyMode: Boolean, listener: PairingListener?) =
super.pair(round, pairables, legacyMode, listener).also { games ->
if (type.individual) {
games.forEach { game ->
pairIndividualGames(round, game)

View File

@@ -5,26 +5,17 @@ import org.jeudego.pairgoth.model.*
abstract class BasePairingHelper(
val round: 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
val pairablesMap: Map<ID, Pairable>, // Map of all known pairables for this tournament
val pairing: PairingParams,
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
// 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
val Pairable.main: Double get() = scores[id]?.second ?: 0.0
val Pairable.main: Double get() = score ?: 0.0
abstract val mainLimits: Pair<Double, Double>
// pairables sorted using overloadable sort function
@@ -75,37 +66,55 @@ abstract class BasePairingHelper(
}
// number of players in the biggest club and the biggest country
// this can be used to disable geocost if there is a majority of players from the same country or club
// this can be used to adjust geocost if there is a majority of players from the same country or club
private val clubCounts by lazy {
pairables.groupingBy { it.club?.take(4)?.uppercase() }.eachCount()
}
protected val biggestClubSize by lazy {
pairables.groupingBy { it.club }.eachCount().values.maxOrNull()!!
clubCounts.values.maxOrNull() ?: 0
}
protected val biggestCountrySize by lazy {
pairables.groupingBy { it.club }.eachCount().values.maxOrNull()!!
pairables.groupingBy { it.country }.eachCount().values.maxOrNull() ?: 0
}
// Local club detection: a club is "local" if it has more than the threshold proportion of players
protected val localClub: String? by lazy {
val threshold = pairing.geo.proportionMainClubThreshold
clubCounts.entries.find { (_, count) ->
count.toDouble() / pairables.size > threshold
}?.key
}
protected val hasLocalClub: Boolean get() = localClub != null
// Check if a player belongs to the local club
protected fun Pairable.isFromLocalClub(): Boolean {
val local = localClub ?: return false
return club?.take(4)?.uppercase() == local
}
// already paired players map
protected fun Pairable.played(other: Pairable) = historyHelper.playedTogether(this, other)
protected fun Pairable.played(other: Pairable) = history.playedTogether(this, other)
// 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.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.nbW: Double get() = historyHelper.nbW(this) ?: 0.0
val Pairable.sos: Double get() = historyHelper.sos[id] ?: 0.0
val Pairable.sosm1: Double get() = historyHelper.sosm1[id] ?: 0.0
val Pairable.sosm2: Double get() = historyHelper.sosm2[id] ?: 0.0
val Pairable.sosos: Double get() = historyHelper.sosos[id] ?: 0.0
val Pairable.sodos: Double get() = historyHelper.sodos[id] ?: 0.0
val Pairable.cums: Double get() = historyHelper.cumScore[id] ?: 0.0
val Pairable.score: Double get() = history.scores[id] ?: 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() = history.sos[id] ?: 0.0
val Pairable.sosm1: Double get() = history.sosm1[id] ?: 0.0
val Pairable.sosm2: Double get() = history.sosm2[id] ?: 0.0
val Pairable.sosos: Double get() = history.sosos[id] ?: 0.0
val Pairable.sodos: Double get() = history.sodos[id] ?: 0.0
val Pairable.cums: Double get() = history.cumScore[id] ?: 0.0
fun Pairable.missedRounds(): Int = (1 until round).map { round ->
if (historyHelper.playersPerRound.getOrNull(round - 1)
if (history.playersPerRound.getOrNull(round - 1)
?.contains(id) == true
) 0 else 1
}.sum()

View File

@@ -2,12 +2,22 @@ package org.jeudego.pairgoth.pairing
import org.jeudego.pairgoth.model.*
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(
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>>) {
protected val history: List<List<Game>>
) {
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) {
BLACK, BOTHWIN -> 1.0
@@ -19,23 +29,10 @@ open class HistoryHelper(
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
open fun playedTogether(p1: Pairable, p2: Pairable) = paired.contains(Pair(p1.id, p2.id))
open fun colorBalance(p: Pairable) = colorBalance[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 {
(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
// Only count games without handicap
private val colorBalance: Map<ID, Int> by lazy {
val colorBalance: Map<ID, Int> by lazy {
history.flatten().filter { game ->
game.handicap == 0
}.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 {
history.map {
it.fold(mutableSetOf<ID>()) { acc, next ->
if(next.white != 0) acc.add(next.white)
if (next.black != 0) acc.add(next.black)
acc
}
history.map { roundGames ->
roundGames.flatMap {
game -> listOf(game.white, game.black)
}.filter { id ->
id != ByePlayer.id
}.toSet()
}
}
@@ -100,67 +97,89 @@ open class HistoryHelper(
}
// 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 {
// SOS for played games against a real opponent or BIP
val historySos = (history.flatten().map { game ->
Pair(
game.black,
if (game.white == 0) scores[game.black]?.first ?: 0.0
else scores[game.white]?.second?.let { it - game.handicap } ?: 0.0
if (game.white == 0) missedRoundsSos[game.black] ?: 0.0
else scores[game.white]?.let { it - game.handicap } ?: 0.0
)
} + history.flatten().map { game ->
Pair(
game.white,
if (game.black == 0) scores[game.white]?.first ?: 0.0
else scores[game.black]?.second?.let { it + game.handicap } ?: 0.0
if (game.black == 0) missedRoundsSos[game.white] ?: 0.0
else scores[game.black]?.let { it + game.handicap } ?: 0.0
)
}).groupingBy {
it.first
}.fold(0.0) { acc, next ->
acc + next.second
}
scores.mapValues { (id, pair) ->
// plus SOS for missed rounds
missedRoundsSos.mapValues { (id, pseudoSos) ->
(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
val sosm1 by lazy {
// SOS for played games against a real opponent or BIP
(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 ->
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 {
it.first
}.mapValues { (id, pairs) ->
val oppScores = pairs.map { it.second }.sortedDescending()
// minus greatest SOS
oppScores.sum() - (oppScores.firstOrNull() ?: 0.0) +
// plus SOS for missed rounds
playersPerRound.sumOf { players ->
if (players.contains(id)) 0.0
else scores[id]?.first ?: 0.0
else missedRoundsSos[id] ?: 0.0
}
}
}
// sos-2
val sosm2 by lazy {
// SOS for played games against a real opponent or BIP
(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 ->
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 {
it.first
}.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 } +
// plus SOS for missed rounds
playersPerRound.sumOf { players ->
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 ->
game.white != 0 // Remove games against byePlayer
}.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 ->
game.white != 0 // Remove games against byePlayer
}.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 ->
acc + next.second
}
}
// sosos
val sosos by lazy {
val currentRound = history.size
@@ -193,9 +213,9 @@ open class HistoryHelper(
acc + next.second
}
scores.mapValues { (id, pair) ->
missedRoundsSos.mapValues { (id, missedRoundSos) ->
(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
}
}
}
// 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() }) {
}

View File

@@ -1,7 +1,6 @@
package org.jeudego.pairgoth.pairing
import org.jeudego.pairgoth.model.Pairable
import org.jeudego.pairgoth.pairing.solver.BaseSolver
fun detRandom(max: Double, p1: Pairable, p2: Pairable, symmetric: Boolean): Double {
var inverse = false

View File

@@ -1,6 +1,7 @@
package org.jeudego.pairgoth.pairing.solver
import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.pairing.HistoryHelper
import java.util.*
import kotlin.math.max
import kotlin.math.min
@@ -8,46 +9,41 @@ import kotlin.math.roundToInt
class MacMahonSolver(round: Int,
totalRounds: Int,
history: List<List<Game>>,
history: HistoryHelper,
pairables: List<Pairable>,
pairablesMap: Map<ID, Pairable>,
allPairablesMap: Map<ID, Pairable>,
pairingParams: PairingParams,
placementParams: PlacementParams,
usedTables: BitSet,
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 {
require (mmBar > mmFloor) { "MMFloor is higher than MMBar" }
pairablesMap.mapValues {
it.value.let { pairable ->
val score = roundScore(pairable.mmBase +
override fun mainScoreMapFactory() =
allPairablesMap.mapValues { (id, pairable) ->
roundScore(pairable.mmBase +
pairable.nbW +
pairable.missedRounds() * pairingParams.main.mmsValueAbsent)
Pair(
if (pairingParams.main.sosValueAbsentUseBase) pairable.mmBase
else roundScore(pairable.mmBase + round/2),
score
)
}
}
pairable.missedRounds() * pairing.main.mmsValueAbsent)
}
override val scoresX: Map<ID, Double> by lazy {
require (mmBar > mmFloor) { "MMFloor is higher than MMBar" }
pairablesMap.mapValues {
it.value.let { pairable ->
override fun scoreXMapFactory() =
allPairablesMap.mapValues { (id, pairable) ->
roundScore(pairable.mmBase + pairable.nbW)
}
override fun missedRoundSosMapFactory() =
allPairablesMap.mapValues { (id, pairable) ->
if (pairing.main.sosValueAbsentUseBase) {
pairable.mmBase
} else {
roundScore(pairable.mmBase + round/2)
}
}
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 = 1 : 1 player is above thresholds -> apply half the weight
// playersMeetCriteria = 2 : Both players are above thresholds -> do not apply weight
@@ -70,7 +66,7 @@ class MacMahonSolver(round: Int,
|| barThresholdActive && (p2.mmBase >= mmBar - Pairable.MIN_RANK)
|| p2.mms >= rankSecThreshold - Pairable.MIN_RANK) playersMeetCriteria++
return pairing.geo.apply(p1, p2, playersMeetCriteria)
return playersMeetCriteria
}
override fun HandicapParams.pseudoRank(pairable: Pairable): Int {
@@ -84,8 +80,7 @@ class MacMahonSolver(round: Int,
// mmBase: starting Mac-Mahon score of the pairable
val Pairable.mmBase: Double get() = min(max(rank, mmFloor), mmBar) + mmsZero + mmsCorrection
// mms: current Mac-Mahon score of the pairable
val Pairable.mms: Double get() = scores[id]?.second ?: 0.0
val Pairable.scoreX: Double get() = scoresX[id] ?: 0.0
val Pairable.mms: Double get() = score
// CB TODO - configurable criteria
val mainScoreMin = mmFloor + PLA_SMMS_SCORE_MIN - Pairable.MIN_RANK

View File

@@ -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
}
}

View File

@@ -4,6 +4,7 @@ import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.model.MainCritParams.DrawUpDown
import org.jeudego.pairgoth.model.MainCritParams.SeedMethod.*
import org.jeudego.pairgoth.pairing.BasePairingHelper
import org.jeudego.pairgoth.pairing.HistoryHelper
import org.jeudego.pairgoth.pairing.detRandom
import org.jeudego.pairgoth.pairing.nonDetRandom
import org.jeudego.pairgoth.store.nextGameId
@@ -18,26 +19,48 @@ import java.text.DecimalFormat
import java.util.*
import kotlin.math.*
sealed class BaseSolver(
sealed class Solver(
round: Int,
totalRounds: Int,
history: List<List<Game>>, // History of all games played for each round
pairables: List<Pairable>, // All pairables for this round, it may include the bye player
pairablesMap: Map<ID, Pairable>, // Map of all known pairables in this tournament
history: HistoryHelper, // Digested history of all games played for each round
pairables: List<Pairable>, // Pairables to pair together
val allPairablesMap: Map<ID, Pairable>, // Map of all known pairables
pairing: PairingParams,
placement: PlacementParams,
val usedTables: BitSet
) : BasePairingHelper(round, totalRounds, history, pairables, pairablesMap, pairing, placement) {
) : BasePairingHelper(round, totalRounds, history, pairables, pairing, placement) {
companion object {
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) =
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.main.apply(p1, p2) +
pairing.secondary.apply(p1, p2)
@@ -49,11 +72,17 @@ sealed class BaseSolver(
else 0.0
}
open fun weight(p1: Pairable, p2: Pairable) =
open fun weight(p1: Pairable, p2: Pairable): Double {
pairingListener?.startPair(p1, p2)
return (
openGothaWeight(p1, p2) +
pairgothBlackWhite(p1, p2) +
pairgothBlackWhite(p1, p2).also { pairingListener?.addWeight("secColorBalance", it) } +
// pairing.base.applyByeWeight(p1, p2) +
pairing.handicap.color(p1, p2)
pairing.handicap.color(p1, p2).also { pairingListener?.addWeight("secHandi", it) }
).also {
pairingListener?.endPair(p1, p2)
}
}
open fun computeWeightForBye(p: Pairable): Double {
// The weightForBye function depends on the system type (Mac-Mahon or Swiss), default value is 0.0
@@ -62,7 +91,7 @@ sealed class BaseSolver(
fun pair(): List<Game> {
// 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")
val builder = GraphBuilder(SimpleDirectedWeightedGraph<Pairable, DefaultWeightedEdge>(DefaultWeightedEdge::class.java))
@@ -70,10 +99,7 @@ sealed class BaseSolver(
val logger = LoggerFactory.getLogger("debug")
val debug = false
weightsLogger?.apply {
this.println("Round $round")
this.println("Costs")
}
pairingListener?.start(round)
var chosenByePlayer: Pairable = ByePlayer
// Choose bye player and remove from pairables
@@ -84,7 +110,7 @@ sealed class BaseSolver(
var byePlayerIndex = 0
for (p in nameSortedPairables){
weightForBye = computeWeightForBye(p)
if (p.id in historyHelper.byePlayers) weightForBye += 1000
if (p.id in history.byePlayers) weightForBye += 1000
if (weightForBye <= minWeight){
minWeight = weightForBye
chosenByePlayer = p
@@ -102,21 +128,6 @@ sealed class BaseSolver(
val q = nameSortedPairables[j]
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) }
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()
@@ -131,6 +142,8 @@ sealed class BaseSolver(
// add game for ByePlayer
if (chosenByePlayer != ByePlayer) result += Game(id = nextGameId, table = 0, white = chosenByePlayer.id, black = ByePlayer.id, result = Game.Result.fromSymbol('w'))
pairingListener?.end()
if (debug) {
var sumOfWeights = 0.0
@@ -144,8 +157,8 @@ sealed class BaseSolver(
for (p in sortedPairables) {
logger.info(String.format("%-20s", p.name.substring(0, min(p.name.length, 18)))
+ " " + String.format("%-4s", p.id)
+ " " + String.format("%-4s", scores[p.id]?.first)
+ " " + String.format("%-4s", scores[p.id]?.second)
+ " " + String.format("%-4s", history.missedRoundsSos[p.id])
+ " " + String.format("%-4s", history.scores[p.id])
+ " " + String.format("%-4s", p.sos)
)
}
@@ -194,11 +207,11 @@ sealed class BaseSolver(
var score = 0.0
// Base Criterion 1 : Avoid Duplicating Game
// Did p1 and p2 already play ?
score += avoidDuplicatingGames(p1, p2)
score += avoidDuplicatingGames(p1, p2).also { pairingListener?.addWeight("baseDuplicateGame", it) }
// Base Criterion 2 : Random
score += applyRandom(p1, p2)
score += applyRandom(p1, p2).also { pairingListener?.addWeight("baseRandom", it) }
// Base Criterion 3 : Balance W and B
score += applyColorBalance(p1, p2)
score += applyColorBalance(p1, p2).also { pairingListener?.addWeight("baseColorBalance", it) }
return score
}
@@ -258,16 +271,16 @@ sealed class BaseSolver(
var score = 0.0
// 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
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
score += applyDUDD(p1, p2)
score += applyDUDD(p1, p2).also { pairingListener?.addWeight("mainDUDD", it) }
// Main criterion 4 seeding
score += applySeeding(p1, p2)
score += applySeeding(p1, p2).also { pairingListener?.addWeight("mainSeed", it) }
return score
}
@@ -389,7 +402,7 @@ sealed class BaseSolver(
val randRange = maxSeedingWeight * 0.2
// for old tests to pass
val rand =
if (legacy_mode && p1.fullName() > p2.fullName()) {
if (legacyMode && p1.fullName() > p2.fullName()) {
// for old tests to pass
detRandom(randRange, p2, p1, false)
} else {
@@ -405,8 +418,7 @@ sealed class BaseSolver(
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 = 1 : 1 player is above thresholds -> apply half the 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*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 {
@@ -427,14 +443,14 @@ sealed class BaseSolver(
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
val countryFactor: Int = if (legacyMode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold)
preferMMSDiffRatherThanSameCountry
else
0
val clubFactor: Int = if (legacy_mode || biggestClubSize.toDouble() / pairables.size <= proportionMainClubThreshold)
preferMMSDiffRatherThanSameClub
else
0
// Club factor: always use the configured value
val clubFactor: Int = preferMMSDiffRatherThanSameClub
//val groupFactor: Int = preferMMSDiffRatherThanSameClubsGroup
// Same country
@@ -447,27 +463,55 @@ sealed class BaseSolver(
// Same club and club group (TODO club group)
var clubRatio = 0.0
// To match OpenGotha, only do a case insensitive comparison of the first four characters.
// But obviously, there is a margin of improvement here towards some way of normalizing clubs.
val commonClub = p1.club?.take(4)?.uppercase() == p2.club?.take(4)?.uppercase()
val commonGroup = false // TODO
if (commonGroup && !commonClub) {
clubRatio = if (clubFactor == 0) {
0.0
// 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 {
clubFactor.toDouble() / 2.0 / placementScoreRange.toDouble()
commonClub
}
} else if (!commonGroup && !commonClub) {
if (commonGroup && !effectiveCommonClub) {
clubRatio = if (clubFactor == 0) {
0.0
} else {
clubFactor.toDouble() * 1.2 / placementScoreRange.toDouble()
val factor = if (bothStrangers) 0.5 else 1.0 // Half bonus for stranger-vs-stranger
factor * clubFactor.toDouble() / 2.0 / placementScoreRange.toDouble()
}
} else if (!commonGroup && !effectiveCommonClub) {
clubRatio = if (clubFactor == 0) {
0.0
} else {
val factor = if (bothStrangers) 0.5 else 1.0 // Half bonus for stranger-vs-stranger
factor * clubFactor.toDouble() * 1.2 / placementScoreRange.toDouble()
}
}
// else: effectiveCommonClub = true → clubRatio stays 0 (no bonus for same club)
clubRatio = min(clubRatio, 1.0)
// TODO Same family
// Same family: when enabled and players are from the same club, check if they have the same surname
// If so, remove the bonus to avoid pairing family members (even if local club logic gave them a bonus)
if (avoidSameFamily && commonClub) {
val sameFamily = p1.name.uppercase() == p2.name.uppercase()
if (sameFamily) {
clubRatio = 0.0 // No bonus for same family within same club
}
}
// compute geoRatio
val mainPart = max(countryRatio, clubRatio)
@@ -489,7 +533,7 @@ sealed class BaseSolver(
2 -> geoMaxCost
1 -> 0.5 * (geoNominalCost + geoMaxCost)
else -> geoNominalCost
}
}.also { pairingListener?.addWeight("secGeo", it) }
}
// Handicap functions
@@ -537,7 +581,7 @@ sealed class BaseSolver(
} else if (p1.colorBalance < p2.colorBalance) {
score = 1.0
} 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
} else {
score = -1.0

View File

@@ -1,27 +1,31 @@
package org.jeudego.pairgoth.pairing.solver
import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.pairing.HistoryHelper
import java.util.*
class SwissSolver(round: Int,
totalRounds: Int,
history: List<List<Game>>,
history: HistoryHelper,
pairables: List<Pairable>,
pairablesMap: Map<ID, Pairable>,
allPairablesMap: Map<ID, Pairable>,
pairingParams: PairingParams,
placementParams: PlacementParams,
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) ->
history.wins[id] ?: 0.0
}
override val scores by lazy {
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)

View File

@@ -5,6 +5,7 @@ import com.github.benmanes.caffeine.cache.Caffeine
import com.republicate.kson.Json
import org.apache.commons.io.input.BOMInputStream
import org.jeudego.pairgoth.api.ApiHandler
import org.jeudego.pairgoth.api.ExplainHandler
import org.jeudego.pairgoth.api.PairingHandler
import org.jeudego.pairgoth.api.PlayerHandler
import org.jeudego.pairgoth.api.ResultsHandler
@@ -99,6 +100,7 @@ class ApiServlet: HttpServlet() {
if ("token" == selector) TokenHandler
else when (subEntity) {
null -> TournamentHandler
"explain" -> ExplainHandler
"part" -> PlayerHandler
"pair" -> PairingHandler
"res" -> ResultsHandler

View File

@@ -1,14 +1,12 @@
package org.jeudego.pairgoth.test
import com.republicate.kson.Json
import org.jeudego.pairgoth.pairing.solver.BaseSolver
import org.jeudego.pairgoth.model.Game
import org.jeudego.pairgoth.pairing.solver.Solver
import org.jeudego.pairgoth.test.PairingTests.Companion.compare_weights
import org.junit.jupiter.api.Test
import java.io.FileWriter
import java.io.PrintWriter
import java.nio.charset.StandardCharsets
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -22,24 +20,22 @@ class BOSP2024Test: TestBase() {
)!!.asObject()
val resp = TestAPI.post("/api/tour", tournament).asObject()
val tourId = resp.getInt("id")
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("bosp2024-weights.txt")))
BaseSolver.legacy_mode = true
TestAPI.post("/api/tour/$tourId/pair/3", Json.Array("all")).asArray()
val outputFile = getOutputFile("bosp2024-weights.txt")
TestAPI.post("/api/tour/$tourId/pair/3?legacy=true&weights_output=$outputFile", Json.Array("all")).asArray()
// 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"))
BaseSolver.legacy_mode = false
val games = TestAPI.post("/api/tour/$tourId/pair/3", Json.Array("all")).asArray()
// 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
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
players.contains(18)
}.firstOrNull()
}
assertNotNull(solved)

View File

@@ -1,11 +1,13 @@
package org.jeudego.pairgoth.test
import org.jeudego.pairgoth.ext.MacMahon39
import org.jeudego.pairgoth.ext.OpenGotha
import org.jeudego.pairgoth.model.toJson
import org.jeudego.pairgoth.util.XmlUtils
import org.junit.jupiter.api.Test
import java.nio.charset.StandardCharsets
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ImportExportTests: TestBase() {
@@ -56,4 +58,73 @@ class ImportExportTests: TestBase() {
assertEquals(jsonTournament, jsonTournament2)
}
}
@Test
fun `003 test macmahon39 import`() {
getTestResources("macmahon39")?.forEach { file ->
logger.info("===== Testing MacMahon 3.9 import: ${file.name} =====")
val resource = file.readText(StandardCharsets.UTF_8)
val root_xml = XmlUtils.parse(resource)
// Verify format detection
assertTrue(MacMahon39.isFormat(root_xml), "File should be detected as MacMahon 3.9 format")
// Import tournament
val tournament = MacMahon39.import(root_xml)
// Verify basic tournament data
logger.info("Tournament name: ${tournament.name}")
logger.info("Number of rounds: ${tournament.rounds}")
logger.info("Number of players: ${tournament.pairables.size}")
assertEquals("Test MacMahon Tournament", tournament.name)
assertEquals(3, tournament.rounds)
assertEquals(4, tournament.pairables.size)
// Verify players
val players = tournament.pairables.values.toList()
val alice = players.find { it.name == "Smith" }
val bob = players.find { it.name == "Jones" }
val carol = players.find { it.name == "White" }
val david = players.find { it.name == "Brown" }
assertTrue(alice != null, "Alice should exist")
assertTrue(bob != null, "Bob should exist")
assertTrue(carol != null, "Carol should exist")
assertTrue(david != null, "David should exist")
assertEquals(2, alice!!.rank) // 3d = rank 2
assertEquals(1, bob!!.rank) // 2d = rank 1
assertEquals(0, carol!!.rank) // 1d = rank 0
assertEquals(-1, david!!.rank) // 1k = rank -1
// Carol is super bar member
assertEquals(1, carol.mmsCorrection)
// David skips round 2
assertTrue(david.skip.contains(2), "David should skip round 2")
// Verify games
val round1Games = tournament.games(1).values.toList()
val round2Games = tournament.games(2).values.toList()
logger.info("Round 1 games: ${round1Games.size}")
logger.info("Round 2 games: ${round2Games.size}")
assertEquals(2, round1Games.size)
assertEquals(2, round2Games.size) // 1 regular game + 1 bye
// Test via API
val resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
logger.info("Imported tournament id: $id")
val apiTournament = TestAPI.get("/api/tour/$id").asObject()
assertEquals("Test MacMahon Tournament", apiTournament.getString("name"))
assertEquals(3, apiTournament.getInt("rounds"))
val apiPlayers = TestAPI.get("/api/tour/$id/part").asArray()
assertEquals(4, apiPlayers.size)
}
}
}

View 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")
}
}

View File

@@ -1,11 +1,8 @@
package org.jeudego.pairgoth.test
import com.republicate.kson.Json
import org.jeudego.pairgoth.pairing.solver.BaseSolver
import org.jeudego.pairgoth.test.PairingTests.Companion.compare_weights
import org.junit.jupiter.api.Test
import java.io.FileWriter
import java.io.PrintWriter
import java.nio.charset.StandardCharsets
import kotlin.test.assertEquals
import kotlin.test.assertTrue
@@ -19,8 +16,8 @@ class MalavasiTest: TestBase() {
)!!.asObject()
val resp = TestAPI.post("/api/tour", tournament).asObject()
val tourId = resp.getInt("id")
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("malavasi-weights.txt")))
val games = TestAPI.post("/api/tour/$tourId/pair/2", Json.Array("all")).asArray()
val outputFile = getOutputFile("malavasi-weights.txt")
val games = TestAPI.post("/api/tour/$tourId/pair/2?weights_output=$outputFile", Json.Array("all")).asArray()
// Oceane is ID 548, Valentine 549
val buggy = games.map { it as Json.Object }.filter { game ->
// build the two-elements set of players ids
@@ -33,6 +30,6 @@ class MalavasiTest: TestBase() {
assertEquals(2, buggy.size)
// 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")
}
}

View File

@@ -2,14 +2,12 @@ package org.jeudego.pairgoth.test
import com.republicate.kson.Json
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.lastPlayerId
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
import java.nio.charset.StandardCharsets
import java.text.DecimalFormat
import kotlin.math.abs
@@ -59,7 +57,6 @@ class PairingTests: TestBase() {
}
fun compare_weights(file1: File, file2: File, skipSeeding: Boolean = false):Boolean {
BaseSolver.weightsLogger!!.flush()
// Maps to store name pairs and costs
val map1 = create_weights_map(file1)
val map2 = create_weights_map(file2)
@@ -165,6 +162,7 @@ class PairingTests: TestBase() {
}
fun test_from_XML(name: String, forcePairing:List<Int>) {
// Let pairgoth use the legacy asymmetric detRandom()
test_from_XML_internal(name, forcePairing, true)
// Non-legacy tests inhibited for now: pairings differ for Toulouse and SimpleMM
// 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) {
// Let pairgoth use the legacy asymmetric detRandom()
BaseSolver.legacy_mode = legacy
// read tournament with pairing
val file = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $file")
val resource = file.readText(StandardCharsets.UTF_8)
val tourFile = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $tourFile")
val resource = tourFile.readText(StandardCharsets.UTF_8)
var resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
val tournament = TestAPI.get("/api/tour/$id").asObject()
@@ -203,15 +200,15 @@ class PairingTests: TestBase() {
for (round in 1..tournament.getInt("rounds")!!) {
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
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("games for round $round: {}", games.toString())
// Compare weights with OpenGotha if legacy mode
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) {
@@ -223,7 +220,7 @@ class PairingTests: TestBase() {
val gameOG = pairingsOG[round - 1].getJson(i)!!.asObject()// ["r"] as String?
val whiteId = gameOG["w"] 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()
}
@@ -273,11 +270,10 @@ class PairingTests: TestBase() {
@Test
fun `SwissTest simpleSwiss`() {
BaseSolver.legacy_mode = true
// read tournament with pairing
var file = getTestFile("opengotha/pairings/simpleswiss.xml")
logger.info("read from file $file")
val resource = file.readText(StandardCharsets.UTF_8)
var tourFile = getTestFile("opengotha/pairings/simpleswiss.xml")
logger.info("read from file $tourFile")
val resource = tourFile.readText(StandardCharsets.UTF_8)
var resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
val tournament = TestAPI.get("/api/tour/$id").asObject()
@@ -315,10 +311,10 @@ class PairingTests: TestBase() {
var firstGameID: Int
for (round in 1..7) {
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
val outputFile = getOutputFile("weights.txt")
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) + "...")
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")
logger.info("Pairings for round $round match OpenGotha")
@@ -354,12 +350,12 @@ class PairingTests: TestBase() {
@Test
fun `SwissTest KPMCSplitbug`() {
// Let pairgoth use the legacy asymmetric detRandom()
BaseSolver.legacy_mode = true
val legacy = true
// read tournament with pairing
val name = "20240921-KPMC-Splitbug"
val file = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $file")
val resource = file.readText(StandardCharsets.UTF_8)
val tourFile = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $tourFile")
val resource = tourFile.readText(StandardCharsets.UTF_8)
var resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
val tournament = TestAPI.get("/api/tour/$id").asObject()
@@ -387,13 +383,13 @@ class PairingTests: TestBase() {
var games: Json.Array
var firstGameID: Int
val outputFile = getOutputFile("weights.txt")
for (round in minRound..maxRound) {
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
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("games for round $round: {}", games.toString().slice(0..50) + "...")
@@ -401,7 +397,7 @@ class PairingTests: TestBase() {
// Compare weights with OpenGotha
assertTrue(
compare_weights(
getOutputFile("weights.txt"),
outputFile,
getTestFile("opengotha/$name/$name" + "_weights_R$round.txt")
), "Not matching opengotha weights for round $round"
)

View File

@@ -7,6 +7,8 @@ import org.jeudego.pairgoth.server.SSEServlet
import org.jeudego.pairgoth.server.WebappManager
import org.mockito.kotlin.*
import java.io.*
import java.net.URL
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.util.*
import javax.servlet.ReadListener
@@ -21,20 +23,45 @@ object TestAPI {
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 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["store"] = "memory"
WebappManager.properties["webapp.env"] = "test"
val (uri, parameters) = parseURL(url)
// mock request
val myHeaderNames = if (reqMethod == "GET") emptyList() else listOf("Content-Type")
val selector = argumentCaptor<String>()
val subSelector = argumentCaptor<String>()
val reqPayload = argumentCaptor<String>()
val parameter = argumentCaptor<String>()
val myInputStream = payload?.let { DelegatingServletInputStream(payload.toString().byteInputStream(StandardCharsets.UTF_8)) }
val myReader = payload?.let { BufferedReader(StringReader(payload.toString())) }
val req = mock<HttpServletRequest> {
@@ -59,6 +86,7 @@ object TestAPI {
}
on { headerNames } doReturn Collections.enumeration(myHeaderNames)
on { getHeader(eq("Accept")) } doReturn accept
on { getParameter(parameter.capture()) } doAnswer { parameters[parameter.lastValue] }
}
// mock response
@@ -77,7 +105,7 @@ object TestAPI {
"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")

View 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>

View File

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

23
build.sh Executable file
View 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/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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":[[]]}

View 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;
}
}

View File

@@ -56,6 +56,10 @@
font-style: italic;
}
.strong {
font-weight: bold;
}
/* header, center, footer */
#header {
@@ -519,10 +523,22 @@
}
}
#logout {
#logout, #settings {
cursor: pointer;
}
#settings-modal {
.setting {
margin: 0.5em 0;
label {
display: flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
}
}
}
@media screen {
#players-list {
font-size: smaller;
@@ -566,7 +582,7 @@
}
/* 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;
}

View File

@@ -377,6 +377,9 @@
width: 6em;
}
}
&:empty + .pairing-post-actions {
display: none;
}
}
#print-pairables {
width: 100%;
@@ -418,8 +421,10 @@
padding: 0.2em 0.8em;
}
.result-sheets {
.pairing-post-actions {
margin-top: 0.2em;
display: flex;
justify-content: space-around;
}
.bottom-pairing-actions {

View File

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

View File

@@ -184,7 +184,7 @@ unpairable, non disponibles,
supports the implémente le système dappariement
white blanc
White Blanc
white vs. black blanc vs. Noir
white vs. black Blanc vs. Noir
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
yyyymmdd-city aaaammjj-ville

View 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>

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
<div class="section">
<h2 class="error">Invalid tournament id</h2>
</div>
#stop
#end
#set($round = $math.toInteger($!params.round))
#if(!$round)
@@ -27,60 +28,40 @@
#stop
#end
#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])
<table>
<tr>
<th>Table</th>
<th>Black</th>
<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])
#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">
<div class="title">$tour.name</div>
<div class="subtitle"></div>
<div class="details">
<div>Table $game.t</div>
#set($komi = $tour.komi)
#if($game.h) #set($komi = $komi - $math.floor($komi)) #end
<div>Handicap $game.h &nbsp;&dash;&nbsp; Komi $komi</div>
<div>Round $round</div>
</div>
<div class="instructions">
Surround winner's name or ½-½
</div>
<div class="players">
<div class="white player">
<div class="color">White</div>
<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>
## <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">&nbsp;&nbsp;&nbsp;</div>
<div class="signature">Signature:</div>
</div>
</div>
#set($table = $i + 1)
<tr>
<td>Table $table</td>
<td>$black.name $!black.firstname</td>
<td>#rank($black.rank)</td>
<td>$white.name $!white.firstname</td>
<td>#rank($white.rank)</td>
<td>$game.h</td>
<td>$komi</td>
</tr>
#end
</table>
#if($foreach.index % 4 == 3)
</div>
#end
#end
</div>
<script type="text/javascript">
onLoad(() => {

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,9 @@
</div>
</form>
</div>
#if($tour.type.startsWith('TEAM'))
<div class="strong">Team Standings</div>
#end
<div id="standings-container" class="roundbox">
#set($standings = $api.get("tour/${params.id}/standings/$round"))
#if($standings.isObject() && ($standings.error || $standings.message))
@@ -105,6 +108,78 @@
</tbody>
</table>
</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">
#if(!$tour.frozen && $round == $tour.rounds)
<button id="freeze" class="ui orange floating right labeled icon button">

View File

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