add more tests

* also make auth provider more powerful
This commit is contained in:
Evgenii Alekseev 2022-01-23 04:34:39 +03:00
parent 0ab9162cb5
commit ccbf581332
15 changed files with 310 additions and 45 deletions

View File

@ -37,23 +37,16 @@ trait Authorization {
def authAdmin(partyId: String)(username: String, password: String)(implicit def authAdmin(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext executionContext: ExecutionContext
): Future[Option[User]] = ): Future[Option[User]] =
authenticator(Permission.admin, partyId)(username, password) auth.authenticator(Permission.admin, partyId)(username, password)
def authGet(partyId: String)(username: String, password: String)(implicit def authGet(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext executionContext: ExecutionContext
): Future[Option[User]] = ): Future[Option[User]] =
authenticator(Permission.get, partyId)(username, password) auth.authenticator(Permission.get, partyId)(username, password)
def authPost(partyId: String)(username: String, password: String)(implicit def authPost(partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext executionContext: ExecutionContext
): Future[Option[User]] = ): Future[Option[User]] =
authenticator(Permission.post, partyId)(username, password) auth.authenticator(Permission.post, partyId)(username, password)
private def authenticator(scope: Permission.Value, partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext
): Future[Option[User]] =
auth.get(partyId, username).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(user)
case _ => None
}
} }

View File

@ -14,19 +14,31 @@ import akka.util.Timeout
import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache} import com.google.common.cache.{CacheBuilder, CacheLoader, LoadingCache}
import com.typesafe.config.Config import com.typesafe.config.Config
import me.arcanis.ffxivbis.messages.{GetUser, Message} import me.arcanis.ffxivbis.messages.{GetUser, Message}
import me.arcanis.ffxivbis.models.User import me.arcanis.ffxivbis.models.{Permission, User}
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import scala.concurrent.Future import scala.concurrent.{ExecutionContext, Future}
trait AuthorizationProvider { trait AuthorizationProvider {
def get(partyId: String, username: String): Future[Option[User]] def get(partyId: String, username: String): Future[Option[User]]
def authenticator[T](scope: Permission.Value, partyId: String)(username: String, password: String)(implicit
executionContext: ExecutionContext,
extractor: User => T
): Future[Option[T]] =
get(partyId, username).map {
case Some(user) if user.verify(password) && user.verityScope(scope) => Some(extractor(user))
case _ => None
}
} }
object AuthorizationProvider { object AuthorizationProvider {
def apply(config: Config, storage: ActorRef[Message], timeout: Timeout, scheduler: Scheduler): AuthorizationProvider = def apply(config: Config, storage: ActorRef[Message])(implicit
timeout: Timeout,
scheduler: Scheduler
): AuthorizationProvider =
new AuthorizationProvider { new AuthorizationProvider {
private val cacheSize = config.getInt("me.arcanis.ffxivbis.web.authorization-cache.cache-size") private val cacheSize = config.getInt("me.arcanis.ffxivbis.web.authorization-cache.cache-size")
private val cacheTimeout = private val cacheTimeout =

View File

@ -28,7 +28,7 @@ class RootEndpoint(system: ActorSystem[Nothing], storage: ActorRef[Message], pro
implicit val scheduler: Scheduler = system.scheduler implicit val scheduler: Scheduler = system.scheduler
implicit val timeout: Timeout = config.getTimeout("me.arcanis.ffxivbis.settings.request-timeout") implicit val timeout: Timeout = config.getTimeout("me.arcanis.ffxivbis.settings.request-timeout")
private val auth = AuthorizationProvider(config, storage, timeout, scheduler) private val auth = AuthorizationProvider(config, storage)
private val rootApiV1Endpoint = new RootApiV1Endpoint(storage, auth, provider, config) private val rootApiV1Endpoint = new RootApiV1Endpoint(storage, auth, provider, config)
private val rootView = new RootView(auth) private val rootView = new RootView(auth)

View File

@ -8,36 +8,38 @@
*/ */
package me.arcanis.ffxivbis.messages package me.arcanis.ffxivbis.messages
import akka.actor.typed.{ActorRef, Behavior} import akka.actor.typed.ActorRef
import me.arcanis.ffxivbis.models._ import me.arcanis.ffxivbis.models._
import me.arcanis.ffxivbis.service.LootSelector import me.arcanis.ffxivbis.service.LootSelector
sealed trait DatabaseMessage extends Message { sealed trait DatabaseMessage extends Message {
def partyId: String def partyId: String
}
object DatabaseMessage { def isReadOnly: Boolean
type Handler = PartialFunction[DatabaseMessage, Behavior[DatabaseMessage]]
} }
// bis handler // bis handler
trait BisDatabaseMessage extends DatabaseMessage trait BisDatabaseMessage extends DatabaseMessage
case class AddPieceToBis(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage { case class AddPieceToBis(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override def partyId: String = playerId.partyId override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
} }
case class GetBiS(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) case class GetBiS(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
extends BisDatabaseMessage extends BisDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage { case class RemovePieceFromBiS(playerId: PlayerId, piece: Piece, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override def partyId: String = playerId.partyId override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
} }
case class RemovePiecesFromBiS(playerId: PlayerId, replyTo: ActorRef[Unit]) extends BisDatabaseMessage { case class RemovePiecesFromBiS(playerId: PlayerId, replyTo: ActorRef[Unit]) extends BisDatabaseMessage {
override def partyId: String = playerId.partyId override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
} }
// loot handler // loot handler
@ -45,54 +47,77 @@ trait LootDatabaseMessage extends DatabaseMessage
case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit]) case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends LootDatabaseMessage { extends LootDatabaseMessage {
override def partyId: String = playerId.partyId override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
} }
case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]]) case class GetLoot(partyId: String, playerId: Option[PlayerId], replyTo: ActorRef[Seq[Player]])
extends LootDatabaseMessage extends LootDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class RemovePieceFrom(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit]) case class RemovePieceFrom(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean, replyTo: ActorRef[Unit])
extends LootDatabaseMessage { extends LootDatabaseMessage {
override def partyId: String = playerId.partyId override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
} }
case class SuggestLoot(partyId: String, piece: Piece, replyTo: ActorRef[LootSelector.LootSelectorResult]) case class SuggestLoot(partyId: String, piece: Piece, replyTo: ActorRef[LootSelector.LootSelectorResult])
extends LootDatabaseMessage extends LootDatabaseMessage {
override val isReadOnly: Boolean = true
}
// party handler // party handler
trait PartyDatabaseMessage extends DatabaseMessage trait PartyDatabaseMessage extends DatabaseMessage
case class AddPlayer(player: Player, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage { case class AddPlayer(player: Player, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override def partyId: String = player.partyId override val partyId: String = player.partyId
override val isReadOnly: Boolean = false
} }
case class GetParty(partyId: String, replyTo: ActorRef[Party]) extends PartyDatabaseMessage case class GetParty(partyId: String, replyTo: ActorRef[Party]) extends PartyDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends PartyDatabaseMessage case class GetPartyDescription(partyId: String, replyTo: ActorRef[PartyDescription]) extends PartyDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetPlayer(playerId: PlayerId, replyTo: ActorRef[Option[Player]]) extends PartyDatabaseMessage { case class GetPlayer(playerId: PlayerId, replyTo: ActorRef[Option[Player]]) extends PartyDatabaseMessage {
override def partyId: String = playerId.partyId override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = true
} }
case class RemovePlayer(playerId: PlayerId, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage { case class RemovePlayer(playerId: PlayerId, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override def partyId: String = playerId.partyId override val partyId: String = playerId.partyId
override val isReadOnly: Boolean = false
} }
case class UpdateParty(partyDescription: PartyDescription, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage { case class UpdateParty(partyDescription: PartyDescription, replyTo: ActorRef[Unit]) extends PartyDatabaseMessage {
override def partyId: String = partyDescription.partyId override val partyId: String = partyDescription.partyId
override val isReadOnly: Boolean = false
} }
// user handler // user handler
trait UserDatabaseMessage extends DatabaseMessage trait UserDatabaseMessage extends DatabaseMessage
case class AddUser(user: User, isHashedPassword: Boolean, replyTo: ActorRef[Unit]) extends UserDatabaseMessage { case class AddUser(user: User, isHashedPassword: Boolean, replyTo: ActorRef[Unit]) extends UserDatabaseMessage {
override def partyId: String = user.partyId override val partyId: String = user.partyId
override val isReadOnly: Boolean = false
} }
case class DeleteUser(partyId: String, username: String, replyTo: ActorRef[Unit]) extends UserDatabaseMessage case class DeleteUser(partyId: String, username: String, replyTo: ActorRef[Unit]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class Exists(partyId: String, replyTo: ActorRef[Boolean]) extends UserDatabaseMessage case class Exists(partyId: String, replyTo: ActorRef[Boolean]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetUser(partyId: String, username: String, replyTo: ActorRef[Option[User]]) extends UserDatabaseMessage case class GetUser(partyId: String, username: String, replyTo: ActorRef[Option[User]]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}
case class GetUsers(partyId: String, replyTo: ActorRef[Seq[User]]) extends UserDatabaseMessage case class GetUsers(partyId: String, replyTo: ActorRef[Seq[User]]) extends UserDatabaseMessage {
override val isReadOnly: Boolean = true
}

View File

@ -16,7 +16,6 @@ import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages._ import me.arcanis.ffxivbis.messages._
import me.arcanis.ffxivbis.models.Party import me.arcanis.ffxivbis.models.Party
import scala.concurrent.duration.FiniteDuration
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMessage]) class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMessage])
@ -62,7 +61,8 @@ class PartyService(context: ActorContext[Message], storage: ActorRef[DatabaseMes
case req: DatabaseMessage => case req: DatabaseMessage =>
storage ! req storage ! req
Behaviors.receiveMessage(handle(cache - req.partyId)) val result = if (req.isReadOnly) cache else cache - req.partyId
Behaviors.receiveMessage(handle(result))
} }
private def getPartyId: Future[String] = { private def getPartyId: Future[String] = {

View File

@ -84,4 +84,5 @@ object Fixtures {
lazy val users: Seq[User] = Seq(userAdmin, userGet) lazy val users: Seq[User] = Seq(userAdmin, userGet)
lazy val authProvider: AuthorizationProvider = (_: String, _: String) => Future.successful(Some(userAdmin)) lazy val authProvider: AuthorizationProvider = (_: String, _: String) => Future.successful(Some(userAdmin))
lazy val rejectingProvider: AuthorizationProvider = (_: String, _: String) => Future.successful(None)
} }

View File

@ -0,0 +1,44 @@
package me.arcanis.ffxivbis.http
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.model.headers.{Authorization, BasicHttpCredentials}
import akka.http.scaladsl.server.Route
import akka.http.scaladsl.testkit.ScalatestRouteTest
import me.arcanis.ffxivbis.Fixtures
import me.arcanis.ffxivbis.http.view.RootView
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class AuthorizationTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val auth =
Authorization(BasicHttpCredentials(Fixtures.userAdmin.username, Fixtures.userPassword))
"authorization directive" must {
"accept credentials" in {
val route = new RootView(Fixtures.authProvider).routes
Get(Uri(s"/party/${Fixtures.partyId}")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
}
}
"reject credentials" in {
val route = new RootView(Fixtures.rejectingProvider).routes
Get(Uri(s"/party/${Fixtures.partyId}")).withHeaders(auth) ~> Route.seal(route) ~> check {
status shouldEqual StatusCodes.Unauthorized
}
}
"reject with empty credentials" in {
val route = new RootView(Fixtures.authProvider).routes
Get(Uri(s"/party/${Fixtures.partyId}")) ~> Route.seal(route) ~> check {
status shouldEqual StatusCodes.Unauthorized
}
}
}
}

View File

@ -0,0 +1,22 @@
package me.arcanis.ffxivbis.http
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.server.Directives.complete
import akka.http.scaladsl.testkit.ScalatestRouteTest
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class HttpLogTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val log = new HttpLog {}
"log directive" must {
"work with empty request" in {
Get(Uri("/")) ~> log.withHttpLog(complete(StatusCodes.OK)) ~> check {
status shouldEqual StatusCodes.OK
}
}
}
}

View File

@ -0,0 +1,39 @@
package me.arcanis.ffxivbis.http
import akka.actor.testkit.typed.scaladsl.ActorTestKit
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.testkit.ScalatestRouteTest
import com.typesafe.config.Config
import me.arcanis.ffxivbis.Settings
import me.arcanis.ffxivbis.service.PartyService
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.database.Database
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class RootEndpointTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val testKit = ActorTestKit(Settings.withRandomDatabase)
override val testConfig: Config = testKit.system.settings.config
private val storage = testKit.spawn(Database())
private val provider = testKit.spawn(BisProvider())
private val party = testKit.spawn(PartyService(storage))
private val route = new RootEndpoint(testKit.system, party, provider).routes
"root route" must {
"return swagger ui" in {
Get(Uri("/api-docs")) ~> route ~> check {
status shouldEqual StatusCodes.OK
}
}
"return static routes" in {
Get(Uri("/static/favicon.ico")) ~> route ~> check {
status shouldEqual StatusCodes.OK
}
}
}
}

View File

@ -0,0 +1,28 @@
package me.arcanis.ffxivbis.http
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.testkit.ScalatestRouteTest
import me.arcanis.ffxivbis.Settings
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class SwaggerTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val swagger = new Swagger(Settings.withRandomDatabase)
"swagger guard" must {
"generate json" in {
Get(Uri("/api-docs/swagger.json")) ~> swagger.routes ~> check {
status shouldEqual StatusCodes.OK
}
}
"generate yml" in {
Get(Uri("/api-docs/swagger.yaml")) ~> swagger.routes ~> check {
status shouldEqual StatusCodes.OK
}
}
}
}

View File

@ -95,5 +95,15 @@ class LootEndpointTest extends AnyWordSpecLike with Matchers with ScalatestRoute
} }
} }
"suggest loot" in {
val entity = PieceModel.fromPiece(Fixtures.lootBody)
val response = Seq(Fixtures.playerEmpty.withCounters(Some(Fixtures.lootBody))).map(PlayerIdWithCountersModel.fromPlayerId)
Put(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[Seq[PlayerIdWithCountersModel]] shouldEqual response
}
}
} }
} }

View File

@ -7,6 +7,7 @@ import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest} import akka.http.scaladsl.testkit.{RouteTestTimeout, ScalatestRouteTest}
import akka.testkit.TestKit import akka.testkit.TestKit
import com.typesafe.config.Config import com.typesafe.config.Config
import me.arcanis.ffxivbis
import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.http.api.v1.json._
import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser} import me.arcanis.ffxivbis.messages.{AddPlayer, AddUser}
import me.arcanis.ffxivbis.service.PartyService import me.arcanis.ffxivbis.service.PartyService
@ -52,7 +53,7 @@ class PlayerEndpointTest extends AnyWordSpecLike with Matchers with ScalatestRou
"api v1 player endpoint" must { "api v1 player endpoint" must {
"get users" in { "get users belonging to the party" in {
val response = Seq(PlayerModel.fromPlayer(Fixtures.playerEmpty)) val response = Seq(PlayerModel.fromPlayer(Fixtures.playerEmpty))
Get(endpoint).withHeaders(auth) ~> route ~> check { Get(endpoint).withHeaders(auth) ~> route ~> check {
@ -61,5 +62,42 @@ class PlayerEndpointTest extends AnyWordSpecLike with Matchers with ScalatestRou
} }
} }
"get party stats" in {
val response = Seq(PlayerIdWithCountersModel.fromPlayerId(Fixtures.playerEmpty.withCounters(None)))
Get(endpoint.withPath(endpoint.path / "stats")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[Seq[PlayerIdWithCountersModel]] shouldEqual response
}
}
"add new player to the party" in {
val entity = PlayerActionModel(ApiAction.add, PlayerModel.fromPlayer(Fixtures.playerWithBiS))
Post(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.Accepted
responseAs[String] shouldEqual ""
}
Get(endpoint).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[Seq[PlayerModel]].map(_.toPlayer.playerId) should contain(Fixtures.playerWithBiS.playerId)
}
}
"remove player from the party" in {
val entity = PlayerActionModel(ApiAction.remove, PlayerModel.fromPlayer(Fixtures.playerEmpty))
Post(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.Accepted
responseAs[String] shouldEqual ""
}
Get(endpoint).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[Seq[PlayerModel]].map(_.toPlayer.playerId) should not contain(Fixtures.playerEmpty.playerId)
}
}
} }
} }

View File

@ -2,8 +2,6 @@ package me.arcanis.ffxivbis.http.api.v1
import akka.http.scaladsl.model.StatusCodes import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.testkit.ScalatestRouteTest import akka.http.scaladsl.testkit.ScalatestRouteTest
import com.typesafe.config.Config
import me.arcanis.ffxivbis.Settings
import me.arcanis.ffxivbis.http.api.v1.json._ import me.arcanis.ffxivbis.http.api.v1.json._
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike import org.scalatest.wordspec.AnyWordSpecLike
@ -13,8 +11,6 @@ import scala.language.postfixOps
class StatusEndpointTest extends AnyWordSpecLike class StatusEndpointTest extends AnyWordSpecLike
with Matchers with ScalatestRouteTest with JsonSupport { with Matchers with ScalatestRouteTest with JsonSupport {
override val testConfig: Config = Settings.withRandomDatabase
private val route = new StatusEndpoint().routes private val route = new StatusEndpoint().routes
"api v1 status endpoint" must { "api v1 status endpoint" must {

View File

@ -0,0 +1,55 @@
package me.arcanis.ffxivbis.http.view
import akka.http.scaladsl.model.{StatusCodes, Uri}
import akka.http.scaladsl.model.headers.{Authorization, BasicHttpCredentials}
import akka.http.scaladsl.testkit.ScalatestRouteTest
import me.arcanis.ffxivbis.Fixtures
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpecLike
class RootViewTest extends AnyWordSpecLike with Matchers with ScalatestRouteTest {
private val auth =
Authorization(BasicHttpCredentials(Fixtures.userAdmin.username, Fixtures.userPassword))
private val route = new RootView(Fixtures.authProvider).routes
"html view endpoint" must {
"return root view" in {
Get(Uri("/")) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
"return root party view" in {
Get(Uri(s"/party/${Fixtures.partyId}")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
"return bis view" in {
Get(Uri(s"/party/${Fixtures.partyId}/bis")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
"return loot view" in {
Get(Uri(s"/party/${Fixtures.partyId}/loot")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
"return users view" in {
Get(Uri(s"/party/${Fixtures.partyId}/users")).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.OK
responseAs[String] should not be empty
}
}
}
}

View File

@ -6,5 +6,7 @@ import java.time.Instant
import scala.language.implicitConversions import scala.language.implicitConversions
object Converters { object Converters {
implicit def pieceToLoot(piece: Piece): Loot = Loot(-1, piece, Instant.ofEpochMilli(0), isFreeLoot = false) implicit def pieceToLoot(piece: Piece): Loot = Loot(-1, piece, Instant.ofEpochMilli(0), isFreeLoot = false)
} }