From f7f112330b2f0ec9d91d2cc0b326a8d9774c2851 Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Thu, 8 Jan 2026 14:24:09 +0200 Subject: [PATCH] feat: multi loot selector preview --- .../arcanis/ffxivbis/http/RootEndpoint.scala | 3 + .../ffxivbis/http/api/v2/HttpHandler.scala | 14 +++ .../ffxivbis/http/api/v2/LootEndpoint.scala | 107 ++++++++++++++++++ .../http/api/v2/RootApiV2Endpoint.scala | 36 ++++++ .../http/api/v2/json/JsonSupport.scala | 17 +++ .../http/api/v2/json/PiecesModel.scala | 25 ++++ .../ffxivbis/http/helpers/LootHelper.scala | 7 ++ .../ffxivbis/messages/DatabaseMessage.scala | 5 + .../me/arcanis/ffxivbis/models/Party.scala | 4 +- .../ffxivbis/service/LootSelector.scala | 8 +- .../arcanis/ffxivbis/service/bis/XivApi.scala | 1 + .../database/impl/DatabaseLootHandler.scala | 9 +- .../ffxivbis/service/LootSelectorTest.scala | 14 +-- 13 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v2/HttpHandler.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v2/LootEndpoint.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v2/RootApiV2Endpoint.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v2/json/JsonSupport.scala create mode 100644 src/main/scala/me/arcanis/ffxivbis/http/api/v2/json/PiecesModel.scala diff --git a/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala index e7202d2..19d8ae2 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/RootEndpoint.scala @@ -15,6 +15,7 @@ import akka.util.Timeout import ch.megard.akka.http.cors.scaladsl.CorsDirectives.cors import com.typesafe.scalalogging.StrictLogging import me.arcanis.ffxivbis.http.api.v1.RootApiV1Endpoint +import me.arcanis.ffxivbis.http.api.v2.RootApiV2Endpoint import me.arcanis.ffxivbis.http.view.RootView import me.arcanis.ffxivbis.messages.{BiSProviderMessage, Message} @@ -31,6 +32,7 @@ class RootEndpoint(system: ActorSystem[Nothing], storage: ActorRef[Message], pro private val auth = AuthorizationProvider(config, storage) private val rootApiV1Endpoint = new RootApiV1Endpoint(storage, auth, provider, config) + private val rootApiV2Endpoint = new RootApiV2Endpoint(storage, auth) private val rootView = new RootView(auth) private val swagger = new Swagger(config) @@ -47,6 +49,7 @@ class RootEndpoint(system: ActorSystem[Nothing], storage: ActorRef[Message], pro pathPrefix("api") { pathPrefix(Segment) { case "v1" => rootApiV1Endpoint.routes + case "v2" => rootApiV2Endpoint.routes case _ => reject } } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v2/HttpHandler.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/HttpHandler.scala new file mode 100644 index 0000000..f598a6a --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/HttpHandler.scala @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2019-2022 Evgeniy Alekseev. + * + * This file is part of ffxivbis + * (see https://github.com/arcan1s/ffxivbis). + * + * License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause + */ +package me.arcanis.ffxivbis.http.api.v2 + +import me.arcanis.ffxivbis.http.api.v1.{HttpHandler => HttpHandlerV1} +import me.arcanis.ffxivbis.http.api.v2.json.JsonSupport + +trait HttpHandler extends HttpHandlerV1 { this: JsonSupport => } diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v2/LootEndpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/LootEndpoint.scala new file mode 100644 index 0000000..da8ce8b --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/LootEndpoint.scala @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2019-2022 Evgeniy Alekseev. + * + * This file is part of ffxivbis + * (see https://github.com/arcan1s/ffxivbis). + * + * License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause + */ +package me.arcanis.ffxivbis.http.api.v2 + +import akka.actor.typed.{ActorRef, Scheduler} +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.parameters.RequestBody +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.security.SecurityRequirement +import io.swagger.v3.oas.annotations.{Operation, Parameter} +import jakarta.ws.rs._ +import me.arcanis.ffxivbis.http.api.v1.json.{ErrorModel, PieceModel, PlayerIdWithCountersModel} +import me.arcanis.ffxivbis.http.api.v2.json._ +import me.arcanis.ffxivbis.http.helpers.LootHelper +import me.arcanis.ffxivbis.http.{Authorization, AuthorizationProvider} +import me.arcanis.ffxivbis.messages.Message + +@Path("/api/v2") +class LootEndpoint(override val storage: ActorRef[Message], override val auth: AuthorizationProvider)(implicit + timeout: Timeout, + scheduler: Scheduler +) extends LootHelper + with Authorization + with JsonSupport + with HttpHandler { + + def routes: Route = suggestLoot + + @PUT + @Path("party/{partyId}/loot") + @Consumes(value = Array("application/json")) + @Produces(value = Array("application/json")) + @Operation( + summary = "suggest loot", + description = "Suggest loot pieces to party", + parameters = Array( + new Parameter( + name = "partyId", + in = ParameterIn.PATH, + description = "unique party ID", + example = "o3KicHQPW5b0JcOm5yI3" + ), + ), + requestBody = new RequestBody( + description = "piece description", + required = true, + content = Array(new Content(schema = new Schema(implementation = classOf[PieceModel]))) + ), + 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[PlayerIdWithCountersModel])), + ) + ) + ), + new ApiResponse( + responseCode = "400", + description = "Invalid parameters were supplied", + content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) + ), + new ApiResponse( + responseCode = "401", + description = "Supplied authorization is invalid", + content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) + ), + new ApiResponse( + responseCode = "403", + description = "Access is forbidden", + content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) + ), + new ApiResponse( + responseCode = "500", + description = "Internal server error", + content = Array(new Content(schema = new Schema(implementation = classOf[ErrorModel]))) + ), + ), + security = Array(new SecurityRequirement(name = "basic", scopes = Array("get"))), + tags = Array("loot"), + ) + def suggestLoot: Route = + path("party" / Segment / "loot") { partyId => + extractExecutionContext { implicit executionContext => + authenticateBasicBCrypt(s"party $partyId", authGet(partyId)) { _ => + put { + entity(as[PiecesModel]) { piece => + onSuccess(suggestPiece(partyId, piece.toPiece)) { response => + complete(response.map(PlayerIdWithCountersModel.fromPlayerId)) + } + } + } + } + } + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v2/RootApiV2Endpoint.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/RootApiV2Endpoint.scala new file mode 100644 index 0000000..0194c36 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/RootApiV2Endpoint.scala @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2019-2022 Evgeniy Alekseev. + * + * This file is part of ffxivbis + * (see https://github.com/arcan1s/ffxivbis). + * + * License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause + */ +package me.arcanis.ffxivbis.http.api.v2 + +import akka.actor.typed.{ActorRef, Scheduler} +import akka.http.scaladsl.server.Directives._ +import akka.http.scaladsl.server.Route +import akka.util.Timeout +import me.arcanis.ffxivbis.http.AuthorizationProvider +import me.arcanis.ffxivbis.http.api.v2.json.JsonSupport +import me.arcanis.ffxivbis.messages.Message + +class RootApiV2Endpoint( + storage: ActorRef[Message], + auth: AuthorizationProvider, +)(implicit + timeout: Timeout, + scheduler: Scheduler +) extends JsonSupport + with HttpHandler { + + private val lootEndpoint = new LootEndpoint(storage, auth) + + def routes: Route = + handleExceptions(exceptionHandler) { + handleRejections(rejectionHandler) { + lootEndpoint.routes + } + } +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v2/json/JsonSupport.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/json/JsonSupport.scala new file mode 100644 index 0000000..17f42b0 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/json/JsonSupport.scala @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2019-2022 Evgeniy Alekseev. + * + * This file is part of ffxivbis + * (see https://github.com/arcan1s/ffxivbis). + * + * License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause + */ +package me.arcanis.ffxivbis.http.api.v2.json + +import me.arcanis.ffxivbis.http.api.v1.json.{JsonSupport => JsonSupportV1} +import spray.json._ + +trait JsonSupport extends JsonSupportV1 { + + implicit val piecesFormat: RootJsonFormat[PiecesModel] = jsonFormat1(PiecesModel.apply) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/api/v2/json/PiecesModel.scala b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/json/PiecesModel.scala new file mode 100644 index 0000000..6582db5 --- /dev/null +++ b/src/main/scala/me/arcanis/ffxivbis/http/api/v2/json/PiecesModel.scala @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2019-2022 Evgeniy Alekseev. + * + * This file is part of ffxivbis + * (see https://github.com/arcan1s/ffxivbis). + * + * License: 3-clause BSD, see https://opensource.org/licenses/BSD-3-Clause + */ +package me.arcanis.ffxivbis.http.api.v2.json + +import io.swagger.v3.oas.annotations.media.Schema +import me.arcanis.ffxivbis.http.api.v1.json.PieceModel +import me.arcanis.ffxivbis.models.Piece + +case class PiecesModel( + @Schema(description = "pieces list", required = true) pieces: Seq[PieceModel], +) { + + def toPiece: Seq[Piece] = pieces.map(_.toPiece) +} + +object PiecesModel { + + def fromPiece(pieces: Seq[Piece]): PiecesModel = PiecesModel(pieces.map(PieceModel.fromPiece)) +} diff --git a/src/main/scala/me/arcanis/ffxivbis/http/helpers/LootHelper.scala b/src/main/scala/me/arcanis/ffxivbis/http/helpers/LootHelper.scala index fb2dfd3..fc302a8 100644 --- a/src/main/scala/me/arcanis/ffxivbis/http/helpers/LootHelper.scala +++ b/src/main/scala/me/arcanis/ffxivbis/http/helpers/LootHelper.scala @@ -56,4 +56,11 @@ trait LootHelper { scheduler: Scheduler ): Future[Seq[PlayerIdWithCounters]] = storage.ask(SuggestLoot(partyId, piece, _)).map(_.result) + + def suggestPiece(partyId: String, pieces: Seq[Piece])(implicit + executionContext: ExecutionContext, + timeout: Timeout, + scheduler: Scheduler + ): Future[Seq[PlayerIdWithCounters]] = + storage.ask(SuggestMultiLoot(partyId, pieces, _)).map(_.result) } diff --git a/src/main/scala/me/arcanis/ffxivbis/messages/DatabaseMessage.scala b/src/main/scala/me/arcanis/ffxivbis/messages/DatabaseMessage.scala index 3f291c2..5263a3d 100644 --- a/src/main/scala/me/arcanis/ffxivbis/messages/DatabaseMessage.scala +++ b/src/main/scala/me/arcanis/ffxivbis/messages/DatabaseMessage.scala @@ -69,6 +69,11 @@ object DatabaseMessage { override val isReadOnly: Boolean = true } + case class SuggestMultiLoot(partyId: String, pieces: Seq[Piece], replyTo: ActorRef[LootSelector.LootSelectorResult]) + extends LootDatabaseMessage { + override val isReadOnly: Boolean = true + } + // party handler trait PartyDatabaseMessage extends DatabaseMessage diff --git a/src/main/scala/me/arcanis/ffxivbis/models/Party.scala b/src/main/scala/me/arcanis/ffxivbis/models/Party.scala index 40507b4..cd415b1 100644 --- a/src/main/scala/me/arcanis/ffxivbis/models/Party.scala +++ b/src/main/scala/me/arcanis/ffxivbis/models/Party.scala @@ -35,8 +35,8 @@ case class Party(partyDescription: PartyDescription, rules: Seq[String], players this } - def suggestLoot(piece: Piece): LootSelector.LootSelectorResult = - LootSelector(getPlayers, piece, rules) + def suggestLoot(pieces: Seq[Piece]): LootSelector.LootSelectorResult = + LootSelector(getPlayers, pieces, rules) } object Party { diff --git a/src/main/scala/me/arcanis/ffxivbis/service/LootSelector.scala b/src/main/scala/me/arcanis/ffxivbis/service/LootSelector.scala index 1943af8..8aa9a9a 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/LootSelector.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/LootSelector.scala @@ -10,9 +10,9 @@ package me.arcanis.ffxivbis.service import me.arcanis.ffxivbis.models.{Piece, Player, PlayerIdWithCounters} -class LootSelector(players: Seq[Player], piece: Piece, orderBy: Seq[String]) { +class LootSelector(players: Seq[Player], pieces: Seq[Piece], orderBy: Seq[String]) { - val counters: Seq[PlayerIdWithCounters] = players.map(_.withCounters(Some(piece))) + val counters: Seq[PlayerIdWithCounters] = pieces.flatMap(piece => players.map(_.withCounters(Some(piece)))) def suggest: LootSelector.LootSelectorResult = LootSelector.LootSelectorResult { @@ -22,8 +22,8 @@ class LootSelector(players: Seq[Player], piece: Piece, orderBy: Seq[String]) { object LootSelector { - def apply(players: Seq[Player], piece: Piece, orderBy: Seq[String]): LootSelectorResult = - new LootSelector(players, piece, orderBy).suggest + def apply(players: Seq[Player], pieces: Seq[Piece], orderBy: Seq[String]): LootSelectorResult = + new LootSelector(players, pieces, orderBy).suggest case class LootSelectorResult(result: Seq[PlayerIdWithCounters]) } diff --git a/src/main/scala/me/arcanis/ffxivbis/service/bis/XivApi.scala b/src/main/scala/me/arcanis/ffxivbis/service/bis/XivApi.scala index 49506a7..5c1fd10 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/bis/XivApi.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/bis/XivApi.scala @@ -72,6 +72,7 @@ object XivApi { case JsBoolean(false) => PieceType.Tome } .getOrElse(throw deserializationError(s"Could not find lot field in $fields")) + case other => throw deserializationError(s"Could not read fields as object from $other") } } } diff --git a/src/main/scala/me/arcanis/ffxivbis/service/database/impl/DatabaseLootHandler.scala b/src/main/scala/me/arcanis/ffxivbis/service/database/impl/DatabaseLootHandler.scala index b7a519a..47dc5e7 100644 --- a/src/main/scala/me/arcanis/ffxivbis/service/database/impl/DatabaseLootHandler.scala +++ b/src/main/scala/me/arcanis/ffxivbis/service/database/impl/DatabaseLootHandler.scala @@ -40,7 +40,14 @@ trait DatabaseLootHandler { this: Database => case SuggestLoot(partyId, piece, client) => run { getParty(partyId, withBiS = true, withLoot = true) - .map(_.suggestLoot(piece)) + .map(_.suggestLoot(Seq(piece))) + }(client ! _) + Behaviors.same + + case SuggestMultiLoot(partyId, pieces, client) => + run { + getParty(partyId, withBiS = true, withLoot = true) + .map(_.suggestLoot(pieces)) }(client ! _) Behaviors.same } diff --git a/src/test/scala/me/arcanis/ffxivbis/service/LootSelectorTest.scala b/src/test/scala/me/arcanis/ffxivbis/service/LootSelectorTest.scala index 888bbfb..f594100 100644 --- a/src/test/scala/me/arcanis/ffxivbis/service/LootSelectorTest.scala +++ b/src/test/scala/me/arcanis/ffxivbis/service/LootSelectorTest.scala @@ -43,14 +43,14 @@ class LootSelectorTest extends AnyWordSpecLike with Matchers with BeforeAndAfter "loot selector" must { "suggest loot by isRequired" in { - toPlayerId(default.suggestLoot(Piece.Head(pieceType = PieceType.Savage, Job.AnyJob))) shouldEqual Seq(dnc.playerId, drg.playerId) + toPlayerId(default.suggestLoot(Seq(Piece.Head(pieceType = PieceType.Savage, Job.AnyJob)))) shouldEqual Seq(dnc.playerId, drg.playerId) } "suggest loot if a player already have it" in { val piece = Piece.Body(pieceType = PieceType.Savage, Job.AnyJob) val party = default.withPlayer(dnc.withLoot(piece)) - toPlayerId(party.suggestLoot(piece)) shouldEqual Seq(drg.playerId, dnc.playerId) + toPlayerId(party.suggestLoot(Seq(piece))) shouldEqual Seq(drg.playerId, dnc.playerId) } "suggest upgrade" in { @@ -60,26 +60,26 @@ class LootSelectorTest extends AnyWordSpecLike with Matchers with BeforeAndAfter ) ) - toPlayerId(party.suggestLoot(Piece.WeaponUpgrade)) shouldEqual Seq(dnc.playerId, drg.playerId) + toPlayerId(party.suggestLoot(Seq(Piece.WeaponUpgrade))) shouldEqual Seq(dnc.playerId, drg.playerId) } "suggest loot by priority" in { val party = default.withPlayer(dnc.copy(priority = 2)) - toPlayerId(party.suggestLoot(Piece.Body(pieceType = PieceType.Savage, Job.AnyJob))) shouldEqual Seq(drg.playerId, dnc.playerId) + toPlayerId(party.suggestLoot(Seq(Piece.Body(pieceType = PieceType.Savage, Job.AnyJob)))) shouldEqual Seq(drg.playerId, dnc.playerId) } "suggest loot by bis pieces got" in { val party = default.withPlayer(dnc.withLoot(Piece.Head(pieceType = PieceType.Savage, Job.AnyJob))) - toPlayerId(party.suggestLoot(Piece.Body(pieceType = PieceType.Savage, Job.AnyJob))) shouldEqual Seq(drg.playerId, dnc.playerId) + toPlayerId(party.suggestLoot(Seq(Piece.Body(pieceType = PieceType.Savage, Job.AnyJob)))) shouldEqual Seq(drg.playerId, dnc.playerId) } "suggest loot by this piece got" in { val piece = Piece.Body(pieceType = PieceType.Tome, Job.AnyJob) val party = default.withPlayer(dnc.withLoot(piece)) - toPlayerId(party.suggestLoot(piece)) shouldEqual Seq(drg.playerId, dnc.playerId) + toPlayerId(party.suggestLoot(Seq(piece))) shouldEqual Seq(drg.playerId, dnc.playerId) } "suggest loot by total piece got" in { @@ -88,7 +88,7 @@ class LootSelectorTest extends AnyWordSpecLike with Matchers with BeforeAndAfter .withPlayer(dnc.withLoot(Seq(piece, piece).map(pieceToLoot))) .withPlayer(drg.withLoot(piece)) - toPlayerId(party.suggestLoot(piece)) shouldEqual Seq(drg.playerId, dnc.playerId) + toPlayerId(party.suggestLoot(Seq(piece))) shouldEqual Seq(drg.playerId, dnc.playerId) } }