demo with optional loot support

This commit is contained in:
Evgenii Alekseev 2020-03-17 02:13:21 +03:00
parent 10c107d2c2
commit 0171b229a1
19 changed files with 112 additions and 65 deletions

View File

@ -0,0 +1 @@
alter table loot add column is_free_loot integer not null default 0;

View File

@ -0,0 +1,20 @@
alter table loot add column is_free_loot integer;
update loot set is_free_loot = 0;
create table loot_new (
loot_id integer primary key autoincrement,
player_id integer not null,
created integer not null,
piece text not null,
piece_type text not null,
job text not null,
is_free_loot integer not null,
foreign key (player_id) references players(player_id) on delete cascade);
insert into loot_new select loot_id, player_id, created, piece, piece_type, job, is_free_loot from loot;
drop index loot_owner_idx;
drop table loot;
alter table loot_new rename to loot;
create index loot_owner_idx on loot(player_id);

View File

@ -22,15 +22,16 @@ trait LootHelper {
def storage: ActorRef
def addPieceLoot(playerId: PlayerId, piece: Piece)
def addPieceLoot(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
(storage ? DatabaseLootHandler.AddPieceTo(playerId, piece)).mapTo[Int]
(storage ? DatabaseLootHandler.AddPieceTo(playerId, piece, isFreeLoot)).mapTo[Int]
def doModifyLoot(action: ApiAction.Value, playerId: PlayerId, piece: Piece)
def doModifyLoot(action: ApiAction.Value, playerId: PlayerId, piece: Piece, maybeFree: Option[Boolean])
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Int] =
action match {
case ApiAction.add => addPieceLoot(playerId, piece)
case ApiAction.remove => removePieceLoot(playerId, piece)
(action, maybeFree) match {
case (ApiAction.add, Some(isFreeLoot)) => addPieceLoot(playerId, piece, isFreeLoot)
case (ApiAction.remove, _) => removePieceLoot(playerId, piece)
case _ => throw new IllegalArgumentException(s"Invalid combinantion of action $action and fee loot $maybeFree")
}
def loot(partyId: String, playerId: Option[PlayerId])

View File

@ -103,7 +103,7 @@ class LootEndpoint(override val storage: ActorRef)(implicit timeout: Timeout)
post {
entity(as[PieceActionResponse]) { action =>
val playerId = action.playerId.withPartyId(partyId)
onComplete(doModifyLoot(action.action, playerId, action.piece.toPiece)) {
onComplete(doModifyLoot(action.action, playerId, action.piece.toPiece, action.isFreeLoot)) {
case Success(_) => complete(StatusCodes.Accepted, HttpEntity.Empty)
case Failure(exception) => throw exception
}

View File

@ -41,12 +41,12 @@ trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
implicit val errorFormat: RootJsonFormat[ErrorResponse] = jsonFormat1(ErrorResponse.apply)
implicit val partyIdFormat: RootJsonFormat[PartyIdResponse] = jsonFormat1(PartyIdResponse.apply)
implicit val pieceFormat: RootJsonFormat[PieceResponse] = jsonFormat3(PieceResponse.apply)
implicit val lootFormat: RootJsonFormat[LootResponse] = jsonFormat2(LootResponse.apply)
implicit val lootFormat: RootJsonFormat[LootResponse] = jsonFormat3(LootResponse.apply)
implicit val partyDescriptionFormat: RootJsonFormat[PartyDescriptionResponse] = jsonFormat2(PartyDescriptionResponse.apply)
implicit val playerFormat: RootJsonFormat[PlayerResponse] = jsonFormat7(PlayerResponse.apply)
implicit val playerActionFormat: RootJsonFormat[PlayerActionResponse] = jsonFormat2(PlayerActionResponse.apply)
implicit val playerIdFormat: RootJsonFormat[PlayerIdResponse] = jsonFormat3(PlayerIdResponse.apply)
implicit val pieceActionFormat: RootJsonFormat[PieceActionResponse] = jsonFormat3(PieceActionResponse.apply)
implicit val pieceActionFormat: RootJsonFormat[PieceActionResponse] = jsonFormat4(PieceActionResponse.apply)
implicit val playerBiSLinkFormat: RootJsonFormat[PlayerBiSLinkResponse] = jsonFormat2(PlayerBiSLinkResponse.apply)
implicit val playerIdWithCountersFormat: RootJsonFormat[PlayerIdWithCountersResponse] =
jsonFormat9(PlayerIdWithCountersResponse.apply)

View File

@ -7,11 +7,12 @@ import me.arcanis.ffxivbis.models.Loot
case class LootResponse(
@Schema(description = "looted piece", required = true) piece: PieceResponse,
@Schema(description = "loot timestamp", required = true) timestamp: Instant) {
def toLoot: Loot = Loot(-1, piece.toPiece, timestamp)
@Schema(description = "loot timestamp", required = true) timestamp: Instant,
@Schema(description = "is loot free for all", required = true) isFreeLoot: Boolean) {
def toLoot: Loot = Loot(-1, piece.toPiece, timestamp, isFreeLoot)
}
object LootResponse {
def fromLoot(loot: Loot): LootResponse =
LootResponse(PieceResponse.fromPiece(loot.piece), loot.timestamp)
LootResponse(PieceResponse.fromPiece(loot.piece), loot.timestamp, loot.isFreeLoot)
}

View File

@ -13,4 +13,5 @@ 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) playerId: PlayerIdResponse)
@Schema(description = "player description", required = true) playerId: PlayerIdResponse,
@Schema(description = "is piece free to roll or not") isFreeLoot: Option[Boolean])

View File

@ -30,7 +30,7 @@ class LootSuggestView(override val storage: ActorRef)(implicit timeout: Timeout)
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
get {
complete {
val text = LootSuggestView.template(partyId, Seq.empty, None, None)
val text = LootSuggestView.template(partyId, Seq.empty, None, false, None)
(StatusCodes.OK, RootView.toHtml(text))
}
}
@ -43,17 +43,20 @@ class LootSuggestView(override val storage: ActorRef)(implicit timeout: Timeout)
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ =>
post {
formFields("piece".as[String], "job".as[String], "piece_type".as[String]) { (piece, job, pieceType) =>
val maybePiece = Try(Piece(piece, PieceType.withName(pieceType), Job.withName(job))).toOption
formFields("piece".as[String], "job".as[String], "piece_type".as[String], "free_loot".as[String].?) {
(piece, job, pieceType, maybeFreeLoot) =>
import me.arcanis.ffxivbis.utils.Implicits._
onComplete(suggestLootCall(partyId, maybePiece)) {
case Success(players) =>
val text = LootSuggestView.template(partyId, players, maybePiece, None)
complete(StatusCodes.OK, RootView.toHtml(text))
case Failure(exception) =>
val text = LootSuggestView.template(partyId, Seq.empty, maybePiece, Some(exception.getMessage))
complete(StatusCodes.OK, RootView.toHtml(text))
}
val maybePiece = Try(Piece(piece, PieceType.withName(pieceType), Job.withName(job))).toOption
onComplete(suggestLootCall(partyId, maybePiece)) {
case Success(players) =>
val text = LootSuggestView.template(partyId, players, maybePiece, maybeFreeLoot, None)
complete(StatusCodes.OK, RootView.toHtml(text))
case Failure(exception) =>
val text = LootSuggestView.template(partyId, Seq.empty, None, false, Some(exception.getMessage))
complete(StatusCodes.OK, RootView.toHtml(text))
}
}
}
}
@ -72,7 +75,8 @@ object LootSuggestView {
import scalatags.Text.all._
import scalatags.Text.tags2.{title => titleTag}
def template(partyId: String, party: Seq[PlayerIdWithCounters], piece: Option[Piece], error: Option[String]): String =
def template(partyId: String, party: Seq[PlayerIdWithCounters], piece: Option[Piece],
isFreeLoot: Boolean, error: Option[String]): String =
"<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\" \"http://www.w3.org/TR/html4/strict.dtd\">" +
html(lang:="en",
head(
@ -93,6 +97,8 @@ object LootSuggestView {
(for (job <- Job.availableWithAnyJob) yield option(job.toString)),
select(name:="piece_type", id:="piece_type", title:="piece type")
(for (pieceType <- PieceType.available) yield option(pieceType.toString)),
input(name:="free_loot", id:="free_loot", title:="is free loot", `type`:="checkbox"),
label(`for`:="free_loot")("is free loot"),
input(name:="suggest", id:="suggest", `type`:="submit", value:="suggest")
),
@ -116,6 +122,7 @@ object LootSuggestView {
input(name:="player", id:="player", `type`:="hidden", value:=player.playerId.toString),
input(name:="piece", id:="piece", `type`:="hidden", value:=piece.map(_.piece).getOrElse("")),
input(name:="piece_type", id:="piece_type", `type`:="hidden", value:=piece.map(_.pieceType.toString).getOrElse("")),
input(name:="free_loot", id:="free_loot", `type`:="hidden", value:=(if (isFreeLoot) "yes" else "no")),
input(name:="action", id:="action", `type`:="hidden", value:="add"),
input(name:="add", id:="add", `type`:="submit", value:="add")
)

View File

@ -46,9 +46,9 @@ class LootView (override val storage: ActorRef)(implicit timeout: Timeout)
extractExecutionContext { implicit executionContext =>
authenticateBasicBCrypt(s"party $partyId", authPost(partyId)) { _ =>
post {
formFields("player".as[String], "piece".as[String], "piece_type".as[String], "action".as[String]) {
(player, piece, pieceType, action) =>
onComplete(modifyLootCall(partyId, player, piece, pieceType, action)) { _ =>
formFields("player".as[String], "piece".as[String], "piece_type".as[String], "action".as[String], "free_loot".as[String].?) {
(player, piece, pieceType, action, isFreeLoot) =>
onComplete(modifyLootCall(partyId, player, piece, pieceType, isFreeLoot, action)) { _ =>
redirect(s"/party/$partyId/loot", StatusCodes.Found)
}
}
@ -58,14 +58,17 @@ class LootView (override val storage: ActorRef)(implicit timeout: Timeout)
}
private def modifyLootCall(partyId: String, player: String, maybePiece: String,
maybePieceType: String, action: String)
maybePieceType: String, maybeFreeLoot: Option[String],
action: String)
(implicit executionContext: ExecutionContext, timeout: Timeout): Future[Unit] = {
import me.arcanis.ffxivbis.utils.Implicits._
def getPiece(playerId: PlayerId) =
Try(Piece(maybePiece, PieceType.withName(maybePieceType), playerId.job)).toOption
PlayerId(partyId, player) match {
case Some(playerId) => (getPiece(playerId), action) match {
case (Some(piece), "add") => addPieceLoot(playerId, piece).map(_ => ())
case (Some(piece), "add") => addPieceLoot(playerId, piece, maybeFreeLoot).map(_ => ())
case (Some(piece), "remove") => removePieceLoot(playerId, piece).map(_ => ())
case _ => Future.failed(new Error(s"Could not construct piece from `$maybePiece ($maybePieceType)`"))
}
@ -99,6 +102,8 @@ object LootView {
(for (piece <- Piece.available) yield option(piece)),
select(name:="piece_type", id:="piece_type", title:="piece type")
(for (pieceType <- PieceType.available) yield option(pieceType.toString)),
input(name:="free_loot", id:="free_loot", title:="is free loot", `type`:="checkbox"),
label(`for`:="free_loot")("is free loot"),
input(name:="action", id:="action", `type`:="hidden", value:="add"),
input(name:="add", id:="add", `type`:="submit", value:="add")
),
@ -108,6 +113,7 @@ object LootView {
th("player"),
th("piece"),
th("piece type"),
th("is free loot"),
th("timestamp"),
th("")
),
@ -115,12 +121,14 @@ object LootView {
td(`class`:="include_search")(player.playerId.toString),
td(`class`:="include_search")(loot.piece.piece),
td(loot.piece.pieceType.toString),
td(loot.isFreeLootToString),
td(loot.timestamp.toString),
td(
form(action:=s"/party/$partyId/loot", method:="post")(
input(name:="player", id:="player", `type`:="hidden", value:=player.playerId.toString),
input(name:="piece", id:="piece", `type`:="hidden", value:=loot.piece.piece),
input(name:="piece_type", id:="piece_type", `type`:="hidden", value:=loot.piece.pieceType.toString),
input(name:="free_loot", id:="free_loot", `type`:="hidden", value:=loot.isFreeLootToString),
input(name:="action", id:="action", `type`:="hidden", value:="remove"),
input(name:="remove", id:="remove", `type`:="submit", value:="x")
)

View File

@ -10,4 +10,6 @@ package me.arcanis.ffxivbis.models
import java.time.Instant
case class Loot(playerId: Long, piece: Piece, timestamp: Instant)
case class Loot(playerId: Long, piece: Piece, timestamp: Instant, isFreeLoot: Boolean) {
def isFreeLootToString: String = if (isFreeLoot) "yes" else "no"
}

View File

@ -31,10 +31,7 @@ case class Player(id: Long,
def withLoot(piece: Loot): Player = withLoot(Seq(piece))
def withLoot(list: Seq[Loot]): Player = {
require(loot.forall(_.playerId == id), "player id must be same")
list match {
case Nil => this
case _ => copy(loot = list)
}
copy(loot = loot ++ list)
}
def isRequired(piece: Option[Piece]): Boolean = {
@ -48,10 +45,10 @@ case class Player(id: Long,
def bisCountTotal(piece: Option[Piece]): Int = bis.pieces.count(_.pieceType == PieceType.Savage)
def lootCount(piece: Option[Piece]): Int = piece match {
case Some(p) => loot.count(_.piece == p)
case Some(p) => loot.count(item => !item.isFreeLoot && item.piece == p)
case None => lootCountTotal(piece)
}
def lootCountBiS(piece: Option[Piece]): Int = loot.map(_.piece).count(bis.hasPiece)
def lootCountTotal(piece: Option[Piece]): Int = loot.length
def lootCountTotal(piece: Option[Piece]): Int = loot.count(!_.isFreeLoot)
def lootPriority(piece: Piece): Int = priority
}

View File

@ -8,17 +8,20 @@
*/
package me.arcanis.ffxivbis.service.impl
import java.time.Instant
import akka.pattern.pipe
import me.arcanis.ffxivbis.models.{Piece, PlayerId}
import me.arcanis.ffxivbis.models.{Loot, Piece, PlayerId}
import me.arcanis.ffxivbis.service.Database
trait DatabaseLootHandler { this: Database =>
import DatabaseLootHandler._
def lootHandler: Receive = {
case AddPieceTo(playerId, piece) =>
case AddPieceTo(playerId, piece, isFreeLoot) =>
val client = sender()
profile.insertPiece(playerId, piece).pipeTo(client)
val loot = Loot(-1, piece, Instant.now, isFreeLoot)
profile.insertPiece(playerId, loot).pipeTo(client)
case GetLoot(partyId, maybePlayerId) =>
val client = sender()
@ -37,7 +40,7 @@ trait DatabaseLootHandler { this: Database =>
}
object DatabaseLootHandler {
case class AddPieceTo(playerId: PlayerId, piece: Piece) extends Database.DatabaseRequest {
case class AddPieceTo(playerId: PlayerId, piece: Piece, isFreeLoot: Boolean) extends Database.DatabaseRequest {
override def partyId: String = playerId.partyId
}
case class GetLoot(partyId: String, playerId: Option[PlayerId]) extends Database.DatabaseRequest

View File

@ -22,7 +22,7 @@ trait BiSProfile { this: DatabaseProfile =>
pieceType: String, job: String) {
def toLoot: Loot = Loot(
playerId, Piece(piece, PieceType.withName(pieceType), Job.withName(job)),
Instant.ofEpochMilli(created))
Instant.ofEpochMilli(created), isFreeLoot = false)
}
object BiSRep {
def fromPiece(playerId: Long, piece: Piece): BiSRep =

View File

@ -44,14 +44,17 @@ class DatabaseProfile(context: ExecutionContext, config: Config)
byPlayerId(playerId, insertPieceBiSById(piece))
// generic loot api
def deletePiece(playerId: PlayerId, piece: Piece): Future[Int] =
byPlayerId(playerId, deletePieceById(piece))
def deletePiece(playerId: PlayerId, piece: Piece): Future[Int] = {
// we don't really care here about loot
val loot = Loot(-1, piece, Instant.now, isFreeLoot = false)
byPlayerId(playerId, deletePieceById(loot))
}
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))
def insertPiece(playerId: PlayerId, loot: Loot): Future[Int] =
byPlayerId(playerId, insertPieceById(loot))
private def byPartyId[T](partyId: String, callback: Seq[Long] => Future[T]): Future[T] =
getPlayers(partyId).map(callback).flatten

View File

@ -19,16 +19,18 @@ trait LootProfile { this: DatabaseProfile =>
import dbConfig.profile.api._
case class LootRep(lootId: Option[Long], playerId: Long, created: Long,
piece: String, pieceType: String, job: String) {
piece: String, pieceType: String, job: String,
isFreeLoot: Int) {
def toLoot: Loot = Loot(
playerId,
Piece(piece, PieceType.withName(pieceType), Job.withName(job)),
Instant.ofEpochMilli(created))
Instant.ofEpochMilli(created), isFreeLoot == 1)
}
object LootRep {
def fromPiece(playerId: Long, piece: Piece): LootRep =
LootRep(None, playerId, DatabaseProfile.now, piece.piece,
piece.pieceType.toString, piece.job.toString)
def fromLoot(playerId: Long, loot: Loot): LootRep =
LootRep(None, playerId, loot.timestamp.toEpochMilli, loot.piece.piece,
loot.piece.pieceType.toString, loot.piece.job.toString,
if (loot.isFreeLoot) 1 else 0)
}
class LootPieces(tag: Tag) extends Table[LootRep](tag, "loot") {
@ -38,9 +40,10 @@ trait LootProfile { this: DatabaseProfile =>
def piece: Rep[String] = column[String]("piece")
def pieceType: Rep[String] = column[String]("piece_type")
def job: Rep[String] = column[String]("job")
def isFreeLoot: Rep[Int] = column[Int]("is_free_loot")
def * =
(lootId.?, playerId, created, piece, pieceType, job) <> ((LootRep.apply _).tupled, LootRep.unapply)
(lootId.?, playerId, created, piece, pieceType, job, isFreeLoot) <> ((LootRep.apply _).tupled, LootRep.unapply)
def fkPlayerId: ForeignKeyQuery[Players, PlayerRep] =
foreignKey("player_id", playerId, playersTable)(_.playerId, onDelete = ForeignKeyAction.Cascade)
@ -48,16 +51,16 @@ trait LootProfile { this: DatabaseProfile =>
index("loot_owner_idx", (playerId), unique = false)
}
def deletePieceById(piece: Piece)(playerId: Long): Future[Int] =
db.run(pieceLoot(LootRep.fromPiece(playerId, piece)).map(_.lootId).max.result).flatMap {
def deletePieceById(loot: Loot)(playerId: Long): Future[Int] =
db.run(pieceLoot(LootRep.fromLoot(playerId, loot)).map(_.lootId).max.result).flatMap {
case Some(id) => db.run(lootTable.filter(_.lootId === id).delete)
case _ => throw new IllegalArgumentException(s"Could not find piece $piece belong to $playerId")
case _ => throw new IllegalArgumentException(s"Could not find piece $loot belong to $playerId")
}
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)))
def insertPieceById(loot: Loot)(playerId: Long): Future[Int] =
db.run(lootTable.insertOrUpdate(LootRep.fromLoot(playerId, loot)))
private def pieceLoot(piece: LootRep) =
piecesLoot(Seq(piece.playerId)).filter(_.piece === piece.piece)

View File

@ -70,7 +70,7 @@ class BiSEndpointTest extends WordSpec
"remove item from best in slot set" in {
val piece = PieceResponse.fromPiece(Fixtures.lootBody)
val entity = PieceActionResponse(ApiAction.remove, piece, playerId)
val entity = PieceActionResponse(ApiAction.remove, piece, playerId, None)
Post(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.Accepted
@ -89,7 +89,7 @@ class BiSEndpointTest extends WordSpec
"add item to best in slot set" in {
val piece = PieceResponse.fromPiece(Fixtures.lootBody)
val entity = PieceActionResponse(ApiAction.add, piece, playerId)
val entity = PieceActionResponse(ApiAction.add, piece, playerId, None)
Post(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.Accepted

View File

@ -51,7 +51,7 @@ class LootEndpointTest extends WordSpec
"add item to loot" in {
val piece = PieceResponse.fromPiece(Fixtures.lootBody)
val entity = PieceActionResponse(ApiAction.add, piece, playerId)
val entity = PieceActionResponse(ApiAction.add, piece, playerId, Some(false))
Post(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.Accepted
@ -76,7 +76,7 @@ class LootEndpointTest extends WordSpec
"remove item from loot" in {
val piece = PieceResponse.fromPiece(Fixtures.lootBody)
val entity = PieceActionResponse(ApiAction.remove, piece, playerId)
val entity = PieceActionResponse(ApiAction.remove, piece, playerId, Some(false))
Post(endpoint, entity).withHeaders(auth) ~> route ~> check {
status shouldEqual StatusCodes.Accepted

View File

@ -34,7 +34,7 @@ class DatabaseLootHandlerTest
"add loot" in {
Fixtures.loot.foreach { piece =>
database ! impl.DatabaseLootHandler.AddPieceTo(Fixtures.playerEmpty.playerId, piece)
database ! impl.DatabaseLootHandler.AddPieceTo(Fixtures.playerEmpty.playerId, piece, isFreeLoot = false)
expectMsg(timeout, 1)
}
}
@ -66,11 +66,11 @@ class DatabaseLootHandlerTest
}
"add same loot" in {
database ! impl.DatabaseLootHandler.AddPieceTo(Fixtures.playerEmpty.playerId, Fixtures.lootBody)
database ! impl.DatabaseLootHandler.AddPieceTo(Fixtures.playerEmpty.playerId, Fixtures.lootBody, isFreeLoot = false)
expectMsg(timeout, 1)
Fixtures.loot.foreach { piece =>
database ! impl.DatabaseLootHandler.AddPieceTo(Fixtures.playerEmpty.playerId, piece)
database ! impl.DatabaseLootHandler.AddPieceTo(Fixtures.playerEmpty.playerId, piece, isFreeLoot = false)
expectMsg(timeout, 1)
}

View File

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