From: David ‘Bombe’ Roden Date: Wed, 10 Apr 2024 20:09:56 +0000 (+0200) Subject: 🔀 Merge branch 'comic/nortverse' into next X-Git-Tag: v2~1 X-Git-Url: https://git.pterodactylus.net/?a=commitdiff_plain;h=f0824d2a3ad6713fed7a11d65535e9ec7a81a904;hp=f49f7b6fdf14053bc115922ec804f938beadb0c1;p=rhynodge.git 🔀 Merge branch 'comic/nortverse' into next --- diff --git a/build.gradle b/build.gradle index 20219b8..85cf007 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,7 @@ dependencies { testImplementation group: "junit", name: "junit", version: "4.13.2" testImplementation group: "org.hamcrest", name: "hamcrest-library", version: "1.3" testImplementation group: "org.mockito", name: "mockito-core", version: "5.11.0" + testImplementation group: "com.spotify", name: "hamcrest-jackson", version: "1.3.2" } task fatJar(type: Jar) { diff --git a/src/main/kotlin/net/pterodactylus/rhynodge/filters/JsonFilter.kt b/src/main/kotlin/net/pterodactylus/rhynodge/filters/JsonFilter.kt new file mode 100644 index 0000000..502a002 --- /dev/null +++ b/src/main/kotlin/net/pterodactylus/rhynodge/filters/JsonFilter.kt @@ -0,0 +1,28 @@ +package net.pterodactylus.rhynodge.filters + +import com.fasterxml.jackson.core.JsonParseException +import com.fasterxml.jackson.databind.ObjectMapper +import net.pterodactylus.rhynodge.Filter +import net.pterodactylus.rhynodge.State +import net.pterodactylus.rhynodge.states.FailedState +import net.pterodactylus.rhynodge.states.HttpState +import net.pterodactylus.rhynodge.states.JsonState + +class JsonFilter : Filter { + + override fun filter(state: State): State { + if (state is FailedState) { + return state + } + val httpState = state as? HttpState ?: throw IllegalArgumentException("state must be HttpState") + val jsonNode = try { + objectMapper.readTree(httpState.rawResult()) + } catch (jsonParseException: JsonParseException) { + return FailedState(jsonParseException) + } + return JsonState(jsonNode) + } + +} + +private val objectMapper = ObjectMapper() diff --git a/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesFilter.kt b/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesFilter.kt new file mode 100644 index 0000000..31bb11f --- /dev/null +++ b/src/main/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesFilter.kt @@ -0,0 +1,49 @@ +package net.pterodactylus.rhynodge.filters.webpages.epicgames + +import com.fasterxml.jackson.databind.JsonNode +import net.pterodactylus.rhynodge.Filter +import net.pterodactylus.rhynodge.State +import net.pterodactylus.rhynodge.states.AbstractState +import net.pterodactylus.rhynodge.states.JsonState +import java.time.Instant + +class EpicGamesFilter : Filter { + + override fun filter(state: State): State { + state as? JsonState ?: throw IllegalArgumentException("state must be a JSON state") + return state.jsonNode.at("/data/Catalog/searchStore/elements").map { gameJson: JsonNode -> + val title = gameJson.get("title").asText() + val imageUrl = gameJson.at("/keyImages/1/url").asText() + val startDate = getPromotionalOfferStartDate(gameJson) + val endDate = getPromotionalOfferEndDate(gameJson) + FreeGame(title, imageUrl, startDate, endDate) + }.let { FreeGamesState(it.toSet()) } + } + + private fun getPromotionalOfferEndDate(gameJson: JsonNode) = getPromotionalOfferDate(gameJson, "endDate") + private fun getPromotionalOfferStartDate(gameJson: JsonNode) = getPromotionalOfferDate(gameJson, "startDate") + + private fun getPromotionalOfferDate(gameJson: JsonNode, date: String) = listOf("promotionalOffers", "upcomingPromotionalOffers") + .map { "/promotions/$it/0/promotionalOffers/0/$date" } + .map { gameJson.at(it) } + .filter { !it.isMissingNode } + .map { it.asText() } + .map(Instant::parse) + .first() + +} + +data class FreeGame( + val title: String, + val imageUrl: String, + val startDate: Instant, + val endDate: Instant +) + +class FreeGamesState(val games: Set) : AbstractState(true) { + + override fun plainText(): String { + TODO("Not yet implemented") + } + +} diff --git a/src/main/kotlin/net/pterodactylus/rhynodge/states/JsonState.kt b/src/main/kotlin/net/pterodactylus/rhynodge/states/JsonState.kt new file mode 100644 index 0000000..8824729 --- /dev/null +++ b/src/main/kotlin/net/pterodactylus/rhynodge/states/JsonState.kt @@ -0,0 +1,10 @@ +package net.pterodactylus.rhynodge.states + +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.databind.JsonNode + +class JsonState(@JsonProperty("json") val jsonNode: JsonNode) : AbstractState(true) { + + override fun plainText() = jsonNode.toPrettyString()!! + +} diff --git a/src/test/java/net/pterodactylus/rhynodge/filters/JsonFilterTest.kt b/src/test/java/net/pterodactylus/rhynodge/filters/JsonFilterTest.kt new file mode 100644 index 0000000..a37990a --- /dev/null +++ b/src/test/java/net/pterodactylus/rhynodge/filters/JsonFilterTest.kt @@ -0,0 +1,71 @@ +package net.pterodactylus.rhynodge.filters + +import com.spotify.hamcrest.jackson.JsonMatchers.jsonArray +import com.spotify.hamcrest.jackson.JsonMatchers.jsonDouble +import com.spotify.hamcrest.jackson.JsonMatchers.jsonNull +import com.spotify.hamcrest.jackson.JsonMatchers.jsonObject +import com.spotify.hamcrest.jackson.JsonMatchers.jsonText +import net.pterodactylus.rhynodge.Filter +import net.pterodactylus.rhynodge.states.AbstractState +import net.pterodactylus.rhynodge.states.FailedState +import net.pterodactylus.rhynodge.states.HttpState +import net.pterodactylus.rhynodge.states.JsonState +import net.pterodactylus.rhynodge.states.StringState +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.instanceOf +import org.junit.Assert +import org.junit.Assert.assertThrows +import org.junit.Test + +class JsonFilterTest { + + @Test + fun `json filter is a filter implementation`() { + assertThat(JsonFilter(), instanceOf(Filter::class.java)) + } + + @Test + fun `http state can be turned into json state`() { + val httpState = HttpState("", 200, "application/json", "{\n \"state\": \"foo\",\n \"list\": [\n \"value1\",\n \"value2\"\n ],\n \"object\": {\n \"foo\": \"bar\",\n \"baz\": null,\n \"quux\": 1.5\n }\n}".encodeToByteArray()) + val jsonState = filter.filter(httpState) as JsonState + val jsonNode = jsonState.jsonNode + assertThat( + jsonNode, jsonObject() + .where("state", jsonText("foo")) + .where( + "list", jsonArray( + contains( + jsonText("value1"), + jsonText("value2") + ) + ) + ) + .where( + "object", jsonObject() + .where("foo", jsonText("bar")) + .where("baz", jsonNull()) + .where("quux", jsonDouble(1.5)) + ) + ) + } + + @Test + fun `json filter returns failed state when json can not be parsed`() { + val httpState = HttpState("", 200, "application/json", "this is not json".encodeToByteArray()) + assertThat(filter.filter(httpState), instanceOf(FailedState::class.java)) + } + + @Test + fun `json filter throws exception if given state is not an http state`() { + assertThrows(IllegalArgumentException::class.java) { filter.filter(StringState("foo")) } + } + + @Test + fun `json filter returns a failed state when a failed state is given`() { + val newState = filter.filter(FailedState()) + assertThat(newState, instanceOf(FailedState::class.java)) + } + + val filter = JsonFilter() +} diff --git a/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesFilterTest.kt b/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesFilterTest.kt new file mode 100644 index 0000000..2d44796 --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/rhynodge/filters/webpages/epicgames/EpicGamesFilterTest.kt @@ -0,0 +1,43 @@ +package net.pterodactylus.rhynodge.filters.webpages.epicgames + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import net.pterodactylus.rhynodge.Filter +import net.pterodactylus.rhynodge.states.HttpState +import net.pterodactylus.rhynodge.states.JsonState +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.instanceOf +import org.junit.Assert.assertThrows +import org.junit.Test +import java.time.Instant.parse + +class EpicGamesFilterTest { + + @Test + fun `epic games filter is a filter`() { + assertThat(filter, instanceOf(Filter::class.java)) + } + + @Test + fun `filter throws exception when given html state`() { + assertThrows(IllegalArgumentException::class.java) { filter.filter(HttpState("", 200, "", byteArrayOf())) } + } + + @Test + fun `filter finds correct games`() { + val gameState = filter.filter(JsonState(objectMapper.readTree(EpicGamesFilterTest::class.java.getResourceAsStream("epic.json")))) as FreeGamesState + assertThat( + gameState.games, contains( + FreeGame("Islets", "https://cdn1.epicgames.com/spt-assets/f991a978e0ce4156a52f951e96e388e7/download-islets-offer-rtq8h.png", parse("2024-03-28T15:00:00.000Z"), parse("2024-04-04T15:00:00.000Z")), + FreeGame("The Outer Worlds: Spacer's Choice Edition", "https://cdn1.epicgames.com/offer/dc61166eea95474e912953b163791d42/EGS_TheOuterWorldsSpacersChoiceEdition_ObsidianEntertainment_S2_1200x1600-24b156886564b75bf9aa823a0a0eb18e", parse("2024-04-04T15:00:00.000Z"), parse("2024-04-11T15:00:00.000Z")), + FreeGame("Thief", "https://cdn1.epicgames.com/spt-assets/44b12bc6a7f045a3bf313574c344dfd7/thief-1hsod.png", parse("2024-04-04T15:00:00.000Z"), parse("2024-04-11T15:00:00.000Z")), + FreeGame("Lost Castle: The Old Ones Awaken", "https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-1qvy6.jpg", parse("2024-04-11T15:00:00.000Z"), parse("2024-04-25T15:00:00.000Z")), + ) + ) + } + + private val filter = EpicGamesFilter() + +} + +private val objectMapper = jacksonObjectMapper() diff --git a/src/test/kotlin/net/pterodactylus/rhynodge/states/JsonStateTest.kt b/src/test/kotlin/net/pterodactylus/rhynodge/states/JsonStateTest.kt new file mode 100644 index 0000000..b21095e --- /dev/null +++ b/src/test/kotlin/net/pterodactylus/rhynodge/states/JsonStateTest.kt @@ -0,0 +1,37 @@ +package net.pterodactylus.rhynodge.states + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.spotify.hamcrest.jackson.JsonMatchers.jsonObject +import org.hamcrest.MatcherAssert.assertThat +import org.junit.Test +import java.io.StringWriter + +class JsonStateTest { + + @Test + fun `json state can be serialized`() { + val jsonState = JsonState(jsonObject) + StringWriter().use { stringWriter -> + objectMapper.writeValue(stringWriter, jsonState) + val deserializedState = objectMapper.readValue(stringWriter.toString(), AbstractState::class.java) as JsonState + assertThat(deserializedState.jsonNode, jsonObject(jsonObject)) + } + } + + private val jsonObject = objectMapper.createObjectNode() + .put("state", "foo") + .also { + it.putArray("list") + .add("value1") + .add("value2") + }.also { + it.putObject("object") + .put("foo", true) + .put("bar", "yes") + .put("baz", 1.5) + .putNull("qux") + } + +} + +private val objectMapper = jacksonObjectMapper() diff --git a/src/test/resources/net/pterodactylus/rhynodge/filters/webpages/epicgames/epic.json b/src/test/resources/net/pterodactylus/rhynodge/filters/webpages/epicgames/epic.json new file mode 100644 index 0000000..592f8ee --- /dev/null +++ b/src/test/resources/net/pterodactylus/rhynodge/filters/webpages/epicgames/epic.json @@ -0,0 +1 @@ +{"data":{"Catalog":{"searchStore":{"elements":[{"title":"Islets","id":"4862aa73b8b048c782958b8f22118d28","namespace":"cecc5a53aa534e15881fdbd67a1e83b7","description":"Take to the sky and reunite a fragmented world in this surprisingly wholesome metroidvania! Help Iko adventure across beautiful hand-painted islands, receive letters from a quirky cast of characters, and face powerful monstrous adversaries.","effectiveDate":"2022-08-24T13:00:00.000Z","offerType":"BASE_GAME","expiryDate":null,"viewableDate":"2022-08-15T13:00:00.000Z","status":"ACTIVE","isCodeRedemptionOnly":false,"keyImages":[{"type":"OfferImageWide","url":"https://cdn1.epicgames.com/spt-assets/f991a978e0ce4156a52f951e96e388e7/islets-offer-1ok6p.png"},{"type":"OfferImageTall","url":"https://cdn1.epicgames.com/spt-assets/f991a978e0ce4156a52f951e96e388e7/download-islets-offer-rtq8h.png"},{"type":"Thumbnail","url":"https://cdn1.epicgames.com/spt-assets/f991a978e0ce4156a52f951e96e388e7/download-islets-offer-rtq8h.png"}],"seller":{"id":"o-ttudgfyffrswppq6pflnkwfzdm6qc3","name":"Armor Games Studios"},"productSlug":null,"urlSlug":"99c1d8e3306e4b6081f12eb58cfd2c09","url":null,"items":[{"id":"e6fa7f09955b400ba1392a82bf397b11","namespace":"cecc5a53aa534e15881fdbd67a1e83b7"}],"customAttributes":[{"key":"autoGeneratedPrice","value":"false"},{"key":"isManuallySetPCReleaseDate","value":"false"},{"key":"isBlockchainUsed","value":"false"}],"categories":[{"path":"freegames"},{"path":"games"},{"path":"games/edition"},{"path":"games/edition/base"}],"tags":[{"id":"1381"},{"id":"1336"},{"id":"1370"},{"id":"9547"},{"id":"9549"},{"id":"1151"}],"catalogNs":{"mappings":[{"pageSlug":"islets-5f2670","pageType":"productHome"}]},"offerMappings":[{"pageSlug":"islets-5f2670","pageType":"productHome"}],"price":{"totalPrice":{"discountPrice":0,"originalPrice":1950,"voucherDiscount":0,"discount":1950,"currencyCode":"EUR","currencyInfo":{"decimals":2},"fmtPrice":{"originalPrice":"€19.50","discountPrice":"0","intermediatePrice":"0"}},"lineOffers":[{"appliedRules":[{"id":"28ce846017a24861a1ea9a47f231b685","endDate":"2024-04-04T15:00:00.000Z","discountSetting":{"discountType":"PERCENTAGE"}}]}]},"promotions":{"promotionalOffers":[{"promotionalOffers":[{"startDate":"2024-03-28T15:00:00.000Z","endDate":"2024-04-04T15:00:00.000Z","discountSetting":{"discountType":"PERCENTAGE","discountPercentage":0}}]}],"upcomingPromotionalOffers":[]}},{"title":"The Outer Worlds: Spacer's Choice Edition","id":"0769596f15a445b7a5ad3f8d7c7730e2","namespace":"dc61166eea95474e912953b163791d42","description":"The Outer Worlds: Spacer’s Choice Edition is the ultimate way to play the award-winning RPG from Obsidian Entertainment and Private Division. Including the base game and all DLC, this remastered masterpiece is the absolute best version of The Outer Worlds.","effectiveDate":"2023-03-07T16:00:00.000Z","offerType":"BASE_GAME","expiryDate":null,"viewableDate":"2023-02-27T14:00:00.000Z","status":"ACTIVE","isCodeRedemptionOnly":false,"keyImages":[{"type":"Thumbnail","url":"https://cdn1.epicgames.com/offer/dc61166eea95474e912953b163791d42/EGS_TheOuterWorldsSpacersChoiceEdition_ObsidianEntertainment_S2_1200x1600-24b156886564b75bf9aa823a0a0eb18e"},{"type":"OfferImageTall","url":"https://cdn1.epicgames.com/offer/dc61166eea95474e912953b163791d42/EGS_TheOuterWorldsSpacersChoiceEdition_ObsidianEntertainment_S2_1200x1600-24b156886564b75bf9aa823a0a0eb18e"},{"type":"OfferImageWide","url":"https://cdn1.epicgames.com/offer/dc61166eea95474e912953b163791d42/EGS_TheOuterWorldsSpacersChoiceEdition_ObsidianEntertainment_S1_2560x1440-dd9211a8277a2392a9dd5b108858ba33"},{"type":"CodeRedemption_340x440","url":"https://cdn1.epicgames.com/offer/dc61166eea95474e912953b163791d42/EGS_TheOuterWorldsSpacersChoiceEdition_ObsidianEntertainment_S2_1200x1600-24b156886564b75bf9aa823a0a0eb18e"}],"seller":{"id":"o-6emrb2lpzacm9ued3z37u42x56wkaz","name":"Private Division"},"productSlug":"the-outer-worlds-spacers-choice-edition","urlSlug":"the-outer-worlds-spacers-choice-edition","url":null,"items":[{"id":"63e19dde752b4306aa0c2afeddfc93aa","namespace":"dc61166eea95474e912953b163791d42"}],"customAttributes":[{"key":"com.epicgames.app.blacklist","value":"[]"},{"key":"com.epicgames.app.productSlug","value":"the-outer-worlds-spacers-choice-edition"}],"categories":[{"path":"freegames"},{"path":"games"},{"path":"games/edition"},{"path":"games/edition/base"},{"path":"applications"}],"tags":[{"id":"1216"},{"id":"21122"},{"id":"1188"},{"id":"21894"},{"id":"21127"},{"id":"9547"},{"id":"9549"},{"id":"15375"},{"id":"21138"},{"id":"21139"},{"id":"21140"},{"id":"21141"},{"id":"1367"},{"id":"1210"},{"id":"1370"},{"id":"21147"},{"id":"21149"},{"id":"21119"}],"catalogNs":{"mappings":[{"pageSlug":"the-outer-worlds-spacers-choice-edition","pageType":"productHome"}]},"offerMappings":[],"price":{"totalPrice":{"discountPrice":5999,"originalPrice":5999,"voucherDiscount":0,"discount":0,"currencyCode":"EUR","currencyInfo":{"decimals":2},"fmtPrice":{"originalPrice":"€59.99","discountPrice":"€59.99","intermediatePrice":"€59.99"}},"lineOffers":[{"appliedRules":[]}]},"promotions":{"promotionalOffers":[],"upcomingPromotionalOffers":[{"promotionalOffers":[{"startDate":"2024-04-04T15:00:00.000Z","endDate":"2024-04-11T15:00:00.000Z","discountSetting":{"discountType":"PERCENTAGE","discountPercentage":0}}]}]}},{"title":"Thief","id":"23e4018b5fea4fa0bc1337da2b286154","namespace":"3319fe5042ab4392a2b11c6938c0cda1","description":"Thief is a 1st person stealth-action game by Eidos-Montréal. In this reimagination of the cult classic Thief franchise, steal, stealth and infiltrate your way through the treacherous City as Garrett, the Master Thief.","effectiveDate":"2023-12-13T16:00:00.000Z","offerType":"BASE_GAME","expiryDate":null,"viewableDate":"2023-12-13T16:00:00.000Z","status":"ACTIVE","isCodeRedemptionOnly":false,"keyImages":[{"type":"OfferImageWide","url":"https://cdn1.epicgames.com/spt-assets/44b12bc6a7f045a3bf313574c344dfd7/thief-ms1j4.png"},{"type":"OfferImageTall","url":"https://cdn1.epicgames.com/spt-assets/44b12bc6a7f045a3bf313574c344dfd7/thief-1hsod.png"},{"type":"Thumbnail","url":"https://cdn1.epicgames.com/spt-assets/44b12bc6a7f045a3bf313574c344dfd7/thief-1hsod.png"}],"seller":{"id":"o-6e9jt6yt8fym4t52q6pcfeke4rxv5l","name":"Eidos Interactive Corporation"},"productSlug":null,"urlSlug":"89f4a7b9f3df4f2aa468a2ee2fe38154","url":null,"items":[{"id":"a8d4d0ed64d743c8a8e1adb0e18dccbc","namespace":"3319fe5042ab4392a2b11c6938c0cda1"}],"customAttributes":[{"key":"autoGeneratedPrice","value":"false"},{"key":"isManuallySetViewableDate","value":"false"},{"key":"isManuallySetPCReleaseDate","value":"true"},{"key":"isBlockchainUsed","value":"false"}],"categories":[{"path":"freegames"},{"path":"games"},{"path":"games/edition"},{"path":"games/edition/base"}],"tags":[{"id":"21894"},{"id":"1336"},{"id":"1370"},{"id":"9547"},{"id":"1084"},{"id":"15375"}],"catalogNs":{"mappings":[{"pageSlug":"thief-5bb95f","pageType":"productHome"}]},"offerMappings":[{"pageSlug":"thief-5bb95f","pageType":"productHome"}],"price":{"totalPrice":{"discountPrice":1999,"originalPrice":1999,"voucherDiscount":0,"discount":0,"currencyCode":"EUR","currencyInfo":{"decimals":2},"fmtPrice":{"originalPrice":"€19.99","discountPrice":"€19.99","intermediatePrice":"€19.99"}},"lineOffers":[{"appliedRules":[]}]},"promotions":{"promotionalOffers":[],"upcomingPromotionalOffers":[{"promotionalOffers":[{"startDate":"2024-04-04T15:00:00.000Z","endDate":"2024-04-11T15:00:00.000Z","discountSetting":{"discountType":"PERCENTAGE","discountPercentage":0}}]}]}},{"title":"Lost Castle: The Old Ones Awaken","id":"4a88d0dc64114b20b67339c74543f859","namespace":"ab29925a0a9a49598adba45d108ceb3e","description":"The Old Ones Awaken adds new levels to explore, new enemies to defeat, and new bosses to face along with a host exciting new weapons and equipment to wield and wear.","effectiveDate":"2024-02-08T16:00:00.000Z","offerType":"ADD_ON","expiryDate":null,"viewableDate":"2024-02-01T16:00:00.000Z","status":"ACTIVE","isCodeRedemptionOnly":false,"keyImages":[{"type":"OfferImageWide","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-r390n.png"},{"type":"OfferImageTall","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-1qvy6.jpg"},{"type":"Thumbnail","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-1qvy6.jpg"},{"type":"featuredMedia","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-5fr2h.jpg"},{"type":"featuredMedia","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-tl3jh.jpg"},{"type":"featuredMedia","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-ooqww.jpg"},{"type":"featuredMedia","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-y89ep.jpg"},{"type":"featuredMedia","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-sagu3.jpg"},{"type":"featuredMedia","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-1309n.jpg"},{"type":"featuredMedia","url":"https://cdn1.epicgames.com/spt-assets/a6d76157ad884f2c9aa470b30da9e2ff/lost-castle-1mwvz.jpg"}],"seller":{"id":"o-ze7grkplqlrzc92lepkjv4xpaj7gn8","name":"Another Indie Studio Limited"},"productSlug":null,"urlSlug":"lost-castle-the-old-ones-awaken","url":null,"items":[{"id":"30f2fedfe5af4e9d96e151696f372a70","namespace":"ab29925a0a9a49598adba45d108ceb3e"}],"customAttributes":[{"key":"isManuallySetRefundableType","value":"true"},{"key":"autoGeneratedPrice","value":"false"},{"key":"isManuallySetViewableDate","value":"true"},{"key":"isManuallySetPCReleaseDate","value":"false"},{"key":"isBlockchainUsed","value":"false"}],"categories":[{"path":"addons"},{"path":"freegames"},{"path":"addons/durable"}],"tags":[{"id":"1264"},{"id":"1265"},{"id":"1367"},{"id":"1370"},{"id":"1083"},{"id":"9547"},{"id":"9549"}],"catalogNs":{"mappings":[{"pageSlug":"lost-castle-abb2e2","pageType":"productHome"}]},"offerMappings":[{"pageSlug":"lost-castle-lost-castle-the-old-ones-awaken-db1545","pageType":"offer"}],"price":{"totalPrice":{"discountPrice":359,"originalPrice":359,"voucherDiscount":0,"discount":0,"currencyCode":"EUR","currencyInfo":{"decimals":2},"fmtPrice":{"originalPrice":"€3.59","discountPrice":"€3.59","intermediatePrice":"€3.59"}},"lineOffers":[{"appliedRules":[]}]},"promotions":{"promotionalOffers":[],"upcomingPromotionalOffers":[{"promotionalOffers":[{"startDate":"2024-04-11T15:00:00.000Z","endDate":"2024-04-25T15:00:00.000Z","discountSetting":{"discountType":"PERCENTAGE","discountPercentage":50}}]}]}}],"paging":{"count":1000,"total":4}}}},"extensions":{}} \ No newline at end of file