Decider admin

This commit is contained in:
anibus
2026-10-24 22:02:29 +03:00
parent 3cad3aec4b
commit 0212f8b6d0
18 changed files with 1362 additions and 71 deletions
View File
+7 -10
View File
@@ -4,14 +4,11 @@ import actors.UserActor._
import akka.actor.{Actor, ActorRef}
import akka.event.LoggingReceive
import akka.util.Timeout
import com.typesafe.config.ConfigFactory
import com.typesafe.scalalogging.LazyLogging
import repository.DeciderRepository
import java.util
import scala.collection.JavaConverters.iterableAsScalaIterableConverter
import scala.collection.immutable.HashSet
import scala.concurrent.duration._
import scala.util.Try
case class LobbyUser(name: String, actorRef: ActorRef)
@@ -23,7 +20,6 @@ case class DeciderMap(map: String, description: Option[String] = None, var isBan
*/
class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with LazyLogging {
val config = ConfigFactory.load()
logger.info(s"Create lobby... host: ${hostUser.actorRef.path.name}")
@@ -49,11 +45,12 @@ class LobbieActor(hostUser: LobbyUser, deciderName: String) extends Actor with L
private val mapsLobby: Set[DeciderMap] = {
val configMaps = config.getList(s"deciders.$deciderName.maps").asScala
configMaps.map(e => {
val mapConfig = e.unwrapped().asInstanceOf[util.ArrayList[String]].asScala
DeciderMap(mapConfig.head, mapConfig.tail.headOption)
}).toSet
DeciderRepository.get(deciderName) match {
case Some(d) =>
d.maps.map(m => DeciderMap(m.map, m.description)).toSet
case None =>
Set.empty[DeciderMap]
}
}
hostUser.actorRef.tell(
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 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

+219 -30
View File
@@ -1,52 +1,241 @@
package controllers
import actors.UserParentActor.GetAllUsers
import actors._
import akka.NotUsed
import akka.actor._
import akka.event.Logging
import akka.pattern.ask
import akka.stream._
import akka.stream.scaladsl._
import akka.util.Timeout
import com.typesafe.config.ConfigFactory
import javax.inject._
import org.reactivestreams.Publisher
import com.typesafe.config.ConfigFactory
import play.api.i18n.Messages
import play.api.libs.json._
import play.api.mvc._
import repository.DeciderRepository
import play.api.libs.ws.WSClient
import scala.concurrent.duration._
import scala.concurrent.{Await, ExecutionContext, Future}
import scala.collection.JavaConverters._
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
/**
* This class creates the actions and the websocket needed.
* Admin panel for managing deciders.
* Authentication via Steam OpenID.
*/
@Singleton
class AdminController @Inject()(cc: ControllerComponents, homeController: HomeController) extends AbstractController(cc) {
class AdminController @Inject()(
cc: ControllerComponents,
ws: WSClient
) extends AbstractController(cc) with SecuredActions {
implicit val actorSystem: ActorSystem = ActorSystem()
implicit val ec: ExecutionContext = defaultExecutionContext
implicit val timeout = Timeout(100.millis)
implicit val ec: ExecutionContext = cc.executionContext
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.AdminController")
// Use a direct reference to SLF4J
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.HomeController")
// ---------- Steam OpenID ----------
val userParentActor: ActorRef = homeController.userParentActor
private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
// Home page that renders template
def viewAllUsers() = Action { implicit request =>
logger.info(s"Received request from: ${request.remoteAddress}")
// ---------- Public endpoints ----------
val allUsers = userParentActor ? GetAllUsers
val res = Await.result(allUsers, Duration.Inf)
Ok(res.toString)
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))
}
// ---------- 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.map(d => Json.obj(
"key" -> d.key,
"name" -> d.name,
"rules" -> d.rules,
"raceCount" -> d.raceCount,
"existAdditionalRaces" -> d.existAdditionalRaces,
"existTextFieldRaces" -> d.existTextFieldRaces,
"description" -> d.description,
"maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description))
))
Ok(Json.toJson(list))
}
// ---------- API: get one decider ----------
def deciderGet(key: String) = Secured { implicit request =>
DeciderRepository.get(key) match {
case Some(d) =>
Ok(Json.obj(
"key" -> d.key,
"name" -> d.name,
"rules" -> d.rules,
"raceCount" -> d.raceCount,
"existAdditionalRaces" -> d.existAdditionalRaces,
"existTextFieldRaces" -> d.existTextFieldRaces,
"description" -> d.description,
"maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description))
))
case None =>
NotFound(Json.obj("error" -> s"Decider $key not found"))
}
}
// ---------- API: save / update decider ----------
def deciderSave() = SecuredJson { implicit request =>
val json = request.body
(json \ "key").asOpt[String] match {
case None =>
BadRequest(Json.obj("error" -> "Missing key"))
case Some(key) =>
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
)
}.toEither match {
case Right(decider) =>
val existed = DeciderRepository.get(decider.key).isDefined
DeciderRepository.save(decider)
DeciderRepository.reload()
if (existed) {
logger.info(
s"Admin ${SecuredActions.steamIdOrUnknown(request)} updated decider '${decider.key}' " +
s"(name='${decider.name}', maps=${decider.maps.size})"
)
} else {
logger.info(
s"Admin ${SecuredActions.steamIdOrUnknown(request)} created decider '${decider.key}' " +
s"(name='${decider.name}', maps=${decider.maps.size})"
)
}
Ok(Json.obj("ok" -> true))
case Left(error) =>
BadRequest(Json.obj("error" -> s"$error"))
}
}
}
// ---------- API: delete decider ----------
def deciderDelete(key: String) = SecuredApi { implicit request =>
if (DeciderRepository.delete(key)) {
DeciderRepository.reload()
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(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 =>
ConfigFactory.invalidateCaches()
logger.info("Config reloaded")
Ok("Config reloaded")
}
}
+8 -6
View File
@@ -13,6 +13,7 @@ import com.typesafe.config.ConfigFactory
import org.reactivestreams.Publisher
import play.api.libs.json._
import play.api.mvc._
import repository.DeciderRepository
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
@@ -41,14 +42,15 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
// Home page that renders template
def index(deciderName: String) = Action { implicit request =>
logger.info(s"Received request from: ${request.remoteAddress}")
val deciderHumanName = config.getString(s"deciders.$deciderName.name")
val deciderDescription = config.getString(s"deciders.$deciderName.rules")
val existAdditionalRaces = Try(config.getBoolean(s"deciders.$deciderName.existAdditionalRaces")).getOrElse(false)
val existTextFieldRaces = Try(config.getBoolean(s"deciders.$deciderName.existTextFieldRaces")).getOrElse(false)
val raceCount = Try(config.getInt(s"deciders.$deciderName.raceCount")).getOrElse(1)
val deciderOpt = DeciderRepository.get(deciderName)
if (deciderOpt.isEmpty) {
NotFound(s"Decider $deciderName not found")
} else {
val d = deciderOpt.get
val lastmapsSettings = LastMapsSelectConfig(true, true, true, true)
val raceSelect = RaceSelect(true, false)
Ok(views.html.index(deciderName, deciderHumanName, raceCount, deciderDescription, raceSelect, lastmapsSettings, existAdditionalRaces, existTextFieldRaces))
Ok(views.html.index(deciderName, d.name, d.raceCount, d.rules, raceSelect, lastmapsSettings, d.existAdditionalRaces, d.existTextFieldRaces))
}
}
/**
+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"))
}
}
}
+179
View File
@@ -0,0 +1,179 @@
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]
)
/**
* Чтение и запись десайдеров.
*
* Десайдеры хранятся во внешнем HOCON-файле (admin.decidersFile, по умолчанию
* "conf/deciders.conf"). Пока этот файл не существует, десайдеры читаются из
* application.conf, поэтому приложение работает "из коробки". Как только админ
* что-то сохраняет через админку, внешний файл становится единственным источником
* правды.
*/
object DeciderRepository {
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
))
}
}
/** Все десайдеры. */
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(" 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
}
}
+190
View File
@@ -0,0 +1,190 @@
package repository
import java.io.File
import java.net.{JarURLConnection, URL}
import java.nio.file.StandardCopyOption
import scala.collection.JavaConverters._
import scala.util.Try
/** Изображение карты, доступное для выбора в админке. */
case class MapImage(name: String, file: String, players: Int)
/**
* Список картинок карт из app/assets/images/maps.
*
* Картинки читаются из исходников (при запуске через `sbt run`) либо из
* classpath (когда приложение собрано и assets упакованы в jar).
*/
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
/** Каталоги с картинками: исходники и результат сборки assets. */
private val MapDirectories = Seq(
"app/assets/images/maps",
"target/web/public/main/images/maps",
"public/images/maps"
)
private val ClasspathDirectory = "public/images/maps"
/** Каталог, в который сохраняются загруженные картинки (исходники). */
private val SourceUploadDirectory = "app/assets/images/maps"
/** Каталоги, из которых Play раздаёт assets в dev-режиме. */
private val RuntimeUploadDirectory = "target/web/public/main/images/maps"
/** Максимальный размер загружаемого файла. */
private val MaxUploadBytes = 10L * 1024 * 1024
/** Все карты, отсортированные по количеству игроков и имени. */
def all: Seq[MapImage] =
loadFileNames()
.distinct
.map(toMapImage)
.sortBy(image => (groupOrder(image.players), image.name.toLowerCase))
/**
* Сохраняет загруженную картинку карты и возвращает её описание.
*
* Файл пишется и в исходники (app/assets), и в каталог, который раздаётся
* в dev-режиме (target/web/public/main), чтобы картинка сразу появилась
* в галерее без перезапуска приложения.
*/
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 directories = uploadDirectories()
if (directories.isEmpty) Left("Map images directory not found")
else {
val fileName = uniqueFileName(safeName, directories)
Try {
directories.foreach { dir =>
java.nio.file.Files.copy(
source.toPath,
new File(dir, fileName).toPath,
StandardCopyOption.REPLACE_EXISTING
)
}
}.toEither match {
case Right(_) => Right(toMapImage(fileName))
case Left(error) => Left(s"Failed to save file: ${error.getMessage}")
}
}
}
}
/** Каталоги для записи: исходники создаются при необходимости, остальные — только существующие. */
private def uploadDirectories(): Seq[File] = {
val source = new File(SourceUploadDirectory)
if (!source.isDirectory) source.mkdirs()
(source +: Seq(new File(RuntimeUploadDirectory)))
.filter(_.isDirectory)
}
/** Проверяет расширение и убирает из имени всё, что может сломать путь. */
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, directories: Seq[File]): String = {
def taken(name: String): Boolean = directories.exists(dir => new File(dir, 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 loadFileNames(): Seq[String] = {
val fromDisk = MapDirectories
.map(new File(_))
.find(_.isDirectory)
.map(dir => Option(dir.listFiles()).toSeq.flatten.filter(_.isFile).map(_.getName))
.getOrElse(Seq.empty)
val fileNames = if (fromDisk.nonEmpty) fromDisk else loadFromClasspath()
fileNames.filter(isMapImage)
}
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 loadFromClasspath(): Seq[String] = {
val loader = Option(getClass.getClassLoader).getOrElse(ClassLoader.getSystemClassLoader)
loader.getResources(ClasspathDirectory).asScala.toSeq.flatMap(readResource)
}
private def readResource(url: URL): Seq[String] = url.getProtocol match {
case "file" =>
Try(new File(url.toURI)).toOption
.filter(_.isDirectory)
.flatMap(dir => Option(dir.listFiles()))
.toSeq
.flatten
.filter(_.isFile)
.map(_.getName)
case "jar" =>
Try {
val connection = url.openConnection().asInstanceOf[JarURLConnection]
connection.setUseCaches(false)
val jar = connection.getJarFile
try {
jar.entries().asScala
.map(_.getName)
.filter(_.startsWith(ClasspathDirectory + "/"))
.map(_.stripPrefix(ClasspathDirectory + "/"))
.filterNot(_.contains("/"))
.toSeq
} finally jar.close()
}.getOrElse(Seq.empty)
case _ => Seq.empty
}
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
}
}
+652 -2
View File
@@ -1,6 +1,656 @@
@()(implicit r: Request[_])
@(deciders: Seq[repository.Decider])(implicit request: Request[_], messages: Messages)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="@{play.filters.csrf.CSRF.getToken.map(_.value).getOrElse("")}">
<title>Admin — Decider</title>
<link rel='stylesheet' href='@routes.Assets.at("lib/bootstrap/css/bootstrap.min.css")'>
<style>
body { padding-top: 70px; }
.decider-card { margin-bottom: 15px; border-left: 4px solid #337ab7; }
/* Строка карты занимает всю доступную ширину: выбор карты фиксированный, описание растягивается */
.map-row { margin-bottom: 5px; display: flex; align-items: center; }
.map-row .map-desc { flex: 1 1 auto; min-width: 0; width: auto; }
.map-row .map-preview, .map-row .btn-remove-map { flex: 0 0 auto; }
.modal-footer { text-align: right; }
.btn-remove-map { margin-left: 5px; }
.decider-key { font-family: monospace; color: #666; font-size: 0.9em; }
.nav-tabs > li > a { cursor: pointer; }
#deciderList { max-height: calc(100vh - 200px); overflow-y: auto; }
#deciderList .list-group-item { cursor: pointer; }
#deciderList .list-group-item.active { background-color: #337ab7; border-color: #337ab7; }
#deciderList .list-group-item:hover { background-color: #f5f5f5; }
#deciderList .list-group-item.active:hover { background-color: #286090; }
.error-msg { color: red; margin-top: 10px; }
.success-msg { color: green; margin-top: 10px; }
.map-preview { width: 64px; height: 40px; object-fit: cover; border-radius: 3px; margin-right: 6px; vertical-align: middle; }
/* Кнопка выбора карты вместо ручного ввода имени */
.btn-select-map { width: 170px; flex: 0 0 170px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 6px; }
.map-gallery { max-height: 60vh; overflow-y: auto; }
/* Поиск карт и загрузка картинки */
.map-picker-toolbar { display: flex; align-items: center; margin-bottom: 10px; }
.map-picker-toolbar .map-search { flex: 1 1 auto; margin-right: 8px; }
#mapUploadMessage { min-height: 18px; margin-bottom: 8px; }
.map-gallery .map-thumb { cursor: pointer; text-align: center; margin-bottom: 10px; padding: 4px; border: 1px solid transparent; border-radius: 4px; }
.map-gallery .map-thumb:hover { border-color: #337ab7; background-color: #f5f5f5; }
.map-gallery .map-thumb img { width: 100%; max-width: 160px; height: 100px; object-fit: cover; border-radius: 4px; }
.map-gallery .map-thumb-name { font-size: 11px; word-break: break-all; margin-top: 3px; }
#mapPickerTabs { margin-bottom: 15px; }
<html>
/* Шапка: название слева, Steam ID и Refresh справа, всё по центру по вертикали */
.navbar .container { display: flex; align-items: center; justify-content: space-between; }
.navbar-header { float: none; }
.navbar-user { display: flex; align-items: center; margin-left: 15px; color: #ccc; }
.navbar-user .navbar-user-id { margin-right: 10px; white-space: nowrap; }
.navbar-user .btn { margin: 0; }
/* Кнопки в заголовках панелей по центру строки с заголовком */
.panel-heading .panel-title { display: flex; align-items: center; justify-content: space-between; }
.panel-heading .panel-title > .btn { flex: 0 0 auto; margin-left: 10px; }
@@media (max-width: 480px) {
.navbar-user .navbar-user-id { display: none; }
}
</style>
</head>
<body>
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a class="navbar-brand" href="/decider/admin">Decider admin</a>
</div>
<div class="navbar-user">
<span class="navbar-user-id">Steam ID: @(request.session.get("steamId").getOrElse("unknown"))</span>
<a href="/decider/admin" class="btn btn-sm btn-default">Refresh</a>
</div>
</div>
</nav>
<div class="container">
<div class="row">
<!-- Left panel: list of deciders -->
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
Deciders (@deciders.size)
<button class="btn btn-sm btn-success" onclick="openNewDecider()">+ New</button>
</h3>
</div>
<div class="list-group" id="deciderList">
@for(d <- deciders) {
<a class="list-group-item" 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</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>
</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;
// 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);
$('#btnDelete').toggle(!!decider.key);
$('#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');
$('.list-group-item').filter(function() {
return $(this).find('.decider-key').text().includes(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');
}
});
}
// 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;
});
});
</script>
</body>
</html>
+1 -1
View File
@@ -61,7 +61,7 @@
</div>
<div style="clear: both"></div>
<div><i><b>Правила турнира "@deciderHumanName"</b><br/>
<p>@rules</p>
<p style="white-space: pre-wrap">@rules</p>
<br>
</i>
</div>
+4 -3
View File
@@ -4,7 +4,7 @@ version := "1.0-SNAPSHOT"
lazy val root = (project in file(".")).enablePlugins(PlayScala)
scalaVersion := "2.12.8"
scalaVersion := "2.12.20"
cancelable in Global := true
@@ -23,8 +23,9 @@ libraryDependencies += "org.webjars" % "backbonejs" % "1.3.3"
libraryDependencies += guice
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2"
libraryDependencies += "com.typesafe.play" %% "play" % "2.8.2"
libraryDependencies += "com.typesafe.play" %% "play-json" % "2.9.1"
// Версии play/play-json берутся из sbt-plugin (2.8.21).
// Раньше здесь были явные версии 2.8.2/2.9.1, из-за чего подтягивался Guice 4.2.3
// с cglib, который не работает на Java 17+ (InaccessibleObjectException).
lazy val akkaVersion = "2.4.18"
libraryDependencies += "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test
+17 -10
View File
@@ -7,6 +7,13 @@ play.filters.hosts {
play.http.secret.key="QCY?tAnfk?aZ?iwrNwnxIlR6CTf:123123Latabg@5241AB`R5W:1uDFN];Ik@n"
play.server.http.port = 1337
admin {
steamApiKey = ""
steamRedirectUrl = "http://localhost:1337/decider/admin/steam/callback"
allowedSteamIds = []
decidersFile = "conf/deciders.conf"
}
deciders{
classic{
maps = [["2p_battle_marshes"],
@@ -42,19 +49,19 @@ deciders{
}
montanaz0r{
maps = [["2p_battle_marshes"],
["2p_blood_river"],
["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_shrine_of_excellion"],
["2p_quests_triumph"],
["2p_outer_reaches"],
["2p_blood_river"]]
name = "VOX of War tournament"
raceCount = 1
rules = """There are 11 maps in total, and each player will ban 3 maps. This means that at the end of the draft, 5 maps will remain."""
["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"],
+16 -3
View File
@@ -2,11 +2,24 @@
# This file defines all application routes (Higher priority routes first)
# ~~~~
GET /decider/:name controllers.HomeController.index(name)
GET /ws controllers.HomeController.ws
GET /admin/users controllers.AdminController.viewAllUsers()
# Admin panel
# ВАЖНО: админские маршруты должны идти ДО /decider/:name,
# иначе /decider/admin перехватывается как десайдер с именем "admin".
GET /decider/admin controllers.AdminController.index()
GET /decider/admin/login controllers.AdminController.login()
GET /decider/admin/steam/callback controllers.AdminController.steamCallback()
# Admin API
GET /decider/admin/maps controllers.AdminController.mapImages()
POST /decider/admin/maps controllers.AdminController.mapImageUpload()
GET /decider/admin/deciders controllers.AdminController.decidersList()
GET /decider/admin/deciders/:key controllers.AdminController.deciderGet(key)
POST /decider/admin/deciders controllers.AdminController.deciderSave()
DELETE /decider/admin/deciders/:key controllers.AdminController.deciderDelete(key)
GET /decider/:name controllers.HomeController.index(name)
# Map static resources from the /public folder to the /assets URL path
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
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.7.0")
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.21")
addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.2")
// Ignore scala-xml binary incompatibility between 1.x (sbt-native-packager, twirl)
// and 2.x (scala-compiler) – they are compatible enough for the build to work
libraryDependencySchemes += "org.scala-lang.modules" %% "scala-xml" % "always"