Compare commits

...
9 Commits
Author SHA1 Message Date
anibus f0ec7bbedb Decider admin access to deciders by steam id 2026-10-30 20:26:43 +03:00
anibus 0212f8b6d0 Decider admin 2026-10-24 22:02:29 +03:00
anibus 3cad3aec4b changes 2026-08-15 13:32:08 +03:00
Anibus d1dd478c43 Add broken toyota icon, support dow pro domain 2025-03-23 22:50:22 +03:00
Anibus 1a8916be22 Add new deciders, move to dawn-of-war.pro 2025-03-16 11:26:09 +03:00
v.holodov d2af1bc797 Add new maps, add bzik tournament 2024-06-08 10:13:16 +03:00
v.holodov 495317016d Конфиги фьюнералиса 2024-04-13 20:19:24 +03:00
v.holodov 7399322f74 Add 3 new maps 2024-04-13 20:06:49 +03:00
v.holodov ed01c92570 Fix readme 2024-03-17 15:47:19 +03:00
35 changed files with 3800 additions and 176 deletions
View File
+9 -12
View File
@@ -4,14 +4,11 @@ import actors.UserActor._
import akka.actor.{Actor, ActorRef} import akka.actor.{Actor, ActorRef}
import akka.event.LoggingReceive import akka.event.LoggingReceive
import akka.util.Timeout import akka.util.Timeout
import com.typesafe.config.ConfigFactory
import com.typesafe.scalalogging.LazyLogging import com.typesafe.scalalogging.LazyLogging
import repository.DeciderRepository
import java.util
import scala.collection.JavaConverters.iterableAsScalaIterableConverter
import scala.collection.immutable.HashSet import scala.collection.immutable.HashSet
import scala.concurrent.duration._ import scala.concurrent.duration._
import scala.util.Try
case class LobbyUser(name: String, actorRef: ActorRef) 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 { class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with LazyLogging {
val config = ConfigFactory.load()
logger.info(s"Create lobby... host: ${hostUser.actorRef.path.name}") 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] = { private val mapsLobby: Set[DeciderMap] = {
val configMaps = config.getList(s"deciders.$deciderName.maps").asScala DeciderRepository.get(deciderName) match {
configMaps.map(e => { case Some(d) =>
val mapConfig = e.unwrapped().asInstanceOf[util.ArrayList[String]].asScala d.maps.map(m => DeciderMap(m.map, m.description)).toSet
DeciderMap(mapConfig.head, mapConfig.tail.headOption) case None =>
}).toSet Set.empty[DeciderMap]
}
} }
hostUser.actorRef.tell( hostUser.actorRef.tell(
@@ -261,9 +258,9 @@ case class ChangeIsNecronSelected(isSelected: Boolean)
case object InfoQuery 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, case class LobbyInfo(user1Info: UserInfo,
user2Info: UserInfo, user2Info: UserInfo,
+2 -1
View File
@@ -176,7 +176,8 @@ class UserActor(out: ActorRef,
logger.info("RACES: " + json.toString()) logger.info("RACES: " + json.toString())
val races = (json \ "mainRaces").as[List[Int]] val races = (json \ "mainRaces").as[List[Int]]
val additionalRaces = (json \ "additionalRaces").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") => case Some("getLobbies") =>
logger.debug("Get all lobby request") logger.debug("Get all lobby request")
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+28 -26
View File
@@ -4,6 +4,7 @@ var userName = ""
var raceCount = $("#raceCount").html() var raceCount = $("#raceCount").html()
var existAdditionalRaces = $("#existAdditionalRaces").html() === "true" var existAdditionalRaces = $("#existAdditionalRaces").html() === "true"
var existTextFieldRaces = $("#existTextFieldRaces").html() === "true"
var isMuted = false; var isMuted = false;
var isDraftStarted = false; var isDraftStarted = false;
@@ -227,6 +228,8 @@ function disconnectLobby(error) {
function getRaceImageByNumber(number) { function getRaceImageByNumber(number) {
switch (number) { switch (number) {
case 10:
return "/assets/images/raceIcons/cancel.png";
case 1: case 1:
return "/assets/images/raceIcons/SM.gif"; return "/assets/images/raceIcons/SM.gif";
case 2: 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() { function renderRaces() {
@@ -277,7 +258,7 @@ function renderRaces() {
var resHtml = ""; var resHtml = "";
for(var raceNum = 1; raceNum <= raceCount; raceNum++){ for(var raceNum = 1; raceNum <= raceCount; raceNum++){
resHtml += "<div class='col-xs-4'><center><h3>" + raceType + " race " + raceNum + "</h3>"; 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"; var selected = "raceNotSelected";
if ($.cookie("race-" + raceType + "-" + raceNum) == i) { if ($.cookie("race-" + raceType + "-" + raceNum) == i) {
selected = "raceSelected"; selected = "raceSelected";
@@ -293,9 +274,21 @@ function renderRaces() {
return resHtml; 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 = ""; var resHtml = "";
if(!existTextFieldRaces){
resHtml += getRaceSelectHtml("main", selectedRaces); resHtml += getRaceSelectHtml("main", selectedRaces);
if(existAdditionalRaces) resHtml += getRaceSelectHtml("additional", selectedAdditionalRaces); if(existAdditionalRaces) resHtml += getRaceSelectHtml("additional", selectedAdditionalRaces);
}else {
resHtml += renderInputField();
}
resHtml += "<div style='clear: both;'></div></br>"; resHtml += "<div style='clear: both;'></div></br>";
resHtml += "<center><button id = 'selectRace' class='btn btn-primary' disabled onclick='sendSelectedRacesToServer()'>OK</button></center>" 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() { function checkRaceSelectFinish() {
if(selectedRaces.filter(x => x).length >= raceCount){ if(selectedRaces.filter(x => x).length >= raceCount){
$("#selectRace").removeAttr("disabled"); $("#selectRace").removeAttr("disabled");
} else if($("#raceInput").val().trim() !== ''){
$("#selectRace").removeAttr("disabled");
} }
} }
function sendSelectedRacesToServer() { function sendSelectedRacesToServer() {
$.cookie('raceTextInput', $("#raceInput").val(), {expires: 1});
var selectedInput = $("#raceInput").val();
if(selectedInput === undefined){
selectedInput = ""
}
console.log(selectedRaces); console.log(selectedRaces);
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "selectRace", type: "selectRace",
mainRaces: selectedRaces.filter(x => x), mainRaces: selectedRaces.filter(x => x),
additionalRaces: selectedAdditionalRaces.filter(x => x) additionalRaces: selectedAdditionalRaces.filter(x => x),
raceTextInput: selectedInput
})); }));
$("#mapList").html("<center>Waiting another player...</center>"); $("#mapList").html("<center>Waiting another player...</center>");
} }
@@ -524,12 +525,13 @@ function getRenderRaces(userInfo1, userInfo2) {
function getRacesBlock(playerRaces, name){ function getRacesBlock(playerRaces, name){
let resHtml = "<div style='display: inline-block; border: black 1px solid; background-color: #e3e3e3;'>" + "<h4>" + name + "</h4>"; let resHtml = "<div style='display: inline-block; border: black 1px solid; background-color: #e3e3e3;'>" + "<h4>" + name + "</h4>";
resHtml += "main:";
playerRaces.mainRaces.forEach((raceId) => playerRaces.mainRaces.forEach((raceId) =>
resHtml += "<img class=\"raceIcon\" src=" + getRaceImageByNumber(raceId) + "> "); resHtml += "<img class=\"raceIcon\" src=" + getRaceImageByNumber(raceId) + "> ");
resHtml += "<br/> addt:";
playerRaces.additionalRaces.forEach((raceId) => playerRaces.additionalRaces.forEach((raceId) =>
resHtml += "<img class=\"raceIcon\" src=" + getRaceImageByNumber(raceId) + "> "); resHtml += "<img class=\"raceIcon\" src=" + getRaceImageByNumber(raceId) + "> ");
if(existTextFieldRaces){
resHtml += "<span style='margin: 10px;'>" + playerRaces.raceTextInput + "</span>";
}
resHtml += "</div>"; resHtml += "</div>";
return resHtml; return resHtml;
} }
+239 -30
View File
@@ -1,52 +1,261 @@
package controllers 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 javax.inject._
import org.reactivestreams.Publisher import com.typesafe.config.ConfigFactory
import play.api.i18n.Messages
import play.api.libs.json._ import play.api.libs.json._
import play.api.mvc._ import play.api.mvc._
import repository.DeciderRepository
import play.api.libs.ws.WSClient
import scala.concurrent.duration._ import scala.collection.JavaConverters._
import scala.concurrent.{Await, ExecutionContext, Future} 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 @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 = cc.executionContext
implicit val ec: ExecutionContext = defaultExecutionContext private val logger = org.slf4j.LoggerFactory.getLogger("controllers.AdminController")
implicit val timeout = Timeout(100.millis)
// Use a direct reference to SLF4J // ---------- Ownership ----------
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.HomeController")
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 => private def owns[A](decider: repository.Decider, request: Request[A]): Boolean =
logger.info(s"Received request from: ${request.remoteAddress}") decider.ownerSteamId.contains(currentSteamId(request))
val allUsers = userParentActor ? GetAllUsers private def deciderJson(d: repository.Decider): JsObject = Json.obj(
val res = Await.result(allUsers, Duration.Inf) "key" -> d.key,
Ok(res.toString) "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 }
// 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 => def reloadConfig() = Action { implicit request =>
ConfigFactory.invalidateCaches()
logger.info("Config reloaded")
Ok("Config reloaded") Ok("Config reloaded")
} }
} }
+9 -6
View File
@@ -13,6 +13,7 @@ import com.typesafe.config.ConfigFactory
import org.reactivestreams.Publisher import org.reactivestreams.Publisher
import play.api.libs.json._ import play.api.libs.json._
import play.api.mvc._ import play.api.mvc._
import repository.DeciderRepository
import scala.concurrent.duration._ import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
@@ -41,13 +42,15 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
// Home page that renders template // Home page that renders template
def index(deciderName: String) = Action { implicit request => def index(deciderName: String) = Action { implicit request =>
logger.info(s"Received request from: ${request.remoteAddress}") logger.info(s"Received request from: ${request.remoteAddress}")
val deciderHumanName = config.getString(s"deciders.$deciderName.name") val deciderOpt = DeciderRepository.get(deciderName)
val deciderDescription = config.getString(s"deciders.$deciderName.rules") if (deciderOpt.isEmpty) {
val existAdditionalRaces = Try(config.getBoolean(s"deciders.$deciderName.existAdditionalRaces")).getOrElse(false) NotFound(s"Decider $deciderName not found")
val raceCount = Try(config.getInt(s"deciders.$deciderName.raceCount")).getOrElse(1) } else {
val d = deciderOpt.get
val lastmapsSettings = LastMapsSelectConfig(true, true, true, true) val lastmapsSettings = LastMapsSelectConfig(true, true, true, true)
val raceSelect = RaceSelect(true, false) 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. * Returns true if the value of the Origin header contains an acceptable value.
*/ */
def originMatches(origin: String, remoteAddress: String): Boolean = { 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://",""))
} }
/** /**
+38
View File
@@ -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"
}
}
+59
View File
@@ -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"))
}
}
}
+194
View File
@@ -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
}
}
+162
View File
@@ -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
}
}
+723 -2
View File
@@ -1,6 +1,727 @@
@()(implicit r: Request[_]) @(deciders: Seq[repository.Decider])(implicit request: Request[_], messages: Messages)
<!DOCTYPE html> <!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 &bull; maps: @d.maps.size &bull; 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">&times;</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">&times;</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">&#10005;</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> </html>
+4 -3
View File
@@ -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> <!DOCTYPE html>
<span id="deciderName" style="display: none">@deciderName</span> <span id="deciderName" style="display: none">@deciderName</span>
<span id="raceCount" style="display: none">@raceCount</span> <span id="raceCount" style="display: none">@raceCount</span>
<span id="existAdditionalRaces" style="display: none">@existAdditionalRaces</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="last1Presence" style="display: none">@boSettings.last1</span>
<span id="last3Presence" style="display: none">@boSettings.last3</span> <span id="last3Presence" style="display: none">@boSettings.last3</span>
<span id="last5Presence" style="display: none">@boSettings.last5</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/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/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("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> </head>
<body data-ws-url="@routes.HomeController.ws.webSocketURL()"> <body data-ws-url="@routes.HomeController.ws.webSocketURL()">
<div class="navbar navbar-inverse navbar-fixed-top"> <div class="navbar navbar-inverse navbar-fixed-top">
@@ -60,7 +61,7 @@
</div> </div>
<div style="clear: both"></div> <div style="clear: both"></div>
<div><i><b>Правила турнира "@deciderHumanName"</b><br/> <div><i><b>Правила турнира "@deciderHumanName"</b><br/>
<p>@rules</p> <p style="white-space: pre-wrap">@rules</p>
<br> <br>
</i> </i>
</div> </div>
+4 -3
View File
@@ -4,7 +4,7 @@ version := "1.0-SNAPSHOT"
lazy val root = (project in file(".")).enablePlugins(PlayScala) lazy val root = (project in file(".")).enablePlugins(PlayScala)
scalaVersion := "2.12.8" scalaVersion := "2.12.20"
cancelable in Global := true cancelable in Global := true
@@ -23,8 +23,9 @@ libraryDependencies += "org.webjars" % "backbonejs" % "1.3.3"
libraryDependencies += guice libraryDependencies += guice
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2" libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2"
libraryDependencies += "com.typesafe.play" %% "play" % "2.8.2" // Версии play/play-json берутся из sbt-plugin (2.8.21).
libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.1" // Раньше здесь были явные версии 2.8.2/2.9.1, из-за чего подтягивался Guice 4.2.3
// с cglib, который не работает на Java 17+ (InaccessibleObjectException).
lazy val akkaVersion = "2.4.18" lazy val akkaVersion = "2.4.18"
libraryDependencies += "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test libraryDependencies += "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test
+682 -71
View File
@@ -2,13 +2,90 @@
# https://www.playframework.com/documentation/latest/AllowedHostsFilter # https://www.playframework.com/documentation/latest/AllowedHostsFilter
# Allow requests to localhost:9000. # Allow requests to localhost:9000.
play.filters.hosts { 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.http.secret.key="QCY?tAnfk?aZ?iwrNwnxIlR6CTf:123123Latabg@5241AB`R5W:1uDFN];Ik@n"
play.server.http.port = 1337 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{ deciders{
classic{ 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"], maps = [["2p_battle_marshes"],
["2p_fallen_city_[Rem]"], ["2p_fallen_city_[Rem]"],
["2p_fata_morgana_[Rem]"], ["2p_fata_morgana_[Rem]"],
@@ -17,13 +94,58 @@ deciders{
["2p_shrine_of_excellion_[Rem]"], ["2p_shrine_of_excellion_[Rem]"],
["2p_titan_fall_[Rem]"], ["2p_titan_fall_[Rem]"],
["2p_tranquilitys_end_[Rem]"], ["2p_tranquilitys_end_[Rem]"],
["2p_fraziersdemise"],
["2p_outer_reaches"], ["2p_outer_reaches"],
["2p_blood_river_[Rem]"],
["2p_deadly_fun_archeology"], ["2p_deadly_fun_archeology"],
["2p_bloody_hell_[Ed]"]] ["2p_blood_river_[Rem]"]]
name = "Классический десайдер" name = "New player tournament"
rules = """""" 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{ freakcup{
maps = [["2p_battle_marshes"], maps = [["2p_battle_marshes"],
@@ -39,6 +161,55 @@ deciders{
name = "Десайдер freakcup" name = "Десайдер freakcup"
rules = """""" 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{ dredicmappool{
maps = [["2p_battle_marshes"], maps = [["2p_battle_marshes"],
["2p_fallen_city_[Rem]"], ["2p_fallen_city_[Rem]"],
@@ -57,6 +228,66 @@ deciders{
name = "Десайдер DreDick" name = "Десайдер DreDick"
rules = """""" 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{ flazzomappool{
maps = [["2p_battle_marshes"], maps = [["2p_battle_marshes"],
["2p_fallen_city_[Rem]"], ["2p_fallen_city_[Rem]"],
@@ -77,6 +308,34 @@ deciders{
name = "Десайдер flazzo" name = "Десайдер flazzo"
rules = """""" 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{ salats{
maps = [["винегрет"], maps = [["винегрет"],
["грибная поляна"], ["грибная поляна"],
@@ -170,6 +429,21 @@ deciders{
Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров. Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров.
Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров.""" Во втором и последующем матчах проигравший выбирает карту из числа выбранных 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{ meatgrinder{
maps = [["2p_battle_marshes"], maps = [["2p_battle_marshes"],
["2p_fallen_city"], ["2p_fallen_city"],
@@ -228,6 +502,26 @@ deciders{
raceCount = 2 raceCount = 2
rules = """Выбор карт в БО3 и БО5 осуществляется без лузерпиков, с тремя и пятью десайдерами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров. Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров.""" 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{ trainercup{
maps = [["2p_battle_marshes"], maps = [["2p_battle_marshes"],
["2p_fallen_city_[Rem]"], ["2p_fallen_city_[Rem]"],
@@ -242,7 +536,7 @@ deciders{
["2p_deadly_fun_archeology"]] ["2p_deadly_fun_archeology"]]
name = "Trainer cup" name = "Trainer cup"
raceCount = 2 raceCount = 2
rules = """Выбор карт в БО3 и БО5 осуществляется без лузерпиков, с тремя и пятью десайдерами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров. Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров.""" rules = """Выбор карт в рамках бо3 и бо5, с тремя и пятью картами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Во втором и последующем матчах проигравший выбирает карту из числа выбранных карт. В сетке лузеров бо1, финал лузеров бо3, в супер-финале игрок из нижней сетки имеет право автоматического лузерпика, а игрок из верхней - плюс очко.."""
} }
customcup{ customcup{
maps = [["2p_battle_marshes"], maps = [["2p_battle_marshes"],
@@ -332,6 +626,22 @@ deciders{
existAdditionalRaces = true existAdditionalRaces = true
rules = """1 раса, но можно взять вторую при мирроре""" 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{ mallusc-cup{
maps = [["2p_outer_reaches"], maps = [["2p_outer_reaches"],
["2p_battle_marshes"], ["2p_battle_marshes"],
@@ -420,67 +730,15 @@ deciders{
Финал сетки лузеров (БО3). Финал сетки лузеров (БО3).
Игрок, прошедший в суперфинал из верхней сетки, имеет +1 очко. Игрок из нижней сетки начинает с лузерпиком. """ Игрок, прошедший в суперфинал из верхней сетки, имеет +1 очко. Игрок из нижней сетки начинает с лузерпиком. """
} }
shouldercup{ testcup{
description = true description = true
existAdditionalRaces = true existAdditionalRaces = true
raceCount = 3 raceCount = 3
maps = [["6p_paynes_retribution", "Количество точек: 28/2/2\nСпорных точек: 10/0/2\nТермогенераторы: 2"], maps = [["6p_irridene_v2", "Тут можно написать всякую фигню про точки и т.д."],
["6p_fury_island", "Количество точек: 26/6/1\nСпорных точек: 6 + 1 крит\nТермогенераторы: Отсутствуют"], ["6p_principian_badlands_BM", "Вася"],
["6p_irridene", "Количество точек: 21/6/3\nСпорных точек: 6/0/3\nТермогенераторы: 6"], ["6p_trivian_groves_v2", "Петя"]]
["6p_shakun_coast", "Количество точек: 24/4/1\nСпорных точек: 6/2/1\nТермогенераторы: 2"], name = "Тестовый турнир"
"2p_tranquilitys_end_[Rem]", rules = """ Тест """
"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 очко. Игрок из нижней сетки начинает с лузерпиком. """
} }
shouldercup4x4{ shouldercup4x4{
description = true description = true
@@ -497,21 +755,53 @@ deciders{
name = "Shoulder to shoulder cup" name = "Shoulder to shoulder cup"
rules = """Черкается по правилу лузерпиков: сначала оставляется одна карта, далее команда выбирает, на какой карте будет играть. """ rules = """Черкается по правилу лузерпиков: сначала оставляется одна карта, далее команда выбирает, на какой карте будет играть. """
} }
springcup{ theemperorprotects{
description = true description = true
raceCount = 2
maps = [["4p_biffys_peril"], maps = [["4p_biffys_peril"],
["4p_cold_war"], ["4p_cold_war"],
["4p_colosseum_of_deadman"], ["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_gorhael_crater"],
["4p_imperial_area"], ["4p_imperial_area"],
["4p_saints_square"], ["4p_saints_square"],
["4p_tartarus_center"], ["4p_tartarus_center"],
["4p_testcake"], ["4p_gurmuns_pass"],
["4p_skerries"],
["4p_tiboraxx"],
["4p_torrents"]] ["4p_torrents"]]
name = "Spring cup 2x2" name = "Back to Skull Cup cup"
rules = """Десайдеры выбирается методом вычеркивания до 3 карт, далее из оставшихся карт методом вычеркивания определяется первая карта. Игрок, начавший вычеркивание из основного пула уступает право начать вычеркивание из оставшихся карт. Исключение суперфинал: Команда прошедшая в суперфинал с верхней сетки имеет преимущество в выборе очередности карт из оставшихся 3 после вычеркивания.""" rules = """"""
} }
shouldercup2x2{ shouldercup2x2{
description = true description = true
@@ -601,6 +891,25 @@ deciders{
name = "No Weakness cup" name = "No Weakness cup"
rules = """Выбор карт в БО3 и БО5 осуществляется без лузерпиков, с тремя и пятью десайдерами соответственно. Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи. Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров. Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров. """ 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{ freneticmappool{
raceCount = 1 raceCount = 1
description = true description = true
@@ -637,6 +946,141 @@ deciders{
name = "Frenetic map pool" name = "Frenetic map pool"
rules = """ """ 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{ freneticmappool2x2{
raceCount = 2 raceCount = 2
description = true description = true
@@ -656,6 +1100,20 @@ deciders{
name = "Frenetic map pool" name = "Frenetic map pool"
rules = """ """ 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{ deadgamecup{
raceCount = 2 raceCount = 2
description = true description = true
@@ -704,6 +1162,8 @@ deciders{
["2p_meeting_of_minds"], ["2p_meeting_of_minds"],
["2p_emerald_river"], ["2p_emerald_river"],
["2p_bloody_hell_[Ed]"], ["2p_bloody_hell_[Ed]"],
["2p_cryptek_arkana_BM"],
["2p_consecrated_gardens_5p_BM"],
] ]
name = "BAD cup" name = "BAD cup"
rules = """Double elimination. БО3 (БО5 суперфинал). БО1 лузерсетка (БО3 финал лузер сетки). 2 Расы на турнир, без банов. Вторая раса берётся в случае лузерпика(и играется до окончания бо) или по договоренности обеих сторон в случае миррора.""" rules = """Double elimination. БО3 (БО5 суперфинал). БО1 лузерсетка (БО3 финал лузер сетки). 2 Расы на турнир, без банов. Вторая раса берётся в случае лузерпика(и играется до окончания бо) или по договоренности обеих сторон в случае миррора."""
@@ -725,6 +1185,21 @@ deciders{
name = "UA cup" name = "UA cup"
rules = """""" 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{ true_arena_bad2{
raceCount = 2 raceCount = 2
maps = [["2p_outer_reaches"], maps = [["2p_outer_reaches"],
@@ -745,6 +1220,45 @@ deciders{
name = "BAD cup" name = "BAD cup"
rules = """""" 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 { ppcz {
raceCount = 2 raceCount = 2
maps = [["2p_fata_morgana_[Rem]"], maps = [["2p_fata_morgana_[Rem]"],
@@ -769,4 +1283,101 @@ deciders{
Некрон не имеет права лузерпикать против Tau (тоже, что и в примере выше) Некрон не имеет права лузерпикать против Tau (тоже, что и в примере выше)
Против некрона нельзя выбирать SOE лузерпиком (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!"""
}
} }
+1604
View File
File diff suppressed because it is too large Load Diff
+20 -3
View File
@@ -2,11 +2,28 @@
# This file defines all application routes (Higher priority routes first) # This file defines all application routes (Higher priority routes first)
# ~~~~ # ~~~~
GET /decider/:name controllers.HomeController.index(name)
GET /ws controllers.HomeController.ws 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 # Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.at(path="/public", file) GET /assets/*file controllers.Assets.at(path="/public", file)
+1 -1
View File
@@ -1 +1 @@
sbt.version=1.2.8 sbt.version=1.9.8
+5 -1
View File
@@ -1,4 +1,8 @@
// Use the Play sbt plugin for Play projects // 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") 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"