puzzle WIP
parent
400b8d23c5
commit
11e3bde24e
|
@ -97,7 +97,6 @@ final class Puzzle(
|
|||
def round3(id: String) =
|
||||
OpenBody { implicit ctx =>
|
||||
NoBot {
|
||||
fuccess(Ok(Json.obj()))
|
||||
implicit val req = ctx.body
|
||||
OptionFuResult(env.puzzle.api.puzzle find Puz.Id(id)) { puzzle =>
|
||||
lila.mon.puzzle.round.attempt(ctx.isAuth).increment()
|
||||
|
@ -110,24 +109,28 @@ final class Puzzle(
|
|||
case Some(me) =>
|
||||
for {
|
||||
isStudent <- env.clas.api.student.isStudent(me.id)
|
||||
round <- env.puzzle.finisher(
|
||||
(round, perf) <- env.puzzle.finisher(
|
||||
puzzle = puzzle,
|
||||
user = me,
|
||||
result = Result(resultInt == 1),
|
||||
isStudent = isStudent
|
||||
)
|
||||
_ = env.puzzle.cursor.onRound(round)
|
||||
me2 <- env.user.repo.byId(me.id).dmap(_ | me)
|
||||
// infos <- env.puzzle userInfos me2
|
||||
} yield Ok(
|
||||
Json.obj(
|
||||
"user" -> env.puzzle.jsonView.userJson(me)
|
||||
// "round" -> lila.puzzle.JsonView.round(round)
|
||||
"perf" -> Json
|
||||
.obj("rating" -> perf.intRating)
|
||||
.add("provisional" -> perf.provisional),
|
||||
"round" -> Json
|
||||
.obj(
|
||||
"win" -> round.win,
|
||||
"ratingDiff" -> (me.perfs.puzzle.intRating - perf.intRating)
|
||||
)
|
||||
.add("vote" -> round.vote)
|
||||
)
|
||||
)
|
||||
case None =>
|
||||
env.puzzle.finisher.incPuzzlePlays(puzzle)
|
||||
Ok(Json.obj("user" -> false)).fuccess
|
||||
fuccess(NoContent)
|
||||
}
|
||||
)
|
||||
.dmap(_ as JSON)
|
||||
|
@ -138,21 +141,18 @@ final class Puzzle(
|
|||
def vote(id: String) =
|
||||
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.update(Puz.Id(id), me, vote == 1) map { newVote =>
|
||||
if (vote == 1) lila.mon.puzzle.vote.up.increment()
|
||||
else lila.mon.puzzle.vote.down.increment()
|
||||
jsonOkResult
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,12 +2,14 @@ package lila.puzzle
|
|||
|
||||
import org.goochjs.glicko2.{ Rating, RatingCalculator, RatingPeriodResults }
|
||||
import org.joda.time.DateTime
|
||||
import scala.util.chaining._
|
||||
|
||||
import lila.common.Bus
|
||||
import lila.db.AsyncColl
|
||||
import lila.db.dsl._
|
||||
import lila.rating.{ Glicko, PerfType }
|
||||
import lila.user.{ User, UserRepo }
|
||||
import lila.rating.Perf
|
||||
|
||||
final private[puzzle] class Finisher(
|
||||
api: PuzzleApi,
|
||||
|
@ -16,15 +18,32 @@ final private[puzzle] class Finisher(
|
|||
colls: PuzzleColls
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
def apply(puzzle: Puzzle, user: User, result: Result, isStudent: Boolean): Fu[PuzzleRound] =
|
||||
import BsonHandlers._
|
||||
|
||||
def apply(puzzle: Puzzle, user: User, result: Result, isStudent: Boolean): Fu[(PuzzleRound, Perf)] =
|
||||
api.round.find(user, puzzle) flatMap { prevRound =>
|
||||
val now = DateTime.now
|
||||
val formerUserRating = user.perfs.puzzle.intRating
|
||||
val userRating = user.perfs.puzzle.toRating
|
||||
// val puzzleRating = puzzle.perf.toRating
|
||||
// updateRatings(userRating, puzzleRating, result.glicko)
|
||||
// val puzzlePerf =
|
||||
// puzzle.perf.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id} user")(puzzleRating)
|
||||
val puzzleRating = new Rating(
|
||||
puzzle.glicko.rating atLeast Glicko.minRating,
|
||||
puzzle.glicko.deviation,
|
||||
puzzle.glicko.volatility,
|
||||
puzzle.plays,
|
||||
null
|
||||
)
|
||||
updateRatings(userRating, puzzleRating, result.glicko)
|
||||
val newPuzzleGlicko = user.perfs.puzzle.established
|
||||
.option {
|
||||
Glicko(
|
||||
rating = puzzleRating.getRating
|
||||
.atMost(puzzle.glicko.rating + Glicko.maxRatingDelta)
|
||||
.atLeast(puzzle.glicko.rating - Glicko.maxRatingDelta),
|
||||
deviation = puzzleRating.getRatingDeviation,
|
||||
volatility = puzzleRating.getVolatility
|
||||
)
|
||||
}
|
||||
.filter(_.sanityCheck)
|
||||
val userPerf =
|
||||
user.perfs.puzzle.addOrReset(_.puzzle.crazyGlicko, s"puzzle ${puzzle.id}")(userRating, now)
|
||||
val round = prevRound
|
||||
|
@ -37,24 +56,25 @@ final private[puzzle] class Finisher(
|
|||
weight = none
|
||||
)
|
||||
)(_.copy(win = result.win))
|
||||
// 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 {
|
||||
api.round.upsert(round) zip
|
||||
isStudent.??(api.round.addDenormalizedUser(round, user)) zip
|
||||
prevRound.isEmpty.?? {
|
||||
colls.puzzle {
|
||||
_.update
|
||||
.one(
|
||||
$id(puzzle.id),
|
||||
$inc(Puzzle.BSONFields.plays -> $int(1)) ++ newPuzzleGlicko ?? { glicko =>
|
||||
$set(Puzzle.BSONFields.glicko -> Glicko.glickoBSONHandler.write(glicko))
|
||||
}
|
||||
)
|
||||
.void
|
||||
}
|
||||
} zip
|
||||
userRepo.setPerf(user.id, PerfType.Puzzle, userPerf) >>-
|
||||
Bus.publish(
|
||||
Puzzle.UserResult(puzzle.id, user.id, result, formerUserRating -> userPerf.intRating),
|
||||
"finishPuzzle"
|
||||
)
|
||||
round
|
||||
}
|
||||
) inject (round -> userPerf)
|
||||
}
|
||||
|
||||
private val VOLATILITY = Glicko.default.volatility
|
||||
|
@ -62,7 +82,7 @@ final private[puzzle] class Finisher(
|
|||
private val system = new RatingCalculator(VOLATILITY, TAU)
|
||||
|
||||
def incPuzzlePlays(puzzle: Puzzle): Funit =
|
||||
colls.puzzle.map(_.incFieldUnchecked($id(puzzle.id.value), Puzzle.BSONFields.plays))
|
||||
colls.puzzle.map(_.incFieldUnchecked($id(puzzle.id), Puzzle.BSONFields.plays))
|
||||
|
||||
private def updateRatings(u1: Rating, u2: Rating, result: Glicko.Result): Unit = {
|
||||
val results = new RatingPeriodResults()
|
||||
|
|
|
@ -35,10 +35,14 @@ final class JsonView(
|
|||
}
|
||||
|
||||
def userJson(u: User) =
|
||||
Json.obj(
|
||||
"rating" -> u.perfs.puzzle.intRating,
|
||||
"recent" -> JsArray()
|
||||
)
|
||||
Json
|
||||
.obj(
|
||||
"rating" -> u.perfs.puzzle.intRating,
|
||||
"recent" -> JsArray()
|
||||
)
|
||||
.add(
|
||||
"provisional" -> u.perfs.puzzle.provisional
|
||||
)
|
||||
|
||||
def pref(p: lila.pref.Pref) =
|
||||
Json.obj(
|
||||
|
@ -55,16 +59,14 @@ final class JsonView(
|
|||
"is3d" -> p.is3d
|
||||
)
|
||||
|
||||
private def puzzleJson(puzzle: Puzzle): JsObject =
|
||||
Json
|
||||
.obj(
|
||||
"id" -> puzzle.id,
|
||||
"rating" -> puzzle.glicko.intRating,
|
||||
"plays" -> puzzle.plays,
|
||||
"initialPly" -> puzzle.initialPly,
|
||||
"solution" -> puzzle.line.tail.map(_.uci),
|
||||
"vote" -> puzzle.vote
|
||||
)
|
||||
private def puzzleJson(puzzle: Puzzle): JsObject = Json.obj(
|
||||
"id" -> puzzle.id,
|
||||
"rating" -> puzzle.glicko.intRating,
|
||||
"plays" -> puzzle.plays,
|
||||
"initialPly" -> puzzle.initialPly,
|
||||
"solution" -> puzzle.line.tail.map(_.uci),
|
||||
"vote" -> puzzle.vote
|
||||
)
|
||||
|
||||
private def makeSolution(puzzle: Puzzle): Option[tree.Branch] = {
|
||||
import chess.format._
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
package lila.puzzle
|
||||
|
||||
import Puzzle.{ BSONFields => F }
|
||||
import cats.implicits._
|
||||
import scala.concurrent.duration._
|
||||
|
||||
import lila.db.AsyncColl
|
||||
|
@ -12,7 +12,7 @@ final private[puzzle] class PuzzleApi(
|
|||
colls: PuzzleColls
|
||||
)(implicit ec: scala.concurrent.ExecutionContext) {
|
||||
|
||||
import Puzzle.BSONFields._
|
||||
import Puzzle.{ BSONFields => F }
|
||||
import BsonHandlers._
|
||||
|
||||
object puzzle {
|
||||
|
@ -36,46 +36,61 @@ final private[puzzle] class PuzzleApi(
|
|||
)
|
||||
}
|
||||
|
||||
// object vote {
|
||||
object vote {
|
||||
|
||||
// def value(id: PuzzleId, user: User): Fu[Option[Boolean]] =
|
||||
// voteColl {
|
||||
// _.primitiveOne[Boolean]($id(Vote.makeId(id, user.id)), "v")
|
||||
// }
|
||||
// def value(id: Puzzle.Id, user: User): Fu[Option[Boolean]] =
|
||||
// colls.round {
|
||||
// _.primitiveOne[Boolean]($id(Vote.makeId(id, user.id)), "v")
|
||||
// }
|
||||
|
||||
// def find(id: PuzzleId, user: User): Fu[Option[Vote]] =
|
||||
// voteColl {
|
||||
// _.byId[Vote](Vote.makeId(id, user.id))
|
||||
// }
|
||||
// def find(id: PuzzleId, user: User): Fu[Option[Vote]] =
|
||||
// voteColl {
|
||||
// _.byId[Vote](Vote.makeId(id, user.id))
|
||||
// }
|
||||
|
||||
// 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 update(id: Puzzle.Id, user: User, v: Boolean): Funit =
|
||||
colls.round {
|
||||
_.ext
|
||||
.findAndUpdate[PuzzleRound](
|
||||
$id(PuzzleRound.Id(user.id, id)),
|
||||
$set($doc(PuzzleRound.BSONFields.vote -> v))
|
||||
)
|
||||
} flatMap {
|
||||
case Some(prevRound) if prevRound.vote.fold(true)(v !=) =>
|
||||
val dir = if (v) 1 else -1
|
||||
val inc = dir * (if (prevRound.vote.isDefined) 2 else 1)
|
||||
colls.puzzle {
|
||||
_.incField($id(id), F.vote, inc).void
|
||||
}
|
||||
case _ => funit
|
||||
}
|
||||
// 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)
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -102,7 +102,7 @@ final class PuzzleCursorApi(colls: PuzzleColls, cacheApi: CacheApi, userRepo: Us
|
|||
)
|
||||
}.map { docOpt =>
|
||||
import NextPuzzleResult._
|
||||
println(docOpt map lila.db.BSON.debug)
|
||||
// println(docOpt map lila.db.BSON.debug)
|
||||
docOpt.fold[NextPuzzleResult](PathMissing) { doc =>
|
||||
doc.getAsOpt[Puzzle.Id]("puzzleId").fold[NextPuzzleResult](PathEnded) { puzzleId =>
|
||||
doc
|
||||
|
|
|
@ -460,7 +460,6 @@ computer analysis, game chat and shareable URL.</string>
|
|||
<string name="findTheBestMoveForWhite">Find the best move for white.</string>
|
||||
<string name="findTheBestMoveForBlack">Find the best move for black.</string>
|
||||
<string name="toTrackYourProgress">To track your progress:</string>
|
||||
<string name="puzzleId">Puzzle %s</string>
|
||||
<string name="puzzleOfTheDay">Puzzle of the day</string>
|
||||
<string name="clickToSolve">Click to solve</string>
|
||||
<string name="goodMove">Good move</string>
|
||||
|
|
|
@ -30,10 +30,6 @@
|
|||
border-bottom: $border;
|
||||
margin-bottom: 2vh;
|
||||
|
||||
a {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@ import { moveTestBuild, MoveTestFn } from './moveTest';
|
|||
import { parseFen, makeFen } from 'chessops/fen';
|
||||
import { parseSquare, parseUci, makeSquare, makeUci } from 'chessops/util';
|
||||
import { pgnToTree, mergeSolution } from './moveTree';
|
||||
import { Redraw, Vm, Controller, PuzzleOpts, PuzzleData, PuzzleRound, MoveTest } from './interfaces';
|
||||
import { Redraw, Vm, Controller, PuzzleOpts, PuzzleData, PuzzleResult, MoveTest } from './interfaces';
|
||||
import { Role, Move, Outcome } from 'chessops/types';
|
||||
import { storedProp } from 'common/storage';
|
||||
|
||||
|
@ -57,7 +57,6 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
vm.mode = 'play';
|
||||
vm.loading = false;
|
||||
vm.round = undefined;
|
||||
vm.voted = undefined;
|
||||
vm.justPlayed = undefined;
|
||||
vm.resultSent = false;
|
||||
vm.lastFeedback = 'init';
|
||||
|
@ -193,7 +192,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
if (p == 'good' || p == 'win') return -1;
|
||||
return 0;
|
||||
});
|
||||
if (recursive) node.children.forEach(child =>
|
||||
if (recursive) node.children.forEach(child =>
|
||||
reorderChildren(path + child.id, true)
|
||||
);
|
||||
}
|
||||
|
@ -236,12 +235,14 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
if (vm.resultSent) return;
|
||||
vm.resultSent = true;
|
||||
nbToVoteCall(Math.max(0, parseInt(nbToVoteCall()) - 1));
|
||||
xhr.round(data.puzzle.id, win).then((res: PuzzleRound) => {
|
||||
data.user = res.user;
|
||||
vm.round = res.round;
|
||||
vm.voted = res.voted;
|
||||
redraw();
|
||||
xhr.round(data.puzzle.id, win).then((res: PuzzleResult | undefined) => {
|
||||
if (res && data.user) {
|
||||
data.user.rating = res.perf.rating;
|
||||
data.user.provisional = res.perf.provisional;
|
||||
vm.round = res.round;
|
||||
}
|
||||
if (win) speech.success();
|
||||
redraw();
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -250,7 +251,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
vm.loading = true;
|
||||
redraw();
|
||||
xhr.nextPuzzle().then((d: PuzzleData) => {
|
||||
vm.round = null;
|
||||
vm.round = undefined;
|
||||
vm.loading = false;
|
||||
initiate(d);
|
||||
redraw();
|
||||
|
@ -402,7 +403,7 @@ export default function(opts: PuzzleOpts, redraw: Redraw): Controller {
|
|||
const vote = throttle(1000, function(v) {
|
||||
if (callToVote()) thanksUntil = Date.now() + 2000;
|
||||
nbToVoteCall(5);
|
||||
vm.voted = v;
|
||||
vm.round!.vote = v;
|
||||
xhr.vote(data.puzzle.id, v);
|
||||
redraw();
|
||||
});
|
||||
|
|
|
@ -61,8 +61,7 @@ export interface Vm {
|
|||
pov: Color;
|
||||
mode: 'play' | 'view' | 'try';
|
||||
loading: boolean;
|
||||
round: any;
|
||||
voted?: boolean | null;
|
||||
round?: PuzzleRound;
|
||||
justPlayed?: Key;
|
||||
resultSent: boolean;
|
||||
lastFeedback: 'init' | 'fail' | 'win' | 'good' | 'retry';
|
||||
|
@ -117,6 +116,7 @@ export interface PuzzleGame {
|
|||
|
||||
export interface PuzzleUser {
|
||||
rating: number;
|
||||
provisional?: boolean;
|
||||
}
|
||||
|
||||
export interface Puzzle {
|
||||
|
@ -127,13 +127,18 @@ export interface Puzzle {
|
|||
initialPly: number;
|
||||
}
|
||||
|
||||
export interface PuzzleResult {
|
||||
perf: {
|
||||
rating: number;
|
||||
provisional?: boolean;
|
||||
}
|
||||
round?: PuzzleRound;
|
||||
}
|
||||
|
||||
export interface PuzzleRound {
|
||||
user: PuzzleUser;
|
||||
round?: {
|
||||
ratingDiff: number;
|
||||
win: boolean;
|
||||
};
|
||||
voted?: null | true | false;
|
||||
win: boolean;
|
||||
ratingDiff: number;
|
||||
vote?: boolean;
|
||||
}
|
||||
|
||||
export interface Promotion {
|
||||
|
|
|
@ -16,9 +16,6 @@ function puzzleInfos(ctrl: Controller, puzzle: Puzzle): VNode {
|
|||
return h('div.infos.puzzle', {
|
||||
attrs: dataIcon('-')
|
||||
}, [h('div', [
|
||||
h('a.title', {
|
||||
attrs: { href: '/training/' + puzzle.id }
|
||||
}, ctrl.trans('puzzleId', puzzle.id)),
|
||||
h('p', ctrl.trans.vdom('ratingX', ctrl.vm.mode === 'play' ? h('span.hidden', ctrl.trans.noarg('hidden')) : h('strong', puzzle.rating))),
|
||||
h('p', ctrl.trans.vdom('playedXTimes', h('strong', numberFormat(puzzle.plays))))
|
||||
])]);
|
||||
|
@ -49,12 +46,12 @@ function gameInfos(ctrl: Controller, game: PuzzleGame, puzzle: Puzzle): VNode {
|
|||
export function userBox(ctrl: Controller): MaybeVNode {
|
||||
const data = ctrl.getData();
|
||||
if (!data.user) return;
|
||||
const diff = ctrl.vm.round && ctrl.vm.round.ratingDiff;
|
||||
const diff = ctrl.vm.round?.ratingDiff;
|
||||
return h('div.puzzle__side__user', [
|
||||
h('h2', ctrl.trans.vdom('yourPuzzleRatingX', h('strong', [
|
||||
data.user.rating,
|
||||
...(diff >= 0 ? [' ', h('good.rp', '+' + diff)] : []),
|
||||
...(diff < 0 ? [' ', h('bad.rp', '−' + (-diff))] : [])
|
||||
...(diff && diff > 0 ? [' ', h('good.rp', '+' + diff)] : []),
|
||||
...(diff && diff < 0 ? [' ', h('bad.rp', '−' + (-diff))] : [])
|
||||
])))
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { PuzzleRound, PuzzleData } from './interfaces';
|
||||
import { PuzzleData, PuzzleResult } from './interfaces';
|
||||
import * as xhr from 'common/xhr';
|
||||
|
||||
export function round(puzzleId: string, win: boolean): Promise<PuzzleRound> {
|
||||
export function round(puzzleId: string, win: boolean): Promise<PuzzleResult | undefined> {
|
||||
return xhr.json(`/training/${puzzleId}/round3`, {
|
||||
method: 'POST',
|
||||
body: xhr.form({ win: win ? 1 : 0 })
|
||||
|
|
Loading…
Reference in New Issue