15 Commits

15 changed files with 145 additions and 120 deletions

View File

@ -12,6 +12,7 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: extract version - name: extract version
id: version id: version
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/} run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
@ -21,13 +22,24 @@ jobs:
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
filter: 'Release \d+\.\d+\.\d+' filter: 'Release \d+\.\d+\.\d+'
- name: setup JDK - name: setup JDK
uses: actions/setup-java@v2 uses: actions/setup-java@v2
with: with:
distribution: temurin distribution: temurin
java-version: 18 java-version: 18
- name: install sbt
uses: eclipse-score/apt-install@main
with:
packages: wget
- run: |
wget https://scala.jfrog.io/artifactory/debian/sbt-1.11.7.deb
sudo dpkg -i sbt-1.11.7.deb
- name: create dist - name: create dist
run: make dist run: make dist
- name: release - name: release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v1
with: with:

View File

@ -13,10 +13,20 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: setup JDK - name: setup JDK
uses: actions/setup-java@v2 uses: actions/setup-java@v2
with: with:
distribution: temurin distribution: temurin
java-version: 18 java-version: 18
- name: install sbt
uses: eclipse-score/apt-install@main
with:
packages: wget
- run: |
wget https://scala.jfrog.io/artifactory/debian/sbt-1.11.7.deb
sudo dpkg -i sbt-1.11.7.deb
- name: run tests - name: run tests
run: make tests run: make tests

View File

@ -2,7 +2,7 @@ organization := "me.arcanis"
name := "ffxivbis" name := "ffxivbis"
scalaVersion := "2.13.8" scalaVersion := "2.13.12"
scalacOptions ++= Seq("-deprecation", "-feature") scalacOptions ++= Seq("-deprecation", "-feature")

View File

@ -1,26 +1,25 @@
val AkkaVersion = "2.6.20" val AkkaVersion = "2.8.6"
val AkkaHttpVersion = "10.2.10" val AkkaHttpVersion = "10.5.3"
val ScalaTestVersion = "3.2.12" val ScalaTestVersion = "3.2.19"
val SlickVersion = "3.3.3"
libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.2.11" libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.5.6"
libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5" libraryDependencies += "com.typesafe.scala-logging" %% "scala-logging" % "3.9.5"
libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion libraryDependencies += "com.typesafe.akka" %% "akka-http" % AkkaHttpVersion
libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % AkkaHttpVersion libraryDependencies += "com.typesafe.akka" %% "akka-http-spray-json" % AkkaHttpVersion
libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion
libraryDependencies += "com.typesafe.akka" %% "akka-stream" % AkkaVersion libraryDependencies += "com.typesafe.akka" %% "akka-stream" % AkkaVersion
libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.8.0" libraryDependencies += "com.github.swagger-akka-http" %% "swagger-akka-http" % "2.11.0"
libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "9.1.0" libraryDependencies += "jakarta.platform" % "jakarta.jakartaee-web-api" % "10.0.0"
libraryDependencies += "ch.megard" %% "akka-http-cors" % "1.1.3" libraryDependencies += "ch.megard" %% "akka-http-cors" % "1.2.0"
libraryDependencies += "io.spray" %% "spray-json" % "1.3.6" libraryDependencies += "io.spray" %% "spray-json" % "1.3.6"
libraryDependencies += "org.playframework.anorm" %% "anorm" % "2.6.10" libraryDependencies += "org.playframework.anorm" %% "anorm" % "2.7.0"
libraryDependencies += "com.zaxxer" % "HikariCP" % "5.0.1" exclude("org.slf4j", "slf4j-api") libraryDependencies += "com.zaxxer" % "HikariCP" % "5.1.0" exclude("org.slf4j", "slf4j-api")
libraryDependencies += "org.flywaydb" % "flyway-core" % "9.2.2" libraryDependencies += "org.flywaydb" % "flyway-core" % "9.16.0"
libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.39.2.1" libraryDependencies += "org.xerial" % "sqlite-jdbc" % "3.46.0.0"
libraryDependencies += "org.postgresql" % "postgresql" % "42.5.0" libraryDependencies += "org.postgresql" % "postgresql" % "42.7.3"
libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4" libraryDependencies += "org.mindrot" % "jbcrypt" % "0.4"
libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre" libraryDependencies += "com.google.guava" % "guava" % "31.0.1-jre"

View File

@ -41,7 +41,7 @@
<div class="container"> <div class="container">
<div id="toolbar"> <div id="toolbar">
<button id="add-btn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-loot-dialog" hidden> <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-loot-dialog" hidden>
<i class="bi bi-plus"></i> add <i class="bi bi-plus"></i> add
</button> </button>
<button class="btn btn-secondary" onclick="reload()"> <button class="btn btn-secondary" onclick="reload()">
@ -147,7 +147,7 @@
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button> <button type="button" class="btn btn-danger" data-bs-dismiss="modal">close</button>
<button type="button" class="btn btn-secondary" onclick="suggestLoot()">suggest</button> <button type="button" class="btn btn-secondary" onclick="suggestLoot()">suggest</button>
<button type="submit" class="btn btn-primary">add</button> <button id="add-btn" type="submit" class="btn btn-primary">add</button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -4,7 +4,7 @@ me.arcanis.ffxivbis {
include "item_data.json" include "item_data.json"
# xivapi base url, string, required # xivapi base url, string, required
xivapi-url = "https://xivapi.com" xivapi-url = "https://v2.xivapi.com"
# xivapi developer key, string, optional # xivapi developer key, string, optional
#xivapi-key = "abcdef" #xivapi-key = "abcdef"
} }

View File

@ -12,5 +12,5 @@ trait ValidatorHelper {
object ValidatorHelper { object ValidatorHelper {
final val VALID_CHARACTERS = HashSet.from("!@#$%^&*()-_=+;:',./? ") final val VALID_CHARACTERS = HashSet.from("!@#$%^&*()-_=+;:',./?| ")
} }

View File

@ -95,6 +95,7 @@ object Job {
case object RPR extends Drgs case object RPR extends Drgs
case object NIN extends Nins case object NIN extends Nins
case object SAM extends Mnks case object SAM extends Mnks
case object VPR extends Mnks
case object BRD extends Ranges case object BRD extends Ranges
case object MCH extends Ranges case object MCH extends Ranges
@ -103,9 +104,10 @@ object Job {
case object BLM extends Casters case object BLM extends Casters
case object SMN extends Casters case object SMN extends Casters
case object RDM extends Casters case object RDM extends Casters
case object PCT extends Casters
val available: Seq[Job] = val available: Seq[Job] =
Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, SGE, MNK, DRG, RPR, NIN, SAM, BRD, MCH, DNC, BLM, SMN, RDM) Seq(PLD, WAR, DRK, GNB, WHM, SCH, AST, SGE, MNK, DRG, RPR, NIN, SAM, VPR, BRD, MCH, DNC, BLM, SMN, RDM, PCT)
val availableWithAnyJob: Seq[Job] = available.prepended(AnyJob) val availableWithAnyJob: Seq[Job] = available.prepended(AnyJob)
def withName(job: String): Job = def withName(job: String): Job =

View File

@ -113,7 +113,7 @@ object Piece {
case "weapon" => Weapon(pieceType, job) case "weapon" => Weapon(pieceType, job)
case "head" => Head(pieceType, job) case "head" => Head(pieceType, job)
case "body" => Body(pieceType, job) case "body" => Body(pieceType, job)
case "hands" => Hands(pieceType, job) case "hand" | "hands" => Hands(pieceType, job)
case "legs" => Legs(pieceType, job) case "legs" => Legs(pieceType, job)
case "feet" => Feet(pieceType, job) case "feet" => Feet(pieceType, job)
case "ears" => Ears(pieceType, job) case "ears" => Ears(pieceType, job)

View File

@ -16,7 +16,7 @@ import com.typesafe.scalalogging.StrictLogging
import me.arcanis.ffxivbis.messages.BiSProviderMessage import me.arcanis.ffxivbis.messages.BiSProviderMessage
import me.arcanis.ffxivbis.models.{BiS, Job, Piece, PieceType} import me.arcanis.ffxivbis.models.{BiS, Job, Piece, PieceType}
import me.arcanis.ffxivbis.service.bis.parser.Parser import me.arcanis.ffxivbis.service.bis.parser.Parser
import me.arcanis.ffxivbis.service.bis.parser.impl.{Ariyala, Etro} import me.arcanis.ffxivbis.service.bis.parser.impl.{Ariyala, Etro, XIVGear}
import spray.json._ import spray.json._
import java.nio.file.Paths import java.nio.file.Paths
@ -52,7 +52,10 @@ class BisProvider(context: ActorContext[BiSProviderMessage])
val url = Uri(link) val url = Uri(link)
val id = Paths.get(link).normalize.getFileName.toString val id = Paths.get(link).normalize.getFileName.toString
val parser = if (url.authority.host.address().contains("etro")) Etro else Ariyala val parser =
if (url.authority.host.address().contains("etro")) Etro
else if (url.authority.host.address().contains("xivgear.app")) XIVGear
else Ariyala
val uri = parser.uri(url, id) val uri = parser.uri(url, id)
sendRequest(uri, BisProvider.parseBisJsonToPieces(job, parser, getPieceType)) sendRequest(uri, BisProvider.parseBisJsonToPieces(job, parser, getPieceType))
} catch { } catch {
@ -81,12 +84,11 @@ object BisProvider {
} }
} }
def remapKey(key: String): Option[String] = key match { def remapKey(key: String): Option[String] = Some(key.toLowerCase).collect {
case "mainhand" => Some("weapon") case "mainhand" => "weapon"
case "chest" => Some("body") case "chest" => "body"
case "ringLeft" | "fingerL" => Some("left ring") case "ringleft" | "fingerl" => "left ring"
case "ringRight" | "fingerR" => Some("right ring") case "ringright" | "fingerr" => "right ring"
case "weapon" | "head" | "body" | "hands" | "legs" | "feet" | "ears" | "neck" | "wrist" | "wrists" => Some(key) case "weapon" | "head" | "body" | "hand" | "hands" | "legs" | "feet" | "ears" | "neck" | "wrist" | "wrists" => key
case _ => None
} }
} }

View File

@ -40,104 +40,38 @@ trait XivApi extends RequestExecutor {
else remotePieceType(remote).map(_ ++ local) else remotePieceType(remote).map(_ ++ local)
} }
private def remotePieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType]] = { private def remotePieceType(itemIds: Seq[Long]): Future[Map[Long, PieceType]] =
val uriForItems = Uri(xivapiUrl) Future
.withPath(Uri.Path / "item") .traverse(itemIds) { id =>
.withQuery( val uriForItem = Uri(xivapiUrl)
Uri.Query( .withPath(Uri.Path / "api" / "sheet" / "Item" / id.toString)
Map( .withQuery(
"columns" -> Seq("ID", "GameContentLinks").mkString(","), Uri.Query(
"ids" -> itemIds.mkString(","), Map(
"private_key" -> xivapiKey.getOrElse("") "fields" -> Seq("Lot").mkString(","),
) "private_key" -> xivapiKey.getOrElse("")
) )
)
sendRequest(uriForItems, XivApi.parseXivapiJsonToShop).flatMap { shops =>
val shopIds = shops.values.map(_._2).toSet
val columns = shops.values.map(pair => s"ItemCost${pair._1}").toSet
val uriForShops = Uri(xivapiUrl)
.withPath(Uri.Path / "specialshop")
.withQuery(
Uri.Query(
Map(
"columns" -> (columns + "ID").mkString(","),
"ids" -> shopIds.mkString(","),
"private_key" -> xivapiKey.getOrElse("")
) )
) )
)
sendRequest(uriForShops, XivApi.parseXivapiJsonToType(shops)) sendRequest(uriForItem, XivApi.parseXivapiJsonToLot).map(id -> _)
} }
} .map(_.toMap)
} }
object XivApi { object XivApi {
private val defaultShop = JsObject("IsUnique" -> JsNumber(1), "StackSize" -> JsNumber(999)) private def parseXivapiJsonToLot(js: JsObject)(implicit executionContext: ExecutionContext): Future[PieceType] =
private def parseXivapiJsonToShop(
js: JsObject
)(implicit executionContext: ExecutionContext): Future[Map[Long, (String, Long)]] = {
def extractTraderId(js: JsObject) =
js.fields
.get("Recipe")
.map(_ => "crafted" -> -1L) // you can craft this item
.orElse { // lets try shop items
js.fields("SpecialShop").asJsObject.fields.collectFirst {
case (shopName, JsArray(array)) if shopName.startsWith("ItemReceive") =>
val shopId = array.head match {
case JsNumber(id) => id.toLong
case other => throw deserializationError(s"Could not parse $other")
}
shopName.replace("ItemReceive", "") -> shopId
}
}
.getOrElse(throw deserializationError(s"Could not parse $js"))
Future { Future {
js.fields("Results") match { js.fields("fields") match {
case array: JsArray => case JsObject(fields) =>
array.elements fields
.map(_.asJsObject.getFields("ID", "GameContentLinks") match { .get("Lot")
case Seq(JsNumber(id), shop: JsObject) => id.toLong -> extractTraderId(shop.asJsObject) .collect {
case other => throw deserializationError(s"Could not parse $other") case JsBoolean(true) => PieceType.Savage
}) case JsBoolean(false) => PieceType.Tome
.toMap
case other => throw deserializationError(s"Could not parse $other")
}
}
}
private def parseXivapiJsonToType(
shops: Map[Long, (String, Long)]
)(js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[Long, PieceType]] =
Future {
val shopMap = js.fields("Results") match {
case array: JsArray =>
array.elements.collect { case shop: JsObject =>
shop.fields("ID") match {
case JsNumber(id) => id.toLong -> shop
case other => throw deserializationError(s"Could not parse $other")
} }
}.toMap .getOrElse(throw deserializationError(s"Could not find lot field in $fields"))
case other => throw deserializationError(s"Could not parse $other")
}
shops.map { case (itemId, (index, shopId)) =>
val pieceType =
if (index == "crafted" && shopId == -1L) PieceType.Crafted
else
Try(shopMap(shopId).fields(s"ItemCost$index").asJsObject)
.getOrElse(defaultShop)
.getFields("IsUnique", "StackSize") match {
case Seq(JsNumber(isUnique), JsNumber(stackSize)) =>
if (isUnique == 1 || stackSize.toLong != 999) PieceType.Tome // either upgraded gear or tomes found
else PieceType.Savage
case other => throw deserializationError(s"Could not parse $other")
}
itemId -> pieceType
} }
} }
} }

View File

@ -0,0 +1,44 @@
/*
* 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.service.bis.parser.impl
import akka.http.scaladsl.model.Uri
import me.arcanis.ffxivbis.models.Job
import me.arcanis.ffxivbis.service.bis.BisProvider
import me.arcanis.ffxivbis.service.bis.parser.Parser
import spray.json.{deserializationError, JsNumber, JsObject}
import scala.concurrent.{ExecutionContext, Future}
object XIVGear extends Parser {
override def parse(job: Job, js: JsObject)(implicit executionContext: ExecutionContext): Future[Map[String, Long]] =
Future {
val set = js.fields.get("items") match {
case Some(JsObject(items)) => items
case other => throw deserializationError(s"Invalid job name $other")
}
set.foldLeft(Map.empty[String, Long]) {
case (acc, (key, JsObject(properties))) =>
val pieceId = properties.get("id").collect { case JsNumber(id) =>
id.toLong
}
(for (
piece <- BisProvider.remapKey(key);
id <- pieceId
) yield (piece, id)).map(acc + _).getOrElse(acc)
case (acc, _) => acc
}
}
override def uri(root: Uri, id: String): Uri = {
val gearSet = Uri(id).query().get("page").map(_.replace("sl|", "")).getOrElse(id)
root.withHost(s"api.${root.authority.host.address()}").withPath(Uri.Path / "shortlink" / gearSet)
}
}

View File

@ -52,12 +52,28 @@ object Fixtures {
Piece.Ring(pieceType = PieceType.Tome, Job.SGE, "right ring") Piece.Ring(pieceType = PieceType.Tome, Job.SGE, "right ring")
) )
) )
lazy val bis4: BiS = BiS(
Seq(
Piece.Weapon(pieceType = PieceType.Savage ,Job.VPR),
Piece.Head(pieceType = PieceType.Savage, Job.VPR),
Piece.Body(pieceType = PieceType.Savage, Job.VPR),
Piece.Hands(pieceType = PieceType.Tome, Job.VPR),
Piece.Legs(pieceType = PieceType.Tome, Job.VPR),
Piece.Feet(pieceType = PieceType.Savage, Job.VPR),
Piece.Ears(pieceType = PieceType.Tome, Job.VPR),
Piece.Neck(pieceType = PieceType.Savage, Job.VPR),
Piece.Wrist(pieceType = PieceType.Tome, Job.VPR),
Piece.Ring(pieceType = PieceType.Savage, Job.VPR, "left ring"),
Piece.Ring(pieceType = PieceType.Tome, Job.VPR, "right ring")
)
)
lazy val link: String = "https://ffxiv.ariyala.com/19V5R" lazy val link: String = "https://ffxiv.ariyala.com/19V5R"
lazy val link2: String = "https://ffxiv.ariyala.com/1A0WM" lazy val link2: String = "https://ffxiv.ariyala.com/1A0WM"
lazy val link3: String = "https://etro.gg/gearset/26a67536-b4ce-4adc-a46a-f70e348bb138" lazy val link3: String = "https://etro.gg/gearset/26a67536-b4ce-4adc-a46a-f70e348bb138"
lazy val link4: String = "https://etro.gg/gearset/865fc886-994f-4c28-8fc1-4379f160a916" lazy val link4: String = "https://etro.gg/gearset/865fc886-994f-4c28-8fc1-4379f160a916"
lazy val link5: String = "https://ffxiv.ariyala.com/1FGU0" lazy val link5: String = "https://ffxiv.ariyala.com/1FGU0"
lazy val link6: String = "https://xivgear.app/?page=sl%7Cd65b4776-01e1-4269-af74-0bc6e01ca2ec"
lazy val lootWeapon: Piece = Piece.Weapon(pieceType = PieceType.Tome, Job.AnyJob) lazy val lootWeapon: Piece = Piece.Weapon(pieceType = PieceType.Tome, Job.AnyJob)
lazy val lootBody: Piece = Piece.Body(pieceType = PieceType.Savage, Job.AnyJob) lazy val lootBody: Piece = Piece.Body(pieceType = PieceType.Savage, Job.AnyJob)

View File

@ -35,11 +35,17 @@ class BisProviderTest extends ScalaTestWithActorTestKit(Settings.withRandomDatab
probe.expectMessage(askTimeout, Fixtures.bis) probe.expectMessage(askTimeout, Fixtures.bis)
} }
"get best in slot set (etro 2)" in { "get best in slot set (etro 2)" ignore {
val probe = testKit.createTestProbe[BiS]() val probe = testKit.createTestProbe[BiS]()
provider ! DownloadBiS(Fixtures.link4, Job.DNC, probe.ref) provider ! DownloadBiS(Fixtures.link4, Job.DNC, probe.ref)
probe.expectMessage(askTimeout, Fixtures.bis2) probe.expectMessage(askTimeout, Fixtures.bis2)
} }
"get best in slot set (xivgear)" in {
val probe = testKit.createTestProbe[BiS]()
provider ! DownloadBiS(Fixtures.link6, Job.VPR, probe.ref)
probe.expectMessage(askTimeout, Fixtures.bis4)
}
} }
} }

View File

@ -1 +1 @@
version := "0.14.0" version := "0.15.6"