events-server: fix tile grouping, chunking, saving

- debounce chunk and tile updates
- make the tile output-dir configurable
- basic test for manual checking
This commit is contained in:
Adam 2022-05-01 15:50:44 +02:00
parent 99d5dec088
commit ee3c7956cd
8 changed files with 370 additions and 80 deletions

View file

@ -134,9 +134,27 @@ fun MapChunkPosition.leftTopTile(chunkSize: Int = MAP_CHUNK_SIZE): MapTilePositi
y * chunkSize, y * chunkSize,
) )
fun MapChunkPosition.rightBottomTile(chunkSize: Int = MAP_CHUNK_SIZE): MapTilePosition =
leftTopTile(chunkSize) + (chunkSize - 1)
fun MapChunkPosition.iterateTiles(chunkSize: Int = MAP_CHUNK_SIZE): Iterator<MapTilePosition> {
val leftTop = leftTopTile(chunkSize)
val rightBottom = rightBottomTile(chunkSize)
return iterator {
(leftTop.x..rightBottom.x).forEach { x ->
(leftTop.y..rightBottom.y).forEach { y ->
yield(MapTilePosition(x, y))
}
}
}
}
//val MapChunkPosition.rightBottomTile: MapTilePosition //val MapChunkPosition.rightBottomTile: MapTilePosition
// get() = leftTopTile + (MAP_CHUNK_SIZE - 1) // get() = leftTopTile + (MAP_CHUNK_SIZE - 1)
//
// //
//val MapChunkPosition.boundingBox: MapBoundingBox //val MapChunkPosition.boundingBox: MapBoundingBox
// get() = MapBoundingBox(leftTopTile, rightBottomTile) // get() = MapBoundingBox(leftTopTile, rightBottomTile)

View file

@ -61,7 +61,7 @@ internal class KafkatorioTopology(
private fun saveTiles() { private fun saveTiles() {
val builder = StreamsBuilder() val builder = StreamsBuilder()
// val topology = saveMapTiles(builder) // val topology = saveMapTiles(builder)
val topology = saveMapTiles2(builder) val topology = saveMapTiles2(builder, appProps.webmapTileDir)
launchTopology("saveTiles", topology) launchTopology("saveTiles", topology)
} }

View file

@ -17,11 +17,18 @@ private const val DOMAIN = "kafkatorio"
const val TOPIC_SRC_SERVER_LOG = "$DOMAIN.src.server-log" const val TOPIC_SRC_SERVER_LOG = "$DOMAIN.src.server-log"
const val TOPIC_GROUPED_MAP_CHUNKS_STATE = "$DOMAIN.state.map-chunks.grouped" const val TOPIC_GROUPED_MAP_CHUNKS_STATE = "$DOMAIN.state.map-chunks.grouped"
const val TOPIC_GROUPED_MAP_CHUNKS_STATE_DEBOUNCED = "$TOPIC_GROUPED_MAP_CHUNKS_STATE.debounced"
const val TOPIC_SUBDIVIDED_MAP_TILES = "$DOMAIN.map-tiles.subdivided"
const val TOPIC_SUBDIVIDED_MAP_TILES_DEBOUNCED = "$TOPIC_SUBDIVIDED_MAP_TILES.debounced"
fun allTopics(): Set<String> = buildSet { fun allTopics(): Set<String> = buildSet {
add(TOPIC_SRC_SERVER_LOG) add(TOPIC_SRC_SERVER_LOG)
add(TOPIC_GROUPED_MAP_CHUNKS_STATE) add(TOPIC_GROUPED_MAP_CHUNKS_STATE)
add(TOPIC_GROUPED_MAP_CHUNKS_STATE_DEBOUNCED)
add(TOPIC_SUBDIVIDED_MAP_TILES)
add(TOPIC_SUBDIVIDED_MAP_TILES_DEBOUNCED)
addAll(packetTopicNames.values) addAll(packetTopicNames.values)
} }

View file

@ -4,6 +4,7 @@ import com.sksamuel.scrimage.ImmutableImage
import com.sksamuel.scrimage.ScaleMethod import com.sksamuel.scrimage.ScaleMethod
import com.sksamuel.scrimage.color.RGBColor import com.sksamuel.scrimage.color.RGBColor
import com.sksamuel.scrimage.nio.PngWriter import com.sksamuel.scrimage.nio.PngWriter
import dev.adamko.kafkatorio.processor.KafkatorioTopology.Companion.rootJob
import dev.adamko.kafkatorio.processor.admin.TOPIC_GROUPED_MAP_CHUNKS_STATE import dev.adamko.kafkatorio.processor.admin.TOPIC_GROUPED_MAP_CHUNKS_STATE
import dev.adamko.kafkatorio.processor.serdes.kxsBinary import dev.adamko.kafkatorio.processor.serdes.kxsBinary
import dev.adamko.kafkatorio.schema.common.ColourHex import dev.adamko.kafkatorio.schema.common.ColourHex
@ -82,8 +83,7 @@ value class TilePngFilename(
val value: String, val value: String,
) { ) {
constructor(id: ServerMapChunkId) : this(buildString { constructor(id: ServerMapChunkId) : this(buildString {
append("src/main/resources/kafkatorio-web-map") append("s${id.surfaceIndex}")
append("/s${id.surfaceIndex}")
append("/z${id.chunkSize.zoomLevel}") append("/z${id.chunkSize.zoomLevel}")
append("/x${id.chunkPosition.x}") append("/x${id.chunkPosition.x}")
append("/y${id.chunkPosition.y}") append("/y${id.chunkPosition.y}")
@ -198,7 +198,7 @@ private class ServerMapChunkHandler : CoroutineScope {
.tiles .tiles
.entries .entries
.groupBy( .groupBy(
{ (tile, _) -> tile.toMapChunkPosition(chunkSize.tilesPerChunk) } { (tile, _) -> tile.toMapChunkPosition(chunkSize.lengthInTiles) }
) { (tilePosition, colour) -> ) { (tilePosition, colour) ->
tilePosition to colour tilePosition to colour
} }
@ -245,14 +245,14 @@ private class ServerMapChunkHandler : CoroutineScope {
): ImmutableImage { ): ImmutableImage {
val chunkImage = ImmutableImage.filled( val chunkImage = ImmutableImage.filled(
chunkSize.tilesPerChunk, chunkSize.lengthInTiles,
chunkSize.tilesPerChunk, chunkSize.lengthInTiles,
TRANSPARENT_AWT, TRANSPARENT_AWT,
BufferedImage.TYPE_INT_ARGB BufferedImage.TYPE_INT_ARGB
) )
val chunkOriginX: Int = chunkPosition.x * chunkSize.tilesPerChunk val chunkOriginX: Int = chunkPosition.x * chunkSize.lengthInTiles
val chunkOriginY: Int = chunkPosition.y * chunkSize.tilesPerChunk val chunkOriginY: Int = chunkPosition.y * chunkSize.lengthInTiles
chunkColours.forEach { (tilePosition, colour) -> chunkColours.forEach { (tilePosition, colour) ->

View file

@ -3,18 +3,25 @@ package dev.adamko.kafkatorio.processor.topology
import com.sksamuel.scrimage.ImmutableImage import com.sksamuel.scrimage.ImmutableImage
import com.sksamuel.scrimage.ScaleMethod import com.sksamuel.scrimage.ScaleMethod
import com.sksamuel.scrimage.nio.PngWriter import com.sksamuel.scrimage.nio.PngWriter
import dev.adamko.kafkatorio.processor.admin.TOPIC_GROUPED_MAP_CHUNKS_STATE import dev.adamko.kafkatorio.processor.admin.TOPIC_GROUPED_MAP_CHUNKS_STATE_DEBOUNCED
import dev.adamko.kafkatorio.processor.admin.TOPIC_SUBDIVIDED_MAP_TILES
import dev.adamko.kafkatorio.processor.admin.TOPIC_SUBDIVIDED_MAP_TILES_DEBOUNCED
import dev.adamko.kafkatorio.processor.misc.DebounceProcessor.Companion.addDebounceProcessor
import dev.adamko.kafkatorio.processor.serdes.kxsBinary import dev.adamko.kafkatorio.processor.serdes.kxsBinary
import dev.adamko.kafkatorio.schema.common.ColourHex import dev.adamko.kafkatorio.schema.common.ColourHex
import dev.adamko.kafkatorio.schema.common.MapTilePosition
import dev.adamko.kafkatorio.schema.common.toMapChunkPosition import dev.adamko.kafkatorio.schema.common.toMapChunkPosition
import dev.adamko.kotka.extensions.consumedAs import dev.adamko.kotka.extensions.consumedAs
import dev.adamko.kotka.extensions.producedAs
import dev.adamko.kotka.extensions.streams.flatMap import dev.adamko.kotka.extensions.streams.flatMap
import dev.adamko.kotka.extensions.streams.forEach import dev.adamko.kotka.extensions.streams.forEach
import dev.adamko.kotka.kxs.serde import dev.adamko.kotka.kxs.serde
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.File import java.nio.file.Path
import kotlin.io.path.fileSize import kotlin.io.path.fileSize
import kotlin.math.abs import kotlin.math.abs
import kotlin.time.Duration.Companion.seconds
import kotlin.time.measureTimedValue
import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.StreamsBuilder
import org.apache.kafka.streams.Topology import org.apache.kafka.streams.Topology
import org.apache.kafka.streams.kstream.KStream import org.apache.kafka.streams.kstream.KStream
@ -22,78 +29,130 @@ import org.apache.kafka.streams.kstream.KStream
private const val WEB_MAP_TILE_SIZE_PX = 256 private const val WEB_MAP_TILE_SIZE_PX = 256
/**
* @param[tileDirectory] [dev.adamko.kafkatorio.processor.config.ApplicationProperties.webmapTileDir]
*/
fun saveMapTiles2( fun saveMapTiles2(
builder: StreamsBuilder, builder: StreamsBuilder,
tileDirectory: Path,
): Topology { ): Topology {
val pid = "saveMapTiles" val pid = "saveMapTiles"
val groupedMapChunkTiles: KStream<ServerMapChunkId, ServerMapChunkTiles<ColourHex>> = val groupedMapChunkTiles: KStream<ServerMapChunkId, ServerMapChunkTiles<ColourHex>> =
builder.stream( builder.stream(
TOPIC_GROUPED_MAP_CHUNKS_STATE, TOPIC_GROUPED_MAP_CHUNKS_STATE_DEBOUNCED,
consumedAs("$pid.consume.grouped-map-chunks", kxsBinary.serde(), kxsBinary.serde()) consumedAs("$pid.consume.grouped-map-chunks", kxsBinary.serde(), kxsBinary.serde()),
) )
groupedMapChunkTiles.flatMap("$pid.subdivide-chunks") { initialKey, initialChunk -> groupedMapChunkTiles.flatMap("$pid.subdivide-chunks") { initialKey, initialChunk ->
ChunkSize.entries.flatMap { chunkSize ->
initialChunk.map.entries
.groupBy(
{ (tile, _) -> tile.toMapChunkPosition(chunkSize.tilesPerChunk) }
) { (tilePosition, colour) ->
tilePosition to colour
}
.map { (chunkPos, tiles) ->
val key = initialKey.copy(
chunkSize = chunkSize,
chunkPosition = chunkPos
)
val value = ServerMapChunkTiles(key, tiles.toMap()) val (chunks, time) = measureTimedValue {
ChunkSize.entries.flatMap { chunkSize ->
initialChunk.map
.entries
.groupingBy { (tilePosition, _) ->
val chunkPosition = tilePosition.toMapChunkPosition(chunkSize.lengthInTiles)
// println("$tilePosition groupingBy $chunkPosition")
initialKey.copy(
chunkSize = chunkSize,
chunkPosition = chunkPosition,
)
}.fold(
initialValueSelector = { _, _ -> mutableMapOf<MapTilePosition, ColourHex>() }
) { _, accumulator, (tilePosition, colour) ->
// println("fold $tilePosition $colour")
accumulator[tilePosition] = colour
accumulator
}
.map { (chunkId, tiles) ->
// println("map $chunkId ${tiles.size}")
chunkId to ServerMapChunkTiles(chunkId, tiles)
}
}
key to value
}
} }
}.forEach("$pid.save-tiles") { key, value -> println("subdivided ${initialKey.chunkPosition} to ${chunks.size} chunks in $time")
val filename = TilePngFilename(key)
val chunkColours = value.map
val chunkImage = ImmutableImage.filled( chunks
key.chunkSize.tilesPerChunk, }.to(
key.chunkSize.tilesPerChunk, TOPIC_SUBDIVIDED_MAP_TILES,
TRANSPARENT_AWT, producedAs(
BufferedImage.TYPE_INT_ARGB "$pid.produce.grouped-map-chunks",
kxsBinary.serde(),
kxsBinary.serde(),
)
)
val subdividedMapChunkTilesDebounced: KStream<ServerMapChunkId, ServerMapChunkTiles<ColourHex>> =
builder.stream(
TOPIC_SUBDIVIDED_MAP_TILES_DEBOUNCED,
consumedAs(
"$pid.consume.debounced-subdivided-chunks",
kxsBinary.serde(),
kxsBinary.serde(),
),
) )
val chunkOriginX: Int = key.chunkPosition.x * key.chunkSize.tilesPerChunk subdividedMapChunkTilesDebounced
val chunkOriginY: Int = key.chunkPosition.y * key.chunkSize.tilesPerChunk .forEach("$pid.save-tiles") { chunkId: ServerMapChunkId, tileColours: ServerMapChunkTiles<ColourHex> ->
chunkColours.forEach { (tilePosition, colour) -> val chunkImage = ImmutableImage.filled(
val rgbColour = colour.toRgbColor() chunkId.chunkSize.lengthInTiles,
chunkId.chunkSize.lengthInTiles,
TRANSPARENT_AWT,
BufferedImage.TYPE_INT_ARGB
)
val pixelX = abs(abs(tilePosition.x) - abs(chunkOriginX)) val chunkOriginX: Int = chunkId.chunkPosition.x * chunkId.chunkSize.lengthInTiles
val pixelY = abs(abs(tilePosition.y) - abs(chunkOriginY)) val chunkOriginY: Int = chunkId.chunkPosition.y * chunkId.chunkSize.lengthInTiles
chunkImage.setColor(pixelX, pixelY, rgbColour) tileColours.map.forEach { (tilePosition, colour) ->
val rgbColour = colour.toRgbColor()
val pixelX = abs(abs(tilePosition.x) - abs(chunkOriginX))
val pixelY = abs(abs(tilePosition.y) - abs(chunkOriginY))
chunkImage.setColor(pixelX, pixelY, rgbColour)
}
val scaledImage =
chunkImage.scaleTo(WEB_MAP_TILE_SIZE_PX, WEB_MAP_TILE_SIZE_PX, ScaleMethod.FastScale)
val filename = TilePngFilename(chunkId)
saveTile(filename, tileDirectory, scaledImage)
} }
val img = chunkImage.scaleTo(WEB_MAP_TILE_SIZE_PX, WEB_MAP_TILE_SIZE_PX, ScaleMethod.FastScale)
val chunkImageFile = File(filename.value)
if (chunkImageFile.parentFile.mkdirs()) {
println("created new map tile parentFile directory ${chunkImageFile.absolutePath}")
}
val sizeBefore = chunkImageFile.takeIf { it.exists() }?.toPath()?.fileSize()
val savedTile = img.output(PngWriter.NoCompression, chunkImageFile)
val sizeAfter = savedTile.takeIf { it.exists() }?.toPath()?.fileSize()
if (sizeBefore != sizeAfter) {
// println("savedTile $sizeBefore/$sizeAfter: $savedTile")
} else {
println("savedTile NO-SIZE-CHANGE $sizeBefore/$sizeAfter: $savedTile")
}
}
return builder.build() return builder.build()
.addDebounceProcessor<ServerMapChunkId, ServerMapChunkTiles<ColourHex>>(
namePrefix = pid,
sourceTopic = TOPIC_SUBDIVIDED_MAP_TILES,
sinkTopic = TOPIC_SUBDIVIDED_MAP_TILES_DEBOUNCED,
inactivityDuration = 15.seconds,
keySerde = kxsBinary.serde(),
valueSerde = kxsBinary.serde(),
)
}
@Synchronized
fun saveTile(
filename: TilePngFilename,
tileDirectory: Path,
img: ImmutableImage,
) {
val chunkImageFile = tileDirectory.resolve(filename.value).toFile()
if (chunkImageFile.parentFile.mkdirs()) {
println("created new map tile parentFile directory ${chunkImageFile.absolutePath}")
}
val sizeBefore = chunkImageFile.takeIf { it.exists() }?.toPath()?.fileSize()
val savedTile = img.output(PngWriter.NoCompression, chunkImageFile)
val sizeAfter = savedTile.takeIf { it.exists() }?.toPath()?.fileSize()
val sizeDescription = if (sizeBefore != sizeAfter && sizeBefore != null) {
"$sizeBefore/$sizeAfter"
} else {
"$sizeAfter"
}
println("savedTile $sizeDescription: $savedTile")
} }

View file

@ -1,6 +1,8 @@
package dev.adamko.kafkatorio.processor.topology package dev.adamko.kafkatorio.processor.topology
import dev.adamko.kafkatorio.processor.admin.TOPIC_GROUPED_MAP_CHUNKS_STATE import dev.adamko.kafkatorio.processor.admin.TOPIC_GROUPED_MAP_CHUNKS_STATE
import dev.adamko.kafkatorio.processor.admin.TOPIC_GROUPED_MAP_CHUNKS_STATE_DEBOUNCED
import dev.adamko.kafkatorio.processor.misc.DebounceProcessor.Companion.addDebounceProcessor
import dev.adamko.kafkatorio.processor.serdes.kxsBinary import dev.adamko.kafkatorio.processor.serdes.kxsBinary
import dev.adamko.kafkatorio.schema.common.ColourHex import dev.adamko.kafkatorio.schema.common.ColourHex
import dev.adamko.kafkatorio.schema.common.MapChunkPosition import dev.adamko.kafkatorio.schema.common.MapChunkPosition
@ -13,7 +15,7 @@ import dev.adamko.kafkatorio.schema.common.toMapChunkPosition
import dev.adamko.kafkatorio.schema.packets.MapChunkUpdate import dev.adamko.kafkatorio.schema.packets.MapChunkUpdate
import dev.adamko.kafkatorio.schema.packets.PrototypesUpdate import dev.adamko.kafkatorio.schema.packets.PrototypesUpdate
import dev.adamko.kotka.extensions.groupedAs import dev.adamko.kotka.extensions.groupedAs
import dev.adamko.kotka.extensions.materializedWith import dev.adamko.kotka.extensions.materializedAs
import dev.adamko.kotka.extensions.producedAs import dev.adamko.kotka.extensions.producedAs
import dev.adamko.kotka.extensions.repartitionedAs import dev.adamko.kotka.extensions.repartitionedAs
import dev.adamko.kotka.extensions.streams.filter import dev.adamko.kotka.extensions.streams.filter
@ -27,6 +29,7 @@ import dev.adamko.kotka.extensions.tables.toStream
import dev.adamko.kotka.kxs.serde import dev.adamko.kotka.kxs.serde
import kotlin.collections.component1 import kotlin.collections.component1
import kotlin.collections.component2 import kotlin.collections.component2
import kotlin.time.Duration.Companion.seconds
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import org.apache.kafka.streams.StreamsBuilder import org.apache.kafka.streams.StreamsBuilder
import org.apache.kafka.streams.Topology import org.apache.kafka.streams.Topology
@ -82,12 +85,20 @@ fun groupMapChunks(builder: StreamsBuilder): Topology {
TOPIC_GROUPED_MAP_CHUNKS_STATE, TOPIC_GROUPED_MAP_CHUNKS_STATE,
producedAs( producedAs(
"$pid.grouped-map-chunks", "$pid.grouped-map-chunks",
kxsBinary.serde<ServerMapChunkId>(), kxsBinary.serde(),
kxsBinary.serde<ServerMapChunkTiles<ColourHex>>() kxsBinary.serde(),
) )
) )
return builder.build() return builder.build()
.addDebounceProcessor<ServerMapChunkId, ServerMapChunkTiles<ColourHex>>(
namePrefix = pid,
sourceTopic = TOPIC_GROUPED_MAP_CHUNKS_STATE,
sinkTopic = TOPIC_GROUPED_MAP_CHUNKS_STATE_DEBOUNCED,
inactivityDuration = 15.seconds,
keySerde = kxsBinary.serde(),
valueSerde = kxsBinary.serde(),
)
} }
@ -122,7 +133,7 @@ private fun groupTilesIntoChunksWithColours(
mapTiles mapTiles
.tiles .tiles
.groupBy { tile -> .groupBy { tile ->
tile.position.toMapChunkPosition(standardChunkSize.tilesPerChunk) tile.position.toMapChunkPosition(standardChunkSize.lengthInTiles)
} }
.map { (chunkPos, tiles) -> .map { (chunkPos, tiles) ->
@ -165,9 +176,8 @@ private fun groupTilesIntoChunksWithColours(
) )
.reduce( .reduce(
"server-map-data.tiles.reduce", "server-map-data.tiles.reduce",
materializedWith( materializedAs(
// materializedAs( "server-map-data.tiles.reduce.store",
// "server-map-data.tiles.reduce.store",
kxsBinary.serde<ServerMapChunkId>(), kxsBinary.serde<ServerMapChunkId>(),
kxsBinary.serde<ServerMapChunkTiles<TileProtoHashCode>>(), kxsBinary.serde<ServerMapChunkTiles<TileProtoHashCode>>(),
) )
@ -185,10 +195,10 @@ private fun groupTilesIntoChunksWithColours(
.join( .join(
other = tileProtoColourDict, other = tileProtoColourDict,
tableJoined = tableJoined("server-map-data.join-tiles-with-prototypes"), tableJoined = tableJoined("server-map-data.join-tiles-with-prototypes"),
materialized = materializedWith( materialized = materializedAs(
// "server-map-data.join-tiles-with-prototypes.store", "server-map-data.join-tiles-with-prototypes.store",
kxsBinary.serde<ServerMapChunkId>(), kxsBinary.serde(),
kxsBinary.serde<ServerMapChunkTiles<ColourHex>>(), kxsBinary.serde(),
), ),
foreignKeyExtractor = { chunkTiles: ServerMapChunkTiles<TileProtoHashCode> -> foreignKeyExtractor = { chunkTiles: ServerMapChunkTiles<TileProtoHashCode> ->
chunkTiles.chunkId.serverId chunkTiles.chunkId.serverId

View file

@ -23,14 +23,15 @@ enum class ChunkSize(
CHUNK_032(3), CHUNK_032(3),
; ;
val tilesPerChunk: Int = 2f.pow(8 - zoomLevel).roundToInt() /** 32, 64, 128, 256, or 512 */
val lengthInTiles: Int = 2f.pow(8 - zoomLevel).roundToInt()
init { init {
require(tilesPerChunk > 0) { "tilesPerChunk $tilesPerChunk must be positive" } require(lengthInTiles > 0) { "tilesPerChunk $lengthInTiles must be positive" }
// 1000 & 0111 = 0000 => pow^2 // 1000 & 0111 = 0000 => pow^2
// 1001 & 1000 = 1000 => !pow^2 // 1001 & 1000 = 1000 => !pow^2
require(tilesPerChunk and (tilesPerChunk - 1) == 0) { require(lengthInTiles and (lengthInTiles - 1) == 0) {
"tilesPerChunk $tilesPerChunk must be a power-of-two number" "tilesPerChunk $lengthInTiles must be a power-of-two number"
} }
} }
@ -38,8 +39,8 @@ enum class ChunkSize(
// cache values for better performance. KT-48872 // cache values for better performance. KT-48872
val entries: Set<ChunkSize> = values().toSet() val entries: Set<ChunkSize> = values().toSet()
val MAX: ChunkSize = entries.maxByOrNull { it.tilesPerChunk }!! val MAX: ChunkSize = entries.maxByOrNull { it.lengthInTiles }!!
val MIN: ChunkSize = entries.minByOrNull { it.tilesPerChunk }!! val MIN: ChunkSize = entries.minByOrNull { it.lengthInTiles }!!
val STANDARD: ChunkSize = entries.first { it.zoomLevel == 0 } val STANDARD: ChunkSize = entries.first { it.zoomLevel == 0 }
} }

View file

@ -0,0 +1,195 @@
package dev.adamko.kafkatorio.processor.topology
import dev.adamko.kafkatorio.processor.serdes.kxsBinary
import dev.adamko.kafkatorio.schema.common.ColourHex
import dev.adamko.kafkatorio.schema.common.MapChunkPosition
import dev.adamko.kafkatorio.schema.common.MapTilePosition
import dev.adamko.kafkatorio.schema.common.SurfaceIndex
import dev.adamko.kafkatorio.schema.common.iterateTiles
import dev.adamko.kafkatorio.schema.common.toMapChunkPosition
import dev.adamko.kotka.kxs.serde
import io.kotest.core.spec.style.FunSpec
import io.kotest.property.Arb
import io.kotest.property.PropTestConfig
import io.kotest.property.RandomSource
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.uByte
import io.kotest.property.checkAll
import java.nio.file.Path
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import kotlin.io.path.Path
import kotlin.io.path.absolutePathString
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration
import kotlinx.coroutines.delay
import org.apache.kafka.streams.StreamsBuilder
import org.apache.kafka.streams.TestInputTopic
import org.apache.kafka.streams.TopologyTestDriver
class SaveMapChunksTest : FunSpec({
test("save tile") {
Scenario().use { scenario ->
val chunkTilesArb = serverMapChunkTilesArb(scenario.serverId)
checkAll(
config = PropTestConfig(iterations = 1),
chunkTilesArb,
) { chunkTiles ->
scenario.groupedMapChunksInputTopic.pipeInput(
chunkTiles.chunkId,
chunkTiles,
)
scenario += 1.minutes
delay(5.seconds)
scenario += 1.minutes
// delay(5.seconds)
scenario += 1.minutes
// delay(5.seconds)
scenario += 1.minutes
// delay(5.seconds)
println("file:///${scenario.outputDir.absolutePathString()}")
}
}
}
}) {
class Scenario(
streamsBuilder: StreamsBuilder = StreamsBuilder(),
groupedMapChunksInputTopicName: String = "kafkatorio.state.map-chunks.grouped.debounced",
val serverId: FactorioServerId = FactorioServerId("test-server-id"),
) : AutoCloseable {
val outputDir: Path = run {
val now = LocalTime.now().format(DateTimeFormatter.ISO_TIME).filter { it.isLetterOrDigit() }
Path("build/test/save-map-chunks-${now}")
}
private val topology = saveMapTiles2(streamsBuilder, outputDir)
val testDriver: TopologyTestDriver = TopologyTestDriver(topology)
val groupedMapChunksInputTopic: TestInputTopic<ServerMapChunkId, ServerMapChunkTiles<ColourHex>> =
testDriver.createInputTopic(
groupedMapChunksInputTopicName,
kxsBinary.serde<ServerMapChunkId>().serializer(),
kxsBinary.serde<ServerMapChunkTiles<ColourHex>>().serializer(),
)
operator fun plusAssign(wallClockTime: Duration) {
testDriver.advanceWallClockTime(wallClockTime.toJavaDuration())
}
override fun close() {
testDriver.close()
}
}
companion object {
fun mapTilePositionArb(
xArb: Arb<Int> = Arb.int(),
yArb: Arb<Int> = Arb.int(),
): Arb<MapTilePosition> = arbitrary {
MapTilePosition(xArb.bind(), yArb.bind())
}
fun mapChunkPositionArb(
xArb: Arb<Int> = Arb.int(-10..10),
yArb: Arb<Int> = Arb.int(-10..10),
): Arb<MapChunkPosition> = arbitrary {
MapChunkPosition(xArb.bind(), yArb.bind())
}
fun colourHexArb(
redArb: Arb<UByte> = Arb.uByte(),
greenArb: Arb<UByte> = Arb.uByte(),
blueArb: Arb<UByte> = Arb.uByte(),
alphaArb: Arb<UByte> = Arb.uByte(),
): Arb<ColourHex> = arbitrary {
ColourHex(
redArb.bind(),
greenArb.bind(),
blueArb.bind(),
// alphaArb.bind(),
255u,
)
// ColourHex(
// 255u,
// 0u,
// 0u,
// 255u,
// )
}
fun serverMapChunkIdArb(
serverId: FactorioServerId,
chunkPosition: Arb<MapChunkPosition> = mapChunkPositionArb(),
surfaceIndex: SurfaceIndex = SurfaceIndex(1u),
chunkSize: ChunkSize = ChunkSize.MAX,
): Arb<ServerMapChunkId> = arbitrary {
ServerMapChunkId(
serverId = serverId,
chunkPosition = chunkPosition.bind(),
surfaceIndex = surfaceIndex,
chunkSize = chunkSize,
)
}
fun tilesInChunkArb(
chunkId: ServerMapChunkId
): Arb<Map<MapTilePosition, ColourHex>> = arbitrary {
val tileColours = mutableMapOf<MapChunkPosition, ColourHex>()
println("chunk size: ${chunkId.chunkSize} ${chunkId.chunkSize.lengthInTiles}")
val map = chunkId.chunkPosition
.iterateTiles(chunkId.chunkSize.lengthInTiles)
.asSequence()
.map { tilePos ->
val colour =
tileColours.getOrPut(tilePos.toMapChunkPosition(ChunkSize.MIN.lengthInTiles)) {
colourHexArb().sample(RandomSource.default()).value
}
tilePos to colour
}
// .take(2500)
.toMap()
println(map.size)
map
}
fun serverMapChunkTilesArb(
serverId: FactorioServerId,
): Arb<ServerMapChunkTiles<ColourHex>> {
val serverMapChunkId = serverMapChunkIdArb(serverId)
return arbitrary {
val chunkId = serverMapChunkId.bind()
val tileColoursArb = tilesInChunkArb(serverMapChunkId.bind())
ServerMapChunkTiles(
chunkId = chunkId,
map = tileColoursArb.bind()
)
}
}
}
}