new puzzles WIP

pull/7680/head
Thibault Duplessis 2020-11-11 10:08:19 +01:00
parent 5d5c607dfa
commit 245b4560fe
19 changed files with 309 additions and 902 deletions

View File

@ -9,6 +9,7 @@ import scala.concurrent.duration._
import lila.app._
import lila.common.HTTPRequest
import lila.game.Pov
import lila.puzzle.Puzzle.Id
final class Export(env: Env) extends LilaController(env) {
@ -53,18 +54,13 @@ final class Export(env: Env) extends LilaController(env) {
}(rateLimitedFu)
}
def legacyPuzzleThumbnail(id: Int) =
Action {
MovedPermanently(routes.Export.puzzleThumbnail(id).url)
}
def puzzleThumbnail(id: Int) =
def puzzleThumbnail(id: String) =
Open { implicit ctx =>
ExportImageRateLimitGlobal("-", msg = HTTPRequest.ipAddress(ctx.req).value) {
OptionFuResult(env.puzzle.api.puzzle find id) { puzzle =>
OptionFuResult(env.puzzle.api.puzzle find Id(id)) { puzzle =>
env.game.gifExport.thumbnail(
fen = puzzle.fenAfterInitialMove | puzzle.fen,
lastMove = puzzle.initialMove.uci.some,
fen = puzzle.fenAfterInitialMove,
lastMove = puzzle.line.head.uci.some,
orientation = puzzle.color
) map stream("image/gif") map { res =>
res.withHeaders(CACHE_CONTROL -> "max-age=86400")

View File

@ -7,7 +7,7 @@ import views._
import lila.api.Context
import lila.app._
import lila.common.config.MaxPerSecond
import lila.puzzle.{ PuzzleId, Result, Puzzle => PuzzleModel, UserInfos }
import lila.puzzle.{ Result, Puzzle => Puz }
final class Puzzle(
env: Env,
@ -15,24 +15,19 @@ final class Puzzle(
) extends LilaController(env) {
private def renderJson(
puzzle: PuzzleModel,
userInfos: Option[UserInfos],
mode: String,
voted: Option[Boolean],
puzzle: Puz,
round: Option[lila.puzzle.Round] = None
)(implicit ctx: Context): Fu[JsObject] =
env.puzzle.jsonView(
puzzle = puzzle,
userInfos = userInfos,
user = ctx.me,
round = round,
mode = mode,
mobileApi = ctx.mobileApiVersion,
voted = voted
mobileApi = ctx.mobileApiVersion
)
private def renderShow(puzzle: PuzzleModel, mode: String)(implicit ctx: Context) =
env.puzzle userInfos ctx.me flatMap { infos =>
renderJson(puzzle = puzzle, userInfos = infos, mode = mode, voted = none) map { json =>
private def renderShow(puzzle: Puz)(implicit ctx: Context) =
ctx.me.?? { env.puzzle.api.round.find(_, puzzle) } flatMap { round =>
renderJson(puzzle, round) map { json =>
EnableSharedArrayBuffer(
Ok(views.html.puzzle.show(puzzle, data = json, pref = env.puzzle.jsonView.pref(ctx.pref)))
)
@ -46,7 +41,7 @@ final class Puzzle(
_.map(_.id) ?? env.puzzle.api.puzzle.find
}) { puzzle =>
negotiate(
html = renderShow(puzzle, "play"),
html = renderShow(puzzle),
api = _ => puzzleJson(puzzle) map { Ok(_) }
) map NoCache
}
@ -56,24 +51,21 @@ final class Puzzle(
def home =
Open { implicit ctx =>
NoBot {
env.puzzle.selector(ctx.me) flatMap { puzzle =>
renderShow(puzzle, if (ctx.isAuth) "play" else "try")
}
???
// env.puzzle.selector(ctx.me) flatMap { puzzle =>
// renderShow(puzzle, if (ctx.isAuth) "play" else "try")
// }
}
}
def show(id: PuzzleId) =
def show(id: Puz.Id) =
Open { implicit ctx =>
if (id < env.puzzle.idMin) notFound
else
NoBot {
OptionFuResult(env.puzzle.api.puzzle find id) { puzzle =>
renderShow(puzzle, "play")
}
}
NoBot {
OptionFuResult(env.puzzle.api.puzzle find id)(renderShow)
}
}
def load(id: PuzzleId) =
def load(id: Puz.Id) =
Open { implicit ctx =>
NoBot {
XhrOnly {
@ -82,120 +74,83 @@ final class Puzzle(
}
}
private def puzzleJson(puzzle: PuzzleModel)(implicit ctx: Context) =
env.puzzle userInfos ctx.me flatMap { infos =>
renderJson(puzzle, infos, if (ctx.isAuth) "play" else "try", voted = none)
}
private def puzzleJson(puzzle: Puz)(implicit ctx: Context) =
renderJson(puzzle, round = none)
// XHR load next play puzzle
def newPuzzle =
Open { implicit ctx =>
NoBot {
XhrOnly {
env.puzzle.selector(ctx.me) flatMap puzzleJson map { json =>
Ok(json) as JSON
}
???
// env.puzzle.selector(ctx.me) flatMap puzzleJson map { json =>
// Ok(json) as JSON
// }
}
}
}
// mobile app BC
def round(id: PuzzleId) =
OpenBody { implicit ctx =>
implicit val req = ctx.body
OptionFuResult(env.puzzle.api.puzzle find id) { puzzle =>
lila.mon.puzzle.round.attempt(puzzle.mate, ctx.isAuth, "old")
env.puzzle.forms.round
.bindFromRequest()
.fold(
jsonFormError,
resultInt => {
val result = Result(resultInt == 1)
ctx.me match {
case Some(me) =>
for {
isStudent <- env.clas.api.student.isStudent(me.id)
(round, mode) <-
env.puzzle.finisher(puzzle, me, result, mobile = true, isStudent = isStudent)
me2 <- if (mode.rated) env.user.repo byId me.id map (_ | me) else fuccess(me)
infos <- env.puzzle userInfos me2
voted <- ctx.me.?? { env.puzzle.api.vote.value(puzzle.id, _) }
data <- renderJson(puzzle, infos.some, "view", voted = voted, round = round.some)
} yield {
val d2 = if (mode.rated) data else data ++ Json.obj("win" -> result.win)
Ok(d2)
}
case None =>
env.puzzle.finisher.incPuzzleAttempts(puzzle)
renderJson(puzzle, none, "view", voted = none) map { data =>
val d2 = data ++ Json.obj("win" -> result.win)
Ok(d2)
}
}
}
) map (_ as JSON)
}
}
// new API
def round2(id: PuzzleId) =
def round2(id: Puz.Id) =
OpenBody { implicit ctx =>
NoBot {
implicit val req = ctx.body
OptionFuResult(env.puzzle.api.puzzle find id) { puzzle =>
lila.mon.puzzle.round.attempt(puzzle.mate, ctx.isAuth, "new")
env.puzzle.forms.round
.bindFromRequest()
.fold(
jsonFormError,
resultInt =>
ctx.me match {
case Some(me) =>
for {
isStudent <- env.clas.api.student.isStudent(me.id)
(round, mode) <- env.puzzle.finisher(
puzzle = puzzle,
user = me,
result = Result(resultInt == 1),
mobile = lila.api.Mobile.Api.requested(ctx.req),
isStudent = isStudent
)
me2 <- if (mode.rated) env.user.repo byId me.id map (_ | me) else fuccess(me)
infos <- env.puzzle userInfos me2
voted <- ctx.me.?? { env.puzzle.api.vote.value(puzzle.id, _) }
} yield Ok(
Json.obj(
"user" -> lila.puzzle.JsonView.infos(isOldMobile = false)(infos),
"round" -> lila.puzzle.JsonView.round(round),
"voted" -> voted
)
)
case None =>
env.puzzle.finisher.incPuzzleAttempts(puzzle)
Ok(Json.obj("user" -> false)).fuccess
}
) map (_ as JSON)
}
???
// implicit val req = ctx.body
// OptionFuResult(env.puzzle.api.puzzle find id) { puzzle =>
// lila.mon.puzzle.round.attempt(puzzle.mate, ctx.isAuth, "new")
// env.puzzle.forms.round
// .bindFromRequest()
// .fold(
// jsonFormError,
// resultInt =>
// ctx.me match {
// case Some(me) =>
// for {
// isStudent <- env.clas.api.student.isStudent(me.id)
// (round, mode) <- env.puzzle.finisher(
// puzzle = puzzle,
// user = me,
// result = Result(resultInt == 1),
// mobile = lila.api.Mobile.Api.requested(ctx.req),
// isStudent = isStudent
// )
// me2 <- if (mode.rated) env.user.repo byId me.id map (_ | me) else fuccess(me)
// infos <- env.puzzle userInfos me2
// voted <- ctx.me.?? { env.puzzle.api.vote.value(puzzle.id, _) }
// } yield Ok(
// Json.obj(
// "user" -> lila.puzzle.JsonView.infos(isOldMobile = false)(infos),
// "round" -> lila.puzzle.JsonView.round(round),
// "voted" -> voted
// )
// )
// case None =>
// env.puzzle.finisher.incPuzzleAttempts(puzzle)
// Ok(Json.obj("user" -> false)).fuccess
// }
// ) map (_ as JSON)
// }
}
}
def vote(id: PuzzleId) =
def vote(id: Puz.Id) =
AuthBody { implicit ctx => me =>
NoBot {
implicit val req = ctx.body
env.puzzle.forms.vote
.bindFromRequest()
.fold(
jsonFormError,
vote =>
env.puzzle.api.vote.find(id, me) flatMap { v =>
env.puzzle.api.vote.update(id, me, v, vote == 1)
} map { case (p, a) =>
if (vote == 1) lila.mon.puzzle.vote.up.increment()
else lila.mon.puzzle.vote.down.increment()
Ok(Json.arr(a.value, p.vote.sum))
}
) map (_ as JSON)
???
// implicit val req = ctx.body
// env.puzzle.forms.vote
// .bindFromRequest()
// .fold(
// jsonFormError,
// vote =>
// env.puzzle.api.vote.find(id, me) flatMap { v =>
// env.puzzle.api.vote.update(id, me, v, vote == 1)
// } map { case (p, a) =>
// if (vote == 1) lila.mon.puzzle.vote.up.increment()
// else lila.mon.puzzle.vote.down.increment()
// Ok(Json.arr(a.value, p.vote.sum))
// }
// ) map (_ as JSON)
}
}
@ -204,42 +159,43 @@ final class Puzzle(
Auth { implicit ctx => me =>
negotiate(
html = notFound,
api = _ =>
for {
puzzles <- env.puzzle.batch.select(
me,
nb = getInt("nb") getOrElse 50 atLeast 1 atMost 100,
after = getInt("after")
)
userInfo <- env.puzzle userInfos me
json <- env.puzzle.jsonView.batch(puzzles, userInfo)
} yield Ok(json) as JSON
api = _ => ???
// for {
// puzzles <- env.puzzle.batch.select(
// me,
// nb = getInt("nb") getOrElse 50 atLeast 1 atMost 100,
// after = getInt("after")
// )
// userInfo <- env.puzzle userInfos me
// json <- env.puzzle.jsonView.batch(puzzles, userInfo)
// } yield Ok(json) as JSON
)
}
/* Mobile API: tell the server about puzzles solved while offline */
def batchSolve =
AuthBody(parse.json) { implicit ctx => me =>
import lila.puzzle.PuzzleBatch._
ctx.body.body
.validate[SolveData]
.fold(
err => BadRequest(err.toString).fuccess,
data =>
negotiate(
html = notFound,
api = _ =>
for {
_ <- env.puzzle.batch.solve(me, data)
me2 <- env.user.repo byId me.id map (_ | me)
infos <- env.puzzle userInfos me2
} yield Ok(
Json.obj(
"user" -> lila.puzzle.JsonView.infos(isOldMobile = false)(infos)
)
)
)
)
???
// import lila.puzzle.PuzzleBatch._
// ctx.body.body
// .validate[SolveData]
// .fold(
// err => BadRequest(err.toString).fuccess,
// data =>
// negotiate(
// html = notFound,
// api = _ =>
// for {
// _ <- env.puzzle.batch.solve(me, data)
// me2 <- env.user.repo byId me.id map (_ | me)
// infos <- env.puzzle userInfos me2
// } yield Ok(
// Json.obj(
// "user" -> lila.puzzle.JsonView.infos(isOldMobile = false)(infos)
// )
// )
// )
// )
}
def frame =

View File

@ -29,15 +29,15 @@ object show {
chessground = false,
openGraph = lila.app.ui
.OpenGraph(
image = cdnUrl(routes.Export.puzzleThumbnail(puzzle.id).url).some,
image = cdnUrl(routes.Export.puzzleThumbnail(puzzle.id.value).url).some,
title = s"Chess tactic #${puzzle.id} - ${puzzle.color.name.capitalize} to play",
url = s"$netBaseUrl${routes.Puzzle.show(puzzle.id).url}",
url = s"$netBaseUrl${routes.Puzzle.show(puzzle.id.value).url}",
description = s"Lichess tactic trainer: " + puzzle.color
.fold(
trans.findTheBestMoveForWhite,
trans.findTheBestMoveForBlack
)
.txt() + s" Played by ${puzzle.attempts} players."
.txt() + s" Played by ${puzzle.plays} players."
)
.some,
zoomable = true

View File

@ -130,9 +130,6 @@ puzzle {
head = puzzle_head
}
api.token = ${api.token}
selector {
puzzle_id_min = 61053 # puzzle 61052 is bad
}
animation.duration = ${chessground.animation.duration}
}
coordinate {

View File

@ -81,14 +81,13 @@ GET /training controllers.Puzzle.home
GET /training/new controllers.Puzzle.newPuzzle
GET /training/daily controllers.Puzzle.daily
GET /training/frame controllers.Puzzle.frame
GET /training/export/png/:id.png controllers.Export.legacyPuzzleThumbnail(id: Int)
GET /training/export/gif/thumbnail/:id.gif controllers.Export.puzzleThumbnail(id: Int)
GET /training/export/gif/thumbnail/:id.gif controllers.Export.puzzleThumbnail(id: String)
GET /training/batch controllers.Puzzle.batchSelect
POST /training/batch controllers.Puzzle.batchSolve
GET /training/:id controllers.Puzzle.show(id: Int)
GET /training/:id/load controllers.Puzzle.load(id: Int)
POST /training/:id/vote controllers.Puzzle.vote(id: Int)
POST /training/:id/round controllers.Puzzle.round(id: Int)
GET /training/:id controllers.Puzzle.show(id: String)
GET /training/:id/load controllers.Puzzle.load(id: String)
POST /training/:id/vote controllers.Puzzle.vote(id: String)
POST /training/:id/round controllers.Puzzle.round(id: String)
# new UI
POST /training/:id/round2 controllers.Puzzle.round2(id: Int)
# mobile app BC

View File

@ -3,6 +3,9 @@ package lila.puzzle
import chess.format.{ FEN, Uci }
import reactivemongo.api.bson._
import scala.util.Success
import lila.db.BSON
import lila.db.dsl._
import lila.game.Game
import lila.rating.Glicko
@ -33,4 +36,16 @@ private[puzzle] object BsonHandlers {
plays = plays
)
}
implicit val RoundIdHandler = tryHandler[Round.Id](
{ case BSONString(v) =>
v split Round.idSep match {
case Array(userId, puzzleId) => Success(Round.Id(userId, Puzzle.Id(puzzleId)))
case _ => handlerBadValue(s"Invalid puzzle round id $v")
}
},
id => BSONString(id.toString)
)
implicit val RoundBSONHandler = Macros.handler[Round]
}

View File

@ -16,8 +16,7 @@ private class PuzzleConfig(
@ConfigName("collection.vote") val voteColl: CollName,
@ConfigName("collection.head") val headColl: CollName,
@ConfigName("api.token") val apiToken: Secret,
@ConfigName("animation.duration") val animationDuration: FiniteDuration,
@ConfigName("selector.puzzle_id_min") val puzzleIdMin: Int
@ConfigName("animation.duration") val animationDuration: FiniteDuration
)
case class RoundRepo(coll: lila.db.AsyncColl)
@ -45,9 +44,7 @@ final class Env(
private def voteColl = db(config.voteColl)
private def headColl = db(config.headColl)
private lazy val gameJson = wire[GameJson]
val idMin = config.puzzleIdMin
private lazy val gameJson: GameJson = wire[GameJson]
lazy val jsonView = wire[JsonView]
@ -68,24 +65,6 @@ final class Env(
puzzleColl = puzzleColl
)
lazy val selector = new Selector(
puzzleColl = puzzleColl,
api = api,
puzzleIdMin = config.puzzleIdMin
)
lazy val batch = new PuzzleBatch(
puzzleColl = puzzleColl,
api = api,
finisher = finisher,
puzzleIdMin = config.puzzleIdMin
)
lazy val userInfos = new UserInfosApi(
roundColl = roundColl,
currentPuzzleId = api.head.currentPuzzleId
)
lazy val forms = PuzzleForm
lazy val daily = new Daily(
@ -101,10 +80,8 @@ final class Env(
def cli =
new lila.common.Cli {
def process = { case "puzzle" :: "disable" :: id :: Nil =>
id.toIntOption ?? { id =>
api.puzzle disable id inject "Done"
}
def process = { case "puzzle" :: "delete" :: id :: Nil =>
api.puzzle delete Puzzle.Id(id) inject "Done"
}
}
}

View File

@ -23,93 +23,62 @@ final private[puzzle] class Finisher(
result: Result,
mobile: Boolean,
isStudent: Boolean
): Fu[(Round, Mode)] = {
val formerUserRating = user.perfs.puzzle.intRating
api.head.find(user) flatMap {
case Some(PuzzleHead(_, Some(c), _)) if c == puzzle.id || mobile =>
api.head.solved(user, puzzle.id) >> {
val userRating = user.perfs.puzzle.toRating
val puzzleRating = puzzle.perf.toRating
updateRatings(userRating, puzzleRating, result.glicko)
val date = DateTime.now
val puzzlePerf =
puzzle.perf.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id} user")(puzzleRating)
val userPerf =
user.perfs.puzzle.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id}")(userRating, date)
val round = new Round(
id = Round.Id(user.id, puzzle.id),
date = date,
result = result,
rating = formerUserRating,
ratingDiff = userPerf.intRating - formerUserRating
)
historyApi.addPuzzle(user = user, completedAt = date, perf = userPerf)
(api.round upsert round) >> {
isStudent ?? api.round.addDenormalizedUser(round, user)
} >> {
puzzleColl {
_.update.one(
$id(puzzle.id),
$inc(Puzzle.BSONFields.attempts -> $int(1)) ++
$set(Puzzle.BSONFields.perf -> PuzzlePerf.puzzlePerfBSONHandler.write(puzzlePerf))
)
} zip userRepo.setPerf(user.id, PerfType.Puzzle, userPerf)
} inject {
Bus.publish(
Puzzle.UserResult(puzzle.id, user.id, result, formerUserRating -> userPerf.intRating),
"finishPuzzle"
)
round -> Mode.Rated
}
}
case _ =>
incPuzzleAttempts(puzzle) inject new Round(
id = Round.Id(user.id, puzzle.id),
date = DateTime.now,
result = result,
rating = formerUserRating,
ratingDiff = 0
) -> Mode.Casual
}
}
/* offline solving from the mobile API
* avoid exploits by not updating the puzzle rating,
* only the user rating (we don't care about that one).
* Returns the user with updated puzzle rating */
def ratedUntrusted(puzzle: Puzzle, user: User, result: Result): Fu[User] = {
val formerUserRating = user.perfs.puzzle.intRating
val userRating = user.perfs.puzzle.toRating
val puzzleRating = puzzle.perf.toRating
updateRatings(userRating, puzzleRating, result.glicko)
val date = DateTime.now
val userPerf =
user.perfs.puzzle.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id}")(userRating, date)
val a = new Round(
id = Round.Id(user.id, puzzle.id),
date = date,
result = result,
rating = formerUserRating,
ratingDiff = userPerf.intRating - formerUserRating
)
(api.round add a) >>
userRepo.setPerf(user.id, PerfType.Puzzle, userPerf) >>-
Bus.publish(
Puzzle.UserResult(puzzle.id, user.id, result, formerUserRating -> userPerf.intRating),
"finishPuzzle"
) inject
user.copy(perfs = user.perfs.copy(puzzle = userPerf))
} recover lila.db.recoverDuplicateKey { _ =>
// logger.info(s"ratedUntrusted ${user.id} ${puzzle.id} duplicate round")
user // has already been solved!
}
): Fu[(Round, Mode)] = ???
// val formerUserRating = user.perfs.puzzle.intRating
// api.head.find(user) flatMap {
// case Some(PuzzleHead(_, Some(c), _)) if c == puzzle.id || mobile =>
// api.head.solved(user, puzzle.id) >> {
// val userRating = user.perfs.puzzle.toRating
// val puzzleRating = puzzle.perf.toRating
// updateRatings(userRating, puzzleRating, result.glicko)
// val date = DateTime.now
// val puzzlePerf =
// puzzle.perf.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id} user")(puzzleRating)
// val userPerf =
// user.perfs.puzzle.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id}")(userRating, date)
// val round = new Round(
// id = Round.Id(user.id, puzzle.id),
// date = date,
// result = result,
// rating = formerUserRating,
// ratingDiff = userPerf.intRating - formerUserRating
// )
// historyApi.addPuzzle(user = user, completedAt = date, perf = userPerf)
// (api.round upsert round) >> {
// isStudent ?? api.round.addDenormalizedUser(round, user)
// } >> {
// puzzleColl {
// _.update.one(
// $id(puzzle.id),
// $inc(Puzzle.BSONFields.attempts -> $int(1)) ++
// $set(Puzzle.BSONFields.perf -> PuzzlePerf.puzzlePerfBSONHandler.write(puzzlePerf))
// )
// } zip userRepo.setPerf(user.id, PerfType.Puzzle, userPerf)
// } inject {
// Bus.publish(
// Puzzle.UserResult(puzzle.id, user.id, result, formerUserRating -> userPerf.intRating),
// "finishPuzzle"
// )
// round -> Mode.Rated
// }
// }
// case _ =>
// incPuzzleAttempts(puzzle) inject new Round(
// id = Round.Id(user.id, puzzle.id),
// date = DateTime.now,
// result = result,
// rating = formerUserRating,
// ratingDiff = 0
// ) -> Mode.Casual
// }
// }
private val VOLATILITY = Glicko.default.volatility
private val TAU = 0.75d
private val system = new RatingCalculator(VOLATILITY, TAU)
def incPuzzleAttempts(puzzle: Puzzle): Funit =
puzzleColl.map(_.incFieldUnchecked($id(puzzle.id), Puzzle.BSONFields.attempts))
def incPuzzlePlays(puzzle: Puzzle): Funit =
puzzleColl.map(_.incFieldUnchecked($id(puzzle.id.value), Puzzle.BSONFields.plays))
private def updateRatings(u1: Rating, u2: Rating, result: Glicko.Result): Unit = {
val results = new RatingPeriodResults()

View File

@ -6,6 +6,7 @@ import lila.common.Json._
import lila.game.GameRepo
import lila.tree
import lila.tree.Node.defaultNodeJsonWriter
import lila.user.User
final class JsonView(
gameJson: GameJson,
@ -13,45 +14,27 @@ final class JsonView(
animationDuration: scala.concurrent.duration.Duration
)(implicit ec: scala.concurrent.ExecutionContext) {
import JsonView._
def apply(
puzzle: Puzzle,
userInfos: Option[UserInfos],
mode: String,
user: Option[User],
mobileApi: Option[lila.common.ApiVersion],
round: Option[Round] = None,
voted: Option[Boolean]
round: Option[Round] = None
): Fu[JsObject] = {
val isOldMobile = mobileApi.exists(_.value < 3)
val isMobile = mobileApi.isDefined
(!isOldMobile ?? gameJson(
gameJson(
gameId = puzzle.gameId,
plies = puzzle.initialPly,
onlyLast = isMobile
) dmap some) map { gameJson =>
onlyLast = mobileApi.isDefined
) map { gameJson =>
Json
.obj(
"game" -> gameJson,
"puzzle" -> puzzleJson(puzzle, isOldMobile),
"mode" -> mode,
"attempt" -> round.ifTrue(isOldMobile).map { r =>
Json.obj(
"userRatingDiff" -> r.ratingDiff,
"win" -> r.result.win,
"seconds" -> "a few" // lol we don't have the value anymore
)
},
"voted" -> voted,
"user" -> userInfos.map(JsonView.infos(isOldMobile)),
"difficulty" -> isOldMobile.option {
Json.obj(
"choices" -> Json.arr(
Json.arr(2, "Normal")
),
"current" -> 2
)
}
"puzzle" -> puzzleJson(puzzle)
)
.noNull
.add("user" -> user.map { u =>
Json.obj("rating" -> u.perfs.puzzle.intRating)
})
}
}
@ -70,88 +53,22 @@ final class JsonView(
"is3d" -> p.is3d
)
def batch(puzzles: List[Puzzle], userInfos: UserInfos): Fu[JsObject] =
for {
games <- gameRepo.gameOptionsFromSecondary(puzzles.map(_.gameId))
jsons <- (puzzles zip games).collect { case (puzzle, Some(game)) =>
gameJson.noCache(game, puzzle.initialPly, onlyLast = true) map { gameJson =>
Json.obj(
"game" -> gameJson,
"puzzle" -> puzzleJson(puzzle, isOldMobile = false)
)
}
}.sequenceFu
} yield Json.obj(
"user" -> JsonView.infos(isOldMobile = false)(userInfos),
"puzzles" -> jsons
)
private def puzzleJson(puzzle: Puzzle, isOldMobile: Boolean): JsObject =
private def puzzleJson(puzzle: Puzzle): JsObject =
Json
.obj(
"id" -> puzzle.id,
"rating" -> puzzle.perf.intRating,
"attempts" -> puzzle.attempts,
"rating" -> puzzle.glicko.intRating,
"plays" -> puzzle.plays,
"fen" -> puzzle.fen,
"color" -> puzzle.color.name,
"initialPly" -> puzzle.initialPly,
"gameId" -> puzzle.gameId,
"lines" -> lila.puzzle.Line.toJson(puzzle.lines),
"vote" -> puzzle.vote.sum
"line" -> puzzle.line.toList.map(_.uci).mkString(" "),
"vote" -> puzzle.vote
)
.add("initialMove" -> isOldMobile.option(puzzle.initialMove.uci))
.add("branch" -> (!isOldMobile).??(makeBranch(puzzle)).map(defaultNodeJsonWriter.writes))
.add("enabled" -> puzzle.enabled)
private def makeBranch(puzzle: Puzzle): Option[tree.Branch] = {
import chess.format._
val fullSolution: List[Uci.Move] = (Line solution puzzle.lines).map { uci =>
Uci.Move(uci) err s"Invalid puzzle solution UCI $uci"
}
val solution =
if (fullSolution.isEmpty) {
logger.warn(s"Puzzle ${puzzle.id} has an empty solution from ${puzzle.lines}")
fullSolution
} else if (fullSolution.size % 2 == 0) fullSolution.init
else fullSolution
val init = chess.Game(none, puzzle.fenAfterInitialMove).withTurns(puzzle.initialPly)
val (_, branchList) = solution.foldLeft[(chess.Game, List[tree.Branch])]((init, Nil)) {
case ((prev, branches), uci) =>
val (game, move) =
prev(uci.orig, uci.dest, uci.promotion).fold(err => sys error s"puzzle ${puzzle.id} $err", identity)
val branch = tree.Branch(
id = UciCharPair(move.toUci),
ply = game.turns,
move = Uci.WithSan(move.toUci, game.pgnMoves.last),
fen = chess.format.Forsyth >> game,
check = game.situation.check,
crazyData = none
)
(game, branch :: branches)
}
branchList.foldLeft[Option[tree.Branch]](None) {
case (None, branch) => branch.some
case (Some(child), branch) => Some(branch addChild child)
}
}
}
object JsonView {
def infos(isOldMobile: Boolean)(i: UserInfos): JsObject =
Json
.obj(
"rating" -> i.user.perfs.puzzle.intRating,
"history" -> isOldMobile.option(i.history.map(_.rating)), // for mobile BC
"recent" -> i.history.map { r =>
Json.arr(r.id.puzzleId, r.ratingDiff, r.rating)
}
)
.noNull
def round(r: Round): JsObject =
Json.obj(
"ratingDiff" -> r.ratingDiff,
"win" -> r.result.win
)
implicit val puzzleIdWrites: Writes[Puzzle.Id] = stringIsoWriter(Puzzle.idIso)
}

View File

@ -1,9 +1,7 @@
package lila.puzzle
import cats.data.NonEmptyList
import chess.Color
import chess.format.{ FEN, Forsyth, Uci }
import scala.util.{ Success, Try }
import lila.rating.Glicko

View File

@ -21,12 +21,13 @@ final class PuzzleActivity(
) {
import PuzzleActivity._
import Round.RoundBSONHandler
import BsonHandlers._
import JsonView._
def stream(config: Config): Source[String, _] =
Source futureSource {
roundColl.map {
_.find($doc("_id" $startsWith config.user.id))
_.find($doc("_id" $startsWith s"${config.user.id}${Round.idSep}"))
.sort($sort desc "_id")
.batchSize(config.perSecond.value)
.cursor[Round](ReadPreference.secondaryPreferred)
@ -44,7 +45,7 @@ final class PuzzleActivity(
private def enrich(rounds: Seq[Round]): Fu[Seq[JsObject]] =
puzzleColl {
_.primitiveMap[Int, Double](
_.primitiveMap[Puzzle.Id, Double](
ids = rounds.map(_.id.puzzleId),
field = "perf.gl.r",
fieldExtractor = obj =>
@ -59,8 +60,7 @@ final class PuzzleActivity(
Json.obj(
"id" -> round.id.puzzleId,
"date" -> round.date,
"rating" -> round.rating,
"ratingDiff" -> round.ratingDiff,
"win" -> round.win,
"puzzleRating" -> puzzleRating.toInt
)
}

View File

@ -15,121 +15,101 @@ final private[puzzle] class PuzzleApi(
cacheApi: lila.memo.CacheApi
)(implicit ec: scala.concurrent.ExecutionContext) {
import Puzzle.puzzleBSONHandler
import Puzzle.BSONFields._
import BsonHandlers._
object puzzle {
def find(id: PuzzleId): Fu[Option[Puzzle]] =
puzzleColl(_.find($doc(F.id -> id)).one[Puzzle])
def find(id: Puzzle.Id): Fu[Option[Puzzle]] =
puzzleColl(_.byId[Puzzle](id.value))
def findMany(ids: List[PuzzleId]): Fu[List[Option[Puzzle]]] =
puzzleColl(_.optionsByOrderedIds[Puzzle, PuzzleId](ids)(_.id))
def latest(nb: Int): Fu[List[Puzzle]] =
puzzleColl {
_.find($empty)
.sort($doc(F.date -> -1))
.cursor[Puzzle]()
.list(nb)
}
val cachedLastId = cacheApi.unit[Int] {
_.refreshAfterWrite(1 day)
.buildAsyncFuture { _ =>
puzzleColl(lila.db.Util.findNextId) dmap (_ - 1)
}
}
def disable(id: PuzzleId): Funit =
puzzleColl {
_.update
.one(
$id(id),
$doc("$set" -> $doc(F.vote -> AggregateVote.disable))
)
.void
}
def delete(id: Puzzle.Id): Funit =
puzzleColl(_.delete.one($id(id.value))).void
}
object round {
def add(a: Round) = roundColl(_.insert.one(a))
def upsert(a: Round) = roundColl(_.update.one($id(a.id), a, upsert = true))
def addDenormalizedUser(a: Round, user: User) = roundColl(_.updateField($id(a.id), "u", user.id).void)
def reset(user: User) =
roundColl {
_.delete.one(
$doc(
Round.BSONFields.id $startsWith s"${user.id}:"
)
)
}
def find(user: User, puzzle: Puzzle): Fu[Option[Round]] =
roundColl(_.byId[Round](Round.Id(user.id, puzzle.id).toString))
}
object vote {
// def add(a: Round) = roundColl(_.insert.one(a))
def value(id: PuzzleId, user: User): Fu[Option[Boolean]] =
voteColl {
_.primitiveOne[Boolean]($id(Vote.makeId(id, user.id)), "v")
}
// def upsert(a: Round) = roundColl(_.update.one($id(a.id), a, upsert = true))
def find(id: PuzzleId, user: User): Fu[Option[Vote]] =
voteColl {
_.byId[Vote](Vote.makeId(id, user.id))
}
// def addDenormalizedUser(a: Round, user: User) = roundColl(_.updateField($id(a.id), "u", user.id).void)
def update(id: PuzzleId, user: User, v1: Option[Vote], v: Boolean): Fu[(Puzzle, Vote)] =
puzzle find id orFail s"Can't vote for non existing puzzle $id" flatMap { p1 =>
val (p2, v2) = v1 match {
case Some(from) =>
(
(p1 withVote (_.change(from.value, v))),
from.copy(v = v)
)
case None =>
(
(p1 withVote (_ add v)),
Vote(Vote.makeId(id, user.id), v)
)
}
voteColl {
_.update
.one(
$id(v2.id),
$set("v" -> v),
upsert = true
)
.void
.recover(lila.db.recoverDuplicateKey { _ => () })
} zip
puzzleColl {
_.update
.one($id(p2.id), $set(F.vote -> p2.vote))
} inject (p2 -> v2)
}
}
// def reset(user: User) =
// roundColl {
// _.delete.one(
// $doc(
// Round.BSONFields.id $startsWith s"${user.id}:"
// )
// )
// }
// }
object head {
// object vote {
def find(user: User): Fu[Option[PuzzleHead]] = headColl(_.byId[PuzzleHead](user.id))
// def value(id: PuzzleId, user: User): Fu[Option[Boolean]] =
// voteColl {
// _.primitiveOne[Boolean]($id(Vote.makeId(id, user.id)), "v")
// }
def set(h: PuzzleHead) = headColl(_.update.one($id(h.id), h, upsert = true).void)
// def find(id: PuzzleId, user: User): Fu[Option[Vote]] =
// voteColl {
// _.byId[Vote](Vote.makeId(id, user.id))
// }
def addNew(user: User, puzzleId: PuzzleId) = set(PuzzleHead(user.id, puzzleId.some, puzzleId))
// def update(id: PuzzleId, user: User, v1: Option[Vote], v: Boolean): Fu[(Puzzle, Vote)] =
// puzzle find id orFail s"Can't vote for non existing puzzle $id" flatMap { p1 =>
// val (p2, v2) = v1 match {
// case Some(from) =>
// (
// (p1 withVote (_.change(from.value, v))),
// from.copy(v = v)
// )
// case None =>
// (
// (p1 withVote (_ add v)),
// Vote(Vote.makeId(id, user.id), v)
// )
// }
// voteColl {
// _.update
// .one(
// $id(v2.id),
// $set("v" -> v),
// upsert = true
// )
// .void
// .recover(lila.db.recoverDuplicateKey { _ => () })
// } zip
// puzzleColl {
// _.update
// .one($id(p2.id), $set(F.vote -> p2.vote))
// } inject (p2 -> v2)
// }
// }
def currentPuzzleId(user: User): Fu[Option[PuzzleId]] =
find(user) dmap2 { (h: PuzzleHead) =>
h.current | h.last
}
// object head {
private[puzzle] def solved(user: User, id: PuzzleId): Funit =
head find user flatMap { headOption =>
set {
PuzzleHead(user.id, none, headOption.fold(id)(head => id atLeast head.last))
}
}
}
// def find(user: User): Fu[Option[PuzzleHead]] = headColl(_.byId[PuzzleHead](user.id))
// def set(h: PuzzleHead) = headColl(_.update.one($id(h.id), h, upsert = true).void)
// def addNew(user: User, puzzleId: PuzzleId) = set(PuzzleHead(user.id, puzzleId.some, puzzleId))
// def currentPuzzleId(user: User): Fu[Option[PuzzleId]] =
// find(user) dmap2 { (h: PuzzleHead) =>
// h.current | h.last
// }
// private[puzzle] def solved(user: User, id: PuzzleId): Funit =
// head find user flatMap { headOption =>
// set {
// PuzzleHead(user.id, none, headOption.fold(id)(head => id atLeast head.last))
// }
// }
// }
}

View File

@ -1,112 +0,0 @@
package lila.puzzle
import lila.db.AsyncColl
import lila.db.dsl._
import lila.user.User
import lila.memo.CacheApi._
final private[puzzle] class PuzzleBatch(
puzzleColl: AsyncColl,
api: PuzzleApi,
finisher: Finisher,
puzzleIdMin: PuzzleId
)(implicit ec: scala.concurrent.ExecutionContext) {
def solve(originalUser: User, data: PuzzleBatch.SolveData): Funit =
for {
puzzles <- api.puzzle findMany data.solutions.map(_.id)
user <- lila.common.Future.fold(data.solutions zip puzzles)(originalUser) {
case (user, (solution, Some(puzzle))) => finisher.ratedUntrusted(puzzle, user, solution.result)
case (user, _) => fuccess(user)
}
_ <- data.solutions.lastOption ?? { lastSolution =>
api.head.solved(user, lastSolution.id).void
}
} yield for {
first <- puzzles.headOption.flatten
last <- puzzles.lastOption.flatten
} {
if (puzzles.sizeIs > 1) logger.info(s"Batch solve ${user.id} ${puzzles.size} ${first.id}->${last.id}")
}
object select {
import Selector._
def apply(user: User, nb: Int, after: Option[PuzzleId]): Fu[List[Puzzle]] = {
api.head.find(user) flatMap {
newPuzzlesForUser(user, _, nb, after)
} addEffect { puzzles =>
lila.mon.puzzle.batch.selector.count.increment(puzzles.size).unit
}
}.mon(_.puzzle.batch.selector.time)
private def newPuzzlesForUser(
user: User,
headOption: Option[PuzzleHead],
nb: Int,
after: Option[PuzzleId]
): Fu[List[Puzzle]] = {
val rating = user.perfs.puzzle.intRating min 2300 max 900
val step = toleranceStepFor(rating, user.perfs.puzzle.nb)
api.puzzle.cachedLastId.getUnit flatMap { maxId =>
val fromId = headOption match {
case Some(PuzzleHead(_, _, l)) if l < maxId - 500 => after.fold(l)(_ atLeast l)
case _ => puzzleIdMin
}
puzzleColl { coll =>
tryRange(
coll = coll,
rating = rating,
tolerance = step,
step = step,
idRange = Range(fromId, fromId + nb * 50),
nb = nb
)
}
}
}
private def tryRange(
coll: Coll,
rating: Int,
tolerance: Int,
step: Int,
idRange: Range,
nb: Int
): Fu[List[Puzzle]] =
coll
.find(
rangeSelector(
rating = rating,
tolerance = tolerance,
idRange = idRange
)
)
.cursor[Puzzle]()
.list(nb) flatMap {
case res if res.sizeIs < nb && (tolerance + step) <= toleranceMax =>
tryRange(
coll = coll,
rating = rating,
tolerance = tolerance + step,
step = step,
idRange = Range(idRange.min, idRange.max + 100),
nb = nb
)
case res => fuccess(res)
}
}
}
object PuzzleBatch {
case class Solution(id: PuzzleId, win: Boolean) {
def result = Result(win)
}
case class SolveData(solutions: List[Solution])
import play.api.libs.json._
implicit private val SolutionReads = Json.reads[Solution]
implicit val SolveDataReads = Json.reads[SolveData]
}

View File

@ -1,23 +0,0 @@
package lila.puzzle
case class PuzzleHead(
_id: lila.user.User.ID,
current: Option[PuzzleId], // current puzzle assigned to user (rated)
last: PuzzleId // last puzzle assigned to user
) {
def id = _id
}
object PuzzleHead {
object BSONFields {
val id = "_id"
val current = "current"
val last = "last"
}
import reactivemongo.api.bson._
implicit private[puzzle] val puzzleHeadBSONHandler = Macros.handler[PuzzleHead]
}

View File

@ -7,85 +7,26 @@ import lila.user.User
case class Round(
id: Round.Id,
date: DateTime,
result: Result,
rating: Int,
ratingDiff: Int
) {
def userPostRating = rating + ratingDiff
}
win: Boolean,
vote: Option[Boolean],
// tags: List[RoundTag],
weight: Option[Int]
) {}
object Round {
case class Id(userId: User.ID, puzzleId: PuzzleId)
val idSep = ':'
case class Id(userId: User.ID, puzzleId: Puzzle.Id) {
override def toString = s"${userId}$idSep${puzzleId}"
}
object BSONFields {
val id = "_id"
val date = "a"
val magic = "m"
}
import reactivemongo.api.bson._
import lila.db.BSON
import lila.db.dsl._
import scala.util.Success
import BSON.BSONJodaDateTimeHandler
/* We shift the puzzle ID by -60000
* Because the initial puzzle is 60000 and something.
* This way the lowest puzzle ID is 00000
* and it allows us to sort rounds by ID: userId:puzzleId
* because all puzzle IDs have the same length */
private val shiftValue = -60000
def encode(puzzleId: PuzzleId) = puzzleId + shiftValue
def decode(puzzleId: PuzzleId) = puzzleId - shiftValue
private val idSep = ':'
implicit val roundIdHandler = tryHandler[Id](
{ case BSONString(v) =>
v split idSep match {
case Array(userId, puzzleId) => Success(Id(userId, decode(Integer parseInt puzzleId)))
case _ => handlerBadValue(s"Invalid puzzle round id $v")
}
},
id => {
val puzzleId = "%05d".format(encode(id.puzzleId))
BSONString(s"${id.userId}$idSep$puzzleId")
}
)
implicit val RoundBSONHandler = new BSON[Round] {
import BSONFields._
/* `magic` field stores;
* - win: boolean | 1 bit
* - ratingDiff: int | 15 bits
* - rating: int | 16 bits
*/
def reads(r: BSON.Reader): Round = {
val m = r int magic
val win = m >>> 31 != 0
val ratingDiff = (m << 1) >>> 17
Round(
id = r.get[Id](id),
date = r.get[DateTime](date),
result = Result(win),
rating = m & (-1 >>> 16),
ratingDiff = if (win) ratingDiff else -ratingDiff
)
}
def writes(w: BSON.Writer, o: Round) =
BSONDocument(
id -> o.id,
date -> o.date,
magic -> {
(o.result.win ?? (1 << 31)) |
(Math.abs(o.ratingDiff) << 16) |
o.rating
}
)
val id = "_id"
val date = "d"
val win = "w"
val vote = "v"
val weight = "w"
}
}

View File

@ -1,146 +0,0 @@
package lila.puzzle
import Puzzle.{ BSONFields => F }
import reactivemongo.api.ReadPreference
import lila.common.ThreadLocalRandom
import lila.db.AsyncColl
import lila.db.dsl._
import lila.memo.CacheApi._
import lila.user.User
final private[puzzle] class Selector(
puzzleColl: AsyncColl,
api: PuzzleApi,
puzzleIdMin: Int
)(implicit ec: scala.concurrent.ExecutionContext) {
import Selector._
private lazy val anonIdsCache: Fu[Vector[Int]] =
puzzleColl { // this query precisely matches a mongodb partial index
_.find($doc(F.voteNb $gte 100), $id(true).some)
.sort($sort desc F.voteRatio)
.cursor[Bdoc](ReadPreference.secondaryPreferred)
.vector(anonPuzzles)
.map(_.flatMap(_ int "_id"))
}
def apply(me: Option[User]): Fu[Puzzle] = {
me match {
// anon
case None =>
anonIdsCache flatMap { ids =>
puzzleColl {
_.byId[Puzzle, Int](ids(ThreadLocalRandom nextInt ids.size))
}
}
// user
case Some(user) =>
api.head find user flatMap {
// new player
case None =>
api.puzzle find puzzleIdMin flatMap { puzzleOption =>
puzzleOption ?? { p =>
api.head.addNew(user, p.id)
} inject puzzleOption
}
// current puzzle
case Some(PuzzleHead(_, Some(current), _)) => api.puzzle find current
// find new based on last
case Some(PuzzleHead(_, _, last)) =>
newPuzzleForUser(user, last) flatMap {
// user played all puzzles. Reset rounds and start anew.
case None =>
api.puzzle.cachedLastId.getUnit flatMap { maxId =>
(last > maxId - 1000) ?? {
api.round.reset(user) >> api.puzzle.find(puzzleIdMin)
}
}
case Some(found) => fuccess(found.some)
} flatMap { puzzleOption =>
puzzleOption ?? { p =>
api.head.addNew(user, p.id)
} inject puzzleOption
}
}
}
}.mon(_.puzzle.selector.time) orFailWith NoPuzzlesAvailableException addEffect { puzzle =>
if (puzzle.vote.sum < -1000)
logger.info(s"Select #${puzzle.id} vote.sum: ${puzzle.vote.sum} for ${me.fold("Anon")(_.username)} (${me
.fold("?")(_.perfs.puzzle.intRating.toString)})")
else
lila.mon.puzzle.selector.vote.record(1000 + puzzle.vote.sum).unit
}
private def newPuzzleForUser(user: User, lastPlayed: PuzzleId): Fu[Option[Puzzle]] = {
val rating = user.perfs.puzzle.intRating atMost 2300 atLeast 900
val step = toleranceStepFor(rating, user.perfs.puzzle.nb)
puzzleColl { coll =>
tryRange(
coll = coll,
rating = rating,
tolerance = step,
step = step,
idRange = Range(lastPlayed, lastPlayed + 200)
)
}
}
private def tryRange(
coll: Coll,
rating: Int,
tolerance: Int,
step: Int,
idRange: Range
): Fu[Option[Puzzle]] =
coll
.find(
rangeSelector(
rating = rating,
tolerance = tolerance,
idRange = idRange
)
)
.sort($sort asc F.id)
.one[Puzzle] flatMap {
case None if (tolerance + step) <= toleranceMax =>
tryRange(coll, rating, tolerance + step, step, Range(idRange.min, idRange.max + 100))
case res => fuccess(res)
}
}
final private object Selector {
case object NoPuzzlesAvailableException extends lila.base.LilaException {
val message = "No puzzles available"
}
val anonPuzzles = 8192
val toleranceMax = 1000
def toleranceStepFor(rating: Int, nbPuzzles: Int) = {
math.abs(1500 - rating) match {
case d if d >= 500 => 300
case d if d >= 300 => 250
case _ => 200
}
} * {
// increase rating tolerance for puzzle blitzers,
// so they get more puzzles to play
if (nbPuzzles > 10000) 2
else if (nbPuzzles > 5000) 3 / 2
else 1
}
def rangeSelector(rating: Int, tolerance: Int, idRange: Range) =
$doc(
F.id $gt idRange.min $lt idRange.max,
F.rating $gt (rating - tolerance) $lt (rating + tolerance),
$or(
F.voteRatio $gt AggregateVote.minRatio,
F.voteNb $lt AggregateVote.minVotes
)
)
}

View File

@ -1,39 +0,0 @@
package lila.puzzle
import reactivemongo.api.bson._
import lila.db.AsyncColl
import lila.db.dsl._
import lila.user.User
case class UserInfos(user: User, history: List[Round])
final class UserInfosApi(roundColl: AsyncColl, currentPuzzleId: User => Fu[Option[PuzzleId]])(implicit
ec: scala.concurrent.ExecutionContext
) {
private val historySize = 15
private val chartSize = 15
def apply(user: Option[User]): Fu[Option[UserInfos]] = user ?? { apply(_) dmap (_.some) }
def apply(user: User): Fu[UserInfos] =
for {
current <- currentPuzzleId(user)
rounds <- fetchRounds(user.id, current)
} yield UserInfos(user, rounds)
private def fetchRounds(userId: User.ID, currentPuzzleId: Option[PuzzleId]): Fu[List[Round]] = {
val idSelector = $doc("$regex" -> BSONRegex(s"^$userId:", "")) ++
currentPuzzleId.?? { id =>
$doc("$lte" -> s"$userId:${Round encode id}")
}
roundColl {
_.find($doc(Round.BSONFields.id -> idSelector))
.sort($sort desc Round.BSONFields.id)
.cursor[Round]()
.list(historySize atLeast chartSize)
.dmap(_.reverse)
}
}
}

View File

@ -1,18 +0,0 @@
package lila.puzzle
case class Vote(
_id: String, // puzzleId/userId
v: Boolean
) {
def id = _id
def value = v
}
object Vote {
def makeId(puzzleId: PuzzleId, userId: String) = s"$puzzleId/$userId"
implicit val voteBSONHandler = reactivemongo.api.bson.Macros.handler[Vote]
}

View File

@ -87,8 +87,8 @@ object BuildSettings {
"-Wdead-code",
"-Wextra-implicit",
// "-Wnumeric-widen",
"-Wunused:imports",
"-Wunused:locals",
// "-Wunused:imports",
// "-Wunused:locals",
"-Wunused:patvars",
// "-Wunused:privates", // unfortunately doesn't work with macros
// "-Wunused:implicits",