team battle WIP

team-tournament
Thibault Duplessis 2019-10-02 18:50:09 +02:00
parent f0e9a09422
commit 44695e7e6a
23 changed files with 2657 additions and 124 deletions

View File

@ -1,5 +1,8 @@
package controllers
import play.api.libs.json._
import play.api.mvc._
import lila.api.Context
import lila.app._
import lila.common.{ HTTPRequest, MaxPerSecond }
@ -9,8 +12,6 @@ import lila.team.{ Joined, Motivate, Team => TeamModel, TeamRepo, MemberRepo }
import lila.user.{ User => UserModel }
import views._
import play.api.mvc._
object Team extends LilaController {
private def forms = Env.team.forms
@ -221,6 +222,25 @@ object Team extends LilaController {
}
)
def autocomplete = Action.async { req =>
get("term", req).filter(_.nonEmpty) match {
case None => BadRequest("No search term provided").fuccess
case Some(term) => for {
teams <- api.autocomplete(term, 10)
_ <- Env.user.lightUserApi preloadMany teams.map(_.createdBy)
} yield Ok {
JsArray(teams map { team =>
Json.obj(
"id" -> team.id,
"name" -> team.name,
"owner" -> Env.user.lightUserApi.sync(team.createdBy).fold(team.createdBy)(_.name),
"members" -> team.nbMembers
)
})
} as JSON
}
}
private def OnePerWeek[A <: Result](me: UserModel)(a: => Fu[A])(implicit ctx: Context): Fu[Result] =
api.hasCreatedRecently(me) flatMap { did =>
if (did && !Granter(_.ManageTeam)(me)) Forbidden(views.html.site.message.teamCreateLimit).fuccess

View File

@ -180,7 +180,7 @@ object Tournament extends LilaController {
}
}
def formTeamBattle(teamId: String) = Auth { implicit ctx => me =>
def teamBattleForm(teamId: String) = Auth { implicit ctx => me =>
NoLameOrBot {
Env.team.api.owns(teamId, me.id) map {
_ ?? {
@ -222,15 +222,10 @@ object Tournament extends LilaController {
setup.password.isDefined) 1 else 4
CreateLimitPerUser(me.id, cost = cost) {
CreateLimitPerIP(HTTPRequest lastRemoteAddress ctx.req, cost = cost) {
env.api.createTournament(
setup,
me,
teams,
getUserTeamIds,
Env.team.api.filterExistingIds
) flatMap { tour =>
fuccess(Redirect(routes.Tournament.show(tour.id)))
}
env.api.createTournament(setup, me, teams, getUserTeamIds) map { tour =>
if (tour.teamBattle.isDefined) Redirect(routes.Tournament.teamBattleEdit(tour.id))
else Redirect(routes.Tournament.show(tour.id))
}
}(rateLimited)
}(rateLimited)
}
@ -250,12 +245,46 @@ object Tournament extends LilaController {
env.forms(me).bindFromRequest.fold(
jsonFormErrorDefaultLang,
setup => teamsIBelongTo(me) flatMap { teams =>
env.api.createTournament(setup, me, teams, getUserTeamIds, Env.team.api.filterExistingIds) flatMap { tour =>
env.api.createTournament(setup, me, teams, getUserTeamIds) flatMap { tour =>
Env.tournament.jsonView(tour, none, none, getUserTeamIds, none, none, partial = false, lila.i18n.defaultLang)
}
} map { Ok(_) }
)
def teamBattleEdit(id: String) = Auth { implicit ctx => me =>
repo byId id flatMap {
_ ?? {
case tour if tour.createdBy == me.id =>
tour.teamBattle ?? { battle =>
lila.team.TeamRepo.byOrderedIds(battle.sortedTeamIds) flatMap { teams =>
Env.user.lightUserApi.preloadMany(teams.map(_.createdBy)) >> {
val form = lila.tournament.TeamBattle.DataForm.edit(teams.map { t =>
s"""${t.id} "${t.name}" by ${Env.user.lightUserApi.sync(t.createdBy).fold(t.createdBy)(_.name)}"""
})
Ok(html.tournament.teamBattle.edit(tour, form)).fuccess
}
}
}
case tour => Redirect(routes.Tournament.show(tour.id)).fuccess
}
}
}
def teamBattleUpdate(id: String) = AuthBody { implicit ctx => me =>
repo byId id flatMap {
_ ?? {
case tour if tour.createdBy == me.id && !tour.isFinished =>
implicit val req = ctx.body
lila.tournament.TeamBattle.DataForm.edit(Nil).bindFromRequest.fold(
err => BadRequest(html.tournament.teamBattle.edit(tour, err)).fuccess,
res => env.api.teamBattleUpdate(tour, res, Env.team.api.filterExistingIds) inject
Redirect(routes.Tournament.show(tour.id))
)
case tour => Redirect(routes.Tournament.show(tour.id)).fuccess
}
}
}
def limitedInvitation = Auth { implicit ctx => me =>
for {
(tours, _) <- upcomingCache.get

View File

@ -146,7 +146,7 @@ trait FormHelper { self: I18nHelper =>
nameValue: Option[(String, String)] = None,
klass: String = "",
confirm: Option[String] = None
): Frag = submitButton(
): Tag = submitButton(
dataIcon := icon,
name := nameValue.map(_._1),
value := nameValue.map(_._2),

View File

@ -67,7 +67,7 @@ object show {
),
(info.createdByMe || isGranted(_.Admin)) option frag(
a(href := routes.Team.edit(t.id), cls := "button button-empty text", dataIcon := "%")(trans.settings()),
a(href := routes.Tournament.formTeamBattle(t.id), cls := "button button-empty text", dataIcon := "g")("Team Battle")
a(href := routes.Tournament.teamBattleForm(t.id), cls := "button button-empty text", dataIcon := "g")("Team Battle")
)
),
NotForKids {

View File

@ -30,7 +30,7 @@ object bits {
td(cls := "name")(
a(cls := "text", dataIcon := tournamentIconChar(tour), href := routes.Tournament.show(tour.id))(tour.name)
),
tour.schedule.fold(td()) { s => td(momentFromNow(s.at)) },
tour.schedule.fold(td) { s => td(momentFromNow(s.at)) },
td(tour.durationString),
td(dataIcon := "r", cls := "text")(tour.nbPlayers)
)

View File

@ -30,7 +30,7 @@ object side {
if (tour.variant == chess.variant.KingOfTheHill) tour.variant.shortName else tour.variant.name
)
} else tour.perfType.map(_.name),
(!tour.position.initial) ?? s"$separator ${trans.thematic.txt()}",
(!tour.position.initial) ?? s"$separator${trans.thematic.txt()}",
separator,
tour.durationString
),
@ -40,7 +40,9 @@ object side {
isGranted(_.TerminateTournament) option
postForm(cls := "terminate", action := routes.Tournament.terminate(tour.id))(
submitButton(dataIcon := "j", cls := "fbt fbt-red confirm", title := "Terminates the tournament immediately")
)
),
ctx.userId.has(tour.createdBy) && tour.teamBattle.isDefined option
a(href := routes.Tournament.teamBattleEdit(tour.id))("Update team list")
)
),
tour.spotlight map { s =>

View File

@ -0,0 +1,34 @@
package views.html
package tournament
import play.api.data.{ Field, Form }
import lila.api.Context
import lila.app.templating.Environment._
import lila.app.ui.ScalatagsTemplate._
import lila.tournament.Tournament
import lila.user.User
import controllers.routes
object teamBattle {
def edit(tour: Tournament, form: Form[_])(implicit ctx: Context) = views.html.base.layout(
title = tour.fullName,
moreCss = cssTag("tournament.form"),
moreJs = jsTag("tournamentTeamBattleForm.js")
)(main(cls := "page-small")(
div(cls := "tour__form box box-pad")(
h1(tour.fullName),
if (tour.isFinished) p("This tournament is over, and the teams can no longer be updated.")
else p("List the teams that will compete in this battle."),
postForm(cls := "form3", action := routes.Tournament.teamBattleUpdate(tour.id))(
form3.group(form("teams"), raw("Team IDs or names, one per line. Use the auto-completion."),
help = frag("You can copy-paste this list from a tournament to another!").some)(
form3.textarea(_)(rows := 25, tour.isFinished.option(disabled))
),
form3.submit("Update teams")(tour.isFinished.option(disabled))
)
)
))
}

View File

@ -213,8 +213,10 @@ GET /whats-next/$fullId<\w{12}> controllers.Round.whatsNext(ful
GET /tournament controllers.Tournament.home(page: Int ?= 1)
GET /tournament/featured controllers.Tournament.featured
GET /tournament/new controllers.Tournament.form
GET /tournament/new-team-battle controllers.Tournament.formTeamBattle(teamId: String)
POST /tournament/new controllers.Tournament.create
GET /tournament/team-battle/new/:teamId controllers.Tournament.teamBattleForm(teamId: String)
GET /tournament/team-battle/edit controllers.Tournament.teamBattleEdit(id: String)
POST /tournament/team-battle/edit controllers.Tournament.teamBattleUpdate(id: String)
GET /tournament/calendar controllers.Tournament.calendar
GET /tournament/$id<\w{8}> controllers.Tournament.show(id: String)
GET /tournament/$id<\w{8}>/standing/:page controllers.Tournament.standing(id: String, page: Int)
@ -261,6 +263,7 @@ GET /team/me controllers.Team.mine
GET /team/all controllers.Team.all(page: Int ?= 1)
GET /team/requests controllers.Team.requests
GET /team/search controllers.Team.search(text: String ?= "", page: Int ?= 1)
GET /team/autocomplete controllers.Team.autocomplete
GET /team/:id controllers.Team.show(id: String, page: Int ?= 1)
POST /team/:id/join controllers.Team.join(id: String)
POST /team/:id/quit controllers.Team.quit(id: String)

View File

@ -337,8 +337,8 @@ trait dsl extends LowPriorityDsl {
def $regex(value: String, options: String = ""): SimpleExpression[BSONRegex] =
SimpleExpression(field, BSONRegex(value, options))
def $startsWith(value: String): SimpleExpression[BSONRegex] =
$regex(s"^$value", "")
def $startsWith(value: String, options: String = ""): SimpleExpression[BSONRegex] =
$regex(s"^$value", options)
}
trait ArrayOperators { self: ElementBuilder =>

View File

@ -204,6 +204,12 @@ final class TeamApi(
def filterExistingIds(ids: Set[String]): Fu[Set[Team.ID]] =
coll.team.distinct[Team.ID, Set]("_id", Some("_id" $in ids))
def autocomplete(term: String, max: Int): Fu[List[Team]] =
coll.team.find($doc(
"name".$startsWith(java.util.regex.Pattern.quote(term), "i"),
"enabled" -> true
)).sort($sort desc "nbMembers").list[Team](max, ReadPreference.secondaryPreferred)
def nbRequests(teamId: Team.ID) = cached.nbRequests get teamId
def recomputeNbMembers =

View File

@ -19,10 +19,7 @@ object TeamRepo {
def cursor(
selector: Bdoc,
readPreference: ReadPreference = ReadPreference.secondaryPreferred
)(
implicit
cp: CursorProducer[Team]
) =
)(implicit cp: CursorProducer[Team]) =
coll.find(selector).cursor[Team](readPreference)
def owned(id: Team.ID, createdBy: User.ID): Fu[Option[Team]] =

View File

@ -39,6 +39,8 @@ object BSONHandlers {
private implicit val spotlightBSONHandler = Macros.handler[Spotlight]
implicit val battleBSONHandler = Macros.handler[TeamBattle]
private implicit val leaderboardRatio = new BSONHandler[BSONInteger, LeaderboardApi.Ratio] {
def read(b: BSONInteger) = LeaderboardApi.Ratio(b.value.toDouble / 100000)
def write(x: LeaderboardApi.Ratio) = BSONInteger((x.value * 100000).toInt)
@ -66,6 +68,7 @@ object BSONHandlers {
mode = r.intO("mode") flatMap Mode.apply getOrElse Mode.Rated,
password = r.strO("password"),
conditions = conditions,
teamBattle = r.getO[TeamBattle]("teamBattle"),
noBerserk = r boolD "noBerserk",
schedule = for {
doc <- r.getO[Bdoc]("schedule")
@ -93,6 +96,7 @@ object BSONHandlers {
"mode" -> o.mode.some.filterNot(_.rated).map(_.id),
"password" -> o.password,
"conditions" -> o.conditions.ifNonEmpty,
"teamBattle" -> o.teamBattle,
"noBerserk" -> w.boolO(o.noBerserk),
"schedule" -> o.schedule.map { s =>
$doc(

View File

@ -55,7 +55,7 @@ final class DataForm {
"rated" -> optional(boolean),
"password" -> optional(nonEmptyText),
"conditions" -> Condition.DataForm.all,
"teamBattle" -> optional(TeamBattle.DataForm.form),
"teamBattle" -> optional(TeamBattle.DataForm.fields),
"berserkable" -> optional(boolean)
)(TournamentSetup.apply)(TournamentSetup.unapply)
.verifying("Invalid clock", _.validClock)

View File

@ -1,10 +1,15 @@
package lila.tournament
import play.api.data._
import play.api.data.Forms._
import lila.hub.lightTeam._
case class TeamBattle(
teams: Set[TeamId]
)
) {
def sortedTeamIds = teams.toList.sorted
}
object TeamBattle {
@ -12,7 +17,9 @@ object TeamBattle {
import play.api.data.Forms._
import lila.common.Form._
val form = mapping(
def edit(teams: List[String]) = Form(fields) fill Setup(s"${teams mkString "\n"}\n")
val fields = mapping(
"teams" -> nonEmptyText
)(Setup.apply)(Setup.unapply)
@ -20,7 +27,7 @@ object TeamBattle {
teams: String
) {
def potentialTeamIds: Set[String] =
teams.lines.mkString(" ").split(" ").filter(_.nonEmpty).toSet
teams.lines.map(_.takeWhile(' ' !=)).filter(_.nonEmpty).toSet
}
}
}

View File

@ -39,12 +39,14 @@ case class Tournament(
def isPrivate = password.isDefined
def fullName = schedule.map(_.freq).fold(s"$name $system") {
case Schedule.Freq.ExperimentalMarathon | Schedule.Freq.Marathon | Schedule.Freq.Unique => name
case Schedule.Freq.Shield => s"$name $system"
case _ if clock.hasIncrement => s"$name Inc $system"
case _ => s"$name $system"
}
def fullName =
if (teamBattle.isDefined) s"$name Team Battle"
else schedule.map(_.freq).fold(s"$name $system") {
case Schedule.Freq.ExperimentalMarathon | Schedule.Freq.Marathon | Schedule.Freq.Unique => name
case Schedule.Freq.Shield => s"$name $system"
case _ if clock.hasIncrement => s"$name Inc $system"
case _ => s"$name $system"
}
def isMarathon = schedule.map(_.freq) exists {
case Schedule.Freq.ExperimentalMarathon | Schedule.Freq.Marathon => true
@ -143,7 +145,8 @@ object Tournament {
password: Option[String],
waitMinutes: Int,
startDate: Option[DateTime],
berserkable: Boolean
berserkable: Boolean,
teamBattle: Option[TeamBattle]
) = Tournament(
id = makeId,
name = name | {
@ -162,6 +165,7 @@ object Tournament {
mode = mode,
password = password,
conditions = Condition.All.empty,
teamBattle = teamBattle,
noBerserk = !berserkable,
schedule = None,
startsAt = startDate | {

View File

@ -50,10 +50,9 @@ final class TournamentApi(
setup: TournamentSetup,
me: User,
myTeams: List[LightTeam],
getUserTeamIds: User => Fu[List[TeamId]],
filterExistingTeamIds: Set[TeamId] => Fu[Set[TeamId]]
getUserTeamIds: User => Fu[List[TeamId]]
): Fu[Tournament] = {
Tournament.make(
val tour = Tournament.make(
by = Right(me),
name = DataForm.canPickName(me) ?? setup.name,
clock = setup.clockConfig,
@ -65,19 +64,15 @@ final class TournamentApi(
system = System.Arena,
variant = setup.realVariant,
position = DataForm.startingPosition(setup.position | chess.StartingPosition.initial.fen, setup.realVariant),
berserkable = setup.berserkable | true
berserkable = setup.berserkable | true,
teamBattle = setup.teamBattle.map { tb =>
TeamBattle(tb.potentialTeamIds)
}
) |> { tour =>
tour.perfType.fold(tour) { perfType =>
tour.copy(conditions = setup.conditions.convert(perfType, myTeams.map(_.pair)(collection.breakOut)))
}
} |> { tour =>
setup.teamBattle.fold(fuccess(tour)) { battle =>
filterExistingTeamIds(battle.potentialTeamIds) map { teamIds =>
tour.copy(teamBattle = teamIds.nonEmpty option TeamBattle(teamIds))
}
}
}
} flatMap { tour =>
if (tour.name != me.titleUsername && lila.common.LameName.anyNameButLichessIsOk(tour.name))
bus.publish(lila.hub.actorApi.slack.TournamentName(me.username, tour.id, tour.name), 'slack)
logger.info(s"Create $tour")
@ -89,6 +84,15 @@ final class TournamentApi(
TournamentRepo.insert(tournament).void
}
def teamBattleUpdate(
tour: Tournament,
data: TeamBattle.DataForm.Setup,
filterExistingTeamIds: Set[TeamId] => Fu[Set[TeamId]]
): Funit =
filterExistingTeamIds(data.potentialTeamIds) flatMap { teamIds =>
TournamentRepo.setTeamBattle(tour.id, TeamBattle(teamIds))
}
private[tournament] def makePairings(oldTour: Tournament, users: WaitingUsers, startAt: Long): Unit = {
Sequencing(oldTour.id)(TournamentRepo.startedById) { tour =>
cached ranking tour flatMap { ranking =>

View File

@ -9,6 +9,8 @@ import lila.common.paginator.Paginator
import lila.db.BSON.BSONJodaDateTimeHandler
import lila.db.dsl._
import lila.db.paginator.{ Adapter, CachedAdapter }
import lila.game.Game
import lila.user.User
object TournamentRepo {
@ -112,27 +114,22 @@ object TournamentRepo {
def clockById(id: Tournament.ID): Fu[Option[chess.Clock.Config]] =
coll.primitiveOne[chess.Clock.Config]($id(id), "clock")
def setStatus(tourId: String, status: Status) = coll.update(
$id(tourId),
$set("status" -> status.id)
).void
def setStatus(tourId: Tournament.ID, status: Status) =
coll.update($id(tourId), $set("status" -> status.id)).void
def setNbPlayers(tourId: String, nb: Int) = coll.update(
$id(tourId),
$set("nbPlayers" -> nb)
).void
def setNbPlayers(tourId: Tournament.ID, nb: Int) =
coll.update($id(tourId), $set("nbPlayers" -> nb)).void
def setWinnerId(tourId: String, userId: String) = coll.update(
$id(tourId),
$set("winner" -> userId)
).void
def setWinnerId(tourId: Tournament.ID, userId: User.ID) =
coll.update($id(tourId), $set("winner" -> userId)).void
def setFeaturedGameId(tourId: String, gameId: String) = coll.update(
$id(tourId),
$set("featured" -> gameId)
).void
def setFeaturedGameId(tourId: Tournament.ID, gameId: Game.ID) =
coll.update($id(tourId), $set("featured" -> gameId)).void
def featuredGameId(tourId: String) = coll.primitiveOne[String]($id(tourId), "featured")
def setTeamBattle(tourId: Tournament.ID, battle: TeamBattle) =
coll.update($id(tourId), $set("teamBattle" -> battle)).void
def featuredGameId(tourId: Tournament.ID) = coll.primitiveOne[Game.ID]($id(tourId), "featured")
private def allCreatedSelect(aheadMinutes: Int) = createdSelect ++
$doc("startsAt" $lt (DateTime.now plusMinutes aheadMinutes))

View File

@ -65,7 +65,8 @@ final class CrudApi {
password = None,
waitMinutes = 0,
startDate = none,
berserkable = true
berserkable = true,
teamBattle = none
)
private def updateTour(tour: Tournament, data: CrudForm.Data) = {

View File

@ -20,62 +20,4 @@ $(function() {
altInput: true,
altFormat: 'Y-m-d h:i K'
});
// if (topicId) lichess.loadScript('vendor/textcomplete.min.js').then(function() {
// var searchCandidates = function(term, candidateUsers) {
// return candidateUsers.filter(function(user) {
// return user.toLowerCase().startsWith(term.toLowerCase());
// });
// };
// // We only ask the server for the thread participants once the user has clicked the text box as most hits to the
// // forums will be only to read the thread. So the 'thread participants' starts out empty until the post text area
// // is focused.
// var threadParticipants = $.ajax({
// url: "/forum/participants/" + topicId
// });
// var textcomplete = new Textcomplete(new Textcomplete.editors.Textarea(textarea));
// textcomplete.register([{
// match: /(^|\s)@(|[a-zA-Z_-][\w-]{0,19})$/,
// search: function(term, callback) {
// // Initially we only autocomplete by participants in the thread. As the user types more,
// // we can autocomplete against all users on the site.
// threadParticipants.then(function(participants) {
// var forumParticipantCandidates = searchCandidates(term, participants);
// if (forumParticipantCandidates.length != 0) {
// // We always prefer a match on the forum thread partcipants' usernames
// callback(forumParticipantCandidates);
// }
// else if (term.length >= 3) {
// // We fall back to every site user after 3 letters of the username have been entered
// // and there are no matches in the forum thread participants
// $.ajax({
// url: "/player/autocomplete",
// data: {
// term: term
// },
// success: function(candidateUsers) {
// callback(searchCandidates(term, candidateUsers));
// },
// cache: true
// });
// } else {
// callback([]);
// }
// });
// },
// replace: function(mention) {
// return '$1@' + mention + ' ';
// }
// }], {
// placement: 'top',
// appendTo: '#lichess_forum'
// });
// });
// });
});

View File

@ -0,0 +1,51 @@
$(function() {
$('#form3-teams').each(function() {
const textarea = this;
lichess.loadScript('vendor/textcomplete.js').then(function() {
const textcomplete = new Textcomplete(new Textcomplete.editors.Textarea(textarea), {
dropdown: {
maxCount: 10,
placement: 'bottom'
}
});
textcomplete.register([{
id: 'team',
match: /(^|\s)(.+)$/,
index: 2,
search: function(term, callback) {
$.ajax({
url: "/team/autocomplete",
data: {
term: term
},
success: function(teams) {
callback(teams);
},
error: function() {
callback([]);
},
cache: true
})
},
template: function(team, i) {
return team.name + ', by ' + team.owner + ', with ' + team.members + ' members';
},
replace: function(team) {
return '$1' + team.id + ' "' + team.name + '" by ' + team.owner + '\n'
}
}]);
textcomplete.on('rendered', function() {
if (textcomplete.dropdown.items.length) {
// Activate the first item by default.
textcomplete.dropdown.items[0].activate();
}
});
});
});
});

2409
public/vendor/textcomplete.js vendored 100644

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -33,3 +33,26 @@
margin: 1.2em 0 .5em 0;
}
}
.textcomplete-dropdown {
@extend %popup-shadow;
background: $c-bg-popup;
li {
list-style: none;
border-top: $border;
padding: .5em;
min-width: 100px;
font-weight: bold;
cursor: pointer;
}
li.textcomplete-header,
li.textcomplete-footer {
display: none;
}
li:hover,
.active {
background-color: mix($c-accent, $c-bg-popup, 10%);
}
a {
color: $c-font;
}
}