Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0ec7bbedb | ||
|
|
0212f8b6d0 | ||
|
|
3cad3aec4b | ||
|
|
d1dd478c43 | ||
|
|
1a8916be22 | ||
|
|
d2af1bc797 | ||
|
|
495317016d | ||
|
|
7399322f74 | ||
|
|
ed01c92570 | ||
|
|
bda5a53bca | ||
|
|
b0dfc5b67a | ||
|
|
ecc69cd7d3 | ||
|
|
31ce4b7beb | ||
|
|
dc228d82be | ||
|
|
750ea483b6 | ||
|
|
d3e90fb01e | ||
|
|
aa692419cf | ||
|
|
52baef061b | ||
|
|
e71480c1f7 |
@@ -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 адрес хостинга или домен.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 831 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -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)",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 565 KiB |
|
After Width: | Height: | Size: 565 KiB |
|
After Width: | Height: | Size: 565 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 60 KiB |
@@ -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'>✕</div>";
|
banHtml = "<div class = 'banX'>✕</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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.{Inject, Singleton}
|
||||||
|
|
||||||
|
import play.api.mvc.{AbstractController, ControllerComponents}
|
||||||
|
import repository.MapImageRepository
|
||||||
|
|
||||||
|
import scala.concurrent.ExecutionContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Раздаёт картинки карт из каталога, заданного в конфиге (`maps.imagesDir`).
|
||||||
|
*
|
||||||
|
* Нужен потому, что каталог может находиться вне `public`/`app/assets`
|
||||||
|
* (например, на сервере), и стандартный Assets его не отдаст.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class MapImageController @Inject()(cc: ControllerComponents) extends AbstractController(cc) {
|
||||||
|
|
||||||
|
implicit val ec: ExecutionContext = cc.executionContext
|
||||||
|
|
||||||
|
def image(file: String) = Action { implicit request =>
|
||||||
|
MapImageRepository.findFile(file) match {
|
||||||
|
case Some(image) =>
|
||||||
|
Ok.sendFile(image, inline = true)
|
||||||
|
.as(contentType(image))
|
||||||
|
.withHeaders(CACHE_CONTROL -> "public, max-age=86400")
|
||||||
|
case None => NotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def contentType(file: File): String = {
|
||||||
|
val name = file.getName.toLowerCase
|
||||||
|
if (name.endsWith(".png")) "image/png"
|
||||||
|
else if (name.endsWith(".webp")) "image/webp"
|
||||||
|
else "image/jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import play.api.libs.json.{JsValue, Json}
|
||||||
|
import play.api.libs.typedmap.TypedKey
|
||||||
|
import play.api.mvc._
|
||||||
|
|
||||||
|
import scala.concurrent.{ExecutionContext, Future}
|
||||||
|
|
||||||
|
object SecuredActions {
|
||||||
|
|
||||||
|
private val SteamIdAttr: TypedKey[String] = TypedKey("steamId")
|
||||||
|
|
||||||
|
/** Steam ID текущего авторизованного пользователя (если он есть в запросе). */
|
||||||
|
def steamId[A](request: Request[A]): Option[String] = request.attrs.get(SteamIdAttr)
|
||||||
|
|
||||||
|
/** Steam ID текущего пользователя или "unknown", если его нет в запросе. */
|
||||||
|
def steamIdOrUnknown[A](request: Request[A]): String = steamId(request).getOrElse("unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple session-based authentication via Steam ID.
|
||||||
|
* Used to protect admin panel routes.
|
||||||
|
*/
|
||||||
|
trait SecuredActions {
|
||||||
|
|
||||||
|
self: AbstractController =>
|
||||||
|
|
||||||
|
private val SteamIdAttr: TypedKey[String] = SecuredActions.SteamIdAttr
|
||||||
|
|
||||||
|
/** Wrapper that requires the user to be logged in with Steam. */
|
||||||
|
def Secured: ActionBuilder[Request, AnyContent] =
|
||||||
|
securedWith(controllerComponents.parsers.defaultBodyParser, apiStyle = false)
|
||||||
|
|
||||||
|
/** Same as [[Secured]], but parses the request body as JSON. */
|
||||||
|
def SecuredJson: ActionBuilder[Request, JsValue] =
|
||||||
|
securedWith(controllerComponents.parsers.json, apiStyle = true)
|
||||||
|
|
||||||
|
/** Same as [[Secured]], but answers AJAX calls with 401 JSON instead of a redirect. */
|
||||||
|
def SecuredApi: ActionBuilder[Request, AnyContent] =
|
||||||
|
securedWith(controllerComponents.parsers.defaultBodyParser, apiStyle = true)
|
||||||
|
|
||||||
|
private def securedWith[A](bodyParser: BodyParser[A], apiStyle: Boolean): ActionBuilder[Request, A] =
|
||||||
|
new ActionBuilder[Request, A] {
|
||||||
|
|
||||||
|
override def parser: BodyParser[A] = bodyParser
|
||||||
|
|
||||||
|
override def executionContext: ExecutionContext = controllerComponents.executionContext
|
||||||
|
|
||||||
|
override def invokeBlock[B](request: Request[B], block: Request[B] => Future[Result]): Future[Result] =
|
||||||
|
request.session.get("steamId") match {
|
||||||
|
case Some(steamId) =>
|
||||||
|
block(request.addAttr(SteamIdAttr, steamId))
|
||||||
|
case None if apiStyle =>
|
||||||
|
Future.successful(Unauthorized(Json.obj("error" -> "Please log in with Steam")))
|
||||||
|
case None =>
|
||||||
|
Future.successful(Redirect("/decider/admin/login").flashing("error" -> "Please log in with Steam"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import com.typesafe.config.{Config, ConfigFactory}
|
||||||
|
|
||||||
|
import java.io.{File, PrintWriter}
|
||||||
|
import scala.collection.JavaConverters._
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Описание одной карты в маппуле десайдера. */
|
||||||
|
case class DeciderMapConfig(map: String, description: Option[String] = None)
|
||||||
|
|
||||||
|
/** Полное описание десайдера (турнира). */
|
||||||
|
case class Decider(
|
||||||
|
key: String,
|
||||||
|
name: String,
|
||||||
|
rules: String,
|
||||||
|
raceCount: Int,
|
||||||
|
existAdditionalRaces: Boolean,
|
||||||
|
existTextFieldRaces: Boolean,
|
||||||
|
description: Boolean,
|
||||||
|
maps: List[DeciderMapConfig],
|
||||||
|
/** Steam ID владельца: только он может редактировать и удалять десайдера. */
|
||||||
|
ownerSteamId: Option[String] = None
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чтение и запись десайдеров.
|
||||||
|
*
|
||||||
|
* Десайдеры хранятся во внешнем HOCON-файле (admin.decidersFile, по умолчанию
|
||||||
|
* "conf/deciders.conf"). Пока этот файл не существует, десайдеры читаются из
|
||||||
|
* application.conf, поэтому приложение работает "из коробки". Как только админ
|
||||||
|
* что-то сохраняет через админку, внешний файл становится единственным источником
|
||||||
|
* правды.
|
||||||
|
*/
|
||||||
|
object DeciderRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Владелец по умолчанию. Записи без ownerSteamId (например, из старого
|
||||||
|
* application.conf) считаются принадлежащими этому Steam ID.
|
||||||
|
*/
|
||||||
|
val DefaultOwnerSteamId: String = "76561198137977374"
|
||||||
|
|
||||||
|
private def appConfig: Config = ConfigFactory.load()
|
||||||
|
|
||||||
|
/** Файл, в который админка пишет десайдеров. */
|
||||||
|
def file: File = {
|
||||||
|
val path = Try(appConfig.getString("admin.decidersFile")).getOrElse("conf/deciders.conf")
|
||||||
|
new File(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Десайдеры как объект Config (содержимое блока `deciders { ... }`). */
|
||||||
|
def decidersConfig: Config = {
|
||||||
|
val f = file
|
||||||
|
if (f.isFile) {
|
||||||
|
Try(ConfigFactory.parseFile(f)).toOption
|
||||||
|
.filter(_.hasPath("deciders"))
|
||||||
|
.map(_.getConfig("deciders"))
|
||||||
|
.getOrElse(fallback)
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Переинициализировать кеш конфигуратора (вызывать после сохранения). */
|
||||||
|
def reload(): Unit = {
|
||||||
|
ConfigFactory.invalidateCaches()
|
||||||
|
}
|
||||||
|
|
||||||
|
private def fallback: Config =
|
||||||
|
if (appConfig.hasPath("deciders")) appConfig.getConfig("deciders") else ConfigFactory.empty()
|
||||||
|
|
||||||
|
/** Ключи всех десайдеров (отсортированные). */
|
||||||
|
def keys: Seq[String] = decidersConfig.root().keySet().asScala.toSeq.sorted
|
||||||
|
|
||||||
|
/** Один десайдер по ключу. */
|
||||||
|
def get(key: String): Option[Decider] = {
|
||||||
|
val c = decidersConfig
|
||||||
|
if (!c.hasPath(key)) None
|
||||||
|
else {
|
||||||
|
val d = c.getConfig(key)
|
||||||
|
val maps = d.getList("maps").asScala.toList.map { value =>
|
||||||
|
value.unwrapped() match {
|
||||||
|
case list: java.util.List[_] =>
|
||||||
|
val parts = list.asScala.map(_.toString).toList
|
||||||
|
DeciderMapConfig(parts.head, parts.lift(1).filter(_.nonEmpty))
|
||||||
|
case other =>
|
||||||
|
// Некоторые десайдеры заданы просто списком строк без описаний.
|
||||||
|
DeciderMapConfig(other.toString, None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Decider(
|
||||||
|
key = key,
|
||||||
|
name = Try(d.getString("name")).getOrElse(key),
|
||||||
|
rules = Try(d.getString("rules")).getOrElse(""),
|
||||||
|
raceCount = Try(d.getInt("raceCount")).getOrElse(1),
|
||||||
|
existAdditionalRaces = Try(d.getBoolean("existAdditionalRaces")).getOrElse(false),
|
||||||
|
existTextFieldRaces = Try(d.getBoolean("existTextFieldRaces")).getOrElse(false),
|
||||||
|
description = Try(d.getBoolean("description")).getOrElse(false),
|
||||||
|
maps = maps,
|
||||||
|
ownerSteamId = Try(d.getString("ownerSteamId")).toOption
|
||||||
|
.map(_.trim)
|
||||||
|
.filter(_.nonEmpty)
|
||||||
|
.orElse(Some(DefaultOwnerSteamId))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все десайдеры. */
|
||||||
|
def all: Seq[Decider] = keys.flatMap(get)
|
||||||
|
|
||||||
|
/** Создаёт или обновляет десайдера и перезаписывает весь файл десайдеров. */
|
||||||
|
def save(decider: Decider): Unit = {
|
||||||
|
val updated = (all.filterNot(_.key == decider.key) :+ decider).sortBy(_.key)
|
||||||
|
write(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Удаляет десайдера. Возвращает true, если десайдер существовал. */
|
||||||
|
def delete(key: String): Boolean = {
|
||||||
|
val current = all
|
||||||
|
if (current.exists(_.key == key)) {
|
||||||
|
write(current.filterNot(_.key == key))
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def write(deciders: Seq[Decider]): Unit = {
|
||||||
|
val sb = new StringBuilder
|
||||||
|
sb.append("# Конфигурация десайдеров.\n")
|
||||||
|
sb.append("# Файл управляется админкой (авторизация через Steam).\n")
|
||||||
|
sb.append("# Ручные правки могут быть перезаписаны при следующем сохранении.\n\n")
|
||||||
|
sb.append("deciders {\n")
|
||||||
|
deciders.foreach { d =>
|
||||||
|
sb.append(" ").append(quoteKey(d.key)).append(" {\n")
|
||||||
|
sb.append(" name = ").append(quote(d.name)).append('\n')
|
||||||
|
sb.append(" ownerSteamId = ")
|
||||||
|
.append(quote(d.ownerSteamId.getOrElse(DefaultOwnerSteamId)))
|
||||||
|
.append('\n')
|
||||||
|
sb.append(" raceCount = ").append(d.raceCount).append('\n')
|
||||||
|
if (d.existAdditionalRaces) sb.append(" existAdditionalRaces = true\n")
|
||||||
|
if (d.existTextFieldRaces) sb.append(" existTextFieldRaces = true\n")
|
||||||
|
if (d.description) sb.append(" description = true\n")
|
||||||
|
if (d.maps.isEmpty) {
|
||||||
|
sb.append(" maps = []\n")
|
||||||
|
} else {
|
||||||
|
sb.append(" maps = [\n")
|
||||||
|
d.maps.zipWithIndex.foreach { case (m, i) =>
|
||||||
|
sb.append(" ").append(renderMap(m))
|
||||||
|
if (i < d.maps.size - 1) sb.append(',')
|
||||||
|
sb.append('\n')
|
||||||
|
}
|
||||||
|
sb.append(" ]\n")
|
||||||
|
}
|
||||||
|
sb.append(" rules = ").append(quote(d.rules)).append('\n')
|
||||||
|
sb.append(" }\n")
|
||||||
|
}
|
||||||
|
sb.append("}\n")
|
||||||
|
|
||||||
|
val f = file
|
||||||
|
Option(f.getParentFile).foreach(_.mkdirs())
|
||||||
|
val writer = new PrintWriter(f, "UTF-8")
|
||||||
|
try writer.write(sb.toString) finally writer.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
private def renderMap(m: DeciderMapConfig): String =
|
||||||
|
m.description.filter(_.nonEmpty) match {
|
||||||
|
case Some(desc) => s"[${quote(m.map)}, ${quote(desc)}]"
|
||||||
|
case None => s"[${quote(m.map)}]"
|
||||||
|
}
|
||||||
|
|
||||||
|
private def quoteKey(key: String): String =
|
||||||
|
if (key.matches("[A-Za-z0-9_-]+") && !key.forall(_.isDigit)) key else quote(key)
|
||||||
|
|
||||||
|
/** Рендерит строку как строку в формате JSON/HOCON. */
|
||||||
|
private def quote(value: String): String = {
|
||||||
|
val sb = new StringBuilder("\"")
|
||||||
|
value.foreach {
|
||||||
|
case '"' => sb.append("\\\"")
|
||||||
|
case '\\' => sb.append("\\\\")
|
||||||
|
case '\n' => sb.append("\\n")
|
||||||
|
case '\r' => sb.append("\\r")
|
||||||
|
case '\t' => sb.append("\\t")
|
||||||
|
// Литерал из обратного слэша и буквы u ломает компилятор Scala:
|
||||||
|
// такая последовательность обрабатывается как Unicode-escape ещё до лексера.
|
||||||
|
// Поэтому символы добавляем по отдельности, без escape-последовательности в исходнике.
|
||||||
|
case c if c < ' ' =>
|
||||||
|
sb.append(0x5c.toChar).append('u').append("%04x".format(c.toInt))
|
||||||
|
case c => sb.append(c)
|
||||||
|
}
|
||||||
|
sb.append('"')
|
||||||
|
sb.toString
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.StandardCopyOption
|
||||||
|
|
||||||
|
import com.typesafe.config.ConfigFactory
|
||||||
|
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
/** Изображение карты, доступное для выбора в админке. */
|
||||||
|
case class MapImage(name: String, file: String, players: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Список картинок карт из каталога, заданного в конфиге (`maps.imagesDir`).
|
||||||
|
*
|
||||||
|
* Каталог читается при каждом обращении, поэтому загруженные через админку
|
||||||
|
* картинки сразу появляются в галерее без перезапуска приложения.
|
||||||
|
*/
|
||||||
|
object MapImageRepository {
|
||||||
|
|
||||||
|
private val ImageExtensions = Set("jpg", "jpeg", "png", "webp")
|
||||||
|
|
||||||
|
/** Служебные картинки, которые не являются картами. */
|
||||||
|
private val IgnoredFiles = Set("default.jpg")
|
||||||
|
private val IgnoredPattern = """^img(_\d+)?\.(jpg|jpeg|png|webp)$""".r
|
||||||
|
|
||||||
|
/** Ключ конфига с каталогом картинок карт. */
|
||||||
|
private val ConfigKey = "maps.imagesDir"
|
||||||
|
|
||||||
|
/** Значение по умолчанию, если ключ в конфиге отсутствует. */
|
||||||
|
private val DefaultImagesDirectory = "app/assets/images/maps"
|
||||||
|
|
||||||
|
/** Каталог с картинками карт из конфига. */
|
||||||
|
def imagesDirectory: File = {
|
||||||
|
val configured = Try(ConfigFactory.load().getString(ConfigKey)).toOption
|
||||||
|
.map(_.trim)
|
||||||
|
.filter(_.nonEmpty)
|
||||||
|
.getOrElse(DefaultImagesDirectory)
|
||||||
|
new File(configured)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Максимальный размер загружаемого файла. */
|
||||||
|
private val MaxUploadBytes = 10L * 1024 * 1024
|
||||||
|
|
||||||
|
/** Все карты, отсортированные по количеству игроков и имени. */
|
||||||
|
def all: Seq[MapImage] = {
|
||||||
|
val directory = imagesDirectory
|
||||||
|
if (!directory.isDirectory) Seq.empty
|
||||||
|
else
|
||||||
|
Option(directory.listFiles()).toSeq.flatten
|
||||||
|
.filter(_.isFile)
|
||||||
|
.map(_.getName)
|
||||||
|
.filter(isMapImage)
|
||||||
|
.distinct
|
||||||
|
.map(toMapImage)
|
||||||
|
.sortBy(image => (groupOrder(image.players), image.name.toLowerCase))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ищет картинку карты по имени файла.
|
||||||
|
*
|
||||||
|
* Используется для раздачи картинок через контроллер: имя проверяется так,
|
||||||
|
* чтобы запрос не мог выйти за пределы каталога из конфига.
|
||||||
|
*/
|
||||||
|
def findFile(fileName: String): Option[File] = {
|
||||||
|
val name = Option(fileName).map(_.trim).getOrElse("")
|
||||||
|
if (name.isEmpty || name != fileName || !isMapImage(name)) None
|
||||||
|
else {
|
||||||
|
val directory = imagesDirectory
|
||||||
|
val parent = Try(directory.getCanonicalFile).toOption
|
||||||
|
val candidate = Try(new File(directory, name).getCanonicalFile).toOption
|
||||||
|
(parent, candidate) match {
|
||||||
|
case (Some(dir), Some(file)) if file.getParentFile == dir && file.isFile => Some(file)
|
||||||
|
case _ => None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сохраняет загруженную картинку карты и возвращает её описание.
|
||||||
|
*
|
||||||
|
* Файл пишется в каталог из конфига, поэтому картинка сразу появляется
|
||||||
|
* в галерее без перезапуска приложения.
|
||||||
|
*/
|
||||||
|
def saveUploaded(rawFileName: String, source: File): Either[String, MapImage] = {
|
||||||
|
if (!source.isFile) Left("Uploaded file not found")
|
||||||
|
else if (source.length() <= 0) Left("Uploaded file is empty")
|
||||||
|
else if (source.length() > MaxUploadBytes) Left("File is too large (max 10 MB)")
|
||||||
|
else sanitizeFileName(rawFileName) match {
|
||||||
|
case None => Left("Unsupported file (allowed types: jpg, jpeg, png, webp)")
|
||||||
|
case Some(safeName) =>
|
||||||
|
val directory = imagesDirectory
|
||||||
|
Try {
|
||||||
|
if (!directory.isDirectory) directory.mkdirs()
|
||||||
|
val fileName = uniqueFileName(safeName, directory)
|
||||||
|
java.nio.file.Files.copy(
|
||||||
|
source.toPath,
|
||||||
|
new File(directory, fileName).toPath,
|
||||||
|
StandardCopyOption.REPLACE_EXISTING
|
||||||
|
)
|
||||||
|
fileName
|
||||||
|
}.toEither match {
|
||||||
|
case Right(fileName) => Right(toMapImage(fileName))
|
||||||
|
case Left(error) => Left(s"Failed to save file: ${error.getMessage}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверяет расширение и убирает из имени всё, что может сломать путь. */
|
||||||
|
private def sanitizeFileName(rawFileName: String): Option[String] = {
|
||||||
|
val name = Option(rawFileName)
|
||||||
|
.map(_.trim)
|
||||||
|
.map(new File(_).getName)
|
||||||
|
.getOrElse("")
|
||||||
|
val safe = name.map { c =>
|
||||||
|
if (c.isLetterOrDigit || c == '_' || c == '-' || c == '.' || c == ' ' ||
|
||||||
|
c == '(' || c == ')' || c == '[' || c == ']') c
|
||||||
|
else '_'
|
||||||
|
}.mkString.trim
|
||||||
|
if (safe.nonEmpty && isMapImage(safe)) Some(safe) else None
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подбирает свободное имя, добавляя суффикс -1, -2, ... при совпадении. */
|
||||||
|
private def uniqueFileName(fileName: String, directory: File): String = {
|
||||||
|
def taken(name: String): Boolean = new File(directory, name).exists()
|
||||||
|
|
||||||
|
if (!taken(fileName)) fileName
|
||||||
|
else {
|
||||||
|
val dot = fileName.lastIndexOf('.')
|
||||||
|
val base = fileName.substring(0, dot)
|
||||||
|
val ext = fileName.substring(dot)
|
||||||
|
Iterator.from(1)
|
||||||
|
.map(i => s"$base-$i$ext")
|
||||||
|
.find(name => !taken(name))
|
||||||
|
.getOrElse(fileName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private def isMapImage(fileName: String): Boolean = {
|
||||||
|
val lower = fileName.toLowerCase
|
||||||
|
ImageExtensions.exists(ext => lower.endsWith("." + ext)) &&
|
||||||
|
!IgnoredFiles.contains(lower) &&
|
||||||
|
IgnoredPattern.findFirstIn(lower).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private def toMapImage(fileName: String): MapImage = {
|
||||||
|
val baseName = fileName.substring(0, fileName.lastIndexOf('.'))
|
||||||
|
MapImage(baseName, fileName, playersFrom(fileName))
|
||||||
|
}
|
||||||
|
|
||||||
|
private val PlayersPattern = """(?i)(?:^|\])\s*(\d+)p""".r
|
||||||
|
|
||||||
|
/** Количество игроков из префикса имени: 2p -> 2, 4p -> 4 и т.д. */
|
||||||
|
private def playersFrom(fileName: String): Int =
|
||||||
|
PlayersPattern.findFirstMatchIn(fileName).map(_.group(1).toInt).getOrElse(0)
|
||||||
|
|
||||||
|
/** Порядок групп: 1x1, 2x2, 3x3, 4x4, остальные. */
|
||||||
|
private def groupOrder(players: Int): Int = players match {
|
||||||
|
case 2 | 4 | 6 | 8 => players
|
||||||
|
case _ => 99
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,727 @@
|
|||||||
@()(implicit r: Request[_])
|
@(deciders: Seq[repository.Decider])(implicit request: Request[_], messages: Messages)
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="csrf-token" content="@{play.filters.csrf.CSRF.getToken.map(_.value).getOrElse("")}">
|
||||||
|
<title>Admin — Decider</title>
|
||||||
|
<link rel='stylesheet' href='@routes.Assets.at("lib/bootstrap/css/bootstrap.min.css")'>
|
||||||
|
<style>
|
||||||
|
body { padding-top: 70px; }
|
||||||
|
.decider-card { margin-bottom: 15px; border-left: 4px solid #337ab7; }
|
||||||
|
/* Строка карты занимает всю доступную ширину: выбор карты фиксированный, описание растягивается */
|
||||||
|
.map-row { margin-bottom: 5px; display: flex; align-items: center; }
|
||||||
|
.map-row .map-desc { flex: 1 1 auto; min-width: 0; width: auto; }
|
||||||
|
.map-row .map-preview, .map-row .btn-remove-map { flex: 0 0 auto; }
|
||||||
|
.modal-footer { text-align: right; }
|
||||||
|
.btn-remove-map { margin-left: 5px; }
|
||||||
|
.decider-key { font-family: monospace; color: #666; font-size: 0.9em; }
|
||||||
|
.nav-tabs > li > a { cursor: pointer; }
|
||||||
|
#deciderList { max-height: calc(100vh - 200px); overflow-y: auto; }
|
||||||
|
#deciderList .list-group-item { cursor: pointer; }
|
||||||
|
#deciderList .list-group-item.active { background-color: #337ab7; border-color: #337ab7; }
|
||||||
|
#deciderList .list-group-item:hover { background-color: #f5f5f5; }
|
||||||
|
#deciderList .list-group-item.active:hover { background-color: #286090; }
|
||||||
|
.error-msg { color: red; margin-top: 10px; }
|
||||||
|
.success-msg { color: green; margin-top: 10px; }
|
||||||
|
.map-preview { width: 64px; height: 40px; object-fit: cover; border-radius: 3px; margin-right: 6px; vertical-align: middle; }
|
||||||
|
/* Кнопка выбора карты вместо ручного ввода имени */
|
||||||
|
.btn-select-map { width: 170px; flex: 0 0 170px; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 6px; }
|
||||||
|
.map-gallery { max-height: 60vh; overflow-y: auto; }
|
||||||
|
/* Поиск карт и загрузка картинки */
|
||||||
|
.map-picker-toolbar { display: flex; align-items: center; margin-bottom: 10px; }
|
||||||
|
.map-picker-toolbar .map-search { flex: 1 1 auto; margin-right: 8px; }
|
||||||
|
#mapUploadMessage { min-height: 18px; margin-bottom: 8px; }
|
||||||
|
.map-gallery .map-thumb { cursor: pointer; text-align: center; margin-bottom: 10px; padding: 4px; border: 1px solid transparent; border-radius: 4px; }
|
||||||
|
.map-gallery .map-thumb:hover { border-color: #337ab7; background-color: #f5f5f5; }
|
||||||
|
.map-gallery .map-thumb img { width: 100%; max-width: 160px; height: 100px; object-fit: cover; border-radius: 4px; }
|
||||||
|
.map-gallery .map-thumb-name { font-size: 11px; word-break: break-all; margin-top: 3px; }
|
||||||
|
#mapPickerTabs { margin-bottom: 15px; }
|
||||||
|
|
||||||
<html>
|
/* Шапка: название слева, Steam ID и Refresh справа, всё по центру по вертикали */
|
||||||
|
.navbar .container { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.navbar-header { float: none; }
|
||||||
|
.navbar-user { display: flex; align-items: center; margin-left: 15px; color: #ccc; }
|
||||||
|
.navbar-user .navbar-user-id { margin-right: 10px; white-space: nowrap; }
|
||||||
|
.navbar-user .btn { margin: 0; }
|
||||||
|
|
||||||
|
/* Кнопки в заголовках панелей по центру строки с заголовком */
|
||||||
|
.panel-heading .panel-title { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.panel-heading .panel-title > .btn { flex: 0 0 auto; margin-left: 10px; }
|
||||||
|
|
||||||
|
@@media (max-width: 480px) {
|
||||||
|
.navbar-user .navbar-user-id { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-inverse navbar-fixed-top">
|
||||||
|
<div class="container">
|
||||||
|
<div class="navbar-header">
|
||||||
|
<a class="navbar-brand" href="/decider/admin">Decider admin</a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-user">
|
||||||
|
<span class="navbar-user-id">Steam ID: @(request.session.get("steamId").getOrElse("unknown"))</span>
|
||||||
|
<a href="/decider/admin" class="btn btn-sm btn-default">Refresh</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Left panel: list of deciders -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
Deciders (@deciders.size)
|
||||||
|
<button class="btn btn-sm btn-success" onclick="openNewDecider()">+ New</button>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="list-group" id="deciderList">
|
||||||
|
@for(d <- deciders) {
|
||||||
|
<a class="list-group-item" data-key="@d.key" onclick="openDecider('@d.key')">
|
||||||
|
<h4 class="list-group-item-heading">@d.name</h4>
|
||||||
|
<p class="list-group-item-text decider-key">@d.key • maps: @d.maps.size • owner: @d.ownerSteamId.getOrElse("-")</p>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if(deciders.isEmpty) {
|
||||||
|
<div class="list-group-item text-muted">No deciders</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right panel: edit form -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="panel panel-default" id="editPanel" style="display: none;">
|
||||||
|
<div class="panel-heading">
|
||||||
|
<h3 class="panel-title">
|
||||||
|
<span id="editTitle">Edit</span>
|
||||||
|
<button class="btn btn-sm btn-danger" id="btnDelete" onclick="deleteCurrentDecider()">Delete</button>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form id="deciderForm">
|
||||||
|
<input type="hidden" id="deciderKey">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="formKey">Key</label>
|
||||||
|
<input type="text" class="form-control" id="formKey" placeholder="e.g. dawn_of_war">
|
||||||
|
<p class="help-block">Unique decider identifier (letters, digits, hyphen, underscore)</p>
|
||||||
|
<p class="help-block" id="keyUrlBlock" style="display: none;">
|
||||||
|
URL: <a id="formKeyUrl" href="#" target="_blank" rel="noopener"></a>
|
||||||
|
<button type="button" class="btn btn-xs btn-default" id="btnCopyUrl"
|
||||||
|
title="Copy URL to clipboard">Copy</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="formOwner">Owner Steam ID</label>
|
||||||
|
<input type="text" class="form-control" id="formOwner" readonly>
|
||||||
|
<p class="help-block">Owner cannot be changed</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="formName">Name</label>
|
||||||
|
<input type="text" class="form-control" id="formName" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="formRaceCount">Race Count</label>
|
||||||
|
<input type="number" class="form-control" id="formRaceCount" min="0">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="formRules">Rules</label>
|
||||||
|
<textarea class="form-control" id="formRules" rows="5"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="formExistAdditionalRaces"> Additional Races (Race count per team doubled)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="formExistTextFieldRaces"> Exist Text Field Races (for Unification like mods)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="formDescription">Map description
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Maps</h4>
|
||||||
|
<div id="mapsContainer"></div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" onclick="openMapPicker()">+ Add map</button>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div class="form-group text-right">
|
||||||
|
<button type="button" class="btn btn-default" onclick="closeEditPanel()">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveDecider()">Save</button>
|
||||||
|
</div>
|
||||||
|
<div id="saveMessage"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="emptyState" class="text-muted text-center" style="padding: 50px;">
|
||||||
|
<h3>Select a decider to edit</h3>
|
||||||
|
<p>or create a new one</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete confirmation modal -->
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||||
|
<h4 class="modal-title">Confirm deletion</h4>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>Are you sure you want to delete decider <strong id="deleteDeciderName"></strong>?</p>
|
||||||
|
<p class="text-danger">This action cannot be undone!</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="confirmDelete()">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map image picker modal -->
|
||||||
|
<div class="modal fade" id="mapPickerModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||||
|
<h4 class="modal-title">Choose a map</h4>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="map-picker-toolbar">
|
||||||
|
<input type="search" id="mapSearch" class="form-control map-search" placeholder="Search maps..." autocomplete="off">
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" id="btnUploadMap">Upload image</button>
|
||||||
|
<input type="file" id="mapUploadInput" accept="image/jpeg,image/png,image/webp" style="display: none;">
|
||||||
|
</div>
|
||||||
|
<div id="mapUploadMessage"></div>
|
||||||
|
<ul class="nav nav-pills" id="mapPickerTabs"></ul>
|
||||||
|
<div class="row map-gallery" id="mapGallery"></div>
|
||||||
|
<div id="mapPickerEmpty" class="text-muted" style="display: none;">No map images found</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src='@routes.Assets.at("lib/jquery/jquery.min.js")'></script>
|
||||||
|
<script src='@routes.Assets.at("lib/bootstrap/js/bootstrap.min.js")'></script>
|
||||||
|
<script src='@routes.Assets.at("lib/underscore/underscore.js")'></script>
|
||||||
|
<script>
|
||||||
|
var currentDeciderKey = null;
|
||||||
|
// Steam ID текущего пользователя: владелец нового десайдера.
|
||||||
|
var currentSteamId = '@request.session.get("steamId").getOrElse("")';
|
||||||
|
|
||||||
|
// CSRF: все AJAX-запросы отправляем с токеном из meta-тега,
|
||||||
|
// иначе Play отклоняет POST/DELETE с JSON-телом.
|
||||||
|
(function() {
|
||||||
|
var token = $('meta[name="csrf-token"]').attr('content');
|
||||||
|
if (token) {
|
||||||
|
$.ajaxSetup({ headers: { 'Csrf-Token': token } });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Open decider for editing
|
||||||
|
function openDecider(key) {
|
||||||
|
currentDeciderKey = key;
|
||||||
|
$.ajax({
|
||||||
|
url: '/decider/admin/deciders/' + encodeURIComponent(key),
|
||||||
|
method: 'GET',
|
||||||
|
success: function(decider) {
|
||||||
|
showEditPanel(decider);
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
alert('Failed to load decider');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open new decider form
|
||||||
|
function openNewDecider() {
|
||||||
|
currentDeciderKey = null;
|
||||||
|
showEditPanel({
|
||||||
|
key: '',
|
||||||
|
name: '',
|
||||||
|
rules: '',
|
||||||
|
raceCount: 1,
|
||||||
|
existAdditionalRaces: false,
|
||||||
|
existTextFieldRaces: false,
|
||||||
|
description: false,
|
||||||
|
maps: []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show edit panel with decider data
|
||||||
|
function showEditPanel(decider) {
|
||||||
|
$('#emptyState').hide();
|
||||||
|
$('#editPanel').show();
|
||||||
|
$('#editTitle').text(decider.key ? 'Edit: ' + decider.name : 'New decider');
|
||||||
|
|
||||||
|
$('#deciderKey').val(decider.key);
|
||||||
|
$('#formKey').val(decider.key).prop('readonly', !!decider.key);
|
||||||
|
|
||||||
|
// Ссылка на десайдер: показываем только для уже созданных.
|
||||||
|
if (decider.key) {
|
||||||
|
var deciderPath = '/decider/' + encodeURIComponent(decider.key);
|
||||||
|
$('#formKeyUrl')
|
||||||
|
.attr('href', deciderPath)
|
||||||
|
.text(window.location.origin + deciderPath);
|
||||||
|
$('#keyUrlBlock').show();
|
||||||
|
} else {
|
||||||
|
$('#formKeyUrl').attr('href', '#').text('');
|
||||||
|
$('#keyUrlBlock').hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#btnDelete').toggle(!!decider.key);
|
||||||
|
$('#formOwner').val(decider.ownerSteamId || currentSteamId);
|
||||||
|
$('#formName').val(decider.name);
|
||||||
|
$('#formRules').val(decider.rules);
|
||||||
|
$('#formRaceCount').val(decider.raceCount);
|
||||||
|
$('#formExistAdditionalRaces').prop('checked', decider.existAdditionalRaces);
|
||||||
|
$('#formExistTextFieldRaces').prop('checked', decider.existTextFieldRaces);
|
||||||
|
$('#formDescription').prop('checked', decider.description);
|
||||||
|
|
||||||
|
// Render maps
|
||||||
|
var container = $('#mapsContainer');
|
||||||
|
container.empty();
|
||||||
|
if (decider.maps && decider.maps.length > 0) {
|
||||||
|
decider.maps.forEach(function(map, i) {
|
||||||
|
addMapRow(map.map, map.description);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addMapRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Превью карт: подгружаем список картинок, чтобы найти файлы любого формата.
|
||||||
|
ensureMapImages();
|
||||||
|
|
||||||
|
// Highlight selected in list (точное совпадение по ключу десайдера)
|
||||||
|
$('.list-group-item').removeClass('active');
|
||||||
|
if (decider.key) {
|
||||||
|
$('.list-group-item').filter(function() {
|
||||||
|
return $(this).attr('data-key') === decider.key;
|
||||||
|
}).addClass('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close edit panel
|
||||||
|
function closeEditPanel() {
|
||||||
|
$('#editPanel').hide();
|
||||||
|
$('#emptyState').show();
|
||||||
|
currentDeciderKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Map image picker ----------
|
||||||
|
|
||||||
|
var mapImages = null;
|
||||||
|
var pickerTargetRow = null;
|
||||||
|
var activeMapGroup = 2;
|
||||||
|
var mapSearchQuery = '';
|
||||||
|
|
||||||
|
// Группы карт: 1x1, 2x2, 3x3, 4x4 и остальные.
|
||||||
|
var mapGroups = [
|
||||||
|
{ players: 2, title: '1x1 (2p)' },
|
||||||
|
{ players: 4, title: '2x2 (4p)' },
|
||||||
|
{ players: 6, title: '3x3 (6p)' },
|
||||||
|
{ players: 8, title: '4x4 (8p)' },
|
||||||
|
{ players: 0, title: 'Other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function openMapPicker(targetRow) {
|
||||||
|
pickerTargetRow = targetRow || null;
|
||||||
|
mapSearchQuery = '';
|
||||||
|
$('#mapSearch').val('');
|
||||||
|
$('#mapUploadMessage').text('');
|
||||||
|
if (mapImages) {
|
||||||
|
showMapPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$.ajax({
|
||||||
|
url: '/decider/admin/maps',
|
||||||
|
method: 'GET',
|
||||||
|
success: function(data) {
|
||||||
|
mapImages = data;
|
||||||
|
showMapPicker();
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
alert('Failed to load map images');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMapPicker() {
|
||||||
|
// Если в текущей группе нет карт, открываем первую непустую.
|
||||||
|
if (mapsInGroup(activeMapGroup).length === 0) {
|
||||||
|
var firstNonEmpty = _.find(mapGroups, function(g) {
|
||||||
|
return mapsInGroup(g.players).length > 0;
|
||||||
|
});
|
||||||
|
activeMapGroup = firstNonEmpty ? firstNonEmpty.players : 2;
|
||||||
|
}
|
||||||
|
renderMapTabs();
|
||||||
|
renderMapGallery();
|
||||||
|
$('#mapPickerModal').modal('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapsInGroup(players) {
|
||||||
|
return (mapImages || []).filter(function(m) {
|
||||||
|
return players === 0
|
||||||
|
? [2, 4, 6, 8].indexOf(m.players) === -1
|
||||||
|
: m.players === players;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карты для галереи: при активном поиске ищем по всем группам сразу.
|
||||||
|
function visibleMaps() {
|
||||||
|
if (!mapSearchQuery) {
|
||||||
|
return mapsInGroup(activeMapGroup);
|
||||||
|
}
|
||||||
|
return (mapImages || []).filter(function(m) {
|
||||||
|
return (m.name || '').toLowerCase().indexOf(mapSearchQuery) !== -1 ||
|
||||||
|
(m.file || '').toLowerCase().indexOf(mapSearchQuery) !== -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMapTabs() {
|
||||||
|
var tabs = $('#mapPickerTabs').empty();
|
||||||
|
// Во время поиска вкладки групп не нужны: показываются все совпадения.
|
||||||
|
if (mapSearchQuery) {
|
||||||
|
tabs.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabs.show();
|
||||||
|
mapGroups.forEach(function(group) {
|
||||||
|
var count = mapsInGroup(group.players).length;
|
||||||
|
if (group.players === 0 && count === 0) return;
|
||||||
|
var link = $('<a href="#">')
|
||||||
|
.text(group.title + ' (' + count + ')')
|
||||||
|
.on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
activeMapGroup = group.players;
|
||||||
|
renderMapTabs();
|
||||||
|
renderMapGallery();
|
||||||
|
});
|
||||||
|
tabs.append($('<li>').toggleClass('active', group.players === activeMapGroup).append(link));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMapGallery() {
|
||||||
|
var gallery = $('#mapGallery').empty();
|
||||||
|
var maps = visibleMaps();
|
||||||
|
maps.forEach(function(map) {
|
||||||
|
var thumb = $('<div class="map-thumb">')
|
||||||
|
.append($('<img>').attr('src', mapImageUrl(map.file)).attr('alt', map.name).attr('title', map.name))
|
||||||
|
.append($('<div class="map-thumb-name">').text(map.name))
|
||||||
|
.on('click', function() {
|
||||||
|
chooseMap(map.name);
|
||||||
|
});
|
||||||
|
gallery.append($('<div class="col-xs-6 col-sm-4 col-md-3">').append(thumb));
|
||||||
|
});
|
||||||
|
$('#mapPickerEmpty')
|
||||||
|
.text(mapSearchQuery ? 'No maps match your search' : 'No map images found')
|
||||||
|
.toggle(maps.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Перечитывает список картинок с сервера и перерисовывает галерею.
|
||||||
|
function reloadMapImages(preferredGroup) {
|
||||||
|
$.ajax({
|
||||||
|
url: '/decider/admin/maps',
|
||||||
|
method: 'GET',
|
||||||
|
success: function(data) {
|
||||||
|
mapImages = data;
|
||||||
|
if (preferredGroup !== undefined) {
|
||||||
|
activeMapGroup = preferredGroup;
|
||||||
|
}
|
||||||
|
renderMapTabs();
|
||||||
|
renderMapGallery();
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
alert('Failed to load map images');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Поиск и загрузка картинок карт ----------
|
||||||
|
|
||||||
|
$('#mapSearch').on('input', function() {
|
||||||
|
mapSearchQuery = $(this).val().trim().toLowerCase();
|
||||||
|
renderMapTabs();
|
||||||
|
renderMapGallery();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnUploadMap').on('click', function() {
|
||||||
|
$('#mapUploadInput').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#mapUploadInput').on('change', function() {
|
||||||
|
var input = this;
|
||||||
|
var file = input.files && input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
var msgEl = $('#mapUploadMessage');
|
||||||
|
msgEl.removeClass('text-danger').addClass('text-muted').text('Uploading...');
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: '/decider/admin/maps',
|
||||||
|
method: 'POST',
|
||||||
|
data: formData,
|
||||||
|
processData: false,
|
||||||
|
contentType: false,
|
||||||
|
success: function(image) {
|
||||||
|
msgEl.removeClass('text-danger').text('');
|
||||||
|
input.value = '';
|
||||||
|
mapSearchQuery = '';
|
||||||
|
$('#mapSearch').val('');
|
||||||
|
reloadMapImages(image ? image.players : undefined);
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
var error = 'Upload failed';
|
||||||
|
if (xhr.responseJSON && xhr.responseJSON.error) {
|
||||||
|
error = xhr.responseJSON.error;
|
||||||
|
}
|
||||||
|
msgEl.removeClass('text-muted').addClass('text-danger').text(error);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function chooseMap(mapName) {
|
||||||
|
if (pickerTargetRow && $.contains(document, pickerTargetRow[0])) {
|
||||||
|
pickerTargetRow.find('.map-name').val(mapName);
|
||||||
|
updateMapRow(pickerTargetRow);
|
||||||
|
} else {
|
||||||
|
addMapRow(mapName, '');
|
||||||
|
}
|
||||||
|
pickerTargetRow = null;
|
||||||
|
$('#mapPickerModal').modal('hide');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapImageUrl(file) {
|
||||||
|
return '/assets/images/maps/' + encodeURIComponent(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Map rows ----------
|
||||||
|
|
||||||
|
// Add map row
|
||||||
|
function addMapRow(mapName, description) {
|
||||||
|
var row = $('<div class="map-row form-inline">');
|
||||||
|
|
||||||
|
var preview = $('<img class="map-preview" alt="">');
|
||||||
|
// Имя карты задаётся только через выбор из галереи, ручной ввод запрещён.
|
||||||
|
var nameInput = $('<input type="hidden" class="map-name">')
|
||||||
|
.val(mapName || '');
|
||||||
|
var descInput = $('<input type="text" class="form-control map-desc" placeholder="Description">')
|
||||||
|
.val(description || '');
|
||||||
|
var selectBtn = $('<button type="button" class="btn btn-sm btn-default btn-select-map">Select map</button>')
|
||||||
|
.on('click', function() {
|
||||||
|
openMapPicker(row);
|
||||||
|
});
|
||||||
|
var removeBtn = $('<button type="button" class="btn btn-sm btn-danger btn-remove-map" title="Remove map">✕</button>')
|
||||||
|
.on('click', function() {
|
||||||
|
row.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(preview, nameInput, selectBtn, descInput, removeBtn);
|
||||||
|
$('#mapsContainer').append(row);
|
||||||
|
updateMapRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляет превью и подпись кнопки выбора карты
|
||||||
|
function updateMapRow(row) {
|
||||||
|
var mapName = row.find('.map-name').val().trim();
|
||||||
|
var selectBtn = row.find('.btn-select-map');
|
||||||
|
selectBtn.text(mapName || 'Select map').attr('title', mapName || 'Select map');
|
||||||
|
|
||||||
|
var img = row.find('.map-preview').off('error').off('load');
|
||||||
|
if (!mapName) {
|
||||||
|
img.hide().removeAttr('src');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
img.attr('src', mapImageUrl(mapImageFileFor(mapName))).show().on('error', function() {
|
||||||
|
$(this).hide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Имя файла картинки для превью: если список карт загружен, берём реальный
|
||||||
|
// файл (он может быть png/webp), иначе считаем, что это jpg.
|
||||||
|
function mapImageFileFor(mapName) {
|
||||||
|
var found = _.find(mapImages || [], function(m) {
|
||||||
|
return m.name === mapName;
|
||||||
|
});
|
||||||
|
if (found) return found.file;
|
||||||
|
return /\.(jpg|jpeg|png|webp)$/i.test(mapName) ? mapName : mapName + '.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Догружает список картинок, чтобы превью находили файлы любого формата.
|
||||||
|
function ensureMapImages() {
|
||||||
|
if (mapImages) return;
|
||||||
|
$.ajax({
|
||||||
|
url: '/decider/admin/maps',
|
||||||
|
method: 'GET',
|
||||||
|
success: function(data) {
|
||||||
|
mapImages = data;
|
||||||
|
$('#mapsContainer .map-row').each(function() {
|
||||||
|
updateMapRow($(this));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove map row
|
||||||
|
function removeMapRow(btn) {
|
||||||
|
$(btn).closest('.map-row').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect form data and save
|
||||||
|
function saveDecider() {
|
||||||
|
var key = $('#formKey').val().trim();
|
||||||
|
if (!key) {
|
||||||
|
alert('Key is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(key)) {
|
||||||
|
alert('Key may contain only letters, digits, hyphen and underscore');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var maps = [];
|
||||||
|
$('#mapsContainer .map-row').each(function() {
|
||||||
|
var mapName = $(this).find('.map-name').val().trim();
|
||||||
|
var desc = $(this).find('.map-desc').val().trim();
|
||||||
|
if (mapName) {
|
||||||
|
maps.push({ map: mapName, description: desc || null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var data = {
|
||||||
|
key: key,
|
||||||
|
name: $('#formName').val().trim(),
|
||||||
|
rules: $('#formRules').val(),
|
||||||
|
raceCount: parseInt($('#formRaceCount').val()) || 1,
|
||||||
|
existAdditionalRaces: $('#formExistAdditionalRaces').is(':checked'),
|
||||||
|
existTextFieldRaces: $('#formExistTextFieldRaces').is(':checked'),
|
||||||
|
description: $('#formDescription').is(':checked'),
|
||||||
|
maps: maps
|
||||||
|
};
|
||||||
|
|
||||||
|
var msgEl = $('#saveMessage');
|
||||||
|
msgEl.html('<span class="text-muted">Saving...</span>');
|
||||||
|
|
||||||
|
var url = '/decider/admin/deciders';
|
||||||
|
var method = 'POST';
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
method: method,
|
||||||
|
contentType: 'application/json',
|
||||||
|
data: JSON.stringify(data),
|
||||||
|
success: function() {
|
||||||
|
msgEl.html('<span class="success-msg">Saved!</span>');
|
||||||
|
setTimeout(function() {
|
||||||
|
location.reload();
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
error: function(xhr) {
|
||||||
|
var error = '';
|
||||||
|
try { error = JSON.parse(xhr.responseText).error; } catch(e) {}
|
||||||
|
msgEl.html('<span class="error-msg">Error: ' + error + '</span>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete current decider
|
||||||
|
function deleteCurrentDecider() {
|
||||||
|
if (!currentDeciderKey) return;
|
||||||
|
var deciderName = $('#formName').val();
|
||||||
|
$('#deleteDeciderName').text(deciderName + ' (' + currentDeciderKey + ')');
|
||||||
|
$('#deleteModal').modal('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
var key = currentDeciderKey;
|
||||||
|
$.ajax({
|
||||||
|
url: '/decider/admin/deciders/' + encodeURIComponent(key),
|
||||||
|
method: 'DELETE',
|
||||||
|
success: function() {
|
||||||
|
location.reload();
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
alert('Failed to delete decider');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копирование ссылки на десайдер в буфер обмена.
|
||||||
|
// navigator.clipboard доступен только в защищённом контексте (https/localhost),
|
||||||
|
// поэтому есть запасной вариант через скрытую textarea.
|
||||||
|
function copyDeciderUrl() {
|
||||||
|
var url = $('#formKeyUrl').text();
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
function done(ok) {
|
||||||
|
var btn = $('#btnCopyUrl');
|
||||||
|
btn.text(ok ? 'Copied!' : 'Copy failed');
|
||||||
|
setTimeout(function() { btn.text('Copy'); }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy() {
|
||||||
|
var textarea = $('<textarea>').val(url).css({
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
opacity: 0
|
||||||
|
}).appendTo('body');
|
||||||
|
textarea[0].select();
|
||||||
|
var ok = false;
|
||||||
|
try {
|
||||||
|
ok = document.execCommand('copy');
|
||||||
|
} catch (e) {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
textarea.remove();
|
||||||
|
done(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(url).then(function() {
|
||||||
|
done(true);
|
||||||
|
}, fallbackCopy);
|
||||||
|
} else {
|
||||||
|
fallbackCopy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize: if there's only one decider, open it automatically
|
||||||
|
$(document).ready(function() {
|
||||||
|
// Nothing to do on load - user selects from list
|
||||||
|
$('#mapPickerModal').on('hidden.bs.modal', function() {
|
||||||
|
pickerTargetRow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnCopyUrl').on('click', copyDeciderUrl);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 @@
|
|||||||
sbt.version=1.2.8
|
sbt.version=1.9.8
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||