408 lines
13 KiB
Scala
408 lines
13 KiB
Scala
package lila.api
|
|
|
|
import akka.stream.scaladsl._
|
|
import chess.format.FEN
|
|
import chess.format.pgn.Tag
|
|
import org.joda.time.DateTime
|
|
import play.api.libs.json._
|
|
import scala.concurrent.duration._
|
|
|
|
import lila.analyse.{ JsonView => analysisJson, Analysis }
|
|
import lila.common.config.MaxPerSecond
|
|
import lila.common.Json._
|
|
import lila.common.{ HTTPRequest, LightUser }
|
|
import lila.db.dsl._
|
|
import lila.game.JsonView._
|
|
import lila.game.PgnDump.WithFlags
|
|
import lila.game.{ Game, PerfPicker, Query }
|
|
import lila.team.GameTeams
|
|
import lila.tournament.Tournament
|
|
import lila.user.User
|
|
import lila.round.GameProxyRepo
|
|
|
|
final class GameApiV2(
|
|
pgnDump: PgnDump,
|
|
gameRepo: lila.game.GameRepo,
|
|
tournamentRepo: lila.tournament.TournamentRepo,
|
|
pairingRepo: lila.tournament.PairingRepo,
|
|
playerRepo: lila.tournament.PlayerRepo,
|
|
swissApi: lila.swiss.SwissApi,
|
|
analysisRepo: lila.analyse.AnalysisRepo,
|
|
annotator: lila.analyse.Annotator,
|
|
getLightUser: LightUser.Getter,
|
|
realPlayerApi: RealPlayerApi,
|
|
gameProxy: GameProxyRepo
|
|
)(implicit
|
|
ec: scala.concurrent.ExecutionContext,
|
|
system: akka.actor.ActorSystem
|
|
) {
|
|
|
|
import GameApiV2._
|
|
|
|
private val keepAliveInterval = 70.seconds // play's idleTimeout = 75s
|
|
|
|
def exportOne(game: Game, config: OneConfig): Fu[String] =
|
|
game.pgnImport ifTrue config.imported match {
|
|
case Some(imported) => fuccess(imported.pgn)
|
|
case None =>
|
|
for {
|
|
realPlayers <- config.playerFile.??(realPlayerApi.apply)
|
|
(game, initialFen, analysis) <- enrich(config.flags)(game)
|
|
export <- config.format match {
|
|
case Format.JSON =>
|
|
toJson(game, initialFen, analysis, config.flags, realPlayers = realPlayers) dmap Json.stringify
|
|
case Format.PGN =>
|
|
pgnDump(
|
|
game,
|
|
initialFen,
|
|
analysis,
|
|
config.flags,
|
|
realPlayers = realPlayers
|
|
) dmap annotator.toPgnString
|
|
}
|
|
} yield export
|
|
}
|
|
|
|
private val fileR = """[\s,]""".r
|
|
|
|
def filename(game: Game, format: Format): Fu[String] =
|
|
gameLightUsers(game) map { case (wu, bu) =>
|
|
fileR.replaceAllIn(
|
|
"lichess_pgn_%s_%s_vs_%s.%s.%s".format(
|
|
Tag.UTCDate.format.print(game.createdAt),
|
|
pgnDump.dumper.player(game.whitePlayer, wu),
|
|
pgnDump.dumper.player(game.blackPlayer, bu),
|
|
game.id,
|
|
format.toString.toLowerCase
|
|
),
|
|
"_"
|
|
)
|
|
}
|
|
|
|
def filename(tour: Tournament, format: Format): String =
|
|
filename(tour, format.toString.toLowerCase)
|
|
|
|
def filename(tour: Tournament, format: String): String =
|
|
fileR.replaceAllIn(
|
|
"lichess_tournament_%s_%s_%s.%s".format(
|
|
Tag.UTCDate.format.print(tour.startsAt),
|
|
tour.id,
|
|
lila.common.String.slugify(tour.name),
|
|
format
|
|
),
|
|
"_"
|
|
)
|
|
|
|
def filename(swiss: lila.swiss.Swiss, format: Format): String =
|
|
filename(swiss, format.toString.toLowerCase)
|
|
|
|
def filename(swiss: lila.swiss.Swiss, format: String): String =
|
|
fileR.replaceAllIn(
|
|
"lichess_swiss_%s_%s_%s.%s".format(
|
|
Tag.UTCDate.format.print(swiss.startsAt),
|
|
swiss.id,
|
|
lila.common.String.slugify(swiss.name),
|
|
format
|
|
),
|
|
"_"
|
|
)
|
|
|
|
def exportByUser(config: ByUserConfig): Source[String, _] =
|
|
Source futureSource {
|
|
config.playerFile.??(realPlayerApi.apply) map { realPlayers =>
|
|
val playerSelect =
|
|
if (config.finished)
|
|
config.vs.fold(Query.user(config.user.id)) { Query.opponents(config.user, _) }
|
|
else
|
|
config.vs.map(_.id).fold(Query.nowPlaying(config.user.id)) {
|
|
Query.nowPlayingVs(config.user.id, _)
|
|
}
|
|
gameRepo
|
|
.sortedCursor(
|
|
playerSelect ++
|
|
Query.createdBetween(config.since, config.until) ++ (!config.ongoing ?? Query.finished),
|
|
config.sort.bson,
|
|
batchSize = config.perSecond.value
|
|
)
|
|
.documentSource()
|
|
.map(g => config.postFilter(g) option g)
|
|
.throttle(config.perSecond.value * 10, 1 second, e => if (e.isDefined) 10 else 2)
|
|
.mapConcat(_.toList)
|
|
.take(config.max | Int.MaxValue)
|
|
.via(upgradeOngoingGame)
|
|
.via(preparationFlow(config, realPlayers))
|
|
.keepAlive(keepAliveInterval, () => emptyMsgFor(config))
|
|
}
|
|
}
|
|
|
|
def exportByIds(config: ByIdsConfig): Source[String, _] =
|
|
Source futureSource {
|
|
config.playerFile.??(realPlayerApi.apply) map { realPlayers =>
|
|
gameRepo
|
|
.sortedCursor(
|
|
$inIds(config.ids),
|
|
Query.sortCreated,
|
|
batchSize = config.perSecond.value
|
|
)
|
|
.documentSource()
|
|
.throttle(config.perSecond.value, 1 second)
|
|
.via(upgradeOngoingGame)
|
|
.via(preparationFlow(config, realPlayers))
|
|
}
|
|
}
|
|
|
|
def exportByTournament(config: ByTournamentConfig, onlyUserId: Option[User.ID]): Source[String, _] =
|
|
Source futureSource {
|
|
tournamentRepo.isTeamBattle(config.tournamentId) map { isTeamBattle =>
|
|
pairingRepo
|
|
.sortedCursor(
|
|
tournamentId = config.tournamentId,
|
|
userId = onlyUserId,
|
|
batchSize = config.perSecond.value
|
|
)
|
|
.documentSource()
|
|
.grouped(config.perSecond.value)
|
|
.throttle(1, 1 second)
|
|
.mapAsync(1) { pairings =>
|
|
isTeamBattle.?? {
|
|
playerRepo.teamsOfPlayers(config.tournamentId, pairings.flatMap(_.users).distinct).dmap(_.toMap)
|
|
} flatMap { playerTeams =>
|
|
gameRepo.gameOptionsFromSecondary(pairings.map(_.gameId)) map {
|
|
_.zip(pairings) collect { case (Some(game), pairing) =>
|
|
import cats.implicits._
|
|
(
|
|
game,
|
|
pairing,
|
|
(
|
|
playerTeams.get(pairing.user1),
|
|
playerTeams.get(
|
|
pairing.user2
|
|
)
|
|
) mapN chess.Color.Map.apply[String]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.mapConcat(identity)
|
|
.mapAsync(4) { case (game, pairing, teams) =>
|
|
enrich(config.flags)(game) dmap { (_, pairing, teams) }
|
|
}
|
|
.mapAsync(4) { case ((game, fen, analysis), pairing, teams) =>
|
|
config.format match {
|
|
case Format.PGN => pgnDump.formatter(config.flags)(game, fen, analysis, teams, none)
|
|
case Format.JSON =>
|
|
def addBerserk(color: chess.Color)(json: JsObject) =
|
|
if (pairing berserkOf color)
|
|
json deepMerge Json.obj(
|
|
"players" -> Json.obj(color.name -> Json.obj("berserk" -> true))
|
|
)
|
|
else json
|
|
toJson(game, fen, analysis, config.flags, teams) dmap
|
|
addBerserk(chess.White) dmap
|
|
addBerserk(chess.Black) dmap { json =>
|
|
s"${Json.stringify(json)}\n"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
def exportBySwiss(config: BySwissConfig): Source[String, _] =
|
|
swissApi
|
|
.gameIdSource(
|
|
swissId = config.swissId,
|
|
batchSize = config.perSecond.value
|
|
)
|
|
.grouped(config.perSecond.value)
|
|
.throttle(1, 1 second)
|
|
.mapAsync(1)(gameRepo.gamesFromSecondary)
|
|
.mapConcat(identity)
|
|
.mapAsync(4)(enrich(config.flags))
|
|
.mapAsync(4) { case (game, fen, analysis) =>
|
|
config.format match {
|
|
case Format.PGN => pgnDump.formatter(config.flags)(game, fen, analysis, none, none)
|
|
case Format.JSON =>
|
|
toJson(game, fen, analysis, config.flags, None) dmap { json =>
|
|
s"${Json.stringify(json)}\n"
|
|
}
|
|
}
|
|
}
|
|
|
|
private val upgradeOngoingGame =
|
|
Flow[Game].mapAsync(4)(gameProxy.upgradeIfPresent)
|
|
|
|
private def preparationFlow(config: Config, realPlayers: Option[RealPlayers]) =
|
|
Flow[Game]
|
|
.mapAsync(4)(enrich(config.flags))
|
|
.mapAsync(4) { case (game, fen, analysis) =>
|
|
formatterFor(config)(game, fen, analysis, None, realPlayers)
|
|
}
|
|
|
|
private def enrich(flags: WithFlags)(game: Game) =
|
|
gameRepo initialFen game flatMap { initialFen =>
|
|
(flags.evals ?? analysisRepo.byGame(game)) dmap {
|
|
(game, initialFen, _)
|
|
}
|
|
}
|
|
|
|
private def formatterFor(config: Config) =
|
|
config.format match {
|
|
case Format.PGN => pgnDump.formatter(config.flags)
|
|
case Format.JSON => jsonFormatter(config.flags)
|
|
}
|
|
|
|
private def emptyMsgFor(config: Config) =
|
|
config.format match {
|
|
case Format.PGN => "\n"
|
|
case Format.JSON => "{}\n"
|
|
}
|
|
|
|
private def jsonFormatter(flags: WithFlags) =
|
|
(
|
|
game: Game,
|
|
initialFen: Option[FEN],
|
|
analysis: Option[Analysis],
|
|
teams: Option[GameTeams],
|
|
realPlayers: Option[RealPlayers]
|
|
) =>
|
|
toJson(game, initialFen, analysis, flags, teams, realPlayers) dmap { json =>
|
|
s"${Json.stringify(json)}\n"
|
|
}
|
|
|
|
private def toJson(
|
|
g: Game,
|
|
initialFen: Option[FEN],
|
|
analysisOption: Option[Analysis],
|
|
withFlags: WithFlags,
|
|
teams: Option[GameTeams] = None,
|
|
realPlayers: Option[RealPlayers] = None
|
|
): Fu[JsObject] =
|
|
for {
|
|
lightUsers <- gameLightUsers(g) dmap { case (wu, bu) => List(wu, bu) }
|
|
pgn <-
|
|
withFlags.pgnInJson ?? pgnDump
|
|
.apply(g, initialFen, analysisOption, withFlags, realPlayers = realPlayers)
|
|
.dmap(annotator.toPgnString)
|
|
.dmap(some)
|
|
} yield Json
|
|
.obj(
|
|
"id" -> g.id,
|
|
"rated" -> g.rated,
|
|
"variant" -> g.variant.key,
|
|
"speed" -> g.speed.key,
|
|
"perf" -> PerfPicker.key(g),
|
|
"createdAt" -> g.createdAt,
|
|
"lastMoveAt" -> g.movedAt,
|
|
"status" -> g.status.name,
|
|
"players" -> JsObject(g.players zip lightUsers map { case (p, user) =>
|
|
p.color.name -> Json
|
|
.obj()
|
|
.add("user", user)
|
|
.add("rating", p.rating)
|
|
.add("ratingDiff", p.ratingDiff)
|
|
.add("name", p.name)
|
|
.add("provisional" -> p.provisional)
|
|
.add("aiLevel" -> p.aiLevel)
|
|
.add("analysis" -> analysisOption.flatMap(analysisJson.player(g pov p.color)))
|
|
.add("team" -> teams.map(_(p.color)))
|
|
// .add("moveCentis" -> withFlags.moveTimes ?? g.moveTimes(p.color).map(_.map(_.centis)))
|
|
})
|
|
)
|
|
.add("initialFen" -> initialFen)
|
|
.add("winner" -> g.winnerColor.map(_.name))
|
|
.add("opening" -> g.opening.ifTrue(withFlags.opening))
|
|
.add("moves" -> withFlags.moves.option {
|
|
withFlags keepDelayIf g.playable applyDelay g.pgnMoves mkString " "
|
|
})
|
|
.add("pgn" -> pgn)
|
|
.add("daysPerTurn" -> g.daysPerTurn)
|
|
.add("analysis" -> analysisOption.ifTrue(withFlags.evals).map(analysisJson.moves(_, withGlyph = false)))
|
|
.add("tournament" -> g.tournamentId)
|
|
.add("swiss" -> g.swissId)
|
|
.add("clock" -> g.clock.map { clock =>
|
|
Json.obj(
|
|
"initial" -> clock.limitSeconds,
|
|
"increment" -> clock.incrementSeconds,
|
|
"totalTime" -> clock.estimateTotalSeconds
|
|
)
|
|
})
|
|
|
|
private def gameLightUsers(game: Game): Fu[(Option[LightUser], Option[LightUser])] =
|
|
(game.whitePlayer.userId ?? getLightUser) zip (game.blackPlayer.userId ?? getLightUser)
|
|
}
|
|
|
|
object GameApiV2 {
|
|
|
|
sealed trait Format
|
|
object Format {
|
|
case object PGN extends Format
|
|
case object JSON extends Format
|
|
def byRequest(req: play.api.mvc.RequestHeader) = if (HTTPRequest acceptsNdJson req) JSON else PGN
|
|
}
|
|
|
|
sealed trait Config {
|
|
val format: Format
|
|
val flags: WithFlags
|
|
}
|
|
|
|
sealed abstract class GameSort(val bson: Bdoc)
|
|
case object DateAsc extends GameSort(Query.sortChronological)
|
|
case object DateDesc extends GameSort(Query.sortAntiChronological)
|
|
|
|
case class OneConfig(
|
|
format: Format,
|
|
imported: Boolean,
|
|
flags: WithFlags,
|
|
playerFile: Option[String]
|
|
) extends Config
|
|
|
|
case class ByUserConfig(
|
|
user: User,
|
|
vs: Option[User],
|
|
format: Format,
|
|
since: Option[DateTime] = None,
|
|
until: Option[DateTime] = None,
|
|
max: Option[Int] = None,
|
|
rated: Option[Boolean] = None,
|
|
perfType: Set[lila.rating.PerfType],
|
|
analysed: Option[Boolean] = None,
|
|
color: Option[chess.Color],
|
|
flags: WithFlags,
|
|
sort: GameSort,
|
|
perSecond: MaxPerSecond,
|
|
playerFile: Option[String],
|
|
ongoing: Boolean = false,
|
|
finished: Boolean = true
|
|
) extends Config {
|
|
def postFilter(g: Game) =
|
|
rated.fold(true)(g.rated ==) && {
|
|
perfType.isEmpty || g.perfType.exists(perfType.contains)
|
|
} && color.fold(true) { c =>
|
|
g.player(c).userId has user.id
|
|
} && analysed.fold(true)(g.metadata.analysed ==)
|
|
}
|
|
|
|
case class ByIdsConfig(
|
|
ids: Seq[Game.ID],
|
|
format: Format,
|
|
flags: WithFlags,
|
|
perSecond: MaxPerSecond,
|
|
playerFile: Option[String] = None
|
|
) extends Config
|
|
|
|
case class ByTournamentConfig(
|
|
tournamentId: Tournament.ID,
|
|
format: Format,
|
|
flags: WithFlags,
|
|
perSecond: MaxPerSecond
|
|
) extends Config
|
|
|
|
case class BySwissConfig(
|
|
swissId: lila.swiss.Swiss.Id,
|
|
format: Format,
|
|
flags: WithFlags,
|
|
perSecond: MaxPerSecond
|
|
) extends Config
|
|
}
|