- MapTile/LuaTile no longer a FactorioObjectData (it doesn't get sent as a packet)

- trying to optimise the topology so it's faster... almost there but the records are now too large
- map tile pngs, multiple zoom levels
- use coroutines for faster map tiles
This commit is contained in:
Adam 2022-02-06 21:05:37 +01:00
parent d75233e71e
commit ea0bfb350b
16 changed files with 441 additions and 97 deletions

View file

@ -1,22 +1,14 @@
package dev.adamko.kafkatorio.processor
import dev.adamko.kafkatorio.events.schema.Colour
import dev.adamko.kafkatorio.events.schema.MapTile
import dev.adamko.kafkatorio.processor.topology.PrototypeName
import dev.adamko.kafkatorio.processor.topology.TileUpdateRecordKey
import dev.adamko.kafkatorio.processor.topology.WebMapTileChunkPixels
import dev.adamko.kafkatorio.processor.topology.WebMapTileChunkPosition
import dev.adamko.kafkatorio.processor.topology.aggregateWebMapTiles
import dev.adamko.kafkatorio.processor.topology.allMapTilesTable
import dev.adamko.kafkatorio.processor.topology.buildFactorioServerMap
import dev.adamko.kafkatorio.processor.topology.factorioServerPacketStream
import dev.adamko.kafkatorio.processor.topology.playerUpdatesToWsServer
import dev.adamko.kafkatorio.processor.topology.saveTileImages
import dev.adamko.kafkatorio.processor.topology.splitFactorioServerLog
import dev.adamko.kafkatorio.processor.topology.tilePrototypeColourTable
import dev.adamko.kafkatorio.processor.topology.saveMapTiles
import dev.adamko.kafkatorio.processor.topology.splitFactorioServerPacketStream
import java.time.Duration
import org.apache.kafka.streams.KafkaStreams
import org.apache.kafka.streams.StreamsBuilder
import org.apache.kafka.streams.TopologyDescription
import org.apache.kafka.streams.kstream.KTable
class KafkatorioTopology(
@ -30,17 +22,25 @@ class KafkatorioTopology(
fun build() {
splitFactorioServerLog(builder)
val packets = factorioServerPacketStream(builder)
splitFactorioServerPacketStream(packets)
playerUpdatesToWsServer(websocketServer, builder)
val allMapTilesTable: KTable<TileUpdateRecordKey, MapTile> = allMapTilesTable(builder)
val tilePrototypeColourTable: KTable<PrototypeName, Colour> = tilePrototypeColourTable(builder)
val serverMapDataTable = buildFactorioServerMap(packets)
val webMapTiles: KTable<WebMapTileChunkPosition, WebMapTileChunkPixels> =
aggregateWebMapTiles(allMapTilesTable, tilePrototypeColourTable)
saveMapTiles(serverMapDataTable)
saveTileImages(webMapTiles)
// the old way...
// val allMapTilesTable: KTable<TileUpdateRecordKey, MapTile> = allMapTilesTable(builder)
// val tilePrototypeColourTable: KTable<PrototypeName, Colour> = tilePrototypeColourTable(builder)
//
// val webMapTiles: KTable<WebMapTileChunkPosition, WebMapTileChunkPixels> =
// aggregateWebMapTiles(allMapTilesTable, tilePrototypeColourTable)
//
// saveTileImages(webMapTiles)
val topology = builder.build()

View file

@ -0,0 +1,127 @@
package dev.adamko.kafkatorio.processor.topology
import dev.adamko.kafkatorio.events.schema.FactorioConfigurationUpdate
import dev.adamko.kafkatorio.events.schema.FactorioEvent
import dev.adamko.kafkatorio.events.schema.FactorioPrototypes
import dev.adamko.kafkatorio.events.schema.KafkatorioPacket
import dev.adamko.kafkatorio.events.schema.MapChunk
import dev.adamko.kafkatorio.events.schema.MapTilePosition
import dev.adamko.kafkatorio.events.schema.MapTilePrototype
import dev.adamko.kafkatorio.events.schema.MapTiles
import dev.adamko.kafkatorio.processor.serdes.jsonMapper
import dev.adamko.kafkatorio.processor.serdes.kxsBinary
import dev.adamko.kotka.extensions.groupedAs
import dev.adamko.kotka.extensions.materializedAs
import dev.adamko.kotka.extensions.materializedWith
import dev.adamko.kotka.extensions.streams.aggregate
import dev.adamko.kotka.extensions.tables.mapValues
import dev.adamko.kotka.kxs.serde
import kotlinx.serialization.Serializable
import org.apache.kafka.streams.kstream.KStream
import org.apache.kafka.streams.kstream.KTable
@Serializable
data class FactorioServerMap(
val serverId: FactorioServerId,
val tilePrototypes: Map<PrototypeName, MapTilePrototype>,
val surfaces: Map<SurfaceIndex, FactorioMapSurface>,
)
@Serializable
data class FactorioMapSurface(
val index: SurfaceIndex,
val tiles: Map<MapTilePosition, PrototypeName>,
)
/** Aggregate [KafkatorioPacket]s into a single [FactorioServerMap] (per [serverId][FactorioServerId]). */
fun buildFactorioServerMap(
factorioServerPacketStream: KStream<FactorioServerId, KafkatorioPacket>
): KTable<FactorioServerId, FactorioServerMap> {
return factorioServerPacketStream
.groupByKey(
groupedAs("factorio-server-map-data-grouping", jsonMapper.serde(), jsonMapper.serde())
)
.aggregate<FactorioServerId, KafkatorioPacket, FactorioServerMapAggregator>(
"factorio-server-map-data-aggregate",
materializedAs("factorio-server-map-aggregate-store", kxsBinary.serde(), kxsBinary.serde()),
{ FactorioServerMapAggregator() },
) { _: FactorioServerId, packet: KafkatorioPacket, aggregate: FactorioServerMapAggregator ->
aggregate + packet
}
.mapValues(
"finalise-factorio-server-map-aggregate",
materializedWith(kxsBinary.serde(), kxsBinary.serde())
) { readOnlyKey, aggregate ->
val surfaces = aggregate.surfaces.mapValues { (_, surface) ->
FactorioMapSurface(surface.index, surface.tiles)
}
FactorioServerMap(readOnlyKey, aggregate.tilePrototypes, surfaces)
}
}
@Serializable
private data class FactorioServerMapAggregator(
val tilePrototypes: MutableMap<PrototypeName, MapTilePrototype> = mutableMapOf(),
val surfaces: MutableMap<SurfaceIndex, FactorioMapSurfaceAggregator> = mutableMapOf(),
) {
operator fun plus(packet: KafkatorioPacket): FactorioServerMapAggregator {
return when (packet) {
is FactorioEvent -> {
when (val data = packet.data) {
is MapChunk -> plus(data)
is MapTiles -> plus(data)
// is MapTile -> {} // hmmm... no surface index
else -> this
}
}
is FactorioPrototypes -> plus(packet)
is FactorioConfigurationUpdate -> this
}
}
operator fun plus(mapTiles: MapTiles): FactorioServerMapAggregator {
// println("FactorioServerMapAggregator: adding ${mapTiles.tiles.size} MapTiles from surface ${mapTiles.surfaceIndex}")
val surfaceIndex = SurfaceIndex(mapTiles.surfaceIndex)
val surface = surfaces.getOrPut(surfaceIndex) { FactorioMapSurfaceAggregator(surfaceIndex) }
val surfaceTiles = mapTiles.tiles.associate { it.position to PrototypeName(it.prototypeName) }
surface.tiles.putAll(surfaceTiles)
return this
}
operator fun plus(mapChunk: MapChunk): FactorioServerMapAggregator {
// println("FactorioServerMapAggregator: adding mapChunk ${mapChunk.position}")
return plus(mapChunk.tiles)
}
operator fun plus(packet: FactorioPrototypes): FactorioServerMapAggregator {
// println("FactorioServerMapAggregator: adding FactorioPrototypes")
val packetPrototypes = packet.prototypes
.filterIsInstance<MapTilePrototype>()
.associateBy { PrototypeName(it.name) }
tilePrototypes.putAll(packetPrototypes)
return this
}
}
@Serializable
private data class FactorioMapSurfaceAggregator(
val index: SurfaceIndex,
val tiles: MutableMap<MapTilePosition, PrototypeName> = mutableMapOf(),
)

View file

@ -35,24 +35,24 @@ fun allMapTilesTable(
builder: StreamsBuilder
): KTable<TileUpdateRecordKey, MapTile> {
val luaTilesUpdatesStream: KStream<FactorioPacketKey, MapTiles> =
builder.stream<FactorioPacketKey, FactorioEvent>(
val luaTilesUpdatesStream: KStream<FactorioServerId, MapTiles> =
builder.stream<FactorioServerId, FactorioEvent>(
"kafkatorio.${KafkatorioPacket.PacketType.EVENT}.${FactorioObjectData.ObjectName.LuaTiles}",
consumedAs("consume-map-tiles-packets", jsonMapper.serde(), jsonMapper.serde())
)
.filter("events.filter.map-tiles") { _: FactorioPacketKey, event: FactorioEvent ->
.filter("events.filter.map-tiles") { _: FactorioServerId, event: FactorioEvent ->
event.data is MapTiles
}
.mapValues("events.extract-map-tiles") { _, event: FactorioEvent ->
(event.data as? MapTiles)!!
}
val chunkTilesUpdateStream: KStream<FactorioPacketKey, MapTiles> =
builder.stream<FactorioPacketKey, FactorioEvent>(
val chunkTilesUpdateStream: KStream<FactorioServerId, MapTiles> =
builder.stream<FactorioServerId, FactorioEvent>(
"kafkatorio.${KafkatorioPacket.PacketType.EVENT}.${FactorioObjectData.ObjectName.MapChunk}",
consumedAs("consume-map-chunk-packets", jsonMapper.serde(), jsonMapper.serde())
)
.filter("events.filter.map-chunks") { _: FactorioPacketKey, event: FactorioEvent ->
.filter("events.filter.map-chunks") { _: FactorioServerId, event: FactorioEvent ->
event.data is MapChunk
}
.mapValues("events.extract-map-chunks") { _, event: FactorioEvent ->

View file

@ -1,17 +1,9 @@
package dev.adamko.kafkatorio.processor.topology
import dev.adamko.kafkatorio.events.schema.ConsoleChatMessage
import dev.adamko.kafkatorio.events.schema.EntityData
import dev.adamko.kafkatorio.events.schema.FactorioConfigurationUpdate
import dev.adamko.kafkatorio.events.schema.FactorioEvent
import dev.adamko.kafkatorio.events.schema.FactorioPrototypes
import dev.adamko.kafkatorio.events.schema.KafkatorioPacket
import dev.adamko.kafkatorio.events.schema.MapChunk
import dev.adamko.kafkatorio.events.schema.MapTile
import dev.adamko.kafkatorio.events.schema.MapTiles
import dev.adamko.kafkatorio.events.schema.PlayerData
import dev.adamko.kafkatorio.events.schema.SurfaceData
import dev.adamko.kafkatorio.events.schema.converters.toMapChunkPosition
import dev.adamko.kafkatorio.processor.KafkatorioTopology
import dev.adamko.kafkatorio.processor.serdes.jsonMapper
import dev.adamko.kotka.extensions.consumedAs
@ -19,25 +11,18 @@ import dev.adamko.kotka.extensions.producedAs
import dev.adamko.kotka.extensions.streams.map
import dev.adamko.kotka.extensions.streams.to
import dev.adamko.kotka.kxs.serde
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import org.apache.kafka.common.serialization.Serdes
import org.apache.kafka.streams.StreamsBuilder
import org.apache.kafka.streams.kstream.KStream
import org.apache.kafka.streams.processor.RecordContext
@Serializable
data class FactorioPacketKey(
val serverId: FactorioServerId,
/** arbitrary distinctive key */
val key: String,
)
fun splitFactorioServerLog(
fun factorioServerPacketStream(
builder: StreamsBuilder = StreamsBuilder(),
) {
): KStream<FactorioServerId, KafkatorioPacket> {
builder.stream(
return builder.stream(
KafkatorioTopology.sourceTopic,
consumedAs(
"read-raw-packets-from-server",
@ -46,18 +31,22 @@ fun splitFactorioServerLog(
)
)
.map("decode-packets") { serverId: String, value: String ->
// println("Mapping $readOnlyKey:$value")
val packet = jsonMapper.decodeFromString<KafkatorioPacket>(value)
val key: String = createPacketKey(packet)
FactorioPacketKey(FactorioServerId(serverId), key) to packet
FactorioServerId(serverId) to packet
}
}
fun splitFactorioServerPacketStream(
factorioServerPacketStream: KStream<FactorioServerId, KafkatorioPacket>
) {
factorioServerPacketStream
.to(
producedAs(
"split-server-log",
jsonMapper.serde(),
jsonMapper.serde(),
)
) { _: FactorioPacketKey, value: KafkatorioPacket, _: RecordContext ->
) { _: FactorioServerId, value: KafkatorioPacket, _: RecordContext ->
// println("[$key] sending event:${value.eventType} to topic:${value.data.objectName()}")
when (value) {
is FactorioEvent ->
@ -68,28 +57,34 @@ fun splitFactorioServerLog(
"kafkatorio.${value.packetType.name}.all"
}
}
}
private fun createPacketKey(packet: KafkatorioPacket): String {
return when (packet) {
is FactorioEvent -> factorioEventKey(packet)
is FactorioConfigurationUpdate,
is FactorioPrototypes -> packet.hashCode().toString()
}
}
private fun factorioEventKey(event: FactorioEvent): String {
val id: String = when (val data = event.data) {
is ConsoleChatMessage -> data.authorPlayerIndex?.toString()
is EntityData -> data.type
is MapChunk -> data.position.toString()
is MapTile -> data.position.toString()
is MapTiles -> data.tiles.firstOrNull()?.position?.toMapChunkPosition()?.toString()
is PlayerData -> (data.characterUnitNumber?.toString() ?: data.name)
is SurfaceData -> data.index.toString()
} ?: event.tick.toString()
return id
}
//@Serializable
//data class FactorioPacketKey(
// val serverId: FactorioServerId,
// /** arbitrary distinctive key */
// val key: String,
//)
//
//private fun createPacketKey(packet: KafkatorioPacket): String {
//
// return when (packet) {
// is FactorioEvent -> factorioEventKey(packet)
// is FactorioConfigurationUpdate,
// is FactorioPrototypes -> packet.hashCode().toString()
// }
//}
//
//private fun factorioEventKey(event: FactorioEvent): String {
// val id: String = when (val data = event.data) {
// is ConsoleChatMessage -> data.authorPlayerIndex?.toString()
// is EntityData -> data.type
// is MapChunk -> data.position.toString()
//// is MapTile -> data.position.toString()
// is MapTiles -> data.tiles.firstOrNull()?.position?.toMapChunkPosition()?.toString()
// is PlayerData -> (data.characterUnitNumber?.toString() ?: data.name)
// is SurfaceData -> data.index.toString()
// } ?: event.tick.toString()
//
// return id
//}

View file

@ -0,0 +1,198 @@
package dev.adamko.kafkatorio.processor.topology
import com.sksamuel.scrimage.ImmutableImage
import com.sksamuel.scrimage.ScaleMethod
import com.sksamuel.scrimage.nio.PngWriter
import dev.adamko.kafkatorio.events.schema.Colour
import dev.adamko.kafkatorio.events.schema.MapChunkPosition
import dev.adamko.kafkatorio.events.schema.MapTilePosition
import dev.adamko.kafkatorio.events.schema.converters.toMapChunkPosition
import java.awt.image.BufferedImage
import java.io.File
import kotlin.math.abs
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.apache.kafka.streams.kstream.KTable
private const val WEB_MAP_TILE_SIZE_PX = 256
private val cScope = CoroutineScope(
Dispatchers.Default + SupervisorJob() + CoroutineName("f-server-map-updates")
)
/** A bridge between Kafka and Kotlin */
private val serverMapDataFlow: MutableStateFlow<FactorioServerMap?> = MutableStateFlow(null)
fun saveMapTiles(
serverMapTable: KTable<FactorioServerId, FactorioServerMap>
) {
serverMapTable
.toStream()
.foreach { _, value ->
runBlocking(Dispatchers.Default) {
serverMapDataFlow.emit(value)
}
}
cScope.launch {
println("launching serverMapDataFlow merging")
serverMapDataFlow
.filterNotNull()
serverMapDataFlow
.filterNotNull()
.conflate()
// .transform { serverMapData ->
.flatMapMerge(10) { serverMapData ->
println("flatMapMerge serverMapData ${serverMapData.serverId}, surfaces ${serverMapData.surfaces.keys}")
val prototypes = serverMapData.tilePrototypes
serverMapData
.surfaces
.values
// .forEach { surfaceData ->
.asFlow()
.flatMapMerge(10) { surfaceData ->
println("flatMapMerge surfaceData ${surfaceData.index}")
// get all tile colours for the surface
val surfaceTileColours = surfaceData.tiles
.entries
.mapNotNull { (tilePosition, prototypeName) ->
prototypes[prototypeName]?.let { prototype ->
tilePosition to prototype.mapColour
}
}
println("surfaceTileColours.size ${surfaceTileColours.size}")
// split a surface into chunks, per zoom-level
ZoomLevel.values
// .forEach { zoom ->
.asFlow()
.flatMapMerge(10) { zoom ->
println("flatMapMerge zoom $zoom")
surfaceTileColours
.groupBy { (tilePosition, _) ->
tilePosition.toMapChunkPosition(zoom.chunkSize)
}
.entries
// .forEach { (chunkPosition, tileColours) ->
.asFlow()
.mapLatest { (chunkPosition, tileColours) ->
println("mapLatest chunkPosition $chunkPosition")
// mapLatest + delay = an alternative to debounce-per-key?
delay(30.seconds)
println("emitting ChunkData $chunkPosition")
ChunkData(
surfaceData.index,
zoom,
chunkPosition,
tileColours.toMap()
)
}
}
}
}
.onEach { chunkData ->
println("starting to save chunkData ${chunkData.chunkPosition}...")
val chunkImageFile = File(chunkData.filename.value)
if (chunkImageFile.parentFile.mkdirs()) {
println("created new map tile parentFile directory ${chunkImageFile.absolutePath}")
}
val image =
createMapTileImage(chunkData.chunkPosition, chunkData.tileColours, chunkData.zoomLevel)
println("saving map tile $chunkImageFile")
val savedTile = image.output(PngWriter.NoCompression, chunkImageFile)
println("savedTile: $savedTile")
}
.launchIn(this)
}
}
@JvmInline
private value class TilePngFilename(
val value: String,
) {
constructor(
surfaceIndex: SurfaceIndex,
zoom: Int,
chunkX: Int,
chunkY: Int,
) : this("src/main/resources/kafkatorio-web-map/s${surfaceIndex.index}/z$zoom/x${chunkX}/y${chunkY}.png")
}
private data class ChunkData(
val surfaceIndex: SurfaceIndex,
val zoomLevel: ZoomLevel,
val chunkPosition: MapChunkPosition,
val tileColours: Map<MapTilePosition, Colour>,
) {
val filename = TilePngFilename(surfaceIndex, zoomLevel.level, chunkPosition.x, chunkPosition.y)
}
private fun createMapTileImage(
chunkPosition: MapChunkPosition,
chunkColours: Map<MapTilePosition, Colour>,
z: ZoomLevel
): ImmutableImage {
val chunkImage = ImmutableImage.filled(
z.chunkSize,
z.chunkSize,
COLOUR_TRANSPARENT.awt(),
BufferedImage.TYPE_INT_ARGB
)
val chunkOriginX: Int = chunkPosition.x * z.chunkSize
val chunkOriginY: Int = chunkPosition.y * z.chunkSize
chunkColours.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
)
}
return chunkImage.scaleTo(WEB_MAP_TILE_SIZE_PX, WEB_MAP_TILE_SIZE_PX, ScaleMethod.Progressive)
}

View file

@ -2,9 +2,11 @@ package dev.adamko.kafkatorio.processor.topology
import com.sksamuel.scrimage.color.RGBColor
import dev.adamko.kafkatorio.events.schema.Colour
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlinx.serialization.Serializable
@Serializable
@JvmInline
value class SurfaceIndex(
@ -13,12 +15,14 @@ value class SurfaceIndex(
override fun toString() = "$index"
}
@JvmInline
@Serializable
value class FactorioServerId(val id: String) {
override fun toString() = id
}
fun Colour.toRgbColor(): RGBColor {
return RGBColor(
red.roundToInt(),
@ -27,3 +31,26 @@ fun Colour.toRgbColor(): RGBColor {
alpha.roundToInt(),
)
}
enum class ZoomLevel(
val level: Int
) {
ZOOM_0(0),
ZOOM_1(1),
ZOOM_2(2),
ZOOM_3(3),
ZOOM_4(4),
ZOOM_5(5),
;
val chunkSize: Int = 2f.pow(10 - level).roundToInt()
init {
require(chunkSize > 0) { "chunkSize must be positive" }
}
companion object {
val values: List<ZoomLevel> = values().asList() // better performance, KT-48872
}
}

View file

@ -14,7 +14,7 @@ fun playerUpdatesToWsServer(
websocketServer: WebsocketServer,
builder: StreamsBuilder = StreamsBuilder(),
) {
builder.stream<FactorioPacketKey, KafkatorioPacket>(
builder.stream<FactorioServerId, KafkatorioPacket>(
"kafkatorio.${KafkatorioPacket.PacketType.EVENT}.${FactorioObjectData.ObjectName.LuaPlayer}",
consumedAs("stream-player-updates-for-ws-server", jsonMapper.serde(), jsonMapper.serde())
)

View file

@ -49,7 +49,7 @@ value class PrototypeName(val name: String) {
/** Get the [Colour] of each [MapTilePrototype] */
fun tilePrototypeColourTable(builder: StreamsBuilder): KTable<PrototypeName, Colour> {
return builder.stream<FactorioPacketKey, FactorioPrototypes>(
return builder.stream<FactorioServerId, FactorioPrototypes>(
"kafkatorio.${KafkatorioPacket.PacketType.PROTOTYPES}.all",
consumedAs(
"consume-all-prototypes-packets",

View file

@ -108,7 +108,7 @@ fun aggregateWebMapTiles(
kxsBinary.serde(),
kxsBinary.serde(),
)
) { pos: TileUpdateRecordKey, px: WebMapTilePixel ->
) { pos: TileUpdateRecordKey, _: WebMapTilePixel ->
val (chunkX, chunkY) = pos.tilePosition.toMapChunkPosition(WEB_MAP_IMAGE_TILE_SIZE)
val chunkPos = WebMapTileChunkPosition(
WEB_MAP_IMAGE_TILE_SIZE,

View file

@ -14,7 +14,7 @@ import kotlin.math.roundToInt
import org.apache.kafka.streams.kstream.KTable
private val COLOUR_TRANSPARENT = RGBColor.fromAwt(Color(0f, 0f, 0f, 0f))
val COLOUR_TRANSPARENT = RGBColor.fromAwt(Color(0f, 0f, 0f, 0f))
fun saveTileImages(
webMapTiles: KTable<WebMapTileChunkPosition, WebMapTileChunkPixels>
@ -54,12 +54,7 @@ private fun saveMapTilesPng(
pixels.forEach { (tilePosition, colour) ->
val rgbColour = RGBColor(
colour.red.roundToInt(),
colour.green.roundToInt(),
colour.blue.roundToInt(),
colour.alpha.roundToInt(),
)
val rgbColour = colour.toRgbColor()
val pixelX = abs(abs(tilePosition.x) - abs(chunkOriginX))
val pixelY = abs(abs(tilePosition.y) - abs(chunkOriginY))