From 3666b9262630d1dcc56b02fe2f1dd45f3a22c21e Mon Sep 17 00:00:00 2001 From: Anibus Date: Thu, 10 Apr 2025 03:48:24 +0300 Subject: [PATCH] - Add building database schema - Add building addon database schema - Add addon modifiers and requires schema - Change weapon-entity many-to-many tables unique keys (same weapon can be in different hardpoint or positions, so unique key is unit+weapon+hardpoint+pozition) - Add building rgd extract service - Add building addons rgd extract service - Add weapon min radius, damage radius, min-max throw force - Fix bug, when same weapon not show in different slots --- pom.xml | 6 + .../dowstats/controllers/AssetsController.kt | 8 +- .../controllers/BuildingsController.kt | 47 ++++ .../dowstats/data/dto/BuildingDataToSave.kt | 11 + .../com/dowstats/data/dto/UnitDataToSave.kt | 8 +- .../data/dto/controllers/BuildingDto.kt | 16 ++ .../dowstats/data/entities/AddonModifiers.kt | 23 ++ .../dowstats/data/entities/AddonRequires.kt | 25 ++ .../com/dowstats/data/entities/Building.kt | 50 ++++ .../dowstats/data/entities/BuildingAddon.kt | 38 +++ .../dowstats/data/entities/BuildingWeapon.kt | 42 +++ .../dowstats/data/entities/SergantWeapon.kt | 12 +- .../com/dowstats/data/entities/UnitWeapon.kt | 12 +- .../com/dowstats/data/entities/Weapon.kt | 4 + .../data/repositories/AddonRepository.kt | 10 + .../data/repositories/BuildingRepository.kt | 19 ++ .../datamaps/DowBuildingMappingService.kt | 65 +++++ ...nitService.kt => DowUnitMappingService.kt} | 0 .../ModStorageIntegrationService.kt | 21 +- .../w40k/BuildingAddonRgdExtractService.kt | 116 +++++++++ .../service/w40k/BuildingRgdExtractService.kt | 239 ++++++++++++++++++ .../com/dowstats/service/w40k/IconsService.kt | 11 +- .../service/w40k/ModAttribPathService.kt | 3 + .../dowstats/service/w40k/ModParserService.kt | 123 +++++++-- .../service/w40k/SergantRgdExtractService.kt | 7 +- .../service/w40k/UnitRgdExtractService.kt | 114 +++++---- .../service/w40k/WeaponRgdExtractService.kt | 51 ++-- .../db/0.0.1/schema/addon_modifiers.json | 63 +++++ .../db/0.0.1/schema/addon_requires.json | 63 +++++ .../db/0.0.1/schema/building_addons.json | 104 ++++++++ .../resources/db/0.0.1/schema/buildings.json | 170 +++++++++++++ .../db/0.0.1/schema/buildings_weapons.json | 82 ++++++ .../db/0.0.1/schema/sergants_weapons.json | 4 +- src/main/resources/db/0.0.1/schema/units.json | 8 +- .../db/0.0.1/schema/units_weapons.json | 4 +- .../resources/db/0.0.1/schema/weapons.json | 31 ++- src/main/resources/db/changelog-master.json | 20 ++ .../w40k/BuildingRgdExtractServiceTest.kt | 50 ++++ src/test/resources/application.properties | 27 ++ .../rgd/waagh_banner/ork_waagh_banner.rgd | Bin 0 -> 235614 bytes 40 files changed, 1571 insertions(+), 136 deletions(-) create mode 100644 src/main/kotlin/com/dowstats/controllers/BuildingsController.kt create mode 100644 src/main/kotlin/com/dowstats/data/dto/BuildingDataToSave.kt create mode 100644 src/main/kotlin/com/dowstats/data/dto/controllers/BuildingDto.kt create mode 100644 src/main/kotlin/com/dowstats/data/entities/AddonModifiers.kt create mode 100644 src/main/kotlin/com/dowstats/data/entities/AddonRequires.kt create mode 100644 src/main/kotlin/com/dowstats/data/entities/Building.kt create mode 100644 src/main/kotlin/com/dowstats/data/entities/BuildingAddon.kt create mode 100644 src/main/kotlin/com/dowstats/data/entities/BuildingWeapon.kt create mode 100644 src/main/kotlin/com/dowstats/data/repositories/AddonRepository.kt create mode 100644 src/main/kotlin/com/dowstats/data/repositories/BuildingRepository.kt create mode 100644 src/main/kotlin/com/dowstats/service/datamaps/DowBuildingMappingService.kt rename src/main/kotlin/com/dowstats/service/datamaps/{DowUnitService.kt => DowUnitMappingService.kt} (100%) create mode 100644 src/main/kotlin/com/dowstats/service/w40k/BuildingAddonRgdExtractService.kt create mode 100644 src/main/kotlin/com/dowstats/service/w40k/BuildingRgdExtractService.kt create mode 100644 src/main/resources/db/0.0.1/schema/addon_modifiers.json create mode 100644 src/main/resources/db/0.0.1/schema/addon_requires.json create mode 100644 src/main/resources/db/0.0.1/schema/building_addons.json create mode 100644 src/main/resources/db/0.0.1/schema/buildings.json create mode 100644 src/main/resources/db/0.0.1/schema/buildings_weapons.json create mode 100644 src/test/kotlin/com/example/dowstats/service/w40k/BuildingRgdExtractServiceTest.kt create mode 100644 src/test/resources/application.properties create mode 100644 src/test/resources/rgd/waagh_banner/ork_waagh_banner.rgd diff --git a/pom.xml b/pom.xml index 4dc63c7..38e2d2e 100644 --- a/pom.xml +++ b/pom.xml @@ -106,6 +106,12 @@ kotlin-test 1.7.22 + + io.mockk + mockk-jvm + 1.13.17 + test + diff --git a/src/main/kotlin/com/dowstats/controllers/AssetsController.kt b/src/main/kotlin/com/dowstats/controllers/AssetsController.kt index 9867fd0..907a602 100644 --- a/src/main/kotlin/com/dowstats/controllers/AssetsController.kt +++ b/src/main/kotlin/com/dowstats/controllers/AssetsController.kt @@ -14,9 +14,11 @@ class AssetsController @Autowired constructor( val iconService: IconsService, ) { - @GetMapping("/icon/{raceIconFolder}/{imageName}") - fun getUnits(@PathVariable raceIconFolder: String, + @GetMapping("/icon/{modName}/{raceIconFolder}/{imageName}") + fun getUnits( + @PathVariable modName: String, + @PathVariable raceIconFolder: String, @PathVariable imageName: String,): ByteArray? { - return iconService.returnIcon(raceIconFolder, imageName) + return iconService.returnIcon(modName, raceIconFolder, imageName) } } \ No newline at end of file diff --git a/src/main/kotlin/com/dowstats/controllers/BuildingsController.kt b/src/main/kotlin/com/dowstats/controllers/BuildingsController.kt new file mode 100644 index 0000000..c4bc2ed --- /dev/null +++ b/src/main/kotlin/com/dowstats/controllers/BuildingsController.kt @@ -0,0 +1,47 @@ +package com.dowstats.controllers + +import com.dowstats.data.dto.controllers.RaceBuildings +import com.dowstats.data.dto.controllers.RaceUnits +import com.dowstats.data.entities.Building +import com.dowstats.data.entities.DowUnit +import com.dowstats.data.repositories.BuildingRepository +import com.dowstats.data.repositories.UnitRepository +import com.dowstats.service.datamaps.DowBuildingMappingService +import com.dowstats.service.datamaps.DowUnitMappingService +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + + +@RestController +@RequestMapping("api/v1/buildings") +class BuildingsController @Autowired constructor( + val dowBuildingMappingService: DowBuildingMappingService, + val buildingRepository: BuildingRepository, +) { + + @GetMapping("/{modId}/{raceId}") + fun getBuildingsByModAndRace(@PathVariable modId: Long, + @PathVariable raceId: String): RaceBuildings { + return dowBuildingMappingService.findBuildingsByModAndRace(modId, raceId) + } + + @GetMapping("/mod/{modId}") + fun getUnitsByMod(@PathVariable modId: Long,): List { + return dowBuildingMappingService.findBuildingsByMod(modId) + } + + + @GetMapping("/{buildingId}") + fun getById(@PathVariable buildingId: Long): Building { + return buildingRepository.findById(buildingId).get() + } + + @DeleteMapping + fun removeAll() { + buildingRepository.deleteAll() + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dowstats/data/dto/BuildingDataToSave.kt b/src/main/kotlin/com/dowstats/data/dto/BuildingDataToSave.kt new file mode 100644 index 0000000..ac22e60 --- /dev/null +++ b/src/main/kotlin/com/dowstats/data/dto/BuildingDataToSave.kt @@ -0,0 +1,11 @@ +package com.dowstats.data.dto + +import com.dowstats.data.entities.Building +import com.dowstats.data.entities.DowUnit +import com.dowstats.data.entities.Sergeant +import com.dowstats.data.entities.Weapon + +data class BuildingDataToSave( + val building: Building, + val buildingWeapons: Set, +) \ No newline at end of file diff --git a/src/main/kotlin/com/dowstats/data/dto/UnitDataToSave.kt b/src/main/kotlin/com/dowstats/data/dto/UnitDataToSave.kt index a76778d..8c13d59 100644 --- a/src/main/kotlin/com/dowstats/data/dto/UnitDataToSave.kt +++ b/src/main/kotlin/com/dowstats/data/dto/UnitDataToSave.kt @@ -4,8 +4,14 @@ import com.dowstats.data.entities.DowUnit import com.dowstats.data.entities.Sergeant import com.dowstats.data.entities.Weapon +data class WeaponsData( + val hardpoint: Int, + val hardpointOrder: Int, + val weapon: Weapon, +) + data class UnitDataToSave( val unit: DowUnit, - val unitWeapons: Set, + val unitWeapons: List?, val sergeants: List>>?, ) \ No newline at end of file diff --git a/src/main/kotlin/com/dowstats/data/dto/controllers/BuildingDto.kt b/src/main/kotlin/com/dowstats/data/dto/controllers/BuildingDto.kt new file mode 100644 index 0000000..81e2846 --- /dev/null +++ b/src/main/kotlin/com/dowstats/data/dto/controllers/BuildingDto.kt @@ -0,0 +1,16 @@ +package com.dowstats.data.dto.controllers + +import com.dowstats.data.entities.Race + +data class RaceBuildings( + val race: Race, + val buildings: List, +) + +data class BuildingDto( + val name: String, + val icon: String, + val id: Long, + val armourTypeName: String, + val canDetect: Boolean, +) \ No newline at end of file diff --git a/src/main/kotlin/com/dowstats/data/entities/AddonModifiers.kt b/src/main/kotlin/com/dowstats/data/entities/AddonModifiers.kt new file mode 100644 index 0000000..1bb9ea5 --- /dev/null +++ b/src/main/kotlin/com/dowstats/data/entities/AddonModifiers.kt @@ -0,0 +1,23 @@ +package com.dowstats.data.entities + +import com.fasterxml.jackson.annotation.JsonIgnore +import jakarta.persistence.* + + +@Entity +@Table(name = "addon_modifiers") +class AddonModifiers { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + @ManyToOne + @JoinColumn(name = "addon_id", nullable = false) + @JsonIgnore + var addon: BuildingAddon? = null + + var references: String? = null + var usageType: String? = null + var value: String? = null +} diff --git a/src/main/kotlin/com/dowstats/data/entities/AddonRequires.kt b/src/main/kotlin/com/dowstats/data/entities/AddonRequires.kt new file mode 100644 index 0000000..d148516 --- /dev/null +++ b/src/main/kotlin/com/dowstats/data/entities/AddonRequires.kt @@ -0,0 +1,25 @@ +package com.dowstats.data.entities + +import com.fasterxml.jackson.annotation.JsonIgnore +import jakarta.persistence.* + + +@Entity +@Table(name = "addon_requires") +class AddonRequires { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + @ManyToOne + @JoinColumn(name = "addon_id", nullable = false) + @JsonIgnore + var addon: BuildingAddon? = null + + var references: String? = null + + var replaceWhenDone: Boolean = false + + var value: String? = null +} diff --git a/src/main/kotlin/com/dowstats/data/entities/Building.kt b/src/main/kotlin/com/dowstats/data/entities/Building.kt new file mode 100644 index 0000000..d744b36 --- /dev/null +++ b/src/main/kotlin/com/dowstats/data/entities/Building.kt @@ -0,0 +1,50 @@ +package com.dowstats.data.entities + +import jakarta.persistence.* + + +@Entity +@Table(name = "buildings") +class Building { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + @ManyToOne + @JoinColumn(name = "race_id", nullable = false) + var race: Race? = null + + @ManyToOne + @JoinColumn(name = "armour_type_id", nullable = false) + var armorType: ArmorType? = null + + @ManyToOne + @JoinColumn(name = "armour_type_2_id") + var armorType2: ArmorType? = null + + var name: String? = null + var description: String? = null + var filename: String? = null + var buildCostRequisition: Double? = null + var buildCostPower: Double? = null + var buildCostPopulation: Double? = null + var buildCostFaith: Double? = null + var buildCostSouls: Double? = null + var buildCostTime: Int? = null + var health: Int? = null + var healthRegeneration: Double? = null + var sightRadius: Int? = null + var detectRadius: Int? = null + var repairMax: Int? = null + var icon: String? = null + var modId: Long? = null + + @OneToMany(mappedBy = "building", cascade = [CascadeType.ALL]) + var addons: MutableSet? = null + + @OneToMany(mappedBy = "building", cascade = [CascadeType.ALL]) + var weapons: MutableSet? = null + + +} diff --git a/src/main/kotlin/com/dowstats/data/entities/BuildingAddon.kt b/src/main/kotlin/com/dowstats/data/entities/BuildingAddon.kt new file mode 100644 index 0000000..76cc9df --- /dev/null +++ b/src/main/kotlin/com/dowstats/data/entities/BuildingAddon.kt @@ -0,0 +1,38 @@ +package com.dowstats.data.entities + +import com.fasterxml.jackson.annotation.JsonIgnore +import jakarta.persistence.* + + +@Entity +@Table(name = "building_addons") +class BuildingAddon { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + var id: Long? = null + + @ManyToOne + @JoinColumn(name = "building_id", nullable = false) + @JsonIgnore + var building: Building? = null + + var name: String? = null + var description: String? = null + var filename: String? = null + var addonCostRequisition: Double? = null + var addonCostPower: Double? = null + var addonCostPopulation: Double? = null + var addonCostFaith: Double? = null + var addonCostSouls: Double? = null + var addonCostTime: Int? = null + + @OneToMany(mappedBy = "addon", cascade = [CascadeType.ALL]) + var addonModifiers: MutableSet? = null + + @OneToMany(mappedBy = "addon", cascade = [CascadeType.ALL]) + var addonRequires: MutableSet? = null + + var icon: String? = null + +} diff --git a/src/main/kotlin/com/dowstats/data/entities/BuildingWeapon.kt b/src/main/kotlin/com/dowstats/data/entities/BuildingWeapon.kt new file mode 100644 index 0000000..6e5d25e --- /dev/null +++ b/src/main/kotlin/com/dowstats/data/entities/BuildingWeapon.kt @@ -0,0 +1,42 @@ +package com.dowstats.data.entities + +import com.fasterxml.jackson.annotation.JsonIgnore +import jakarta.persistence.* +import java.io.Serializable + + +@Embeddable +class BuildingWeaponKey : Serializable { + @Column(name = "building_id") + var buildingId: Long? = null + + @Column(name = "weapon_id") + var weaponId: Long? = null + + @Column(nullable = false) + var hardpoint: Int = 0 + + @Column(nullable = false) + var hardpointOrder: Int = 0 +} + +@Entity +@Table(name = "buildings_weapons") +class BuildingWeapon { + + @EmbeddedId + @JsonIgnore + var buildingWeaponKey: BuildingWeaponKey? = null + + @ManyToOne + @MapsId("buildingId") + @JoinColumn(name = "building_id") + @JsonIgnore + var building: Building? = null + + @ManyToOne + @MapsId("weaponId") + @JoinColumn(name = "weapon_id") + var weapon: Weapon? = null + +} diff --git a/src/main/kotlin/com/dowstats/data/entities/SergantWeapon.kt b/src/main/kotlin/com/dowstats/data/entities/SergantWeapon.kt index 5b0ced2..505d059 100644 --- a/src/main/kotlin/com/dowstats/data/entities/SergantWeapon.kt +++ b/src/main/kotlin/com/dowstats/data/entities/SergantWeapon.kt @@ -13,6 +13,11 @@ class SergeantWeaponKey : Serializable { @Column(name = "weapon_id") var weaponId: Long? = null + @Column(nullable = false) + var hardpoint: Int = 0 + + @Column(nullable = false) + var hardpointOrder: Int = 0 } @Entity @@ -33,11 +38,4 @@ class SergeantWeapon { @MapsId("weaponId") @JoinColumn(name = "weapon_id") var weapon: Weapon? = null - - @Column(nullable = false) - var hardpoint: Int = 0 - - @Column(nullable = false) - var hardpointOrder: Int = 0 - } diff --git a/src/main/kotlin/com/dowstats/data/entities/UnitWeapon.kt b/src/main/kotlin/com/dowstats/data/entities/UnitWeapon.kt index 6689782..1a85d53 100644 --- a/src/main/kotlin/com/dowstats/data/entities/UnitWeapon.kt +++ b/src/main/kotlin/com/dowstats/data/entities/UnitWeapon.kt @@ -13,6 +13,12 @@ class UnitWeaponKey : Serializable { @Column(name = "weapon_id") var weaponId: Long? = null + @Column(name = "hardpoint") + var hardpoint: Int = 0 + + @Column(nullable = false) + var hardpointOrder: Int = 0 + } @Entity @@ -34,10 +40,4 @@ class UnitWeapon { @JoinColumn(name = "weapon_id") var weapon: Weapon? = null - @Column(nullable = false) - var hardpoint: Int = 0 - - @Column(nullable = false) - var hardpointOrder: Int = 0 - } diff --git a/src/main/kotlin/com/dowstats/data/entities/Weapon.kt b/src/main/kotlin/com/dowstats/data/entities/Weapon.kt index e5485a3..85d4892 100644 --- a/src/main/kotlin/com/dowstats/data/entities/Weapon.kt +++ b/src/main/kotlin/com/dowstats/data/entities/Weapon.kt @@ -18,6 +18,7 @@ class Weapon { var costTimeSeconds: Int? = null var accuracy: Double? = null var reloadTime: Double? = null + var minRange: Double? = null var maxRange: Double? = null var setupTime: Double? = null var accuracyReductionMoving: Double? = null @@ -25,6 +26,9 @@ class Weapon { var maxDamage: Double? = null var minDamageValue: Double? = null var moraleDamage: Double? = null + var damageRadius: Double? = null + var throwForceMin: Double? = null + var throwForceMax: Double? = null var isMeleeWeapon: Boolean = true var canAttackAir: Boolean = true var canAttackGround: Boolean = true diff --git a/src/main/kotlin/com/dowstats/data/repositories/AddonRepository.kt b/src/main/kotlin/com/dowstats/data/repositories/AddonRepository.kt new file mode 100644 index 0000000..1e3ccc9 --- /dev/null +++ b/src/main/kotlin/com/dowstats/data/repositories/AddonRepository.kt @@ -0,0 +1,10 @@ +package com.dowstats.data.repositories + +import com.dowstats.data.entities.Building +import com.dowstats.data.entities.BuildingAddon +import com.dowstats.data.entities.DowUnit +import com.dowstats.data.entities.Race +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository + +interface AddonRepository : CrudRepository \ No newline at end of file diff --git a/src/main/kotlin/com/dowstats/data/repositories/BuildingRepository.kt b/src/main/kotlin/com/dowstats/data/repositories/BuildingRepository.kt new file mode 100644 index 0000000..989c651 --- /dev/null +++ b/src/main/kotlin/com/dowstats/data/repositories/BuildingRepository.kt @@ -0,0 +1,19 @@ +package com.dowstats.data.repositories + +import com.dowstats.data.entities.Building +import com.dowstats.data.entities.DowUnit +import com.dowstats.data.entities.Race +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.CrudRepository + +interface BuildingRepository : CrudRepository{ + @Query(""" + select b, a1, a2 + from Building b + left join fetch ArmorType a1 on b.armorType.id = a1.id + left join fetch ArmorType a2 on b.armorType2.id = a2.id + where b.modId = :modId + and (:race is null or b.race = :race) + """) + fun findByModIdAndRace(modId: Long, race: Race?): List +} \ No newline at end of file diff --git a/src/main/kotlin/com/dowstats/service/datamaps/DowBuildingMappingService.kt b/src/main/kotlin/com/dowstats/service/datamaps/DowBuildingMappingService.kt new file mode 100644 index 0000000..698cd91 --- /dev/null +++ b/src/main/kotlin/com/dowstats/service/datamaps/DowBuildingMappingService.kt @@ -0,0 +1,65 @@ +package com.dowstats.service.datamaps + +import com.dowstats.data.dto.controllers.BuildingDto +import com.dowstats.data.dto.controllers.RaceBuildings +import com.dowstats.data.dto.controllers.RaceUnits +import com.dowstats.data.dto.controllers.UnitDto +import com.dowstats.data.entities.Building +import com.dowstats.data.entities.DowUnit +import com.dowstats.data.entities.Race +import com.dowstats.data.repositories.BuildingRepository +import com.dowstats.data.repositories.RaceRepository +import com.dowstats.data.repositories.UnitRepository +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service + +@Service +class DowBuildingMappingService @Autowired constructor( + val buildingRepository: BuildingRepository, + val raceRepository: RaceRepository +) { + + fun findBuildingsByMod(modId: Long): List { + return getAllBuildings(modId).groupBy { it.race }.mapNotNull {raceUnits -> + RaceBuildings(raceUnits.key ?: throw Exception("Race is null"), raceUnits.value.toBuildingDto()) + } + } + + fun findBuildingsByModAndRace(modId: Long, race: String): RaceBuildings { + val buildings = getAllBuildings(modId, race) + val raceEntity = race.let{ raceRepository.findById(race) ?: throw Exception("Race $race not found") } + return RaceBuildings(raceEntity, buildings.toBuildingDto()) + } + + + + private fun List.toBuildingDto(): List = + this.mapNotNull { + val name = it.name ?: it.filename + val icon = it.icon + if (name == null || icon == null) null else BuildingDto(name, icon, it.id!!, + it.armorType?.name!!, + (it.detectRadius ?: 0) > 0) + } + + + private fun getAllBuildings(modId: Long, race: String? = null): List { + val raceEntity = race?.let{ raceRepository.findById(race) ?: throw Exception("Race $race not found") } + return filterCompanyUnits(buildingRepository.findByModIdAndRace(modId, raceEntity)) + } + + private fun filterCompanyUnits(buildings: List): List = + buildings.filter { + it.filename?.contains("_sp.") != true + && it.filename?.contains("_sp_") != true + && it.filename?.contains("sp_eldar_") != true + && it.filename?.contains("_dxp3.") != true + && it.filename?.contains("_dxp3_") != true + && it.filename?.contains("_nis.") != true + && it.filename?.contains("_exarch_council.") != true + && it.filename?.contains("_dark_reapers_base.") != true + && it.filename?.contains("tau_squad_slave_murdered") != true + && it.filename?.contains("space_marine_single_player_only_drop_pod_building_2.rgd") != true + && it.filename?.contains("space_marine_single_player_only_drop_pod_building.rgd") != true + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/dowstats/service/datamaps/DowUnitService.kt b/src/main/kotlin/com/dowstats/service/datamaps/DowUnitMappingService.kt similarity index 100% rename from src/main/kotlin/com/dowstats/service/datamaps/DowUnitService.kt rename to src/main/kotlin/com/dowstats/service/datamaps/DowUnitMappingService.kt diff --git a/src/main/kotlin/com/dowstats/service/integrations/ModStorageIntegrationService.kt b/src/main/kotlin/com/dowstats/service/integrations/ModStorageIntegrationService.kt index 92ab5f5..f753d92 100644 --- a/src/main/kotlin/com/dowstats/service/integrations/ModStorageIntegrationService.kt +++ b/src/main/kotlin/com/dowstats/service/integrations/ModStorageIntegrationService.kt @@ -54,14 +54,19 @@ class ModStorageIntegrationService( .sortedBy { it.id } newVersions.map { toSave -> - downloadAndExtractMod(toSave.technicalName, toSave.version) - val savedMod = modRepository.save(Mod().also { - it.version = toSave.version - it.isBeta = toSave.isBeta - it.name = "Dowstats balance mod" - it.technicalName = toSave.technicalName - }) - modParserService.parceModFilesAndSaveToDb(savedMod) + try{ + downloadAndExtractMod(toSave.technicalName, toSave.version) + val savedMod = modRepository.save(Mod().also { + it.version = toSave.version + it.isBeta = toSave.isBeta + it.name = "Dowstats balance mod" + it.technicalName = toSave.technicalName + }) + modParserService.parceModFilesAndSaveToDb(savedMod) + } catch (e: Exception) { + log.error("Error while download and extract mod", e) + } + } } diff --git a/src/main/kotlin/com/dowstats/service/w40k/BuildingAddonRgdExtractService.kt b/src/main/kotlin/com/dowstats/service/w40k/BuildingAddonRgdExtractService.kt new file mode 100644 index 0000000..7566a4e --- /dev/null +++ b/src/main/kotlin/com/dowstats/service/w40k/BuildingAddonRgdExtractService.kt @@ -0,0 +1,116 @@ +package com.dowstats.service.w40k + +import com.dowstats.data.dto.BuildCost +import com.dowstats.data.entities.* +import com.dowstats.data.rgd.RgdData +import com.dowstats.data.rgd.RgdDataUtil.getDoubleByName +import com.dowstats.data.rgd.RgdDataUtil.getRgdTableByName +import com.dowstats.data.rgd.RgdDataUtil.getStringByName +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import java.io.File +import java.nio.file.Path +import kotlin.io.path.exists + +@Service +class BuildingAddonRgdExtractService @Autowired constructor( + private val modAttribPathService: ModAttribPathService, + private val iconsService: IconsService, +) { + + val log = LoggerFactory.getLogger(BuildingAddonRgdExtractService::class.java) + + data class AddonTexts( + val name: String?, + val description: String?, + ) + + fun extractToAddonEntity( + fileName: String, + modDictionary: Map, + addonRgdData: List, + modFolderData: String, + building: Building, + mod: Mod, + ): BuildingAddon { + + val addon = BuildingAddon() + + val nameAndDescription = getAddonNameAndDescription(addonRgdData, modDictionary) + addon.name = nameAndDescription.name + addon.description = nameAndDescription.description + addon.building = building + addon.filename = fileName + + val buildCost = getAddonCost(addonRgdData) + addon.addonCostRequisition = buildCost.requisition + addon.addonCostPower = buildCost.power + addon.addonCostPopulation = buildCost.population + addon.addonCostFaith = buildCost.faith + addon.addonCostSouls = buildCost.souls + addon.addonCostTime = buildCost.time + + val addonIcon = convertIconAndReturnPath(addonRgdData, modFolderData, mod.name) + addon.icon = addonIcon + + + return addon + } + + + private fun getAddonCost(addonData: List): BuildCost { + + val cost = addonData.getRgdTableByName("time_cost") + + val costResources = cost?.getRgdTableByName("cost") + + return BuildCost( + costResources?.getDoubleByName("requisition"), + costResources?.getDoubleByName("power"), + costResources?.getDoubleByName("population"), + costResources?.getDoubleByName("faith"), + costResources?.getDoubleByName("souls"), + cost?.getDoubleByName("time_seconds")?.toInt() + ) + } + + private fun getAddonNameAndDescription(addonData: List, modDictionary: Map): AddonTexts { + val uiInfo = addonData.getRgdTableByName("ui_info") + + val nameRef = uiInfo?.getStringByName("screen_name_id")?.replace("$", "") + val name = nameRef?.let { try{modDictionary[it.toInt()]} catch (e: Exception) { null } } + + val descriptionRefs = uiInfo?.getRgdTableByName("help_text_list") + ?.map{(it.value as String).replace("$", "")} + ?.filter{it != "0" && it != "tables\\text_table.lua" && it != ""} + ?.sortedBy { try { it.toInt() } catch (e: Exception) { 0 } } + + val description = try { + descriptionRefs?.map { modDictionary[it.toInt()] }?.joinToString ( "\n" ) + } catch(e:Exception) { + log.warn("Error parsing ui description", e) + null + } + + return AddonTexts(name, description) + } + + + private fun convertIconAndReturnPath(buildingData: List, modFolderData: String, modName: String?): String? { + val iconPathInMod = buildingData + .getRgdTableByName("ui_info") + ?.getStringByName("icon_name") + ?.replace("/", File.separator) + + + val tgaIconPath = iconPathInMod?.let { + val modIcon = modAttribPathService.getIconPath(modFolderData, it) + if(Path.of(modIcon).exists()) modIcon else + modAttribPathService.getIconPath(modAttribPathService.pathToWanilaData, it) + } + + return tgaIconPath?.let { iconsService.convertTgaToJpegImage(iconPathInMod, it, modName) } + } + +} diff --git a/src/main/kotlin/com/dowstats/service/w40k/BuildingRgdExtractService.kt b/src/main/kotlin/com/dowstats/service/w40k/BuildingRgdExtractService.kt new file mode 100644 index 0000000..162b9a4 --- /dev/null +++ b/src/main/kotlin/com/dowstats/service/w40k/BuildingRgdExtractService.kt @@ -0,0 +1,239 @@ +package com.dowstats.service.w40k + +import com.dowstats.data.dto.BuildCost +import com.dowstats.data.dto.BuildingDataToSave +import com.dowstats.data.entities.* +import com.dowstats.data.rgd.RgdData +import com.dowstats.data.rgd.RgdDataUtil.getDoubleByName +import com.dowstats.data.rgd.RgdDataUtil.getIntByName +import com.dowstats.data.rgd.RgdDataUtil.getRgdTableByName +import com.dowstats.data.rgd.RgdDataUtil.getStringByName +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import java.io.File +import java.nio.file.Path +import kotlin.io.path.exists + +@Service +class BuildingRgdExtractService @Autowired constructor( + private val modAttribPathService: ModAttribPathService, + private val iconsService: IconsService, + private val addonRgdExtractService: BuildingAddonRgdExtractService, +) { + + val log = LoggerFactory.getLogger(BuildingRgdExtractService::class.java) + + data class HealthData( + val hitpoints: Double?, + val regeneration: Double?, + val maxRepaires: Int?, + ) + + data class WeaponsData( + val hardpoint: Int, + val hardpointOrder: Int, + val weaponFilename: String, + ) + + data class BuildingTexts( + val name: String?, + val description: String?, + ) + + fun extractToBuildingEntity( + fileName: String, + modDictionary: Map, + buildingData: List, + weapons: Set, + race: String, + modFolderData: String, + mod: Mod, + races: List, + armorTypes: List, + addonsRgdData: Map>, + ): BuildingDataToSave { + + val building = Building() + val race = races.find { it.id == race } ?: throw Exception("Cant get race $race") + + building.race = race + building.armorType = getBuildingArmourType(buildingData, armorTypes, "type_armour") ?: throw Exception("Cant get armor type") + building.armorType2 = getBuildingArmourType(buildingData, armorTypes, "type_armour_2") + + val nameAndDescription = getBuildingNameAndDescription(buildingData, modDictionary) + building.name = nameAndDescription.name + building.description = nameAndDescription.description + building.filename = fileName + + val buildCost = getBuildCost(buildingData) + building.buildCostRequisition = buildCost.requisition + building.buildCostPower = buildCost.power + building.buildCostPopulation = buildCost.population + building.buildCostFaith = buildCost.faith + building.buildCostSouls = buildCost.souls + building.buildCostTime = buildCost.time + + + + val healthData = getHealthData(buildingData) + building.health = healthData.hitpoints?.toInt() + building.healthRegeneration = healthData.regeneration + + building.sightRadius = getSightRadius(buildingData)?.toInt() + building.detectRadius = getDetectRadius(buildingData)?.toInt() + + building.repairMax = healthData.maxRepaires + + val addons = getAddons(buildingData) + building.addons = addons?.mapNotNull {addonFileName -> + val addonRgdData = addonsRgdData[addonFileName] + if(addonRgdData != null){ + addonRgdExtractService.extractToAddonEntity(addonFileName, modDictionary, addonRgdData, modFolderData, building, mod) + } else { + log.warn("Can't find addon $addonFileName") + null + } + }?.toMutableSet() + + val buildingIcon = convertIconAndReturnPath(buildingData, modFolderData, mod.name) + building.icon = buildingIcon + + val buildingWeapons = getBuildingWeapon(buildingData)?.mapNotNull { weaponData -> + weapons.find { + it.filename == weaponData.weaponFilename + ".rgd" + }.also { + it?.hardpoint = weaponData.hardpoint + it?.hardpointOrder = weaponData.hardpointOrder + } + }.orEmpty().toSet() + + building.modId = mod.id + + return BuildingDataToSave(building, buildingWeapons) + } + + + private fun getBuildingArmourType( + unitData: List, + armorTypes: Iterable, + armorTypeTableName: String + ): ArmorType? { + val armorType = unitData.getRgdTableByName("type_ext") + ?.getRgdTableByName(armorTypeTableName) + ?.getStringByName("\$REF") + + return armorTypes.find { it.id == armorType?.replace("type_armour\\tp_", "")?.replace(".lua", "") } + } + + private fun getBuildCost(buildingData: List): BuildCost { + + val cost = buildingData.getRgdTableByName("cost_ext") + ?.getRgdTableByName("time_cost") + + val costResources = cost?.getRgdTableByName("cost") + + return BuildCost( + costResources?.getDoubleByName("requisition"), + costResources?.getDoubleByName("power"), + costResources?.getDoubleByName("population"), + costResources?.getDoubleByName("faith"), + costResources?.getDoubleByName("souls"), + cost?.getDoubleByName("time_seconds")?.toInt() + ) + } + + private fun getBuildingNameAndDescription(buildingData: List, modDictionary: Map): BuildingTexts { + val uiInfo = buildingData.getRgdTableByName("ui_ext") + ?.getRgdTableByName("ui_info") + + val nameRef = uiInfo?.getStringByName("screen_name_id")?.replace("$", "") + val name = nameRef?.let { try{modDictionary[it.toInt()]} catch (e: Exception) { null } } + + val descriptionRefs = uiInfo?.getRgdTableByName("help_text_list") + ?.map{(it.value as String).replace("$", "")} + ?.filter{it != "0" && it != "tables\\text_table.lua" && it != ""} + ?.sortedBy { try { it.toInt() } catch (e: Exception) { 0 } } + + val description = try { + descriptionRefs?.map { modDictionary[it.toInt()] }?.joinToString ( "\n" ) + } catch(e:Exception) { + log.warn("Error parsing ui description", e) + null + } + + return BuildingTexts(name, description) + } + + private fun getHealthData(buildingData: List): HealthData { + val healthExt = buildingData.getRgdTableByName("health_ext") + return HealthData( + healthExt?.getDoubleByName("hitpoints"), + healthExt?.getDoubleByName("regeneration_rate"), + healthExt?.getIntByName("max_repairers") + ) + } + + + private fun getSightRadius(unitData: List): Double? = unitData + .getRgdTableByName("sight_ext") + ?.getDoubleByName("sight_radius") + + private fun getDetectRadius(unitData: List): Double? = unitData + .getRgdTableByName("sight_ext") + ?.getDoubleByName("keen_sight_radius") + + + private fun getBuildingWeapon(buildingData: List?): List? = buildingData + ?.getRgdTableByName("combat_ext") + ?.getRgdTableByName("hardpoints") + ?.mapNotNull { hardpoint -> + if (hardpoint.name.contains("hardpoint_")) { + val hardpointValue = hardpoint.name.replace("hardpoint_", "").toInt() + val hardpointTable = hardpoint.value as List + hardpointTable.getRgdTableByName("weapon_table")?.let { + it.mapNotNull { weapon -> + (weapon.value as? List)?.getStringByName("weapon")?.let { + if (it != "") { + WeaponsData(hardpointValue, + weapon.name.replace("weapon_", "").toInt(), + it.replace("weapon\\", "").replace(".lua", "")) + } else null + } + } + } + } else null + }?.flatten() + + + private fun convertIconAndReturnPath(buildingData: List, modFolderData: String, modName: String?): String? { + val iconPathInMod = buildingData + .getRgdTableByName("ui_ext") + ?.getRgdTableByName("ui_info") + ?.getStringByName("icon_name") + ?.replace("/", File.separator) + + + val tgaIconPath = iconPathInMod?.let { + val modIcon = modAttribPathService.getIconPath(modFolderData, it) + if(Path.of(modIcon).exists()) modIcon else + modAttribPathService.getIconPath(modAttribPathService.pathToWanilaData, it) + } + + return tgaIconPath?.let { iconsService.convertTgaToJpegImage(iconPathInMod, it, modName) } + } + + + private fun getAddons(buildingData: List): List? = buildingData + .getRgdTableByName("addon_ext") + ?.getRgdTableByName("addons") + ?.mapNotNull { addon -> + if (addon.value != "") { + addon.value.toString() + .replace("addons\\", "") + .replace(".lua", "") + .plus(".rgd") + } else null + } +} + diff --git a/src/main/kotlin/com/dowstats/service/w40k/IconsService.kt b/src/main/kotlin/com/dowstats/service/w40k/IconsService.kt index b9101c3..e5eb2f7 100644 --- a/src/main/kotlin/com/dowstats/service/w40k/IconsService.kt +++ b/src/main/kotlin/com/dowstats/service/w40k/IconsService.kt @@ -20,7 +20,7 @@ class IconsService @Autowired constructor( * @param pathToTgaIcon - путь до иконки * @return путь до сконвертированной иконки */ - fun convertTgaToJpegImage(iconPathInMod: String, pathToTgaIcon: String): String? { + fun convertTgaToJpegImage(iconPathInMod: String, pathToTgaIcon: String, modName: String? = null): String? { try{ val image: BufferedImage = try { ImageIO.read(File(pathToTgaIcon)) @@ -28,7 +28,10 @@ class IconsService @Autowired constructor( ImageIO.read(File(pathToTgaIcon.lowercase())) } - val pathToSave = "${storageConfig.iconsStorage.replace("/", File.separator)}${File.separator}${iconPathInMod.replace("\\", File.separator)}.png" + val modFolder = modName?.let { "${File.separator}$modName" } ?: "" + + val pathToSave = "${storageConfig.iconsStorage.replace("/", File.separator)}$modFolder" + + "${File.separator}${iconPathInMod.replace("\\", File.separator)}.png" val directoryToSave = File(pathToSave.split(File.separator).dropLast(1).joinToString (File.separator)) if(!directoryToSave.exists()) directoryToSave.mkdirs() @@ -45,8 +48,8 @@ class IconsService @Autowired constructor( } - fun returnIcon(raceIconFolder: String, iconName: String): ByteArray? { - val pathToIcon = "${storageConfig.iconsStorage.replace("/", File.separator)}${File.separator}$raceIconFolder${File.separator}$iconName" + fun returnIcon(modName: String, raceIconFolder: String, iconName: String): ByteArray? { + val pathToIcon = "${storageConfig.iconsStorage.replace("/", File.separator)}${File.separator}$modName${File.separator}$raceIconFolder${File.separator}$iconName" return File(pathToIcon).readBytes() } diff --git a/src/main/kotlin/com/dowstats/service/w40k/ModAttribPathService.kt b/src/main/kotlin/com/dowstats/service/w40k/ModAttribPathService.kt index aaebe08..195f1ef 100644 --- a/src/main/kotlin/com/dowstats/service/w40k/ModAttribPathService.kt +++ b/src/main/kotlin/com/dowstats/service/w40k/ModAttribPathService.kt @@ -21,6 +21,9 @@ class ModAttribPathService @Autowired constructor( fun getWeaponAttribsPath(modFolderData: String): String = "$modFolderData${File.separator}attrib${File.separator}weapon" + fun getAddonAttribsPath(modFolderData: String): String = + "$modFolderData${File.separator}attrib${File.separator}addons" + fun getSbpsAttribsFolderPath(modFolderData: String): String = "$modFolderData${File.separator}attrib${File.separator}sbps${File.separator}races${File.separator}" diff --git a/src/main/kotlin/com/dowstats/service/w40k/ModParserService.kt b/src/main/kotlin/com/dowstats/service/w40k/ModParserService.kt index 4b1223c..269d9dd 100644 --- a/src/main/kotlin/com/dowstats/service/w40k/ModParserService.kt +++ b/src/main/kotlin/com/dowstats/service/w40k/ModParserService.kt @@ -24,6 +24,8 @@ class ModParserService @Autowired constructor( val weaponRgdExtractService: WeaponRgdExtractService, val weaponRepository: WeaponRepository, val modAttribPathService: ModAttribPathService, + val buildingRepository: BuildingRepository, + val buildingExtractService: BuildingRgdExtractService, ) { val defaultDictionary: MutableMap = mutableMapOf() @@ -53,8 +55,6 @@ class ModParserService @Autowired constructor( log.info("Start parse mod files ${mod.technicalName}:${mod.version}") - val modId = mod.id!! - val modFolderData = modAttribPathService.getModFolderData(mod.technicalName!!, mod.version!!) val racesList = Files.walk(Path(modAttribPathService.getSbpsAttribsFolderPath(modFolderData)), 1) .toList() @@ -62,10 +62,9 @@ class ModParserService @Autowired constructor( .filter { Files.isDirectory(it) }.map { it.name }.toList() racesList.forEach{ - unitRepository.deleteAllByModIdAndRaceId(modId, it) + unitRepository.deleteAllByModIdAndRaceId(mod.id!!, it) } - val modDictionary: MutableMap = mutableMapOf() log.info("Extract dictionaries from $modFolderData") @@ -73,7 +72,10 @@ class ModParserService @Autowired constructor( it.bufferedReader(Charsets.UTF_8).lines().forEach { val kv = it.split("\\s+".toRegex()) if (kv.size < 2) return@forEach - val key = kv.first().filter { it.isDigit() }.toInt() + val key = try {kv.first().filter { it.isDigit() }.toInt()} catch(e: Exception) { + log.warn("Cant' get key ${kv.first()} for dict") + 0 + } val value = kv.drop(1).joinToString(" ").replace("\u0000","") modDictionary[key] = value } @@ -81,25 +83,20 @@ class ModParserService @Autowired constructor( val enrichedModDictionary = modDictionary + defaultDictionary - val weapons = saveWeapons(modFolderData, modId, enrichedModDictionary) - saveUnits(modFolderData, weapons, racesList, modId, enrichedModDictionary) + val weapons = saveWeapons(modFolderData, mod, enrichedModDictionary) + saveUnits(modFolderData, weapons, racesList, mod, enrichedModDictionary) + + saveBuildings(modFolderData, weapons, racesList, mod, enrichedModDictionary) } - fun saveUnits(modFolderData: String, weapons: Set, racesList: List, modId: Long, modDictionary: Map) { + fun saveUnits(modFolderData: String, weapons: Set, racesList: List, mod: Mod, modDictionary: Map) { val races = raceRepository.findAll().toList() val armorTypes = armorTypeRepository.findAll().toList() racesList.forEach { raceFolder -> - if( raceRepository.findById(raceFolder) == null){ - val race = Race().also { it.id = raceFolder; it.name = raceFolder } - raceRepository.save(race) - } - - println(raceFolder) - val classicRgdDataSquads = rgdParserService.parseFolderToRgdFiles(modAttribPathService.getSbpsAttribsPath(modAttribPathService.pathToWanilaData, raceFolder)) val modRgdDataSquads = @@ -136,7 +133,7 @@ class ModParserService @Autowired constructor( weapons, raceFolder, modFolderData, - modId, + mod, races, armorTypes, modUnitsFull @@ -148,18 +145,18 @@ class ModParserService @Autowired constructor( try { val unit = unitRepository.save(unitDataToSave.unit) - unit.weapons = unitDataToSave.unitWeapons.map {weapon -> + unit.weapons = unitDataToSave.unitWeapons?.map {weapon -> UnitWeapon().also { it.unit = unit - it.weapon = weapon - it.hardpoint = weapon.hardpoint - it.hardpointOrder = weapon.hardpointOrder + it.weapon = weapon.weapon it.unitWeaponKey = UnitWeaponKey().also { it.unitId = unit.id - it.weaponId = weapon.id + it.weaponId = weapon.weapon?.id + it.hardpoint = weapon.hardpoint + it.hardpointOrder = weapon.hardpointOrder } } - }.toMutableSet() + }?.toMutableSet() unitRepository.save(unit) @@ -171,11 +168,12 @@ class ModParserService @Autowired constructor( SergeantWeapon().also { it.sergeant = sergeant it.weapon = weapon - it.hardpoint = weapon.hardpoint - it.hardpointOrder = weapon.hardpointOrder + it.sergeantWeaponKey = SergeantWeaponKey().also { swk -> swk.sergeantId = sergeant.id swk.weaponId = weapon.id + swk.hardpoint = weapon.hardpoint + swk.hardpointOrder = weapon.hardpointOrder } } }.toMutableSet() @@ -188,7 +186,80 @@ class ModParserService @Autowired constructor( } } - fun saveWeapons(modFolderData: String, modId: Long, modDictionary: Map): Set { + fun saveBuildings(modFolderData: String, + weapons: Set, + racesList: List, + mod: Mod, modDictionary: Map) { + + val races = raceRepository.findAll().toList() + val armorTypes = armorTypeRepository.findAll().toList() + + val addonsRgdData = getAddonsRgdData(modFolderData) + + racesList.forEach { raceFolder -> + val classicRgdDataStructures = + rgdParserService.parseFolderToRgdFiles(modAttribPathService.geBuildingAttribsPath(modAttribPathService.pathToWanilaData, raceFolder)) + val modRgdDataStructures = + rgdParserService.parseFolderToRgdFiles(modAttribPathService.geBuildingAttribsPath(modFolderData, raceFolder)) + + val modStructuresFull = classicRgdDataStructures + modRgdDataStructures + + modStructuresFull.forEach { structure -> + val structureDataToSave = + try { + buildingExtractService.extractToBuildingEntity( + structure.key, + modDictionary, + structure.value, + weapons, + raceFolder, + modFolderData, + mod, + races, + armorTypes, + addonsRgdData, + ) + } catch (e: Exception) { + log.error("Can't extract ${structure.key}", e) + return@forEach + } + + try { + val building = buildingRepository.save(structureDataToSave.building) + building.weapons = structureDataToSave.buildingWeapons.map {weapon -> + BuildingWeapon().also { + it.building = building + it.weapon = weapon + it.buildingWeaponKey = BuildingWeaponKey().also { + it.buildingId = building.id + it.weaponId = weapon.id + it.hardpoint = weapon.hardpoint + it.hardpointOrder = weapon.hardpointOrder + } + } + }.toMutableSet() + buildingRepository.save(building) + + } catch (e: Exception) { + throw e + } + } + + + } + } + + fun getAddonsRgdData(modFolderData: String): Map> { + + val classicAddons = rgdParserService.parseFolderToRgdFiles("${modAttribPathService.pathToWanilaData}/attrib/addons") + + val modAddons = + rgdParserService.parseFolderToRgdFiles(modAttribPathService.getAddonAttribsPath(modFolderData)) + + return classicAddons + modAddons + } + + fun saveWeapons(modFolderData: String, mod: Mod, modDictionary: Map): Set { val classicWeapons = rgdParserService.parseFolderToRgdFiles("${modAttribPathService.pathToWanilaData}/attrib/weapon") @@ -198,7 +269,7 @@ class ModParserService @Autowired constructor( val allWeapons = classicWeapons + modWeapons val weaponsToSave = allWeapons.mapNotNull { - weaponRgdExtractService.extractToWeaponEntity(it.key, it.value, modId, modFolderData, modDictionary) + weaponRgdExtractService.extractToWeaponEntity(it.key, it.value, mod, modFolderData, modDictionary) } return try { diff --git a/src/main/kotlin/com/dowstats/service/w40k/SergantRgdExtractService.kt b/src/main/kotlin/com/dowstats/service/w40k/SergantRgdExtractService.kt index bfaf514..794a3bf 100644 --- a/src/main/kotlin/com/dowstats/service/w40k/SergantRgdExtractService.kt +++ b/src/main/kotlin/com/dowstats/service/w40k/SergantRgdExtractService.kt @@ -48,6 +48,7 @@ class SergeantRgdExtractService @Autowired constructor( fun extractToSergeantEntity( fileName: String, modDictionary: Map, + mod: Mod, sergeantData: List, weapons: Set, modFolderData: String, @@ -84,7 +85,7 @@ class SergeantRgdExtractService @Autowired constructor( sergeant.mass = massData.mass sergeant.upTime = massData.upTime - val unitIcon = convertSergeantIconAndReturnPath(sergeantData, modFolderData) + val unitIcon = convertSergeantIconAndReturnPath(sergeantData, modFolderData, mod.name) sergeant.icon = unitIcon val sergeantWeapons = getSergeantWeapons(sergeantData)?.mapNotNull { weaponData -> @@ -183,7 +184,7 @@ class SergeantRgdExtractService @Autowired constructor( } else null }?.flatten() - private fun convertSergeantIconAndReturnPath(sergeantData: List, modFolderData: String): String? { + private fun convertSergeantIconAndReturnPath(sergeantData: List, modFolderData: String, modName: String?): String? { val iconPathInMod = sergeantData .getRgdTableByName("ui_ext") ?.getRgdTableByName("ui_info") @@ -197,7 +198,7 @@ class SergeantRgdExtractService @Autowired constructor( modAttribPathService.getIconPath(modAttribPathService.pathToWanilaData, it) } - return tgaIconPath?.let { iconsService.convertTgaToJpegImage(iconPathInMod, it) } + return tgaIconPath?.let { iconsService.convertTgaToJpegImage(iconPathInMod, it, modName) } } } diff --git a/src/main/kotlin/com/dowstats/service/w40k/UnitRgdExtractService.kt b/src/main/kotlin/com/dowstats/service/w40k/UnitRgdExtractService.kt index cfd7660..6c7d88e 100644 --- a/src/main/kotlin/com/dowstats/service/w40k/UnitRgdExtractService.kt +++ b/src/main/kotlin/com/dowstats/service/w40k/UnitRgdExtractService.kt @@ -2,6 +2,7 @@ package com.dowstats.service.w40k import com.dowstats.data.dto.BuildCost import com.dowstats.data.dto.UnitDataToSave +import com.dowstats.data.dto.WeaponsData import com.dowstats.data.entities.* import com.dowstats.data.rgd.RgdData import com.dowstats.data.rgd.RgdDataUtil.getDoubleByName @@ -36,12 +37,6 @@ class UnitRgdExtractService @Autowired constructor( val regeneration: Double?, ) - data class WeaponsData( - val hardpoint: Int, - val hardpointOrder: Int, - val weaponFilename: String, - ) - data class SergeantData( val filePath: String, val cost: BuildCost, @@ -65,7 +60,7 @@ class UnitRgdExtractService @Autowired constructor( weapons: Set, race: String, modFolderData: String, - modId: Long, + mod: Mod, races: List, armorTypes: List, modUnitsFull: Map>, @@ -131,10 +126,11 @@ class UnitRgdExtractService @Autowired constructor( val sergeantsEntities: List>>? = sergeantsData.first?.mapNotNull { sergeantData -> val sergeantFile = sergeantData.filePath.split("\\").last().replace(".lua", ".rgd") val sergeantRgdData = modUnitsFull[sergeantFile] - if(sergeantRgdData != null){ + if (sergeantRgdData != null) { sergeantRgdExtractService.extractToSergeantEntity( sergeantFile, modDictionary, + mod, sergeantRgdData, weapons, modFolderData, @@ -146,19 +142,12 @@ class UnitRgdExtractService @Autowired constructor( unit.maxSergeants = sergeantsData.second - val unitIcon = convertIconAndReturnPath(squadData, modFolderData) + val unitIcon = convertIconAndReturnPath(mod.name, squadData, modFolderData) unit.icon = unitIcon - val unitWeapons = getUnitWeapons(unitData)?.mapNotNull { weaponData -> - weapons.find { - it.filename == weaponData.weaponFilename + ".rgd" - }.also { - it?.hardpoint = weaponData.hardpoint - it?.hardpointOrder = weaponData.hardpointOrder - } - }.orEmpty().toSet() + val unitWeapons = getUnitWeapons(unitData, weapons) - unit.modId = modId + unit.modId = mod.id return UnitDataToSave(unit, unitWeapons, sergeantsEntities) } @@ -212,16 +201,28 @@ class UnitRgdExtractService @Autowired constructor( val nameRef = uiInfo?.getStringByName("screen_name_id")?.replace("$", "") - val name = nameRef?.let { try{modDictionary[it.toInt()]} catch (e: Exception) { null } } + val name = nameRef?.let { + try { + modDictionary[it.toInt()] + } catch (e: Exception) { + null + } + } val descriptionRefs = uiInfo?.getRgdTableByName("help_text_list") - ?.map{(it.value as String).replace("$", "")} - ?.filter{it != "0" && it != "tables\\text_table.lua" && it != ""} - ?.sortedBy { try { it.toInt() } catch (e: Exception) { 0 } } + ?.map { (it.value as String).replace("$", "") } + ?.filter { it != "0" && it != "tables\\text_table.lua" && it != "" } + ?.sortedBy { + try { + it.toInt() + } catch (e: Exception) { + 0 + } + } val description = try { - descriptionRefs?.map { modDictionary[it.toInt()] }?.joinToString ( "\n" ) - } catch(e:Exception) { + descriptionRefs?.map { modDictionary[it.toInt()] }?.joinToString("\n") + } catch (e: Exception) { log.warn("Error parsing ui description", e) null } @@ -321,20 +322,34 @@ class UnitRgdExtractService @Autowired constructor( private fun getReinforceTime(reinforceData: List?): Int? = reinforceData ?.getIntByName("time_seconds") - private fun getUnitWeapons(reinforceData: List?): List? = reinforceData + private fun getUnitWeapons(reinforceData: List?, weapons: Set): List? = reinforceData ?.getRgdTableByName("combat_ext") ?.getRgdTableByName("hardpoints") ?.mapNotNull { hardpoint -> if (hardpoint.name.contains("hardpoint_")) { val hardpointValue = hardpoint.name.replace("hardpoint_", "").toInt() val hardpointTable = hardpoint.value as List + + + hardpointTable.getRgdTableByName("weapon_table")?.let { it.mapNotNull { weapon -> (weapon.value as? List)?.getStringByName("weapon")?.let { if (it != "") { - WeaponsData(hardpointValue, - weapon.name.replace("weapon_", "").toInt(), - it.replace("weapon\\", "").replace(".lua", "")) + val weaponFileName = it.replace("weapon\\", "").replace(".lua", ".rgd") + val weaponEntity = weapons.find { + it.filename == weaponFileName + } + if(weaponEntity == null){ + log.warn("Can't find weapon $weaponFileName") + null + } else { + WeaponsData( + hardpointValue, + weapon.name.replace("weapon_", "").toInt(), + weaponEntity + ) + } } else null } } @@ -350,34 +365,35 @@ class UnitRgdExtractService @Autowired constructor( val maxSergeants = squadTable?.getIntByName("max_leaders") val sergeantsData = squadTable?.mapNotNull { sergeantData -> - if (sergeantData.name.contains("leader_")) { - val sergeantRgdTable = sergeantData.value as List + if (sergeantData.name.contains("leader_")) { + val sergeantRgdTable = sergeantData.value as List - val sergeantLeaderFilePath = sergeantRgdTable.getRgdTableByName("leader")?.getStringByName("type") + val sergeantLeaderFilePath = sergeantRgdTable.getRgdTableByName("leader")?.getStringByName("type") - if(sergeantLeaderFilePath == null || sergeantLeaderFilePath == "") null else { + if (sergeantLeaderFilePath == null || sergeantLeaderFilePath == "") null else { - val cost = sergeantRgdTable.getRgdTableByName("cost_time") - val costResources = cost?.getRgdTableByName("cost") + val cost = sergeantRgdTable.getRgdTableByName("cost_time") + val costResources = cost?.getRgdTableByName("cost") - SergeantData(sergeantLeaderFilePath, - BuildCost( - costResources?.getDoubleByName("requisition"), - costResources?.getDoubleByName("power"), - costResources?.getDoubleByName("population"), - costResources?.getDoubleByName("faith"), - costResources?.getDoubleByName("souls"), - cost?.getIntByName("time_seconds"), - ) + SergeantData( + sergeantLeaderFilePath, + BuildCost( + costResources?.getDoubleByName("requisition"), + costResources?.getDoubleByName("power"), + costResources?.getDoubleByName("population"), + costResources?.getDoubleByName("faith"), + costResources?.getDoubleByName("souls"), + cost?.getIntByName("time_seconds"), ) - } - } else null - } + ) + } + } else null + } return Pair(sergeantsData, maxSergeants) } - private fun convertIconAndReturnPath(squadData: List, modFolderData: String): String? { + private fun convertIconAndReturnPath(modName: String?, squadData: List, modFolderData: String): String? { val iconPathInMod = squadData .getRgdTableByName("squad_ui_ext") ?.getRgdTableByName("ui_info") @@ -387,11 +403,11 @@ class UnitRgdExtractService @Autowired constructor( val tgaIconPath = iconPathInMod?.let { val modIcon = modAttribPathService.getIconPath(modFolderData, it) - if(Path.of(modIcon).exists()) modIcon else + if (Path.of(modIcon).exists()) modIcon else modAttribPathService.getIconPath(modAttribPathService.pathToWanilaData, it) } - return tgaIconPath?.let { iconsService.convertTgaToJpegImage(iconPathInMod, it) } + return tgaIconPath?.let { iconsService.convertTgaToJpegImage(iconPathInMod, it, modName) } } } diff --git a/src/main/kotlin/com/dowstats/service/w40k/WeaponRgdExtractService.kt b/src/main/kotlin/com/dowstats/service/w40k/WeaponRgdExtractService.kt index c83e5be..a70d3bc 100644 --- a/src/main/kotlin/com/dowstats/service/w40k/WeaponRgdExtractService.kt +++ b/src/main/kotlin/com/dowstats/service/w40k/WeaponRgdExtractService.kt @@ -1,6 +1,7 @@ package com.dowstats.service.w40k import com.dowstats.data.entities.ArmorType +import com.dowstats.data.entities.Mod import com.dowstats.data.entities.Weapon import com.dowstats.data.entities.WeaponArmorPiercing import com.dowstats.data.repositories.ArmorTypeRepository @@ -32,9 +33,12 @@ class WeaponRgdExtractService @Autowired constructor( val seconds: Int? ) - data class ArmourDamage( + data class AreaEffectData( val minDamage: Double, val maxDamage: Double, + val damageRadius: Double, + val throwForceMin: Double, + val throwForceMax: Double, val minDamageValue: Double, val moraleDamage: Double, val armourPiercing: List, @@ -47,14 +51,14 @@ class WeaponRgdExtractService @Autowired constructor( val haveEquipButton: Boolean ) - fun extractToWeaponEntity(weaponFileName: String, weaponData: List, modId: Long, modFolderData: String, modDictionary: Map): Weapon? { + fun extractToWeaponEntity(weaponFileName: String, weaponData: List, mod: Mod, modFolderData: String, modDictionary: Map): Weapon? { val armorTypes = armorTypeRepository.findAll().toSet() val weapon = Weapon() weapon.filename = weaponFileName - val weaponUiData = getWeaponNameAndDescription(weaponData, modDictionary, modFolderData) + val weaponUiData = getWeaponNameAndDescription(weaponData, modDictionary, modFolderData, mod) weapon.name = weaponUiData.name weapon.icon = weaponUiData.iconPath weapon.description = weaponUiData.description @@ -67,21 +71,26 @@ class WeaponRgdExtractService @Autowired constructor( weapon.costTimeSeconds = cost.seconds ?: 0 weapon.accuracy = weaponData.getDoubleByName("accuracy") weapon.accuracyReductionMoving = weaponData.getDoubleByName("accuracy_reduction_when_moving") + weapon.minRange = weaponData.getDoubleByName("min_range") weapon.maxRange = weaponData.getDoubleByName("max_range") + weapon.reloadTime = weaponData.getDoubleByName("reload_time") weapon.setupTime = weaponData.getDoubleByName("setup_time") weapon.isMeleeWeapon = weaponData.getBooleanByName("melee_weapon") ?: false - weapon.canAttackAir = weaponData.getBooleanByName("can_attack_air_units") ?: false - weapon.canAttackGround = weaponData.getBooleanByName("can_attack_ground_units") ?: false + weapon.canAttackAir = weaponData.getBooleanByName("can_attack_air_units") ?: true + weapon.canAttackGround = weaponData.getBooleanByName("can_attack_ground_units") ?: true - val armourDamage = getArmourDamage(weaponData, armorTypes, weapon) - weapon.minDamageValue = armourDamage.minDamageValue - weapon.minDamage = armourDamage.minDamage - weapon.maxDamage = armourDamage.maxDamage - weapon.moraleDamage = armourDamage.moraleDamage - weapon.weaponPiercings = armourDamage.armourPiercing - weapon.modId = modId + val areaEffectData = getAreaEffectData(weaponData, armorTypes, weapon) + weapon.minDamageValue = areaEffectData.minDamageValue + weapon.minDamage = areaEffectData.minDamage + weapon.maxDamage = areaEffectData.maxDamage + weapon.throwForceMin = areaEffectData.throwForceMin + weapon.throwForceMax = areaEffectData.throwForceMax + weapon.moraleDamage = areaEffectData.moraleDamage + weapon.weaponPiercings = areaEffectData.armourPiercing + weapon.damageRadius = areaEffectData.damageRadius + weapon.modId = mod.id return if(weapon.minDamage == 0.0 && weapon.maxDamage == 0.0 && weapon.moraleDamage == 0.0){ null @@ -97,8 +106,15 @@ class WeaponRgdExtractService @Autowired constructor( return BuildCost(requisition, power, costTime) } - private fun getArmourDamage(weaponData: List, armorTypes: Set, thisWeapon: Weapon): ArmourDamage { - val armourDamage = weaponData.getRgdTableByName("area_effect") + private fun getAreaEffectData(weaponData: List, armorTypes: Set, thisWeapon: Weapon): AreaEffectData { + val areaEffect = weaponData.getRgdTableByName("area_effect") + val damageRadius = areaEffect?.getRgdTableByName("area_effect_information")?.getDoubleByName("radius") ?: 0.0 + + val throwData = areaEffect?.getRgdTableByName("throw_data") + val forceMin = throwData?.getDoubleByName("force_min") ?: 0.0 + val forceMax = throwData?.getDoubleByName("force_max") ?: 0.0 + + val armourDamage = areaEffect ?.getRgdTableByName("weapon_damage") ?.getRgdTableByName("armour_damage")!! val minDamage = armourDamage.getDoubleByName("min_damage")!! @@ -106,6 +122,7 @@ class WeaponRgdExtractService @Autowired constructor( val minDamageValue = armourDamage.getDoubleByName("min_damage_value")!! val moraleDamage = armourDamage.getDoubleByName("morale_damage")!! + val defaultArmourPiercing = armourDamage.getDoubleByName("armour_piercing") val weaponDmgMap: Map = armourDamage.getRgdTableByName("armour_piercing_types")!!.mapNotNull { armour_piercing -> @@ -125,11 +142,11 @@ class WeaponRgdExtractService @Autowired constructor( weaponArmourPiercing } - return ArmourDamage(minDamage, maxDamage, minDamageValue, moraleDamage, armoursPiercing) + return AreaEffectData(minDamage, maxDamage, damageRadius, forceMin, forceMax, minDamageValue, moraleDamage, armoursPiercing) } - private fun getWeaponNameAndDescription(weaponData: List, modDictionary: Map, modFolderData: String): WeaponUiInfo { + private fun getWeaponNameAndDescription(weaponData: List, modDictionary: Map, modFolderData: String, mod: Mod): WeaponUiInfo { val weaponUiInfo = weaponData.getRgdTableByName("ui_info") val nameRef = weaponUiInfo?.getStringByName("screen_name_id")?.replace("$", "") @@ -156,7 +173,7 @@ class WeaponRgdExtractService @Autowired constructor( if(Path.of(modIcon).exists()) modIcon else modAttribPathService.getIconPath(modAttribPathService.pathToWanilaData, it) } - tgaIconPath?.let { iconsService.convertTgaToJpegImage(iconPath, it) } + tgaIconPath?.let { iconsService.convertTgaToJpegImage(iconPath, it, mod.name) } } catch (e: Exception) { log.error("Error parsing ui icon path", e) null diff --git a/src/main/resources/db/0.0.1/schema/addon_modifiers.json b/src/main/resources/db/0.0.1/schema/addon_modifiers.json new file mode 100644 index 0000000..2a86160 --- /dev/null +++ b/src/main/resources/db/0.0.1/schema/addon_modifiers.json @@ -0,0 +1,63 @@ +{ + "databaseChangeLog": [ + { + "changeSet": { + "id": "Add buildings table", + "author": "anibus", + "changes": [ + { + "createTable": { + "tableName": "addon_modifiers", + "columns": [ + { + "column": { + "name": "id", + "type": "int", + "autoIncrement": true, + "constraints": { + "primaryKey": true, + "nullable": false + } + } + },{ + "column": { + "name": "references", + "type": "varchar(255)" + } + },{ + "column": { + "name": "usage_type", + "type": "varchar(255)" + } + },{ + "column": { + "name": "value", + "type": "number" + } + },{ + "column": { + "name": "addon_id", + "type": "int", + "constraints": { + "nullable": false + } + } + } + ] + } + }, + { + "addForeignKeyConstraint": + { + "baseColumnNames": "addon_id", + "baseTableName": "addon_modifiers", + "constraintName": "fk_building_addons_addon_modifiers", + "referencedColumnNames": "id", + "referencedTableName": "building_addons" + } + } + ] + } + } + ] +} diff --git a/src/main/resources/db/0.0.1/schema/addon_requires.json b/src/main/resources/db/0.0.1/schema/addon_requires.json new file mode 100644 index 0000000..4fe22a2 --- /dev/null +++ b/src/main/resources/db/0.0.1/schema/addon_requires.json @@ -0,0 +1,63 @@ +{ + "databaseChangeLog": [ + { + "changeSet": { + "id": "Add buildings table", + "author": "anibus", + "changes": [ + { + "createTable": { + "tableName": "addon_requires", + "columns": [ + { + "column": { + "name": "id", + "type": "int", + "autoIncrement": true, + "constraints": { + "primaryKey": true, + "nullable": false + } + } + },{ + "column": { + "name": "references", + "type": "varchar(255)" + } + },{ + "column": { + "name": "value", + "type": "varchar(255)" + } + },{ + "column": { + "name": "replace_when_done", + "type": "boolean" + } + },{ + "column": { + "name": "addon_id", + "type": "int", + "constraints": { + "nullable": false + } + } + } + ] + } + }, + { + "addForeignKeyConstraint": + { + "baseColumnNames": "addon_id", + "baseTableName": "addon_requires", + "constraintName": "fk_building_addons_addon_requires", + "referencedColumnNames": "id", + "referencedTableName": "building_addons" + } + } + ] + } + } + ] +} diff --git a/src/main/resources/db/0.0.1/schema/building_addons.json b/src/main/resources/db/0.0.1/schema/building_addons.json new file mode 100644 index 0000000..d380367 --- /dev/null +++ b/src/main/resources/db/0.0.1/schema/building_addons.json @@ -0,0 +1,104 @@ +{ + "databaseChangeLog": [ + { + "changeSet": { + "id": "Add buildings table", + "author": "anibus", + "changes": [ + { + "createTable": { + "tableName": "building_addons", + "columns": [ + { + "column": { + "name": "id", + "type": "int", + "autoIncrement": true, + "constraints": { + "primaryKey": true, + "nullable": false + } + } + }, + { + "column": { + "name": "filename", + "type": "varchar(255)", + "constraints": { + "nullable": false + } + } + },{ + "column": { + "name": "name", + "type": "varchar(255)" + } + },{ + "column": { + "name": "description", + "type": "varchar(5000)" + } + }, + { + "column": { + "name": "addon_cost_requisition", + "type": "number" + } + },{ + "column": { + "name": "addon_cost_power", + "type": "number" + } + },{ + "column": { + "name": "addon_cost_population", + "type": "number" + } + },{ + "column": { + "name": "addon_cost_faith", + "type": "number" + } + },{ + "column": { + "name": "addon_cost_souls", + "type": "number" + } + },{ + "column": { + "name": "addon_cost_time", + "type": "int" + } + },{ + "column": { + "name": "icon", + "type": "varchar(128)" + } + }, + { + "column": { + "name": "building_id", + "type": "int", + "constraints": { + "nullable": false + } + } + } + ] + } + }, + { + "addForeignKeyConstraint": + { + "baseColumnNames": "building_id", + "baseTableName": "building_addons", + "constraintName": "fk_building_addons_buildings", + "referencedColumnNames": "id", + "referencedTableName": "buildings" + } + } + ] + } + } + ] +} diff --git a/src/main/resources/db/0.0.1/schema/buildings.json b/src/main/resources/db/0.0.1/schema/buildings.json new file mode 100644 index 0000000..8748a3f --- /dev/null +++ b/src/main/resources/db/0.0.1/schema/buildings.json @@ -0,0 +1,170 @@ +{ + "databaseChangeLog": [ + { + "changeSet": { + "id": "Add buildings table", + "author": "anibus", + "changes": [ + { + "createTable": { + "tableName": "buildings", + "columns": [ + { + "column": { + "name": "id", + "type": "int", + "autoIncrement": true, + "constraints": { + "primaryKey": true, + "nullable": false + } + } + }, + { + "column": { + "name": "filename", + "type": "varchar(255)", + "constraints": { + "nullable": false + } + } + },{ + "column": { + "name": "name", + "type": "varchar(4096)" + } + },{ + "column": { + "name": "description", + "type": "varchar(5000)" + } + }, + { + "column": { + "name": "build_cost_requisition", + "type": "number" + } + },{ + "column": { + "name": "build_cost_power", + "type": "number" + } + },{ + "column": { + "name": "build_cost_population", + "type": "number" + } + },{ + "column": { + "name": "build_cost_faith", + "type": "number" + } + },{ + "column": { + "name": "build_cost_souls", + "type": "number" + } + },{ + "column": { + "name": "build_cost_time", + "type": "int" + } + },{ + "column": { + "name": "health", + "type": "int" + } + },{ + "column": { + "name": "health_regeneration", + "type": "number" + } + },{ + "column": { + "name": "race_id", + "type": "varchar(50)", + "constraints": { + "nullable":false + } + } + },{ + "column": { + "name": "armour_type_id", + "type": "varchar(50)", + "constraints": { + "nullable":false + } + } + },{ + "column": { + "name": "armour_type_2_id", + "type": "varchar(50)" + } + },{ + "column": { + "name": "sight_radius", + "type": "int" + } + },{ + "column": { + "name": "detect_radius", + "type": "int" + } + },{ + "column": { + "name": "repair_max", + "type": "int" + } + },{ + "column": { + "name": "mod_id", + "type": "int", + "constraints": { + "nullable": false + } + } + }, + { + "column": { + "name": "icon", + "type": "varchar(128)" + } + } + ] + } + }, + { + "addForeignKeyConstraint": + { + "baseColumnNames": "race_id", + "baseTableName": "buildings", + "constraintName": "fk_buildings_races", + "referencedColumnNames": "id", + "referencedTableName": "races" + } + }, + { + "addForeignKeyConstraint": + { + "baseColumnNames": "armour_type_id", + "baseTableName": "buildings", + "constraintName": "fk_buildings_armor_types", + "referencedColumnNames": "id", + "referencedTableName": "armor_types" + } + }, + { + "addForeignKeyConstraint": + { + "baseColumnNames": "mod_id", + "baseTableName": "buildings", + "constraintName": "fk_buildings_mods", + "referencedColumnNames": "id", + "referencedTableName": "mods" + } + } + ] + } + } + ] +} diff --git a/src/main/resources/db/0.0.1/schema/buildings_weapons.json b/src/main/resources/db/0.0.1/schema/buildings_weapons.json new file mode 100644 index 0000000..b34ef9d --- /dev/null +++ b/src/main/resources/db/0.0.1/schema/buildings_weapons.json @@ -0,0 +1,82 @@ +{ + "databaseChangeLog": [ + { + "changeSet": { + "id": "Add buildings_weapons table", + "author": "anibus", + "changes": [ + { + "createTable": { + "tableName": "buildings_weapons", + "columns": [ + { + "column": { + "name": "building_id", + "type": "int", + "constraints": { + "nullable": false + } + } + }, + { + "column": { + "name": "weapon_id", + "type": "int", + "constraints": { + "nullable": false + } + } + }, + { + "column": { + "name": "hardpoint", + "type": "int", + "constraints": { + "nullable": false + } + } + }, + { + "column": { + "name": "hardpoint_order", + "type": "int", + "constraints": { + "nullable": false + } + } + } + ] + } + }, + { + "addUniqueConstraint": { + "columnNames": "building_id, weapon_id, hardpoint, hardpoint_order", + "constraintName": "uc_building_weapon_hardpoint_hardpoint_order", + "tableName": "buildings_weapons" + } + }, + { + "addForeignKeyConstraint": + { + "baseColumnNames": "building_id", + "baseTableName": "buildings_weapons", + "constraintName": "fk_buildings_buildings_weapons", + "referencedColumnNames": "id", + "referencedTableName": "buildings" + } + }, + { + "addForeignKeyConstraint": + { + "baseColumnNames": "weapon_id", + "baseTableName": "buildings_weapons", + "constraintName": "fk_weapons_buildings_weapons", + "referencedColumnNames": "id", + "referencedTableName": "weapons" + } + } + ] + } + } + ] +} diff --git a/src/main/resources/db/0.0.1/schema/sergants_weapons.json b/src/main/resources/db/0.0.1/schema/sergants_weapons.json index e853607..6111a91 100644 --- a/src/main/resources/db/0.0.1/schema/sergants_weapons.json +++ b/src/main/resources/db/0.0.1/schema/sergants_weapons.json @@ -50,8 +50,8 @@ }, { "addUniqueConstraint": { - "columnNames": "sergeant_id, weapon_id", - "constraintName": "uc_sergeant_weapon", + "columnNames": "sergeant_id, weapon_id, hardpoint, hardpoint_order", + "constraintName": "uc_sergeant_weapon_hardpoint_hardpoint_order", "tableName": "sergeants_weapons" } }, diff --git a/src/main/resources/db/0.0.1/schema/units.json b/src/main/resources/db/0.0.1/schema/units.json index 5bc032c..de6eccb 100644 --- a/src/main/resources/db/0.0.1/schema/units.json +++ b/src/main/resources/db/0.0.1/schema/units.json @@ -31,7 +31,7 @@ },{ "column": { "name": "name", - "type": "varchar(255)" + "type": "varchar(4096)" } },{ "column": { @@ -215,12 +215,6 @@ "name": "icon", "type": "varchar(128)" } - }, - { - "column": { - "name": "hotkey_name", - "type": "varchar(64)" - } } ] } diff --git a/src/main/resources/db/0.0.1/schema/units_weapons.json b/src/main/resources/db/0.0.1/schema/units_weapons.json index 7d952ce..35d27d5 100644 --- a/src/main/resources/db/0.0.1/schema/units_weapons.json +++ b/src/main/resources/db/0.0.1/schema/units_weapons.json @@ -50,8 +50,8 @@ }, { "addUniqueConstraint": { - "columnNames": "unit_id, weapon_id", - "constraintName": "uc_unit_weapon", + "columnNames": "unit_id, weapon_id, hardpoint, hardpoint_order", + "constraintName": "uc_unit_weapon_hardpoint_hardpoint_order", "tableName": "units_weapons" } }, diff --git a/src/main/resources/db/0.0.1/schema/weapons.json b/src/main/resources/db/0.0.1/schema/weapons.json index 31745bf..7bb1d8d 100644 --- a/src/main/resources/db/0.0.1/schema/weapons.json +++ b/src/main/resources/db/0.0.1/schema/weapons.json @@ -31,7 +31,7 @@ },{ "column": { "name": "name", - "type": "varchar(255)" + "type": "varchar(4096)" } },{ "column": { @@ -94,6 +94,11 @@ "nullable": false } } + },{ + "column": { + "name": "min_range", + "type": "number" + } },{ "column": { "name": "max_range", @@ -131,6 +136,30 @@ "nullable": false } } + },{ + "column": { + "name": "damage_radius", + "type": "number", + "constraints": { + "nullable": false + } + } + },{ + "column": { + "name": "throw_force_min", + "type": "number", + "constraints": { + "nullable": false + } + } + },{ + "column": { + "name": "throw_force_max", + "type": "number", + "constraints": { + "nullable": false + } + } }, { "column": { diff --git a/src/main/resources/db/changelog-master.json b/src/main/resources/db/changelog-master.json index e20e599..b6d3c23 100644 --- a/src/main/resources/db/changelog-master.json +++ b/src/main/resources/db/changelog-master.json @@ -21,10 +21,30 @@ "include": { "file": "db/0.0.1/schema/sergants.json" } + },{ + "include": { + "file": "db/0.0.1/schema/buildings.json" + } },{ "include": { "file": "db/0.0.1/schema/weapons.json" } + },{ + "include": { + "file": "db/0.0.1/schema/buildings_weapons.json" + } + },{ + "include": { + "file": "db/0.0.1/schema/building_addons.json" + } + },{ + "include": { + "file": "db/0.0.1/schema/addon_modifiers.json" + } + },{ + "include": { + "file": "db/0.0.1/schema/addon_requires.json" + } },{ "include": { "file": "db/0.0.1/schema/units_weapons.json" diff --git a/src/test/kotlin/com/example/dowstats/service/w40k/BuildingRgdExtractServiceTest.kt b/src/test/kotlin/com/example/dowstats/service/w40k/BuildingRgdExtractServiceTest.kt new file mode 100644 index 0000000..bf94000 --- /dev/null +++ b/src/test/kotlin/com/example/dowstats/service/w40k/BuildingRgdExtractServiceTest.kt @@ -0,0 +1,50 @@ +import com.dowstats.configuration.StorageConfig +import com.dowstats.data.entities.ArmorType +import com.dowstats.data.entities.Mod +import com.dowstats.data.entities.Race +import com.dowstats.service.w40k.* +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import java.io.DataInputStream +import java.io.File +import kotlin.test.assertEquals + +@ExtendWith(MockKExtension::class) +class BuildingRgdExtractServiceTest { + + val modAttribPathService = ModAttribPathService(StorageConfig()) + val iconService = mockk() + val addonRgdExtractService = mockk() + + val buildingRgdExtractService = BuildingRgdExtractService(modAttribPathService, iconService, addonRgdExtractService) + + val rgdService = RgdParserService() + + @Test + fun `Should correct parse building`() { + + val buildingRgdFile = File("src/test/resources/rgd/waagh_banner/ork_waagh_banner.rgd") + + val rgdData = rgdService.parseRgdFileStream(DataInputStream(buildingRgdFile.inputStream())) + + val res = buildingRgdExtractService.extractToBuildingEntity("Waagh banner", emptyMap(), rgdData, emptySet(), "orks", "/modFolder", Mod(), listOf( + Race().also { + it.id = "orks" + it.name = "Орки" + } + ), listOf( + ArmorType().also { + it.id = "building_low" + it.name = "Лёгкие здания" + } + ), + emptyMap()) + + assertEquals(170.toDouble(), res.building.buildCostRequisition) + assertEquals(emptySet(), res.buildingWeapons) + + } + +} \ No newline at end of file diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties new file mode 100644 index 0000000..48fd808 --- /dev/null +++ b/src/test/resources/application.properties @@ -0,0 +1,27 @@ +spring.datasource.url=jdbc:postgresql://localhost:5433/wiki-db +spring.datasource.username=wiki-application +spring.datasource.password=hQXmQLJkKXdbIgBx +spring.datasource.driver-class-name=org.postgresql.Driver +spring.liquibase.change-log=classpath:db/changelog-master.json + +spring.servlet.multipart.enabled=true +spring.servlet.multipart.max-file-size=500MB +spring.servlet.multipart.max-request-size=500MB + +spring.jpa.database=postgresql +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation= true +spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.PostgreSQLDialect + + +hibernate.jdbc.batch_size = 1000 + +server.port=8082 + +steam.api.key=1DDFA1A35D907D5F5C20418A8D1885C1 + +storage.key=3F82D04CC274897B2552E8798F82F7A1 +storage.take-last=2 +storage.mod-storage=/home/cnb/mods +storage.wanila-storage=/home/cnb/wanila +storage.icons-storage=/home/cnb/icons diff --git a/src/test/resources/rgd/waagh_banner/ork_waagh_banner.rgd b/src/test/resources/rgd/waagh_banner/ork_waagh_banner.rgd new file mode 100644 index 0000000000000000000000000000000000000000..52e47d3b01a4f8edd1b2deb44bba0fc616682d36 GIT binary patch literal 235614 zcmeHQ3wRVowyr?rAqqi*Bmv1FQ4x_u-XKbNEE^CI5qU<3PG(5POlF3e39l86yP&wD z1YrT+DByyEa2Hr2>Jk+J0WaM3h2nL?iUKaX;+56x%IZCT_4H%{2@E7e(ZBQ6%s-Xt z?&{O0{yJ5C>hy4@+m&tWms{Y;n|k5-Nh(Guo%;6~(WlQZe%T+tJ1(j#pGtMP_v_2r z362xo-}Y`pht5@M=>_*T0=`oUca6=A170ckin%R10vF2eJvKen3Ap`8rT(#Cbrx`u z;Ld5^-Eaa)WUYk%@P;L)vPR2}*L=Fg2{@f+Icxk>PYNBl#PKS9#3+sEUQ)kBgF z-&pnl@El3)&)=;jxk2!Z+pZ`@ezBwt8z=4qeo@lU{R!U#Z99~Z#)9cT(Pd1F4 zTzi(`@i_hVoC41@{XcuB4({->@^XS1MG-I?Wc zP6;|a0T-^oJf&oq{}&O5Whd~s!uei@Yl2HIGh80`qtPFKW5YeeX2~7L8PM)8gOT4- z(x-LLn+bfWq%VWR?*(opY5kvWy&w14CaLkeZ+{5vmNb0ELmvSzmh|K`T|PniosvFm zT67foyCto@JMG`V4RErGR~4M!#?&dRZcB5FT-&^3%4EpR@ z1unP4<;k(Ty_2<2QdAF>rmmILQA(R_F!EUD$vl%>ey=Cr=?UT>&hnr77^vb2`viPW zXLfF;`DOQbJ0Aw%D?ae>DJ3lY`&$?4;Jf^F?^mn`K@NY^yJAY5wnf0S{{^T=Q-7 z-oX1sIsTnC^B2JJjg@M)Vf_H${z6ivf1mkF;NV5&^<5e-<(f_!g#2xSZ=Tp|C~(;& zO5M@udX3vAirzR5-GcnlEtK+o@zogMMWR@fKbw`K_f1yns<-<4^uDc?%G>?^Rj290i-0q( zRqB)31D^n%(n+b$*M7AYczGA49(Z}Uy_8xKSF#=X zAKjqT=KO~L1AIXrrRqI;&HKQ;`YGj@G<6TK`xnAltM~l_`0-yVm3w64*T8C!Qud9v zl>yHltkkle-87fJEaWG*Lw)+(oCIA?@Vy(8`+&F5t}NZi7u;u>y#Qs3Bpsf&^|!#s zBrQC&elGMVSCp`*Wxc17?-qQXd|UeqR0mU1bMbAHbko1u6yY2xk{W$5;?Ka)qx|Ks zeJ@{{pF<>9P9l?}u}Dh^`lp8V9%c1bdEs(ultQ&xpV~YZuD>?V7mdEmk~&%XvRtsG zFY%%mmcA55=*x^+))yno<#q+9+5@>>f6$)o@@E&gM7N+}R_A)0-j%CTZOXbf&sAML z>MsYVycr(XX8Rjr2K~bAF#MUfK>n8aUHLvEJBVsT#YxkpoTX!n1zS3HL4=M?iO@0F zw6!VE32q}_Y@ghy6-%z6HtScL=aNf1?Q0t%`&j(}v?3bay454n9;o%mY|VkpA%@@O zaoPu%cOzrq2SS@(O2HO)B}8x+`b5i~npMl(B!@+JZ!AqXT|M}zaTxW5`eL;O@=MLnX^>yaZ6wY)g(lKZG6(ik)|ZOY^?IJh zo^N=JoQQ`Dk$>JcGW#d?Pc_{?agL93d>k)vyaXLM-RG|CkJulvKhhJnS`3!c zVr`CDYV%y+h}t|K`Gx#Kej&ercWjbs+NYfSQu8vc&He#;QJd$ZzEEG-AF)3I@7N^$ z5dG1;a$f6s(vNxFO|Zb9<+W#dy@6;~-PA6Z*Ji&Vx2#(I)G*mUD&8r;;t1=B-h?Ts zH_0-b5&A~-)i>gnpx2+D<()bWHHvQC!?~hwSTpOe%UWcQBihH9)w(CAJ6Cot>08w6 zypGqmh?`()SadqZXAn`g?~qW>C0#n*c0wDw=IsjVOS#QN9}4(3_vdF}U2|>hz0IkHK}Ei$0-fHv>jzjxuk; z2)PNTqhr63!q&XvQo)v=dYr^1u;Te3zKRvE0I_|lbA6{fF3ip~yaD^^M=GezI@ad7 zaBoldd}k7;Ku__QaeG4XR#H#A6%uYGigKtjW37k`YWlgs>FUAdQiq>XtQFc*w5Px= z*kUD3Dt$6T|Bw$WJ|Xhg)mdjCAAL{rxf66Cvi5@C+Bx|8itCSEo(V2@&~F4KBx-rs zP$}A5l4>An=ZwozfU!Nldg_(<#yM_E`t@~)m54Idxg`Xq`n<3i=vU!<&*c1RJj$d= zT6Nc)Sq}N`ENR;-*M5!s7bIC*OvQ=?g8qVRdA1f2%oHUHb-fT{@sRWVmN*8sqa%mw zn3|}URTl|EsW<7q!+FSvCT3}9jAsOcMs{w{YuCTE(_@UjcIzBC-jI5SmZKi6CAHl2 z{Yv1=B*l-4TL;`m(s7sO%17)+1nmn)lZ}a~C*}SzyR?aX>FvCZ$p{~-&)4piDABHR>6sP)X?1#(cOG#*A*%qhEhfWpG9XfB8yTIv_U}EyfG8F%IxP(^? z;5k|#^%hI?fz-$dzj8p?(s4aw_pisL>22HPdARLnJ+I1w%4wZ=x9!*tD1>?j#uRtJ zy^oVLuk+@v@}=W#{=VM}`hF_8N!w-}$UuIqU`xJ;ZyPKQWFfz;;2nRvG#j|R;6WF( z`!$ZACb<3$H_bqPXTi(vyZS!Jq`Tmj+i#kM{9b~03>Y&D`RRi9{_QBjRw;+zA%E=o zJLDtg=cpz*3z3hQpKT^y`xNTz6Pz*JvkYYp3;x>|Pd$e+M+AGeKC%w@sNg#udF6GK zFBAOg%Hqw)-!Bz>eeQ4G06r-A&JT`lL!A%H#}PyAK>iWIPvyM13;9Roqb&W)zv5i~ z6#V5sx9Rx5Xe`gqO#2(kmkIvQ%}b5|9}~Q%ZS!L&ujIo%YbwH+s#u+|I6b5X5U;kO z{_=Xm>-E3ncS@h$8Yc_3w8t^7p5NS-{yz5^BhhCrQrz@NDYTa*12Y>l5}eqwBOPVO`2K1Et$qj z#<58cXCmJw>7q}!{0=xpQp*q1J^@CfnL6su&w#<3*g_KXI|CA(Iolb*LFWIf`h<=2 z^SRFzr2swGTj|37CP+_*{#y3}dH*@wSj!yb`thOzpCivEsUlAXTsgVrnWD317izsLml!BJ#9SV(s`%{xOvONe_x~X1@An#L)YnJ!GCyQ-2oiuFM_Q&+@*pM zZM(Dlb(5IlvV~N3}HF2<@?vzN;n1EXG>zIv-UFz(-&yYIdd7;WZ( z>q>e8OUUVP-O~lH{p0@*LB53bSBux&J_1-b#)ggl7dT5&dA(qrl>b7fo1(f%eY>dc zV#UCYDgQ5GqNnJ*FuP3&MzmA&f7WLwIYwuRquxXILhyW7wgjO^2OAMXA9?a`eP1fO zesGL+{peS%>z7dC;pX2GE$-IPZwy_(Y(InKcl%i>=P*)^EY_- z79Hw6HNw8=8wxWYNF5b%@#;7OP_!a~rSI;iJ255S$G>?%!qSUs$tM4k4foW#e*A9x z#IP&}z9r52O}dVOzF#`JCq8~%+oN%J9pCoD;gSuX&mCc-N!yWQq<`36;ds~_J(tx5 z9xWAk^v!)5quuVBQ`i{!4Pqtcd&5^W9xudv#Cd&lw1$t3G-Kxa2B{x8AW?32?&IBEy8>tH3@Xg92mn8^GJHk@hk+uoc)QaqO4%y5l|I zDWaYUbxJkfEmX93c{d$@zO5M2SNzw8y(m*C4d$)2c^?7q5hCzr|LP!cS~s!l7A74A zo+X2f7w2vM3b<4ncH7uZ`W+43uQS_l{}y#&Ug_Z;gp(H@|kMLUZ; zKpr3ukO#;E!W9v}~p z2gn2D0rCKOfIL7RICpq}Yu6E95*xHO9LG^lEmM!r9X;lJ3}cEiRx4xRR*we|H{FU2 zjM%|eY~dooR&3$_5p2a4en+qsSGbbc+d3?_bv%@;(8runtYHL3*RLSKJ88@}tqVy< z#sEHP%-|~eHN)X>;Vn02^c#t%Ep@H3-i$k3@jg}|Q^s>&2 zqOzH)un}l?8LC{)e2+%8^_TA_3G)Kq`@;9>{E+X{iJVJtNyE1066OoG`muKfTm9G` zH2%;#iq6tpf*tb@$8~Pm-aydqcTOyD1@sgE`!%@>RnC2!%6=gKhV_~JOaA>K`IqmB z3(X7H(tPb~;Z3VwdrPp@uk99$e(n7C+w=lDtK+jAzjv~{lvv)`?&LU)?=#^049wYy zleW29;)f~H_AP#>;{BYPs?86T+FwqOei^&GN7>~L3N@5J=sKxfs+hAekM`q{E1&sR z@0l;{gxa+esIS!5bIDh!FrwdjRj_5(ZboNi&ex;^owP4G)x1Ha&m957yo_BYFajG_7Mf;;a0Q~{z4^Vx70NRnXBhMu}lKM)0rM_~F z26=!yKpr3ukO#;EK$VXtSH~&563E**(uC_N{4xA||>Bd$s0N*C5$9)gI27J4uE8kze z4S2ewl}om~34E6%Wn{VB5{l6o$n^U2>{$gax5MSh3D|Re_Ks%QnUEu@S#YXd|J47f zdRKGD6Ao^8JWjtpau>5i#Ul*6>6i8_tBfw-%1f8CM1knj(S~ZX?{a?t}k`FHB zqC(GaA#23KthlERJ8A8vaP5OG(c%_VevDYb8}iRPfMeJs$!Fit#RpyaPP;xz`Q@v6 zQlfN2lV-Z8d{}kCaW49F%kPkny1ez+6~70zNox6F+9$v%lBSNj^D|(19lhQ{=Q{p0 zq5tBmT|a!{oo_o{St4(@H@8F1tK@C3e_?@Qm=-@|TQOllY zTrdH>ksCiy`^BW)$d4BsQ}N{4 z-ux^hXfHosQz>`<7u}Lj9$V)o?OFrRXrt7G`;Y3jxK5-p`-7fck-uMprQWi5LN|T< z2j%`ODaruuzoxuy(=`V$`PQ!=?kb?H!}q zG=AwW;L%wzYV4x1Gk|B!j8QA=OkWLr!HO8=96R|Bz(qS_)PXCv904x*B1W|?YF-9> zN2ErT3yM5ye!#WB)I;HLBo)rD(Tw?HjV+ty=%8}ln2KeEh(no zzM07X?lPs;`qu9TZrnyb`ndA#8D76@n%5IF-1d$tq=~tP-{JGRJVCp|88lq(fNpku zfS$mv)X#1dfvn zTehlUE8qmF(Eg`aBm>)GeZaSCBUVLO3fd5 z<1XNXNlIne(%uF>(n4gl`MY<3%YGf-m0yjuj>XPZZv@XTjltSCH zu3w-8g9ubYrum?g(aW}PQiewEGmwh)=V6_A1DWr0QEsfouVAf=v$Nnf0^t+IZzH9hnBxB;as%0ZRyZq&Rd%Ij&h(JBFBu>2kHa$;lw!v zwhOikwhOik@xiL}nKk+0Z1Tgcz8t^daE6>&#EV6oVMaRrL07hRg%Nu`8Xp|^=U6^C z$$~8(9GhUv2PZ|a<%6?5!Ut!E;IP+1Wk98CLr{+P zB&0xp8S)17Agl>&FNnp=c{__4s1MW!@`Ff#c15I*Gy4nb1NGrVpLezkwhOikwu^5I zVKh+=l*7r_7PG%#f5HCZOn8$1GW3_BzYL#$a{eBcChZ8?5ws)D#E#%|V3R*62g*St zz;!!Zx5ITiRX+cxzYOhdv!9?GCypd1+YmT_-6kIng8lL;vY$^jDKJT~XCIgibGY|dlX%sh59{xZ#kck8bhGz@<4 z9VLDHz{WAaLnO6ZIm!cni(E-D{r1g7{O9fGrWG+G_NOUxb1_T z6M|OY-CV=(@OfRHpxxmN8ZLK0&zp`iCsKzSo*burgx|={b9r*i{VM!xfXg5Lhf=NO zR6~mEUWoc&+n)G#JaB^;rAj-;CIZLRQEJ($hOK}Tq=NgOUXcuJi&g6Whh}yFZYz_O zt#`~$1x~A{)RJ*Mx&U{tuhfc?)!l*9MQ{!GCiMi)Xjr+=&C*8Y^UKd&pwzK<>kdWv z&4TZ#H*Xm5&I^@#>HFl7!0$9xYC+f3F~A=+k$sP*JAn7cDOL1+k{kGNQ>DhuO?(je z=tWZJeHSkT?iH`p;Kgrh++S|kvK8x|M1J8VvQLk^Rlu{FE7f)1q4mIfgha~%FKq%Y zO;qaDoW3Q%X)TnRKk&v~z}=;@&9bGv4Ls{osmJE;-T^LZrPOO9Jf8tCN>*y=^=pp; zFSaRl$@E=iz{}-kaL!i*AsgjDIfw)}_szL)&V6(4+cJIl&H=u2KzEFs+cNEgvnhud z83TllAM(^0onYp~wSplzyE zucBt?U-aLBWN5R}W~I$ao7L(&X|tY1oAnzZ+=@NXId7w6xpM4*T(381RB4{*ju!Sz zd0CMI=giRkR!L3T14=pDKrmGbDBj2Cd7Cp%_! z<#4S0_&>LCQ&mGIaubwFw(4*+_3DJ7c(vq7zAD{|=grhB>XkR(;+U0V*0X9Ck#9}D z{prYIRHV<*t-c(;;c%KRM{r*n>GTI(*;P6(X%jp&Wvrs{sS-D&uuqk^(1d-eY=SMH zsuaPNPu2Fz%6+PK2o78Ql{-+Oz>m%!YIKA@)a?=eP*WoOp$a4Xp=L$+Lls5%LoJH% zhguxr54AkPA8O6T<^E8`;u~f8LzRecl;sb#O?;y)f2iH!8)f-J!8gkChk|dEw6ADaL}gcSekLR)&ew$mL3`2^1m!?E zhy-X)(w?L}Nqds^WXL`^oBqge$T=#`bX9+i#JT8~{Zo_sRC z%E5pLIR4`Ji{mejzc~I1bqIXkWeS3Fpd26ptY5`OeV`m50qO(a*~NEueOrjOKsitj zv7!fMGtOKN^p~N(3}S;Yz6|5MIXMr(aRv+v**M;!93TOXw>aM7IOELiYx)hDJW4rG z4kCfxrW8WfMd&R6`d28T2K9t`Qm!ZT6EU@ca-baOFXQskuaJI)S#Hi#aejvL5}cRd zyaeYZs{8zt<4uk?Io_mQO}mJ&pqicLw_0i%Vct${8@Z=`y1svRAcW+W?VAM zm*L!dZ#MOT?q$XKnLaS6AT*J zdG>)G`*6dPx9Gqw@|itAp8^JCkd_;x&SgBYbsJI5vh$JJ44 z*{X)EfD@!b`=4Hs3~Y;4>i&mjb^vZG%Fuep>{Q^idP*%B*P{z?_xeh$C|TVdI9&wQ zaBosi;Eaa!v4I@;4ky0DiLr4x*Uh<2&TVpTlXIJ~ViQ#7-JrC|MS!wV4wQpPfMeBy z$e0TBv7s-w$qSSN3TW3EsR zX6zZtfpQRD)N^aF8>tV}hqJ2>xB7DYhQk?hW)UwIafTV`^aowp+7(7l{zLfSbUGs+ zoK6*ea5~WkXYk@T7s3~&I9~jWR;+sxxTKjp9#QJ<}(3Ep{C?O9~}CgnEFIHP!5m) z#~mDZaNI$AoAx&CZS)7Uw;=_}!IT2!KsoRkJ9Gg3BYh1h2T=q1oK0W(<$`}QjtmT8T8ca!04wQpPK#y%83F-s&f%@Rl0WB#9 z%E23`%uk*EG$$L&fI-=hKnj$DDFw=ba)^uxMtz_@P#;`g+EYG%;)FFQe44ck2#yNIRQyfl~L>n>P%&@Is|t z`aXFi@T|s4E$Es$2Dqq+_)8y6cK|Pnli%Mbxq%lqRchSa#0PT&IkVCs>BpSo^Dfi&p&TfONE?v)Kz*P-oH!<6 zyI{LuyI{MhYOG9t_~H1W;^;vC3XKgIS49R#vQZ9{gGhjJ>mp(>vt3XhB4fx>AE*!1 zhsxsj(S9(k2g-qRV4NMs*6S^zI-f z(?4vl-~)lp(Q{c{;L$NkJ^JQ8jn_z!nO$=V8zaAgI6U-k_=?8kWn%A$^ZMq_CX&R_rJu>t2cR4UIts2gyJ_@wk1zN`mug7~4t?8>_V*e9`C;zs4C18cN%?(}0&> zs?>^?d*1`Bl9k#qyUo48qissL14Dlcyr#9pn90m20&dVosp&@t&IKNyqSOOR?^*y{ ze7VG@x##1@f#a@}Hv7qvrNFsumHPUsk6r;Txk{-<9h;Q^CtNKuObEUT>}#)7fid|F z;BD6^wP|c%E3hq9sb#(Hcn^3=n(R}jRO8*(Dz$fcxA&3XR^GgI#eZ$s3tZS)skheV zeFVIxi&Ea~UmXNa>!#HHg-M5jXLVQV#d({*0xs<#`^RoN3f%oVrQ}ws{w?Z?6wK|B zZ}vcaRIH@FO>XUqSVZu_I(%dOUhthk`De$r()UKyCq51lgo}(aO