Compare commits

..
19 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
v.holodov bda5a53bca Add readme 2024-03-17 14:31:58 +03:00
v.holodov b0dfc5b67a Sync conf 2024-03-17 14:06:09 +03:00
v.holodov ecc69cd7d3 Additional races 2023-08-05 16:08:50 +03:00
v.holodov 31ce4b7beb Рабочий десайдер 2023-05-04 23:58:43 +03:00
v.holodov dc228d82be Add fourth player, add 4x4 maps 2022-11-19 01:27:55 +03:00
v.holodov 750ea483b6 2 new map, fix map name 4+ players 2022-10-05 23:22:08 +03:00
v.holodov d3e90fb01e Видосы и параметризованный выбор кол-ва рас 2022-06-03 21:07:54 +03:00
Viktor Kholodov aa692419cf Tournaments can be configured now 2021-10-23 14:13:36 +03:00
Viktor Kholodov 52baef061b Autumn meatgrinder 2021-09-25 03:14:47 +03:00
Viktor Kholodov e71480c1f7 with superfinal 2021-09-12 13:27:46 +03:00
94 changed files with 5252 additions and 509 deletions
View File
+18 -1
View File
@@ -1 +1,18 @@
This is decider for Soulstorm # Десайдер для турниров по Soulstorm
## Стек технологий backend:
- akka (не типизированные акторы)
- play framework 2.8.2
- scala 2.12.8
## Стек технологий frontend:
- jquery 1.4.1
- верстка/ui: bootstrap 3.3.6
Для запуска проекта необходимо установить sbt(scala build tools) и выполнить команду ```sbt run```.
Если при переходе на ```http://localhost:9000/decider/classic``` откроется десайдер - всё сделано правильно.
В проекте предусмотрена защита от xss атак *(хотя, тут воровать то нечего, но всё же)*, потому при развертывании
где-либо, необходимо добавить в HomeController в originMatches ip адрес хостинга или домен.
+92 -52
View File
@@ -1,28 +1,25 @@
package actors package actors
import actors.UserActor.{BanMapMessage, GetName, HostLeaveLobby, LobbyFatal, Message, RefreshLobbyInfo, SecondPlayerLeaveLobby, SendBanMapMessage, SendMessage, SetUserAsHost, SetUserAsObs, SetUserAsSecondPlayer} import actors.UserActor._
import akka.actor.{Actor, ActorLogging, ActorRef} import akka.actor.{Actor, ActorRef}
import akka.event.LoggingReceive import akka.event.LoggingReceive
import com.typesafe.scalalogging.LazyLogging
import akka.pattern.ask
import akka.util.Timeout import akka.util.Timeout
import com.typesafe.config.{Config, ConfigFactory} import com.typesafe.scalalogging.LazyLogging
import repository.DeciderRepository
import scala.collection.JavaConverters.iterableAsScalaIterableConverter import scala.collection.immutable.HashSet
import scala.collection.immutable.{HashSet, Queue}
import scala.concurrent.Await
import scala.concurrent.duration._ import scala.concurrent.duration._
case class LobbyUser(name: String, actorRef: ActorRef) case class LobbyUser(name: String, actorRef: ActorRef)
case class DeciderMap(map: String, var isBanned: Boolean = false)
case class DeciderMap(map: String, description: Option[String] = None, var isBanned: Boolean = false)
/** /**
* There is one StockActor per stock symbol. The StockActor maintains a list of users watching the stock and the stock * There is one StockActor per stock symbol. The StockActor maintains a list of users watching the stock and the stock
* values. Each StockActor updates a rolling dataset of randomly generated stock values. * values. Each StockActor updates a rolling dataset of randomly generated stock values.
*/ */
class LobbieActor(hostUser: LobbyUser) 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}")
@@ -41,20 +38,27 @@ class LobbieActor(hostUser: LobbyUser) extends Actor with LazyLogging {
private var status: LobbyStatus = NotStarted() private var status: LobbyStatus = NotStarted()
private var lobbyType: LobbyType = Last3() private var lobbyType: LobbyType = Last1()
private var firstPlayerSelectedRaces: Option[SelectedRaces] = None
private var secondPlayerSelectedRaces: Option[SelectedRaces] = None
private val mapsLobby: Set[DeciderMap] = { private val mapsLobby: Set[DeciderMap] = {
val configMaps = config.getStringList("maps").asScala DeciderRepository.get(deciderName) match {
configMaps.map(e => { case Some(d) =>
DeciderMap(e) d.maps.map(m => DeciderMap(m.map, m.description)).toSet
}).toSet case None =>
Set.empty[DeciderMap]
}
} }
hostUser.actorRef.tell( hostUser.actorRef.tell(
SetUserAsHost(LobbyInfo( SetUserAsHost(LobbyInfo(
UserInfo(hostUser.name,firstPlayerReady), UserInfo(hostUser.name, firstPlayerReady),
UserInfo("", secondPlayerReady), UserInfo("", secondPlayerReady),
self.path.name, self.path.name,
deciderName,
status.toString(), status.toString(),
playerTurn, playerTurn,
lobbyType.toString(), lobbyType.toString(),
@@ -69,49 +73,53 @@ class LobbieActor(hostUser: LobbyUser) extends Actor with LazyLogging {
// notify watchers // notify watchers
logger.info(s"Ban map $mapName by ${sender.path.name}") logger.info(s"Ban map $mapName by ${sender.path.name}")
if(status == Draft()){ if (status == Draft()) {
if((playerTurn == 1 && sender == host.actorRef) || if ((playerTurn == 1 && sender == host.actorRef) ||
(playerTurn == 2 && secondPlayer.exists(_.actorRef == sender))){ (playerTurn == 2 && secondPlayer.exists(_.actorRef == sender))) {
mapsLobby.find(p => p.map == mapName) match { mapsLobby.find(p => p.map == mapName) match {
case Some(map) => case Some(map) =>
if(!map.isBanned){ if (!map.isBanned) {
map.isBanned = true map.isBanned = true
if (playerTurn== 1) playerTurn = 2 else playerTurn = 1 if (playerTurn == 1) playerTurn = 2 else playerTurn = 1
if(mapsLobby.count(_.isBanned == false) == 3 && lobbyType == Last3() || if (isFinish) {
mapsLobby.count(_.isBanned == false) == 4 && lobbyType == Last4() ||
mapsLobby.count(_.isBanned == false) == 5 && lobbyType == Last5() ||
mapsLobby.count(_.isBanned == false) == 1 && lobbyType == LooserPick()){
status = Finish() status = Finish()
} }
users.foreach(_ ! RefreshLobbyInfo(getLobbyInfoResponse)) refreshAndBanMap(mapName)
val senderName: String = if (sender == host.actorRef) {
host.name
} else secondPlayer.get.name
users.foreach(_ ! SendBanMapMessage(BanMapMessage(senderName, mapName)))
} else { } else {
logger.warn(s"User ban map $mapName, but map already banned") logger.warn(s"User ban map $mapName, but map already banned")
} }
case None => case None =>
logger.error(s"Map $mapName not exist") logger.error(s"Map $mapName not exist")
} }
} else{ } else {
logger.warn(s"Player ${sender.path.name} ban map, but turn is $playerTurn") logger.warn(s"Player ${sender.path.name} ban map, but turn is $playerTurn")
} }
} else { } else {
logger.warn(s"Player ${sender.path.name} ban map, but lobby status is '$status' status") logger.warn(s"Player ${sender.path.name} ban map, but lobby status is '$status' status")
} }
case selectedRaces: SelectedRaces =>
if (sender == host.actorRef) {
firstPlayerSelectedRaces = Some(selectedRaces)
logger.info(s"First player select $selectedRaces")
} else if (sender == secondPlayer.get.actorRef) {
secondPlayerSelectedRaces = Some(selectedRaces)
logger.info(s"Second player select $selectedRaces")
}
if (firstPlayerSelectedRaces.nonEmpty && secondPlayerSelectedRaces.nonEmpty) {
status = Draft()
users.foreach(_ ! RefreshLobbyInfo(getLobbyInfoResponse))
}
case SetReady => case SetReady =>
// notify watchers // notify watchers
var readyPlayer = sender() var readyPlayer = sender()
if(readyPlayer == hostUser.actorRef){ if (readyPlayer == hostUser.actorRef) {
firstPlayerReady = true firstPlayerReady = true
} else if (secondPlayer.exists(sp => sp.actorRef == readyPlayer)) { } else if (secondPlayer.exists(sp => sp.actorRef == readyPlayer)) {
secondPlayerReady = true secondPlayerReady = true
} }
if(firstPlayerReady && secondPlayerReady){ if (firstPlayerReady && secondPlayerReady) {
status = Draft() status = SelectRace()
} }
users.foreach(_ ! RefreshLobbyInfo(getLobbyInfoResponse)) users.foreach(_ ! RefreshLobbyInfo(getLobbyInfoResponse))
@@ -119,7 +127,7 @@ class LobbieActor(hostUser: LobbyUser) extends Actor with LazyLogging {
case SetNotReady => case SetNotReady =>
// notify watchers // notify watchers
var notReadyPlayer = sender() var notReadyPlayer = sender()
if(notReadyPlayer == hostUser.actorRef){ if (notReadyPlayer == hostUser.actorRef) {
firstPlayerReady = false firstPlayerReady = false
} else if (secondPlayer.exists(sp => sp.actorRef == notReadyPlayer)) { } else if (secondPlayer.exists(sp => sp.actorRef == notReadyPlayer)) {
secondPlayerReady = false secondPlayerReady = false
@@ -128,14 +136,15 @@ class LobbieActor(hostUser: LobbyUser) extends Actor with LazyLogging {
case ChangeLobbyType(lobbyTypeNew) => case ChangeLobbyType(lobbyTypeNew) =>
lobbyTypeNew match { lobbyTypeNew match {
case "last1" =>
lobbyType = Last1()
case "last3" => case "last3" =>
lobbyType = Last3() lobbyType = Last3()
case "last4" =>
lobbyType = Last4()
case "last5" => case "last5" =>
lobbyType = Last5() lobbyType = Last5()
case "looserpick" => case "last7" =>
lobbyType = LooserPick() lobbyType = Last7()
} }
users.foreach(_ ! RefreshLobbyInfo(getLobbyInfoResponse)) users.foreach(_ ! RefreshLobbyInfo(getLobbyInfoResponse))
@@ -150,7 +159,7 @@ class LobbieActor(hostUser: LobbyUser) extends Actor with LazyLogging {
case MessageForLobby(message) => case MessageForLobby(message) =>
if (sender() == host.actorRef) { if (sender() == host.actorRef) {
users.foreach(_ ! SendMessage(Message(host.name, message))) users.foreach(_ ! SendMessage(Message(host.name, message)))
}else if (secondPlayer.exists(sp => sp.actorRef == sender())){ } else if (secondPlayer.exists(sp => sp.actorRef == sender())) {
users.foreach(_ ! SendMessage(Message(secondPlayer.get.name, message))) users.foreach(_ ! SendMessage(Message(secondPlayer.get.name, message)))
} }
case JoinLobbyAsPlayer(sp) => case JoinLobbyAsPlayer(sp) =>
@@ -171,16 +180,16 @@ class LobbieActor(hostUser: LobbyUser) extends Actor with LazyLogging {
sender ! SetUserAsObs(getLobbyInfoResponse) sender ! SetUserAsObs(getLobbyInfoResponse)
case LeaveLobby => case LeaveLobby =>
users = users - sender users = users - sender
if(host.actorRef == sender()){ if (host.actorRef == sender()) {
users.foreach(_ ! HostLeaveLobby) users.foreach(_ ! HostLeaveLobby)
context.stop(self) context.stop(self)
}else if(secondPlayer.exists(_.actorRef == sender())){ } else if (secondPlayer.exists(_.actorRef == sender())) {
secondPlayerReady = false secondPlayerReady = false
secondPlayer = None secondPlayer = None
if(status == Draft()){ if (status == Draft() || status == SelectRace()) {
users.foreach(_ ! SecondPlayerLeaveLobby) users.foreach(_ ! SecondPlayerLeaveLobby)
context.stop(self) context.stop(self)
}else { } else {
users.foreach(_ ! RefreshLobbyInfo(getLobbyInfoResponse)) users.foreach(_ ! RefreshLobbyInfo(getLobbyInfoResponse))
} }
} }
@@ -196,21 +205,38 @@ class LobbieActor(hostUser: LobbyUser) extends Actor with LazyLogging {
private def getLobbyInfoResponse: LobbyInfo = { private def getLobbyInfoResponse: LobbyInfo = {
val user2Name = secondPlayer.map(_.name).getOrElse("") val user2Name = secondPlayer.map(_.name).getOrElse("")
LobbyInfo( LobbyInfo(
UserInfo(host.name, firstPlayerReady), UserInfo(host.name, firstPlayerReady, firstPlayerSelectedRaces),
UserInfo(user2Name, secondPlayerReady), UserInfo(user2Name, secondPlayerReady, secondPlayerSelectedRaces),
self.path.name, self.path.name,
deciderName,
status.toString(), status.toString(),
playerTurn, playerTurn,
lobbyType.toString(), lobbyType.toString(),
mapsLobby) mapsLobby)
} }
private def isFinish: Boolean = {
mapsLobby.count(_.isBanned == false) == 1 && lobbyType == Last1() ||
mapsLobby.count(_.isBanned == false) == 3 && lobbyType == Last3() ||
mapsLobby.count(_.isBanned == false) == 5 && lobbyType == Last5() ||
mapsLobby.count(_.isBanned == false) == 7 && lobbyType == Last7()
}
private def refreshAndBanMap(mapName: String): Unit = {
val senderName: String = if (sender == host.actorRef) {
host.name
} else secondPlayer.get.name
users.foreach(_ ! SendBanMapMessage(BanMapMessage(senderName, mapName)))
users.foreach(_ ! RefreshLobbyInfo(getLobbyInfoResponse))
}
} }
case class BanMap(mapName: String) case class BanMap(mapName: String)
case class MapsUpdate(maps: Set[String]) case class MapsUpdate(maps: Set[String])
case class CreateLobby(userName: String) case class CreateLobby(userName: String, deciderName: String)
case class JoinLobbyAsPlayer(lobbyUser: LobbyUser) case class JoinLobbyAsPlayer(lobbyUser: LobbyUser)
@@ -228,13 +254,18 @@ case class MessageForLobby(message: String)
case class ChangeLobbyType(lobbyType: String) case class ChangeLobbyType(lobbyType: String)
case class ChangeIsNecronSelected(isSelected: Boolean)
case object InfoQuery case object InfoQuery
case class UserInfo(name: String, isReady: Boolean) case class SelectedRaces(mainRaces: List[Int], additionalRaces: List[Int], raceTextInput: String)
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,
lobbyActorName: String, lobbyActorName: String,
deciderName: String,
status: String, status: String,
playerTurn: BigDecimal, playerTurn: BigDecimal,
selectedType: String, selectedType: String,
@@ -243,12 +274,21 @@ case class LobbyInfo(user1Info: UserInfo,
class LobbyStatus class LobbyStatus
sealed case class NotStarted() extends LobbyStatus sealed case class NotStarted() extends LobbyStatus
sealed case class SelectRace() extends LobbyStatus
sealed case class Draft() extends LobbyStatus sealed case class Draft() extends LobbyStatus
sealed case class Finish() extends LobbyStatus sealed case class Finish() extends LobbyStatus
class LobbyType class LobbyType
sealed case class Last1() extends LobbyType
sealed case class Last3() extends LobbyType sealed case class Last3() extends LobbyType
sealed case class Last4() extends LobbyType
sealed case class Last5() extends LobbyType sealed case class Last5() extends LobbyType
sealed case class LooserPick() extends LobbyType
sealed case class Last7() extends LobbyType
sealed case class Superfinal() extends LobbyType
+2 -2
View File
@@ -17,10 +17,10 @@ class LobbiesActor extends Actor with LazyLogging {
val lobbies: ListBuffer[String] = ListBuffer() val lobbies: ListBuffer[String] = ListBuffer()
def receive: Receive = LoggingReceive { def receive: Receive = LoggingReceive {
case CreateLobby(hostName) => case CreateLobby(hostName, deciderName) =>
val hostActorRef = sender val hostActorRef = sender
logger.info(s"Player ${hostActorRef.path.name} create lobby.") logger.info(s"Player ${hostActorRef.path.name} create lobby.")
val lobbyActor = context.actorOf(Props(new LobbieActor(LobbyUser(hostName, hostActorRef))), val lobbyActor = context.actorOf(Props(new LobbieActor(LobbyUser(hostName, hostActorRef), deciderName)),
s"lobbyActor-${(math.random * 100000000L).toLong}") s"lobbyActor-${(math.random * 100000000L).toLong}")
lobbyActor.tell(WatchLobby("watchIt"), hostActorRef) lobbyActor.tell(WatchLobby("watchIt"), hostActorRef)
case JoinLobbyByActorName(lobbyName, userName) => case JoinLobbyByActorName(lobbyName, userName) =>
+22 -5
View File
@@ -13,21 +13,27 @@ import scala.concurrent.duration.DurationInt
import scala.util.{Failure, Success} import scala.util.{Failure, Success}
class UserActor(out: ActorRef, class UserActor(out: ActorRef,
userParentActor: ActorRef) extends Actor with LazyLogging { userParentActor: ActorRef,
ip: String) extends Actor with LazyLogging {
implicit val timeout: Timeout = 3.seconds implicit val timeout: Timeout = 3.seconds
val ipAddress: String = ip
var lobbieActor: Option[ActorRef] = None var lobbieActor: Option[ActorRef] = None
implicit val mapJson: Writes[DeciderMap] = new Writes[DeciderMap] { implicit val mapJson: Writes[DeciderMap] = new Writes[DeciderMap] {
def writes(deciderMap: DeciderMap): JsValue = { def writes(deciderMap: DeciderMap): JsValue = {
Json.obj( Json.obj(
"map" -> deciderMap.map, "map" -> deciderMap.map,
"description" -> deciderMap.description,
"isBanned" -> deciderMap.isBanned "isBanned" -> deciderMap.isBanned
) )
} }
} }
implicit val selectedRacesWrites: OWrites[SelectedRaces] = Json.writes[SelectedRaces]
implicit val userInfoWrites: OWrites[UserInfo] = Json.writes[UserInfo] implicit val userInfoWrites: OWrites[UserInfo] = Json.writes[UserInfo]
implicit val lobbyWrites: Writes[LobbyInfo] = new Writes[LobbyInfo]{ implicit val lobbyWrites: Writes[LobbyInfo] = new Writes[LobbyInfo]{
@@ -35,6 +41,7 @@ class UserActor(out: ActorRef,
"user1Info" -> lobby.user1Info, "user1Info" -> lobby.user1Info,
"user2Info" -> lobby.user2Info, "user2Info" -> lobby.user2Info,
"lobbyActorName" -> lobby.lobbyActorName, "lobbyActorName" -> lobby.lobbyActorName,
"deciderName" -> lobby.deciderName,
"status" -> lobby.status, "status" -> lobby.status,
"playerTurn" -> lobby.playerTurn, "playerTurn" -> lobby.playerTurn,
"selectedType" -> lobby.selectedType, "selectedType" -> lobby.selectedType,
@@ -53,6 +60,7 @@ class UserActor(out: ActorRef,
"user1Info" -> lobby.user1Info, "user1Info" -> lobby.user1Info,
"user2Info" -> lobby.user2Info, "user2Info" -> lobby.user2Info,
"lobbyActorName" -> lobby.lobbyActorName, "lobbyActorName" -> lobby.lobbyActorName,
"deciderName" -> lobby.deciderName,
"status" -> lobby.status, "status" -> lobby.status,
"playerTurn" -> lobby.playerTurn, "playerTurn" -> lobby.playerTurn,
"selectedType" -> lobby.selectedType, "selectedType" -> lobby.selectedType,
@@ -127,7 +135,8 @@ class UserActor(out: ActorRef,
logger.debug(s"Set user name: $name for actor ${this.self}") logger.debug(s"Set user name: $name for actor ${this.self}")
case Some("createDecider") => case Some("createDecider") =>
LobbiesActor.actor ! CreateLobby(name) val deciderName = (json \ "deciderName").as[String]
LobbiesActor.actor ! CreateLobby(name, deciderName)
case Some("leaveDecider") => case Some("leaveDecider") =>
lobbieActor.foreach(lobby => lobby ! LeaveLobby) lobbieActor.foreach(lobby => lobby ! LeaveLobby)
@@ -163,6 +172,13 @@ class UserActor(out: ActorRef,
val map = (json \ "map").as[String] val map = (json \ "map").as[String]
lobbieActor.foreach(lobby => lobby ! BanMap(map)) lobbieActor.foreach(lobby => lobby ! BanMap(map))
case Some("selectRace") =>
logger.info("RACES: " + json.toString())
val races = (json \ "mainRaces").as[List[Int]]
val additionalRaces = (json \ "additionalRaces").as[List[Int]]
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")
(LobbiesActor.actor ? GetAllLobbies).mapTo[List[RefreshLobbyInfo]] onComplete { (LobbiesActor.actor ? GetAllLobbies).mapTo[List[RefreshLobbyInfo]] onComplete {
@@ -192,10 +208,11 @@ class UserParentActor(actorSystem: ActorSystem) extends Actor with ActorLogging
import UserParentActor._ import UserParentActor._
override def receive: Receive = LoggingReceive { override def receive: Receive = LoggingReceive {
case Create(id, out) => case Create(id, ipAddress, out) =>
val child: ActorRef = actorSystem.actorOf(Props(classOf[UserActor], out, self), s"userActor-$id") val child: ActorRef = actorSystem.actorOf(Props(classOf[UserActor], out, self, ipAddress), s"userActor-$id")
sender() ! child sender() ! child
case GetAllUsers => case GetAllUsers =>
System.out.print("12123" + context.children)
sender() ! context.children sender() ! context.children
} }
} }
@@ -203,7 +220,7 @@ class UserParentActor(actorSystem: ActorSystem) extends Actor with ActorLogging
object UserParentActor { object UserParentActor {
case class Create(id: String, out: ActorRef) case class Create(id: String, ipAddress: String, out: ActorRef)
case object GetAllUsers case object GetAllUsers
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 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: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 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: 44 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: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

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: 49 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -132,6 +132,8 @@
"6p_agamar_desert": "Agamar Desert (6)", "6p_agamar_desert": "Agamar Desert (6)",
"6p_alvarus": "Alvarus (6)", "6p_alvarus": "Alvarus (6)",
"6p_kaurav_city": "Kaurav City (6)", "6p_kaurav_city": "Kaurav City (6)",
"6p_snowblind": "Snowblind (6)",
"6p_ruined_greatway": "Ruined Greatway (6)",
"8p_forbidden_jungle": "Forbidden Jungle (8)", "8p_forbidden_jungle": "Forbidden Jungle (8)",
"8p_rhean_jungle": "Rhean Jungle (8)", "8p_rhean_jungle": "Rhean Jungle (8)",
"8p_thurabis_plateau": "ThurAbis Plateau (8)", "8p_thurabis_plateau": "ThurAbis Plateau (8)",
Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+318 -90
View File
@@ -2,7 +2,14 @@ var ws;
var userName = "" var userName = ""
window.onload = function() { var raceCount = $("#raceCount").html()
var existAdditionalRaces = $("#existAdditionalRaces").html() === "true"
var existTextFieldRaces = $("#existTextFieldRaces").html() === "true"
var isMuted = false;
var isDraftStarted = false;
window.onload = function () {
'use strict'; 'use strict';
@@ -28,26 +35,23 @@ window.onload = function() {
"click .title": "check" // Обработчик клика на кнопке "Проверить" "click .title": "check" // Обработчик клика на кнопке "Проверить"
}, },
joinAsPlayer: function () { render: function () {
this.elCount = "azaz" + getRandomInt(100000000);
this.render();
},
render: function() {
var lobbiesAsTableRows = _.reduce(this.lobbies, function (memo, lobby) { var lobbiesAsTableRows = _.reduce(this.lobbies, function (memo, lobby) {
var joinButton = (lobby.status === "NotStarted()") ? '<button class="btn btn-primary join-as-player" onclick="joinDecider(\''+lobby.lobbyActorName+'\')">' + if ($("#deciderName").html() !== lobby.deciderName) return memo;
var joinButton = (lobby.status === "NotStarted()") ? '<button class="btn btn-primary join-as-player" onclick="joinDecider(\'' + lobby.lobbyActorName + '\')">' +
'<img src="/assets/images/buttons/isAuto.png" style="height: 25px;"> Join lobby <img src="/assets/images/buttons/isAuto.png" style="height: 25px;">' + '<img src="/assets/images/buttons/isAuto.png" style="height: 25px;"> Join lobby <img src="/assets/images/buttons/isAuto.png" style="height: 25px;">' +
'</button>' : ""; '</button>' : "";
var observerButton = '<button class="btn btn-success join-as-observer" onclick="observerDecider(\''+lobby.lobbyActorName+'\')">' + var observerButton = '<button class="btn btn-success join-as-observer" onclick="observerDecider(\'' + lobby.lobbyActorName + '\')">' +
'<img src="/assets/images/buttons/Ulthwe.png" style="height: 25px;"> Observer <img src="/assets/images/buttons/Ulthwe.png" style="height: 25px;">' + '<img src="/assets/images/buttons/Ulthwe.png" style="height: 25px;"> Observer <img src="/assets/images/buttons/Ulthwe.png" style="height: 25px;">' +
'</button>'; '</button>';
return memo + '<tr>\n' + return memo + '<tr>\n' +
' <td>'+ lobby.user1Info.name.substring(0, 20) +'</td>\n' + ' <td>' + lobby.user1Info.name.substring(0, 20) + '</td>\n' +
' <td>'+ lobby.user2Info.name.substring(0, 20) +'</td>\n' + ' <td>' + lobby.user2Info.name.substring(0, 20) + '</td>\n' +
' <td>'+ lobby.status +'</td>\n' + ' <td>' + lobby.status + '</td>\n' +
' <td>' + joinButton + ' ' + observerButton + ' <td>' + joinButton + ' ' + observerButton +
' </td>' + ' </td>' +
'</tr>'; '</tr>';
@@ -74,14 +78,16 @@ window.onload = function() {
$("#lobbiesList").append(lobbyList.$el); $("#lobbiesList").append(lobbyList.$el);
lobbyList.render(); lobbyList.render();
$("#createDecider").click(function(event) { $("#createDecider").click(function (event) {
event.preventDefault(); event.preventDefault();
isObserver = false;
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "createDecider" type: "createDecider",
deciderName: $("#deciderName").html()
})); }));
}); });
$("#submitmsg").click(function(event) { $("#submitmsg").click(function (event) {
sendMessage(); sendMessage();
}); });
$("#usermsg").on('keyup', function (e) { $("#usermsg").on('keyup', function (e) {
@@ -90,8 +96,17 @@ window.onload = function() {
} }
}); });
$("#mute").click(function (event) {
if (isMuted) {
isMuted = false;
$("#mute").html("🔊");
} else {
isMuted = true;
$("#mute").html("🔈");
}
});
$("#exit").click(function(event) { $("#exit").click(function (event) {
event.preventDefault(); event.preventDefault();
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "leaveDecider" type: "leaveDecider"
@@ -101,24 +116,34 @@ window.onload = function() {
$(".navbar").show(); $(".navbar").show();
}); });
if($.cookie('user')=== undefined){ function requestName() {
var result = prompt("Enter nickname: ", ); if ($.cookie('user') === undefined || $.cookie('user') === "null" || $.cookie('user') === "") {
$.cookie('user', result, { expires: 1337 }); var result = prompt("Enter nickname: ",);
$.cookie('user', result, {expires: 1337});
userName = result; userName = result;
}else{ requestName();
} else {
userName = $.cookie('user'); userName = $.cookie('user');
} }
}
requestName();
$("#playerName").html(userName); $("#playerName").html(userName);
ws = new WebSocket($("body").data("ws-url")); ws = new WebSocket($("body").data("ws-url"));
ws.onmessage = function(event) { ws.onmessage = function (event) {
var message; var message;
message = JSON.parse(event.data); message = JSON.parse(event.data);
switch (message.type) { switch (message.type) {
case "refreshLobby": case "refreshLobby":
if (message.lobby.status === "SelectRace()" && (!isObserver)) {
isDraftStarted = false;
renderRaces();
} else {
renderMaps(message.lobby.maps, message.lobby.status); renderMaps(message.lobby.maps, message.lobby.status);
}
renderPlayersAndStats(message.lobby); renderPlayersAndStats(message.lobby);
break; break;
case "switchToLobby": case "switchToLobby":
@@ -132,7 +157,7 @@ window.onload = function() {
break; break;
case "sendMapBanMessage": case "sendMapBanMessage":
console.log(message); console.log(message);
addBanMapMessageToChat(message.message); handleMapBanEvent(message.message);
break; break;
case "lobbyError": case "lobbyError":
console.log(message); console.log(message);
@@ -146,6 +171,7 @@ window.onload = function() {
default: default:
} }
}; };
ws.onopen = function () { ws.onopen = function () {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "userName", type: "userName",
@@ -156,7 +182,7 @@ window.onload = function() {
})); }));
} }
var timerId = setInterval(() => setInterval(() =>
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "getLobbies" type: "getLobbies"
})), 2000); })), 2000);
@@ -164,9 +190,9 @@ window.onload = function() {
function changeNick() { function changeNick() {
result = prompt("Введите ник", $.cookie('user')); result = prompt("Введите ник", $.cookie('user'));
if(result == null) return; if (result == null) return;
result = result.substr(0, 20); result = result.substr(0, 20);
$.cookie('user', result, { expires: 7 }); $.cookie('user', result, {expires: 7});
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "userName", type: "userName",
name: $.cookie('user') name: $.cookie('user')
@@ -177,7 +203,6 @@ function changeNick() {
} }
// -----ф-ии прихода с сервера // -----ф-ии прихода с сервера
function switchToLobby(maps, status) { function switchToLobby(maps, status) {
@@ -199,6 +224,125 @@ function disconnectLobby(error) {
alert(error); alert(error);
} }
// ---------------Выбор рас --------------
function getRaceImageByNumber(number) {
switch (number) {
case 10:
return "/assets/images/raceIcons/cancel.png";
case 1:
return "/assets/images/raceIcons/SM.gif";
case 2:
return "/assets/images/raceIcons/Chaos.gif";
case 3:
return "/assets/images/raceIcons/Eldar.gif";
case 4:
return "/assets/images/raceIcons/Orks.gif";
case 5:
return "/assets/images/raceIcons/IG.gif";
case 6:
return "/assets/images/raceIcons/Nec.gif";
case 7:
return "/assets/images/raceIcons/Tau.gif";
case 8:
return "/assets/images/raceIcons/Sob.gif";
case 9:
return "/assets/images/raceIcons/De.gif";
}
}
function renderRaces() {
function getRaceSelectHtml(raceType, raceArray) {
var resHtml = "";
for(var raceNum = 1; raceNum <= raceCount; raceNum++){
resHtml += "<div class='col-xs-4'><center><h3>" + raceType + " race " + raceNum + "</h3>";
for (var i = 1; i <= 10; i++) {
var selected = "raceNotSelected";
if ($.cookie("race-" + raceType + "-" + raceNum) == i) {
selected = "raceSelected";
console.log("setted: " + i);
raceArray[raceNum] = i;
}
let raceIconClass = "raceIcon-" + raceNum + "-" + raceType;
resHtml += '<a href = "#" class="' + selected + ' ' + raceIconClass + '"' +
' onclick="selectRace(this, '+i+', '+raceNum+', \''+raceType+'\')"><img class="raceIcon" src="' + getRaceImageByNumber(i) + '"></a> ';
}
resHtml += "</center></div>";
}
return resHtml;
}
function renderInputField() {
var raceTextInput = ''
if($.cookie("raceTextInput") !== undefined){
raceTextInput = $.cookie("raceTextInput")
}
return '<center><input type="text" class="form-control" id="raceInput" placeholder="Black fists, Imperial angels, Blood templars" oninput="checkRaceSelectFinish()" value="'+ raceTextInput +'"/></center>';
}
var resHtml = "";
if(!existTextFieldRaces){
resHtml += getRaceSelectHtml("main", selectedRaces);
if(existAdditionalRaces) resHtml += getRaceSelectHtml("additional", selectedAdditionalRaces);
}else {
resHtml += renderInputField();
}
resHtml += "<div style='clear: both;'></div></br>";
resHtml += "<center><button id = 'selectRace' class='btn btn-primary' disabled onclick='sendSelectedRacesToServer()'>OK</button></center>"
$("#mapList").html(resHtml);
checkRaceSelectFinish();
}
var selectedRaces = [];
var selectedAdditionalRaces = [];
function selectRace(button, race, raceNum, raceType) {
if(raceType === "main"){
selectedRaces[raceNum] = race;
}else{
selectedAdditionalRaces[raceNum] = race;
}
$.cookie("race-" + raceType + "-" + raceNum, race, {expires: 1});
_.each($(".raceIcon-" + raceNum + "-" + raceType), function (el) {
$(el).removeClass("raceSelected");
$(el).addClass("raceNotSelected");
});
$(button).removeClass("raceNotSelected");
$(button).addClass("raceSelected");
checkRaceSelectFinish();
}
function checkRaceSelectFinish() {
if(selectedRaces.filter(x => x).length >= raceCount){
$("#selectRace").removeAttr("disabled");
} else if($("#raceInput").val().trim() !== ''){
$("#selectRace").removeAttr("disabled");
}
}
function sendSelectedRacesToServer() {
$.cookie('raceTextInput', $("#raceInput").val(), {expires: 1});
var selectedInput = $("#raceInput").val();
if(selectedInput === undefined){
selectedInput = ""
}
console.log(selectedRaces);
ws.send(JSON.stringify({
type: "selectRace",
mainRaces: selectedRaces.filter(x => x),
additionalRaces: selectedAdditionalRaces.filter(x => x),
raceTextInput: selectedInput
}));
$("#mapList").html("<center>Waiting another player...</center>");
}
// ---------------------------------------------
function renderMaps(maps, lobbyStatus) { function renderMaps(maps, lobbyStatus) {
var resHtml = ""; var resHtml = "";
@@ -206,23 +350,47 @@ function renderMaps(maps, lobbyStatus) {
var banHtml = ""; var banHtml = "";
var banClass = ""; var banClass = "";
if(map.isBanned){ if (map.isBanned) {
banHtml = "<div class = 'banX'>&#x2715;</div>"; banHtml = "<div class = 'banX'>&#x2715;</div>";
banClass = "bannedMap"; banClass = "bannedMap";
} }
if(lobbyStatus === "NotStarted()"){
banClass = "bannedMap"; var title = ""
if (map.description != null) {
title = "title='" + map.description + "'";
} }
resHtml = resHtml + '<div class="col-xs-4 col-md-3 col-lg-2"><button class = "mapSelect" onmousedown="banMap(\''+map.map+'\')">'+ resHtml = resHtml + '<div class="col-xs-4 col-md-3 col-lg-2"><button class = "mapSelect" onmousedown="banMap(\'' + map.map + '\')">' +
'<div class="mapImageAndText '+banClass+'">' + '<div class="mapImageAndText ' + banClass + '">' +
'<img class="img-rounded center-block" style="width:128px;" src="/assets/images/maps/'+encodeURI(map.map)+'.jpg">' + '<img class="img-rounded center-block" ' + title + ' style="width:128px;" src="/assets/images/maps/' + encodeURI(map.map) + '.jpg">' +
'</div>'+convertMapName(map.map)+ '</div>' + convertMapName(map.map) +
banHtml + '</button></div>'; banHtml + '</button></div>';
}) })
$("#mapList").html(resHtml); $("#mapList").html(resHtml);
} }
var isObserver = false;
function renderFinish(maps, firstPlayerInfo, secondPlayerInfo) {
var resHtml = "<center>";
_.each(maps, function (map) {
if (!map.isBanned) {
resHtml = resHtml + '<div class="col-xs-4 col-md-3 col-lg-2"><button class = "mapSelect" >' +
'<div class="mapImageAndText ">' +
'<img class="img-rounded center-block" style="width:128px;" src="/assets/images/maps/' + encodeURI(map.map) + '.jpg">' +
'</div>' + convertMapName(map.map) + '</button></div>';
}
})
resHtml += "<div style='clear: both;'>";
resHtml += getRenderRaces(firstPlayerInfo, secondPlayerInfo);
resHtml += "</center>";
$("#mapList").html(resHtml);
}
function renderPlayersAndStats(lobby) { function renderPlayersAndStats(lobby) {
var resHtml = "<center>"; var resHtml = "<center>";
var player1ReadyBtn = ""; var player1ReadyBtn = "";
@@ -231,30 +399,30 @@ function renderPlayersAndStats(lobby) {
var notReadyImg = "<img src='/assets/images/buttons/isNotAuto.png' class='readyImg'/>"; var notReadyImg = "<img src='/assets/images/buttons/isNotAuto.png' class='readyImg'/>";
var warningClass = (lobby.user2Info.name !== "") ? "warningButton" : ""; var warningClass = (lobby.user2Info.name !== "") ? "warningButton" : "";
if(lobby.user1Info.name === userName){ if (lobby.user1Info.name === userName) {
if(lobby.user1Info.isReady){ if (lobby.user1Info.isReady) {
player1ReadyBtn = "<button class='btn btn-default' onclick='setNotReady()'>"+readyImg+" Ready "+readyImg+"</button>" player1ReadyBtn = "<button class='btn btn-default' onclick='setNotReady()'>" + readyImg + " Ready " + readyImg + "</button>"
}else{ } else {
player1ReadyBtn = "<button class='btn btn-default "+warningClass+"' onclick='setReady()'>"+notReadyImg+" Not ready "+notReadyImg+"</button>" player1ReadyBtn = "<button class='btn btn-default " + warningClass + "' onclick='setReady()'>" + notReadyImg + " Not ready " + notReadyImg + "</button>"
} }
}else{ } else {
if(lobby.user1Info.isReady){ if (lobby.user1Info.isReady) {
player1ReadyBtn = readyImg player1ReadyBtn = readyImg
}else{ } else {
player1ReadyBtn = notReadyImg player1ReadyBtn = notReadyImg
} }
} }
if(lobby.user2Info.name === userName){ if (lobby.user2Info.name === userName) {
if(lobby.user2Info.isReady){ if (lobby.user2Info.isReady) {
player2ReadyBtn = "<button class='btn btn-default' onclick='setNotReady()'>"+readyImg+" Ready "+readyImg+"</button>" player2ReadyBtn = "<button class='btn btn-default' onclick='setNotReady()'>" + readyImg + " Ready " + readyImg + "</button>"
}else{ } else {
player2ReadyBtn = "<button class='btn btn-default "+warningClass+"' onclick='setReady()'>"+notReadyImg+" Not ready "+notReadyImg+"</button>" player2ReadyBtn = "<button class='btn btn-default " + warningClass + "' onclick='setReady()'>" + notReadyImg + " Not ready " + notReadyImg + "</button>"
} }
} else { } else {
if(lobby.user2Info.isReady){ if (lobby.user2Info.isReady) {
player2ReadyBtn = readyImg player2ReadyBtn = readyImg
}else{ } else {
player2ReadyBtn = notReadyImg player2ReadyBtn = notReadyImg
} }
} }
@@ -266,68 +434,86 @@ function renderPlayersAndStats(lobby) {
} }
var lobbyTypeText = ""; var lobbyTypeText = "";
var last1Selected = "";
var last3Selected = ""; var last3Selected = "";
var last4Selected = "";
var last5Selected = ""; var last5Selected = "";
var looserPickSelected = ""; var last7Selected = "";
var isNecronsSelected = "";
if (lobby.isNecrons) isNecronsSelected = "checked";
switch (lobby.selectedType) { switch (lobby.selectedType) {
case "Last1()":
looserPickSelected = "selected";
lobbyTypeText = "Play on last map";
break;
case "Last3()": case "Last3()":
last3Selected = "selected"; last3Selected = "selected";
lobbyTypeText = "Play on last 3 maps"; lobbyTypeText = "Play on last 3 maps";
break; break;
case "Last4()":
last4Selected = "selected";
lobbyTypeText = "Play on last 4 maps";
break;
case "Last5()": case "Last5()":
last5Selected = "selected"; last5Selected = "selected";
lobbyTypeText = "Play on last 5 maps"; lobbyTypeText = "Play on last 5 maps";
break; break;
case "LooserPick()": case "Last7()":
looserPickSelected = "selected"; last7Selected = "selected";
lobbyTypeText = "Play on last map"; lobbyTypeText = "Play on last 7 maps";
break; break;
} }
console.log(lobby.status);
switch (lobby.status) { switch (lobby.status) {
case "NotStarted()": case "NotStarted()":
resHtml = "<div style='float: left; padding-top: 10px;'> <div>" + lobby.user1Info.name + ": " + player1ReadyBtn +"</div><br/>" resHtml = "<div style='float: left; padding-top: 10px;'> <div>" + lobby.user1Info.name + ": " + player1ReadyBtn + "</div><br/>"
if(lobby.user2Info.name !== ""){ if (lobby.user2Info.name !== "") {
var kickBtn = "" var kickBtn = ""
if(lobby.user1Info.name === userName){ if (lobby.user1Info.name === userName) {
kickBtn = " - <button class='btn btn-danger' onclick='kickSecondPlayer()'>Kick</button>"; kickBtn = " - <button class='btn btn-danger' onclick='kickSecondPlayer()'>Kick</button>";
} }
resHtml += "<div>" + lobby.user2Info.name + ": " + player2ReadyBtn + kickBtn + "</div>" resHtml += "<div>" + lobby.user2Info.name + ": " + player2ReadyBtn + kickBtn + "</div>"
}else{ } else {
resHtml += "<div>waiting 2-nd player...</div>" resHtml += "<div>waiting 2-nd player...</div>"
} }
var disabledText = ""; var disabledText = "";
if(lobby.user1Info.name !== userName){ if (lobby.user1Info.name !== userName) {
disabledText = "disabled"; disabledText = "disabled";
} }
resHtml += "<br/><select class=\"form-control\" id = 'deciderOption' onChange='changeLobbyType()' "+disabledText+" >" +
"<option "+last3Selected+" value = 'last3'>Play on last 3 maps (BO3)</option>" + //TODO: here from config
"<option "+last5Selected+" value = 'last5'>Play on last 5 maps (BO5)</option>" + resHtml += "<br/><select class=\"form-control\" id = 'deciderOption' onChange='changeLobbyType()' " + disabledText + " >" +
"</select>" "<option " + last1Selected + " value = 'last1'>Play on last map</option>" +
"<option " + last3Selected + " value = 'last3'>Play on last 3 maps</option>" +
"<option " + last5Selected + " value = 'last5'>Play on last 5 maps</option>" +
"<option " + last7Selected + " value = 'last7'>Play on last 7 maps</option>" +
"</select>";
if (lobby.user1Info.name !== userName && lobby.user2Info.name !== userName) {
disabledText = "disabled";
} else {
disabledText = "";
}
resHtml += "</div>"; resHtml += "</div>";
break; break;
case "SelectRace()":
resHtml = "<center>" + lobby.user1Info.name + " vs " + lobby.user2Info.name + " - " + lobbyTypeText + "<br/> players select races</center>";
break;
case "Draft()": case "Draft()":
console.log(lobby.turn); if (!isDraftStarted) {
isDraftStarted = true;
}
var playerTurn = (lobby.playerTurn === 1) ? lobby.user1Info.name : lobby.user2Info.name var playerTurn = (lobby.playerTurn === 1) ? lobby.user1Info.name : lobby.user2Info.name
resHtml = "<center>"+lobby.user1Info.name + " vs " + lobby.user2Info.name + " - " +lobbyTypeText+ "<br/><b>" + playerTurn +"</b> turn</center>"; resHtml = "<center><br/>";
resHtml += getRenderRaces(lobby.user1Info, lobby.user2Info);
resHtml += "<br/> <b>" + playerTurn + "</b> turn" + "</center>";
break; break;
case "Finish()": case "Finish()":
var lastMaps = _.filter(lobby.maps, function (map){ renderFinish(lobby.maps, lobby.user1Info, lobby.user2Info);
return map.isBanned === false; break;
});
resHtml = "<center><b>";
_.forEach(lastMaps,function (map){
resHtml += convertMapName(map.map) + "; "
})
resHtml += "</b></center>";
} }
resHtml = resHtml + "</center>" resHtml = resHtml + "</center>"
@@ -335,40 +521,68 @@ function renderPlayersAndStats(lobby) {
$("#playersStatsList").html(resHtml); $("#playersStatsList").html(resHtml);
} }
function addMessageToChat(message){ function getRenderRaces(userInfo1, userInfo2) {
function getRacesBlock(playerRaces, name){
let resHtml = "<div style='display: inline-block; border: black 1px solid; background-color: #e3e3e3;'>" + "<h4>" + name + "</h4>";
playerRaces.mainRaces.forEach((raceId) =>
resHtml += "<img class=\"raceIcon\" src=" + getRaceImageByNumber(raceId) + "> ");
playerRaces.additionalRaces.forEach((raceId) =>
resHtml += "<img class=\"raceIcon\" src=" + getRaceImageByNumber(raceId) + "> ");
if(existTextFieldRaces){
resHtml += "<span style='margin: 10px;'>" + playerRaces.raceTextInput + "</span>";
}
resHtml += "</div>";
return resHtml;
}
let resHtml = "";
resHtml += getRacesBlock(userInfo1.selectedRaces, userInfo1.name);
resHtml += "<div style='width: 100px; font-size: 24px; display: inline-block;'><b>VS</b></div>";
resHtml += getRacesBlock(userInfo2.selectedRaces, userInfo2.name);
return resHtml;
}
function addMessageToChat(message) {
var messageHtml = "<div class='msgln'> <b>" + message.userName + "</b>: " + message.message + "<br></div>"; var messageHtml = "<div class='msgln'> <b>" + message.userName + "</b>: " + message.message + "<br></div>";
var chatBox = $("#chatbox"); var chatBox = $("#chatbox");
chatBox.append(messageHtml); chatBox.append(messageHtml);
chatBox.scrollTop(40000); chatBox.scrollTop(40000);
} }
function addBanMapMessageToChat(message){ function handleMapBanEvent(message) {
if (!isMuted) {
var audioPick = new Audio('/assets/sound/pick.mp3.mpeg');
audioPick.volume = 0.1;
audioPick.play();
}
var messageHtml = "<div class='msgln'><span style='color: #2C3D9B'>" + convertMapName(message.mapTechName) + "</span> banned by " + message.user + "<br></div>"; var messageHtml = "<div class='msgln'><span style='color: #2C3D9B'>" + convertMapName(message.mapTechName) + "</span> banned by " + message.user + "<br></div>";
var chatBox = $("#chatbox"); var chatBox = $("#chatbox");
chatBox.append(messageHtml); chatBox.append(messageHtml);
chatBox.scrollTop(40000); chatBox.scrollTop(40000);
} }
function banMap(map){ function banMap(map) {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "banMap", type: "banMap",
map: map map: map
})); }));
} }
function setReady(){ function setReady() {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "setReady" type: "setReady"
})); }));
} }
function setNotReady(){ function setNotReady() {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "setNotReady" type: "setNotReady"
})); }));
} }
function changeLobbyType(){ function changeLobbyType() {
var lobbyType = $("#deciderOption").val(); var lobbyType = $("#deciderOption").val();
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "changeLobbyType", type: "changeLobbyType",
@@ -376,25 +590,35 @@ function changeLobbyType(){
})); }));
} }
function joinDecider(actorName){ function changeIsNecronSelected() {
var isNecronSelected = $("#isNecron").is(':checked');
ws.send(JSON.stringify({
type: "changeIsNecronSelected",
isNecronSelected: isNecronSelected
}));
}
function joinDecider(actorName) {
isObserver = false;
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "joinDecider", type: "joinDecider",
lobbyActorName: actorName lobbyActorName: actorName
})); }));
} }
function observerDecider(actorName){ function observerDecider(actorName) {
isObserver = true;
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "observerDecider", type: "observerDecider",
lobbyActorName: actorName lobbyActorName: actorName
})); }));
} }
function sendMessage(){ function sendMessage() {
var userInput = $("#usermsg"); var userInput = $("#usermsg");
var message = userInput.val(); var message = userInput.val();
userInput.val(""); userInput.val("");
if(message !== ""){ if (message !== "") {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "sendMessage", type: "sendMessage",
message: message message: message
@@ -402,13 +626,17 @@ function sendMessage(){
} }
} }
function kickSecondPlayer(){ function kickSecondPlayer() {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "kickSecondPlayer" type: "kickSecondPlayer"
})); }));
} }
function convertMapName (techMapName) { function convertMapName(techMapName) {
var mapName = techMapName.replace("2p_", "").replaceAll("_", " ") + " (2)"; var mapPlayerSize = techMapName.charAt(0);
var mapName = techMapName.replace(mapPlayerSize + "p_", "").replaceAll("_", " ") +
" (" + mapPlayerSize + ")";
return mapName.charAt(0).toUpperCase() + mapName.slice(1); return mapName.charAt(0).toUpperCase() + mapName.slice(1);
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+40
View File
@@ -20,6 +20,13 @@
-webkit-filter: grayscale(1); /* Webkit браузеры */ -webkit-filter: grayscale(1); /* Webkit браузеры */
} }
.bannedRace {
height: 96px;
background-color: red;
clip-path: polygon(20% 0%, 0% 20%, 30% 50%, 0% 80%, 20% 100%, 50% 70%, 80% 100%, 100% 80%, 70% 50%, 100% 20%, 80% 0%, 50% 30%);
border: 2px solid red;
}
.mapSelect{ .mapSelect{
margin: 4px; margin: 4px;
position:relative; position:relative;
@@ -29,6 +36,39 @@
overflow: hidden; overflow: hidden;
} }
.raceIcon{
width: 60px;
border-radius: 5px;
margin: 3px;
border: 1px solid;
}
.raceNotSelected{
-webkit-filter: grayscale(1); /* Webkit браузеры */
}
.raceNotSelected:hover{
-webkit-filter: grayscale(0); /* Webkit браузеры */
}
.raceSelected{
-webkit-filter: brightness(1.3);
}
.tooltip span{
border-radius: 5px 5px 5px 5px;
visibility: hidden;
position: absolute;
left: 200px;
background: #fff;
box-shadow: -2px 2px 10px -1px #333;
border-radius: 2px;
}
.tooltip:hover span{
visibility: visible;
}
@keyframes glowing { @keyframes glowing {
0% { 0% {
box-shadow: 0 0 2px #074673; box-shadow: 0 0 2px #074673;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+239 -25
View File
@@ -1,47 +1,261 @@
package controllers package controllers
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.{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) 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")
// Use a direct reference to SLF4J // ---------- Ownership ----------
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.HomeController")
// Home page that renders template private def currentSteamId[A](request: Request[A]): String =
def viewAllLobbies() = Action { implicit request => SecuredActions.steamIdOrUnknown(request)
logger.info(s"Received request from: ${request.remoteAddress}")
Ok(views.html.admin()) /** Редактировать и удалять десайдера может только его владелец. */
private def owns[A](decider: repository.Decider, request: Request[A]): Boolean =
decider.ownerSteamId.contains(currentSteamId(request))
private def deciderJson(d: repository.Decider): JsObject = Json.obj(
"key" -> d.key,
"name" -> d.name,
"rules" -> d.rules,
"raceCount" -> d.raceCount,
"existAdditionalRaces" -> d.existAdditionalRaces,
"existTextFieldRaces" -> d.existTextFieldRaces,
"description" -> d.description,
"ownerSteamId" -> d.ownerSteamId,
"maps" -> d.maps.map(m => Json.obj("map" -> m.map, "description" -> m.description))
)
// ---------- Steam OpenID ----------
private val SteamLoginUrl = "https://steamcommunity.com/openid/login"
// ---------- Public endpoints ----------
def login() = Action { implicit request =>
val realm = request.headers.get("Host").map(h => s"http://$h/").getOrElse("http://localhost:1337/")
val returnTo = controllers.routes.AdminController.steamCallback().absoluteURL()
val params = Map(
"openid.ns" -> "http://specs.openid.net/auth/2.0",
"openid.mode" -> "checkid_setup",
"openid.return_to" -> returnTo,
"openid.realm" -> realm,
"openid.identity" -> "http://specs.openid.net/auth/2.0/identifier_select",
"openid.claimed_id" -> "http://specs.openid.net/auth/2.0/identifier_select"
)
val query = params.map { case (k, v) => s"${java.net.URLEncoder.encode(k, "UTF-8")}=${java.net.URLEncoder.encode(v, "UTF-8")}" }.mkString("&")
Redirect(s"$SteamLoginUrl?$query")
} }
def banLobby(lobbyActorName: String): Unit = { 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"))
} }
def reloadConfig()= Action { implicit request => case (Some("id_res"), _) =>
ConfigFactory.invalidateCaches() Future.successful(Redirect("/decider/admin").flashing("error" -> "Steam authentication failed"))
logger.info("Config reloaded")
case _ =>
Future.successful(Redirect("/decider/admin").flashing("error" -> "Invalid OpenID response"))
}
}
private def isSteamId(id: String): Boolean =
id.startsWith("https://steamcommunity.com/openid/id/") ||
id.startsWith("http://steamcommunity.com/openid/id/")
private def parseSteamId64(id: String): String =
id.replaceAll("""(https?://steamcommunity\.com/openid/id/)""", "")
// ---------- Admin page ----------
def index() = Secured { implicit request =>
implicit val messages: Messages = messagesApi.preferred(request)
Ok(views.html.admin(DeciderRepository.all.filter(d => owns(d, request))))
}
// ---------- API: list map images ----------
def mapImages() = Secured { implicit request =>
val images = repository.MapImageRepository.all.map { image =>
Json.obj(
"name" -> image.name,
"file" -> image.file,
"players" -> image.players
)
}
Ok(Json.toJson(images))
}
// ---------- API: upload map image ----------
def mapImageUpload() = Secured(parse.multipartFormData) { implicit request =>
request.body.file("file") match {
case Some(part) =>
repository.MapImageRepository.saveUploaded(part.filename, part.ref.path.toFile) match {
case Right(image) =>
logger.info(s"Admin ${SecuredActions.steamIdOrUnknown(request)} uploaded map image '${image.file}'")
Ok(Json.obj(
"ok" -> true,
"name" -> image.name,
"file" -> image.file,
"players" -> image.players
))
case Left(error) =>
BadRequest(Json.obj("error" -> error))
}
case None =>
BadRequest(Json.obj("error" -> "No file uploaded"))
}
}
// ---------- API: list all deciders ----------
def decidersList() = Secured { implicit request =>
val list = DeciderRepository.all.filter(d => owns(d, request)).map(deciderJson)
Ok(Json.toJson(list))
}
// ---------- API: get one decider ----------
def deciderGet(key: String) = Secured { implicit request =>
DeciderRepository.get(key) match {
case Some(d) if owns(d, request) =>
Ok(deciderJson(d))
case Some(_) =>
Forbidden(Json.obj("error" -> "You don't own this decider"))
case None =>
NotFound(Json.obj("error" -> s"Decider $key not found"))
}
}
// ---------- API: save / update decider ----------
def deciderSave() = SecuredJson { implicit request =>
val json = request.body
val steamId = currentSteamId(request)
(json \ "key").asOpt[String] match {
case None =>
BadRequest(Json.obj("error" -> "Missing key"))
case Some(key) =>
val existing = DeciderRepository.get(key)
if (existing.exists(d => !owns(d, request))) {
Forbidden(Json.obj("error" -> "You don't own this decider"))
} else {
val name = (json \ "name").asOpt[String].map(_.trim).getOrElse("")
val duplicateName = existing.isEmpty && name.nonEmpty &&
DeciderRepository.all.exists(_.name.trim.equalsIgnoreCase(name))
if (duplicateName) {
Conflict(Json.obj("error" -> s"A decider named '$name' already exists"))
} else {
Try {
val maps = (json \ "maps").as[List[JsValue]].map { m =>
repository.DeciderMapConfig(
map = (m \ "map").as[String],
description = (m \ "description").asOpt[String]
)
}
repository.Decider(
key = key,
name = (json \ "name").as[String],
rules = (json \ "rules").as[String],
raceCount = (json \ "raceCount").as[Int],
existAdditionalRaces = (json \ "existAdditionalRaces").as[Boolean],
existTextFieldRaces = (json \ "existTextFieldRaces").as[Boolean],
description = (json \ "description").as[Boolean],
maps = maps,
// Владельца из запроса игнорируем: он либо уже назначен, либо
// им становится автор нового десайдера.
ownerSteamId = existing.flatMap(_.ownerSteamId).orElse(Some(steamId))
)
}.toEither match {
case Right(decider) =>
DeciderRepository.save(decider)
DeciderRepository.reload()
val action = if (existing.isDefined) "updated" else "created"
logger.info(
s"Admin $steamId $action decider '${decider.key}' " +
s"(name='${decider.name}', maps=${decider.maps.size}, " +
s"owner=${decider.ownerSteamId.getOrElse("-")})"
)
Ok(Json.obj("ok" -> true))
case Left(error) =>
BadRequest(Json.obj("error" -> s"$error"))
}
}
}
}
}
// ---------- API: delete decider ----------
def deciderDelete(key: String) = SecuredApi { implicit request =>
DeciderRepository.get(key) match {
case Some(d) if !owns(d, request) =>
Forbidden(Json.obj("error" -> "You don't own this decider"))
case _ =>
if (DeciderRepository.delete(key)) {
DeciderRepository.reload()
logger.info(s"Admin ${currentSteamId(request)} deleted decider '$key'")
Ok(Json.obj("ok" -> true))
} else {
NotFound(Json.obj("error" -> s"Decider $key not found"))
}
}
}
// ---------- Legacy endpoints (deprecated) ----------
@deprecated("unused", "1.0")
def viewAllUsers() = Action { implicit request =>
logger.info("viewAllUsers")
Ok("viewAllUsers")
}
@deprecated("unused", "1.0")
def reloadConfig() = Action { implicit request =>
Ok("Config reloaded") Ok("Config reloaded")
} }
} }
+22 -6
View File
@@ -9,12 +9,18 @@ import akka.pattern.ask
import akka.stream._ import akka.stream._
import akka.stream.scaladsl._ import akka.stream.scaladsl._
import akka.util.Timeout import akka.util.Timeout
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}
import scala.util.Try
case class LastMapsSelectConfig(last1: Boolean, last3: Boolean, last5: Boolean, last7: Boolean)
case class RaceSelect(select: Boolean, showAtStart: Boolean)
/** /**
* This class creates the actions and the websocket needed. * This class creates the actions and the websocket needed.
@@ -25,6 +31,8 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
implicit val actorSystem: ActorSystem = ActorSystem() implicit val actorSystem: ActorSystem = ActorSystem()
implicit val ec: ExecutionContext = defaultExecutionContext implicit val ec: ExecutionContext = defaultExecutionContext
val config = ConfigFactory.load()
// Use a direct reference to SLF4J // Use a direct reference to SLF4J
private val logger = org.slf4j.LoggerFactory.getLogger("controllers.HomeController") private val logger = org.slf4j.LoggerFactory.getLogger("controllers.HomeController")
@@ -32,9 +40,17 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
val userParentActor: ActorRef = actorSystem.actorOf(Props(classOf[UserParentActor], actorSystem)) val userParentActor: ActorRef = actorSystem.actorOf(Props(classOf[UserParentActor], actorSystem))
// Home page that renders template // Home page that renders template
def index() = 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}")
Ok(views.html.index()) 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, d.name, d.raceCount, d.rules, raceSelect, lastmapsSettings, d.existAdditionalRaces, d.existTextFieldRaces))
}
} }
/** /**
@@ -89,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("139.59.210.74") || origin.contains("localhost") || origin.contains("localhost:9000") || origin.contains("localhost:19001") config.getStringList("play.filters.hosts.allowed").contains(origin.replace("http://","").replace("https://",""))
} }
/** /**
@@ -100,7 +116,7 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
val (webSocketOut: ActorRef, webSocketIn: Publisher[JsValue]) = createWebSocketConnections() val (webSocketOut: ActorRef, webSocketIn: Publisher[JsValue]) = createWebSocketConnections()
// Create a user actor off the request id and attach it to the source // Create a user actor off the request id and attach it to the source
val userActorFuture = createUserActor(request.id.toString, webSocketOut) val userActorFuture = createUserActor(request.id.toString, request.remoteAddress, webSocketOut)
// Once we have an actor available, create a flow... // Once we have an actor available, create a flow...
userActorFuture.map { userActor => userActorFuture.map { userActor =>
@@ -178,11 +194,11 @@ class HomeController @Inject()(cc: ControllerComponents) extends AbstractControl
* @param webSocketOut the "write" side of the websocket, that the user actor sends JsValue to. * @param webSocketOut the "write" side of the websocket, that the user actor sends JsValue to.
* @return a user actor for this ws connection. * @return a user actor for this ws connection.
*/ */
private def createUserActor(name: String, webSocketOut: ActorRef): Future[ActorRef] = { private def createUserActor(name: String, remoteAddress: String, webSocketOut: ActorRef): Future[ActorRef] = {
// Use guice assisted injection to instantiate and configure the child actor. // Use guice assisted injection to instantiate and configure the child actor.
val userActorFuture = { val userActorFuture = {
implicit val timeout = Timeout(100.millis) implicit val timeout = Timeout(100.millis)
(userParentActor ? UserParentActor.Create(name, webSocketOut)).mapTo[ActorRef] (userParentActor ? UserParentActor.Create(name, remoteAddress, webSocketOut)).mapTo[ActorRef]
} }
userActorFuture userActorFuture
} }
+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>
+16 -12
View File
@@ -1,19 +1,27 @@
@()(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="raceCount" style="display: none">@raceCount</span>
<span id="existAdditionalRaces" style="display: none">@existAdditionalRaces</span>
<span id="existTextFieldRaces" style="display: none">@existTextFieldRaces</span>
<span id="last1Presence" style="display: none">@boSettings.last1</span>
<span id="last3Presence" style="display: none">@boSettings.last3</span>
<span id="last5Presence" style="display: none">@boSettings.last5</span>
<span id="last7Presence" style="display: none">@boSettings.last7</span>
<html> <html>
<head> <head>
<title>Soulstorm tournament decider</title> <title>Soulstorm tournament decider</title>
<link rel='stylesheet' href='@routes.Assets.at("lib/bootstrap/css/bootstrap.min.css")'> <link rel='stylesheet' href='@routes.Assets.at("lib/bootstrap/css/bootstrap.min.css")'>
<link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css")"> <link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css?080422")">
<link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")"> <link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")">
<script type='text/javascript' src='@routes.Assets.at("lib/jquery/jquery.min.js")'></script> <script type='text/javascript' src='@routes.Assets.at("lib/jquery/jquery.min.js")'></script>
<script type='text/javascript' src='@routes.Assets.at("lib/flot/jquery.flot.js")'></script> <script type='text/javascript' src='@routes.Assets.at("lib/flot/jquery.flot.js")'></script>
<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?14122025")'></script>
<script type='text/javascript' src='@routes.Assets.at("javascripts/index.js?030721")'></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">
@@ -37,6 +45,7 @@
<div id = "decider" style="display:none"> <div id = "decider" style="display:none">
<button id = "exit" class="btn btn-primary">Exit</button> <button id = "exit" class="btn btn-primary">Exit</button>
<button id = "mute" class="btn btn-primary">🔊</button>
<br/> <br/>
<div id = "mapList" class="container"></div> <div id = "mapList" class="container"></div>
<div id = "playersStatsList"></div> <div id = "playersStatsList"></div>
@@ -51,14 +60,9 @@
</div> </div>
</div> </div>
<div style="clear: both"></div> <div style="clear: both"></div>
<div><i><b>Правила турнира Big Boss of Summer 2021</b><br/> <div><i><b>Правила турнира "@deciderHumanName"</b><br/>
<p>Выбор карт в БО3 и БО5 осуществляется без лузерпиков, с тремя и пятью десайдерами соответственно. <p style="white-space: pre-wrap">@rules</p>
Игроки вычеркивают по очереди карты из маппула, пока их не останется 3 (для БО3) или 5 (для БО5). <br>
На этих картах и проходят все матчи встречи. Затем игроки вычеркивают выбранные карты, пока не останется только одна, на которой и играется первый матч встречи.
Тот, кто первым начал вычеркивать из всего маппула, уступает оппоненту право вычеркивания первой карты из оставшихся карт-десайдеров.
Во втором и последующем матчах проигравший выбирает карту из числа выбранных 3 (5) десайдеров.</p>
<p>В суперфинале турнира оба игрока вычеркивают по две карты. После этого из оставшихся 9 карт можно брать любые, выбирает их проигравший в предыдущем матче.
Первый лузер-пик - за игроком из нижней сетки, который начинает с -1 очком.</p>
</i> </i>
</div> </div>
+6 -3
View File
@@ -4,8 +4,9 @@ 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
// scalaz-bintray resolver needed for specs2 library // scalaz-bintray resolver needed for specs2 library
resolvers += "scalaz-bintray" at "https://dl.bintray.com/scalaz/releases" resolvers += "scalaz-bintray" at "https://dl.bintray.com/scalaz/releases"
@@ -18,11 +19,13 @@ libraryDependencies += "org.webjars" % "jquery-cookie" % "1.4.1-1"
libraryDependencies += "org.webjars.npm" % "underscore" % "1.11.0" libraryDependencies += "org.webjars.npm" % "underscore" % "1.11.0"
libraryDependencies += "org.webjars" % "backbonejs" % "1.3.3" 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
+1364 -4
View File
File diff suppressed because it is too large Load Diff
+1604
View File
File diff suppressed because it is too large Load Diff
+32 -25
View File
@@ -1,41 +1,48 @@
<!--
Copyright (C) Lightbend Inc. <https://www.lightbend.com>
-->
<!-- The default logback configuration that Play uses if no other configuration is provided -->
<configuration> <configuration>
<conversionRule conversionWord="coloredLevel" converterClass="play.api.libs.logback.ColoredLevel"/> <conversionRule conversionWord="coloredLevel" converterClass="play.api.libs.logback.ColoredLevel" />
<property name="LOG_FILE" value="logs\decider-log" />
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- daily rollover -->
<fileNamePattern>${LOG_FILE}.%d{yyyy-MM-dd}.gz</fileNamePattern>
<!-- keep 30 days' worth of history capped at 3GB total size -->
<maxHistory>30</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%-4relative [%thread] %-5level %logger{35} - %msg%n</pattern>
</encoder>
</appender>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder> <encoder>
<pattern>%coloredLevel %logger{15} - [%marker] %message%n%xException{10}</pattern> <pattern>%coloredLevel %logger{15} - %message%n%xException{10}</pattern>
</encoder> </encoder>
</appender> </appender>
<logger name="play" level="INFO"/> <appender name="ASYNCSTDOUT" class="ch.qos.logback.classic.AsyncAppender">
<!-- increases the default queue size -->
<queueSize>512</queueSize>
<!-- don't discard messages -->
<discardingThreshold>0</discardingThreshold>
<!-- block when queue is full -->
<neverBlock>false</neverBlock>
<appender-ref ref="STDOUT" />
</appender>
<!-- actors logging --> <logger name="play" level="INFO" />
<logger name="akka" level="DEBUG"/>
<logger name="actors" level="DEBUG"/> <logger name="com.gargoylesoftware.htmlunit.javascript" level="OFF" />
<!-- controllers --> <appender name="FILE" class="ch.qos.logback.core.FileAppender">
<logger name="controllers" level="DEBUG"/> <file>${application.home:-.}/logs/application.log</file>
<encoder>
<pattern>%date [%level] from %logger in %thread - %message%n%xException</pattern>
</encoder>
</appender>
<appender name="ASYNCFILE" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE" />
</appender>
<root level="INFO"> <root level="INFO">
<appender-ref ref="STDOUT"/> <appender-ref ref="ASYNCFILE" />
<appender-ref ref="FILE"/> <appender-ref ref="ASYNCSTDOUT" />
</root> </root>
<shutdownHook class="ch.qos.logback.core.hook.DelayingShutdownHook"/>
</configuration> </configuration>
+19 -6
View File
@@ -2,15 +2,28 @@
# This file defines all application routes (Higher priority routes first) # This file defines all application routes (Higher priority routes first)
# ~~~~ # ~~~~
GET /meatgrinder controllers.HomeController.index
GET /bigbossofsummer controllers.HomeController.index
GET /dvijcup controllers.HomeController.index
GET /ws controllers.HomeController.ws GET /ws controllers.HomeController.ws
GET /reloadconfig controllers.AdminController.reloadConfig # 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()
GET /lobbyadmin controllers.AdminController.viewAllLobbies # 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"