lila/modules/api/src/main/GameApiV2.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
}