puzzle WIP

pull/7680/head
Thibault Duplessis 2020-11-14 19:02:07 +01:00
parent 5f54e07d7e
commit dd9abd4532
17 changed files with 237 additions and 120 deletions

View File

@ -7,7 +7,7 @@ import views._
import lila.api.Context
import lila.app._
import lila.common.config.MaxPerSecond
import lila.puzzle.{ Result, Puzzle => Puz }
import lila.puzzle.{ Result, PuzzleRound, Puzzle => Puz }
final class Puzzle(
env: Env,
@ -16,7 +16,7 @@ final class Puzzle(
private def renderJson(
puzzle: Puz,
round: Option[lila.puzzle.Round] = None
round: Option[PuzzleRound] = None
)(implicit ctx: Context): Fu[JsObject] =
env.puzzle.jsonView(
puzzle = puzzle,
@ -50,10 +50,9 @@ 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.cursorApi.nextPuzzleFor(ctx.me.get) flatMap { puzzle =>
renderShow(puzzle)
}
}
}

View File

@ -124,10 +124,9 @@ puzzle {
uri = "mongodb://127.0.0.1:27017/lichess"
}
collection {
puzzle = puzzle
round = puzzle_round2
vote = puzzle_vote
head = puzzle_head
puzzle = puzzle2_puzzle
round = puzzle2_round
path = puzzle2_path
}
api.token = ${api.token}
animation.duration = ${chessground.animation.duration}

View File

@ -42,7 +42,7 @@ case class StudentProgress(
final class ClasProgressApi(
gameRepo: GameRepo,
historyApi: lila.history.HistoryApi,
puzzleRoundRepo: lila.puzzle.RoundRepo,
puzzleColls: lila.puzzle.PuzzleColls,
getStudentIds: () => Fu[Set[User.ID]]
)(implicit ec: scala.concurrent.ExecutionContext) {
@ -76,7 +76,7 @@ final class ClasProgressApi(
}
private def getPuzzleStats(userIds: List[User.ID], days: Int): Fu[Map[User.ID, PlayStats]] =
puzzleRoundRepo.coll.get.flatMap {
puzzleColls.round {
_.aggregateList(
maxDocs = Int.MaxValue,
ReadPreference.secondaryPreferred

View File

@ -10,7 +10,7 @@ final class Env(
userRepo: lila.user.UserRepo,
gameRepo: lila.game.GameRepo,
historyApi: lila.history.HistoryApi,
puzzleRoundRepo: lila.puzzle.RoundRepo,
puzzleColls: lila.puzzle.PuzzleColls,
msgApi: lila.msg.MsgApi,
lightUserAsync: lila.common.LightUser.Getter,
securityForms: lila.security.SecurityForm,

View File

@ -2,7 +2,11 @@ package lila.db
import dsl._
final class AsyncColl(resolve: () => Fu[Coll])(implicit ec: scala.concurrent.ExecutionContext) {
import lila.common.config.CollName
final class AsyncColl(val name: CollName, resolve: () => Fu[Coll])(implicit
ec: scala.concurrent.ExecutionContext
) {
def get: Fu[Coll] = resolve()

View File

@ -24,7 +24,7 @@ final class AsyncDb(
conn database dbName.getOrElse("lichess")
}
def apply(name: CollName) = new AsyncColl(() => db.dmap(_(name.value)))
def apply(name: CollName) = new AsyncColl(name, () => db.dmap(_(name.value)))
}
final class Db(

View File

@ -27,9 +27,8 @@ final class CacheApi(
}
// AsyncLoadingCache for a single entry
def unit[V](build: Builder => AsyncLoadingCache[Unit, V]): AsyncLoadingCache[Unit, V] = {
def unit[V](build: Builder => AsyncLoadingCache[Unit, V]): AsyncLoadingCache[Unit, V] =
build(scaffeine initialCapacity 1)
}
// AsyncLoadingCache with monitoring and a synchronous getter
def sync[K, V](

View File

@ -2,7 +2,6 @@ package lila.puzzle
import chess.format.{ FEN, Uci }
import reactivemongo.api.bson._
import scala.util.Success
import lila.db.BSON
@ -37,15 +36,34 @@ private[puzzle] object BsonHandlers {
)
}
implicit val RoundIdHandler = tryHandler[Round.Id](
implicit val RoundIdHandler = tryHandler[PuzzleRound.Id](
{ case BSONString(v) =>
v split Round.idSep match {
case Array(userId, puzzleId) => Success(Round.Id(userId, Puzzle.Id(puzzleId)))
v split PuzzleRound.idSep match {
case Array(userId, puzzleId) => Success(PuzzleRound.Id(userId, Puzzle.Id(puzzleId)))
case _ => handlerBadValue(s"Invalid puzzle round id $v")
}
},
id => BSONString(id.toString)
)
implicit val RoundBSONHandler = Macros.handler[Round]
implicit val RoundHandler = new BSON[PuzzleRound] {
import PuzzleRound.BSONFields._
def reads(r: BSON.Reader) = PuzzleRound(
id = r.get[PuzzleRound.Id](id),
date = r.date(date),
win = r.bool(win),
vote = r.boolO(vote),
weight = r.intO(weight)
)
def writes(w: BSON.Writer, r: PuzzleRound) =
$doc(
id -> r.id,
date -> r.date,
win -> r.win,
vote -> r.vote,
weight -> r.weight
)
}
implicit val PathIdBSONHandler: BSONHandler[Puzzle.PathId] = stringIsoHandler(Puzzle.pathIdIso)
}

View File

@ -5,12 +5,11 @@ import org.joda.time.DateTime
import Puzzle.{ BSONFields => F }
import scala.concurrent.duration._
import lila.db.AsyncColl
import lila.db.dsl._
import lila.memo.CacheApi._
final private[puzzle] class Daily(
coll: AsyncColl,
colls: PuzzleColls,
renderer: lila.hub.actors.Renderer,
cacheApi: lila.memo.CacheApi
)(implicit ec: scala.concurrent.ExecutionContext) {
@ -45,7 +44,7 @@ final private[puzzle] class Daily(
}
private def findCurrent =
coll {
colls.puzzle {
_.find(
$doc(F.day $gt DateTime.now.minusMinutes(24 * 60 - 15))
)
@ -53,7 +52,7 @@ final private[puzzle] class Daily(
}
private def findNew =
coll { c =>
colls.puzzle { c =>
c.find($doc(F.day $exists false))
.sort($doc(F.vote -> -1))
.one[Puzzle] flatMap {

View File

@ -7,19 +7,23 @@ import play.api.Configuration
import scala.concurrent.duration.FiniteDuration
import lila.common.config._
import lila.db.AsyncColl
@Module
private class PuzzleConfig(
@ConfigName("mongodb.uri") val mongoUri: String,
@ConfigName("collection.puzzle") val puzzleColl: CollName,
@ConfigName("collection.round") val roundColl: CollName,
@ConfigName("collection.vote") val voteColl: CollName,
@ConfigName("collection.head") val headColl: CollName,
@ConfigName("collection.path") val pathColl: CollName,
@ConfigName("api.token") val apiToken: Secret,
@ConfigName("animation.duration") val animationDuration: FiniteDuration
)
case class RoundRepo(coll: lila.db.AsyncColl)
case class PuzzleColls(
puzzle: AsyncColl,
round: AsyncColl,
path: AsyncColl
)
@Module
final class Env(
@ -38,45 +42,29 @@ final class Env(
private val config = appConfig.get[PuzzleConfig]("puzzle")(AutoConfig.loader)
private lazy val db = mongo.asyncDb("puzzle", config.mongoUri)
private def puzzleColl = db(config.puzzleColl)
private def roundColl = db(config.roundColl)
private def voteColl = db(config.voteColl)
private def headColl = db(config.headColl)
private lazy val db = mongo.asyncDb("puzzle", config.mongoUri)
lazy val colls = PuzzleColls(
puzzle = db(config.puzzleColl),
round = db(config.roundColl),
path = db(config.pathColl)
)
private lazy val gameJson: GameJson = wire[GameJson]
lazy val jsonView = wire[JsonView]
lazy val api = new PuzzleApi(
puzzleColl = puzzleColl,
roundColl = roundColl,
voteColl = voteColl,
headColl = headColl,
cacheApi = cacheApi
)
lazy val api: PuzzleApi = wire[PuzzleApi]
lazy val roundRepo = RoundRepo(roundColl)
lazy val cursorApi: PuzzleCursorApi = wire[PuzzleCursorApi]
lazy val finisher = new Finisher(
historyApi = historyApi,
userRepo = userRepo,
api = api,
puzzleColl = puzzleColl
)
lazy val finisher = wire[Finisher]
lazy val forms = PuzzleForm
lazy val daily = new Daily(
puzzleColl,
renderer,
cacheApi = cacheApi
)
lazy val daily = wire[Daily]
lazy val activity = new PuzzleActivity(
puzzleColl = puzzleColl,
roundColl = roundColl
)
lazy val activity = wire[PuzzleActivity]
def cli =
new lila.common.Cli {

View File

@ -13,10 +13,10 @@ final private[puzzle] class Finisher(
api: PuzzleApi,
userRepo: UserRepo,
historyApi: lila.history.HistoryApi,
puzzleColl: AsyncColl
colls: PuzzleColls
)(implicit ec: scala.concurrent.ExecutionContext) {
def apply(puzzle: Puzzle, user: User, result: Result, isStudent: Boolean): Fu[Round] =
def apply(puzzle: Puzzle, user: User, result: Result, isStudent: Boolean): Fu[PuzzleRound] =
api.round.find(user, puzzle) flatMap { prevRound =>
val now = DateTime.now
val formerUserRating = user.perfs.puzzle.intRating
@ -29,8 +29,8 @@ final private[puzzle] class Finisher(
user.perfs.puzzle.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id}")(userRating, now)
val round = prevRound
.fold(
Round(
id = Round.Id(user.id, puzzle.id),
PuzzleRound(
id = PuzzleRound.Id(user.id, puzzle.id),
date = now,
win = result.win,
vote = none,
@ -62,7 +62,7 @@ final private[puzzle] class Finisher(
private val system = new RatingCalculator(VOLATILITY, TAU)
def incPuzzlePlays(puzzle: Puzzle): Funit =
puzzleColl.map(_.incFieldUnchecked($id(puzzle.id.value), Puzzle.BSONFields.plays))
colls.puzzle.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

@ -19,7 +19,7 @@ final class JsonView(
def apply(
puzzle: Puzzle,
user: Option[User],
round: Option[Round] = None
round: Option[PuzzleRound] = None
): Fu[JsObject] = {
gameJson(
gameId = puzzle.gameId,

View File

@ -35,7 +35,7 @@ object Puzzle {
case class Id(value: String) extends AnyVal with StringValue
case class Line(initial: Uci.Move, first: Uci.Move, more: List[(Uci.Move, Uci.Move)])
case class PathId(value: String) extends AnyVal with StringValue
case class UserResult(
puzzleId: Id,
@ -55,5 +55,6 @@ object Puzzle {
val plays = "plays"
}
implicit val idIso = lila.common.Iso.string[Id](Id.apply, _.value)
implicit val idIso = lila.common.Iso.string[Id](Id.apply, _.value)
implicit val pathIdIso = lila.common.Iso.string[PathId](PathId.apply, _.value)
}

View File

@ -13,8 +13,7 @@ import lila.db.dsl._
import lila.user.User
final class PuzzleActivity(
puzzleColl: AsyncColl,
roundColl: AsyncColl
colls: PuzzleColls
)(implicit
ec: scala.concurrent.ExecutionContext,
system: akka.actor.ActorSystem
@ -26,11 +25,11 @@ final class PuzzleActivity(
def stream(config: Config): Source[String, _] =
Source futureSource {
roundColl.map {
_.find($doc("_id" $startsWith s"${config.user.id}${Round.idSep}"))
colls.round.map {
_.find($doc("_id" $startsWith s"${config.user.id}${PuzzleRound.idSep}"))
.sort($sort desc "_id")
.batchSize(config.perSecond.value)
.cursor[Round](ReadPreference.secondaryPreferred)
.cursor[PuzzleRound](ReadPreference.secondaryPreferred)
.documentSource()
.take(config.max | Int.MaxValue)
.grouped(config.perSecond.value)
@ -43,8 +42,8 @@ final class PuzzleActivity(
}
}
private def enrich(rounds: Seq[Round]): Fu[Seq[JsObject]] =
puzzleColl {
private def enrich(rounds: Seq[PuzzleRound]): Fu[Seq[JsObject]] =
colls.puzzle {
_.primitiveMap[Puzzle.Id, Double](
ids = rounds.map(_.id.puzzleId),
field = "perf.gl.r",

View File

@ -1,18 +1,15 @@
package lila.puzzle
import Puzzle.{ BSONFields => F }
import scala.concurrent.duration._
import lila.db.AsyncColl
import lila.db.dsl._
import lila.user.User
import Puzzle.{ BSONFields => F }
import lila.memo.CacheApi
import lila.user.{ User, UserRepo }
final private[puzzle] class PuzzleApi(
puzzleColl: AsyncColl,
roundColl: AsyncColl,
voteColl: AsyncColl,
headColl: AsyncColl,
cacheApi: lila.memo.CacheApi
colls: PuzzleColls
)(implicit ec: scala.concurrent.ExecutionContext) {
import Puzzle.BSONFields._
@ -21,32 +18,22 @@ final private[puzzle] class PuzzleApi(
object puzzle {
def find(id: Puzzle.Id): Fu[Option[Puzzle]] =
puzzleColl(_.byId[Puzzle](id.value))
colls.puzzle(_.byId[Puzzle](id.value))
def delete(id: Puzzle.Id): Funit =
puzzleColl(_.delete.one($id(id.value))).void
colls.puzzle(_.delete.one($id(id.value))).void
}
object round {
def find(user: User, puzzle: Puzzle): Fu[Option[Round]] =
roundColl(_.byId[Round](Round.Id(user.id, puzzle.id).toString))
def find(user: User, puzzle: Puzzle): Fu[Option[PuzzleRound]] =
colls.round(_.byId[PuzzleRound](PuzzleRound.Id(user.id, puzzle.id).toString))
def upsert(a: Round) = roundColl(_.update.one($id(a.id), a, upsert = true))
def upsert(a: PuzzleRound) = colls.round(_.update.one($id(a.id), a, upsert = true))
def addDenormalizedUser(a: Round, user: User) = roundColl(
_.updateField($id(a.id), Round.BSONFields.user, user.id).void
def addDenormalizedUser(a: PuzzleRound, user: User): Funit = colls.round(
_.updateField($id(a.id), PuzzleRound.BSONFields.user, user.id).void
)
// def reset(user: User) =
// roundColl {
// _.delete.one(
// $doc(
// Round.BSONFields.id $startsWith s"${user.id}:"
// )
// )
// }
// }
}
// object vote {
@ -91,25 +78,4 @@ final private[puzzle] class PuzzleApi(
// } inject (p2 -> v2)
// }
// }
// object head {
// 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

@ -0,0 +1,145 @@
package lila.puzzle
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext
import lila.db.dsl._
import lila.memo.CacheApi
import lila.rating.{ Perf, PerfType }
import lila.user.{ User, UserRepo }
private case class PuzzleCursor(
path: Puzzle.PathId,
previousPaths: Set[Puzzle.PathId],
positionInPath: Int
) {
def switchTo(pathId: Puzzle.PathId) = copy(
path = pathId,
previousPaths = previousPaths + pathId,
positionInPath = 0
)
def next = copy(positionInPath = positionInPath + 1)
}
final class PuzzleCursorApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: UserRepo)(implicit
ec: ExecutionContext
) {
import BsonHandlers._
import Puzzle.PathId
private[puzzle] def cursorOf(user: User): Fu[PuzzleCursor] =
cursors.get(user.id)
sealed private trait NextPuzzleResult
private object NextPuzzleResult {
case object PathMissing extends NextPuzzleResult
case object PathEnded extends NextPuzzleResult
case class PuzzleMissing(id: Puzzle.Id) extends NextPuzzleResult
case class PuzzleAlreadyPlayed(puzzle: Puzzle) extends NextPuzzleResult
case class PuzzleFound(puzzle: Puzzle) extends NextPuzzleResult
}
def nextPuzzleFor(user: User, isRetry: Boolean = false): Fu[Puzzle] =
cursorOf(user) flatMap { cursor =>
import NextPuzzleResult._
nextPuzzleResult(user, cursor).thenPp flatMap {
case PathMissing | PathEnded if !isRetry =>
nextPathIdFor(user.id, cursor.previousPaths) flatMap {
case None => fufail(s"No remaining puzzle path for ${user.id}")
case Some(pathId) =>
val newCursor = cursor switchTo pathId
cursors.put(user.id, fuccess(newCursor))
nextPuzzleFor(user, isRetry = true)
}
case PathMissing | PathEnded => fufail(s"Puzzle patth missing or ended for ${user.id}")
case PuzzleMissing(id) =>
logger.warn(s"Puzzle missing: $id")
cursors.put(user.id, fuccess(cursor.next))
nextPuzzleFor(user, isRetry = isRetry)
case PuzzleAlreadyPlayed(_) =>
cursors.put(user.id, fuccess(cursor.next))
nextPuzzleFor(user, isRetry = isRetry)
case PuzzleFound(puzzle) => fuccess(puzzle)
}
}
private def nextPuzzleResult(user: User, cursor: PuzzleCursor): Fu[NextPuzzleResult] =
colls.path {
_.aggregateOne() { framework =>
import framework._
Match($id(cursor.path)) -> List(
Project($doc("puzzleId" -> $doc("$arrayElemAt" -> $arr("$ids", 0)))),
PipelineOperator(
$doc(
"$lookup" -> $doc(
"from" -> colls.puzzle.name.value,
"localField" -> "puzzleId",
"foreignField" -> "_id",
"as" -> "puzzle"
)
)
),
PipelineOperator(
$doc(
"$lookup" -> $doc(
"from" -> colls.round.name.value,
"let" -> $doc(
"roundId" -> $doc("$concat" -> $arr(s"${user.id}${PuzzleRound.idSep}", "$puzzleId"))
),
"pipeline" -> $arr(
$doc("$match" -> $id("$$roundId")),
// $doc("$match" -> $id("thibault:313og")),
$doc("$project" -> $doc("i" -> "$$roundId"))
),
"as" -> "round"
)
)
)
)
}.map { docOpt =>
import NextPuzzleResult._
println(docOpt map lila.db.BSON.debug)
docOpt.fold[NextPuzzleResult](PathMissing) { doc =>
doc.getAsOpt[Puzzle.Id]("puzzleId").fold[NextPuzzleResult](PathEnded) { puzzleId =>
doc
.getAsOpt[List[Puzzle]]("puzzle")
.flatMap(_.headOption)
.fold[NextPuzzleResult](PuzzleMissing(puzzleId)) { puzzle =>
if (doc.getAsOpt[List[Bdoc]]("round").exists(_.nonEmpty)) PuzzleAlreadyPlayed(puzzle)
else PuzzleFound(puzzle)
}
}
}
}
}
private val cursors = cacheApi[User.ID, PuzzleCursor](32768, "puzzle.cursor")(
_.expireAfterWrite(1 hour)
.buildAsyncFuture { userId =>
nextPathIdFor(userId, Set.empty)
.orFail(s"No puzzle path found for $userId")
.dmap(pathId => PuzzleCursor(pathId, Set.empty, 0))
}
)
private def nextPathIdFor(userId: User.ID, previousPaths: Set[PathId]): Fu[Option[PathId]] =
userRepo.perfOf(userId, PerfType.Puzzle).dmap(_ | Perf.default) flatMap { perf =>
colls.path {
_.aggregateOne() { framework =>
import framework._
Match(
$doc(
"tier" -> "top",
"min" $lte perf.glicko.rating,
"max" $gt perf.glicko.rating,
"_id" $nin previousPaths
)
) -> List(
Project($id(true)),
Sample(1)
)
}.dmap(_.flatMap(_.getAsOpt[PathId]("_id")))
}
}
}

View File

@ -4,8 +4,8 @@ import org.joda.time.DateTime
import lila.user.User
case class Round(
id: Round.Id,
case class PuzzleRound(
id: PuzzleRound.Id,
date: DateTime,
win: Boolean,
vote: Option[Boolean],
@ -13,7 +13,7 @@ case class Round(
weight: Option[Int]
) {}
object Round {
object PuzzleRound {
val idSep = ':'