Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0ec7bbedb | ||
|
|
0212f8b6d0 | ||
|
|
3cad3aec4b | ||
|
|
d1dd478c43 | ||
|
|
1a8916be22 | ||
|
|
d2af1bc797 | ||
|
|
495317016d | ||
|
|
7399322f74 | ||
|
|
ed01c92570 |
@@ -1,12 +1,12 @@
|
||||
#Десайдер для турниров по Soulstorm
|
||||
# Десайдер для турниров по Soulstorm
|
||||
|
||||
##Стек технологий backend:
|
||||
## Стек технологий backend:
|
||||
|
||||
- akka (не типизированные акторы)
|
||||
- play framework 2.8.2
|
||||
- scala 2.12.8
|
||||
|
||||
##Стек технологий frontend:
|
||||
## Стек технологий frontend:
|
||||
|
||||
- jquery 1.4.1
|
||||
- верстка/ui: bootstrap 3.3.6
|
||||
|
||||
@@ -4,14 +4,11 @@ import actors.UserActor._
|
||||
import akka.actor.{Actor, ActorRef}
|
||||
import akka.event.LoggingReceive
|
||||
import akka.util.Timeout
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import com.typesafe.scalalogging.LazyLogging
|
||||
import repository.DeciderRepository
|
||||
|
||||
import java.util
|
||||
import scala.collection.JavaConverters.iterableAsScalaIterableConverter
|
||||
import scala.collection.immutable.HashSet
|
||||
import scala.concurrent.duration._
|
||||
import scala.util.Try
|
||||
|
||||
case class LobbyUser(name: String, actorRef: ActorRef)
|
||||
|
||||
@@ -23,7 +20,6 @@ case class DeciderMap(map: String, description: Option[String] = None, var isBan
|
||||
*/
|
||||
class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with LazyLogging {
|
||||
|
||||
val config = ConfigFactory.load()
|
||||
logger.info(s"Create lobby... host: ${hostUser.actorRef.path.name}")
|
||||
|
||||
|
||||
@@ -49,11 +45,12 @@ class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with L
|
||||
|
||||
|
||||
private val mapsLobby: Set[DeciderMap] = {
|
||||
val configMaps = config.getList(s"deciders.$deciderName.maps").asScala
|
||||
configMaps.map(e => {
|
||||
val mapConfig = e.unwrapped().asInstanceOf[util.ArrayList[String]].asScala
|
||||
DeciderMap(mapConfig.head, mapConfig.tail.headOption)
|
||||
}).toSet
|
||||
DeciderRepository.get(deciderName) match {
|
||||
case Some(d) =>
|
||||
d.maps.map(m => DeciderMap(m.map, m.description)).toSet
|
||||
case None =>
|
||||
Set.empty[DeciderMap]
|
||||
}
|
||||
}
|
||||
|
||||
hostUser.actorRef.tell(
|
||||
@@ -261,9 +258,9 @@ case class ChangeIsNecronSelected(isSelected: Boolean)
|
||||
|
||||
case object InfoQuery
|
||||
|
||||
case class SelectedRaces(mainRaces: List[Int], additionalRaces: List[Int])
|
||||
case class SelectedRaces(mainRaces: List[Int], additionalRaces: List[Int], raceTextInput: String)
|
||||
|
||||
case class UserInfo(name: String, isReady: Boolean, selectedRaces: Option[SelectedRaces] = None)
|
||||
case class UserInfo(name: String, isReady: Boolean, selectedRaces: Option[SelectedRaces] = None, selectedRacesText: Option[String] = None)
|
||||
|
||||
case class LobbyInfo(user1Info: UserInfo,
|
||||
user2Info: UserInfo,
|
||||
|
||||
@@ -176,7 +176,8 @@ class UserActor(out: ActorRef,
|
||||
logger.info("RACES: " + json.toString())
|
||||
val races = (json \ "mainRaces").as[List[Int]]
|
||||
val additionalRaces = (json \ "additionalRaces").as[List[Int]]
|
||||
lobbieActor.foreach(lobby => lobby ! SelectedRaces(races, additionalRaces))
|
||||
val raceTextInput = (json \ "raceTextInput").as[String]
|
||||
lobbieActor.foreach(lobby => lobby ! SelectedRaces(races, additionalRaces, raceTextInput))
|
||||
|
||||
case Some("getLobbies") =>
|
||||
logger.debug("Get all lobby request")
|
||||
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 831 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -4,6 +4,7 @@ var userName = ""
|
||||
|
||||
var raceCount = $("#raceCount").html()
|
||||
var existAdditionalRaces = $("#existAdditionalRaces").html() === "true"
|
||||
var existTextFieldRaces = $("#existTextFieldRaces").html() === "true"
|
||||
|
||||
var isMuted = false;
|
||||
var isDraftStarted = false;
|
||||
@@ -227,6 +228,8 @@ function disconnectLobby(error) {
|
||||
|
||||
function getRaceImageByNumber(number) {
|
||||
switch (number) {
|
||||
case 10:
|
||||
return "/assets/images/raceIcons/cancel.png";
|
||||
case 1:
|
||||
return "/assets/images/raceIcons/SM.gif";
|
||||
case 2:
|
||||
@@ -248,28 +251,6 @@ function getRaceImageByNumber(number) {
|
||||
}
|
||||
}
|
||||
|
||||
function getRaceVideoByNumber(number) {
|
||||
switch (number) {
|
||||
case 1:
|
||||
return "/assets/video/races/SM.mp4";
|
||||
case 2:
|
||||
return "/assets/video/races/Chaos.mp4";
|
||||
case 3:
|
||||
return "/assets/video/races/Eldar.mp4";
|
||||
case 4:
|
||||
return "/assets/video/races/Orks.mp4";
|
||||
case 5:
|
||||
return "/assets/video/races/IG.mp4";
|
||||
case 6:
|
||||
return "/assets/video/races/Nec.mp4";
|
||||
case 7:
|
||||
return "/assets/video/races/Tau.mp4";
|
||||
case 8:
|
||||
return "/assets/video/races/Sob.mp4";
|
||||
case 9:
|
||||
return "/assets/video/races/SM.mp4";
|
||||
}
|
||||
}
|
||||
|
||||
function renderRaces() {
|
||||
|
||||
@@ -277,7 +258,7 @@ function renderRaces() {
|
||||
var resHtml = "";
|
||||
for(var raceNum = 1; raceNum <= raceCount; raceNum++){
|
||||
resHtml += "<div class='col-xs-4'><center><h3>" + raceType + " race " + raceNum + "</h3>";
|
||||
for (var i = 1; i <= 9; i++) {
|
||||
for (var i = 1; i <= 10; i++) {
|
||||
var selected = "raceNotSelected";
|
||||
if ($.cookie("race-" + raceType + "-" + raceNum) == i) {
|
||||
selected = "raceSelected";
|
||||
@@ -293,9 +274,21 @@ function renderRaces() {
|
||||
return resHtml;
|
||||
}
|
||||
|
||||
function renderInputField() {
|
||||
var raceTextInput = ''
|
||||
if($.cookie("raceTextInput") !== undefined){
|
||||
raceTextInput = $.cookie("raceTextInput")
|
||||
}
|
||||
return '<center><input type="text" class="form-control" id="raceInput" placeholder="Black fists, Imperial angels, Blood templars" oninput="checkRaceSelectFinish()" value="'+ raceTextInput +'"/></center>';
|
||||
}
|
||||
|
||||
var resHtml = "";
|
||||
if(!existTextFieldRaces){
|
||||
resHtml += getRaceSelectHtml("main", selectedRaces);
|
||||
if(existAdditionalRaces) resHtml += getRaceSelectHtml("additional", selectedAdditionalRaces);
|
||||
}else {
|
||||
resHtml += renderInputField();
|
||||
}
|
||||
resHtml += "<div style='clear: both;'></div></br>";
|
||||
|
||||
resHtml += "<center><button id = 'selectRace' class='btn btn-primary' disabled onclick='sendSelectedRacesToServer()'>OK</button></center>"
|
||||
@@ -327,15 +320,23 @@ function selectRace(button, race, raceNum, raceType) {
|
||||
function checkRaceSelectFinish() {
|
||||
if(selectedRaces.filter(x => x).length >= raceCount){
|
||||
$("#selectRace").removeAttr("disabled");
|
||||
} else if($("#raceInput").val().trim() !== ''){
|
||||
$("#selectRace").removeAttr("disabled");
|
||||
}
|
||||
}
|
||||
|
||||
function sendSelectedRacesToServer() {
|
||||
$.cookie('raceTextInput', $("#raceInput").val(), {expires: 1});
|
||||
var selectedInput = $("#raceInput").val();
|
||||
if(selectedInput === undefined){
|
||||
selectedInput = ""
|
||||
}
|
||||
console.log(selectedRaces);
|
||||
ws.send(JSON.stringify({
|
||||
type: "selectRace",
|
||||
mainRaces: selectedRaces.filter(x => x),
|
||||
additionalRaces: selectedAdditionalRaces.filter(x => x)
|
||||
additionalRaces: selectedAdditionalRaces.filter(x => x),
|
||||
raceTextInput: selectedInput
|
||||
}));
|
||||
$("#mapList").html("<center>Waiting another player...</center>");
|
||||
}
|
||||
@@ -524,12 +525,13 @@ function getRenderRaces(userInfo1, userInfo2) {
|
||||
|
||||
function getRacesBlock(playerRaces, name){
|
||||
let resHtml = "<div style='display: inline-block; border: black 1px solid; background-color: #e3e3e3;'>" + "<h4>" + name + "</h4>";
|
||||
resHtml += "main:";
|
||||
playerRaces.mainRaces.forEach((raceId) =>
|
||||
resHtml += "<img class=\"raceIcon\" src=" + getRaceImageByNumber(raceId) + "> ");
|
||||
resHtml += "<br/> addt:";
|
||||
playerRaces.additionalRaces.forEach((raceId) =>
|
||||
resHtml += "<img class=\"raceIcon\" src=" + getRaceImageByNumber(raceId) + "> ");
|
||||
if(existTextFieldRaces){
|
||||
resHtml += "<span style='margin: 10px;'>" + playerRaces.raceTextInput + "</span>";
|
||||
}
|
||||
resHtml += "</div>";
|
||||
return resHtml;
|
||||
}
|
||||
|
||||
@@ -1,52 +1,261 @@
|
||||
package controllers
|
||||
|
||||
import actors.UserParentActor.GetAllUsers
|
||||
import actors._
|
||||
import akka.NotUsed
|
||||
import akka.actor._
|
||||
import akka.event.Logging
|
||||
import akka.pattern.ask
|
||||
import akka.stream._
|
||||
import akka.stream.scaladsl._
|
||||
import akka.util.Timeout
|
||||
import com.typesafe.config.ConfigFactory
|
||||
|
||||
import javax.inject._
|
||||
import org.reactivestreams.Publisher
|
||||
import com.typesafe.config.ConfigFactory
|
||||
import play.api.i18n.Messages
|
||||
import play.api.libs.json._
|
||||
import play.api.mvc._
|
||||
import repository.DeciderRepository
|
||||
import play.api.libs.ws.WSClient
|
||||
|
||||
import scala.concurrent.duration._
|
||||
import scala.concurrent.{Await, ExecutionContext, Future}
|
||||
import scala.collection.JavaConverters._
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
import scala.util.Try
|
||||
|
||||
/**
|
||||
* This class creates the actions and the websocket needed.
|
||||
* Admin panel for managing deciders.
|
||||
* Authentication via Steam OpenID.
|
||||
*/
|
||||
@Singleton
|
||||
class AdminController @Inject()(cc: ControllerComponents, homeController: HomeController) extends AbstractController(cc) {
|
||||
class AdminController @Inject()(
|
||||
cc: ControllerComponents,
|
||||
ws: WSClient
|
||||
) extends AbstractController(cc) with SecuredActions {
|
||||
|
||||
implicit val actorSystem: ActorSystem = ActorSystem()
|
||||
implicit val ec: ExecutionContext = defaultExecutionContext
|
||||
implicit val timeout = Timeout(100.millis)
|
||||
implicit val ec: ExecutionContext = cc.executionContext
|
||||
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.AdminController")
|
||||
|
||||
// Use a direct reference to SLF4J
|
||||
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.HomeController")
|
||||
// ---------- Ownership ----------
|
||||
|
||||
val userParentActor: ActorRef = homeController.userParentActor
|
||||
private def currentSteamId[A](request: Request[A]): String =
|
||||
SecuredActions.steamIdOrUnknown(request)
|
||||
|
||||
// Home page that renders template
|
||||
def viewAllUsers() = Action { implicit request =>
|
||||
logger.info(s"Received request from: ${request.remoteAddress}")
|
||||
/** Редактировать и удалять десайдера может только его владелец. */
|
||||
private def owns[A](decider: repository.Decider, request: Request[A]): Boolean =
|
||||
decider.ownerSteamId.contains(currentSteamId(request))
|
||||
|
||||
val allUsers = userParentActor ? GetAllUsers
|
||||
val res = Await.result(allUsers, Duration.Inf)
|
||||
Ok(res.toString)
|
||||
private def deciderJson(d: repository.Decider): JsObject = Json.obj(
|
||||
"key" -> d.key,
|
||||
"name" -> d.name,
|
||||
"rules" -> d.rules,
|
||||
"raceCount" -> d.raceCount,
|
||||
"existAdditionalRaces" -> d.existAdditionalRaces,
|
||||
"existTextFieldRaces" -> d.existTextFieldRaces,
|
||||
"description" -> d.description,
|
||||
"ownerSteamId" -> d.ownerSteamId,
|
||||
"maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description))
|
||||
)
|
||||
|
||||
// ---------- Steam OpenID ----------
|
||||
|
||||
private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
|
||||
|
||||
// ---------- Public endpoints ----------
|
||||
|
||||
def login() = Action { implicit request =>
|
||||
val realm = request.headers.get("Host").map(h => s"http://$h/").getOrElse("http://localhost:1337/")
|
||||
val returnTo = controllers.routes.AdminController.steamCallback().absoluteURL()
|
||||
|
||||
val params = Map(
|
||||
"openid.ns" -> "http://specs.openid.net/auth/2.0",
|
||||
"openid.mode" -> "checkid_setup",
|
||||
"openid.return_to" -> returnTo,
|
||||
"openid.realm" -> realm,
|
||||
"openid.identity" -> "http://specs.openid.net/auth/2.0/identifier_select",
|
||||
"openid.claimed_id" -> "http://specs.openid.net/auth/2.0/identifier_select"
|
||||
)
|
||||
|
||||
val query = params.map { case (k, v) => s"${java.net.URLEncoder.encode(k, "UTF-8")}=${java.net.URLEncoder.encode(v, "UTF-8")}" }.mkString("&")
|
||||
Redirect(s"$SteamLoginUrl?$query")
|
||||
}
|
||||
|
||||
def steamCallback() = Action.async { implicit request =>
|
||||
// Steam присылает параметры в формате OpenID: openid.mode, openid.identity,
|
||||
// openid.claimed_id. Play отдаёт ключи в queryString как есть, с точкой,
|
||||
// поэтому нормализуем имена к виду openid_mode и т.д.
|
||||
val openidParams = request.queryString.map { case (k, v) => k.replace('.', '_') -> v }
|
||||
|
||||
def reloadConfig()= Action { implicit request =>
|
||||
ConfigFactory.invalidateCaches()
|
||||
logger.info("Config reloaded")
|
||||
// Validate Steam OpenID response
|
||||
val mode = openidParams.get("openid_mode").flatMap(_.headOption)
|
||||
val identifier = openidParams.get("openid_identity").flatMap(_.headOption)
|
||||
val assertion = openidParams.get("openid_claimed_id").flatMap(_.headOption)
|
||||
|
||||
(mode, identifier orElse assertion) match {
|
||||
case (Some("id_res"), Some(steamId)) if isSteamId(steamId) =>
|
||||
val steamId64 = parseSteamId64(steamId)
|
||||
val config = ConfigFactory.load()
|
||||
val allowed = Try(config.getStringList("admin.allowedSteamIds").asScala.toSet).getOrElse(Set.empty)
|
||||
if (allowed.isEmpty || allowed.contains(steamId64)) {
|
||||
Future.successful(Redirect("/decider/admin").withSession("steamId" -> steamId64))
|
||||
} else {
|
||||
Future.successful(Redirect("/decider/admin").flashing("error" -> "You are not authorized"))
|
||||
}
|
||||
|
||||
case (Some("id_res"), _) =>
|
||||
Future.successful(Redirect("/decider/admin").flashing("error" -> "Steam authentication failed"))
|
||||
|
||||
case _ =>
|
||||
Future.successful(Redirect("/decider/admin").flashing("error" -> "Invalid OpenID response"))
|
||||
}
|
||||
}
|
||||
|
||||
private def isSteamId(id: String): Boolean =
|
||||
id.startsWith("https://steamcommunity.com/openid/id/") ||
|
||||
id.startsWith("http://steamcommunity.com/openid/id/")
|
||||
|
||||
private def parseSteamId64(id: String): String =
|
||||
id.replaceAll("""(https?://steamcommunity\.com/openid/id/)""", "")
|
||||
|
||||
// ---------- Admin page ----------
|
||||
|
||||
def index() = Secured { implicit request =>
|
||||
implicit val messages: Messages = messagesApi.preferred(request)
|
||||
Ok(views.html.admin(DeciderRepository.all.filter(d => owns(d, request))))
|
||||
}
|
||||
|
||||
// ---------- API: list map images ----------
|
||||
|
||||
def mapImages() = Secured { implicit request =>
|
||||
val images = repository.MapImageRepository.all.map { image =>
|
||||
Json.obj(
|
||||
"name" -> image.name,
|
||||
"file" -> image.file,
|
||||
"players" -> image.players
|
||||
)
|
||||
}
|
||||
Ok(Json.toJson(images))
|
||||
}
|
||||
|
||||
// ---------- API: upload map image ----------
|
||||
|
||||
def mapImageUpload() = Secured(parse.multipartFormData) { implicit request =>
|
||||
request.body.file("file") match {
|
||||
case Some(part) =>
|
||||
repository.MapImageRepository.saveUploaded(part.filename, part.ref.path.toFile) match {
|
||||
case Right(image) =>
|
||||
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(request)} uploaded map image '${image.file}'")
|
||||
Ok(Json.obj(
|
||||
"ok" -> true,
|
||||
"name" -> image.name,
|
||||
"file" -> image.file,
|
||||
"players" -> image.players
|
||||
))
|
||||
case Left(error) =>
|
||||
BadRequest(Json.obj("error" -> error))
|
||||
}
|
||||
case None =>
|
||||
BadRequest(Json.obj("error" -> "No file uploaded"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- API: list all deciders ----------
|
||||
|
||||
def decidersList() = Secured { implicit request =>
|
||||
val list = DeciderRepository.all.filter(d => owns(d, request)).map(deciderJson)
|
||||
Ok(Json.toJson(list))
|
||||
}
|
||||
|
||||
// ---------- API: get one decider ----------
|
||||
|
||||
def deciderGet(key: String) = Secured { implicit request =>
|
||||
DeciderRepository.get(key) match {
|
||||
case Some(d) if owns(d, request) =>
|
||||
Ok(deciderJson(d))
|
||||
case Some(_) =>
|
||||
Forbidden(Json.obj("error" -> "You don't own this decider"))
|
||||
case None =>
|
||||
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- API: save / update decider ----------
|
||||
|
||||
def deciderSave() = SecuredJson { implicit request =>
|
||||
val json = request.body
|
||||
val steamId = currentSteamId(request)
|
||||
|
||||
(json \ "key").asOpt[String] match {
|
||||
case None =>
|
||||
BadRequest(Json.obj("error" -> "Missing key"))
|
||||
|
||||
case Some(key) =>
|
||||
val existing = DeciderRepository.get(key)
|
||||
if (existing.exists(d => !owns(d, request))) {
|
||||
Forbidden(Json.obj("error" -> "You don't own this decider"))
|
||||
} else {
|
||||
val name = (json \ "name").asOpt[String].map(_.trim).getOrElse("")
|
||||
val duplicateName = existing.isEmpty && name.nonEmpty &&
|
||||
DeciderRepository.all.exists(_.name.trim.equalsIgnoreCase(name))
|
||||
if (duplicateName) {
|
||||
Conflict(Json.obj("error" -> s"A decider named '$name' already exists"))
|
||||
} else {
|
||||
Try {
|
||||
val maps = (json \ "maps").as[List[JsValue]].map { m =>
|
||||
repository.DeciderMapConfig(
|
||||
map = (m \ "map").as[String],
|
||||
description = (m \ "description").asOpt[String]
|
||||
)
|
||||
}
|
||||
repository.Decider(
|
||||
key = key,
|
||||
name = (json \ "name").as[String],
|
||||
rules = (json \ "rules").as[String],
|
||||
raceCount = (json \ "raceCount").as[Int],
|
||||
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
|
||||
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
|
||||
description = (json \ "description").as[Boolean],
|
||||
maps = maps,
|
||||
// Владельца из запроса игнорируем: он либо уже назначен, либо
|
||||
// им становится автор нового десайдера.
|
||||
ownerSteamId = existing.flatMap(_.ownerSteamId).orElse(Some(steamId))
|
||||
)
|
||||
}.toEither match {
|
||||
case Right(decider) =>
|
||||
DeciderRepository.save(decider)
|
||||
DeciderRepository.reload()
|
||||
val action = if (existing.isDefined) "updated" else "created"
|
||||
logger.info(
|
||||
s"Admin $steamId $action decider '${decider.key}' " +
|
||||
s"(name='${decider.name}', maps=${decider.maps.size}, " +
|
||||
s"owner=${decider.ownerSteamId.getOrElse("-")})"
|
||||
)
|
||||
Ok(Json.obj("ok" -> true))
|
||||
|
||||
case Left(error) =>
|
||||
BadRequest(Json.obj("error" -> s"$error"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- API: delete decider ----------
|
||||
|
||||
def deciderDelete(key: String) = SecuredApi { implicit request =>
|
||||
DeciderRepository.get(key) match {
|
||||
case Some(d) if !owns(d, request) =>
|
||||
Forbidden(Json.obj("error" -> "You don't own this decider"))
|
||||
case _ =>
|
||||
if (DeciderRepository.delete(key)) {
|
||||
DeciderRepository.reload()
|
||||
logger.info(s"Admin ${currentSteamId(request)} deleted decider '$key'")
|
||||
Ok(Json.obj("ok" -> true))
|
||||
} else {
|
||||
NotFound(Json.obj("error" -> s"Decider $key not found"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Legacy endpoints (deprecated) ----------
|
||||
|
||||
@deprecated("unused", "1.0")
|
||||
def viewAllUsers() = Action { implicit request =>
|
||||
logger.info("viewAllUsers")
|
||||
Ok("viewAllUsers")
|
||||
}
|
||||
|
||||
@deprecated("unused", "1.0")
|
||||
def reloadConfig() = Action { implicit request =>
|
||||
Ok("Config reloaded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.typesafe.config.ConfigFactory
|
||||
import org.reactivestreams.Publisher
|
||||
import play.api.libs.json._
|
||||
import play.api.mvc._
|
||||
import repository.DeciderRepository
|
||||
|
||||
import scala.concurrent.duration._
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
@@ -41,13 +42,15 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
|
||||
// Home page that renders template
|
||||
def index(deciderName: String) = Action { implicit request =>
|
||||
logger.info(s"Received request from: ${request.remoteAddress}")
|
||||
val deciderHumanName = config.getString(s"deciders.$deciderName.name")
|
||||
val deciderDescription = config.getString(s"deciders.$deciderName.rules")
|
||||
val existAdditionalRaces = Try(config.getBoolean(s"deciders.$deciderName.existAdditionalRaces")).getOrElse(false)
|
||||
val raceCount = Try(config.getInt(s"deciders.$deciderName.raceCount")).getOrElse(1)
|
||||
val deciderOpt = DeciderRepository.get(deciderName)
|
||||
if (deciderOpt.isEmpty) {
|
||||
NotFound(s"Decider $deciderName not found")
|
||||
} else {
|
||||
val d = deciderOpt.get
|
||||
val lastmapsSettings = LastMapsSelectConfig(true, true, true, true)
|
||||
val raceSelect = RaceSelect(true, false)
|
||||
Ok(views.html.index(deciderName, deciderHumanName, raceCount, deciderDescription, raceSelect, lastmapsSettings, existAdditionalRaces))
|
||||
Ok(views.html.index(deciderName, d.name, d.raceCount, d.rules, raceSelect, lastmapsSettings, d.existAdditionalRaces, d.existTextFieldRaces))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,7 +105,7 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
|
||||
* Returns true if the value of the Origin header contains an acceptable value.
|
||||
*/
|
||||
def originMatches(origin: String, remoteAddress: String): Boolean = {
|
||||
origin.contains("89.108.83.108") || origin.contains("crosspick.ru") || origin.contains("localhost") || origin.contains("localhost:9000") || origin.contains("localhost:19001")
|
||||
config.getStringList("play.filters.hosts.allowed").contains(origin.replace("http://","").replace("https://",""))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package controllers
|
||||
|
||||
import java.io.File
|
||||
import javax.inject.{Inject, Singleton}
|
||||
|
||||
import play.api.mvc.{AbstractController, ControllerComponents}
|
||||
import repository.MapImageRepository
|
||||
|
||||
import scala.concurrent.ExecutionContext
|
||||
|
||||
/**
|
||||
* Раздаёт картинки карт из каталога, заданного в конфиге (`maps.imagesDir`).
|
||||
*
|
||||
* Нужен потому, что каталог может находиться вне `public`/`app/assets`
|
||||
* (например, на сервере), и стандартный Assets его не отдаст.
|
||||
*/
|
||||
@Singleton
|
||||
class MapImageController @Inject()(cc: ControllerComponents) extends AbstractController(cc) {
|
||||
|
||||
implicit val ec: ExecutionContext = cc.executionContext
|
||||
|
||||
def image(file: String) = Action { implicit request =>
|
||||
MapImageRepository.findFile(file) match {
|
||||
case Some(image) =>
|
||||
Ok.sendFile(image, inline = true)
|
||||
.as(contentType(image))
|
||||
.withHeaders(CACHE_CONTROL -> "public, max-age=86400")
|
||||
case None => NotFound
|
||||
}
|
||||
}
|
||||
|
||||
private def contentType(file: File): String = {
|
||||
val name = file.getName.toLowerCase
|
||||
if (name.endsWith(".png")) "image/png"
|
||||
else if (name.endsWith(".webp")) "image/webp"
|
||||
else "image/jpeg"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package controllers
|
||||
|
||||
import play.api.libs.json.{JsValue, Json}
|
||||
import play.api.libs.typedmap.TypedKey
|
||||
import play.api.mvc._
|
||||
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
|
||||
object SecuredActions {
|
||||
|
||||
private val SteamIdAttr: TypedKey[String] = TypedKey("steamId")
|
||||
|
||||
/** Steam ID текущего авторизованного пользователя (если он есть в запросе). */
|
||||
def steamId[A](request: Request[A]): Option[String] = request.attrs.get(SteamIdAttr)
|
||||
|
||||
/** Steam ID текущего пользователя или "unknown", если его нет в запросе. */
|
||||
def steamIdOrUnknown[A](request: Request[A]): String = steamId(request).getOrElse("unknown")
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple session-based authentication via Steam ID.
|
||||
* Used to protect admin panel routes.
|
||||
*/
|
||||
trait SecuredActions {
|
||||
|
||||
self: AbstractController =>
|
||||
|
||||
private val SteamIdAttr: TypedKey[String] = SecuredActions.SteamIdAttr
|
||||
|
||||
/** Wrapper that requires the user to be logged in with Steam. */
|
||||
def Secured: ActionBuilder[Request, AnyContent] =
|
||||
securedWith(controllerComponents.parsers.defaultBodyParser, apiStyle = false)
|
||||
|
||||
/** Same as [[Secured]], but parses the request body as JSON. */
|
||||
def SecuredJson: ActionBuilder[Request, JsValue] =
|
||||
securedWith(controllerComponents.parsers.json, apiStyle = true)
|
||||
|
||||
/** Same as [[Secured]], but answers AJAX calls with 401 JSON instead of a redirect. */
|
||||
def SecuredApi: ActionBuilder[Request, AnyContent] =
|
||||
securedWith(controllerComponents.parsers.defaultBodyParser, apiStyle = true)
|
||||
|
||||
private def securedWith[A](bodyParser: BodyParser[A], apiStyle: Boolean): ActionBuilder[Request, A] =
|
||||
new ActionBuilder[Request, A] {
|
||||
|
||||
override def parser: BodyParser[A] = bodyParser
|
||||
|
||||
override def executionContext: ExecutionContext = controllerComponents.executionContext
|
||||
|
||||
override def invokeBlock[B](request: Request[B], block: Request[B] => Future[Result]): Future[Result] =
|
||||
request.session.get("steamId") match {
|
||||
case Some(steamId) =>
|
||||
block(request.addAttr(SteamIdAttr, steamId))
|
||||
case None if apiStyle =>
|
||||
Future.successful(Unauthorized(Json.obj("error" -> "Please log in with Steam")))
|
||||
case None =>
|
||||
Future.successful(Redirect("/decider/admin/login").flashing("error" -> "Please log in with Steam"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package repository
|
||||
|
||||
import com.typesafe.config.{Config, ConfigFactory}
|
||||
|
||||
import java.io.{File, PrintWriter}
|
||||
import scala.collection.JavaConverters._
|
||||
import scala.util.Try
|
||||
|
||||
/** Описание одной карты в маппуле десайдера. */
|
||||
case class DeciderMapConfig(map: String, description: Option[String] = None)
|
||||
|
||||
/** Полное описание десайдера (турнира). */
|
||||
case class Decider(
|
||||
key: String,
|
||||
name: String,
|
||||
rules: String,
|
||||
raceCount: Int,
|
||||
existAdditionalRaces: Boolean,
|
||||
existTextFieldRaces: Boolean,
|
||||
description: Boolean,
|
||||
maps: List[DeciderMapConfig],
|
||||
/** Steam ID владельца: только он может редактировать и удалять десайдера. */
|
||||
ownerSteamId: Option[String] = None
|
||||
)
|
||||
|
||||
/**
|
||||
* Чтение и запись десайдеров.
|
||||
*
|
||||
* Десайдеры хранятся во внешнем HOCON-файле (admin.decidersFile, по умолчанию
|
||||
* "conf/deciders.conf"). Пока этот файл не существует, десайдеры читаются из
|
||||
* application.conf, поэтому приложение работает "из коробки". Как только админ
|
||||
* что-то сохраняет через админку, внешний файл становится единственным источником
|
||||
* правды.
|
||||
*/
|
||||
object DeciderRepository {
|
||||
|
||||
/**
|
||||
* Владелец по умолчанию. Записи без ownerSteamId (например, из старого
|
||||
* application.conf) считаются принадлежащими этому Steam ID.
|
||||
*/
|
||||
val DefaultOwnerSteamId: String = "76561198137977374"
|
||||
|
||||
private def appConfig: Config = ConfigFactory.load()
|
||||
|
||||
/** Файл, в который админка пишет десайдеров. */
|
||||
def file: File = {
|
||||
val path = Try(appConfig.getString("admin.decidersFile")).getOrElse("conf/deciders.conf")
|
||||
new File(path)
|
||||
}
|
||||
|
||||
/** Десайдеры как объект Config (содержимое блока `deciders { ... }`). */
|
||||
def decidersConfig: Config = {
|
||||
val f = file
|
||||
if (f.isFile) {
|
||||
Try(ConfigFactory.parseFile(f)).toOption
|
||||
.filter(_.hasPath("deciders"))
|
||||
.map(_.getConfig("deciders"))
|
||||
.getOrElse(fallback)
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
/** Переинициализировать кеш конфигуратора (вызывать после сохранения). */
|
||||
def reload(): Unit = {
|
||||
ConfigFactory.invalidateCaches()
|
||||
}
|
||||
|
||||
private def fallback: Config =
|
||||
if (appConfig.hasPath("deciders")) appConfig.getConfig("deciders") else ConfigFactory.empty()
|
||||
|
||||
/** Ключи всех десайдеров (отсортированные). */
|
||||
def keys: Seq[String] = decidersConfig.root().keySet().asScala.toSeq.sorted
|
||||
|
||||
/** Один десайдер по ключу. */
|
||||
def get(key: String): Option[Decider] = {
|
||||
val c = decidersConfig
|
||||
if (!c.hasPath(key)) None
|
||||
else {
|
||||
val d = c.getConfig(key)
|
||||
val maps = d.getList("maps").asScala.toList.map { value =>
|
||||
value.unwrapped() match {
|
||||
case list: java.util.List[_] =>
|
||||
val parts = list.asScala.map(_.toString).toList
|
||||
DeciderMapConfig(parts.head, parts.lift(1).filter(_.nonEmpty))
|
||||
case other =>
|
||||
// Некоторые десайдеры заданы просто списком строк без описаний.
|
||||
DeciderMapConfig(other.toString, None)
|
||||
}
|
||||
}
|
||||
Some(Decider(
|
||||
key = key,
|
||||
name = Try(d.getString("name")).getOrElse(key),
|
||||
rules = Try(d.getString("rules")).getOrElse(""),
|
||||
raceCount = Try(d.getInt("raceCount")).getOrElse(1),
|
||||
existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false),
|
||||
existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).getOrElse(false),
|
||||
description = Try(d.getBoolean("description")).getOrElse(false),
|
||||
maps = maps,
|
||||
ownerSteamId = Try(d.getString("ownerSteamId")).toOption
|
||||
.map(_.trim)
|
||||
.filter(_.nonEmpty)
|
||||
.orElse(Some(DefaultOwnerSteamId))
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/** Все десайдеры. */
|
||||
def all: Seq[Decider] = keys.flatMap(get)
|
||||
|
||||
/** Создаёт или обновляет десайдера и перезаписывает весь файл десайдеров. */
|
||||
def save(decider: Decider): Unit = {
|
||||
val updated = (all.filterNot(_.key == decider.key) :+ decider).sortBy(_.key)
|
||||
write(updated)
|
||||
}
|
||||
|
||||
/** Удаляет десайдера. Возвращает true, если десайдер существовал. */
|
||||
def delete(key: String): Boolean = {
|
||||
val current = all
|
||||
if (current.exists(_.key == key)) {
|
||||
write(current.filterNot(_.key == key))
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private def write(deciders: Seq[Decider]): Unit = {
|
||||
val sb = new StringBuilder
|
||||
sb.append("# Конфигурация десайдеров.\n")
|
||||
sb.append("# Файл управляется админкой (авторизация через Steam).\n")
|
||||
sb.append("# Ручные правки могут быть перезаписаны при следующем сохранении.\n\n")
|
||||
sb.append("deciders {\n")
|
||||
deciders.foreach { d =>
|
||||
sb.append(" ").append(quoteKey(d.key)).append(" {\n")
|
||||
sb.append(" name = ").append(quote(d.name)).append('\n')
|
||||
sb.append(" ownerSteamId = ")
|
||||
.append(quote(d.ownerSteamId.getOrElse(DefaultOwnerSteamId)))
|
||||
.append('\n')
|
||||
sb.append(" raceCount = ").append(d.raceCount).append('\n')
|
||||
if (d.existAdditionalRaces) sb.append(" existAdditionalRaces = true\n")
|
||||
if (d.existTextFieldRaces) sb.append(" existTextFieldRaces = true\n")
|
||||
if (d.description) sb.append(" description = true\n")
|
||||
if (d.maps.isEmpty) {
|
||||
sb.append(" maps = []\n")
|
||||
} else {
|
||||
sb.append(" maps = [\n")
|
||||
d.maps.zipWithIndex.foreach { case (m, i) =>
|
||||
sb.append(" ").append(renderMap(m))
|
||||
if (i < d.maps.size - 1) sb.append(',')
|
||||
sb.append('\n')
|
||||
}
|
||||
sb.append(" ]\n")
|
||||
}
|
||||
sb.append(" rules = ").append(quote(d.rules)).append('\n')
|
||||
sb.append(" }\n")
|
||||
}
|
||||
sb.append("}\n")
|
||||
|
||||
val f = file
|
||||
Option(f.getParentFile).foreach(_.mkdirs())
|
||||
val writer = new PrintWriter(f, "UTF-8")
|
||||
try writer.write(sb.toString) finally writer.close()
|
||||
}
|
||||
|
||||
private def renderMap(m: DeciderMapConfig): String =
|
||||
m.description.filter(_.nonEmpty) match {
|
||||
case Some(desc) => s"[${quote(m.map)}, ${quote(desc)}]"
|
||||
case None => s"[${quote(m.map)}]"
|
||||
}
|
||||
|
||||
private def quoteKey(key: String): String =
|
||||
if (key.matches("[A-Za-z0-9_-]+") && !key.forall(_.isDigit)) key else quote(key)
|
||||
|
||||
/** Рендерит строку как строку в формате JSON/HOCON. */
|
||||
private def quote(value: String): String = {
|
||||
val sb = new StringBuilder("\"")
|
||||
value.foreach {
|
||||
case '"' => sb.append("\\\"")
|
||||
case '\\' => sb.append("\\\\")
|
||||
case '\n' => sb.append("\\n")
|
||||
case '\r' => sb.append("\\r")
|
||||
case '\t' => sb.append("\\t")
|
||||
// Литерал из обратного слэша и буквы u ломает компилятор Scala:
|
||||
// такая последовательность обрабатывается как Unicode-escape ещё до лексера.
|
||||
// Поэтому символы добавляем по отдельности, без escape-последовательности в исходнике.
|
||||
case c if c < ' ' =>
|
||||
sb.append(0x5c.toChar).append('u').append("%04x".format(c.toInt))
|
||||
case c => sb.append(c)
|
||||
}
|
||||
sb.append('"')
|
||||
sb.toString
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package repository
|
||||
|
||||
import java.io.File
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
import com.typesafe.config.ConfigFactory
|
||||
|
||||
import scala.util.Try
|
||||
|
||||
/** Изображение карты, доступное для выбора в админке. */
|
||||
case class MapImage(name: String, file: String, players: Int)
|
||||
|
||||
/**
|
||||
* Список картинок карт из каталога, заданного в конфиге (`maps.imagesDir`).
|
||||
*
|
||||
* Каталог читается при каждом обращении, поэтому загруженные через админку
|
||||
* картинки сразу появляются в галерее без перезапуска приложения.
|
||||
*/
|
||||
object MapImageRepository {
|
||||
|
||||
private val ImageExtensions = Set("jpg", "jpeg", "png", "webp")
|
||||
|
||||
/** Служебные картинки, которые не являются картами. */
|
||||
private val IgnoredFiles = Set("default.jpg")
|
||||
private val IgnoredPattern = """^img(_\d+)?\.(jpg|jpeg|png|webp)$""".r
|
||||
|
||||
/** Ключ конфига с каталогом картинок карт. */
|
||||
private val ConfigKey = "maps.imagesDir"
|
||||
|
||||
/** Значение по умолчанию, если ключ в конфиге отсутствует. */
|
||||
private val DefaultImagesDirectory = "app/assets/images/maps"
|
||||
|
||||
/** Каталог с картинками карт из конфига. */
|
||||
def imagesDirectory: File = {
|
||||
val configured = Try(ConfigFactory.load().getString(ConfigKey)).toOption
|
||||
.map(_.trim)
|
||||
.filter(_.nonEmpty)
|
||||
.getOrElse(DefaultImagesDirectory)
|
||||
new File(configured)
|
||||
}
|
||||
|
||||
/** Максимальный размер загружаемого файла. */
|
||||
private val MaxUploadBytes = 10L * 1024 * 1024
|
||||
|
||||
/** Все карты, отсортированные по количеству игроков и имени. */
|
||||
def all: Seq[MapImage] = {
|
||||
val directory = imagesDirectory
|
||||
if (!directory.isDirectory) Seq.empty
|
||||
else
|
||||
Option(directory.listFiles()).toSeq.flatten
|
||||
.filter(_.isFile)
|
||||
.map(_.getName)
|
||||
.filter(isMapImage)
|
||||
.distinct
|
||||
.map(toMapImage)
|
||||
.sortBy(image => (groupOrder(image.players), image.name.toLowerCase))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ищет картинку карты по имени файла.
|
||||
*
|
||||
* Используется для раздачи картинок через контроллер: имя проверяется так,
|
||||
* чтобы запрос не мог выйти за пределы каталога из конфига.
|
||||
*/
|
||||
def findFile(fileName: String): Option[File] = {
|
||||
val name = Option(fileName).map(_.trim).getOrElse("")
|
||||
if (name.isEmpty || name != fileName || !isMapImage(name)) None
|
||||
else {
|
||||
val directory = imagesDirectory
|
||||
val parent = Try(directory.getCanonicalFile).toOption
|
||||
val candidate = Try(new File(directory, name).getCanonicalFile).toOption
|
||||
(parent, candidate) match {
|
||||
case (Some(dir), Some(file)) if file.getParentFile == dir && file.isFile => Some(file)
|
||||
case _ => None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет загруженную картинку карты и возвращает её описание.
|
||||
*
|
||||
* Файл пишется в каталог из конфига, поэтому картинка сразу появляется
|
||||
* в галерее без перезапуска приложения.
|
||||
*/
|
||||
def saveUploaded(rawFileName: String, source: File): Either[String, MapImage] = {
|
||||
if (!source.isFile) Left("Uploaded file not found")
|
||||
else if (source.length() <= 0) Left("Uploaded file is empty")
|
||||
else if (source.length() > MaxUploadBytes) Left("File is too large (max 10 MB)")
|
||||
else sanitizeFileName(rawFileName) match {
|
||||
case None => Left("Unsupported file (allowed types: jpg, jpeg, png, webp)")
|
||||
case Some(safeName) =>
|
||||
val directory = imagesDirectory
|
||||
Try {
|
||||
if (!directory.isDirectory) directory.mkdirs()
|
||||
val fileName = uniqueFileName(safeName, directory)
|
||||
java.nio.file.Files.copy(
|
||||
source.toPath,
|
||||
new File(directory, fileName).toPath,
|
||||
StandardCopyOption.REPLACE_EXISTING
|
||||
)
|
||||
fileName
|
||||
}.toEither match {
|
||||
case Right(fileName) => Right(toMapImage(fileName))
|
||||
case Left(error) => Left(s"Failed to save file: ${error.getMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Проверяет расширение и убирает из имени всё, что может сломать путь. */
|
||||
private def sanitizeFileName(rawFileName: String): Option[String] = {
|
||||
val name = Option(rawFileName)
|
||||
.map(_.trim)
|
||||
.map(new File(_).getName)
|
||||
.getOrElse("")
|
||||
val safe = name.map { c =>
|
||||
if (c.isLetterOrDigit || c == '_' || c == '-' || c == '.' || c == ' ' ||
|
||||
c == '(' || c == ')' || c == '[' || c == ']') c
|
||||
else '_'
|
||||
}.mkString.trim
|
||||
if (safe.nonEmpty && isMapImage(safe)) Some(safe) else None
|
||||
}
|
||||
|
||||
/** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */
|
||||
private def uniqueFileName(fileName: String, directory: File): String = {
|
||||
def taken(name: String): Boolean = new File(directory, name).exists()
|
||||
|
||||
if (!taken(fileName)) fileName
|
||||
else {
|
||||
val dot = fileName.lastIndexOf('.')
|
||||
val base = fileName.substring(0, dot)
|
||||
val ext = fileName.substring(dot)
|
||||
Iterator.from(1)
|
||||
.map(i => s"$base-$i$ext")
|
||||
.find(name => !taken(name))
|
||||
.getOrElse(fileName)
|
||||
}
|
||||
}
|
||||
|
||||
private def isMapImage(fileName: String): Boolean = {
|
||||
val lower = fileName.toLowerCase
|
||||
ImageExtensions.exists(ext => lower.endsWith("." + ext)) &&
|
||||
!IgnoredFiles.contains(lower) &&
|
||||
IgnoredPattern.findFirstIn(lower).isEmpty
|
||||
}
|
||||
|
||||
private def toMapImage(fileName: String): MapImage = {
|
||||
val baseName = fileName.substring(0, fileName.lastIndexOf('.'))
|
||||
MapImage(baseName, fileName, playersFrom(fileName))
|
||||
}
|
||||
|
||||
private val PlayersPattern = """(?i)(?:^|\])\s*(\d+)p""".r
|
||||
|
||||
/** Количество игроков из префикса имени: 2p -> 2, 4p -> 4 и т.д. */
|
||||
private def playersFrom(fileName: String): Int =
|
||||
PlayersPattern.findFirstMatchIn(fileName).map(_.group(1).toInt).getOrElse(0)
|
||||
|
||||
/** Порядок групп: 1x1, 2x2, 3x3, 4x4, остальные. */
|
||||
private def groupOrder(players: Int): Int = players match {
|
||||
case 2 | 4 | 6 | 8 => players
|
||||
case _ => 99
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,727 @@
|
||||
@()(implicit r: Request[_])
|
||||
@(deciders: Seq[repository.Decider])(implicit request: Request[_], messages: Messages)
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="@{play.filters.csrf.CSRF.getToken.map(_.value).getOrElse("")}">
|
||||
<title>Admin — Decider</title>
|
||||
<link rel='stylesheet' href='@routes.Assets.at("lib/bootstrap/css/bootstrap.min.css")'>
|
||||
<style>
|
||||
body { padding-top: 70px; }
|
||||
.decider-card { margin-bottom: 15px; border-left: 4px solid #337ab7; }
|
||||
/* Строка карты занимает всю доступную ширину: выбор карты фиксированный, описание растягивается */
|
||||
.map-row { margin-bottom: 5px; display: flex; align-items: center; }
|
||||
.map-row .map-desc { flex: 1 1 auto; min-width: 0; width: auto; }
|
||||
.map-row .map-preview, .map-row .btn-remove-map { flex: 0 0 auto; }
|
||||
.modal-footer { text-align: right; }
|
||||
.btn-remove-map { margin-left: 5px; }
|
||||
.decider-key { font-family: monospace; color: #666; font-size: 0.9em; }
|
||||
.nav-tabs > li > a { cursor: pointer; }
|
||||
#deciderList { max-height: calc(100vh - 200px); overflow-y: auto; }
|
||||
#deciderList .list-group-item { cursor: pointer; }
|
||||
#deciderList .list-group-item.active { background-color: #337ab7; border-color: #337ab7; }
|
||||
#deciderList .list-group-item:hover { background-color: #f5f5f5; }
|
||||
#deciderList .list-group-item.active:hover { background-color: #286090; }
|
||||
.error-msg { color: red; margin-top: 10px; }
|
||||
.success-msg { color: green; margin-top: 10px; }
|
||||
.map-preview { width: 64px; height: 40px; object-fit: cover; border-radius: 3px; margin-right: 6px; vertical-align: middle; }
|
||||
/* Кнопка выбора карты вместо ручного ввода имени */
|
||||
.btn-select-map { width: 170px; flex: 0 0 170px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 6px; }
|
||||
.map-gallery { max-height: 60vh; overflow-y: auto; }
|
||||
/* Поиск карт и загрузка картинки */
|
||||
.map-picker-toolbar { display: flex; align-items: center; margin-bottom: 10px; }
|
||||
.map-picker-toolbar .map-search { flex: 1 1 auto; margin-right: 8px; }
|
||||
#mapUploadMessage { min-height: 18px; margin-bottom: 8px; }
|
||||
.map-gallery .map-thumb { cursor: pointer; text-align: center; margin-bottom: 10px; padding: 4px; border: 1px solid transparent; border-radius: 4px; }
|
||||
.map-gallery .map-thumb:hover { border-color: #337ab7; background-color: #f5f5f5; }
|
||||
.map-gallery .map-thumb img { width: 100%; max-width: 160px; height: 100px; object-fit: cover; border-radius: 4px; }
|
||||
.map-gallery .map-thumb-name { font-size: 11px; word-break: break-all; margin-top: 3px; }
|
||||
#mapPickerTabs { margin-bottom: 15px; }
|
||||
|
||||
<html>
|
||||
/* Шапка: название слева, Steam ID и Refresh справа, всё по центру по вертикали */
|
||||
.navbar .container { display: flex; align-items: center; justify-content: space-between; }
|
||||
.navbar-header { float: none; }
|
||||
.navbar-user { display: flex; align-items: center; margin-left: 15px; color: #ccc; }
|
||||
.navbar-user .navbar-user-id { margin-right: 10px; white-space: nowrap; }
|
||||
.navbar-user .btn { margin: 0; }
|
||||
|
||||
/* Кнопки в заголовках панелей по центру строки с заголовком */
|
||||
.panel-heading .panel-title { display: flex; align-items: center; justify-content: space-between; }
|
||||
.panel-heading .panel-title > .btn { flex: 0 0 auto; margin-left: 10px; }
|
||||
|
||||
@@media (max-width: 480px) {
|
||||
.navbar-user .navbar-user-id { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-inverse navbar-fixed-top">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<a class="navbar-brand" href="/decider/admin">Decider admin</a>
|
||||
</div>
|
||||
<div class="navbar-user">
|
||||
<span class="navbar-user-id">Steam ID: @(request.session.get("steamId").getOrElse("unknown"))</span>
|
||||
<a href="/decider/admin" class="btn btn-sm btn-default">Refresh</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<!-- Left panel: list of deciders -->
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
Deciders (@deciders.size)
|
||||
<button class="btn btn-sm btn-success" onclick="openNewDecider()">+ New</button>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="list-group" id="deciderList">
|
||||
@for(d <- deciders) {
|
||||
<a class="list-group-item" data-key="@d.key" onclick="openDecider('@d.key')">
|
||||
<h4 class="list-group-item-heading">@d.name</h4>
|
||||
<p class="list-group-item-text decider-key">@d.key • maps: @d.maps.size • owner: @d.ownerSteamId.getOrElse("-")</p>
|
||||
</a>
|
||||
}
|
||||
@if(deciders.isEmpty) {
|
||||
<div class="list-group-item text-muted">No deciders</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right panel: edit form -->
|
||||
<div class="col-md-8">
|
||||
<div class="panel panel-default" id="editPanel" style="display: none;">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
<span id="editTitle">Edit</span>
|
||||
<button class="btn btn-sm btn-danger" id="btnDelete" onclick="deleteCurrentDecider()">Delete</button>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<form id="deciderForm">
|
||||
<input type="hidden" id="deciderKey">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="formKey">Key</label>
|
||||
<input type="text" class="form-control" id="formKey" placeholder="e.g. dawn_of_war">
|
||||
<p class="help-block">Unique decider identifier (letters, digits, hyphen, underscore)</p>
|
||||
<p class="help-block" id="keyUrlBlock" style="display: none;">
|
||||
URL: <a id="formKeyUrl" href="#" target="_blank" rel="noopener"></a>
|
||||
<button type="button" class="btn btn-xs btn-default" id="btnCopyUrl"
|
||||
title="Copy URL to clipboard">Copy</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="formOwner">Owner Steam ID</label>
|
||||
<input type="text" class="form-control" id="formOwner" readonly>
|
||||
<p class="help-block">Owner cannot be changed</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="formName">Name</label>
|
||||
<input type="text" class="form-control" id="formName" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="formRaceCount">Race Count</label>
|
||||
<input type="number" class="form-control" id="formRaceCount" min="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="formRules">Rules</label>
|
||||
<textarea class="form-control" id="formRules" rows="5"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="formExistAdditionalRaces"> Additional Races (Race count per team doubled)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="formExistTextFieldRaces"> Exist Text Field Races (for Unification like mods)
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" id="formDescription">Map description
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Maps</h4>
|
||||
<div id="mapsContainer"></div>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="openMapPicker()">+ Add map</button>
|
||||
|
||||
<hr>
|
||||
<div class="form-group text-right">
|
||||
<button type="button" class="btn btn-default" onclick="closeEditPanel()">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveDecider()">Save</button>
|
||||
</div>
|
||||
<div id="saveMessage"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="emptyState" class="text-muted text-center" style="padding: 50px;">
|
||||
<h3>Select a decider to edit</h3>
|
||||
<p>or create a new one</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete confirmation modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||
<h4 class="modal-title">Confirm deletion</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete decider <strong id="deleteDeciderName"></strong>?</p>
|
||||
<p class="text-danger">This action cannot be undone!</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map image picker modal -->
|
||||
<div class="modal fade" id="mapPickerModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||
<h4 class="modal-title">Choose a map</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="map-picker-toolbar">
|
||||
<input type="search" id="mapSearch" class="form-control map-search" placeholder="Search maps..." autocomplete="off">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="btnUploadMap">Upload image</button>
|
||||
<input type="file" id="mapUploadInput" accept="image/jpeg,image/png,image/webp" style="display: none;">
|
||||
</div>
|
||||
<div id="mapUploadMessage"></div>
|
||||
<ul class="nav nav-pills" id="mapPickerTabs"></ul>
|
||||
<div class="row map-gallery" id="mapGallery"></div>
|
||||
<div id="mapPickerEmpty" class="text-muted" style="display: none;">No map images found</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src='@routes.Assets.at("lib/jquery/jquery.min.js")'></script>
|
||||
<script src='@routes.Assets.at("lib/bootstrap/js/bootstrap.min.js")'></script>
|
||||
<script src='@routes.Assets.at("lib/underscore/underscore.js")'></script>
|
||||
<script>
|
||||
var currentDeciderKey = null;
|
||||
// Steam ID текущего пользователя: владелец нового десайдера.
|
||||
var currentSteamId = '@request.session.get("steamId").getOrElse("")';
|
||||
|
||||
// CSRF: все AJAX-запросы отправляем с токеном из meta-тега,
|
||||
// иначе Play отклоняет POST/DELETE с JSON-телом.
|
||||
(function() {
|
||||
var token = $('meta[name="csrf-token"]').attr('content');
|
||||
if (token) {
|
||||
$.ajaxSetup({ headers: { 'Csrf-Token': token } });
|
||||
}
|
||||
})();
|
||||
|
||||
// Open decider for editing
|
||||
function openDecider(key) {
|
||||
currentDeciderKey = key;
|
||||
$.ajax({
|
||||
url: '/decider/admin/deciders/' + encodeURIComponent(key),
|
||||
method: 'GET',
|
||||
success: function(decider) {
|
||||
showEditPanel(decider);
|
||||
},
|
||||
error: function() {
|
||||
alert('Failed to load decider');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Open new decider form
|
||||
function openNewDecider() {
|
||||
currentDeciderKey = null;
|
||||
showEditPanel({
|
||||
key: '',
|
||||
name: '',
|
||||
rules: '',
|
||||
raceCount: 1,
|
||||
existAdditionalRaces: false,
|
||||
existTextFieldRaces: false,
|
||||
description: false,
|
||||
maps: []
|
||||
});
|
||||
}
|
||||
|
||||
// Show edit panel with decider data
|
||||
function showEditPanel(decider) {
|
||||
$('#emptyState').hide();
|
||||
$('#editPanel').show();
|
||||
$('#editTitle').text(decider.key ? 'Edit: ' + decider.name : 'New decider');
|
||||
|
||||
$('#deciderKey').val(decider.key);
|
||||
$('#formKey').val(decider.key).prop('readonly', !!decider.key);
|
||||
|
||||
// Ссылка на десайдер: показываем только для уже созданных.
|
||||
if (decider.key) {
|
||||
var deciderPath = '/decider/' + encodeURIComponent(decider.key);
|
||||
$('#formKeyUrl')
|
||||
.attr('href', deciderPath)
|
||||
.text(window.location.origin + deciderPath);
|
||||
$('#keyUrlBlock').show();
|
||||
} else {
|
||||
$('#formKeyUrl').attr('href', '#').text('');
|
||||
$('#keyUrlBlock').hide();
|
||||
}
|
||||
|
||||
$('#btnDelete').toggle(!!decider.key);
|
||||
$('#formOwner').val(decider.ownerSteamId || currentSteamId);
|
||||
$('#formName').val(decider.name);
|
||||
$('#formRules').val(decider.rules);
|
||||
$('#formRaceCount').val(decider.raceCount);
|
||||
$('#formExistAdditionalRaces').prop('checked', decider.existAdditionalRaces);
|
||||
$('#formExistTextFieldRaces').prop('checked', decider.existTextFieldRaces);
|
||||
$('#formDescription').prop('checked', decider.description);
|
||||
|
||||
// Render maps
|
||||
var container = $('#mapsContainer');
|
||||
container.empty();
|
||||
if (decider.maps && decider.maps.length > 0) {
|
||||
decider.maps.forEach(function(map, i) {
|
||||
addMapRow(map.map, map.description);
|
||||
});
|
||||
} else {
|
||||
addMapRow();
|
||||
}
|
||||
|
||||
// Превью карт: подгружаем список картинок, чтобы найти файлы любого формата.
|
||||
ensureMapImages();
|
||||
|
||||
// Highlight selected in list (точное совпадение по ключу десайдера)
|
||||
$('.list-group-item').removeClass('active');
|
||||
if (decider.key) {
|
||||
$('.list-group-item').filter(function() {
|
||||
return $(this).attr('data-key') === decider.key;
|
||||
}).addClass('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Close edit panel
|
||||
function closeEditPanel() {
|
||||
$('#editPanel').hide();
|
||||
$('#emptyState').show();
|
||||
currentDeciderKey = null;
|
||||
}
|
||||
|
||||
// ---------- Map image picker ----------
|
||||
|
||||
var mapImages = null;
|
||||
var pickerTargetRow = null;
|
||||
var activeMapGroup = 2;
|
||||
var mapSearchQuery = '';
|
||||
|
||||
// Группы карт: 1x1, 2x2, 3x3, 4x4 и остальные.
|
||||
var mapGroups = [
|
||||
{ players: 2, title: '1x1 (2p)' },
|
||||
{ players: 4, title: '2x2 (4p)' },
|
||||
{ players: 6, title: '3x3 (6p)' },
|
||||
{ players: 8, title: '4x4 (8p)' },
|
||||
{ players: 0, title: 'Other' }
|
||||
];
|
||||
|
||||
function openMapPicker(targetRow) {
|
||||
pickerTargetRow = targetRow || null;
|
||||
mapSearchQuery = '';
|
||||
$('#mapSearch').val('');
|
||||
$('#mapUploadMessage').text('');
|
||||
if (mapImages) {
|
||||
showMapPicker();
|
||||
return;
|
||||
}
|
||||
$.ajax({
|
||||
url: '/decider/admin/maps',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
mapImages = data;
|
||||
showMapPicker();
|
||||
},
|
||||
error: function() {
|
||||
alert('Failed to load map images');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showMapPicker() {
|
||||
// Если в текущей группе нет карт, открываем первую непустую.
|
||||
if (mapsInGroup(activeMapGroup).length === 0) {
|
||||
var firstNonEmpty = _.find(mapGroups, function(g) {
|
||||
return mapsInGroup(g.players).length > 0;
|
||||
});
|
||||
activeMapGroup = firstNonEmpty ? firstNonEmpty.players : 2;
|
||||
}
|
||||
renderMapTabs();
|
||||
renderMapGallery();
|
||||
$('#mapPickerModal').modal('show');
|
||||
}
|
||||
|
||||
function mapsInGroup(players) {
|
||||
return (mapImages || []).filter(function(m) {
|
||||
return players === 0
|
||||
? [2, 4, 6, 8].indexOf(m.players) === -1
|
||||
: m.players === players;
|
||||
});
|
||||
}
|
||||
|
||||
// Карты для галереи: при активном поиске ищем по всем группам сразу.
|
||||
function visibleMaps() {
|
||||
if (!mapSearchQuery) {
|
||||
return mapsInGroup(activeMapGroup);
|
||||
}
|
||||
return (mapImages || []).filter(function(m) {
|
||||
return (m.name || '').toLowerCase().indexOf(mapSearchQuery) !== -1 ||
|
||||
(m.file || '').toLowerCase().indexOf(mapSearchQuery) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
function renderMapTabs() {
|
||||
var tabs = $('#mapPickerTabs').empty();
|
||||
// Во время поиска вкладки групп не нужны: показываются все совпадения.
|
||||
if (mapSearchQuery) {
|
||||
tabs.hide();
|
||||
return;
|
||||
}
|
||||
tabs.show();
|
||||
mapGroups.forEach(function(group) {
|
||||
var count = mapsInGroup(group.players).length;
|
||||
if (group.players === 0 && count === 0) return;
|
||||
var link = $('<a href="#">')
|
||||
.text(group.title + ' (' + count + ')')
|
||||
.on('click', function(e) {
|
||||
e.preventDefault();
|
||||
activeMapGroup = group.players;
|
||||
renderMapTabs();
|
||||
renderMapGallery();
|
||||
});
|
||||
tabs.append($('<li>').toggleClass('active', group.players === activeMapGroup).append(link));
|
||||
});
|
||||
}
|
||||
|
||||
function renderMapGallery() {
|
||||
var gallery = $('#mapGallery').empty();
|
||||
var maps = visibleMaps();
|
||||
maps.forEach(function(map) {
|
||||
var thumb = $('<div class="map-thumb">')
|
||||
.append($('<img>').attr('src', mapImageUrl(map.file)).attr('alt', map.name).attr('title', map.name))
|
||||
.append($('<div class="map-thumb-name">').text(map.name))
|
||||
.on('click', function() {
|
||||
chooseMap(map.name);
|
||||
});
|
||||
gallery.append($('<div class="col-xs-6 col-sm-4 col-md-3">').append(thumb));
|
||||
});
|
||||
$('#mapPickerEmpty')
|
||||
.text(mapSearchQuery ? 'No maps match your search' : 'No map images found')
|
||||
.toggle(maps.length === 0);
|
||||
}
|
||||
|
||||
// Перечитывает список картинок с сервера и перерисовывает галерею.
|
||||
function reloadMapImages(preferredGroup) {
|
||||
$.ajax({
|
||||
url: '/decider/admin/maps',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
mapImages = data;
|
||||
if (preferredGroup !== undefined) {
|
||||
activeMapGroup = preferredGroup;
|
||||
}
|
||||
renderMapTabs();
|
||||
renderMapGallery();
|
||||
},
|
||||
error: function() {
|
||||
alert('Failed to load map images');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Поиск и загрузка картинок карт ----------
|
||||
|
||||
$('#mapSearch').on('input', function() {
|
||||
mapSearchQuery = $(this).val().trim().toLowerCase();
|
||||
renderMapTabs();
|
||||
renderMapGallery();
|
||||
});
|
||||
|
||||
$('#btnUploadMap').on('click', function() {
|
||||
$('#mapUploadInput').click();
|
||||
});
|
||||
|
||||
$('#mapUploadInput').on('change', function() {
|
||||
var input = this;
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
var msgEl = $('#mapUploadMessage');
|
||||
msgEl.removeClass('text-danger').addClass('text-muted').text('Uploading...');
|
||||
|
||||
$.ajax({
|
||||
url: '/decider/admin/maps',
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
success: function(image) {
|
||||
msgEl.removeClass('text-danger').text('');
|
||||
input.value = '';
|
||||
mapSearchQuery = '';
|
||||
$('#mapSearch').val('');
|
||||
reloadMapImages(image ? image.players : undefined);
|
||||
},
|
||||
error: function(xhr) {
|
||||
var error = 'Upload failed';
|
||||
if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||
error = xhr.responseJSON.error;
|
||||
}
|
||||
msgEl.removeClass('text-muted').addClass('text-danger').text(error);
|
||||
input.value = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function chooseMap(mapName) {
|
||||
if (pickerTargetRow && $.contains(document, pickerTargetRow[0])) {
|
||||
pickerTargetRow.find('.map-name').val(mapName);
|
||||
updateMapRow(pickerTargetRow);
|
||||
} else {
|
||||
addMapRow(mapName, '');
|
||||
}
|
||||
pickerTargetRow = null;
|
||||
$('#mapPickerModal').modal('hide');
|
||||
}
|
||||
|
||||
function mapImageUrl(file) {
|
||||
return '/assets/images/maps/' + encodeURIComponent(file);
|
||||
}
|
||||
|
||||
// ---------- Map rows ----------
|
||||
|
||||
// Add map row
|
||||
function addMapRow(mapName, description) {
|
||||
var row = $('<div class="map-row form-inline">');
|
||||
|
||||
var preview = $('<img class="map-preview" alt="">');
|
||||
// Имя карты задаётся только через выбор из галереи, ручной ввод запрещён.
|
||||
var nameInput = $('<input type="hidden" class="map-name">')
|
||||
.val(mapName || '');
|
||||
var descInput = $('<input type="text" class="form-control map-desc" placeholder="Description">')
|
||||
.val(description || '');
|
||||
var selectBtn = $('<button type="button" class="btn btn-sm btn-default btn-select-map">Select map</button>')
|
||||
.on('click', function() {
|
||||
openMapPicker(row);
|
||||
});
|
||||
var removeBtn = $('<button type="button" class="btn btn-sm btn-danger btn-remove-map" title="Remove map">✕</button>')
|
||||
.on('click', function() {
|
||||
row.remove();
|
||||
});
|
||||
|
||||
row.append(preview, nameInput, selectBtn, descInput, removeBtn);
|
||||
$('#mapsContainer').append(row);
|
||||
updateMapRow(row);
|
||||
}
|
||||
|
||||
// Обновляет превью и подпись кнопки выбора карты
|
||||
function updateMapRow(row) {
|
||||
var mapName = row.find('.map-name').val().trim();
|
||||
var selectBtn = row.find('.btn-select-map');
|
||||
selectBtn.text(mapName || 'Select map').attr('title', mapName || 'Select map');
|
||||
|
||||
var img = row.find('.map-preview').off('error').off('load');
|
||||
if (!mapName) {
|
||||
img.hide().removeAttr('src');
|
||||
return;
|
||||
}
|
||||
img.attr('src', mapImageUrl(mapImageFileFor(mapName))).show().on('error', function() {
|
||||
$(this).hide();
|
||||
});
|
||||
}
|
||||
|
||||
// Имя файла картинки для превью: если список карт загружен, берём реальный
|
||||
// файл (он может быть png/webp), иначе считаем, что это jpg.
|
||||
function mapImageFileFor(mapName) {
|
||||
var found = _.find(mapImages || [], function(m) {
|
||||
return m.name === mapName;
|
||||
});
|
||||
if (found) return found.file;
|
||||
return /\.(jpg|jpeg|png|webp)$/i.test(mapName) ? mapName : mapName + '.jpg';
|
||||
}
|
||||
|
||||
// Догружает список картинок, чтобы превью находили файлы любого формата.
|
||||
function ensureMapImages() {
|
||||
if (mapImages) return;
|
||||
$.ajax({
|
||||
url: '/decider/admin/maps',
|
||||
method: 'GET',
|
||||
success: function(data) {
|
||||
mapImages = data;
|
||||
$('#mapsContainer .map-row').each(function() {
|
||||
updateMapRow($(this));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove map row
|
||||
function removeMapRow(btn) {
|
||||
$(btn).closest('.map-row').remove();
|
||||
}
|
||||
|
||||
// Collect form data and save
|
||||
function saveDecider() {
|
||||
var key = $('#formKey').val().trim();
|
||||
if (!key) {
|
||||
alert('Key is required');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
|
||||
alert('Key may contain only letters, digits, hyphen and underscore');
|
||||
return;
|
||||
}
|
||||
|
||||
var maps = [];
|
||||
$('#mapsContainer .map-row').each(function() {
|
||||
var mapName = $(this).find('.map-name').val().trim();
|
||||
var desc = $(this).find('.map-desc').val().trim();
|
||||
if (mapName) {
|
||||
maps.push({ map: mapName, description: desc || null });
|
||||
}
|
||||
});
|
||||
|
||||
var data = {
|
||||
key: key,
|
||||
name: $('#formName').val().trim(),
|
||||
rules: $('#formRules').val(),
|
||||
raceCount: parseInt($('#formRaceCount').val()) || 1,
|
||||
existAdditionalRaces: $('#formExistAdditionalRaces').is(':checked'),
|
||||
existTextFieldRaces: $('#formExistTextFieldRaces').is(':checked'),
|
||||
description: $('#formDescription').is(':checked'),
|
||||
maps: maps
|
||||
};
|
||||
|
||||
var msgEl = $('#saveMessage');
|
||||
msgEl.html('<span class="text-muted">Saving...</span>');
|
||||
|
||||
var url = '/decider/admin/deciders';
|
||||
var method = 'POST';
|
||||
|
||||
$.ajax({
|
||||
url: url,
|
||||
method: method,
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(data),
|
||||
success: function() {
|
||||
msgEl.html('<span class="success-msg">Saved!</span>');
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 1000);
|
||||
},
|
||||
error: function(xhr) {
|
||||
var error = '';
|
||||
try { error = JSON.parse(xhr.responseText).error; } catch(e) {}
|
||||
msgEl.html('<span class="error-msg">Error: ' + error + '</span>');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete current decider
|
||||
function deleteCurrentDecider() {
|
||||
if (!currentDeciderKey) return;
|
||||
var deciderName = $('#formName').val();
|
||||
$('#deleteDeciderName').text(deciderName + ' (' + currentDeciderKey + ')');
|
||||
$('#deleteModal').modal('show');
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
var key = currentDeciderKey;
|
||||
$.ajax({
|
||||
url: '/decider/admin/deciders/' + encodeURIComponent(key),
|
||||
method: 'DELETE',
|
||||
success: function() {
|
||||
location.reload();
|
||||
},
|
||||
error: function() {
|
||||
alert('Failed to delete decider');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Копирование ссылки на десайдер в буфер обмена.
|
||||
// navigator.clipboard доступен только в защищённом контексте (https/localhost),
|
||||
// поэтому есть запасной вариант через скрытую textarea.
|
||||
function copyDeciderUrl() {
|
||||
var url = $('#formKeyUrl').text();
|
||||
if (!url) return;
|
||||
|
||||
function done(ok) {
|
||||
var btn = $('#btnCopyUrl');
|
||||
btn.text(ok ? 'Copied!' : 'Copy failed');
|
||||
setTimeout(function() { btn.text('Copy'); }, 1500);
|
||||
}
|
||||
|
||||
function fallbackCopy() {
|
||||
var textarea = $('<textarea>').val(url).css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
opacity: 0
|
||||
}).appendTo('body');
|
||||
textarea[0].select();
|
||||
var ok = false;
|
||||
try {
|
||||
ok = document.execCommand('copy');
|
||||
} catch (e) {
|
||||
ok = false;
|
||||
}
|
||||
textarea.remove();
|
||||
done(ok);
|
||||
}
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
done(true);
|
||||
}, fallbackCopy);
|
||||
} else {
|
||||
fallbackCopy();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize: if there's only one decider, open it automatically
|
||||
$(document).ready(function() {
|
||||
// Nothing to do on load - user selects from list
|
||||
$('#mapPickerModal').on('hidden.bs.modal', function() {
|
||||
pickerTargetRow = null;
|
||||
});
|
||||
|
||||
$('#btnCopyUrl').on('click', copyDeciderUrl);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
@(deciderName: String, deciderHumanName: String, raceCount: Int, rules: String, raceSelect: RaceSelect, boSettings: LastMapsSelectConfig, existAdditionalRaces: Boolean)(implicit r: Request[_])
|
||||
@(deciderName: String, deciderHumanName: String, raceCount: Int, rules: String, raceSelect: RaceSelect, boSettings: LastMapsSelectConfig, existAdditionalRaces: Boolean, existTextFieldRaces: Boolean)(implicit r: Request[_])
|
||||
<!DOCTYPE html>
|
||||
|
||||
<span id="deciderName" style="display: none">@deciderName</span>
|
||||
<span id="raceCount" style="display: none">@raceCount</span>
|
||||
<span id="existAdditionalRaces" style="display: none">@existAdditionalRaces</span>
|
||||
<span id="existTextFieldRaces" style="display: none">@existTextFieldRaces</span>
|
||||
<span id="last1Presence" style="display: none">@boSettings.last1</span>
|
||||
<span id="last3Presence" style="display: none">@boSettings.last3</span>
|
||||
<span id="last5Presence" style="display: none">@boSettings.last5</span>
|
||||
@@ -20,7 +21,7 @@
|
||||
<script type='text/javascript' src='@routes.Assets.at("lib/underscore/underscore.js")'></script>
|
||||
<script type='text/javascript' src='@routes.Assets.at("lib/backbonejs/backbone.js")'></script>
|
||||
<script type="text/javascript" src="@routes.Assets.at("lib/jquery-cookie/jquery.cookie.js")"></script>
|
||||
<script type='text/javascript' src='@routes.Assets.at("javascripts/index.js?170823")'></script>
|
||||
<script type='text/javascript' src='@routes.Assets.at("javascripts/index.js?14122025")'></script>
|
||||
</head>
|
||||
<body data-ws-url="@routes.HomeController.ws.webSocketURL()">
|
||||
<div class="navbar navbar-inverse navbar-fixed-top">
|
||||
@@ -60,7 +61,7 @@
|
||||
</div>
|
||||
<div style="clear: both"></div>
|
||||
<div><i><b>Правила турнира "@deciderHumanName"</b><br/>
|
||||
<p>@rules</p>
|
||||
<p style="white-space: pre-wrap">@rules</p>
|
||||
<br>
|
||||
</i>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ version := "1.0-SNAPSHOT"
|
||||
|
||||
lazy val root = (project in file(".")).enablePlugins(PlayScala)
|
||||
|
||||
scalaVersion := "2.12.8"
|
||||
scalaVersion := "2.12.20"
|
||||
|
||||
cancelable in Global := true
|
||||
|
||||
@@ -23,8 +23,9 @@ libraryDependencies += "org.webjars" % "backbonejs" % "1.3.3"
|
||||
libraryDependencies += guice
|
||||
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2"
|
||||
|
||||
libraryDependencies += "com.typesafe.play" %% "play" % "2.8.2"
|
||||
libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.1"
|
||||
// Версии play/play-json берутся из sbt-plugin (2.8.21).
|
||||
// Раньше здесь были явные версии 2.8.2/2.9.1, из-за чего подтягивался Guice 4.2.3
|
||||
// с cglib, который не работает на Java 17+ (InaccessibleObjectException).
|
||||
|
||||
lazy val akkaVersion = "2.4.18"
|
||||
libraryDependencies += "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test
|
||||
|
||||
@@ -2,13 +2,90 @@
|
||||
# https://www.playframework.com/documentation/latest/AllowedHostsFilter
|
||||
# Allow requests to localhost:9000.
|
||||
play.filters.hosts {
|
||||
allowed = ["localhost:9000", "localhost", "89.108.83.108", "*", "crosspick.ru"]
|
||||
allowed = ["localhost:9000", "localhost", "89.108.83.108", "*", "crosspick.ru:1337", "decider.dawn-of-war.pro", "127.0.0.1:1337"]
|
||||
}
|
||||
play.http.secret.key="QCY?tAnfk?aZ?iwrNwnxIlR6CTf:123123Latabg@5241AB`R5W:1uDFN];Ik@n"
|
||||
play.server.http.port = 1337
|
||||
|
||||
admin {
|
||||
steamApiKey = ""
|
||||
steamRedirectUrl = "http://localhost:1337/decider/admin/steam/callback"
|
||||
allowedSteamIds = []
|
||||
decidersFile = "conf/deciders.conf"
|
||||
}
|
||||
|
||||
# Каталог с картинками карт: из него читается галерея в админке и в него
|
||||
# сохраняются загруженные картинки. Путь может быть абсолютным или
|
||||
# относительным (от рабочего каталога приложения).
|
||||
maps {
|
||||
imagesDir = "app/assets/images/maps"
|
||||
}
|
||||
|
||||
deciders{
|
||||
classic{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city"],
|
||||
["2p_fata_morgana"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_titan_fall"],
|
||||
["2p_tranquilitys_end"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_blood_river"],
|
||||
["2p_sugaroasis"]]
|
||||
|
||||
name = "Классический десайдер"
|
||||
rules = """"""
|
||||
}
|
||||
mironcup{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city"],
|
||||
["2p_fata_morgana"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_titan_fall"],
|
||||
["2p_tranquilitys_end"],
|
||||
["2p_emerald_river"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_blood_river"]]
|
||||
name = "Десайдер для турнира мироныча"
|
||||
raceCount = 3
|
||||
rules = """"""
|
||||
}
|
||||
montanaz0r{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_blood_river"],
|
||||
["2p_fallen_city"],
|
||||
["2p_tranquilitys_end"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_fata_morgana"],
|
||||
["2p_titan_fall"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_deadly_fun_archeology"]]
|
||||
name = "-"
|
||||
raceCount = 2
|
||||
rules = """There are 11 maps in total. The veto process varies depending on the round: cross-pick continues until 1 map remains for Bo1, 3 maps remain for Bo3, or 5 maps remain for Bo5."""
|
||||
}
|
||||
mistrzostwa{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city"],
|
||||
["2p_fata_morgana"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_titan_fall"],
|
||||
["2p_tranquilitys_end"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_blood_river"]]
|
||||
name = "Mistrzostwa Polski DoW 2026"
|
||||
raceCount = 1
|
||||
rules = """Each player would ban a total of four maps, leaving the final two maps to be played."""
|
||||
}
|
||||
new-players-tournament{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
["2p_fata_morgana_[Rem]"],
|
||||
@@ -17,13 +94,58 @@ deciders{
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_fraziersdemise"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_blood_river_[Rem]"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_bloody_hell_[Ed]"]]
|
||||
name = "Классический десайдер"
|
||||
rules = """"""
|
||||
["2p_blood_river_[Rem]"]]
|
||||
name = "New player tournament"
|
||||
raceCount = 1
|
||||
rules = """New player tournament."""
|
||||
}
|
||||
taktik-garik-winter-cup{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city"],
|
||||
["2p_fata_morgana"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_titan_fall"],
|
||||
["2p_tranquilitys_end"],
|
||||
["2p_bloody_hell_[Ed]"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_blood_river"]]
|
||||
name = "TaktikGarik Winter Cup"
|
||||
raceCount = 2
|
||||
rules = """Crossed out up to 3(5) maps, and all games of the meeting will be played on them. The first map is determined by further crossing out. Repeating maps is prohibited. It is allowed to play on remasters of these maps."""
|
||||
}
|
||||
definitive-cup{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_blood_river"],
|
||||
["2p_fallen_city"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_titan_fall"],
|
||||
["2p_tranquilitys_end"]]
|
||||
raceCount = 3
|
||||
name = "Definitive Cup"
|
||||
rules = """Первая карта выбирается вычеркиванием до 1 карты. Далее лузерпик из всех карт маппула. При выборе рас указываете основную(1), дополнительную(2) и нежелательную(3). The first map is selected by crossing out up to 1 map. Next, a loser is selected from all the maps in the mappool. At race selection - select main (1) additional(2) and unwanted(3) race."""
|
||||
}
|
||||
world-championship-de-cup{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_blood_river"],
|
||||
["2p_fallen_city"],
|
||||
["2p_gavrix_imperial_ale"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_titan_fall"],
|
||||
["2p_tranquilitys_end"]]
|
||||
raceCount = 3
|
||||
name = "Dawn of War World Championship - Definitive Edition"
|
||||
rules = """Group Stage: Best of 3 (BO3) matches — or Best of 1 (BO1), depending on total player count. Playoffs: Double-Elimination Bracket with: • Winners Bracket (BO3) • Losers Bracket (BO1) • Losers Final (BO3) • Grand Final (BO5); Grand Final Advantage: Winner of the Winners Bracket bans the first map, then picks the first map to play."""
|
||||
}
|
||||
freakcup{
|
||||
maps = [["2p_battle_marshes"],
|
||||
@@ -39,6 +161,55 @@ deciders{
|
||||
name = "Десайдер freakcup"
|
||||
rules = """"""
|
||||
}
|
||||
dvijcupseason{
|
||||
description = true
|
||||
existAdditionalRaces = true
|
||||
raceCount = 3
|
||||
maps = [["6p_paynes_retribution", "Количество точек: 28/2/2\nСпорных точек: 10/0/2\nТермогенераторы: 2"],
|
||||
["6p_fury_island", "Количество точек: 26/6/1\nСпорных точек: 6 + 1 крит\nТермогенераторы: Отсутствуют"],
|
||||
["6p_irridene", "Количество точек: 21/6/3\nСпорных точек: 6/0/3\nТермогенераторы: 6"],
|
||||
["6p_shakun_coast", "Количество точек: 24/4/1\nСпорных точек: 6/2/1\nТермогенераторы: 2"],
|
||||
["6p_platform", "Количество точек: 26/2/3\nТермогенераторы: 2"],
|
||||
["6p_temple_cyrene", "Количество точек: 25/4/5\nСпорных точек: 8/0/3\nТермогенераторы: 2"],
|
||||
["6p_testing_grounds", "Количество точек: 32/6/5\nСпорных точек: 10/2/5\nТермогенераторы: 6"],
|
||||
["6p_snowblind", "Количество точек: 22/2/3\nСпорных точек: 4/2/3\nТермогенераторы: отсутствуют"],
|
||||
["6p_trivian_groves_v2", "Количество точек: 32/4/3\nСпорных точек: 6/2/3\nТермогенераторы: 2"],
|
||||
["6p_gear", "Количество точек: 24/2/3\nСпорных точек: 2 крита, но ввиду контактности карты сражение может идти на большинстве внешних точек\nТермогенераторы: 2"]]
|
||||
name = "Dvij cup season"
|
||||
rules = """Классическая Double elimination с сеткой лузеров. В сетке виннеров - БО3, в сетке лузеров - БО1, в суперфинале - БО5. Игрок, прошедший в суперфинал из верхней сетки, имеет +1 очко. Игрок из нижней сетки начинает с лузерпиком. """
|
||||
}
|
||||
stscup3x3{
|
||||
description = true
|
||||
existTextFieldRaces = true
|
||||
raceCount = 3
|
||||
maps = [["6p_paynes_retribution", "Количество точек: 28/2/2\nСпорных точек: 10/0/2\nТермогенераторы: 2"],
|
||||
["6p_fury_island", "Количество точек: 26/6/1\nСпорных точек: 6 + 1 крит\nТермогенераторы: Отсутствуют"],
|
||||
["6p_irridene", "Количество точек: 21/6/3\nСпорных точек: 6/0/3\nТермогенераторы: 6"],
|
||||
["6p_shakun_coast", "Количество точек: 24/4/1\nСпорных точек: 6/2/1\nТермогенераторы: 2"],
|
||||
["6p_platform", "Количество точек: 26/2/3\nТермогенераторы: 2"],
|
||||
["6p_testing_grounds", "Количество точек: 32/6/5\nСпорных точек: 10/2/5\nТермогенераторы: 6"],
|
||||
["6p_gear", "Количество точек: 24/2/3\nСпорных точек: 2 крита, но ввиду контактности карты сражение может идти на большинстве внешних точек\nТермогенераторы: 2"],
|
||||
["6p_constellation", ""],
|
||||
["6p_trivian_groves_v2", ""],
|
||||
["6p_principian_badlands_BM", ""],
|
||||
["6p_team_streets_of_vogen", ""]]
|
||||
name = "Shoulder to Shoulder Cup"
|
||||
rules = """Классическая Double elimination с сеткой лузеров. В сетке виннеров - БО3, в сетке лузеров - БО1, в финале лузеров - БО 3, в суперфинале - БО5. Дошедшие с сетки виннеров получают +1 очко, а вышедшие с сетки лузеров имеют право лузерпика."""
|
||||
}
|
||||
balance-rage-2x2{
|
||||
maps = [["4p_tartarus_center"],
|
||||
["4p_skerries"],
|
||||
["4p_chaos_platenau"],
|
||||
["4p_cold_war"],
|
||||
["4p_broken_infinity"],
|
||||
["4p_imperial_area"],
|
||||
["4p_saints_square"],
|
||||
["4p_gorhael_crater"],
|
||||
["4p_biffys_peril"]]
|
||||
raceCount = 2
|
||||
name = "BALANCE RAGE 2V2 CUP"
|
||||
rules = """Для БО3 вычеркивание до 3х карт, на которых будут проходить все игры. Первая карта выбирается дальнейшим вычеркиванием. Для БО5 вычеркивание до 5 карт. Повтор карт запрещен. """
|
||||
}
|
||||
dredicmappool{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
@@ -57,6 +228,66 @@ deciders{
|
||||
name = "Десайдер DreDick"
|
||||
rules = """"""
|
||||
}
|
||||
dredicmappool2x2{
|
||||
maps = [["4p_tartarus_center"],
|
||||
["4p_skerries"],
|
||||
["4p_chaos_platenau"],
|
||||
["4p_cold_war"],
|
||||
["4p_broken_infinity"],
|
||||
["4p_colosseum_of_deadman"],
|
||||
["4p_imperial_area"],
|
||||
["4p_torrents"],
|
||||
["4p_saints_square"],
|
||||
["4p_marconia"],
|
||||
["4p_biffys_peril"]]
|
||||
raceCount = 2
|
||||
name = "Десайдер DreDick 2x2"
|
||||
rules = """"""
|
||||
}
|
||||
dredicmappool3x3{
|
||||
maps = [["6p_paynes_retribution"],
|
||||
["6p_fury_island"],
|
||||
["6p_testing_grounds"],
|
||||
["6p_alvarus"],
|
||||
["6p_shakun_coast"],
|
||||
["6p_team_streets_of_vogen"],
|
||||
["6p_irridene"],
|
||||
["6p_gear"],
|
||||
["6p_platform"]]
|
||||
raceCount = 3
|
||||
name = "Десайдер 3x3"
|
||||
rules = """"""
|
||||
}
|
||||
dredicmappool4x4{
|
||||
maps = [["8p_burial_grounds", "Количество стратегических/реликтовых/критических точек: 32/2/4\nСпорных точек: 4/2/2\nТермогенераторы: 3"],
|
||||
["8p_daturias_pits", "Количество стратегических/реликтовых/критических точек: 32/4/5\nСпорных точек: 2/2/1\nТермогенераторы: 0"],
|
||||
["8p_forbidden_jungle", "Количество стратегических/реликтовых/критических точек: 36/5/0\nСпорных точек: 12/5/0\nТермогенераторы: 4"],
|
||||
["8p_demes_northlands", "Количество стратегических/реликтовых/критических точек: 32/4/3\nСпорных точек: 3/2/2\nТермогенераторы: 2"],
|
||||
["8p_glacier", "Количество стратегических/реликтовых/критических точек: 24/4/5\nСпорных точек: 0/0/4\nТермогенераторы: 4"],
|
||||
["8p_jalaganda_lowlands", "Количество стратегических/реликтовых/критических точек: 26/5/3\nСпорных точек: 2/1/3\nТермогенераторы: 0"],
|
||||
["8p_monse", "Количество стратегических/реликтовых/критических точек: 26/4/4\nСпорных точек: 2/2/4\nТермогенераторы: 1"],
|
||||
["8p_oasis_of_sharr.jpg"],
|
||||
["8p_verdant_isles_ed", "Количество стратегических/реликтовых/критических точек: 32/4/3\nСпорных точек: 0/4/3\nТермогенераторы: 0"]]
|
||||
raceCount = 4
|
||||
name = "Десайдер DreDick 4x4"
|
||||
rules = """"""
|
||||
}
|
||||
wellplayed{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city"],
|
||||
["2p_fata_morgana"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_titan_fall"],
|
||||
["2p_tranquilitys_end"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_emerald_river"],
|
||||
["2p_blood_river"]]
|
||||
raceCount = 1
|
||||
name = "Десайдер Wellplayed"
|
||||
rules = """1st map after veto (A-B-A-B etc) A is Top in Grid. After looserpick"""
|
||||
}
|
||||
flazzomappool{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
@@ -77,6 +308,34 @@ deciders{
|
||||
name = "Десайдер flazzo"
|
||||
rules = """"""
|
||||
}
|
||||
westerners-invitational{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
["2p_blood_river_[Rem]"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_gavrix_imperial_ale"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_outer_reaches"]]
|
||||
raceCount = 2
|
||||
name = "Westerners Invitational"
|
||||
rules = """In case of a BO3, 3 maps must be left after the banning. Further the players must strike out a map each to decide the first map they play versus each other, the losers pick the next map (losers pick). In case of a BO5, 5 maps are needed."""
|
||||
}
|
||||
madcup{
|
||||
maps = [["2p_outer_reaches"],
|
||||
["2p_blood_river"],
|
||||
["2p_fata_morgana"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_fallen_city"],
|
||||
["2p_tranquilitys_end"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_titan_fall"]]
|
||||
raceCount = 3
|
||||
name = "Decider MAD"
|
||||
rules = """"""
|
||||
}
|
||||
salats{
|
||||
maps = [["винегрет"],
|
||||
["грибная поляна"],
|
||||
@@ -170,6 +429,21 @@ deciders{
|
||||
Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров.
|
||||
Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров."""
|
||||
}
|
||||
world_championship_2024{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
["2p_fata_morgana_[Rem]"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_bloody_hell"],2
|
||||
["2p_blood_river_[Rem]"],
|
||||
["2p_outer_reaches"]]
|
||||
name = "World championsip 2024"
|
||||
raceCount = 2
|
||||
rules = """1) One player bans a map, and then the other player bans a map until there is only 3 maps left (5 if it’s a BO5, 1 if it’s a BO1) 2) Then players write down the maps that are not banned (in lobby) 3) Then the players write down a ban each (in lobby) to decide the first map (decider map) 4) The player that lost the first map, now gets to choose a map from the last 2 unbanned 5) If it goes to a third game, the last game will be on the last map that’s not played yet"""}
|
||||
meatgrinder{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city"],
|
||||
@@ -228,6 +502,26 @@ deciders{
|
||||
raceCount = 2
|
||||
rules = """Выбор карт в БО3 и БО5 осуществляется без лузерпиков, с тремя и пятью десайдерами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров. Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров."""
|
||||
}
|
||||
burgercup-de{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
["2p_fata_morgana_[Rem]"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_fraziersdemise"],
|
||||
["2p_bloody_hell_[Ed]"],
|
||||
["2p_blood_river_[Rem]"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
["2p_ancient_jungle_BM"]]
|
||||
name = "Burger cup"
|
||||
raceCount = 3
|
||||
rules = """Выбор карт в БО3 и БО5 осуществляется без лузерпиков, с тремя и пятью десайдерами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров. Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров."""
|
||||
}
|
||||
trainercup{
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
@@ -242,7 +536,7 @@ deciders{
|
||||
["2p_deadly_fun_archeology"]]
|
||||
name = "Trainer cup"
|
||||
raceCount = 2
|
||||
rules = """Выбор карт в БО3 и БО5 осуществляется без лузерпиков, с тремя и пятью десайдерами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров. Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров."""
|
||||
rules = """Выбор карт в рамках бо3 и бо5, с тремя и пятью картами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Во втором и последующем матчах проигравший выбирает карту из числа выбранных карт. В сетке лузеров бо1, финал лузеров бо3, в супер-финале игрок из нижней сетки имеет право автоматического лузерпика, а игрок из верхней - плюс очко.."""
|
||||
}
|
||||
customcup{
|
||||
maps = [["2p_battle_marshes"],
|
||||
@@ -332,6 +626,22 @@ deciders{
|
||||
existAdditionalRaces = true
|
||||
rules = """1 раса, но можно взять вторую при мирроре"""
|
||||
}
|
||||
summer-cup{
|
||||
maps = [["2p_outer_reaches"],
|
||||
["2p_battle_marshes"],
|
||||
["2p_fallen_city"],
|
||||
["2p_fata_morgana_[Rem]"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_titan_fall"],
|
||||
["2p_tranquilitys_end"],
|
||||
["2p_bloody_hell_[Ed]"],
|
||||
["2p_blood_river"],
|
||||
["2p_meeting_of_minds"]]
|
||||
name = "Summer cup"
|
||||
raceCount = 2
|
||||
rules = """Вычеркивание до 3(5) карт, на них пройдут все игры встречи. Первая карта определяется дальнейшим вычеркиванием. Повтор карт запрещен. Можно играть на ремастерах указанных карт """
|
||||
}
|
||||
mallusc-cup{
|
||||
maps = [["2p_outer_reaches"],
|
||||
["2p_battle_marshes"],
|
||||
@@ -420,67 +730,15 @@ deciders{
|
||||
Финал сетки лузеров (БО3).
|
||||
Игрок, прошедший в суперфинал из верхней сетки, имеет +1 очко. Игрок из нижней сетки начинает с лузерпиком. """
|
||||
}
|
||||
shouldercup{
|
||||
testcup{
|
||||
description = true
|
||||
existAdditionalRaces = true
|
||||
raceCount = 3
|
||||
maps = [["6p_paynes_retribution", "Количество точек: 28/2/2\nСпорных точек: 10/0/2\nТермогенераторы: 2"],
|
||||
["6p_fury_island", "Количество точек: 26/6/1\nСпорных точек: 6 + 1 крит\nТермогенераторы: Отсутствуют"],
|
||||
["6p_irridene", "Количество точек: 21/6/3\nСпорных точек: 6/0/3\nТермогенераторы: 6"],
|
||||
["6p_shakun_coast", "Количество точек: 24/4/1\nСпорных точек: 6/2/1\nТермогенераторы: 2"],
|
||||
"2p_tranquilitys_end_[Rem]",
|
||||
"2p_outer_reaches",
|
||||
"2p_blood_river_[Rem]",
|
||||
"2p_sugaroasis",
|
||||
"2p_meeting_of_minds"]
|
||||
name = "Horus heresy cup 2"
|
||||
rules = """ Первая карта десайдер выбирается методом вычеркивания, остальные лузер пик. По обоюдному согласию игроков разрешен повтор карт. Сетка турнира Double Elimination.
|
||||
Сетка виннеров:
|
||||
Каждая игра до 2х победы (БО3),
|
||||
В суперфинале игра до 3-х побед (БО5).
|
||||
Сетка лузеров:
|
||||
Каждая игра до 1й победы (БО1),
|
||||
Финал сетки лузеров (БО3).
|
||||
Игрок, прошедший в суперфинал из верхней сетки, имеет +1 очко. Игрок из нижней сетки начинает с лузерпиком. """
|
||||
}
|
||||
shouldercup{
|
||||
description = true
|
||||
existAdditionalRaces = true
|
||||
raceCount = 3
|
||||
maps = [["6p_paynes_retribution", "Количество точек: 28/2/2\nСпорных точек: 10/0/2\nТермогенераторы: 2"],
|
||||
["6p_fury_island", "Количество точек: 26/6/1\nСпорных точек: 6 + 1 крит\nТермогенераторы: Отсутствуют"],
|
||||
["6p_irridene", "Количество точек: 21/6/3\nСпорных точек: 6/0/3\nТермогенераторы: 6"],
|
||||
["6p_shakun_coast", "Количество точек: 24/4/1\nСпорных точек: 6/2/1\nТермогенераторы: 2"],
|
||||
"2p_tranquilitys_end_[Rem]",
|
||||
"2p_outer_reaches",
|
||||
"2p_blood_river_[Rem]",
|
||||
"2p_sugaroasis",
|
||||
"2p_meeting_of_minds"]
|
||||
name = "Horus heresy cup 2"
|
||||
rules = """ Первая карта десайдер выбирается методом вычеркивания, остальные лузер пик. По обоюдному согласию игроков разрешен повтор карт. Сетка турнира Double Elimination.
|
||||
Сетка виннеров:
|
||||
Каждая игра до 2х победы (БО3),
|
||||
В суперфинале игра до 3-х побед (БО5).
|
||||
Сетка лузеров:
|
||||
Каждая игра до 1й победы (БО1),
|
||||
Финал сетки лузеров (БО3).
|
||||
Игрок, прошедший в суперфинал из верхней сетки, имеет +1 очко. Игрок из нижней сетки начинает с лузерпиком. """
|
||||
}
|
||||
shouldercup{
|
||||
description = true
|
||||
existAdditionalRaces = true
|
||||
raceCount = 3
|
||||
maps = [["6p_paynes_retribution", "Количество точек: 28/2/2\nСпорных точек: 10/0/2\nТермогенераторы: 2"],
|
||||
["6p_fury_island", "Количество точек: 26/6/1\nСпорных точек: 6 + 1 крит\nТермогенераторы: Отсутствуют"],
|
||||
["6p_irridene", "Количество точек: 21/6/3\nСпорных точек: 6/0/3\nТермогенераторы: 6"],
|
||||
["6p_shakun_coast", "Количество точек: 24/4/1\nСпорных точек: 6/2/1\nТермогенераторы: 2"],
|
||||
["6p_platform", "Количество точек: 26/2/3\nТермогенераторы: 2"],
|
||||
["6p_temple_cyrene", "Количество точек: 25/4/5\nСпорных точек: 8/0/3\nТермогенераторы: 2"],
|
||||
["6p_testing_grounds", "Количество точек: 32/6/5\nСпорных точек: 10/2/5\nТермогенераторы: 6"],
|
||||
["6p_ruined_greatway", "Количество точек: 22/2/1\nСпорных точек: 2/2/1\nТермогенераторы: 2"],
|
||||
["6p_gear", "Количество точек: 24/2/3\nСпорных точек: 2 крита, но ввиду контактности карты сражение может идти на большинстве внешних точек\nТермогенераторы: 2"]]
|
||||
name = "Shoulder to shoulder cup"
|
||||
rules = """Классическая Double elimination с сеткой лузеров. В сетке виннеров - БО3, в сетке лузеров - БО1, в суперфинале - БО5. Игрок, прошедший в суперфинал из верхней сетки, имеет +1 очко. Игрок из нижней сетки начинает с лузерпиком. """
|
||||
maps = [["6p_irridene_v2", "Тут можно написать всякую фигню про точки и т.д."],
|
||||
["6p_principian_badlands_BM", "Вася"],
|
||||
["6p_trivian_groves_v2", "Петя"]]
|
||||
name = "Тестовый турнир"
|
||||
rules = """ Тест """
|
||||
}
|
||||
shouldercup4x4{
|
||||
description = true
|
||||
@@ -497,21 +755,53 @@ deciders{
|
||||
name = "Shoulder to shoulder cup"
|
||||
rules = """Черкается по правилу лузерпиков: сначала оставляется одна карта, далее команда выбирает, на какой карте будет играть. """
|
||||
}
|
||||
springcup{
|
||||
theemperorprotects{
|
||||
description = true
|
||||
raceCount = 2
|
||||
maps = [["4p_biffys_peril"],
|
||||
["4p_cold_war"],
|
||||
["4p_colosseum_of_deadman"],
|
||||
["4p_imperial_area"],
|
||||
["4p_chaos_platenau"],
|
||||
["4p_saints_square"],
|
||||
["4p_tartarus_center"],
|
||||
["4p_skerries"],
|
||||
["4p_torrents"]]
|
||||
name = "The emperor protects 2x2"
|
||||
rules = """Первая карта выбирается вычеркиванием в десайдере. Далее - лузерпик из всех карт маппула. Повтор карт запрещен."""
|
||||
}
|
||||
mironcup1x1{
|
||||
description = true
|
||||
maps = [["4p_abandoned_jungle"],
|
||||
["4p_antiga_bay"],
|
||||
["4p_chaos_platenau"],
|
||||
["4p_cold_war"],
|
||||
["4p_broken_infinity"],
|
||||
["4p_colosseum_of_deadman"],
|
||||
["4p_imperial_area"],
|
||||
["4p_ruined_greatway"],
|
||||
["4p_sad_spires"],
|
||||
["4p_snowblind"],
|
||||
["4p_summit"],
|
||||
["4p_mariza"],
|
||||
["4p_testcake"]]
|
||||
name = "Турнир мироныча"
|
||||
rules = """Десайдеры выбирается методом вычеркивания до 3 карт, далее из оставшихся карт методом вычеркивания определяется первая карта. Игрок, начавший вычеркивание из основного пула уступает право начать вычеркивание из оставшихся карт."""
|
||||
}
|
||||
backtoskullcup{
|
||||
description = true
|
||||
raceCount = 2
|
||||
maps = [["4p_biffys_peril"],
|
||||
["4p_chaos_platenau"],
|
||||
["4p_colosseum_of_deadman"],
|
||||
["4p_gorhael_crater"],
|
||||
["4p_imperial_area"],
|
||||
["4p_saints_square"],
|
||||
["4p_tartarus_center"],
|
||||
["4p_testcake"],
|
||||
["4p_skerries"],
|
||||
["4p_tiboraxx"],
|
||||
["4p_gurmuns_pass"],
|
||||
["4p_torrents"]]
|
||||
name = "Spring cup 2x2"
|
||||
rules = """Десайдеры выбирается методом вычеркивания до 3 карт, далее из оставшихся карт методом вычеркивания определяется первая карта. Игрок, начавший вычеркивание из основного пула уступает право начать вычеркивание из оставшихся карт. Исключение суперфинал: Команда прошедшая в суперфинал с верхней сетки имеет преимущество в выборе очередности карт из оставшихся 3 после вычеркивания."""
|
||||
name = "Back to Skull Cup cup"
|
||||
rules = """"""
|
||||
}
|
||||
shouldercup2x2{
|
||||
description = true
|
||||
@@ -601,6 +891,25 @@ deciders{
|
||||
name = "No Weakness cup"
|
||||
rules = """Выбор карт в БО3 и БО5 осуществляется без лузерпиков, с тремя и пятью десайдерами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров. Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров. """
|
||||
}
|
||||
primus_inter_pares{
|
||||
raceCount = 1
|
||||
description = true
|
||||
maps =[
|
||||
["2p_battle_marshes"],
|
||||
["2p_blood_river_[Rem]"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_bloody_hell_[Ed]"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_sugaroasis"]
|
||||
]
|
||||
name = "Primus inter pares"
|
||||
rules = """ """
|
||||
}
|
||||
freneticmappool{
|
||||
raceCount = 1
|
||||
description = true
|
||||
@@ -637,6 +946,141 @@ deciders{
|
||||
name = "Frenetic map pool"
|
||||
rules = """ """
|
||||
}
|
||||
freneticmappool3{
|
||||
raceCount = 2
|
||||
description = true
|
||||
maps =[["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
["2p_fata_morgana_[Rem]"],
|
||||
["2p_emerald_river"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_sugaroasis"],
|
||||
["2p_blood_river_[Rem]"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_meeting_of_minds"]]
|
||||
name = "Frenetic map pool"
|
||||
rules = """ """
|
||||
}
|
||||
dredicmappool3{
|
||||
raceCount = 2
|
||||
description = true
|
||||
maps =[["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
["2p_fata_morgana_[Rem]"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_blood_river_[Rem]"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_meeting_of_minds"]]
|
||||
name = "Dredic map pool 3"
|
||||
rules = """ """
|
||||
}
|
||||
badcup-5{
|
||||
raceCount = 2
|
||||
description = true
|
||||
maps =[["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_blood_river_[Rem]"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_meeting_of_minds"]]
|
||||
name = "Bad cup 5"
|
||||
rules = """ """
|
||||
}
|
||||
freneticmappool15{
|
||||
raceCount = 2
|
||||
description = true
|
||||
maps =[["2p_titan_fall_[Rem]"],
|
||||
["2p_fallen_city_[Rem]", "Обратите внимание, ремастер"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_cryptek_arkana_BM"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_consecrated_gardens_6p_BM"],
|
||||
["2p_ancient_jungle_BM"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_sugaroasis"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_emerald_river"],
|
||||
["2p_battle_marshes"],
|
||||
["2p_gavrix_imperial_ale"]],
|
||||
name = "Frenetic map pool (15 maps)"
|
||||
rules = """ """
|
||||
}
|
||||
freneticmappool17{
|
||||
raceCount = 2
|
||||
description = true
|
||||
maps =[["2p_titan_fall_[Rem]"],
|
||||
["2p_fallen_city", "Обратите внимание, не ремастер"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_cryptek_arkana_BM"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
["2p_ancient_jungle_BM"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_sugaroasis"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_emerald_river"],
|
||||
["2p_fraziersdemise"],
|
||||
["2p_bloody_hell_[Ed]"]],
|
||||
name = "Frenetic map pool (17 maps)"
|
||||
rules = """ """
|
||||
}
|
||||
zikipchik{
|
||||
raceCount = 2
|
||||
description = true
|
||||
maps =[["2p_titan_fall"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
["2p_fallen_city"],
|
||||
["2p_fata_morgana"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion"],
|
||||
["2p_tranquilitys_end"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_sugaroasis"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_battle_marshes"],
|
||||
["2p_blood_river"]],
|
||||
name = "Zikipchik decider"
|
||||
rules = """ """
|
||||
}
|
||||
beercup{
|
||||
raceCount = 3
|
||||
description = true
|
||||
maps =[["2p_titan_fall_[Rem]"],
|
||||
["2p_fallen_city_[Rem]", "Обратите внимание, ремастер"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_fraziersdemise"],
|
||||
["2p_ancient_jungle_BM"],
|
||||
["2p_battle_marshes"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
["2p_blood_river_[Rem]"],
|
||||
["2p_fata_morgana_[Rem]"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_outer_reaches"],
|
||||
["2p_bloody_hell_[Ed]"]]
|
||||
name = "Beer Cup"
|
||||
rules = """ Выбор карт без лузерпиков, с тремя и пятью десайдерами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров. Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров. """
|
||||
}
|
||||
freneticmappool2x2{
|
||||
raceCount = 2
|
||||
description = true
|
||||
@@ -656,6 +1100,20 @@ deciders{
|
||||
name = "Frenetic map pool"
|
||||
rules = """ """
|
||||
}
|
||||
freneticmappool3x3{
|
||||
maps = [["6p_paynes_retribution"],
|
||||
["6p_fury_island"],
|
||||
["6p_principian_badlands_BM", "Карта с баланс мода"],
|
||||
["6p_trivian_groves_BM", "Карта с баланс мода"],
|
||||
["6p_confrontation"],
|
||||
["6p_constellation"],
|
||||
["6p_snowblind"],
|
||||
["6p_agamar_desert"],
|
||||
["6p_platform"]]
|
||||
raceCount = 3
|
||||
name = "Frenetic mappool 3x3"
|
||||
rules = """Frenetic mappool 3x3"""
|
||||
}
|
||||
deadgamecup{
|
||||
raceCount = 2
|
||||
description = true
|
||||
@@ -704,6 +1162,8 @@ deciders{
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_emerald_river"],
|
||||
["2p_bloody_hell_[Ed]"],
|
||||
["2p_cryptek_arkana_BM"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
]
|
||||
name = "BAD cup"
|
||||
rules = """Double elimination. БО3 (БО5 суперфинал). БО1 лузерсетка (БО3 финал лузер сетки). 2 Расы на турнир, без банов. Вторая раса берётся в случае лузерпика(и играется до окончания бо) или по договоренности обеих сторон в случае миррора."""
|
||||
@@ -725,6 +1185,21 @@ deciders{
|
||||
name = "UA cup"
|
||||
rules = """"""
|
||||
}
|
||||
soulstormlegue{
|
||||
raceCount = 2
|
||||
maps = [["2p_battle_marshes"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_sugaroasis"],
|
||||
["2p_gavrix_imperial_ale"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_fallen_city"],
|
||||
["2p_meeting_of_minds"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
]
|
||||
name = "Soulstorm League"
|
||||
rules = """Для БО3 вычеркивание до 3х карт, на которых будут проходить все игры. Первая карта выбирается дальнейшим вычеркиванием. Для БО5 вычеркивание до 5 карт. Повтор карт запрещен. """
|
||||
}
|
||||
true_arena_bad2{
|
||||
raceCount = 2
|
||||
maps = [["2p_outer_reaches"],
|
||||
@@ -745,6 +1220,45 @@ deciders{
|
||||
name = "BAD cup"
|
||||
rules = """"""
|
||||
}
|
||||
bad_vs_icc_2x2{
|
||||
raceCount = 3
|
||||
maps = [ ["4p_biffys_peril"],
|
||||
["4p_colosseum_of_deadman"],
|
||||
["4p_cold_war"],
|
||||
["4p_imperial_area"],
|
||||
["4p_tainted_soul"]
|
||||
["4p_tartarus_center"],
|
||||
["4p_skerries"]
|
||||
]
|
||||
name = "BAD vs ICC 2x2"
|
||||
rules = """"""
|
||||
}
|
||||
bad_vs_icc_3x3{
|
||||
raceCount = 4
|
||||
maps = [
|
||||
["6p_fury_island"],
|
||||
["6p_irridene"],
|
||||
["6p_trivian_groves"],
|
||||
["6p_platform"],
|
||||
["6p_principian_badlands"],
|
||||
["6p_testing_grounds"],
|
||||
["6p_gear"]
|
||||
]
|
||||
name = "BAD vs ICC 3x3"
|
||||
rules = """"""
|
||||
}
|
||||
bad_vs_icc_4x4{
|
||||
raceCount = 5
|
||||
maps = [
|
||||
["8p_morriah_coast"],
|
||||
["8p_burial_grounds"],
|
||||
["8p_kier_harrad"],
|
||||
["8p_hydraphur"],
|
||||
["8p_forbidden_jungle"],
|
||||
]
|
||||
name = "BAD vs ICC 4x4"
|
||||
rules = """"""
|
||||
}
|
||||
ppcz {
|
||||
raceCount = 2
|
||||
maps = [["2p_fata_morgana_[Rem]"],
|
||||
@@ -762,11 +1276,108 @@ deciders{
|
||||
["2p_meeting_of_minds"]]
|
||||
name = "PPCZ"
|
||||
rules = """
|
||||
Игрок выбирает основную и дополнительную расу (по желанию). Первая игра всегда играется основной расой. Начиная со второй игры проигравший может взять дополнительную расу, при этом победивший не может поменять расу.
|
||||
Игрок выбирает основную и дополнительную расу (по желанию). Первая игра всегда играется основной расой. Начиная со второй игры проигравший может взять дополнительную расу, при этом победивший не может поменять расу.
|
||||
|
||||
Эльдар не имеет права лузерпикать против SM, IG, CSM (вместо этого карту выбирает оппонент)
|
||||
Эльдар не имеет права лузерпикать против SM, IG, CSM (вместо этого карту выбирает оппонент)
|
||||
|
||||
Некрон не имеет права лузерпикать против Tau (тоже, что и в примере выше)
|
||||
Против некрона нельзя выбирать SOE лузерпиком (Tau все еще может выбирать любую карту против некрона"""
|
||||
Некрон не имеет права лузерпикать против Tau (тоже, что и в примере выше)
|
||||
Против некрона нельзя выбирать SOE лузерпиком (Tau все еще может выбирать любую карту против некрона"""
|
||||
}
|
||||
4soulchamp {
|
||||
raceCount = 1
|
||||
maps = [["2p_ancient_jungle_BM"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
["2p_cryptek_arkana_BM"],
|
||||
["2p_battle_marshes"],
|
||||
["2p_fallen_city_[Rem]"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_sugaroasis"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"]]
|
||||
name = "4soulchamp tournament"
|
||||
rules = """Черк до трех последних. Финал - до 5 последних."""
|
||||
}
|
||||
balancemodcup{
|
||||
raceCount = 2
|
||||
description = true
|
||||
maps =[["2p_cryptek_arkana_BM"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
["2p_ancient_jungle_BM"],
|
||||
["2p_battle_marshes"],
|
||||
["2p_fallen_city"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_sugaroasis"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
],
|
||||
name = "Balance Mod Cup"
|
||||
rules = """ Первая карта выбирается вычеркиванием. Далее - лузерпик. Повтор карт запрещен. """
|
||||
}
|
||||
Dow_Stats_Tournament{
|
||||
raceCount = 0
|
||||
maps =[["2p_cryptek_arkana_BM"],
|
||||
["2p_consecrated_gardens_5p_BM"],
|
||||
["2p_ancient_jungle_BM"],
|
||||
["2p_battle_marshes"],
|
||||
["2p_fallen_city"],
|
||||
["2p_deadly_fun_archeology"],
|
||||
["2p_quests_triumph"],
|
||||
["2p_shrine_of_excellion_[Rem]"],
|
||||
["2p_sugaroasis"],
|
||||
["2p_titan_fall_[Rem]"],
|
||||
["2p_tranquilitys_end_[Rem]"],
|
||||
],
|
||||
name = "Dow Stats Tournament"
|
||||
rules = """ """
|
||||
}
|
||||
BalanceModCup2x2{
|
||||
raceCount = 2
|
||||
maps =[
|
||||
["4p_biffys_peril"],
|
||||
["4p_cold_war"],
|
||||
["4p_broken_infinity"],
|
||||
["4p_torrents"],
|
||||
["4p_imperial_area"],
|
||||
["4p_chaos_platenau"],
|
||||
["4p_saints_square"],
|
||||
["4p_tartarus_center"],
|
||||
["4p_skerries"],
|
||||
],
|
||||
name = "Dowstats Balance Mod Cup 2x2"
|
||||
rules = """Первая карта выбирается вычеркиванием. Далее - лузерпик из всех карт маппула. Повтор карт запрещен. Некроны доступны. В рамках каждого БО1/3/5 игроки в десайдере выбирают расы. Одинаковые расы запрещены.
|
||||
"""
|
||||
}
|
||||
BalanceModCup2v2{
|
||||
raceCount = 2
|
||||
maps =[
|
||||
["4p_biffys_peril"],
|
||||
["4p_cold_war"],
|
||||
["4p_imperial_area"],
|
||||
["4p_tainted_soul"],
|
||||
["4p_saints_square"],
|
||||
["4p_tartarus_center"],
|
||||
["4p_skerries"],
|
||||
],
|
||||
name = "Dowstats Balance Mod Cup 2x2"
|
||||
rules = """ """
|
||||
}
|
||||
Dow_Stats_Tournament_Race{
|
||||
raceCount = 1
|
||||
existAdditionalRaces = true
|
||||
maps = []
|
||||
name = "Бан расы"
|
||||
rules = """Если в текущем раунде вы выбираете СОБ или Космодесант, вы можете забанить две расы. Банить СоБ или СМ запрещено. If your team choose Space Marines or Sisters Of Battle, you can ban two races. You can't ban SOB or SM."""
|
||||
}
|
||||
the_emperor_protects_race_ban{
|
||||
raceCount = 1
|
||||
existAdditionalRaces = true
|
||||
maps = []
|
||||
name = "The Emperor protects 2x2. Races ban."
|
||||
rules = """Бан рас для турнира The Emperor protects! Если в текущем раунде вы играете за СМ или СоБ, то можете забанить 2 расы. Если в вашей команде не будет СМ или СоБ, забанить можно только 1 расу. Из-за ограничений десайдера вы баните 2 раза одну расу! Запрещено банить СМ и СоБ! |||||||||||
|
||||
Races ban decider. You can ban 2 races, if you are playing for SM or SoB in the current round. If your team does not have SM or SoB, you can only ban 1 race. Due to the restrictions of the Decider, you ban 2 times one race! Banning SM and SoB is prohibited!"""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,28 @@
|
||||
# This file defines all application routes (Higher priority routes first)
|
||||
# ~~~~
|
||||
|
||||
GET /decider/:name controllers.HomeController.index(name)
|
||||
|
||||
GET /ws controllers.HomeController.ws
|
||||
|
||||
GET /admin/users controllers.AdminController.viewAllUsers()
|
||||
# Admin panel
|
||||
# ВАЖНО: админские маршруты должны идти ДО /decider/:name,
|
||||
# иначе /decider/admin перехватывается как десайдер с именем "admin".
|
||||
GET /decider/admin controllers.AdminController.index()
|
||||
GET /decider/admin/login controllers.AdminController.login()
|
||||
GET /decider/admin/steam/callback controllers.AdminController.steamCallback()
|
||||
|
||||
# Admin API
|
||||
GET /decider/admin/maps controllers.AdminController.mapImages()
|
||||
POST /decider/admin/maps controllers.AdminController.mapImageUpload()
|
||||
GET /decider/admin/deciders controllers.AdminController.decidersList()
|
||||
GET /decider/admin/deciders/:key controllers.AdminController.deciderGet(key)
|
||||
POST /decider/admin/deciders controllers.AdminController.deciderSave()
|
||||
DELETE /decider/admin/deciders/:key controllers.AdminController.deciderDelete(key)
|
||||
|
||||
GET /decider/:name controllers.HomeController.index(name)
|
||||
|
||||
# Картинки карт из каталога maps.imagesDir.
|
||||
# ВАЖНО: маршрут должен идти ДО /assets/*file, иначе его перехватит Assets.
|
||||
GET /assets/images/maps/:file controllers.MapImageController.image(file)
|
||||
|
||||
# Map static resources from the /public folder to the /assets URL path
|
||||
GET /assets/*file controllers.Assets.at(path="/public", file)
|
||||
|
||||
@@ -1 +1 @@
|
||||
sbt.version=1.2.8
|
||||
sbt.version=1.9.8
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// Use the Play sbt plugin for Play projects
|
||||
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.0")
|
||||
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.21")
|
||||
|
||||
addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.2")
|
||||
|
||||
// Ignore scala-xml binary incompatibility between 1.x (sbt-native-packager, twirl)
|
||||
// and 2.x (scala-compiler) – they are compatible enough for the build to work
|
||||
libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % "always"
|
||||
|
||||