diff --git a/README.md b/README.md index 42113a5..04ea3b2 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Are you sick of players turning your awesome server into a NSFW gallery ? Do you wish to bring back your logic displays without the fear of seing anime girls in questionable situations ? Well, worry no more, xpdustry cooked another banger plugin for just this situation. -Introducing **nohorny 2**, the successor of [BMI](https://github.com/L0615T1C5-216AC-9437/BannedMindustryImage). +Introducing **nohorny**, the successor of [BMI](https://github.com/L0615T1C5-216AC-9437/BannedMindustryImage). This mindustry plugin automatically tracks logic displays and canvases and process them when needed with the anti-nsfw API of your choice. @@ -25,7 +25,7 @@ This plugin requires at least : - Mindustry v146 -- [KotlinRuntime](https://github.com/xpdustry/kotlin-runtime) v3.2.0-k.1.9.23 +- [KotlinRuntime](https://github.com/xpdustry/kotlin-runtime) latest ## Usage @@ -35,13 +35,6 @@ Then, go to the created directory `config/mods/nohorny` and create a file named Now you can set up the analyzer of your choice: -- **[ModerateContent](https://moderatecontent.com/)**: Incredibly generous free tier with 10000 free requests per month. - - ```yaml - analyzer: - moderate-content-token: xxx - ``` - - **[SightEngine](https://sightengine.com/)**: Very nice service with 2000 free operations per month. Also supports gore detection. ```yaml @@ -64,9 +57,6 @@ Now you can set up the analyzer of your choice: analyzer: Debug ``` - > There is also the in-game command `nohorny-tracker-debug` that allows you to check - if displays and canvases are properly tracked. - Once you chose your analyzer, load your changes using the command `nohorny-reload` in the console, and enjoy, the plugin will automatically ban players that have built structures at `UNSAFE` Rating. @@ -99,19 +89,23 @@ In `config.yaml`: # Whether nohorny should automatically ban players when nsfw is detected, # set to false if you want to handle that yourself auto-ban: true -# The minimum number of draw instructions in a logic processor to be part of a cluster -minimum-instruction-count: 100 # The delay between the last logic or canvas block built and the analysis step, # lower it on servers with fast build time such as sandbox processing-delay: 5s -# The minimum number of canvases in a cluster to be eligible for processing, -# relatively high since you a lot of canvases are needed for a clear picture -minimum-canvas-cluster-size: 9 -# The minimum number of logic processors in a cluster to be eligible for processing -minimum-processor-count: 5 -# The search radius of linked logic processors around a cluster of logic displays, -# tweak depending on the average size of your server maps -processor-search-radius: 10 +# Display tracker configuration +displays: + # The minimum number of draw instructions in a logic processor to be part of a cluster + minimum-instruction-count: 100 + # The minimum number of logic processors in a cluster to be eligible for processing + minimum-processor-count: 5 + # The search radius of linked logic processors around a cluster of logic displays, + # tweak depending on the average size of your server maps + processor-search-radius: 10 +# Canvas tracker configuration +canvases: + # The minimum number of canvases in a cluster to be eligible for processing, + # relatively high since you a lot of canvases are needed for a clear picture + minimum-canvas-cluster-size: 9 ``` ## Building diff --git a/build.gradle.kts b/build.gradle.kts index d1ad155..54e6026 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,10 +31,6 @@ toxopid { repositories { mavenCentral() anukeXpdustry() - maven("https://maven.xpdustry.com/releases") { - name = "xpdustry-releases" - mavenContent { releasesOnly() } - } } dependencies { diff --git a/settings.gradle.kts b/settings.gradle.kts index 2c857c7..6852884 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,10 +5,6 @@ pluginManagement { name = "xpdustry-releases" mavenContent { releasesOnly() } } - maven("https://maven.xpdustry.com/snapshots") { - name = "xpdustry-snapshots" - mavenContent { snapshotsOnly() } - } } } diff --git a/src/main/kotlin/com/xpdustry/nohorny/NoHornyAutoBan.kt b/src/main/kotlin/com/xpdustry/nohorny/NoHornyAutoBan.kt index e801dcf..20b2528 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/NoHornyAutoBan.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/NoHornyAutoBan.kt @@ -25,10 +25,12 @@ */ package com.xpdustry.nohorny -import com.xpdustry.nohorny.analyzer.ImageAnalyzerEvent -import com.xpdustry.nohorny.analyzer.ImageInformation import com.xpdustry.nohorny.extension.onEvent import com.xpdustry.nohorny.geometry.ImmutablePoint +import com.xpdustry.nohorny.image.NoHornyImage +import com.xpdustry.nohorny.image.NoHornyInformation +import com.xpdustry.nohorny.image.analyzer.ImageAnalyzerEvent +import kotlinx.coroutines.Dispatchers import mindustry.Vars import mindustry.content.Blocks import mindustry.gen.Call @@ -37,10 +39,10 @@ import mindustry.world.blocks.logic.CanvasBlock import mindustry.world.blocks.logic.LogicBlock import mindustry.world.blocks.logic.LogicDisplay -internal class NoHornyAutoBan(private val plugin: NoHornyPlugin) : NoHornyListener { +internal class NoHornyAutoBan(private val plugin: NoHornyPlugin) : NoHornyListener("Auto Ban", Dispatchers.Default) { override fun onInit() { onEvent { (result, cluster, _, author) -> - if (result.rating == ImageInformation.Rating.UNSAFE && + if (result.rating == NoHornyInformation.Rating.UNSAFE && plugin.config.autoBan && author != null ) { diff --git a/src/main/kotlin/com/xpdustry/nohorny/NoHornyConfig.kt b/src/main/kotlin/com/xpdustry/nohorny/NoHornyConfig.kt index 8691b84..e2f7a55 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/NoHornyConfig.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/NoHornyConfig.kt @@ -25,43 +25,20 @@ */ package com.xpdustry.nohorny -import com.sksamuel.hoplite.Secret -import com.xpdustry.nohorny.analyzer.ImageInformation +import com.xpdustry.nohorny.image.analyzer.AnalyzerConfig import com.xpdustry.nohorny.tracker.CanvasesConfig import com.xpdustry.nohorny.tracker.DisplaysConfig import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds internal data class NoHornyConfig( - val analyzer: Analyzer = Analyzer.None, + val analyzer: AnalyzerConfig = AnalyzerConfig.None, val autoBan: Boolean = true, val processingDelay: Duration = 5.seconds, val displays: DisplaysConfig = DisplaysConfig(), val canvases: CanvasesConfig = CanvasesConfig(), ) { init { - require(processingDelay > Duration.ZERO) { "processingDelay must be above 0" } - } - - sealed interface Analyzer { - data object None : Analyzer - - data object Debug : Analyzer - - data class SightEngine( - val sightEngineUser: String, - val sightEngineSecret: Secret, - val unsafeThreshold: Float = 0.55F, - val warningThreshold: Float = 0.4F, - val kinds: List = listOf(ImageInformation.Kind.NUDITY), - ) : Analyzer { - init { - require(unsafeThreshold >= 0) { "unsafeThreshold cannot be lower than 0" } - require(warningThreshold >= 0) { "warningThreshold cannot be lower than 0" } - require(kinds.isNotEmpty()) { "models cannot be empty" } - } - } - - data class Fallback(val primary: Analyzer, val secondary: Analyzer) : Analyzer + require(processingDelay >= 1.seconds) { "processingDelay must be above 1 second" } } } diff --git a/src/main/kotlin/com/xpdustry/nohorny/NoHornyImageRenderer.kt b/src/main/kotlin/com/xpdustry/nohorny/NoHornyImageRenderer.kt deleted file mode 100644 index e907887..0000000 --- a/src/main/kotlin/com/xpdustry/nohorny/NoHornyImageRenderer.kt +++ /dev/null @@ -1,124 +0,0 @@ -/* - * This file is part of NoHorny. The plugin securing your server against nsfw builds. - * - * MIT License - * - * Copyright (c) 2024 Xpdustry - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package com.xpdustry.nohorny - -import com.xpdustry.nohorny.geometry.BlockGroup -import java.awt.Color -import java.awt.geom.AffineTransform -import java.awt.image.AffineTransformOp -import java.awt.image.BufferedImage - -internal interface NoHornyImageRenderer { - fun render(group: BlockGroup): BufferedImage -} - -internal object SimpleImageRenderer : NoHornyImageRenderer { - private const val RES_PER_BLOCK = 32 - - override fun render(group: BlockGroup): BufferedImage { - val image = BufferedImage(group.w * RES_PER_BLOCK, group.h * RES_PER_BLOCK, BufferedImage.TYPE_INT_ARGB) - val graphics = image.graphics - graphics.color = Color(0, 0, 0, 0) - graphics.fillRect(0, 0, image.width, image.height) - - for (block in group.blocks) { - graphics.drawImage( - createImage(block.data), - (block.x - group.x) * RES_PER_BLOCK, - (block.y - group.y) * RES_PER_BLOCK, - block.size * RES_PER_BLOCK, - block.size * RES_PER_BLOCK, - null, - ) - } - - // Invert y-axis, because mindustry uses bottom-left as origin - val inverted = invertYAxis(image) - graphics.dispose() - return inverted - } - - private fun createImage(image: NoHornyImage): BufferedImage { - var output = BufferedImage(image.resolution, image.resolution, BufferedImage.TYPE_INT_RGB) - val graphics = output.graphics - graphics.color = Color(0, 0, 0, 0) - graphics.fillRect(0, 0, output.width, output.height) - - when (image) { - is NoHornyImage.Canvas -> { - for (pixel in image.pixels) { - output.setRGB( - pixel.key % image.resolution, - pixel.key / image.resolution, - pixel.value, - ) - } - output = invertYAxis(output) - } - is NoHornyImage.Display -> { - for (processor in image.processors.values) { - for (instruction in processor.instructions) { - when (instruction) { - is NoHornyImage.Instruction.Color -> { - graphics.color = - Color(instruction.r, instruction.g, instruction.b, instruction.a) - } - is NoHornyImage.Instruction.Rect -> { - if (instruction.w == 1 && instruction.h == 1) { - output.setRGB(instruction.x, instruction.y, graphics.color.rgb) - } else { - graphics.fillRect( - instruction.x, - instruction.y, - instruction.w, - instruction.h, - ) - } - } - is NoHornyImage.Instruction.Triangle -> { - graphics.fillPolygon( - intArrayOf(instruction.x1, instruction.x2, instruction.x3), - intArrayOf(instruction.y1, instruction.y2, instruction.y3), - 3, - ) - } - } - } - } - } - } - - graphics.dispose() - return output - } - - private fun invertYAxis(image: BufferedImage): BufferedImage { - val transform = AffineTransform.getScaleInstance(1.0, -1.0) - transform.translate(0.0, -image.getHeight(null).toDouble()) - val op = AffineTransformOp(transform, AffineTransformOp.TYPE_NEAREST_NEIGHBOR) - return op.filter(image, null) - } -} diff --git a/src/main/kotlin/com/xpdustry/nohorny/NoHornyListener.kt b/src/main/kotlin/com/xpdustry/nohorny/NoHornyListener.kt index 3236dab..ab0b95a 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/NoHornyListener.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/NoHornyListener.kt @@ -25,12 +25,23 @@ */ package com.xpdustry.nohorny -import arc.util.CommandHandler +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import org.slf4j.LoggerFactory +import kotlin.coroutines.CoroutineContext -internal interface NoHornyListener { - fun onInit() = Unit +private val NoHornyCoroutineExceptionHandler = + CoroutineExceptionHandler { _, throwable -> + LoggerFactory.getLogger(NoHornyPlugin::class.java).error("An uncaught error occurred", throwable) + } - fun onServerCommandsRegistration(handler: CommandHandler) = Unit +internal abstract class NoHornyListener(name: String, context: CoroutineContext) { + protected val scope = + CoroutineScope( + context + SupervisorJob() + CoroutineName("NoHorny $name Scope") + NoHornyCoroutineExceptionHandler, + ) - fun onClientCommandsRegistration(handler: CommandHandler) = Unit + open fun onInit() = Unit } diff --git a/src/main/kotlin/com/xpdustry/nohorny/NoHornyPlugin.kt b/src/main/kotlin/com/xpdustry/nohorny/NoHornyPlugin.kt index 32d2720..ffdcffe 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/NoHornyPlugin.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/NoHornyPlugin.kt @@ -27,25 +27,22 @@ package com.xpdustry.nohorny import arc.ApplicationListener import arc.Core -import arc.Events import arc.util.CommandHandler import com.sksamuel.hoplite.ConfigException import com.sksamuel.hoplite.ConfigLoaderBuilder import com.sksamuel.hoplite.addPathSource -import com.xpdustry.nohorny.analyzer.DebugImageAnalyzer -import com.xpdustry.nohorny.analyzer.FallbackAnalyzer -import com.xpdustry.nohorny.analyzer.ImageAnalyzer -import com.xpdustry.nohorny.analyzer.ImageAnalyzerEvent -import com.xpdustry.nohorny.analyzer.SightEngineAnalyzer -import com.xpdustry.nohorny.cache.NoHornyCache -import com.xpdustry.nohorny.cache.SQLCache -import com.xpdustry.nohorny.geometry.BlockGroup +import com.xpdustry.nohorny.image.ImageProcessorImpl +import com.xpdustry.nohorny.image.ImageRendererImpl +import com.xpdustry.nohorny.image.analyzer.AnalyzerConfig +import com.xpdustry.nohorny.image.analyzer.DebugImageAnalyzer +import com.xpdustry.nohorny.image.analyzer.FallbackAnalyzer +import com.xpdustry.nohorny.image.analyzer.ImageAnalyzer +import com.xpdustry.nohorny.image.analyzer.SightEngineAnalyzer +import com.xpdustry.nohorny.image.cache.H2ImageCache import com.xpdustry.nohorny.tracker.CanvasesTracker import com.xpdustry.nohorny.tracker.DisplaysTracker import com.zaxxer.hikari.HikariConfig import com.zaxxer.hikari.HikariDataSource -import kotlinx.coroutines.future.await -import kotlinx.coroutines.launch import mindustry.Vars import mindustry.mod.Plugin import okhttp3.Dispatcher @@ -55,20 +52,18 @@ import java.lang.Runnable import java.nio.file.Files import java.util.concurrent.Executors import java.util.concurrent.ThreadFactory -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import kotlin.io.path.absolutePathString import kotlin.io.path.notExists import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration -public class NoHornyPlugin : Plugin(), NoHornyAPI { +public class NoHornyPlugin : Plugin() { private val directory = Vars.modDirectory.child("nohorny").file().toPath() private val file = directory.resolve("config.yaml") private val listeners = mutableListOf() private val executor = Executors.newScheduledThreadPool(4, NoHornyThreadFactory) private val logger = LoggerFactory.getLogger(NoHornyPlugin::class.java) - private val renderer: NoHornyImageRenderer = SimpleImageRenderer private val loader = ConfigLoaderBuilder.empty() @@ -88,27 +83,20 @@ public class NoHornyPlugin : Plugin(), NoHornyAPI { .connectTimeout(10.seconds.toJavaDuration()) .readTimeout(10.seconds.toJavaDuration()) .writeTimeout(10.seconds.toJavaDuration()) - .dispatcher(Dispatcher(executor)) + .dispatcher(Dispatcher(Executors.newScheduledThreadPool(4, NoHornyThreadFactory))) .build() - internal val coroutines = NoHornyCoroutines(executor) internal var config = NoHornyConfig() - internal var analyzer: ImageAnalyzer = ImageAnalyzer.None - private lateinit var hikari: HikariDataSource - private var cache: NoHornyCache = SQLCache({ hikari }, coroutines) + private var analyzer: ImageAnalyzer = ImageAnalyzer.None init { Files.createDirectories(directory) - listeners += cache as NoHornyListener - listeners += CanvasesTracker(this) - listeners += DisplaysTracker(this) - listeners += NoHornyAutoBan(this) } override fun init() { reload() - hikari = + val hikari = HikariDataSource( HikariConfig().apply { jdbcUrl = "jdbc:h2:${directory.resolve("database.h2").absolutePathString()};MODE=MYSQL" @@ -118,6 +106,14 @@ public class NoHornyPlugin : Plugin(), NoHornyAPI { }, ) + val renderer = ImageRendererImpl + val cache = H2ImageCache(hikari) + listeners += cache + val processor = ImageProcessorImpl({ analyzer }, cache, renderer) + listeners += CanvasesTracker({ config }, processor) + listeners += DisplaysTracker({ config }, processor) + listeners += NoHornyAutoBan(this) + listeners.forEach(NoHornyListener::onInit) logger.info("Initialized no-horny, to the horny jail we go.") @@ -125,9 +121,6 @@ public class NoHornyPlugin : Plugin(), NoHornyAPI { object : ApplicationListener { override fun dispose() { executor.shutdown() - if (!executor.awaitTermination(10L, TimeUnit.SECONDS)) { - executor.shutdownNow() - } hikari.close() } }, @@ -135,7 +128,6 @@ public class NoHornyPlugin : Plugin(), NoHornyAPI { } override fun registerServerCommands(handler: CommandHandler) { - listeners.forEach { it.onServerCommandsRegistration(handler) } handler.register("nohorny-reload", "Reload nohorny config.") { _, _ -> try { reload() @@ -148,74 +140,29 @@ public class NoHornyPlugin : Plugin(), NoHornyAPI { } } - override fun registerClientCommands(handler: CommandHandler) { - listeners.forEach { it.onClientCommandsRegistration(handler) } - } - private fun reload() { if (file.notExists()) { analyzer = ImageAnalyzer.None return } - val config = loader.loadConfigOrThrow() - val analyzer = createAnalyzer(config.analyzer) - this.config = config - this.analyzer = analyzer - } - - override fun setCache(cache: NoHornyCache) { - this.cache = cache - logger.debug("Set cache to {}", cache) + this.analyzer = createAnalyzer(config.analyzer) } - private fun createAnalyzer(config: NoHornyConfig.Analyzer): ImageAnalyzer = + private fun createAnalyzer(config: AnalyzerConfig): ImageAnalyzer = when (config) { - is NoHornyConfig.Analyzer.None -> ImageAnalyzer.None - is NoHornyConfig.Analyzer.Debug -> DebugImageAnalyzer(directory.resolve("debug")) - is NoHornyConfig.Analyzer.SightEngine -> SightEngineAnalyzer(config, http) - is NoHornyConfig.Analyzer.Fallback -> FallbackAnalyzer(createAnalyzer(config.primary), createAnalyzer(config.secondary)) - } - - internal fun process(group: BlockGroup) = - coroutines.global.launch { - logger.trace("Processing group at ({}, {})", group.x, group.y) - val image = renderer.render(group) - var store = false - var result = - try { - cache.getResult(group, image).await() - } catch (e: Exception) { - logger.error("Failed to get cached result for group at (${group.x}, ${group.y})", e) - null - } - if (result == null) { - logger.trace("Cache miss for group at ({}, {})", group.x, group.y) - store = true - try { - result = analyzer.analyse(image).await()!! - } catch (e: Exception) { - logger.error("Failed to analyse image for group at (${group.x}, ${group.y})", e) - return@launch - } - } else { - logger.trace("Cache hit for group at ({}, {})", group.x, group.y) - } - - logger.trace("Result for group at ({}, {}): {}", group.x, group.y, result) - if (store) { - logger.trace("Storing result for group at ({}, {})", group.x, group.y) - cache.putResult(group, image, result) - } - Core.app.post { - Events.fire(ImageAnalyzerEvent(result, group, image)) - } + is AnalyzerConfig.None -> ImageAnalyzer.None + is AnalyzerConfig.Debug -> DebugImageAnalyzer(directory.resolve("debug")) + is AnalyzerConfig.Fallback -> FallbackAnalyzer(createAnalyzer(config.primary), createAnalyzer(config.secondary)) + is AnalyzerConfig.SightEngine -> SightEngineAnalyzer(config, http) } private object NoHornyThreadFactory : ThreadFactory { private val count = AtomicInteger(0) - override fun newThread(runnable: Runnable) = Thread(runnable, "nohorny-worker-${count.incrementAndGet()}").apply { isDaemon = true } + override fun newThread(runnable: Runnable) = + Thread(runnable, "nohorny-worker-${count.incrementAndGet()}") + .apply { isDaemon = true } } } diff --git a/src/main/kotlin/com/xpdustry/nohorny/analyzer/ImageAnalyzerEvent.kt b/src/main/kotlin/com/xpdustry/nohorny/analyzer/ImageAnalyzerEvent.kt deleted file mode 100644 index 5a440e5..0000000 --- a/src/main/kotlin/com/xpdustry/nohorny/analyzer/ImageAnalyzerEvent.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * This file is part of NoHorny. The plugin securing your server against nsfw builds. - * - * MIT License - * - * Copyright (c) 2024 Xpdustry - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -package com.xpdustry.nohorny.analyzer - -import com.xpdustry.nohorny.NoHornyImage -import com.xpdustry.nohorny.geometry.BlockGroup -import java.awt.image.BufferedImage - -public data class ImageAnalyzerEvent( - val result: ImageInformation, - val group: BlockGroup, - val image: BufferedImage, -) { - public operator fun component4(): NoHornyImage.Author? = author - - public val author: NoHornyImage.Author? - get() = - group.blocks - .flatMap { block -> - when (block.data) { - is NoHornyImage.Canvas -> listOf(block.data.author) - is NoHornyImage.Display -> block.data.processors.values.map { it.author } - } - } - .let { authors -> - val safe = authors.filterNotNull() - if (safe.size < authors.size / 2) { - return@let null - } - val max = - safe - .groupingBy(NoHornyImage.Author::address) - .eachCount() - .maxByOrNull { it.value } - ?.key ?: return@let null - return safe.firstOrNull { it.address == max } - } -} diff --git a/src/main/kotlin/com/xpdustry/nohorny/extension/BufferedImageExtensions.kt b/src/main/kotlin/com/xpdustry/nohorny/extension/BufferedImageExtensions.kt index 29a1998..3d19f11 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/extension/BufferedImageExtensions.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/extension/BufferedImageExtensions.kt @@ -28,26 +28,28 @@ package com.xpdustry.nohorny.extension import java.awt.Color import java.awt.Graphics2D import java.awt.Image +import java.awt.geom.AffineTransform +import java.awt.image.AffineTransformOp import java.awt.image.BufferedImage import java.io.ByteArrayOutputStream import javax.imageio.ImageIO -internal inline fun BufferedImage.withGraphics(block: (Graphics2D) -> Unit): BufferedImage { - val image = BufferedImage(width, height, type) - val graphics = image.createGraphics() +internal inline fun BufferedImage.withGraphics(block: (Graphics2D) -> Unit) { + val graphics = createGraphics() try { block(graphics) } finally { graphics.dispose() } - return image } internal fun BufferedImage.toJpgByteArray(): ByteArray { var image = this if (this.type != BufferedImage.TYPE_INT_RGB) { image = BufferedImage(this.width, this.height, BufferedImage.TYPE_INT_RGB) - image.createGraphics().apply { drawImage(this@toJpgByteArray, 0, 0, null) }.dispose() + image.withGraphics { graphics -> + graphics.drawImage(this@toJpgByteArray, 0, 0, null) + } } return ByteArrayOutputStream() .also { ImageIO.write(image, "jpg", it) } @@ -65,11 +67,20 @@ internal fun BufferedImage.resize( } else { this } - return BufferedImage(w, h, type).withGraphics { + val result = BufferedImage(w, h, type) + result.withGraphics { graphics -> if (fill != null) { - it.color = fill - it.fillRect(0, 0, w, h) + graphics.color = fill + graphics.fillRect(0, 0, w, h) } - it.drawImage(source, 0, 0, w, h, null) + graphics.drawImage(source, 0, 0, w, h, null) } + return result +} + +internal fun BufferedImage.invertYAxis(): BufferedImage { + val transform = AffineTransform.getScaleInstance(1.0, -1.0) + transform.translate(0.0, -getHeight(null).toDouble()) + val op = AffineTransformOp(transform, AffineTransformOp.TYPE_NEAREST_NEIGHBOR) + return op.filter(this, null) } diff --git a/src/main/kotlin/com/xpdustry/nohorny/extension/MindustryExtensions.kt b/src/main/kotlin/com/xpdustry/nohorny/extension/MindustryExtensions.kt index da632b1..a018560 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/extension/MindustryExtensions.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/extension/MindustryExtensions.kt @@ -27,8 +27,8 @@ package com.xpdustry.nohorny.extension import arc.Events import com.google.common.net.InetAddresses -import com.xpdustry.nohorny.NoHornyImage import com.xpdustry.nohorny.NoHornyPlugin +import com.xpdustry.nohorny.image.NoHornyImage import mindustry.game.EventType import mindustry.gen.Building import mindustry.gen.Player diff --git a/src/main/kotlin/com/xpdustry/nohorny/geometry/GroupingBlockIndex.kt b/src/main/kotlin/com/xpdustry/nohorny/geometry/GroupingBlockIndex.kt index 1d55db2..c7e4259 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/geometry/GroupingBlockIndex.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/geometry/GroupingBlockIndex.kt @@ -49,12 +49,12 @@ public interface GroupingBlockIndex { public fun selectAll(): Collection> - public fun upsert( + public fun insert( x: Int, y: Int, size: Int, data: T, - ): IndexBlock? + ): Boolean public fun remove( x: Int, @@ -68,7 +68,7 @@ public interface GroupingBlockIndex { y: Int, ): Collection> - public fun groups(): Collection> + public fun groups(): Collection> public companion object { @JvmStatic @@ -77,57 +77,6 @@ public interface GroupingBlockIndex { } } -public data class IndexBlock( - val x: Int, - val y: Int, - val size: Int, - val data: T, -) { - public data class WithLinks( - val block: IndexBlock, - val links: Collection>, - ) -} - -public data class BlockGroup( - val x: Int, - val y: Int, - val w: Int, - val h: Int, - val blocks: List>, -) - -public fun interface GroupingFunction { - public fun group( - a: IndexBlock.WithLinks, - b: IndexBlock.WithLinks, - ): Boolean - - public companion object { - @Suppress("UNCHECKED_CAST") - @JvmStatic - public fun always(): GroupingFunction = Always as GroupingFunction - - @Suppress("UNCHECKED_CAST") - @JvmStatic - public fun single(): GroupingFunction = Single as GroupingFunction - } - - private object Always : GroupingFunction { - override fun group( - a: IndexBlock.WithLinks, - b: IndexBlock.WithLinks, - ): Boolean = true - } - - private object Single : GroupingFunction { - override fun group( - a: IndexBlock.WithLinks, - b: IndexBlock.WithLinks, - ): Boolean = false - } -} - @Suppress("UnstableApiUsage") internal class GroupingBlockIndexImpl(private val group: GroupingFunction) : GroupingBlockIndex { private val index = IntMap>() @@ -153,17 +102,17 @@ internal class GroupingBlockIndexImpl(private val group: GroupingFuncti override fun selectAll() = index.values().toSet() - override fun upsert( + override fun insert( x: Int, y: Int, size: Int, data: T, - ): IndexBlock? { + ): Boolean { require(size > 0) { "Size must be greater than 0" } val previous = select(x, y) if (previous != null) { - remove(x, y) + return false } val block = IndexBlock(x, y, size, data) @@ -193,21 +142,21 @@ internal class GroupingBlockIndexImpl(private val group: GroupingFuncti } } - return previous + return true } override fun remove( x: Int, y: Int, ): IndexBlock? { - val packed = Point2.pack(x, y) val block = select(x, y) ?: return null for (i in block.x until block.x + block.size) { for (j in block.y until block.y + block.size) { - index.remove(Point2.pack(i, j)) + val packed = Point2.pack(i, j) + index.remove(packed) + graph.removeNode(packed) } } - graph.removeNode(packed) return block } @@ -224,8 +173,8 @@ internal class GroupingBlockIndexImpl(private val group: GroupingFuncti return if (graph.nodes().contains(packed)) graph.adjacentNodes(packed).map { index[it]!! } else emptyList() } - override fun groups(): List> { - val clusters = mutableListOf>() + override fun groups(): List> { + val groups = mutableListOf>() val visited = IntSet() for (node in graph.nodes()) { if (node in visited) continue @@ -249,9 +198,9 @@ internal class GroupingBlockIndexImpl(private val group: GroupingFuncti queue.addLast(neighbor) } } - clusters += BlockGroup(x, y, w, h, blocks) + groups += IndexGroup(x, y, w, h, blocks) } - return clusters + return groups } private fun canBeGroupedWith( diff --git a/src/main/kotlin/com/xpdustry/nohorny/geometry/GroupingFunction.kt b/src/main/kotlin/com/xpdustry/nohorny/geometry/GroupingFunction.kt new file mode 100644 index 0000000..ca388f4 --- /dev/null +++ b/src/main/kotlin/com/xpdustry/nohorny/geometry/GroupingFunction.kt @@ -0,0 +1,57 @@ +/* + * This file is part of NoHorny. The plugin securing your server against nsfw builds. + * + * MIT License + * + * Copyright (c) 2024 Xpdustry + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.xpdustry.nohorny.geometry + +public fun interface GroupingFunction { + public fun group( + a: IndexBlock.WithLinks, + b: IndexBlock.WithLinks, + ): Boolean + + public companion object { + @Suppress("UNCHECKED_CAST") + @JvmStatic + public fun always(): GroupingFunction = Always as GroupingFunction + + @Suppress("UNCHECKED_CAST") + @JvmStatic + public fun single(): GroupingFunction = Single as GroupingFunction + } + + private object Always : GroupingFunction { + override fun group( + a: IndexBlock.WithLinks, + b: IndexBlock.WithLinks, + ): Boolean = true + } + + private object Single : GroupingFunction { + override fun group( + a: IndexBlock.WithLinks, + b: IndexBlock.WithLinks, + ): Boolean = false + } +} diff --git a/src/main/kotlin/com/xpdustry/nohorny/NoHornyCoroutines.kt b/src/main/kotlin/com/xpdustry/nohorny/geometry/IndexBlock.kt similarity index 63% rename from src/main/kotlin/com/xpdustry/nohorny/NoHornyCoroutines.kt rename to src/main/kotlin/com/xpdustry/nohorny/geometry/IndexBlock.kt index a322992..c86b719 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/NoHornyCoroutines.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/geometry/IndexBlock.kt @@ -23,18 +23,20 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny +package com.xpdustry.nohorny.geometry -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.asCoroutineDispatcher -import java.util.concurrent.ExecutorService +public data class IndexBlock( + val x: Int, + val y: Int, + val size: Int, + val data: T, +) { + init { + require(size > 0) { "Size must be greater than 0" } + } -internal class NoHornyCoroutines(executor: ExecutorService) { - private val dispatcher = executor.asCoroutineDispatcher() - val job = SupervisorJob() - val displays = CoroutineScope(dispatcher.limitedParallelism(1) + job + CoroutineName("Displays Scope")) - val canvases = CoroutineScope(dispatcher.limitedParallelism(1) + job + CoroutineName("Canvases Scope")) - val global = CoroutineScope(dispatcher + job + CoroutineName("Global Nohorny Scope")) + public data class WithLinks( + val block: IndexBlock, + val links: Collection>, + ) } diff --git a/src/main/kotlin/com/xpdustry/nohorny/NoHornyAPI.kt b/src/main/kotlin/com/xpdustry/nohorny/geometry/IndexGroup.kt similarity index 78% rename from src/main/kotlin/com/xpdustry/nohorny/NoHornyAPI.kt rename to src/main/kotlin/com/xpdustry/nohorny/geometry/IndexGroup.kt index 6c008ab..e59f57c 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/NoHornyAPI.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/geometry/IndexGroup.kt @@ -23,15 +23,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny +package com.xpdustry.nohorny.geometry -import com.xpdustry.nohorny.cache.NoHornyCache -import mindustry.Vars - -public interface NoHornyAPI { - public fun setCache(cache: NoHornyCache) - - public companion object { - @JvmStatic public fun get(): NoHornyAPI = Vars.mods.getMod(NoHornyPlugin::class.java).main as NoHornyAPI - } -} +public data class IndexGroup( + val x: Int, + val y: Int, + val w: Int, + val h: Int, + val blocks: List>, +) diff --git a/src/main/kotlin/com/xpdustry/nohorny/image/ImageProcessor.kt b/src/main/kotlin/com/xpdustry/nohorny/image/ImageProcessor.kt new file mode 100644 index 0000000..b255aca --- /dev/null +++ b/src/main/kotlin/com/xpdustry/nohorny/image/ImageProcessor.kt @@ -0,0 +1,88 @@ +/* + * This file is part of NoHorny. The plugin securing your server against nsfw builds. + * + * MIT License + * + * Copyright (c) 2024 Xpdustry + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.xpdustry.nohorny.image + +import arc.Core +import arc.Events +import com.xpdustry.nohorny.NoHornyListener +import com.xpdustry.nohorny.geometry.IndexGroup +import com.xpdustry.nohorny.image.analyzer.ImageAnalyzer +import com.xpdustry.nohorny.image.analyzer.ImageAnalyzerEvent +import com.xpdustry.nohorny.image.cache.ImageCache +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.future.await +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory + +internal interface ImageProcessor { + fun process(group: IndexGroup) +} + +internal class ImageProcessorImpl( + private val analyzer: () -> ImageAnalyzer, + private val cache: ImageCache, + private val renderer: ImageRenderer, +) : ImageProcessor, NoHornyListener("Image Processor", Dispatchers.Default) { + override fun process(group: IndexGroup) { + scope.launch { + logger.trace("Processing group at ({}, {})", group.x, group.y) + val image = renderer.render(group) + var store = false + var result = + try { + cache.getResult(group, image).await() + } catch (e: Exception) { + logger.error("Failed to get cached result for group at (${group.x}, ${group.y})", e) + null + } + if (result == null) { + logger.trace("Cache miss for group at ({}, {})", group.x, group.y) + store = true + try { + result = analyzer().analyse(image).await()!! + } catch (e: Exception) { + logger.error("Failed to analyse image for group at (${group.x}, ${group.y})", e) + return@launch + } + } else { + logger.trace("Cache hit for group at ({}, {})", group.x, group.y) + } + + logger.trace("Result for group at ({}, {}): {}", group.x, group.y, result) + if (store) { + logger.trace("Storing result for group at ({}, {})", group.x, group.y) + cache.putResult(group, image, result) + } + Core.app.post { + Events.fire(ImageAnalyzerEvent(result, group, image)) + } + } + } + + companion object { + private val logger = LoggerFactory.getLogger(ImageProcessorImpl::class.java) + } +} diff --git a/src/main/kotlin/com/xpdustry/nohorny/image/ImageRenderer.kt b/src/main/kotlin/com/xpdustry/nohorny/image/ImageRenderer.kt new file mode 100644 index 0000000..9b29e2d --- /dev/null +++ b/src/main/kotlin/com/xpdustry/nohorny/image/ImageRenderer.kt @@ -0,0 +1,118 @@ +/* + * This file is part of NoHorny. The plugin securing your server against nsfw builds. + * + * MIT License + * + * Copyright (c) 2024 Xpdustry + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.xpdustry.nohorny.image + +import com.xpdustry.nohorny.extension.invertYAxis +import com.xpdustry.nohorny.extension.withGraphics +import com.xpdustry.nohorny.geometry.IndexGroup +import java.awt.Color +import java.awt.image.BufferedImage + +internal interface ImageRenderer { + fun render(group: IndexGroup): BufferedImage +} + +internal object ImageRendererImpl : ImageRenderer { + private const val PIXELS_PER_BLOCK = 32 + + override fun render(group: IndexGroup): BufferedImage { + val image = BufferedImage(group.w * PIXELS_PER_BLOCK, group.h * PIXELS_PER_BLOCK, BufferedImage.TYPE_INT_ARGB) + image.withGraphics { graphics -> + graphics.color = Color(0, 0, 0, 0) + graphics.fillRect(0, 0, image.width, image.height) + + for (block in group.blocks) { + graphics.drawImage( + createImage(block.data), + (block.x - group.x) * PIXELS_PER_BLOCK, + (block.y - group.y) * PIXELS_PER_BLOCK, + block.size * PIXELS_PER_BLOCK, + block.size * PIXELS_PER_BLOCK, + null, + ) + } + } + // Invert y-axis, because mindustry uses bottom-left as origin + return image.invertYAxis() + } + + private fun createImage(image: NoHornyImage): BufferedImage { + val output = BufferedImage(image.resolution, image.resolution, BufferedImage.TYPE_INT_RGB) + var invert = false + output.withGraphics { graphics -> + graphics.color = Color(0, 0, 0, 0) + graphics.fillRect(0, 0, output.width, output.height) + + when (image) { + is NoHornyImage.Canvas -> { + invert = true + for (pixel in image.pixels) { + output.setRGB( + pixel.key % image.resolution, + pixel.key / image.resolution, + pixel.value, + ) + } + } + + is NoHornyImage.Display -> { + for (processor in image.processors.values) { + for (instruction in processor.instructions) { + when (instruction) { + is NoHornyImage.Instruction.Color -> { + graphics.color = + Color(instruction.r, instruction.g, instruction.b, instruction.a) + } + + is NoHornyImage.Instruction.Rect -> { + if (instruction.w == 1 && instruction.h == 1) { + output.setRGB(instruction.x, instruction.y, graphics.color.rgb) + } else { + graphics.fillRect( + instruction.x, + instruction.y, + instruction.w, + instruction.h, + ) + } + } + + is NoHornyImage.Instruction.Triangle -> { + graphics.fillPolygon( + intArrayOf(instruction.x1, instruction.x2, instruction.x3), + intArrayOf(instruction.y1, instruction.y2, instruction.y3), + 3, + ) + } + } + } + } + } + } + } + return output.let { if (invert) it.invertYAxis() else it } + } +} diff --git a/src/main/kotlin/com/xpdustry/nohorny/NoHornyImage.kt b/src/main/kotlin/com/xpdustry/nohorny/image/NoHornyImage.kt similarity index 98% rename from src/main/kotlin/com/xpdustry/nohorny/NoHornyImage.kt rename to src/main/kotlin/com/xpdustry/nohorny/image/NoHornyImage.kt index 71f3032..622ea4d 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/NoHornyImage.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/image/NoHornyImage.kt @@ -23,7 +23,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny +package com.xpdustry.nohorny.image import arc.struct.IntIntMap import com.xpdustry.nohorny.geometry.ImmutablePoint diff --git a/src/main/kotlin/com/xpdustry/nohorny/analyzer/ImageInformation.kt b/src/main/kotlin/com/xpdustry/nohorny/image/NoHornyInformation.kt similarity index 86% rename from src/main/kotlin/com/xpdustry/nohorny/analyzer/ImageInformation.kt rename to src/main/kotlin/com/xpdustry/nohorny/image/NoHornyInformation.kt index b61c4fc..5f82f46 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/analyzer/ImageInformation.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/image/NoHornyInformation.kt @@ -23,12 +23,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny.analyzer +package com.xpdustry.nohorny.image import kotlinx.serialization.Serializable @Serializable -public data class ImageInformation(val rating: Rating, val details: Map) { +public data class NoHornyInformation(val rating: Rating, val details: Map) { @Serializable public enum class Kind { NUDITY, @@ -43,6 +43,6 @@ public data class ImageInformation(val rating: Rating, val details: Map = listOf(NoHornyInformation.Kind.NUDITY), + ) : AnalyzerConfig { + init { + require(unsafeThreshold >= 0) { "unsafeThreshold cannot be lower than 0" } + require(warningThreshold >= 0) { "warningThreshold cannot be lower than 0" } + require(kinds.isNotEmpty()) { "models cannot be empty" } + } + } +} diff --git a/src/main/kotlin/com/xpdustry/nohorny/analyzer/DebugImageAnalyzer.kt b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/DebugImageAnalyzer.kt similarity index 90% rename from src/main/kotlin/com/xpdustry/nohorny/analyzer/DebugImageAnalyzer.kt rename to src/main/kotlin/com/xpdustry/nohorny/image/analyzer/DebugImageAnalyzer.kt index 99fadc8..fb3c72a 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/analyzer/DebugImageAnalyzer.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/DebugImageAnalyzer.kt @@ -23,9 +23,10 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny.analyzer +package com.xpdustry.nohorny.image.analyzer import com.xpdustry.nohorny.extension.toJpgByteArray +import com.xpdustry.nohorny.image.NoHornyInformation import java.awt.image.BufferedImage import java.io.IOException import java.nio.file.Path @@ -33,7 +34,7 @@ import java.util.concurrent.CompletableFuture import kotlin.io.path.writeBytes internal class DebugImageAnalyzer(private val directory: Path) : ImageAnalyzer { - override fun analyse(image: BufferedImage): CompletableFuture { + override fun analyse(image: BufferedImage): CompletableFuture { try { directory.toFile().mkdirs() directory @@ -42,6 +43,6 @@ internal class DebugImageAnalyzer(private val directory: Path) : ImageAnalyzer { } catch (error: IOException) { return CompletableFuture.failedFuture(error) } - return CompletableFuture.completedFuture(ImageInformation.EMPTY) + return CompletableFuture.completedFuture(NoHornyInformation.EMPTY) } } diff --git a/src/main/kotlin/com/xpdustry/nohorny/analyzer/FallbackAnalyzer.kt b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/FallbackAnalyzer.kt similarity index 93% rename from src/main/kotlin/com/xpdustry/nohorny/analyzer/FallbackAnalyzer.kt rename to src/main/kotlin/com/xpdustry/nohorny/image/analyzer/FallbackAnalyzer.kt index ff550b5..2a985bb 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/analyzer/FallbackAnalyzer.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/FallbackAnalyzer.kt @@ -23,15 +23,16 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny.analyzer +package com.xpdustry.nohorny.image.analyzer +import com.xpdustry.nohorny.image.NoHornyInformation import org.slf4j.LoggerFactory import java.awt.image.BufferedImage import java.util.concurrent.CompletableFuture internal class FallbackAnalyzer(private val primary: ImageAnalyzer, private val secondary: ImageAnalyzer) : ImageAnalyzer { - override fun analyse(image: BufferedImage): CompletableFuture = + override fun analyse(image: BufferedImage): CompletableFuture = primary.analyse(image).exceptionallyCompose { throwable -> LOGGER.debug("Primary analyzer failed, switching to secondary", throwable) secondary.analyse(image) diff --git a/src/main/kotlin/com/xpdustry/nohorny/analyzer/ImageAnalyzer.kt b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/ImageAnalyzer.kt similarity index 87% rename from src/main/kotlin/com/xpdustry/nohorny/analyzer/ImageAnalyzer.kt rename to src/main/kotlin/com/xpdustry/nohorny/image/analyzer/ImageAnalyzer.kt index c2e8e95..f93feaf 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/analyzer/ImageAnalyzer.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/ImageAnalyzer.kt @@ -23,18 +23,17 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny.analyzer +package com.xpdustry.nohorny.image.analyzer +import com.xpdustry.nohorny.image.NoHornyInformation import java.awt.image.BufferedImage import java.util.concurrent.CompletableFuture public interface ImageAnalyzer { - public fun analyse(image: BufferedImage): CompletableFuture + public fun analyse(image: BufferedImage): CompletableFuture public object None : ImageAnalyzer { - override fun analyse(image: BufferedImage): CompletableFuture = - CompletableFuture.completedFuture( - ImageInformation.EMPTY, - ) + override fun analyse(image: BufferedImage): CompletableFuture = + CompletableFuture.completedFuture(NoHornyInformation.EMPTY) } } diff --git a/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/ImageAnalyzerEvent.kt b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/ImageAnalyzerEvent.kt new file mode 100644 index 0000000..9587b81 --- /dev/null +++ b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/ImageAnalyzerEvent.kt @@ -0,0 +1,68 @@ +/* + * This file is part of NoHorny. The plugin securing your server against nsfw builds. + * + * MIT License + * + * Copyright (c) 2024 Xpdustry + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package com.xpdustry.nohorny.image.analyzer + +import com.xpdustry.nohorny.geometry.IndexGroup +import com.xpdustry.nohorny.image.NoHornyImage +import com.xpdustry.nohorny.image.NoHornyInformation +import java.awt.image.BufferedImage +import java.net.InetAddress + +public data class ImageAnalyzerEvent( + val result: NoHornyInformation, + val group: IndexGroup, + val image: BufferedImage, + val author: NoHornyImage.Author?, +) { + public constructor( + result: NoHornyInformation, + group: IndexGroup, + image: BufferedImage, + ) : this(result, group, image, computeAuthor(group)) +} + +private fun computeAuthor(group: IndexGroup): NoHornyImage.Author? { + val authors = + group.blocks.flatMap { block -> + when (block.data) { + is NoHornyImage.Canvas -> listOf(block.data.author) + is NoHornyImage.Display -> block.data.processors.values.map { it.author } + } + } + val safe = authors.filterNotNull() + if (safe.isNotEmpty() && (safe.size / authors.size) < 0.4) { + return null + } + val counts = mutableMapOf() + var author: NoHornyImage.Author? = null + for (entry in safe) { + counts.compute(entry.address) { _, v -> (v ?: 0) + 1 } + if (author == null || counts[entry.address]!! > counts[author.address]!!) { + author = entry + } + } + return author +} diff --git a/src/main/kotlin/com/xpdustry/nohorny/analyzer/SightEngineAnalyzer.kt b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/SightEngineAnalyzer.kt similarity index 81% rename from src/main/kotlin/com/xpdustry/nohorny/analyzer/SightEngineAnalyzer.kt rename to src/main/kotlin/com/xpdustry/nohorny/image/analyzer/SightEngineAnalyzer.kt index 696f1cb..4c61284 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/analyzer/SightEngineAnalyzer.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/image/analyzer/SightEngineAnalyzer.kt @@ -23,12 +23,12 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny.analyzer +package com.xpdustry.nohorny.image.analyzer -import com.xpdustry.nohorny.NoHornyConfig import com.xpdustry.nohorny.extension.toCompletableFuture import com.xpdustry.nohorny.extension.toJpgByteArray import com.xpdustry.nohorny.extension.toJsonObject +import com.xpdustry.nohorny.image.NoHornyInformation import kotlinx.serialization.json.float import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -44,10 +44,10 @@ import java.io.IOException import java.util.concurrent.CompletableFuture internal class SightEngineAnalyzer( - private val config: NoHornyConfig.Analyzer.SightEngine, + private val config: AnalyzerConfig.SightEngine, private val http: OkHttpClient, ) : ImageAnalyzer { - override fun analyse(image: BufferedImage): CompletableFuture { + override fun analyse(image: BufferedImage): CompletableFuture { val request = MultipartBody.Builder() .setType(MultipartBody.FORM) @@ -57,8 +57,8 @@ internal class SightEngineAnalyzer( "models", config.kinds.joinToString(",") { when (it) { - ImageInformation.Kind.NUDITY -> "nudity-2.0" - ImageInformation.Kind.GORE -> "gore" + NoHornyInformation.Kind.NUDITY -> "nudity-2.0" + NoHornyInformation.Kind.GORE -> "gore" } }, ) @@ -87,31 +87,31 @@ internal class SightEngineAnalyzer( ) } - val results = mutableMapOf() + val results = mutableMapOf() - if (ImageInformation.Kind.NUDITY in config.kinds) { + if (NoHornyInformation.Kind.NUDITY in config.kinds) { val percent = EXPLICIT_NUDITY_FIELDS.maxOf { json["nudity"]!!.jsonObject[it]!!.jsonPrimitive.float } - results[ImageInformation.Kind.NUDITY] = percent + results[NoHornyInformation.Kind.NUDITY] = percent } - if (ImageInformation.Kind.GORE in config.kinds) { + if (NoHornyInformation.Kind.GORE in config.kinds) { val percent = json["gore"]!!.jsonObject["prob"]!!.jsonPrimitive.float - results[ImageInformation.Kind.GORE] = percent + results[NoHornyInformation.Kind.GORE] = percent } val result = results.maxOfOrNull { it.value } ?: 0F val rating = when { - result > config.unsafeThreshold -> ImageInformation.Rating.UNSAFE - result > config.warningThreshold -> ImageInformation.Rating.WARNING - else -> ImageInformation.Rating.SAFE + result > config.unsafeThreshold -> NoHornyInformation.Rating.UNSAFE + result > config.warningThreshold -> NoHornyInformation.Rating.WARNING + else -> NoHornyInformation.Rating.SAFE } return@thenCompose CompletableFuture.completedFuture( - ImageInformation(rating, results), + NoHornyInformation(rating, results), ) } } diff --git a/src/main/kotlin/com/xpdustry/nohorny/cache/SQLCache.kt b/src/main/kotlin/com/xpdustry/nohorny/image/cache/H2ImageCache.kt similarity index 85% rename from src/main/kotlin/com/xpdustry/nohorny/cache/SQLCache.kt rename to src/main/kotlin/com/xpdustry/nohorny/image/cache/H2ImageCache.kt index 8691740..95fb067 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/cache/SQLCache.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/image/cache/H2ImageCache.kt @@ -23,21 +23,19 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny.cache +package com.xpdustry.nohorny.image.cache import arc.struct.IntSeq -import com.xpdustry.nohorny.NoHornyCoroutines -import com.xpdustry.nohorny.NoHornyImage import com.xpdustry.nohorny.NoHornyListener -import com.xpdustry.nohorny.analyzer.ImageInformation import com.xpdustry.nohorny.extension.resize -import com.xpdustry.nohorny.geometry.BlockGroup +import com.xpdustry.nohorny.geometry.IndexGroup +import com.xpdustry.nohorny.image.NoHornyImage +import com.xpdustry.nohorny.image.NoHornyInformation import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.future.future import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.awt.image.BufferedImage import java.nio.ByteBuffer import java.util.BitSet @@ -45,13 +43,11 @@ import java.util.concurrent.CompletableFuture import javax.sql.DataSource import kotlin.time.Duration.Companion.hours -@Suppress("SqlNoDataSourceInspection") -internal class SQLCache( - private val datasource: () -> DataSource, - private val coroutines: NoHornyCoroutines, -) : NoHornyCache, NoHornyListener { +internal class H2ImageCache( + private val datasource: DataSource, +) : ImageCache, NoHornyListener("Database", Dispatchers.IO) { override fun onInit() { - datasource().connection.use { connection -> + datasource.connection.use { connection -> connection.createStatement().use { statement -> statement.execute( """ @@ -89,7 +85,7 @@ internal class SQLCache( } } - coroutines.global.launch(Dispatchers.IO) { + scope.launch { while (isActive) { cleanup() delay(1.hours) @@ -98,13 +94,13 @@ internal class SQLCache( } override fun getResult( - group: BlockGroup, + group: IndexGroup, image: BufferedImage, - ): CompletableFuture = - coroutines.global.future(Dispatchers.IO) { + ): CompletableFuture = + scope.future { val hashes = computeBlockMeanHashRedundant(image) if (hashes.isEmpty()) return@future null - datasource().connection.use { connection -> + datasource.connection.use { connection -> connection.createStatement().use { statement -> statement.execute( """ @@ -130,7 +126,7 @@ internal class SQLCache( } val matched = IntSeq() - var info = ImageInformation.EMPTY + var info = NoHornyInformation.EMPTY connection.prepareStatement( """ SELECT @@ -155,7 +151,7 @@ internal class SQLCache( val identifier = result.getInt("image_id") matched.add(identifier) val temp = getInformation(identifier) - if (temp.rating > info.rating || info == ImageInformation.EMPTY) info = temp + if (temp.rating > info.rating || info == NoHornyInformation.EMPTY) info = temp } } } @@ -177,19 +173,19 @@ internal class SQLCache( } } - info.takeUnless { it == ImageInformation.EMPTY } + info.takeUnless { it == NoHornyInformation.EMPTY } } } override fun putResult( - group: BlockGroup, + group: IndexGroup, image: BufferedImage, - result: ImageInformation, + result: NoHornyInformation, ) { - coroutines.global.launch(Dispatchers.IO) { + scope.launch { val hashes = computeBlockMeanHashRedundant(image) if (hashes.isEmpty()) return@launch - datasource().connection.use { connection -> + datasource.connection.use { connection -> connection.prepareStatement( """ INSERT INTO @@ -247,7 +243,7 @@ internal class SQLCache( } private fun getInformation(id: Int) = - datasource().connection.use { connection -> + datasource.connection.use { connection -> val rating = connection.prepareStatement( """ @@ -261,16 +257,16 @@ internal class SQLCache( ).use { statement -> statement.setInt(1, id) statement.executeQuery().use { result -> - if (!result.next()) return ImageInformation.EMPTY + if (!result.next()) return NoHornyInformation.EMPTY try { - ImageInformation.Rating.valueOf(result.getString("rating")) + NoHornyInformation.Rating.valueOf(result.getString("rating")) } catch (e: IllegalArgumentException) { - return ImageInformation.EMPTY + return NoHornyInformation.EMPTY } } } - val details = mutableMapOf() + val details = mutableMapOf() connection.prepareStatement( """ SELECT @@ -286,7 +282,7 @@ internal class SQLCache( while (result.next()) { val kind = try { - ImageInformation.Kind.valueOf(result.getString("name")) + NoHornyInformation.Kind.valueOf(result.getString("name")) } catch (e: IllegalArgumentException) { continue } @@ -295,22 +291,20 @@ internal class SQLCache( } } - ImageInformation(rating, details) + NoHornyInformation(rating, details) } - private suspend fun cleanup() = - withContext(Dispatchers.IO) { - datasource().connection.use { connection -> - connection.prepareStatement( - """ - DELETE FROM - `nh_image` - WHERE - DATEDIFF('HOUR', `last_match`, CURRENT_TIMESTAMP()) >= 1 - """.trimIndent(), - ).use { statement -> - statement.executeUpdate() - } + private fun cleanup() = + datasource.connection.use { connection -> + connection.prepareStatement( + """ + DELETE FROM + `nh_image` + WHERE + DATEDIFF('HOUR', `last_match`, CURRENT_TIMESTAMP()) >= 1 + """.trimIndent(), + ).use { statement -> + statement.executeUpdate() } } diff --git a/src/main/kotlin/com/xpdustry/nohorny/cache/NoHornyCache.kt b/src/main/kotlin/com/xpdustry/nohorny/image/cache/ImageCache.kt similarity index 68% rename from src/main/kotlin/com/xpdustry/nohorny/cache/NoHornyCache.kt rename to src/main/kotlin/com/xpdustry/nohorny/image/cache/ImageCache.kt index 3f8728d..d61f8dd 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/cache/NoHornyCache.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/image/cache/ImageCache.kt @@ -23,36 +23,36 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package com.xpdustry.nohorny.cache +package com.xpdustry.nohorny.image.cache -import com.xpdustry.nohorny.NoHornyImage -import com.xpdustry.nohorny.analyzer.ImageInformation -import com.xpdustry.nohorny.geometry.BlockGroup +import com.xpdustry.nohorny.geometry.IndexGroup +import com.xpdustry.nohorny.image.NoHornyImage +import com.xpdustry.nohorny.image.NoHornyInformation import java.awt.image.BufferedImage import java.util.concurrent.CompletableFuture -public interface NoHornyCache { - public fun getResult( - group: BlockGroup, +internal interface ImageCache { + fun getResult( + group: IndexGroup, image: BufferedImage, - ): CompletableFuture + ): CompletableFuture - public fun putResult( - group: BlockGroup, + fun putResult( + group: IndexGroup, image: BufferedImage, - result: ImageInformation, + result: NoHornyInformation, ) - public data object None : NoHornyCache { + data object None : ImageCache { override fun getResult( - group: BlockGroup, + group: IndexGroup, image: BufferedImage, - ): CompletableFuture = CompletableFuture.completedFuture(null) + ): CompletableFuture = CompletableFuture.completedFuture(null) override fun putResult( - group: BlockGroup, + group: IndexGroup, image: BufferedImage, - result: ImageInformation, + result: NoHornyInformation, ): Unit = Unit } } diff --git a/src/main/kotlin/com/xpdustry/nohorny/tracker/CanvasesTracker.kt b/src/main/kotlin/com/xpdustry/nohorny/tracker/CanvasesTracker.kt index 96b8a3b..8e33dbc 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/tracker/CanvasesTracker.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/tracker/CanvasesTracker.kt @@ -29,24 +29,26 @@ import arc.graphics.Color import arc.math.geom.Point2 import arc.struct.IntIntMap import arc.struct.IntMap -import com.xpdustry.nohorny.NoHornyImage +import com.xpdustry.nohorny.NoHornyConfig import com.xpdustry.nohorny.NoHornyListener -import com.xpdustry.nohorny.NoHornyPlugin import com.xpdustry.nohorny.extension.asAuthor import com.xpdustry.nohorny.extension.onBuildingLifecycleEvent import com.xpdustry.nohorny.extension.onEvent import com.xpdustry.nohorny.extension.rx import com.xpdustry.nohorny.extension.ry -import com.xpdustry.nohorny.geometry.BlockGroup import com.xpdustry.nohorny.geometry.GroupingBlockIndex +import com.xpdustry.nohorny.geometry.IndexGroup +import com.xpdustry.nohorny.image.ImageProcessor +import com.xpdustry.nohorny.image.NoHornyImage +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mindustry.game.EventType import mindustry.world.blocks.logic.CanvasBlock import java.util.concurrent.atomic.AtomicReference import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.toJavaDuration internal data class CanvasesConfig( val minimumGroupSize: Int = 9, @@ -56,10 +58,13 @@ internal data class CanvasesConfig( } } -internal class CanvasesTracker(private val plugin: NoHornyPlugin) : NoHornyListener { +internal class CanvasesTracker( + private val config: () -> NoHornyConfig, + private val processor: ImageProcessor, +) : NoHornyListener("Canvases Tracker", Dispatchers.Default.limitedParallelism(1)) { private val canvases = GroupingBlockIndex.create() private val marked = IntMap() - private val groups: AtomicReference>> = AtomicReference(emptyList()) + private val groups: AtomicReference>> = AtomicReference(emptyList()) override fun onInit() { onBuildingLifecycleEvent( @@ -69,8 +74,8 @@ internal class CanvasesTracker(private val plugin: NoHornyPlugin) : NoHornyListe val y = canvas.ry val size = canvas.block.size val pixels = readCanvas(canvas) - plugin.coroutines.canvases.launch { - canvases.upsert( + scope.launch { + canvases.insert( x, y, size, @@ -82,7 +87,7 @@ internal class CanvasesTracker(private val plugin: NoHornyPlugin) : NoHornyListe } }, remove = { x, y -> - plugin.coroutines.canvases.launch { + scope.launch { canvases.remove(x, y) marked.remove(Point2.pack(x, y)) } @@ -90,33 +95,40 @@ internal class CanvasesTracker(private val plugin: NoHornyPlugin) : NoHornyListe ) onEvent { - plugin.coroutines.displays.launch { + scope.launch { canvases.removeAll() marked.clear() } } - plugin.coroutines.global.launch { - while (isActive) { - delay(plugin.config.processingDelay.toJavaDuration().toMillis()) - plugin.coroutines.canvases.launch { - groups.set(canvases.groups().toList()) - for (group in groups.get()) { - val now = System.currentTimeMillis() - val lastMod = - group.blocks.asSequence() - .mapNotNull { marked.get(Point2.pack(it.x, it.y)) } - .maxOrNull() - ?: now - val elapsed = (now - lastMod).milliseconds - if ( - elapsed > plugin.config.processingDelay / 2 && - group.blocks.size >= plugin.config.canvases.minimumGroupSize - ) { - plugin.process(group) - for (block in group.blocks) marked.remove(Point2.pack(block.x, block.y)) - } - } + scope.launch { + withContext(Dispatchers.Default) { + while (isActive) { + delay(config().processingDelay) + scope.launch { update() } + } + } + } + } + + private fun update() { + val config = config() + groups.set(canvases.groups().toList()) + for (group in groups.get()) { + val now = System.currentTimeMillis() + val lastMod = + group.blocks.asSequence() + .mapNotNull { marked.get(Point2.pack(it.x, it.y)) } + .maxOrNull() + ?: now + val elapsed = (now - lastMod).milliseconds + if ( + elapsed > config.processingDelay / 2 && + group.blocks.size >= config.canvases.minimumGroupSize + ) { + processor.process(group) + for (block in group.blocks) { + marked.remove(Point2.pack(block.x, block.y)) } } } diff --git a/src/main/kotlin/com/xpdustry/nohorny/tracker/DisplaysTracker.kt b/src/main/kotlin/com/xpdustry/nohorny/tracker/DisplaysTracker.kt index 73b0038..d22cf21 100644 --- a/src/main/kotlin/com/xpdustry/nohorny/tracker/DisplaysTracker.kt +++ b/src/main/kotlin/com/xpdustry/nohorny/tracker/DisplaysTracker.kt @@ -28,21 +28,24 @@ package com.xpdustry.nohorny.tracker import arc.math.geom.Point2 import arc.struct.IntMap import com.google.common.collect.ImmutableMap -import com.xpdustry.nohorny.NoHornyImage +import com.xpdustry.nohorny.NoHornyConfig import com.xpdustry.nohorny.NoHornyListener -import com.xpdustry.nohorny.NoHornyPlugin import com.xpdustry.nohorny.extension.asAuthor import com.xpdustry.nohorny.extension.onBuildingLifecycleEvent import com.xpdustry.nohorny.extension.onEvent import com.xpdustry.nohorny.extension.rx import com.xpdustry.nohorny.extension.ry -import com.xpdustry.nohorny.geometry.BlockGroup import com.xpdustry.nohorny.geometry.GroupingBlockIndex import com.xpdustry.nohorny.geometry.GroupingFunction import com.xpdustry.nohorny.geometry.ImmutablePoint +import com.xpdustry.nohorny.geometry.IndexGroup +import com.xpdustry.nohorny.image.ImageProcessor +import com.xpdustry.nohorny.image.NoHornyImage +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import mindustry.Vars import mindustry.game.EventType import mindustry.logic.LExecutor @@ -63,11 +66,14 @@ internal data class DisplaysConfig( } } -internal class DisplaysTracker(private val plugin: NoHornyPlugin) : NoHornyListener { +internal class DisplaysTracker( + private val config: () -> NoHornyConfig, + private val processor: ImageProcessor, +) : NoHornyListener("Display Tracker", Dispatchers.Default.limitedParallelism(1)) { private val processors = GroupingBlockIndex.create(GroupingFunction.single()) private val displays = GroupingBlockIndex.create() private val marked = IntMap() - private val groups: AtomicReference>> = AtomicReference(emptyList()) + private val groups: AtomicReference>> = AtomicReference(emptyList()) override fun onInit() { onBuildingLifecycleEvent( @@ -75,7 +81,7 @@ internal class DisplaysTracker(private val plugin: NoHornyPlugin) : NoHornyListe val resolution = (display.block as LogicDisplay).displaySize val map = ImmutableMap.builder() - val config = plugin.config.displays + val config = config().displays for ((x, y, _, data) in processors.select( display.tileX() - (config.processorSearchRadius / 2), display.tileY() - (config.processorSearchRadius / 2), @@ -103,13 +109,13 @@ internal class DisplaysTracker(private val plugin: NoHornyPlugin) : NoHornyListe val y = display.ry val size = display.block.size - plugin.coroutines.displays.launch { - displays.upsert(x, y, size, NoHornyImage.Display(resolution, map.build())) + scope.launch { + displays.insert(x, y, size, NoHornyImage.Display(resolution, map.build())) marked.put(Point2.pack(display.rx, display.ry), System.currentTimeMillis()) } }, remove = { x, y -> - plugin.coroutines.displays.launch { + scope.launch { displays.remove(x, y) marked.remove(Point2.pack(x, y)) } @@ -121,7 +127,7 @@ internal class DisplaysTracker(private val plugin: NoHornyPlugin) : NoHornyListe val instructions = readInstructions(processor.executor) val links = processor.links.select { it.active }.map { ImmutablePoint(it.x, it.y) } - if (instructions.size < plugin.config.displays.minimumInstructionCount || links.isEmpty) { + if (instructions.size < config().displays.minimumInstructionCount || links.isEmpty) { return@onBuildingLifecycleEvent } @@ -131,11 +137,11 @@ internal class DisplaysTracker(private val plugin: NoHornyPlugin) : NoHornyListe val y = processor.ry val size = processor.block.size - plugin.coroutines.displays.launch { - processors.upsert(x, y, size, data) + scope.launch { + processors.insert(x, y, size, data) for (link in links) { val element = displays.select(link.x, link.y) ?: continue - displays.upsert( + displays.insert( element.x, element.y, element.size, @@ -152,12 +158,12 @@ internal class DisplaysTracker(private val plugin: NoHornyPlugin) : NoHornyListe } }, remove = { x, y -> - plugin.coroutines.displays.launch { + scope.launch { val point = ImmutablePoint(x, y) val block = processors.remove(x, y) ?: return@launch for (link in block.data.links) { val element = displays.select(link.x, link.y) ?: continue - displays.upsert( + displays.insert( element.x, element.y, element.size, @@ -172,33 +178,38 @@ internal class DisplaysTracker(private val plugin: NoHornyPlugin) : NoHornyListe ) onEvent { - plugin.coroutines.displays.launch { + scope.launch { processors.removeAll() displays.removeAll() marked.clear() } } - plugin.coroutines.global.launch { - while (isActive) { - delay(plugin.config.processingDelay) - plugin.coroutines.displays.launch { - groups.set(displays.groups().toList()) - for (group in groups.get()) { - val now = System.currentTimeMillis() - val lastMod = - group.blocks.asSequence() - .mapNotNull { marked.get(Point2.pack(it.x, it.y)) } - .maxOrNull() - ?: now - val elapsed = (now - lastMod).milliseconds - if (elapsed > (plugin.config.processingDelay / 2) && - group.blocks.sumOf { it.data.processors.size } >= plugin.config.displays.minimumProcessorCount - ) { - plugin.process(group) - for (block in group.blocks) marked.remove(Point2.pack(block.x, block.y)) - } - } + scope.launch { + withContext(Dispatchers.Default) { + while (isActive) { + delay(config().processingDelay) + scope.launch { update() } + } + } + } + } + + private fun update() { + val config = config() + groups.set(displays.groups().toList()) + for (group in groups.get()) { + val now = System.currentTimeMillis() + val lastMod = + group.blocks.asSequence().mapNotNull { marked.get(Point2.pack(it.x, it.y)) }.maxOrNull() + ?: now + val elapsed = (now - lastMod).milliseconds + if (elapsed > (config.processingDelay / 2) && + group.blocks.sumOf { it.data.processors.size } >= config.displays.minimumProcessorCount + ) { + processor.process(group) + for (block in group.blocks) { + marked.remove(Point2.pack(block.x, block.y)) } } } diff --git a/src/test/kotlin/com/xpdustry/nohorny/geometry/GroupingBlockIndexImplTest.kt b/src/test/kotlin/com/xpdustry/nohorny/geometry/GroupingBlockIndexImplTest.kt index 622f4cf..b25cd33 100644 --- a/src/test/kotlin/com/xpdustry/nohorny/geometry/GroupingBlockIndexImplTest.kt +++ b/src/test/kotlin/com/xpdustry/nohorny/geometry/GroupingBlockIndexImplTest.kt @@ -29,11 +29,18 @@ import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Test class GroupingBlockIndexImplTest { + @Test + fun `test ignore occupied`() { + val index = createIndex() + Assertions.assertTrue(index.insert(0, 0, 1, Unit)) + Assertions.assertFalse(index.insert(0, 0, 1, Unit)) + } + @Test fun `test blocks that share a side`() { val index = createIndex() - index.upsert(0, 0, 1, Unit) - index.upsert(1, 0, 1, Unit) + index.insert(0, 0, 1, Unit) + index.insert(1, 0, 1, Unit) Assertions.assertEquals(1, index.groups().size) val cluster = index.groups().toList()[0] @@ -47,25 +54,25 @@ class GroupingBlockIndexImplTest { @Test fun `test blocks that do not share a side`() { val index = createIndex() - index.upsert(2, 2, 2, Unit) - index.upsert(-2, 0, 1, Unit) - index.upsert(10, 10, 10, Unit) + index.insert(2, 2, 2, Unit) + index.insert(-2, 0, 1, Unit) + index.insert(10, 10, 10, Unit) Assertions.assertEquals(3, index.groups().size) } @Test fun `test blocks that partially share a side`() { val index = createIndex() - index.upsert(1, 1, 2, Unit) - index.upsert(3, 2, 2, Unit) + index.insert(1, 1, 2, Unit) + index.insert(3, 2, 2, Unit) Assertions.assertEquals(1, index.groups().size) } @Test fun `test blocks that only share a corner`() { val index = createIndex() - index.upsert(0, 0, 1, Unit) - index.upsert(1, 1, 1, Unit) + index.insert(0, 0, 1, Unit) + index.insert(1, 1, 1, Unit) Assertions.assertEquals(2, index.groups().size) } @@ -74,7 +81,7 @@ class GroupingBlockIndexImplTest { val index = createIndex() for (x in 0..2) { for (y in 0..5) { - index.upsert(x, y, 1, Unit) + index.insert(x, y, 1, Unit) } } @@ -93,7 +100,7 @@ class GroupingBlockIndexImplTest { val index = createIndex() for (x in 0..4) { for (y in 0..4) { - index.upsert(x, y, 1, Unit) + index.insert(x, y, 1, Unit) } } @@ -116,9 +123,9 @@ class GroupingBlockIndexImplTest { fun `test cluster split`() { val index = createIndex() for (x in 0..2) { - index.upsert(x, 0, 1, Unit) + index.insert(x, 0, 1, Unit) } - index.upsert(1, 1, 1, Unit) + index.insert(1, 1, 1, Unit) Assertions.assertEquals(1, index.groups().size) index.remove(1, 0) Assertions.assertEquals(3, index.groups().size) @@ -129,32 +136,23 @@ class GroupingBlockIndexImplTest { val index = createIndex() for (y in 0..2) { for (x in 0..2) { - index.upsert(x, y * 2, 1, Unit) + index.insert(x, y * 2, 1, Unit) } } Assertions.assertEquals(3, index.groups().size) - index.upsert(1, 1, 1, Unit) + index.insert(1, 1, 1, Unit) Assertions.assertEquals(2, index.groups().size) - index.upsert(1, 3, 1, Unit) + index.insert(1, 3, 1, Unit) Assertions.assertEquals(1, index.groups().size) } - /* - @Test - fun `test error on add to occupied`() { - val index = createIndex() - index.upsert(0, 0, 1, Unit) - assertThrows { index.upsert(createBlock(0, 0, 1)) } - } - */ - @Test fun `test cluster on same axis spaced by 1`() { val index = createIndex() - index.upsert(0, 0, 6, Unit) - index.upsert(7, 0, 6, Unit) - index.upsert(0, 7, 6, Unit) - index.upsert(7, 7, 6, Unit) + index.insert(0, 0, 6, Unit) + index.insert(7, 0, 6, Unit) + index.insert(0, 7, 6, Unit) + index.insert(7, 7, 6, Unit) Assertions.assertEquals(4, index.groups().size) }