10 Commits

12 changed files with 129 additions and 108 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

@ -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

@ -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

@ -15,7 +15,6 @@ import spray.json._
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
import scala.jdk.CollectionConverters._ import scala.jdk.CollectionConverters._
import scala.util.Try import scala.util.Try
import scala.util.matching.Regex
trait XivApi extends RequestExecutor { trait XivApi extends RequestExecutor {
@ -41,106 +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 val itemRegexp = new Regex("""Item(Receive|Cost)(\d+)""", "type", "index")
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 itemRegexp.matches(shopName) =>
val shopId = array.head match {
case JsNumber(id) => id.toLong
case other => throw deserializationError(s"Could not parse $other")
}
itemRegexp.findFirstMatchIn(shopName).get.group("index") -> 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.15.1" version := "0.15.6"