* initial migration to scala
This commit is contained in:
2019-10-16 03:06:58 +03:00
committed by GitHub
parent 2d84459c4d
commit 49fd33fffc
146 changed files with 2242 additions and 5058 deletions

View File

@ -0,0 +1,36 @@
create table players (
party_id text not null,
player_id integer primary key,
created integer not null,
nick text not null,
job text not null,
bis_link text,
priority integer not null default 1);
create unique index players_nick_job_idx on players(party_id, nick, job);
create table loot (
loot_id integer primary key,
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create index loot_owner_idx on loot(player_id);
create table bis (
player_id integer not null,
created integer not null,
piece text not null,
is_tome integer not null,
job text not null,
foreign key (player_id) references players(player_id) on delete cascade);
create unique index bis_piece_player_id_idx on bis(player_id, piece);
create table users (
party_id text not null,
user_id integer primary key,
username text not null,
password text not null,
permission text not null);
create unique index users_username_idx on users(party_id, username);

View File

@ -0,0 +1,43 @@
me.arcanis.ffxivbis {
ariyala {
// ariyala base url, string, required
ariyala-url = "https://ffxiv.ariyala.com"
// xivapi base url, string, required
xivapi-url = "https://xivapi.com"
// xivapi developer key, string, optional
# xivapi-key = "abc-def"
}
database {
// database section. Section must be declared inside
// for more detailed section descriptions refer to slick documentation
mode = "sqlite"
sqlite {
profile = "slick.jdbc.SQLiteProfile$"
db {
url = "jdbc:sqlite:ffxivbis.db"
user = "user"
password = "password"
}
numThreads = 10
}
}
settings {
// counters of Player class which will be called to sort players for loot priority
// list of strings, required
priority = [
"isRequired", "lootCountBiS", "priority", "lootCount", "lootCountTotal"
]
// general request timeout, duratin, required
request-timeout = 10s
}
web {
// address to bind, string, required
host = "0.0.0.0"
// port to bind, int, required
port = 8000
}
}

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>ReDoc</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='/api-docs/swagger.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@ -0,0 +1,43 @@
package me.arcanis.ffxivbis
import akka.actor.{Actor, Props}
import akka.http.scaladsl.Http
import akka.stream.ActorMaterializer
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.RootEndpoint
import me.arcanis.ffxivbis.service.Ariyala
import me.arcanis.ffxivbis.service.impl.DatabaseImpl
import me.arcanis.ffxivbis.storage.Migration
import scala.concurrent.{Await, ExecutionContext}
import scala.concurrent.duration.Duration
import scala.util.{Failure, Success}
class Application extends Actor with StrictLogging {
implicit private val executionContext: ExecutionContext = context.system.dispatcher
implicit private val materializer: ActorMaterializer = ActorMaterializer()
private val config = context.system.settings.config
private val host = config.getString("me.arcanis.ffxivbis.web.host")
private val port = config.getInt("me.arcanis.ffxivbis.web.port")
override def receive: Receive = Actor.emptyBehavior
Migration(config).onComplete {
case Success(_) =>
val ariyala = context.system.actorOf(Ariyala.props, "ariyala")
val storage = context.system.actorOf(DatabaseImpl.props, "storage")
val http = new RootEndpoint(context.system, storage, ariyala)
logger.info(s"start server at $host:$port")
val bind = Http()(context.system).bindAndHandle(http.route, host, port)
Await.result(context.system.whenTerminated, Duration.Inf)
bind.foreach(_.unbind())
case Failure(exception) => throw exception
}
}
object Application {
def props: Props = Props(new Application)
}

View File

@ -0,0 +1,12 @@
package me.arcanis.ffxivbis
import akka.actor.ActorSystem
import com.typesafe.config.ConfigFactory
object ffxivbis {
def main(args: Array[String]): Unit = {
val config = ConfigFactory.load()
val actorSystem = ActorSystem("ffxivbis", config)
actorSystem.actorOf(Application.props, "ffxivbis")
}
}

View File

@ -0,0 +1,16 @@
package me.arcanis.ffxivbis.http
import akka.actor.ActorRef
import akka.pattern.ask
import akka.util.Timeout
import me.arcanis.ffxivbis.models.{BiS, Job, Piece}
import me.arcanis.ffxivbis.service.Ariyala
import scala.concurrent.{ExecutionContext, Future}
class AriyalaHelper(ariyala: ActorRef) {
def downloadBiS(link: String, job: Job.Job)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[BiS] =
(ariyala ? Ariyala.GetBiS(link, job)).mapTo[Seq[Piece]].map(BiS(_))
}

View File

@ -0,0 +1,53 @@
package me.arcanis.ffxivbis.http
import akka.actor.ActorRef
import akka.http.scaladsl.model.headers._
import akka.http.scaladsl.server.AuthenticationFailedRejection._
import akka.http.scaladsl.server._
import akka.http.scaladsl.server.Directives._
import akka.pattern.ask
import akka.util.Timeout
import me.arcanis.ffxivbis.models.{Permission, User}
import me.arcanis.ffxivbis.service.impl.DatabaseUserHandler
import scala.concurrent.{ExecutionContext, Future}
// idea comes from https://synkre.com/bcrypt-for-akka-http-password-encryption/
trait Authorization {
def storage: ActorRef
def authenticateBasicBCrypt[T](realm: String,
authenticate: (String, String) => Future[Option[T]]): Directive1[T] = {
def challenge = HttpChallenges.basic(realm)
extractCredentials.flatMap {
case Some(BasicHttpCredentials(username, password)) =>
onSuccess(authenticate(username, password)).flatMap {
case Some(client) => provide(client)
case None => reject(AuthenticationFailedRejection(CredentialsRejected, challenge))
}
case _ => reject(AuthenticationFailedRejection(CredentialsMissing, challenge))
}
}
def authenticator(scope: Permission.Value)(partyId: String)
(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] =
(storage ? DatabaseUserHandler.GetUser(partyId, username)).mapTo[Option[User]].map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(username)
case _ => None
}
def authAdmin(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] =
authenticator(Permission.admin)(partyId)(username, password)
def authGet(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] =
authenticator(Permission.get)(partyId)(username, password)
def authPost(partyId: String)(username: String, password: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[String]] =
authenticator(Permission.post)(partyId)(username, password)
}

View File

@ -0,0 +1,29 @@
package me.arcanis.ffxivbis.http
import akka.actor.ActorRef
import akka.pattern.ask
import akka.util.Timeout
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId}
import me.arcanis.ffxivbis.service.impl.DatabaseBiSHandler
import scala.concurrent.{ExecutionContext, Future}
class BiSHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) {
def addPieceBiS(playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext): Future[Unit] =
Future { storage ! DatabaseBiSHandler.AddPieceToBis(playerId, piece) }
def bis(partyId: String, playerId: Option[PlayerId])
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] =
(storage ? DatabaseBiSHandler.GetBiS(partyId, playerId)).mapTo[Seq[Player]]
def putBiS(playerId: PlayerId, link: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] =
downloadBiS(link, playerId.job).map(_.pieces.map(addPieceBiS(playerId, _)))
def removePieceBiS(playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext): Future[Unit] =
Future { storage ! DatabaseBiSHandler.RemovePieceFromBiS(playerId, piece) }
}

View File

@ -0,0 +1,29 @@
package me.arcanis.ffxivbis.http
import akka.actor.ActorRef
import akka.pattern.ask
import akka.util.Timeout
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerId, PlayerIdWithCounters}
import me.arcanis.ffxivbis.service.LootSelector.LootSelectorResult
import me.arcanis.ffxivbis.service.impl.DatabaseLootHandler
import scala.concurrent.{ExecutionContext, Future}
class LootHelper(storage: ActorRef) {
def addPieceLoot(playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext): Future[Unit] =
Future { storage ! DatabaseLootHandler.AddPieceTo(playerId, piece) }
def loot(partyId: String, playerId: Option[PlayerId])
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] =
(storage ? DatabaseLootHandler.GetLoot(partyId, playerId)).mapTo[Seq[Player]]
def removePieceLoot(playerId: PlayerId, piece: Piece)
(implicit executionContext: ExecutionContext): Future[Unit] =
Future { storage ! DatabaseLootHandler.RemovePieceFrom(playerId, piece) }
def suggestPiece(partyId: String, piece: Piece)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[PlayerIdWithCounters]] =
(storage ? DatabaseLootHandler.SuggestLoot(partyId, piece)).mapTo[LootSelectorResult].map(_.result)
}

View File

@ -0,0 +1,37 @@
package me.arcanis.ffxivbis.http
import akka.actor.ActorRef
import akka.pattern.ask
import akka.util.Timeout
import me.arcanis.ffxivbis.models.{Player, PlayerId}
import me.arcanis.ffxivbis.service.Party
import me.arcanis.ffxivbis.service.impl.{DatabaseBiSHandler, DatabasePartyHandler}
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
class PlayerHelper(storage: ActorRef, ariyala: ActorRef) extends AriyalaHelper(ariyala) {
def addPlayer(player: Player)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] =
Future { storage ! DatabasePartyHandler.AddPlayer(player) }.andThen {
case Success(_) if player.link.isDefined =>
downloadBiS(player.link.get, player.job).map { bis =>
bis.pieces.map(storage ! DatabaseBiSHandler.AddPieceToBis(player.playerId, _))
}.map(_ => ())
case Success(_) => Future.successful(())
case Failure(exception) => Future.failed(exception)
}
def getPlayers(partyId: String, maybePlayerId: Option[PlayerId])
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[Player]] =
maybePlayerId match {
case Some(playerId) =>
(storage ? DatabasePartyHandler.GetPlayer(playerId)).mapTo[Player].map(Seq(_))
case None =>
(storage ? DatabasePartyHandler.GetParty(partyId)).mapTo[Party].map(_.players.values.toSeq)
}
def removePlayer(playerId: PlayerId)(implicit executionContext: ExecutionContext): Future[Unit] =
Future { storage ! DatabasePartyHandler.RemovePlayer(playerId) }
}

View File

@ -0,0 +1,45 @@
package me.arcanis.ffxivbis.http
import akka.actor.{ActorRef, ActorSystem}
import akka.http.scaladsl.model._
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.http.api.v1.ApiV1Endpoint
class RootEndpoint(system: ActorSystem, storage: ActorRef, ariyala: ActorRef)
extends StrictLogging {
import me.arcanis.ffxivbis.utils.Implicits._
private val config = system.settings.config
implicit val timeout: Timeout =
config.getDuration("me.arcanis.ffxivbis.settings.request-timeout")
private val apiV1Endpoint: ApiV1Endpoint = new ApiV1Endpoint(storage, ariyala)
def route: Route = apiRoute ~ htmlRoute ~ Swagger.routes ~ swaggerUIRoute
private def apiRoute: Route =
ignoreTrailingSlash {
pathPrefix("api") {
pathPrefix(Segment) {
case "v1" => apiV1Endpoint.route
case _ => reject
}
}
}
private def htmlRoute: Route =
ignoreTrailingSlash {
pathEndOrSingleSlash {
complete(StatusCodes.OK)
}
}
private def swaggerUIRoute: Route =
path("swagger") {
getFromResource("swagger/index.html")
} ~ getFromResourceDirectory("swagger")
}

View File

@ -0,0 +1,24 @@
package me.arcanis.ffxivbis.http
import com.github.swagger.akka.SwaggerHttpService
import com.github.swagger.akka.model.Info
import io.swagger.v3.oas.models.security.SecurityScheme
object Swagger extends SwaggerHttpService {
override val apiClasses: Set[Class[_]] = Set(
classOf[api.v1.BiSEndpoint], classOf[api.v1.LootEndpoint],
classOf[api.v1.PlayerEndpoint], classOf[api.v1.UserEndpoint]
)
override val info: Info = Info()
private val basicAuth = new SecurityScheme()
.description("basic http auth")
.`type`(SecurityScheme.Type.HTTP)
.in(SecurityScheme.In.HEADER)
.scheme("bearer")
override def securitySchemes: Map[String, SecurityScheme] = Map("basic auth" -> basicAuth)
override val unwantedDefinitions: Seq[String] =
Seq("Function1", "Function1RequestContextFutureRouteResult")
}

View File

@ -0,0 +1,28 @@
package me.arcanis.ffxivbis.http
import akka.actor.ActorRef
import akka.pattern.ask
import akka.util.Timeout
import me.arcanis.ffxivbis.models.User
import me.arcanis.ffxivbis.service.impl.DatabaseUserHandler
import scala.concurrent.{ExecutionContext, Future}
class UserHelper(storage: ActorRef) {
def addUser(user: User, isHashedPassword: Boolean)
(implicit executionContext: ExecutionContext): Future[Unit] =
Future { storage ! DatabaseUserHandler.InsertUser(user, isHashedPassword) }
def user(partyId: String, username: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Option[User]] =
(storage ? DatabaseUserHandler.GetUser(partyId, username)).mapTo[Option[User]]
def users(partyId: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Seq[User]] =
(storage ? DatabaseUserHandler.GetUsers(partyId)).mapTo[Seq[User]]
def removeUser(partyId: String, username: String)
(implicit executionContext: ExecutionContext): Future[Unit] =
Future { storage ! DatabaseUserHandler.DeleteUser(partyId, username) }
}

View File

@ -0,0 +1,17 @@
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server.Route
import akka.util.Timeout
class ApiV1Endpoint(storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout) {
private val biSEndpoint = new BiSEndpoint(storage, ariyala)
private val lootEndpoint = new LootEndpoint(storage)
private val playerEndpoint = new PlayerEndpoint(storage, ariyala)
private val userEndpoint = new UserEndpoint(storage)
def route: Route =
biSEndpoint.route ~ lootEndpoint.route ~ playerEndpoint.route ~ userEndpoint.route
}

View File

@ -0,0 +1,132 @@
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import javax.ws.rs.{Consumes, GET, POST, PUT, Path, Produces}
import me.arcanis.ffxivbis.http.{Authorization, BiSHelper}
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.PlayerId
@Path("api/v1")
class BiSEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout)
extends BiSHelper(storage, ariyala) with Authorization with JsonSupport {
import spray.json.DefaultJsonProtocol._
def route: Route = createBiS ~ getBiS ~ modifyBiS
@PUT
@Path("party/{partyId}/bis")
@Consumes(value = Array("application/json"))
@Operation(summary = "create best in slot", description = "Create the best in slot set",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "player best in slot description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerBiSLinkResponse])))),
responses = Array(
new ApiResponse(responseCode = "201", description = "Best in slot set has been created"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("best in slot"),
)
def createBiS: Route =
path("party" / Segment / "bis") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
put {
entity(as[PlayerBiSLinkResponse]) { bisLink =>
val playerId = bisLink.playerId.withPartyId(partyId)
complete(putBiS(playerId, bisLink.link).map(_ => StatusCodes.Created))
}
}
}
}
}
@GET
@Path("party/{partyId}/bis")
@Produces(value = Array("application/json"))
@Operation(summary = "get best in slot", description = "Return the best in slot items",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
new Parameter(name = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"),
new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Best in slot",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("best in slot"),
)
def getBiS: Route =
path("party" / Segment / "bis") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
complete(bis(partyId, playerId).map(_.map(PlayerResponse.fromPlayer)))
}
}
}
}
}
@POST
@Path("party/{partyId}/bis")
@Consumes(value = Array("application/json"))
@Operation(summary = "modify best in slot", description = "Add or remove an item from the best in slot",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "action and piece description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))),
responses = Array(
new ApiResponse(responseCode = "202", description = "Best in slot set has been modified"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("best in slot"),
)
def modifyBiS: Route =
path("party" / Segment / "bis") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PieceActionResponse]) { action =>
val playerId = action.playerIdResponse.withPartyId(partyId)
complete {
val result = action.action match {
case ApiAction.add => addPieceBiS(playerId, action.piece.toPiece)
case ApiAction.remove => removePieceBiS(playerId, action.piece.toPiece)
}
result.map(_ => StatusCodes.Accepted)
}
}
}
}
}
}
}

View File

@ -0,0 +1,139 @@
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import javax.ws.rs.{Consumes, GET, POST, PUT, Path, Produces}
import me.arcanis.ffxivbis.http.{Authorization, LootHelper}
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.PlayerId
@Path("api/v1")
class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
extends LootHelper(storage) with Authorization with JsonSupport {
import spray.json.DefaultJsonProtocol._
def route: Route = getLoot ~ modifyLoot
@GET
@Path("party/{partyId}/loot")
@Produces(value = Array("application/json"))
@Operation(summary = "get loot list", description = "Return the looted items",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
new Parameter(name = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"),
new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Loot list",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse]))
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("loot"),
)
def getLoot: Route =
path("party" / Segment / "loot") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
complete(loot(partyId, playerId).map(_.map(PlayerResponse.fromPlayer)))
}
}
}
}
}
@POST
@Consumes(value = Array("application/json"))
@Path("party/{partyId}/loot")
@Operation(summary = "modify loot list", description = "Add or remove an item from the loot list",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "action and piece description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceActionResponse])))),
responses = Array(
new ApiResponse(responseCode = "202", description = "Loot list has been modified"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("loot"),
)
def modifyLoot: Route =
path("party" / Segment / "loot") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
entity(as[PieceActionResponse]) { action =>
val playerId = action.playerIdResponse.withPartyId(partyId)
complete {
val result = action.action match {
case ApiAction.add => addPieceLoot(playerId, action.piece.toPiece)
case ApiAction.remove => removePieceLoot(playerId, action.piece.toPiece)
}
result.map(_ => StatusCodes.Accepted)
}
}
}
}
}
}
@PUT
@Path("party/{partyId}/loot")
@Consumes(value = Array("application/json"))
@Produces(value = Array("application/json"))
@Operation(summary = "suggest loot", description = "Suggest loot piece to party",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "piece description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PieceResponse])))),
responses = Array(
new ApiResponse(responseCode = "200", description = "Players with counters ordered by priority to get this item",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerIdWithCountersResponse])),
))),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("loot"),
)
def suggestLoot: Route =
path("party" / Segment / "loot") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
put {
entity(as[PieceResponse]) { piece =>
complete {
suggestPiece(partyId, piece.toPiece).map { players =>
players.map(PlayerIdWithCountersResponse.fromPlayerId)
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,97 @@
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import javax.ws.rs.{Consumes, GET, POST, Path, Produces}
import me.arcanis.ffxivbis.http.{Authorization, PlayerHelper}
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.PlayerId
@Path("api/v1")
class PlayerEndpoint(override val storage: ActorRef, ariyala: ActorRef)(implicit timeout: Timeout)
extends PlayerHelper(storage, ariyala) with Authorization with JsonSupport {
import spray.json.DefaultJsonProtocol._
def route: Route = getParty ~ modifyParty
@GET
@Path("party/{partyId}")
@Produces(value = Array("application/json"))
@Operation(summary = "get party", description = "Return the players who belong to the party",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
new Parameter(name = "nick", in = ParameterIn.QUERY, description = "player nick name to filter", example = "Siuan Sanche"),
new Parameter(name = "job", in = ParameterIn.QUERY, description = "player job to filter", example = "DNC"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Players list",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[PlayerResponse])),
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("get"))),
tags = Array("party"),
)
def getParty: Route =
path("party" / Segment) { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
parameters("nick".as[String].?, "job".as[String].?) { (maybeNick, maybeJob) =>
val playerId = PlayerId(partyId, maybeNick, maybeJob)
complete(getPlayers(partyId, playerId).map(_.map(PlayerResponse.fromPlayer)))
}
}
}
}
}
@POST
@Path("party/{partyId}")
@Consumes(value = Array("application/json"))
@Operation(summary = "modify party", description = "Add or remove a player from party list",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "player description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[PlayerActionResponse])))),
responses = Array(
new ApiResponse(responseCode = "202", description = "Party has been modified"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("post"))),
tags = Array("party"),
)
def modifyParty: Route =
path("party" / Segment) { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
entity(as[PlayerActionResponse]) { action =>
val player = action.playerIdResponse.toPlayer.copy(partyId = partyId)
complete {
val result = action.action match {
case ApiAction.add => addPlayer(player)
case ApiAction.remove => removePlayer(player.playerId)
}
result.map(_ => StatusCodes.Accepted)
}
}
}
}
}
}

View File

@ -0,0 +1,152 @@
package me.arcanis.ffxivbis.http.api.v1
import akka.actor.ActorRef
import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Directives._
import akka.http.scaladsl.server._
import akka.util.Timeout
import io.swagger.v3.oas.annotations.enums.ParameterIn
import io.swagger.v3.oas.annotations.media.{ArraySchema, Content, Schema}
import io.swagger.v3.oas.annotations.responses.ApiResponse
import io.swagger.v3.oas.annotations.{Operation, Parameter}
import io.swagger.v3.oas.annotations.parameters.RequestBody
import io.swagger.v3.oas.annotations.security.SecurityRequirement
import javax.ws.rs.{Consumes, DELETE, GET, POST, PUT, Path, Produces}
import me.arcanis.ffxivbis.http.{Authorization, UserHelper}
import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.models.Permission
@Path("api/v1")
class UserEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
extends UserHelper(storage) with Authorization with JsonSupport {
import spray.json.DefaultJsonProtocol._
def route: Route = createParty ~ createUser ~ deleteUser ~ getUsers
@PUT
@Path("party/{partyId}")
@Consumes(value = Array("application/json"))
@Operation(summary = "create new party", description = "Create new party with specified ID",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "party administrator description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))),
responses = Array(
new ApiResponse(responseCode = "201", description = "Party has been created"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "406", description = "Party with the specified ID already exists"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
tags = Array("party"),
)
def createParty: Route =
path("party" / Segment) { partyId: String =>
extractExecutionContext { implicit executionContext =>
put {
entity(as[UserResponse]) { user =>
val admin = user.toUser.copy(partyId = partyId, permission = Permission.admin)
complete {
addUser(admin, isHashedPassword = false).map(_ => StatusCodes.Created)
}
}
}
}
}
@POST
@Path("party/{partyId}/users")
@Consumes(value = Array("application/json"))
@Operation(summary = "create new user", description = "Add an user to the specified party",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
requestBody = new RequestBody(description = "user description", required = true,
content = Array(new Content(schema = new Schema(implementation = classOf[UserResponse])))),
responses = Array(
new ApiResponse(responseCode = "201", description = "User has been created"),
new ApiResponse(responseCode = "400", description = "Invalid parameters were supplied"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"),
)
def createUser: Route =
path("party" / Segment / "users") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
post {
entity(as[UserResponse]) { user =>
val withPartyId = user.toUser.copy(partyId = partyId)
complete {
addUser(withPartyId, isHashedPassword = false).map(_ => StatusCodes.Created)
}
}
}
}
}
}
@DELETE
@Path("party/{partyId}/users/{username}")
@Operation(summary = "remove user", description = "Remove an user from the specified party",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
new Parameter(name = "username", in = ParameterIn.PATH, description = "username to remove", example = "siuan"),
),
responses = Array(
new ApiResponse(responseCode = "202", description = "User has been removed"),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"),
)
def deleteUser: Route =
path("party" / Segment / "users" / Segment) { (partyId: String, username: String) =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
delete {
complete {
removeUser(partyId, username).map(_ => StatusCodes.Accepted)
}
}
}
}
}
@GET
@Path("party/{partyId}/users")
@Produces(value = Array("application/json"))
@Operation(summary = "get users", description = "Return the list of users belong to party",
parameters = Array(
new Parameter(name = "partyId", in = ParameterIn.PATH, description = "unique party ID", example = "abcdefgh"),
),
responses = Array(
new ApiResponse(responseCode = "200", description = "Users list",
content = Array(new Content(
array = new ArraySchema(schema = new Schema(implementation = classOf[UserResponse])),
))),
new ApiResponse(responseCode = "401", description = "Supplied authorization is invalid"),
new ApiResponse(responseCode = "403", description = "Access is forbidden"),
new ApiResponse(responseCode = "500", description = "Internal server error"),
),
security = Array(new SecurityRequirement(name = "basic auth", scopes = Array("admin"))),
tags = Array("users"),
)
def getUsers: Route =
path("party" / Segment / "users") { partyId: String =>
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authAdmin(partyId)) { _ =>
get {
complete {
users(partyId).map(_.map(UserResponse.fromUser))
}
}
}
}
}
}

View File

@ -0,0 +1,5 @@
package me.arcanis.ffxivbis.http.api.v1.json
object ApiAction extends Enumeration {
val add, remove = Value
}

View File

@ -0,0 +1,31 @@
package me.arcanis.ffxivbis.http.api.v1.json
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import me.arcanis.ffxivbis.models.Permission
import spray.json._
trait JsonSupport extends SprayJsonSupport {
import DefaultJsonProtocol._
private def enumFormat[E <: Enumeration](enum: E): RootJsonFormat[E#Value] =
new RootJsonFormat[E#Value] {
override def write(obj: E#Value): JsValue = obj.toString.toJson
override def read(json: JsValue): E#Value = json match {
case JsString(name) => enum.withName(name)
case other => deserializationError(s"String or number expected, got $other")
}
}
implicit val actionFormat: RootJsonFormat[ApiAction.Value] = enumFormat(ApiAction)
implicit val permissionFormat: RootJsonFormat[Permission.Value] = enumFormat(Permission)
implicit val pieceFormat: RootJsonFormat[PieceResponse] = jsonFormat3(PieceResponse.apply)
implicit val pieceActionFormat: RootJsonFormat[PieceActionResponse] = jsonFormat3(PieceActionResponse.apply)
implicit val playerFormat: RootJsonFormat[PlayerResponse] = jsonFormat7(PlayerResponse.apply)
implicit val playerActionFormat: RootJsonFormat[PlayerActionResponse] = jsonFormat2(PlayerActionResponse.apply)
implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkResponse] = jsonFormat2(PlayerBiSLinkResponse.apply)
implicit val playerIdFormat: RootJsonFormat[PlayerIdResponse] = jsonFormat3(PlayerIdResponse.apply)
implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersResponse] =
jsonFormat9(PlayerIdWithCountersResponse.apply)
implicit val userFormat: RootJsonFormat[UserResponse] = jsonFormat4(UserResponse.apply)
}

View File

@ -0,0 +1,8 @@
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PieceActionResponse(
@Schema(description = "action to perform", required = true, `type` = "string", allowableValues = Array("add", "remove")) action: ApiAction.Value,
@Schema(description = "piece description", required = true) piece: PieceResponse,
@Schema(description = "player description", required = true) playerIdResponse: PlayerIdResponse)

View File

@ -0,0 +1,16 @@
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, Piece}
case class PieceResponse(
@Schema(description = "is piece tome gear", required = true) isTome: Boolean,
@Schema(description = "job name to which piece belong or AnyJob", required = true, example = "DNC") job: String,
@Schema(description = "piece name", required = true, example = "body") piece: String) {
def toPiece: Piece = Piece(piece, isTome, Job.fromString(job))
}
object PieceResponse {
def fromPiece(piece: Piece): PieceResponse =
PieceResponse(piece.isTome, piece.job.toString, piece.piece)
}

View File

@ -0,0 +1,7 @@
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PlayerActionResponse(
@Schema(description = "action to perform", required = true, `type` = "string", allowableValues = Array("add", "remove"), example = "add") action: ApiAction.Value,
@Schema(description = "player description", required = true) playerIdResponse: PlayerResponse)

View File

@ -0,0 +1,7 @@
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
case class PlayerBiSLinkResponse(
@Schema(description = "link to player best in slot", required = true, example = "https://ffxiv.ariyala.com/19V5R") link: String,
@Schema(description = "player description", required = true) playerId: PlayerIdResponse)

View File

@ -0,0 +1,12 @@
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Job, PlayerId}
case class PlayerIdResponse(
@Schema(description = "unique party ID. Required in responses", example = "abcdefgh") partyId: Option[String],
@Schema(description = "job name", required = true, example = "DNC") job: String,
@Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String) {
def withPartyId(partyId: String): PlayerId =
PlayerId(partyId, Job.fromString(job), nick)
}

View File

@ -0,0 +1,29 @@
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.PlayerIdWithCounters
case class PlayerIdWithCountersResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String,
@Schema(description = "job name", required = true, example = "DNC") job: String,
@Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String,
@Schema(description = "is piece required by player or not", required = true) isRequired: Boolean,
@Schema(description = "player loot priority", required = true) priority: Int,
@Schema(description = "count of savage pieces in best in slot", required = true) bisCountTotal: Int,
@Schema(description = "count of looted pieces", required = true) lootCount: Int,
@Schema(description = "count of looted pieces which are parts of best in slot", required = true) lootCountBiS: Int,
@Schema(description = "total count of looted pieces", required = true) lootCountTotal: Int)
object PlayerIdWithCountersResponse {
def fromPlayerId(playerIdWithCounters: PlayerIdWithCounters): PlayerIdWithCountersResponse =
PlayerIdWithCountersResponse(
playerIdWithCounters.partyId,
playerIdWithCounters.job.toString,
playerIdWithCounters.nick,
playerIdWithCounters.isRequired,
playerIdWithCounters.priority,
playerIdWithCounters.bisCountTotal,
playerIdWithCounters.lootCount,
playerIdWithCounters.lootCountBiS,
playerIdWithCounters.lootCountTotal)
}

View File

@ -0,0 +1,25 @@
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{BiS, Job, Player}
case class PlayerResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String,
@Schema(description = "job name", required = true, example = "DNC") job: String,
@Schema(description = "player nick name", required = true, example = "Siuan Sanche") nick: String,
@Schema(description = "pieces in best in slot") bis: Option[Seq[PieceResponse]],
@Schema(description = "looted pieces") loot: Option[Seq[PieceResponse]],
@Schema(description = "link to best in slot", example = "https://ffxiv.ariyala.com/19V5R") link: Option[String],
@Schema(description = "player loot priority") priority: Option[Int]) {
def toPlayer: Player =
Player(partyId, Job.fromString(job), nick,
BiS(bis.getOrElse(Seq.empty).map(_.toPiece)), loot.getOrElse(Seq.empty).map(_.toPiece),
link, priority.getOrElse(0))
}
object PlayerResponse {
def fromPlayer(player: Player): PlayerResponse =
PlayerResponse(player.partyId, player.job.toString, player.nick,
Some(player.bis.pieces.map(PieceResponse.fromPiece)), Some(player.loot.map(PieceResponse.fromPiece)),
player.link, Some(player.priority))
}

View File

@ -0,0 +1,18 @@
package me.arcanis.ffxivbis.http.api.v1.json
import io.swagger.v3.oas.annotations.media.Schema
import me.arcanis.ffxivbis.models.{Permission, User}
case class UserResponse(
@Schema(description = "unique party ID", required = true, example = "abcdefgh") partyId: String,
@Schema(description = "username to login to party", required = true, example = "siuan") username: String,
@Schema(description = "password to login to party", required = true, example = "pa55w0rd") password: String,
@Schema(description = "user permission", defaultValue = "get", allowableValues = Array("get", "post", "admin")) permission: Option[Permission.Value] = None) {
def toUser: User =
User(partyId, username, password, permission.getOrElse(Permission.get))
}
object UserResponse {
def fromUser(user: User): UserResponse =
UserResponse(user.partyId, user.username, "", Some(user.permission))
}

View File

@ -0,0 +1,72 @@
package me.arcanis.ffxivbis.models
case class BiS(weapon: Option[Piece],
head: Option[Piece],
body: Option[Piece],
hands: Option[Piece],
waist: Option[Piece],
legs: Option[Piece],
feet: Option[Piece],
ears: Option[Piece],
neck: Option[Piece],
wrist: Option[Piece],
leftRing: Option[Piece],
rightRing: Option[Piece]) {
val pieces: Seq[Piece] =
Seq(weapon, head, body, hands, waist, legs, feet, ears, neck, wrist, leftRing, rightRing).flatten
def hasPiece(piece: Piece): Boolean = piece match {
case upgrade: PieceUpgrade => upgrades.contains(upgrade)
case _ => pieces.contains(piece)
}
def upgrades: Map[PieceUpgrade, Int] =
pieces.groupBy(_.upgrade).foldLeft(Map.empty[PieceUpgrade, Int]) {
case (acc, (Some(k), v)) => acc + (k -> v.length)
case (acc, _) => acc
} withDefaultValue 0
def withPiece(piece: Piece): BiS = copyWithPiece(piece.piece, Some(piece))
def withoutPiece(piece: Piece): BiS = copyWithPiece(piece.piece, None)
private def copyWithPiece(name: String, piece: Option[Piece]): BiS = {
val params = Map(
"weapon" -> weapon,
"head" -> head,
"body" -> body,
"hands" -> hands,
"waist" -> waist,
"legs" -> legs,
"feet" -> feet,
"ears" -> ears,
"neck" -> neck,
"wrist" -> wrist,
"leftRing" -> leftRing,
"rightRing" -> rightRing
) + (name -> piece)
BiS(params)
}
}
object BiS {
def apply(data: Map[String, Option[Piece]]): BiS =
BiS(
data.get("weapon").flatten,
data.get("head").flatten,
data.get("body").flatten,
data.get("hands").flatten,
data.get("waist").flatten,
data.get("legs").flatten,
data.get("feet").flatten,
data.get("ears").flatten,
data.get("neck").flatten,
data.get("wrist").flatten,
data.get("leftRing").flatten,
data.get("rightRing").flatten)
def apply(): BiS = BiS(Seq.empty)
def apply(pieces: Seq[Piece]): BiS =
BiS(pieces.map { piece => piece.piece -> Some(piece) }.toMap)
}

View File

@ -0,0 +1,65 @@
package me.arcanis.ffxivbis.models
object Job {
sealed trait Job
case object AnyJob extends Job {
override def equals(obj: Any): Boolean = obj match {
case Job => true
case _ => false
}
}
case object PLD extends Job
case object WAR extends Job
case object DRK extends Job
case object GNB extends Job
case object WHM extends Job
case object SCH extends Job
case object AST extends Job
case object MNK extends Job
case object DRG extends Job
case object NIN extends Job
case object SAM extends Job
case object BRD extends Job
case object MCH extends Job
case object DNC extends Job
case object BLM extends Job
case object SMN extends Job
case object RDM extends Job
def groupAccessoriesDex: Seq[Job.Job] = groupRanges :+ NIN
def groupAccessoriesStr: Seq[Job.Job] = groupMnk :+ DRG
def groupAll: Seq[Job.Job] = groupCasters ++ groupHealers ++ groupRanges ++ groupTanks
def groupCasters: Seq[Job.Job] = Seq(BLM, SMN, RDM)
def groupHealers: Seq[Job.Job] = Seq(WHM, SCH, AST)
def groupMnk: Seq[Job.Job] = Seq(MNK, SAM)
def groupRanges: Seq[Job.Job] = Seq(BRD, MCH, DNC)
def groupTanks: Seq[Job.Job] = Seq(PLD, WAR, DRK, GNB)
def groupFull: Seq[Seq[Job.Job]] = Seq(groupCasters, groupHealers, groupMnk, groupRanges, groupTanks)
def groupRight: Seq[Seq[Job.Job]] = Seq(groupAccessoriesDex, groupAccessoriesStr)
def fromString(job: String): Job.Job = groupAll.find(_.toString == job.toUpperCase).orNull
def hasSameLoot(left: Job, right: Job, piece: Piece): Boolean = {
def isAccessory(piece: Piece): Boolean = piece match {
case _: PieceAccessory => true
case _ => false
}
def isWeapon(piece: Piece): Boolean = piece match {
case _: PieceWeapon => true
case _ => false
}
if (left == right) true
else if (isWeapon(piece)) false
else if (groupFull.exists(group => group.contains(left) && group.contains(right))) true
else if (isAccessory(piece) && groupRight.exists(group => group.contains(left) && group.contains(right))) true
else false
}
}

View File

@ -0,0 +1,3 @@
package me.arcanis.ffxivbis.models
case class Loot(playerId: Long, piece: Piece)

View File

@ -0,0 +1,97 @@
package me.arcanis.ffxivbis.models
trait Piece {
def isTome: Boolean
def job: Job.Job
def piece: String
def upgrade: Option[PieceUpgrade] = this match {
case _ if !isTome => None
case _: Waist => Some(AccessoryUpgrade)
case _: PieceAccessory => Some(AccessoryUpgrade)
case _: PieceBody => Some(BodyUpgrade)
case _: PieceWeapon => Some(WeaponUpgrade)
case _ => None
}
}
trait PieceAccessory extends Piece
trait PieceBody extends Piece
trait PieceUpgrade extends Piece {
val isTome: Boolean = true
val job: Job.Job = Job.AnyJob
}
trait PieceWeapon extends Piece
case class Weapon(override val isTome: Boolean, override val job: Job.Job) extends PieceWeapon {
val piece: String = "weapon"
}
case class Head(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "head"
}
case class Body(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "body"
}
case class Hands(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "hands"
}
case class Waist(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "waist"
}
case class Legs(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "legs"
}
case class Feet(override val isTome: Boolean, override val job: Job.Job) extends PieceBody {
val piece: String = "feet"
}
case class Ears(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory {
val piece: String = "ears"
}
case class Neck(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory {
val piece: String = "neck"
}
case class Wrist(override val isTome: Boolean, override val job: Job.Job) extends PieceAccessory {
val piece: String = "wrist"
}
case class Ring(override val isTome: Boolean, override val job: Job.Job, override val piece: String = "ring")
extends PieceAccessory {
override def equals(obj: Any): Boolean = obj match {
case Ring(thatIsTome, thatJob, _) => (thatIsTome == isTome) && (thatJob == job)
case _ => false
}
}
case object AccessoryUpgrade extends PieceUpgrade {
val piece: String = "accessory upgrade"
}
case object BodyUpgrade extends PieceUpgrade {
val piece: String = "body upgrade"
}
case object WeaponUpgrade extends PieceUpgrade {
val piece: String = "weapon upgrade"
}
object Piece {
def apply(piece: String, isTome: Boolean, job: Job.Job = Job.AnyJob): Piece =
piece.toLowerCase match {
case "weapon" => Weapon(isTome, job)
case "head" => Head(isTome, job)
case "body" => Body(isTome, job)
case "hands" => Hands(isTome, job)
case "waist" => Waist(isTome, job)
case "legs" => Legs(isTome, job)
case "feet" => Feet(isTome, job)
case "ears" => Ears(isTome, job)
case "neck" => Neck(isTome, job)
case "wrist" => Wrist(isTome, job)
case "ring" => Ring(isTome, job)
case "leftring" => Ring(isTome, job).copy(piece = "leftRing")
case "rightring" => Ring(isTome, job).copy(piece = "rightRing")
case "accessory upgrade" => AccessoryUpgrade
case "body upgrade" => BodyUpgrade
case "weapon upgrade" => WeaponUpgrade
case other => throw new Error(s"Unknown item type $other")
}
}

View File

@ -0,0 +1,44 @@
package me.arcanis.ffxivbis.models
case class Player(partyId: String,
job: Job.Job,
nick: String,
bis: BiS,
loot: Seq[Piece],
link: Option[String] = None,
priority: Int = 0) {
require(job != Job.AnyJob, "AnyJob is not allowed")
val playerId: PlayerId = PlayerId(partyId, job, nick)
def withBiS(set: Option[BiS]): Player = set match {
case Some(value) => copy(bis = value)
case None => this
}
def withCounters(piece: Option[Piece]): PlayerIdWithCounters =
PlayerIdWithCounters(
partyId, job, nick, isRequired(piece), priority,
bisCountTotal(piece), lootCount(piece),
lootCountBiS(piece), lootCountTotal(piece))
def withLoot(list: Option[Seq[Piece]]): Player = list match {
case Some(value) => copy(loot = value)
case None => this
}
def isRequired(piece: Option[Piece]): Boolean = {
piece match {
case None => false
case Some(p) if !bis.hasPiece(p) => false
case Some(p: PieceUpgrade) => bis.upgrades(p) > lootCount(piece)
case Some(_) => lootCount(piece) == 0
}
}
def bisCountTotal(piece: Option[Piece]): Int = bis.pieces.count(!_.isTome)
def lootCount(piece: Option[Piece]): Int = piece match {
case Some(p) => loot.count(_ == p)
case None => lootCountTotal(piece)
}
def lootCountBiS(piece: Option[Piece]): Int = loot.count(bis.hasPiece)
def lootCountTotal(piece: Option[Piece]): Int = loot.length
def lootPriority(piece: Piece): Int = priority
}

View File

@ -0,0 +1,18 @@
package me.arcanis.ffxivbis.models
trait PlayerIdBase {
def job: Job.Job
def nick: String
override def toString: String = s"$nick ($job)"
}
case class PlayerId(partyId: String, job: Job.Job, nick: String) extends PlayerIdBase
object PlayerId {
def apply(partyId: String, maybeNick: Option[String], maybeJob: Option[String]): Option[PlayerId] =
(maybeNick, maybeJob) match {
case (Some(nick), Some(job)) => Some(PlayerId(partyId, Job.fromString(job), nick))
case _ => None
}
}

View File

@ -0,0 +1,45 @@
package me.arcanis.ffxivbis.models
case class PlayerIdWithCounters(partyId: String,
job: Job.Job,
nick: String,
isRequired: Boolean,
priority: Int,
bisCountTotal: Int,
lootCount: Int,
lootCountBiS: Int,
lootCountTotal: Int)
extends PlayerIdBase {
import PlayerIdWithCounters._
def playerId: PlayerId = PlayerId(partyId, job, nick)
def gt(that: PlayerIdWithCounters, orderBy: Seq[String]): Boolean =
withCounters(orderBy) > that.withCounters(orderBy)
private val counters: Map[String, Int] = Map(
"isRequired" -> (if (isRequired) 1 else 0),
"priority" -> priority,
"bisCountTotal" -> bisCountTotal,
"lootCount" -> lootCount,
"lootCountBiS" -> lootCountBiS,
"lootCountTotal" -> lootCountTotal) withDefaultValue 0
private def withCounters(orderBy: Seq[String]): PlayerCountersComparator =
PlayerCountersComparator(orderBy.map(counters): _*)
}
object PlayerIdWithCounters {
private case class PlayerCountersComparator(values: Int*) {
def >(that: PlayerCountersComparator): Boolean = {
@scala.annotation.tailrec
def compareLists(left: List[Int], right: List[Int]): Boolean =
(left, right) match {
case (hl :: tl, hr :: tr) => if (hl == hr) compareLists(tl, tr) else hl > hr
case (_ :: _, Nil) => true
case (_, _) => false
}
compareLists(values.toList, that.values.toList)
}
}
}

View File

@ -0,0 +1,17 @@
package me.arcanis.ffxivbis.models
import org.mindrot.jbcrypt.BCrypt
object Permission extends Enumeration {
val get, post, admin = Value
}
case class User(partyId: String,
username: String,
password: String,
permission: Permission.Value) {
def hash: String = BCrypt.hashpw(password, BCrypt.gensalt)
def verify(plain: String): Boolean = BCrypt.checkpw(plain, password)
def verityScope(scope: Permission.Value): Boolean = permission >= scope
}

View File

@ -0,0 +1,117 @@
package me.arcanis.ffxivbis.service
import java.nio.file.Paths
import akka.actor.{Actor, Props}
import akka.http.scaladsl.model._
import akka.http.scaladsl.Http
import akka.pattern.pipe
import akka.stream.ActorMaterializer
import akka.stream.scaladsl.{Keep, Sink}
import akka.util.ByteString
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.models.{Job, Piece}
import spray.json._
import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
class Ariyala extends Actor with StrictLogging {
import Ariyala._
private val settings = context.system.settings.config
private val ariyalaUrl = settings.getString("me.arcanis.ffxivbis.ariyala.ariyala-url")
private val xivapiUrl = settings.getString("me.arcanis.ffxivbis.ariyala.xivapi-url")
private val xivapiKey = Try(settings.getString("me.arcanis.ffxivbis.ariyala.xivapi-key")).toOption
private val http = Http()(context.system)
private implicit val materializer: ActorMaterializer = ActorMaterializer()
private implicit val executionContext: ExecutionContext = context.dispatcher
override def receive: Receive = {
case GetBiS(link, job) =>
val client = sender()
get(link, job).pipeTo(client)
}
private def get(link: String, job: Job.Job): Future[Seq[Piece]] = {
val id = Paths.get(link).normalize.getFileName.toString
val uri = Uri(ariyalaUrl)
.withPath(Uri.Path / "store.app")
.withQuery(Uri.Query(Map("identifier" -> id)))
sendRequest(uri, Ariyala.parseAriyalaJsonToPieces(job, getIsTome))
}
private def getIsTome(itemId: Long): Future[Boolean] = {
val uri = Try(Uri(xivapiUrl)
.withPath(Uri.Path / "item" / itemId.toString)
.withQuery(Uri.Query(Map("columns" -> "IsEquippable", "private_key" -> xivapiKey.getOrElse("")))))
sendRequest(uri.toOption.get, Ariyala.parseXivapiJson)
}
private def sendRequest[T](uri: Uri, parser: JsObject => Future[T]): Future[T] =
http.singleRequest(HttpRequest(uri = uri)).map {
case HttpResponse(status, _, entity, _) if status.isSuccess() =>
entity.dataBytes
.fold(ByteString.empty)(_ ++ _)
.map(_.utf8String)
.map(result => parser(result.parseJson.asJsObject))
.toMat(Sink.head)(Keep.right)
.run().flatten
case _ => Future.failed(deserializationError("Invalid response from server"))
}.flatten
}
object Ariyala {
def props: Props = Props(new Ariyala)
case class GetBiS(link: String, job: Job.Job)
private def parseAriyalaJson(job: Job.Job)(js: JsObject): Future[Map[String, Long]] = {
try {
val apiJob = js.fields.get("content") match {
case Some(JsString(value)) => value
case other => throw deserializationError(s"Invalid job name $other")
}
Future.successful(js.fields.get("datasets") match {
case Some(datasets: JsObject) =>
val fields = datasets.fields
fields.getOrElse(apiJob, fields(job.toString)).asJsObject
.fields("normal").asJsObject
.fields("items").asJsObject
.fields.foldLeft(Map.empty[String, Long]) {
case (acc, (key, JsNumber(id))) => remapKey(key).map(k => acc + (k -> id.toLong)).getOrElse(acc)
case (acc, _) => acc
}
case other => throw deserializationError(s"Invalid json $other")
})
} catch {
case e: Exception => Future.failed(e)
}
}
private def parseAriyalaJsonToPieces(job: Job.Job, isTome: Long => Future[Boolean])(js: JsObject)
(implicit executionContext: ExecutionContext): Future[Seq[Piece]] =
parseAriyalaJson(job)(js).map { pieces =>
Future.sequence(pieces.toSeq.map {
case (itemName, itemId) => isTome(itemId).map(Piece(itemName, _, job))
})
}.flatten
private def parseXivapiJson(js: JsObject): Future[Boolean] =
js.fields.get("IsEquippable") match {
case Some(JsNumber(value)) => Future.successful(value == 0) // don't ask
case other => Future.failed(deserializationError(s"Could not parse $other"))
}
private def remapKey(key: String): Option[String] = key match {
case "mainhand" => Some("weapon")
case "chest" => Some("body")
case "ringLeft" => Some("leftRing")
case "ringRight" => Some("rightRing")
case "head" | "hands" | "waist" | "legs" | "feet" | "ears" | "neck" | "wrist" => Some(key)
case _ => None
}
}

View File

@ -0,0 +1,26 @@
package me.arcanis.ffxivbis.service
import akka.actor.Actor
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.models.{Player, PlayerId}
import me.arcanis.ffxivbis.storage.DatabaseProfile
import scala.concurrent.{ExecutionContext, Future}
trait Database extends Actor with StrictLogging {
implicit def executionContext: ExecutionContext
def profile: DatabaseProfile
def filterParty(party: Party, maybePlayerId: Option[PlayerId]): Seq[Player] =
(party, maybePlayerId) match {
case (_, Some(playerId)) => party.player(playerId).map(Seq(_)).getOrElse(Seq.empty)
case (_, _) => party.getPlayers
}
def getParty(partyId: String, withBiS: Boolean, withLoot: Boolean): Future[Party] =
for {
players <- profile.getParty(partyId)
bis <- if (withBiS) profile.getPiecesBiS(partyId) else Future(Seq.empty)
loot <- if (withLoot) profile.getPieces(partyId) else Future(Seq.empty)
} yield Party(partyId, context.system.settings.config, players, bis, loot)
}

View File

@ -0,0 +1,20 @@
package me.arcanis.ffxivbis.service
import me.arcanis.ffxivbis.models.{Piece, Player, PlayerIdWithCounters}
class LootSelector(players: Seq[Player], piece: Piece, orderBy: Seq[String]) {
val counters: Seq[PlayerIdWithCounters] = players.map(_.withCounters(Some(piece)))
def suggest: LootSelector.LootSelectorResult =
LootSelector.LootSelectorResult {
counters.sortWith { case (left, right) => left.gt(right, orderBy) }
}
}
object LootSelector {
def apply(players: Seq[Player], piece: Piece, orderBy: Seq[String]): LootSelectorResult =
new LootSelector(players, piece, orderBy).suggest
case class LootSelectorResult(result: Seq[PlayerIdWithCounters])
}

View File

@ -0,0 +1,48 @@
package me.arcanis.ffxivbis.service
import com.typesafe.config.Config
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.models.{BiS, Loot, Piece, Player, PlayerId}
import scala.jdk.CollectionConverters._
import scala.util.Random
case class Party(partyId: String, config: Config, players: Map[PlayerId, Player])
extends StrictLogging {
private val rules =
config.getStringList("me.arcanis.ffxivbis.settings.priority").asScala.toSeq
def getPlayers: Seq[Player] = players.values.toSeq
def player(playerId: PlayerId): Option[Player] = players.get(playerId)
def withPlayer(player: Player): Party =
try {
require(player.partyId == partyId, "player must belong to this party")
copy(players = players + (player.playerId -> player))
} catch {
case exception: Exception =>
logger.error("cannot add player", exception)
this
}
def suggestLoot(piece: Piece): LootSelector.LootSelectorResult =
LootSelector(getPlayers, piece, rules)
}
object Party {
def apply(partyId: Option[String], config: Config): Party =
new Party(partyId.getOrElse(Random.alphanumeric.take(20).mkString), config, Map.empty)
def apply(partyId: String, config: Config,
players: Map[Long, Player], bis: Seq[Loot], loot: Seq[Loot]): Party = {
val bisByPlayer = bis.groupBy(_.playerId).view.mapValues(piece => BiS(piece.map(_.piece)))
val lootByPlayer = loot.groupBy(_.playerId).view.mapValues(_.map(_.piece))
val playersWithItems = players.foldLeft(Map.empty[PlayerId, Player]) {
case (acc, (playerId, player)) =>
acc + (player.playerId -> player
.withBiS(bisByPlayer.get(playerId))
.withLoot(lootByPlayer.get(playerId)))
}
Party(partyId, config, playersWithItems)
}
}

View File

@ -0,0 +1,29 @@
package me.arcanis.ffxivbis.service.impl
import akka.pattern.pipe
import me.arcanis.ffxivbis.models.{BiS, Piece, PlayerId}
import me.arcanis.ffxivbis.service.Database
trait DatabaseBiSHandler { this: Database =>
import DatabaseBiSHandler._
def bisHandler: Receive = {
case AddPieceToBis(playerId, piece) =>
profile.insertPieceBiS(playerId, piece)
case GetBiS(partyId, maybePlayerId) =>
val client = sender()
getParty(partyId, withBiS = true, withLoot = false)
.map(filterParty(_, maybePlayerId))
.pipeTo(client)
case RemovePieceFromBiS(playerId, piece) =>
profile.deletePieceBiS(playerId, piece)
}
}
object DatabaseBiSHandler {
case class AddPieceToBis(playerId: PlayerId, piece: Piece)
case class GetBiS(partyId: String, playerId: Option[PlayerId])
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece)
}

View File

@ -0,0 +1,22 @@
package me.arcanis.ffxivbis.service.impl
import akka.actor.Props
import me.arcanis.ffxivbis.service.Database
import me.arcanis.ffxivbis.storage.DatabaseProfile
import scala.concurrent.ExecutionContext
class DatabaseImpl extends Database
with DatabaseBiSHandler with DatabaseLootHandler
with DatabasePartyHandler with DatabaseUserHandler {
implicit val executionContext: ExecutionContext = context.dispatcher
val profile = new DatabaseProfile(executionContext, context.system.settings.config)
override def receive: Receive =
bisHandler orElse lootHandler orElse partyHandler orElse userHandler
}
object DatabaseImpl {
def props: Props = Props(new DatabaseImpl)
}

View File

@ -0,0 +1,34 @@
package me.arcanis.ffxivbis.service.impl
import akka.pattern.pipe
import me.arcanis.ffxivbis.models.{Piece, PlayerId}
import me.arcanis.ffxivbis.service.Database
trait DatabaseLootHandler { this: Database =>
import DatabaseLootHandler._
def lootHandler: Receive = {
case AddPieceTo(playerId, piece) =>
profile.insertPiece(playerId, piece)
case GetLoot(partyId, maybePlayerId) =>
val client = sender()
getParty(partyId, withBiS = false, withLoot = true)
.map(filterParty(_, maybePlayerId))
.pipeTo(client)
case RemovePieceFrom(playerId, piece) =>
profile.deletePiece(playerId, piece)
case SuggestLoot(partyId, piece) =>
val client = sender()
getParty(partyId, withBiS = true, withLoot = true).map(_.suggestLoot(piece)).pipeTo(client)
}
}
object DatabaseLootHandler {
case class AddPieceTo(playerId: PlayerId, piece: Piece)
case class GetLoot(partyId: String, playerId: Option[PlayerId])
case class RemovePieceFrom(playerId: PlayerId, piece: Piece)
case class SuggestLoot(partyId: String, piece: Piece)
}

View File

@ -0,0 +1,39 @@
package me.arcanis.ffxivbis.service.impl
import akka.actor.Actor
import akka.pattern.pipe
import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.models.{BiS, Player, PlayerId}
import me.arcanis.ffxivbis.service.Database
trait DatabasePartyHandler { this: Actor with StrictLogging with Database =>
import DatabasePartyHandler._
def partyHandler: Receive = {
case AddPlayer(player) =>
profile.insertPlayer(player)
case GetParty(partyId) =>
val client = sender()
getParty(partyId, withBiS = true, withLoot = true).pipeTo(client)
case GetPlayer(playerId) =>
val client = sender()
val player = for {
bis <- profile.getPiecesBiS(playerId)
loot <- profile.getPieces(playerId)
} yield Player(playerId.partyId, playerId.job, playerId.nick,
BiS(bis.map(_.piece)), loot.map(_.piece))
player.pipeTo(client)
case RemovePlayer(playerId) =>
profile.deletePlayer(playerId)
}
}
object DatabasePartyHandler {
case class AddPlayer(player: Player)
case class GetParty(partyId: String)
case class GetPlayer(playerId: PlayerId)
case class RemovePlayer(playerId: PlayerId)
}

View File

@ -0,0 +1,33 @@
package me.arcanis.ffxivbis.service.impl
import akka.pattern.pipe
import me.arcanis.ffxivbis.models.User
import me.arcanis.ffxivbis.service.Database
trait DatabaseUserHandler { this: Database =>
import DatabaseUserHandler._
def userHandler: Receive = {
case DeleteUser(partyId, username) =>
profile.deleteUser(partyId, username)
case GetUser(partyId, username) =>
val client = sender()
profile.getUser(partyId, username).pipeTo(client)
case GetUsers(partyId) =>
val client = sender()
profile.getUsers(partyId).pipeTo(client)
case InsertUser(user, isHashedPassword) =>
val toInsert = if (isHashedPassword) user else user.copy(password = user.hash)
profile.insertUser(toInsert)
}
}
object DatabaseUserHandler {
case class DeleteUser(partyId: String, username: String)
case class GetUser(partyId: String, username: String)
case class GetUsers(partyId: String)
case class InsertUser(user: User, isHashedPassword: Boolean)
}

View File

@ -0,0 +1,48 @@
package me.arcanis.ffxivbis.storage
import me.arcanis.ffxivbis.models.{Job, Loot, Piece}
import slick.lifted.{ForeignKeyQuery, Index}
import scala.concurrent.Future
trait BiSProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
case class BiSRep(playerId: Long, created: Long, piece: String, isTome: Int, job: String) {
def toLoot: Loot = Loot(playerId, Piece(piece, isTome == 1, Job.fromString(job)))
}
object BiSRep {
def fromPiece(playerId: Long, piece: Piece) =
BiSRep(playerId, DatabaseProfile.now, piece.piece, if (piece.isTome) 1 else 0,
piece.job.toString)
}
class BiSPieces(tag: Tag) extends Table[BiSRep](tag, "bis") {
def playerId: Rep[Long] = column[Long]("player_id")
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece")
def isTome: Rep[Int] = column[Int]("is_tome")
def job: Rep[String] = column[String]("job")
def * =
(playerId, created, piece, isTome, job) <> ((BiSRep.apply _).tupled, BiSRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
def bisPiecePlayerIdIdx: Index =
index("bis_piece_player_id_idx", (playerId, piece), unique = true)
}
def deletePieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
db.run(pieceBiS(BiSRep.fromPiece(playerId, piece)).delete)
def getPiecesBiSById(playerId: Long): Future[Seq[Loot]] = getPiecesBiSById(Seq(playerId))
def getPiecesBiSById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesBiS(playerIds).result).map(_.map(_.toLoot))
def insertPieceBiSById(piece: Piece)(playerId: Long): Future[Int] =
db.run(bisTable.insertOrUpdate(BiSRep.fromPiece(playerId, piece)))
private def pieceBiS(piece: BiSRep) =
piecesBiS(Seq(piece.playerId)).filter(_.piece === piece.piece)
private def piecesBiS(playerIds: Seq[Long]) =
bisTable.filter(_.playerId.inSet(playerIds.toSet))
}

View File

@ -0,0 +1,62 @@
package me.arcanis.ffxivbis.storage
import java.time.Instant
import com.typesafe.config.Config
import me.arcanis.ffxivbis.models.{Loot, Piece, PlayerId}
import slick.basic.DatabaseConfig
import slick.jdbc.JdbcProfile
import scala.concurrent.{ExecutionContext, Future}
class DatabaseProfile(context: ExecutionContext, config: Config)
extends BiSProfile with LootProfile with PlayersProfile with UsersProfile {
implicit val executionContext: ExecutionContext = context
val dbConfig: DatabaseConfig[JdbcProfile] =
DatabaseConfig.forConfig[JdbcProfile]("", DatabaseProfile.getSection(config))
import dbConfig.profile.api._
val db = dbConfig.db
val bisTable: TableQuery[BiSPieces] = TableQuery[BiSPieces]
val lootTable: TableQuery[LootPieces] = TableQuery[LootPieces]
val playersTable: TableQuery[Players] = TableQuery[Players]
val usersTable: TableQuery[Users] = TableQuery[Users]
// generic bis api
def deletePieceBiS(playerId: PlayerId, piece: Piece): Future[Int] =
byPlayerId(playerId, deletePieceBiSById(piece))
def getPiecesBiS(playerId: PlayerId): Future[Seq[Loot]] =
byPlayerId(playerId, getPiecesBiSById)
def getPiecesBiS(partyId: String): Future[Seq[Loot]] =
byPartyId(partyId, getPiecesBiSById)
def insertPieceBiS(playerId: PlayerId, piece: Piece): Future[Int] =
byPlayerId(playerId, insertPieceBiSById(piece))
// generic loot api
def deletePiece(playerId: PlayerId, piece: Piece): Future[Int] =
byPlayerId(playerId, deletePieceById(piece))
def getPieces(playerId: PlayerId): Future[Seq[Loot]] =
byPlayerId(playerId, getPiecesById)
def getPieces(partyId: String): Future[Seq[Loot]] =
byPartyId(partyId, getPiecesById)
def insertPiece(playerId: PlayerId, piece: Piece): Future[Int] =
byPlayerId(playerId, insertPieceById(piece))
private def byPartyId[T](partyId: String, callback: Seq[Long] => Future[T]): Future[T] =
getPlayers(partyId).map(callback).flatten
private def byPlayerId[T](playerId: PlayerId, callback: Long => Future[T]): Future[T] =
getPlayer(playerId).map {
case Some(id) => callback(id)
case None => Future.failed(new Error(s"Could not find player $playerId"))
}.flatten
}
object DatabaseProfile {
def now: Long = Instant.now.toEpochMilli
def getSection(config: Config): Config = {
val section = config.getString("me.arcanis.ffxivbis.database.mode")
config.getConfig("me.arcanis.ffxivbis.database").getConfig(section)
}
}

View File

@ -0,0 +1,50 @@
package me.arcanis.ffxivbis.storage
import me.arcanis.ffxivbis.models.{Job, Loot, Piece}
import slick.lifted.{ForeignKeyQuery, Index}
import scala.concurrent.Future
trait LootProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
case class LootRep(lootId: Option[Long], playerId: Long, created: Long, piece: String,
isTome: Int, job: String) {
def toLoot: Loot = Loot(playerId, Piece(piece, isTome == 1, Job.fromString(job)))
}
object LootRep {
def fromPiece(playerId: Long, piece: Piece) =
LootRep(None, playerId, DatabaseProfile.now, piece.piece, if (piece.isTome) 1 else 0,
piece.job.toString)
}
class LootPieces(tag: Tag) extends Table[LootRep](tag, "loot") {
def lootId: Rep[Long] = column[Long]("loot_id", O.AutoInc, O.PrimaryKey)
def playerId: Rep[Long] = column[Long]("player_id")
def created: Rep[Long] = column[Long]("created")
def piece: Rep[String] = column[String]("piece")
def isTome: Rep[Int] = column[Int]("is_tome")
def job: Rep[String] = column[String]("job")
def * =
(lootId.?, playerId, created, piece, isTome, job) <> ((LootRep.apply _).tupled, LootRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
def lootOwnerIdx: Index =
index("loot_owner_idx", (playerId), unique = false)
}
def deletePieceById(piece: Piece)(playerId: Long): Future[Int] =
db.run(pieceLoot(LootRep.fromPiece(playerId, piece)).take(1).delete)
def getPiecesById(playerId: Long): Future[Seq[Loot]] = getPiecesById(Seq(playerId))
def getPiecesById(playerIds: Seq[Long]): Future[Seq[Loot]] =
db.run(piecesLoot(playerIds).result).map(_.map(_.toLoot))
def insertPieceById(piece: Piece)(playerId: Long): Future[Int] =
db.run(lootTable.insertOrUpdate(LootRep.fromPiece(playerId, piece)))
private def pieceLoot(piece: LootRep) =
piecesLoot(Seq(piece.playerId)).filter(_.piece === piece.piece)
private def piecesLoot(playerIds: Seq[Long]) =
lootTable.filter(_.playerId.inSet(playerIds.toSet))
}

View File

@ -0,0 +1,23 @@
package me.arcanis.ffxivbis.storage
import com.typesafe.config.Config
import org.flywaydb.core.Flyway
import scala.concurrent.Future
class Migration(config: Config) {
def performMigration(): Future[Int] = {
val section = DatabaseProfile.getSection(config)
val url = section.getString("db.url")
val username = section.getString("db.user")
val password = section.getString("db.password")
val flyway = Flyway.configure().dataSource(url, username, password).load()
Future.successful(flyway.migrate())
}
}
object Migration {
def apply(config: Config): Future[Int] = new Migration(config).performMigration()
}

View File

@ -0,0 +1,58 @@
package me.arcanis.ffxivbis.storage
import me.arcanis.ffxivbis.models.{BiS, Job, Player, PlayerId}
import slick.lifted.Index
import scala.concurrent.Future
trait PlayersProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
case class PlayerRep(partyId: String, playerId: Option[Long], created: Long, nick: String,
job: String, link: Option[String], priority: Int) {
def toPlayer: Player =
Player(partyId, Job.fromString(job), nick, BiS(Seq.empty), List.empty, link, priority)
}
object PlayerRep {
def fromPlayer(player: Player): PlayerRep =
PlayerRep(player.partyId, None, DatabaseProfile.now, player.nick,
player.job.toString, player.link, player.priority)
}
class Players(tag: Tag) extends Table[PlayerRep](tag, "players") {
def partyId: Rep[String] = column[String]("party_id")
def playerId: Rep[Long] = column[Long]("player_id", O.AutoInc, O.PrimaryKey)
def created: Rep[Long] = column[Long]("created")
def nick: Rep[String] = column[String]("nick")
def job: Rep[String] = column[String]("job")
def bisLink: Rep[Option[String]] = column[Option[String]]("bis_link")
def priority: Rep[Int] = column[Int]("priority", O.Default(1))
def * =
(partyId, playerId.?, created, nick, job, bisLink, priority) <> ((PlayerRep.apply _).tupled, PlayerRep.unapply)
def playersNickJobIdx: Index =
index("players_nick_job_idx", (partyId, nick, job), unique = true)
}
def deletePlayer(playerId: PlayerId): Future[Int] = db.run(player(playerId).delete)
def getParty(partyId: String): Future[Map[Long, Player]] =
db.run(players(partyId).result).map(_.foldLeft(Map.empty[Long, Player]) {
case (acc, p @ PlayerRep(_, Some(id), _, _, _, _, _)) => acc + (id -> p.toPlayer)
case (acc, _) => acc
})
def getPlayer(playerId: PlayerId): Future[Option[Long]] =
db.run(player(playerId).map(_.playerId).result.headOption)
def getPlayers(partyId: String): Future[Seq[Long]] =
db.run(players(partyId).map(_.playerId).result)
def insertPlayer(player: Player): Future[Int] =
db.run(playersTable.insertOrUpdate(PlayerRep.fromPlayer(player)))
private def player(playerId: PlayerId) =
playersTable
.filter(_.partyId === playerId.partyId)
.filter(_.job === playerId.job.toString)
.filter(_.nick === playerId.nick)
private def players(partyId: String) =
playersTable.filter(_.partyId === partyId)
}

View File

@ -0,0 +1,48 @@
package me.arcanis.ffxivbis.storage
import me.arcanis.ffxivbis.models.{Permission, User}
import slick.lifted.Index
import scala.concurrent.Future
trait UsersProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
case class UserRep(partyId: String, userId: Option[Long], username: String, password: String,
permission: String) {
def toUser: User = User(partyId, username, password, Permission.withName(permission))
}
object UserRep {
def fromUser(user: User): UserRep =
UserRep(user.partyId, None, user.username, user.password, user.permission.toString)
}
class Users(tag: Tag) extends Table[UserRep](tag, "users") {
def partyId: Rep[String] = column[String]("party_id")
def userId: Rep[Long] = column[Long]("user_id", O.AutoInc, O.PrimaryKey)
def username: Rep[String] = column[String]("username")
def password: Rep[String] = column[String]("password")
def permission: Rep[String] = column[String]("permission")
def * =
(partyId, userId.?, username, password, permission) <> ((UserRep.apply _).tupled, UserRep.unapply)
def usersUsernameIdx: Index =
index("users_username_idx", (partyId, username), unique = true)
}
def deleteUser(partyId: String, username: String): Future[Int] =
db.run(user(partyId, Some(username)).delete)
def getUser(partyId: String, username: String): Future[Option[User]] =
db.run(user(partyId, Some(username)).result.headOption).map(_.map(_.toUser))
def getUsers(partyId: String): Future[Seq[User]] =
db.run(user(partyId, None).result).map(_.map(_.toUser))
def insertUser(user: User): Future[Int] = {
db.run(usersTable.insertOrUpdate(UserRep.fromUser(user)))
}
private def user(partyId: String, username: Option[String]) =
usersTable
.filter(_.partyId === partyId)
.filterIf(username.isDefined)(_.username === username.orNull)
}

View File

@ -0,0 +1,14 @@
package me.arcanis.ffxivbis.utils
import java.time.Duration
import java.util.concurrent.TimeUnit
import akka.util.Timeout
import scala.concurrent.duration.FiniteDuration
import scala.language.implicitConversions
object Implicits {
implicit def getFiniteDuration(duration: Duration): Timeout =
FiniteDuration(duration.toNanos, TimeUnit.NANOSECONDS)
}