finish migrating to ktor!

- remove http4k
- renamed 'schema' subproject to 'library', because it contains more than just data models
- tiles are now hosted per factorio-server
- changed api base to `kafkatorio/data`
- tidied up some Gradle conf
- remove 'events-processor' subproject - I don't want to set up loads of microservices right now
This commit is contained in:
Adam 2022-07-02 08:45:21 +02:00
parent 8a90c4fb5e
commit c0c82e7c9c
80 changed files with 1878 additions and 1280 deletions

View file

@ -0,0 +1,76 @@
package dev.adamko.kafkatorio.library
import kotlin.reflect.KClass
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonTransformingSerializer
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.modules.SerializersModule
val jsonMapperKafkatorio = Json {
prettyPrint = true
prettyPrintIndent = " "
serializersModule = SerializersModule {
contextual(List::class) { args -> FactorioJsonListSerializer(args[0]) }
}
}
/**
* Factorio outputs an *empty* list as an *empty* Json object. This serde performs the same
* conversion.
*/
class FactorioJsonListSerializer<T>(dataSerializer: KSerializer<T>) :
JsonTransformingSerializer<List<T>>(ListSerializer(dataSerializer)) {
override fun transformDeserialize(element: JsonElement): JsonElement {
return when (element) {
is JsonObject -> JsonArray(emptyList())
else -> element
}
}
override fun transformSerialize(element: JsonElement): JsonElement {
return when {
element is JsonArray && element.isEmpty() -> buildJsonObject { }
else -> element
}
}
}
interface SerializerProvider<T : Any> {
val serializer: KSerializer<out T>
}
open class SerializerProviderDeserializer<T : Any>(
private val baseClass: KClass<T>,
private val key: String,
private val selector: (String) -> SerializerProvider<out T>?,
) : JsonContentPolymorphicSerializer<T>(baseClass) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<out T> {
val type = element
.jsonObject[key]
?.jsonPrimitive
?.contentOrNull
?.let { json -> selector(json) }
requireNotNull(type) { "Unknown ${baseClass.simpleName} $key: $element" }
return type.serializer
}
}

View file

@ -0,0 +1,160 @@
package dev.adamko.kafkatorio.library
import kotlinx.serialization.BinaryFormat
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.SerializationStrategy
import kotlinx.serialization.builtins.ByteArraySerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.AbstractDecoder
import kotlinx.serialization.encoding.AbstractEncoder
import kotlinx.serialization.encoding.CompositeDecoder
import kotlinx.serialization.encoding.CompositeEncoder
import kotlinx.serialization.modules.EmptySerializersModule
import kotlinx.serialization.modules.SerializersModule
import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource
val kxsBinary: KxsBinary = KxsBinary()
/** [`https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/formats.md#efficient-binary-format`](https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/formats.md#efficient-binary-format) */
class KxsBinary(
override val serializersModule: SerializersModule = EmptySerializersModule
) : BinaryFormat {
override fun <T> encodeToByteArray(serializer: SerializationStrategy<T>, value: T): ByteArray {
val output = Buffer()
val encoder = KxsDataOutputEncoder(output, serializersModule)
encoder.encodeSerializableValue(serializer, value)
return output.readByteArray()
}
override fun <T> decodeFromByteArray(
deserializer: DeserializationStrategy<T>,
bytes: ByteArray
): T {
val input = Buffer().write(bytes)
val decoder = KxsDataInputDecoder(input, serializersModule)
return decoder.decodeSerializableValue(deserializer)
}
}
private val byteArraySerializer = ByteArraySerializer()
class KxsDataOutputEncoder(
private val output: BufferedSink,
override val serializersModule: SerializersModule = EmptySerializersModule,
) : AbstractEncoder() {
private operator fun BufferedSink.invoke(action: BufferedSink.() -> Unit): Unit = action()
override fun encodeBoolean(value: Boolean) = output { writeByte(if (value) 1 else 0) }
override fun encodeByte(value: Byte) = output { writeByte(value.toInt()) }
override fun encodeChar(value: Char) = output { writeUtf8CodePoint(value.code) }
override fun encodeDouble(value: Double) = output { writeLong(value.toRawBits()) }
override fun encodeEnum(enumDescriptor: SerialDescriptor, index: Int) = output { writeInt(index) }
override fun encodeFloat(value: Float) = output { writeInt(value.toRawBits()) }
override fun encodeInt(value: Int) = output { writeInt(value) }
override fun encodeLong(value: Long) = output { writeLong(value) }
override fun encodeShort(value: Short) = output { writeShort(value.toInt()) }
override fun encodeString(value: String) = output {
encodeCompactSize(value.length)
writeUtf8(value)
}
override fun beginCollection(
descriptor: SerialDescriptor,
collectionSize: Int
): CompositeEncoder {
encodeCompactSize(collectionSize)
return this
}
override fun encodeNull() = encodeBoolean(false)
override fun encodeNotNullMark() = encodeBoolean(true)
override fun <T> encodeSerializableValue(serializer: SerializationStrategy<T>, value: T) {
when (serializer.descriptor) {
byteArraySerializer.descriptor -> encodeByteArray(value as ByteArray)
else -> super.encodeSerializableValue(serializer, value)
}
}
private fun encodeByteArray(bytes: ByteArray) {
encodeCompactSize(bytes.size)
output.write(bytes)
}
private fun encodeCompactSize(value: Int) {
if (value < 0xff) {
output.writeByte(value)
} else {
output.writeByte(0xff)
output.writeInt(value)
}
}
}
class KxsDataInputDecoder(
private val input: BufferedSource,
override val serializersModule: SerializersModule = EmptySerializersModule,
private var elementsCount: Int = 0,
) : AbstractDecoder() {
private var elementIndex = 0
override fun decodeBoolean(): Boolean = input.readByte().toInt() != 0
override fun decodeByte(): Byte = input.readByte()
override fun decodeChar(): Char = input.readUtf8CodePoint().toChar()
override fun decodeDouble(): Double = Double.fromBits(input.readLong())
override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = input.readInt()
override fun decodeFloat(): Float = Float.fromBits(input.readInt())
override fun decodeInt(): Int = input.readInt()
override fun decodeLong(): Long = input.readLong()
override fun decodeShort(): Short = input.readShort()
override fun decodeString(): String {
val size = decodeCompactSize()
return input.readUtf8(size.toLong())
}
override fun decodeElementIndex(descriptor: SerialDescriptor): Int {
if (elementIndex == elementsCount) return CompositeDecoder.DECODE_DONE
return elementIndex++
}
override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder =
KxsDataInputDecoder(input, serializersModule, descriptor.elementsCount)
override fun decodeSequentially(): Boolean = true
override fun decodeCollectionSize(descriptor: SerialDescriptor): Int =
decodeCompactSize().also { elementsCount = it }
override fun decodeNotNullMark(): Boolean = decodeBoolean()
override fun <T> decodeSerializableValue(
deserializer: DeserializationStrategy<T>,
previousValue: T?
): T {
@Suppress("UNCHECKED_CAST")
return when (deserializer.descriptor) {
byteArraySerializer.descriptor -> decodeByteArray() as T
else -> super.decodeSerializableValue(deserializer, previousValue)
}
}
private fun decodeByteArray(): ByteArray {
val bytes = ByteArray(decodeCompactSize())
input.readFully(bytes)
return bytes
}
private fun decodeCompactSize(): Int {
val byte = input.readByte().toInt() and 0xff
if (byte < 0xff) return byte
return input.readInt()
}
}

View file

@ -0,0 +1,141 @@
package dev.adamko.kafkatorio.schema.common
import dev.adamko.kxstsgen.core.experiments.TupleSerializer
import kotlin.math.roundToInt
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Red, green, blue and alpha values, all in range `[0, 1]` or all in range `[0, 255]` if any
* value is > 1.
*
* All values here are optional. Colour channels default to `0`, the alpha channel defaults to `1`.
*/
@Serializable(with = Colour.Serializer::class)
@SerialName("kafkatorio.common.Colour")
data class Colour(
val red: Float = 0f,
val green: Float = 0f,
val blue: Float = 0f,
val alpha: Float = 1f,
) {
object Serializer : TupleSerializer<Colour>(
"Colour",
{
element(Colour::red)
element(Colour::green)
element(Colour::blue)
element(Colour::alpha)
}
) {
override fun tupleConstructor(elements: Iterator<*>): Colour {
return Colour(
elements.next() as Float,
elements.next() as Float,
elements.next() as Float,
elements.next() as Float,
)
}
}
}
/** Size-efficient version of [Colour] (`4*4` bytes vs `4*1` bytes) */
@Serializable
@SerialName("kafkatorio.common.ColourHex")
data class ColourHex(
val red: UByte = UByte.MIN_VALUE,
val green: UByte = UByte.MIN_VALUE,
val blue: UByte = UByte.MIN_VALUE,
val alpha: UByte = UByte.MAX_VALUE,
) {
companion object {
val TRANSPARENT = ColourHex(0u, 0u, 0u, 0u)
}
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/** True if any value is greater than 1, so the values are hexadecimal. */
fun Colour.isHexadecimal(): Boolean {
return red > 1f
|| green > 1f
|| blue > 1f
|| alpha > 1f
}
/** True if all values are between `[0..1]`. */
fun Colour.isPercentage(): Boolean = !isHexadecimal()
fun Colour.toHexString(includeAlpha: Boolean = false): String {
val hexConverter: Float.() -> String = when {
isHexadecimal() -> Float::hexadecimalToString
else -> Float::percentageToHexadecimalString
}
val length = if (includeAlpha) 9 else 7
return buildString(length) {
append('#')
append(red.hexConverter())
append(green.hexConverter())
append(blue.hexConverter())
if (includeAlpha) {
append(alpha.hexConverter())
}
}
}
fun Float.hexadecimalToString(): String =
roundToInt().toString(16).padStart(2, ' ')
fun Float.percentageToHexadecimalString(): String =
times(255).roundToInt().toString(16).padStart(2, ' ')
private val ubyteMaxFloat = UByte.MAX_VALUE.toFloat()
private val ubyteMinFloat = UByte.MIN_VALUE.toFloat()
private val ubyteRangeFloat = ubyteMinFloat..ubyteMaxFloat
private fun Float.toColourHex() = (this * ubyteMaxFloat)
.coerceIn(ubyteRangeFloat)
.toUInt()
.toUByte()
private fun Float.coerceToColourHex() = coerceIn(ubyteRangeFloat)
.toUInt()
.toUByte()
fun Colour.toHex(): ColourHex {
val converter: Float.() -> UByte = when {
isPercentage() -> Float::toColourHex
else -> Float::coerceToColourHex
}
return ColourHex(
red.converter(),
green.converter(),
blue.converter(),
alpha.converter(),
)
}
private fun UByte.toPercentileFloat() = (this.toFloat() / ubyteMaxFloat).coerceIn(0f..1f)
fun ColourHex.toPercentile(): Colour {
return Colour(
red.toPercentileFloat(),
green.toPercentileFloat(),
blue.toPercentileFloat(),
alpha.toPercentileFloat(),
)
}

View file

@ -0,0 +1,84 @@
package dev.adamko.kafkatorio.schema.common
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
@Serializable
@JvmInline
value class Tick(val value: UInt)
val UInt.tick
get() = Tick(this)
/**
* Unique ID associated with this surface
*
* See [`LuaSurface.index`](https://lua-api.factorio.com/latest/LuaSurface.html#LuaSurface.index)
*/
@Serializable
@JvmInline
value class SurfaceIndex(private val id: UInt) {
override fun toString(): String = "$id"
}
@Serializable
@JvmInline
value class ForceIndex(private val id: UInt) {
override fun toString(): String = "$id"
}
/** The player's index in `LuaGameScript::players` */
@Serializable
@JvmInline
value class PlayerIndex(private val id: UInt) {
override fun toString(): String = "$id"
}
/** > This is universally unique for every entity that has one, for the lifetime of a whole game. */
@Serializable
@JvmInline
value class UnitNumber(private val id: UInt) {
override fun toString(): String = "$id"
}
@Serializable
@JvmInline
value class PrototypeName(private val id: String) {
override fun toString(): String = id
}
@Serializable
@JvmInline
value class EventName(private val name: String) {
override fun toString(): String = name
}
interface EntityIdentifiers {
val unitNumber: UnitNumber?
val name: String
/** The prototype-type of the entity. */
val protoType: String
}
// instant of EntityIdentifiers, because kxs needs a concrete instance
@Serializable
data class EntityIdentifiersData(
override val unitNumber: UnitNumber? = null,
override val name: String,
override val protoType: String,
) : EntityIdentifiers
@JvmInline
@Serializable
value class FactorioServerId(private val id: String) {
override fun toString() = id
}

View file

@ -0,0 +1,52 @@
package dev.adamko.kafkatorio.schema.common
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("kafkatorio.prototype.FactorioPrototype")
sealed interface FactorioPrototype {
val name: PrototypeName
val mapColour: Colour
@SerialName("kafkatorio.prototype.MapTile")
@Serializable
data class MapTile(
override val name: PrototypeName,
override val mapColour: Colour,
val layer: UInt,
@Contextual
val collisionMasks: List<String>,
val order: String,
/** Can the tile be mined for resources? */
val canBeMined: Boolean,
) : FactorioPrototype
@SerialName("kafkatorio.prototype.Entity")
@Serializable
data class Entity(
override val name: PrototypeName,
override val mapColour: Colour,
val protoType: String,
val objectName: String,
val colour: Colour,
val maxHealth: Float,
val isBuilding: Boolean,
val isEntityWithOwner: Boolean,
val isMilitaryTarget: Boolean,
val miningProperties: MiningProperties
) : FactorioPrototype {
@Serializable
data class MiningProperties(
val canBeMined: Boolean,
val products: List<MinedProduct>?,
)
}
}

View file

@ -0,0 +1,112 @@
package dev.adamko.kafkatorio.schema.common
import dev.adamko.kafkatorio.schema.common.MapTileDictionary.PrototypeKey
import kotlin.jvm.JvmInline
import kotlinx.serialization.Serializable
const val MAP_CHUNK_SIZE = 32
@Serializable
data class MapTile(
val x: Int,
val y: Int,
val proto: PrototypeName,
)
/**
* 2D map of x/y tile positions and prototype-name, optimised for JSON size.
*
* [protos] is a one-to-one map between a [PrototypeName] and [PrototypeKey].
*
* [PrototypeKey] is an arbitrary integer value, which maps to one [PrototypeName] only for a
* specific instance of [MapTileDictionary].
*
* Each X/Y coordinate in [tilesXY] maps to a [PrototypeKey].
*/
@Serializable
data class MapTileDictionary(
/** Map an X,Y coordinate a prototype name */
val tilesXY: Map<String, Map<String, PrototypeKey>>,
val protos: Map<PrototypeName, PrototypeKey>,
) {
private val protosIndexToName: Map<PrototypeKey, PrototypeName> by lazy {
protos.entries.associate { (name, key) -> key to name }
}
fun toMapTileList(): List<MapTile> = buildList {
tilesXY.forEach { (xString, row) ->
row.forEach { (yString, protoIndex) ->
val x = xString.toIntOrNull()
val y = yString.toIntOrNull()
val protoName = protosIndexToName[protoIndex]
if (x != null && y != null && protoName != null) {
add(MapTile(x, y, protoName))
} else if (protoName == null) {
println("warning: no prototype name found for index:$protoIndex. $protosIndexToName")
}
}
}
}
/** An arbitrary ID for a prototype in [MapTileDictionary.protos]. */
@Serializable
@JvmInline
value class PrototypeKey(private val index: Int)
companion object {
fun MapTileDictionary?.isNullOrEmpty(): Boolean {
return this == null || (tilesXY.isEmpty() && tilesXY.all { it.value.isEmpty() })
}
}
}
@Serializable
data class MapBoundingBox(
val topLeft: MapTilePosition,
val bottomRight: MapTilePosition,
)
@Serializable
data class ServerMapChunkId(
val serverId: FactorioServerId,
val chunkPosition: MapChunkPosition,
val surfaceIndex: SurfaceIndex,
val chunkSize: ChunkSize,
)
/** `${serverDataDir}/servers/{serverId}/map/tiles/s{surfaceIndex}/z{zoomLevel}/x{chunkX}/y{chunkY}.png` */
@Serializable
@JvmInline
value class TilePngFilename(
val value: String,
) {
constructor(id: ServerMapChunkId) : this(
id.serverId,
id.surfaceIndex,
id.chunkSize.zoomLevel,
id.chunkPosition.x,
id.chunkPosition.y,
)
constructor(
serverId: FactorioServerId,
surfaceIndex: SurfaceIndex,
zoomLevel: Int,
chunkX: Int,
chunkY: Int,
) : this(buildString {
append("servers/${serverId}")
append("/map/tiles")
append("/s${surfaceIndex}")
append("/z${zoomLevel}")
append("/x${chunkX}")
append("/y${chunkY}")
append(".png")
})
}

View file

@ -0,0 +1,233 @@
package dev.adamko.kafkatorio.schema.common
import dev.adamko.kxstsgen.core.*
import dev.adamko.kxstsgen.core.experiments.TupleSerializer
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlinx.serialization.Serializable
/**
* Coordinates of a [EntityData] on a map.
*
* A [MapEntityPosition] can be translated to a [MapChunkPosition] by dividing the `x`/`y` values
* by [32][MAP_CHUNK_SIZE].
*/
@Serializable(with = MapEntityPosition.Serializer::class)
data class MapEntityPosition(
val x: Double,
val y: Double,
) {
object Serializer : TupleSerializer<MapEntityPosition>(
"MapEntityPosition",
{
element(MapEntityPosition::x)
element(MapEntityPosition::y)
}
) {
override fun tupleConstructor(elements: Iterator<*>): MapEntityPosition {
return MapEntityPosition(
elements.next() as Double,
elements.next() as Double,
)
}
}
}
/** Coordinates of a chunk in a [SurfaceData] where each integer `x`/`y` represents a different
* chunk.
*
* A [MapChunkPosition] can be translated to a [MapEntityPosition] by multiplying the `x`/`y`
* values by [32][MAP_CHUNK_SIZE].
*/
@Serializable(with = MapChunkPosition.Serializer::class)
data class MapChunkPosition(
val x: Int,
val y: Int,
) {
object Serializer : TupleSerializer<MapChunkPosition>(
"MapChunkPosition",
{
element(MapChunkPosition::x)
element(MapChunkPosition::y)
}
) {
override fun tupleConstructor(elements: Iterator<*>): MapChunkPosition {
return MapChunkPosition(
elements.next() as Int,
elements.next() as Int,
)
}
}
private val bounds: MapChunkPositionBounds by lazy {
MapChunkPositionBounds(this, ChunkSize.CHUNK_032)
}
operator fun contains(tilePosition: MapTilePosition): Boolean = tilePosition in bounds
}
@Serializable
data class MapChunkPositionBounds(
val leftTop: MapTilePosition,
val rightBottom: MapTilePosition,
) : ClosedRange<MapTilePosition> {
constructor(chunkPosition: MapChunkPosition, chunkSize: ChunkSize) : this(
chunkPosition.leftTopTile(chunkSize),
chunkPosition.rightBottomTile(chunkSize),
)
override val start: MapTilePosition
get() = leftTop
override val endInclusive: MapTilePosition
get() = rightBottom
}
/**
* Coordinates of a tile in a chunk on a [SurfaceData] where each integer `x`/`y` represents a
* different [MapTile].
*
* It rounds any `x`/`y` down to whole numbers.
*/
@Serializable(with = MapTilePosition.Serializer::class)
data class MapTilePosition(
val x: Int,
val y: Int,
) : Comparable<MapTilePosition> {
object Serializer : TupleSerializer<MapTilePosition>(
"MapTilePosition",
{
element(MapTilePosition::x)
element(MapTilePosition::y)
}
) {
override fun tupleConstructor(elements: Iterator<*>): MapTilePosition {
val x = requireNotNull(elements.next() as? Int)
val y = requireNotNull(elements.next() as? Int)
return MapTilePosition(x, y)
}
}
override operator fun compareTo(other: MapTilePosition): Int =
when {
other.x != x -> x.compareTo(other.x)
else -> y.compareTo(other.y)
}
}
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
operator fun MapTilePosition.times(factor: Int) =
MapTilePosition(x * factor, y * factor)
operator fun MapTilePosition.div(divisor: Int) =
MapTilePosition(x / divisor, y / divisor)
operator fun MapTilePosition.plus(addend: Int) =
MapTilePosition(x + addend, y + addend)
operator fun MapTilePosition.plus(addend: MapTilePosition) =
MapTilePosition(x + addend.x, y + addend.y)
operator fun MapTilePosition.minus(subtrahend: MapTilePosition) =
MapTilePosition(x - subtrahend.x, y - subtrahend.y)
fun MapTilePosition.toMapChunkPosition(chunkSize: ChunkSize) =
MapChunkPosition(
floor(x.toDouble() / chunkSize.lengthInTiles.toDouble()).toInt(),
floor(y.toDouble() / chunkSize.lengthInTiles.toDouble()).toInt(),
)
fun MapEntityPosition.toMapTilePosition() =
MapChunkPosition(floor(x).toInt(), floor(y).toInt())
fun MapEntityPosition.toMapChunkPosition(chunkSize: ChunkSize) =
MapChunkPosition(
floor(x / chunkSize.lengthInTiles.toDouble()).toInt(),
floor(y / chunkSize.lengthInTiles.toDouble()).toInt(),
)
operator fun MapChunkPosition.times(factor: Int) =
MapChunkPosition(x * factor, y * factor)
operator fun MapChunkPosition.plus(addend: Int) =
MapChunkPosition(x + addend, y + addend)
operator fun MapChunkPosition.minus(subtrahend: Int) =
MapChunkPosition(x - subtrahend, y - subtrahend)
fun MapChunkPosition.leftTopTile(chunkSize: ChunkSize): MapTilePosition =
MapTilePosition(
x * chunkSize.lengthInTiles,
y * chunkSize.lengthInTiles,
)
fun MapChunkPosition.rightBottomTile(chunkSize: ChunkSize): MapTilePosition =
leftTopTile(chunkSize) + (chunkSize.lengthInTiles - 1)
fun MapChunkPosition.iterateTiles(chunkSize: ChunkSize): 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
// get() = leftTopTile + (MAP_CHUNK_SIZE - 1)
//
//val MapChunkPosition.boundingBox: MapBoundingBox
// get() = MapBoundingBox(leftTopTile, rightBottomTile)
@Serializable
enum class ChunkSize(
val zoomLevel: Int,
) : Comparable<ChunkSize> {
CHUNK_512(-1),
CHUNK_256(0),
CHUNK_128(1),
CHUNK_064(2),
CHUNK_032(3),
;
/** 32, 64, 128, 256, or 512 */
val lengthInTiles: Int = 2f.pow(8 - zoomLevel).roundToInt()
init {
require(lengthInTiles > 0) { "tilesPerChunk $lengthInTiles must be positive" }
// 1000 & 0111 = 0000 => pow^2
// 1001 & 1000 = 1000 => !pow^2
require(lengthInTiles and (lengthInTiles - 1) == 0) {
"tilesPerChunk $lengthInTiles must be a power-of-two number"
}
}
companion object {
// cache values for better performance. KT-48872
val entries: Set<ChunkSize> = values().toSet()
val MAX: ChunkSize = entries.maxByOrNull { it.lengthInTiles }!!
val MIN: ChunkSize = entries.minByOrNull { it.lengthInTiles }!!
val STANDARD: ChunkSize = entries.first { it.zoomLevel == 0 }
}
}

View file

@ -0,0 +1,42 @@
package dev.adamko.kafkatorio.schema.common
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("kafkatorio.resource.MinedProduct")
data class MinedProduct(
/** `item` or `fluid`. */
val type: Type,
/** Prototype name of the result. */
@SerialName("name")
val resultPrototype: PrototypeName,
//Amount of the item or fluid to give. If not specified, amount_min, amount_max and probability must all be specified.
val amount: Double?,
//Minimal amount of the item or fluid to give. Has no effect when amount is specified.
//amount_min : Uint or double?,
//Maximum amount of the item or fluid to give. Has no effect when amount is specified.
//amount_max : Uint or double?,
//A value in range [0, 1]. Item or fluid is only given with this probability; otherwise no product is produced.
//probability : Double?,
//How much of this product is a catalyst.
//catalyst_amount : Uint or double?,
) {
@Serializable
@SerialName("kafkatorio.resource.MinedProductType")
enum class Type {
@SerialName("item")
ITEM,
@SerialName("fluid")
FLUID,
}
}

View file

@ -0,0 +1,27 @@
package dev.adamko.kafkatorio.schema.packets
import dev.adamko.kafkatorio.schema.common.ServerMapChunkId
import dev.adamko.kafkatorio.schema.common.TilePngFilename
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
sealed class EventServerPacket {
@Serializable
@SerialName("kafkatorio.event-server-packet.Kafkatorio")
data class Kafkatorio(
val packet: KafkatorioPacket
) : EventServerPacket()
@Serializable
@SerialName("kafkatorio.event-server-packet.ChunkTileSaved")
data class ChunkTileSaved(
val chunkId: ServerMapChunkId,
val filename: TilePngFilename,
) : EventServerPacket()
}

View file

@ -0,0 +1,28 @@
package dev.adamko.kafkatorio.schema.packets
import dev.adamko.kafkatorio.schema.common.Tick
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName("kafkatorio.packet.KafkatorioPacket")
data class KafkatorioPacket(
val modVersion: String,
val tick: Tick,
val data: KafkatorioPacketData,
)
@Serializable
@SerialName("kafkatorio.packet.KafkatorioPacketData")
sealed class KafkatorioPacketData {
@Serializable
@SerialName("kafkatorio.packet.KafkatorioPacketData.Error")
data class Error(
val message: String? = null,
val rawValue: String? = null,
) : KafkatorioPacketData()
}

View file

@ -0,0 +1,78 @@
package dev.adamko.kafkatorio.schema.packets
import dev.adamko.kafkatorio.schema.common.FactorioPrototype
import dev.adamko.kafkatorio.schema.common.PlayerIndex
import dev.adamko.kafkatorio.schema.common.SurfaceIndex
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** No key, no caching. Must be sent instantly. */
@Serializable
@SerialName("kafkatorio.packet.KafkatorioPacket")
sealed class KafkatorioInstantPacketData : KafkatorioPacketData()
/* *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** */
@Serializable
@SerialName("kafkatorio.packet.instant.ConfigurationUpdate")
data class ConfigurationUpdate(
val factorioData: ConfigurationUpdateGameData,
val allMods: List<ConfigurationUpdateModData>,
val modStartupSettingsChange: Boolean,
val migrationApplied: Boolean,
) : KafkatorioInstantPacketData() {
@Serializable
@SerialName("kafkatorio.packet.instant.ConfigurationUpdate.ConfigurationUpdateGameData")
data class ConfigurationUpdateGameData(
val oldVersion: String? = null,
val newVersion: String? = null,
)
@Serializable
@SerialName("kafkatorio.packet.instant.ConfigurationUpdate.ConfigurationUpdateModData")
data class ConfigurationUpdateModData(
val modName: String,
val currentVersion: String? = null,
val previousVersion: String? = null,
)
}
@Serializable
@SerialName("kafkatorio.packet.instant.ConsoleChatUpdate")
data class ConsoleChatUpdate(
val authorPlayerIndex: PlayerIndex?,
val content: String,
) : KafkatorioInstantPacketData()
@Serializable
@SerialName("kafkatorio.packet.instant.ConsoleCommandUpdate")
data class ConsoleCommandUpdate(
val authorPlayerIndex: PlayerIndex?,
val command: String,
val parameters: String,
) : KafkatorioInstantPacketData()
@Serializable
@SerialName("kafkatorio.packet.instant.PrototypesUpdate")
data class PrototypesUpdate(
val prototypes: List<FactorioPrototype>,
) : KafkatorioInstantPacketData()
@Serializable
@SerialName("kafkatorio.packet.instant.SurfaceUpdate")
data class SurfaceUpdate(
val index: SurfaceIndex,
val daytime: Double,
val name: String,
) : KafkatorioInstantPacketData()
/* *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** */

View file

@ -0,0 +1,137 @@
package dev.adamko.kafkatorio.schema.packets
import dev.adamko.kafkatorio.schema.common.Colour
import dev.adamko.kafkatorio.schema.common.EntityIdentifiers
import dev.adamko.kafkatorio.schema.common.EntityIdentifiersData
import dev.adamko.kafkatorio.schema.common.EventName
import dev.adamko.kafkatorio.schema.common.ForceIndex
import dev.adamko.kafkatorio.schema.common.MapChunkPosition
import dev.adamko.kafkatorio.schema.common.MapEntityPosition
import dev.adamko.kafkatorio.schema.common.MapTileDictionary
import dev.adamko.kafkatorio.schema.common.PlayerIndex
import dev.adamko.kafkatorio.schema.common.PrototypeName
import dev.adamko.kafkatorio.schema.common.SurfaceIndex
import dev.adamko.kafkatorio.schema.common.Tick
import dev.adamko.kafkatorio.schema.common.UnitNumber
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Has a key, so it can be debounced */
@Serializable
@SerialName("kafkatorio.packet.keyed.KafkatorioKeyedPacketData")
sealed class KafkatorioKeyedPacketData : KafkatorioPacketData() {
abstract val key: KafkatorioKeyedPacketKey
/** A list of all events that have been aggregated into this packet. */
abstract val events: Map<EventName, List<Tick>>?
}
@Serializable
@SerialName("kafkatorio.packet.keyed.KafkatorioKeyedPacketKey")
sealed class KafkatorioKeyedPacketKey
/* *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** */
@Serializable
@SerialName("kafkatorio.packet.keyed.EntityUpdateKey")
data class EntityUpdateKey(
/** While normally optional, here a [unitNumber] is required for caching */
override val unitNumber: UnitNumber,
override val name: String,
override val protoType: String,
) : EntityIdentifiers, KafkatorioKeyedPacketKey()
@Serializable
@SerialName("kafkatorio.packet.keyed.EntityUpdate")
data class EntityUpdate(
override val key: EntityUpdateKey,
override val events: Map<EventName, List<Tick>>? = null,
val chunkPosition: MapEntityPosition? = null,
val graphicsVariation: UByte? = null,
val health: Float? = null,
val isActive: Boolean? = null,
val isRotatable: Boolean? = null,
val lastUser: UInt? = null,
val localisedDescription: String? = null,
val localisedName: String? = null,
val prototype: PrototypeName? = null,
) : KafkatorioKeyedPacketData()
/* *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** */
@Serializable
@SerialName("kafkatorio.packet.keyed.MapChunkUpdateKey")
data class MapChunkUpdateKey(
val chunkPosition: MapChunkPosition,
val surfaceIndex: SurfaceIndex,
) : KafkatorioKeyedPacketKey()
@Serializable
@SerialName("kafkatorio.packet.keyed.MapChunkUpdate")
data class MapChunkUpdate(
override val key: MapChunkUpdateKey,
override val events: Map<EventName, List<Tick>>? = null,
val player: PlayerIndex? = null,
val robot: EntityIdentifiersData? = null,
val force: ForceIndex? = null,
/** updated tiles - might be partial and not all tiles in the chunk */
val tileDictionary: MapTileDictionary? = null,
val isDeleted: Boolean? = null,
) : KafkatorioKeyedPacketData()
/* *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** */
@Serializable
@SerialName("kafkatorio.packet.keyed.PlayerUpdateKey")
data class PlayerUpdateKey(
val index: PlayerIndex,
) : KafkatorioKeyedPacketKey()
@Serializable
@SerialName("kafkatorio.packet.keyed.PlayerUpdate")
data class PlayerUpdate(
override val key: PlayerUpdateKey,
override val events: Map<EventName, List<Tick>>? = null,
val characterUnitNumber: UnitNumber? = null,
val chatColour: Colour? = null,
val colour: Colour? = null,
val name: String? = null,
val afkTime: Tick? = null,
val ticksToRespawn: Tick? = null,
val forceIndex: ForceIndex? = null,
val isAdmin: Boolean? = null,
val isConnected: Boolean? = null,
val isShowOnMap: Boolean? = null,
val isSpectator: Boolean? = null,
val lastOnline: Tick? = null,
val onlineTime: Tick? = null,
val position: MapEntityPosition? = null,
val surfaceIndex: SurfaceIndex? = null,
val tag: String? = null,
val diedCause: EntityIdentifiersData? = null,
val bannedReason: String? = null,
val kickedReason: String? = null,
val disconnectReason: String? = null,
/** `true` when a player is removed (deleted) from the game */
val isRemoved: Boolean? = null,
) : KafkatorioKeyedPacketData()
/* *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** */

View file

@ -0,0 +1,81 @@
package dev.adamko.kafkatorio.events.schema
import dev.adamko.kafkatorio.library.jsonMapperKafkatorio
import dev.adamko.kafkatorio.schema.common.Colour
import dev.adamko.kafkatorio.schema.common.ColourHex
import dev.adamko.kafkatorio.schema.common.toHex
import dev.adamko.kafkatorio.schema.common.toPercentile
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.floats.shouldBeWithinPercentageOf
import io.kotest.matchers.should
import io.kotest.matchers.shouldBe
import io.kotest.property.checkAll
import kotlinx.serialization.decodeFromString
class ColourHexTest : BehaviorSpec({
Given("colour json") {
//Language=JSON
val colourJson = """
[0.1, 0.4, 0.7, 0.6]
""".trimIndent()
val colour: Colour = jsonMapperKafkatorio.decodeFromString(colourJson)
Then("expect colour is decoded") {
colour shouldBe Colour(
0.1f,
0.4f,
0.7f,
0.6f,
)
}
Then("expect Colour can be converted to ColourHex and back") {
val colourHex = colour.toHex()
colourHex shouldBe ColourHex(
25u,
102u,
178u,
153u,
)
colourHex.toPercentile() should { back ->
withClue(back) {
val tolerance = 2.6 // 2.6%, because 255/100 = 2.55
withClue("red") { back.red.shouldBeWithinPercentageOf(0.1f, tolerance) }
withClue("green") { back.green.shouldBeWithinPercentageOf(0.4f, tolerance) }
withClue("blue") { back.blue.shouldBeWithinPercentageOf(0.7f, tolerance) }
withClue("alpha") { back.alpha.shouldBeWithinPercentageOf(0.6f, tolerance) }
}
}
}
}
Given("any colour json") {
Then("expect can always convert") {
checkAll<UByte, UByte, UByte, UByte> { r, g, b, a ->
//Language=JSON
val colourJson = """
[
${r.toFloat() / UByte.MAX_VALUE.toFloat()},
${g.toFloat() / UByte.MAX_VALUE.toFloat()},
${b.toFloat() / UByte.MAX_VALUE.toFloat()},
${a.toFloat() / UByte.MAX_VALUE.toFloat()}
]
""".trimIndent()
val colour: Colour = jsonMapperKafkatorio.decodeFromString(colourJson)
val colourHex = colour.toHex()
colourHex shouldBe ColourHex(r, g, b, a)
}
}
}
})

View file

@ -0,0 +1,94 @@
package dev.adamko.kafkatorio.events.schema
import dev.adamko.kafkatorio.schema.common.EventName
import dev.adamko.kafkatorio.schema.common.MapChunkPosition
import dev.adamko.kafkatorio.schema.common.MapTileDictionary
import dev.adamko.kafkatorio.schema.common.PrototypeName
import dev.adamko.kafkatorio.schema.common.SurfaceIndex
import dev.adamko.kafkatorio.schema.common.tick
import dev.adamko.kafkatorio.schema.jsonMapperKafkatorio
import dev.adamko.kafkatorio.schema.packets.KafkatorioPacket
import dev.adamko.kafkatorio.schema.packets.MapChunkUpdate
import dev.adamko.kafkatorio.schema.packets.MapChunkUpdateKey
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.serialization.decodeFromString
class MapChunkUpdateTest : FunSpec({
context("MapChunkUpdate update") {
//language=json
val json = """
{
"data": {
"type": "kafkatorio.packet.keyed.MapChunkUpdate",
"key": {
"surfaceIndex": 1,
"chunkPosition": [
-6,
-3
]
},
"tileDictionary": {
"tilesXY": {
"-192": {
"-65": 2
},
"161": {
"-96": 1,
"-95": 2,
"94": 2,
"93": 32
}
},
"protos": {
"water": 1,
"grass-4": 2,
"grass-3": 32
}
},
"events": {
"on_chunk_generated": [1]
}
},
"modVersion": "0.4.0",
"tick": 36
}
""".trimIndent()
val expected = KafkatorioPacket(
tick = 36u.tick,
modVersion = "0.4.0",
data = MapChunkUpdate(
key = MapChunkUpdateKey(
MapChunkPosition(-6, -3),
SurfaceIndex(1u),
),
events = mapOf(
EventName("on_chunk_generated") to listOf(1u.tick)
),
tileDictionary = MapTileDictionary(
tilesXY = mapOf(
"-192" to mapOf("-65" to MapTileDictionary.PrototypeKey(2)),
"161" to mapOf(
"-96" to MapTileDictionary.PrototypeKey(1),
"-95" to MapTileDictionary.PrototypeKey(2),
"94" to MapTileDictionary.PrototypeKey(2),
"93" to MapTileDictionary.PrototypeKey(32),
),
),
protos = mapOf(
PrototypeName("water") to MapTileDictionary.PrototypeKey(1),
PrototypeName("grass-4") to MapTileDictionary.PrototypeKey(2),
PrototypeName("grass-3") to MapTileDictionary.PrototypeKey(32),
)
)
)
)
test("decode") {
val packet: KafkatorioPacket = jsonMapperKafkatorio.decodeFromString(json)
packet shouldBe expected
}
}
})

View file

@ -0,0 +1,681 @@
package dev.adamko.kafkatorio.events.schema
import dev.adamko.kafkatorio.schema.common.Colour
import dev.adamko.kafkatorio.schema.common.FactorioPrototype
import dev.adamko.kafkatorio.schema.common.PrototypeName
import dev.adamko.kafkatorio.schema.jsonMapperKafkatorio
import dev.adamko.kafkatorio.schema.packets.KafkatorioPacket
import dev.adamko.kafkatorio.schema.packets.PrototypesUpdate
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.inspectors.forOne
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import kotlinx.serialization.decodeFromString
class PrototypesTest : BehaviorSpec({
Given("Prototypes Json") {
// language=JSON
val raw = """
{
"modVersion": "0.2.4",
"tick": 534,
"data": {
"type": "kafkatorio.packet.instant.PrototypesUpdate",
"prototypes": [
{
"type": "kafkatorio.prototype.MapTile",
"name": "tile-unknown",
"order": "z-a",
"layer": 0,
"collisionMasks": {},
"mapColour": [0, 0, 0, 255],
"canBeMined": false
}
]
}
}
""".trimIndent()
Then("expect can be parsed") {
val data = jsonMapperKafkatorio.decodeFromString<KafkatorioPacket>(raw)
println(data)
}
}
Given("Big prototypes json") {
// language=JSON
val raw = """
{
"modVersion": "0.3.2",
"tick": 534,
"data": {
"type": "kafkatorio.packet.instant.PrototypesUpdate",
"prototypes": [
{
"type": "kafkatorio.prototype.MapTile",
"name": "stone-path",
"order": "a[artificial]-a[tier-1]-a[stone-path]",
"layer": 60,
"collisionMasks": [
"ground-tile"
],
"mapColour": [86, 82, 74, 255],
"canBeMined": true
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "concrete",
"order": "a[artificial]-b[tier-2]-a[concrete]",
"layer": 61,
"collisionMasks": [
"ground-tile"
],
"mapColour": [63, 61, 59, 255],
"canBeMined": true
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "hazard-concrete-left",
"order": "a[artificial]-b[tier-2]-b[hazard-concrete-left]",
"layer": 62,
"collisionMasks": [
"ground-tile"
],
"mapColour": [176, 142, 39, 255],
"canBeMined": true
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "hazard-concrete-right",
"order": "a[artificial]-b[tier-2]-c[hazard-concrete-right]",
"layer": 62,
"collisionMasks": [
"ground-tile"
],
"mapColour": [176, 142, 39, 255],
"canBeMined": true
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "refined-concrete",
"order": "a[artificial]-c[tier-3]-a[refined-concrete]",
"layer": 64,
"collisionMasks": [
"ground-tile"
],
"mapColour": [49, 48, 45, 255],
"canBeMined": true
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "refined-hazard-concrete-left",
"order": "a[artificial]-c[tier-3]-b[refined-hazard-concrete-left]",
"layer": 65,
"collisionMasks": [
"ground-tile"
],
"mapColour": [116, 94, 26, 255],
"canBeMined": true
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "refined-hazard-concrete-right",
"order": "a[artificial]-c[tier-3]-c[refined-hazard-concrete-right]",
"layer": 65,
"collisionMasks": [
"ground-tile"
],
"mapColour": [116, 94, 26, 255],
"canBeMined": true
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "landfill",
"order": "a[artificial]-d[utility]-a[landfill]",
"layer": 57,
"collisionMasks": [
"ground-tile"
],
"mapColour": [57, 39, 26, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "acid-refined-concrete",
"order": "a[artificial]-e[color-concrete]-acid",
"layer": 97,
"collisionMasks": [
"ground-tile"
],
"mapColour": [142, 194, 40, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "black-refined-concrete",
"order": "a[artificial]-e[color-concrete]-black",
"layer": 88,
"collisionMasks": [
"ground-tile"
],
"mapColour": [25, 25, 25, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "blue-refined-concrete",
"order": "a[artificial]-e[color-concrete]-blue",
"layer": 73,
"collisionMasks": [
"ground-tile"
],
"mapColour": [39, 137, 228, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "brown-refined-concrete",
"order": "a[artificial]-e[color-concrete]-brown",
"layer": 91,
"collisionMasks": [
"ground-tile"
],
"mapColour": [76, 29, 0, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "cyan-refined-concrete",
"order": "a[artificial]-e[color-concrete]-cyan",
"layer": 94,
"collisionMasks": [
"ground-tile"
],
"mapColour": [70, 192, 181, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "green-refined-concrete",
"order": "a[artificial]-e[color-concrete]-green",
"layer": 70,
"collisionMasks": [
"ground-tile"
],
"mapColour": [23, 195, 43, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "orange-refined-concrete",
"order": "a[artificial]-e[color-concrete]-orange",
"layer": 76,
"collisionMasks": [
"ground-tile"
],
"mapColour": [221, 127, 33, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "pink-refined-concrete",
"order": "a[artificial]-e[color-concrete]-pink",
"layer": 82,
"collisionMasks": [
"ground-tile"
],
"mapColour": [236, 98, 131, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "purple-refined-concrete",
"order": "a[artificial]-e[color-concrete]-purple",
"layer": 85,
"collisionMasks": [
"ground-tile"
],
"mapColour": [123, 28, 168, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "red-refined-concrete",
"order": "a[artificial]-e[color-concrete]-red",
"layer": 67,
"collisionMasks": [
"ground-tile"
],
"mapColour": [207, 6, 0, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "yellow-refined-concrete",
"order": "a[artificial]-e[color-concrete]-yellow",
"layer": 79,
"collisionMasks": [
"ground-tile"
],
"mapColour": [212, 169, 19, 127],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "grass-1",
"order": "b[natural]-a[grass]-a[grass-1]",
"layer": 26,
"collisionMasks": [
"ground-tile"
],
"mapColour": [55, 53, 11, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "grass-2",
"order": "b[natural]-a[grass]-b[grass-2]",
"layer": 28,
"collisionMasks": [
"ground-tile"
],
"mapColour": [66, 57, 15, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "grass-3",
"order": "b[natural]-a[grass]-c[grass-3]",
"layer": 29,
"collisionMasks": [
"ground-tile"
],
"mapColour": [65, 52, 28, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "grass-4",
"order": "b[natural]-a[grass]-d[grass-4]",
"layer": 30,
"collisionMasks": [
"ground-tile"
],
"mapColour": [59, 40, 18, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "dry-dirt",
"order": "b[natural]-b[dirt]-a[dry-dirt]",
"layer": 18,
"collisionMasks": [
"ground-tile"
],
"mapColour": [94, 66, 37, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "dirt-1",
"order": "b[natural]-b[dirt]-b[dirt-1]",
"layer": 19,
"collisionMasks": [
"ground-tile"
],
"mapColour": [141, 104, 60, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "dirt-2",
"order": "b[natural]-b[dirt]-c[dirt-2]",
"layer": 20,
"collisionMasks": [
"ground-tile"
],
"mapColour": [136, 96, 59, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "dirt-3",
"order": "b[natural]-b[dirt]-d[dirt-3]",
"layer": 21,
"collisionMasks": [
"ground-tile"
],
"mapColour": [133, 92, 53, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "dirt-4",
"order": "b[natural]-b[dirt]-e[dirt-4]",
"layer": 22,
"collisionMasks": [
"ground-tile"
],
"mapColour": [103, 72, 43, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "dirt-5",
"order": "b[natural]-b[dirt]-f[dirt-5]",
"layer": 23,
"collisionMasks": [
"ground-tile"
],
"mapColour": [91, 63, 38, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "dirt-6",
"order": "b[natural]-b[dirt]-g[dirt-6]",
"layer": 24,
"collisionMasks": [
"ground-tile"
],
"mapColour": [80, 55, 31, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "dirt-7",
"order": "b[natural]-b[dirt]-h[dirt-7]",
"layer": 25,
"collisionMasks": [
"ground-tile"
],
"mapColour": [80, 54, 28, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "sand-1",
"order": "b[natural]-c[sand]-a[sand-1]",
"layer": 8,
"collisionMasks": [
"ground-tile"
],
"mapColour": [138, 103, 58, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "sand-2",
"order": "b[natural]-c[sand]-b[sand-2]",
"layer": 9,
"collisionMasks": [
"ground-tile"
],
"mapColour": [128, 93, 52, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "sand-3",
"order": "b[natural]-c[sand]-c[sand-3]",
"layer": 10,
"collisionMasks": [
"ground-tile"
],
"mapColour": [115, 83, 47, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "red-desert-0",
"order": "b[natural]-d[red-desert]-a[red-desert-0]",
"layer": 31,
"collisionMasks": [
"ground-tile"
],
"mapColour": [103, 70, 32, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "red-desert-1",
"order": "b[natural]-d[red-desert]-b[red-desert-1]",
"layer": 14,
"collisionMasks": [
"ground-tile"
],
"mapColour": [116, 81, 39, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "red-desert-2",
"order": "b[natural]-d[red-desert]-c[red-desert-2]",
"layer": 15,
"collisionMasks": [
"ground-tile"
],
"mapColour": [116, 84, 43, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "red-desert-3",
"order": "b[natural]-d[red-desert]-d[red-desert-3]",
"layer": 16,
"collisionMasks": [
"ground-tile"
],
"mapColour": [128, 93, 52, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "water",
"order": "c[water]-a[water]",
"layer": 3,
"collisionMasks": [
"doodad-layer",
"item-layer",
"player-layer",
"resource-layer",
"water-tile"
],
"mapColour": [51, 83, 95, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "deepwater",
"order": "c[water]-b[deep-water]",
"layer": 3,
"collisionMasks": [
"doodad-layer",
"item-layer",
"player-layer",
"resource-layer",
"water-tile"
],
"mapColour": [38, 64, 73, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "water-green",
"order": "c[water]-c[water-green]",
"layer": 3,
"collisionMasks": [
"doodad-layer",
"item-layer",
"player-layer",
"resource-layer",
"water-tile"
],
"mapColour": [31, 48, 18, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "deepwater-green",
"order": "c[water]-d[deepwater-green]",
"layer": 3,
"collisionMasks": [
"doodad-layer",
"item-layer",
"player-layer",
"resource-layer",
"water-tile"
],
"mapColour": [23, 37, 16, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "water-shallow",
"order": "c[water]-e[water-shallow]",
"layer": 6,
"collisionMasks": [
"item-layer",
"object-layer",
"resource-layer",
"water-tile"
],
"mapColour": [82, 98, 92, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "water-mud",
"order": "c[water]-g[water-mud]",
"layer": 7,
"collisionMasks": [
"item-layer",
"object-layer",
"resource-layer",
"water-tile"
],
"mapColour": [65, 89, 90, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "nuclear-ground",
"order": "d[destruction]-a[nuclear]-a[nuclear-ground]",
"layer": 33,
"collisionMasks": [
"ground-tile"
],
"mapColour": [48, 40, 35, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "water-wube",
"order": "x[wube]-a[water-wube]",
"layer": 2,
"collisionMasks": [
"doodad-layer",
"item-layer",
"player-layer",
"resource-layer",
"water-tile"
],
"mapColour": [0, 0, 0, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "tile-unknown",
"order": "z-a",
"layer": 0,
"collisionMasks": {
},
"mapColour": [0, 0, 0, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "out-of-map",
"order": "z[other]-a[out-of-map]",
"layer": 0,
"collisionMasks": [
"doodad-layer",
"floor-layer",
"ground-tile",
"item-layer",
"object-layer",
"player-layer",
"resource-layer",
"water-tile"
],
"mapColour": [0, 0, 0, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "lab-dark-1",
"order": "z[other]-b[lab]-a[lab-dark-1]",
"layer": 70,
"collisionMasks": [
"ground-tile"
],
"mapColour": [49, 49, 49, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "lab-dark-2",
"order": "z[other]-b[lab]-b[lab-dark-2]",
"layer": 70,
"collisionMasks": [
"ground-tile"
],
"mapColour": [0, 0, 0, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "lab-white",
"order": "z[other]-b[lab]-c[lab-white]",
"layer": 70,
"collisionMasks": [
"ground-tile"
],
"mapColour": [255, 255, 255, 255],
"canBeMined": false
},
{
"type": "kafkatorio.prototype.MapTile",
"name": "tutorial-grid",
"order": "z[other]-c[tutorial]-a[tutorial-grid]",
"layer": 55,
"collisionMasks": [
"ground-tile"
],
"mapColour": [122, 122, 122, 255],
"canBeMined": false
}
]
}
}
""".trimIndent()
Then("expect can be parsed") {
val packet: KafkatorioPacket = jsonMapperKafkatorio.decodeFromString(raw)
// println(packet)
packet.data.shouldBeInstanceOf<PrototypesUpdate>()
.prototypes
.forOne {
it.shouldBeInstanceOf<FactorioPrototype.MapTile>()
it.name shouldBe PrototypeName("tutorial-grid")
it.order shouldBe "z[other]-c[tutorial]-a[tutorial-grid]"
it.layer shouldBe 55u
it.collisionMasks shouldBe listOf("ground-tile")
it.mapColour shouldBe Colour(122f, 122f, 122f, 255f)
it.canBeMined shouldBe false
}
}
}
})

View file

@ -0,0 +1,253 @@
package dev.adamko.kafkatorio.events.schema
import dev.adamko.kafkatorio.schema.common.Colour
import dev.adamko.kafkatorio.schema.common.EntityIdentifiersData
import dev.adamko.kafkatorio.schema.common.EventName
import dev.adamko.kafkatorio.schema.common.ForceIndex
import dev.adamko.kafkatorio.schema.common.MapEntityPosition
import dev.adamko.kafkatorio.schema.common.PlayerIndex
import dev.adamko.kafkatorio.schema.common.SurfaceIndex
import dev.adamko.kafkatorio.schema.common.Tick
import dev.adamko.kafkatorio.schema.common.UnitNumber
import dev.adamko.kafkatorio.schema.common.tick
import dev.adamko.kafkatorio.schema.jsonMapperKafkatorio
import dev.adamko.kafkatorio.schema.packets.KafkatorioKeyedPacketData
import dev.adamko.kafkatorio.schema.packets.KafkatorioPacket
import dev.adamko.kafkatorio.schema.packets.PlayerUpdate
import dev.adamko.kafkatorio.schema.packets.PlayerUpdateKey
import dev.adamko.kafkatorio.schema.packets.SurfaceUpdate
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
class UpdatePacketsTest : FunSpec({
context("player update packet") {
// language=JSON
val json = """
{
"tick": 66,
"modVersion": "0.3.2",
"data": {
"type": "kafkatorio.packet.keyed.PlayerUpdate",
"key": {"index": 1},
"afkTime": 0,
"chatColour": [1, 0.4, 0.6, 1],
"colour": [0.8, 0.5, 0.2, 0.5],
"events": {
"on_player_changed_position": [7, 99],
"on_player_joined_game": [14]
},
"forceIndex": 1,
"isAdmin": true,
"isConnected": true,
"isShowOnMap": true,
"isSpectator": false,
"lastOnline": 0,
"name": "fredthedeadhead",
"onlineTime": 0,
"position": [
-58,
13
],
"tag": ""
}
}
""".trimIndent()
test("decode") {
val packet: KafkatorioPacket = jsonMapperKafkatorio.decodeFromString(json)
packet shouldBe KafkatorioPacket(
modVersion = "0.3.2",
tick = Tick(66u),
data = PlayerUpdate(
key = PlayerUpdateKey(PlayerIndex(1u)),
afkTime = Tick(0u),
bannedReason = null,
characterUnitNumber = null,
chatColour = Colour(red = 1f, green = 0.4f, blue = 0.6f, alpha = 1f),
colour = Colour(red = 0.8f, green = 0.5f, blue = 0.2f, alpha = 0.5f),
diedCause = null,
disconnectReason = null,
events = mapOf(
EventName("on_player_changed_position") to listOf(7u.tick, 99u.tick),
EventName("on_player_joined_game") to listOf(14u.tick),
),
forceIndex = ForceIndex(1u),
isAdmin = true,
isConnected = true,
isRemoved = null,
isShowOnMap = true,
isSpectator = false,
kickedReason = null,
lastOnline = Tick(0u),
name = "fredthedeadhead",
onlineTime = Tick(0u),
position = MapEntityPosition(-58.0, 13.0),
surfaceIndex = null,
tag = "",
ticksToRespawn = null,
)
)
}
test("encode") {
val v = KafkatorioPacket(
modVersion = "0.3.2",
tick = Tick(66u),
data = PlayerUpdate(
key = PlayerUpdateKey(PlayerIndex(1u)),
characterUnitNumber = null,
chatColour = null,
colour = null,
name = "fredthedeadhead",
afkTime = Tick(0u),
ticksToRespawn = null,
forceIndex = null,
isAdmin = null,
isConnected = null,
isShowOnMap = true,
isSpectator = false,
lastOnline = Tick(0u),
onlineTime = Tick(0u),
position = MapEntityPosition(-58.0, 13.0),
surfaceIndex = null,
tag = null,
diedCause = null,
bannedReason = null,
kickedReason = null,
disconnectReason = null,
isRemoved = null,
)
)
val string = jsonMapperKafkatorio.encodeToString(v)
// language=JSON
string shouldBe """
{
"modVersion": "0.3.2",
"tick": 66,
"data": {
"type": "kafkatorio.packet.keyed.PlayerUpdate",
"key": {
"index": 1
},
"name": "fredthedeadhead",
"afkTime": 0,
"isShowOnMap": true,
"isSpectator": false,
"lastOnline": 0,
"onlineTime": 0,
"position": [
-58.0,
13.0
]
}
}
""".trimIndent()
}
}
context("player died") {
// language=JSON
val json = """
{
"tick": 227934,
"modVersion": "0.3.2",
"data": {
"type": "kafkatorio.packet.keyed.PlayerUpdate",
"key": {
"index": 1
},
"lastOnline": 227932,
"onlineTime": 227888,
"afkTime": 324,
"isConnected": true,
"diedCause": {
"unitNumber": 66,
"name": "small-biter",
"protoType": "unit"
},
"events": {
"on_player_died": [2]
}
}
}
""".trimIndent()
test("decode") {
val packet: KafkatorioPacket = jsonMapperKafkatorio.decodeFromString(json)
packet shouldBe KafkatorioPacket(
modVersion = "0.3.2",
tick = 227934u.tick,
data = PlayerUpdate(
afkTime = 324u.tick,
bannedReason = null,
characterUnitNumber = null,
chatColour = null,
colour = null,
diedCause = EntityIdentifiersData(
unitNumber = UnitNumber(66u),
name = "small-biter",
protoType = "unit",
),
disconnectReason = null,
events = mapOf(
EventName("on_player_died") to listOf(2u.tick),
),
forceIndex = null,
key = PlayerUpdateKey(PlayerIndex(1u)),
isAdmin = null,
isConnected = true,
isRemoved = null,
isShowOnMap = null,
isSpectator = null,
kickedReason = null,
lastOnline = 227932u.tick,
name = null,
onlineTime = 227888u.tick,
position = null,
surfaceIndex = null,
tag = null,
ticksToRespawn = null,
)
)
}
}
context("surface update") {
//language=json
val json = """
{
"data": {
"type": "kafkatorio.packet.instant.SurfaceUpdate",
"name": "nauvis",
"index": 1,
"daytime": 0.73
},
"modVersion": "0.3.2",
"tick": 176000
}
""".trimIndent()
val expected = KafkatorioPacket(
tick = Tick(176000u),
modVersion = "0.3.2",
data = SurfaceUpdate(
name = "nauvis",
index = SurfaceIndex(1u),
daytime = 0.73,
)
)
println(jsonMapperKafkatorio.encodeToString(expected))
test("decode") {
val packet: KafkatorioPacket = jsonMapperKafkatorio.decodeFromString(json)
packet shouldBe expected
}
}
})

View file

@ -0,0 +1,98 @@
package dev.adamko.kafkatorio.schema.common
import io.kotest.assertions.asClue
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.ints.shouldBeExactly
class MapChunkPositionBoundsTest : FunSpec({
context("create bounds") {
context("MapChunkPosition(0, 0)") {
context("chunk size 32") {
val chunkPos = MapChunkPosition(0, 0)
val bounds = MapChunkPositionBounds(chunkPos, ChunkSize.CHUNK_032)
test("leftTop should be 0,0") {
bounds.leftTop.asClue {
it.x shouldBeExactly 0
it.y shouldBeExactly 0
}
}
test("rightBottom should be 31,31") {
bounds.rightBottom.asClue {
it.x shouldBeExactly 31
it.y shouldBeExactly 31
}
}
}
}
context("chunk size 256") {
val chunkPos = MapChunkPosition(0, 0)
val bounds = MapChunkPositionBounds(chunkPos, ChunkSize.CHUNK_256)
test("leftTop should be 0,0") {
bounds.leftTop.asClue {
it.x shouldBeExactly 0
it.y shouldBeExactly 0
}
}
test("rightBottom should be 255,255") {
bounds.rightBottom.asClue {
it.x shouldBeExactly 255
it.y shouldBeExactly 255
}
}
}
}
context("MapChunkPosition(-10, -10)") {
context("chunk size 32") {
val chunkPos = MapChunkPosition(-10, -10)
val bounds = MapChunkPositionBounds(chunkPos, ChunkSize.CHUNK_032)
test("leftTop should be -320,-320") {
bounds.leftTop.asClue {
it.x shouldBeExactly -320
it.y shouldBeExactly -320
}
}
test("rightBottom should be -289,-289") {
bounds.rightBottom.asClue {
it.x shouldBeExactly -289
it.y shouldBeExactly -289
}
}
}
context("chunk size 256") {
val chunkPos = MapChunkPosition(-10, -10)
val bounds = MapChunkPositionBounds(chunkPos, ChunkSize.CHUNK_256)
test("leftTop should be -2560") {
bounds.leftTop.asClue {
it.x shouldBeExactly -2560
it.y shouldBeExactly -2560
}
}
test("rightBottom should be (-2560 + 255)") {
bounds.rightBottom.asClue {
it.x shouldBeExactly (-2560 + 255)
it.y shouldBeExactly (-2560 + 255)
}
}
}
}
})

View file

@ -0,0 +1,81 @@
package dev.adamko.kafkatorio.schema.common
import io.kotest.core.spec.style.FunSpec
import io.kotest.datatest.withData
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.arbitrary
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
class MapChunkPositionTest : FunSpec({
context("check MapChunkPosition.contains()") {
context("MapChunkPosition(0, 0)") {
val chunkPos = MapChunkPosition(0, 0)
test("should contain tiles from (0,0) to (31,31)") {
checkAll(mapTilePositionArb(0..31)) { tilePos ->
(tilePos in chunkPos) shouldBe true
}
}
test("should not contain tiles below (0,0)") {
checkAll(
mapTilePositionArb(-1000..-1)
) { tilePos ->
(tilePos in chunkPos) shouldBe false
}
}
test("should not contain tiles above (31,31)") {
checkAll(
mapTilePositionArb(32..100)
) { tilePos ->
(tilePos in chunkPos) shouldBe false
}
}
}
context("MapChunkPosition(-10, -10)") {
val chunkPos = MapChunkPosition(-10, -10)
test("should contain tiles from (-320,-320) to (-289,-289)") {
checkAll(mapTilePositionArb(-320..-289)) { tilePos ->
(tilePos in chunkPos) shouldBe true
}
}
test("should not contain tiles below (-320,-320)") {
checkAll(
mapTilePositionArb(-400..-321)
) { tilePos ->
(tilePos in chunkPos) shouldBe false
}
}
test("should not contain tiles above (-289,-289)") {
checkAll(
mapTilePositionArb(-288..100)
) { tilePos ->
(tilePos in chunkPos) shouldBe false
}
}
}
}
}) {
companion object {
fun mapTilePositionArb(
range: IntRange = -1000..1000
) = arbitrary {
MapTilePosition(
Arb.int(range).bind(),
Arb.int(range).bind(),
)
}
}
}

View file

@ -0,0 +1,42 @@
package dev.adamko.kafkatorio.schema.common
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainAll
class MapTileDictionaryTest : FunSpec({
context("MapTileDictionary") {
val actual = MapTileDictionary(
tilesXY = mapOf(
"-192" to mapOf("-65" to MapTileDictionary.PrototypeKey(2)),
"161" to mapOf(
"-96" to MapTileDictionary.PrototypeKey(1),
"-95" to MapTileDictionary.PrototypeKey(2),
"94" to MapTileDictionary.PrototypeKey(2),
"93" to MapTileDictionary.PrototypeKey(32),
),
// unknown prototype
"44" to mapOf("1" to MapTileDictionary.PrototypeKey(44)),
),
protos = mapOf(
PrototypeName("water") to MapTileDictionary.PrototypeKey(1),
PrototypeName("grass-4") to MapTileDictionary.PrototypeKey(2),
PrototypeName("grass-3") to MapTileDictionary.PrototypeKey(32),
PrototypeName("not-used") to MapTileDictionary.PrototypeKey(99),
)
)
test("toMapTileList") {
actual.toMapTileList() shouldContainAll listOf(
MapTile(-192, -65, PrototypeName("grass-4")),
MapTile(161, -96, PrototypeName("water")),
MapTile(161, -95, PrototypeName("grass-4")),
MapTile(161, 94, PrototypeName("grass-4")),
MapTile(161, 93, PrototypeName("grass-3")),
)
}
}
})

View file

@ -0,0 +1,43 @@
package dev.adamko.kafkatorio.schema.common
import dev.adamko.kafkatorio.schema.common.MapChunkPositionTest.Companion.mapTilePositionArb
import io.kotest.core.spec.style.FunSpec
import io.kotest.property.forAll
class MapTilePositionTest : FunSpec({
context("comparison") {
test("less than <") {
forAll(
mapTilePositionArb(),
mapTilePositionArb(1..100),
) { tilePos, delta ->
tilePos < (tilePos + delta)
}
}
test("less than or equal to <=") {
forAll(mapTilePositionArb()) { tilePos ->
tilePos <= (tilePos)
}
}
test("greater than or equal to >=") {
forAll(mapTilePositionArb()) { tilePos ->
tilePos >= (tilePos)
}
}
test("greater than >") {
forAll(
mapTilePositionArb(),
mapTilePositionArb(1..100),
) { tilePos, delta ->
tilePos > (tilePos - delta)
}
}
}
})

View file

@ -0,0 +1,50 @@
package dev.adamko.kafkatorio.schema.packets
import dev.adamko.kafkatorio.schema.jsonMapperKafkatorio
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.types.shouldBeInstanceOf
import kotlinx.serialization.decodeFromString
class EntityUpdateTest : FunSpec({
context("EntityUpdate packet") {
/*language=json*/
val json = """
{
"data": {
"type": "kafkatorio.packet.keyed.EntityUpdate",
"key": {
"name": "assembling-machine-2",
"protoType": "assembling-machine-2",
"unitNumber": 4622
},
"events": {
"on_built_entity": [
38023
]
},
"chunkPosition": [
-13.5,
4.5
],
"health": 350,
"isActive": true,
"isRotatable": true,
"lastUser": 1,
"prototype": "assembling-machine-2"
},
"modVersion": "0.4.0",
"tick": 38388
}
""".trimIndent()
test("decode") {
val entityUpdate: KafkatorioPacket = jsonMapperKafkatorio.decodeFromString(json)
entityUpdate.data
.shouldBeInstanceOf<EntityUpdate>()
// entityUpdate.health.shouldNotBeNull().shouldBeExactly(350f)
}
}
})

View file

@ -0,0 +1,94 @@
package dev.adamko.kafkatorio.events.schema
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.contract
import kotlin.reflect.KClass
import kotlin.reflect.KClassifier
import kotlin.reflect.KProperty
import kotlin.reflect.KType
import kotlin.reflect.KTypeProjection
import kotlin.reflect.full.createType
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.full.starProjectedType
import kotlin.reflect.full.withNullability
import me.ntrrgc.tsGenerator.ClassTransformer
/**
* 'Unwrap' value classes
*/
object ValueClassTransformer : ClassTransformer {
private val mappedValueClasses: MutableMap<KType, KType> = mutableMapOf(
// don't unwrap unsigned numbers - they're mapped to Factorio types
UInt::class.starProjectedType to UInt::class.starProjectedType,
UByte::class.starProjectedType to UByte::class.starProjectedType,
UShort::class.starProjectedType to UShort::class.starProjectedType,
ULong::class.starProjectedType to ULong::class.starProjectedType,
)
override fun transformPropertyType(type: KType, property: KProperty<*>, klass: KClass<*>): KType {
val unwrappedType =
unwrapValueClassType(type, property, klass)
?: unwrapArgs(type, property, klass)
return when (unwrappedType) {
null -> super.transformPropertyType(type, property, klass)
else -> super.transformPropertyType(unwrappedType, property, klass)
}
}
private fun unwrapArgs(type: KType, property: KProperty<*>, klass: KClass<*>): KType? {
val classifier: KClassifier = type.classifier ?: return type
return if (type.arguments.isNotEmpty()) {
println("unwrapping args for $type - ${type.arguments}")
val args = type.arguments.map { arg: KTypeProjection ->
// recursively map each argument
val argType = arg.type!!
val converted = transformPropertyType(argType, property, klass)
KTypeProjection(arg.variance, converted.withNullability(argType.isMarkedNullable))
}
classifier.createType(
arguments = args,
nullable = type.isMarkedNullable,
annotations = type.annotations,
)
} else {
null
}
}
private fun unwrapValueClassType(type: KType, property: KProperty<*>, klass: KClass<*>): KType? {
val valueKlass: KClass<*> = type.classifier as? KClass<*> ?: return null
return if (isValueClass(type)) {
val mappedType = mappedValueClasses.getOrPut(type.withNullability(false)) {
val result = valueKlass
.declaredMemberProperties
.first()
.returnType
.withNullability(type.isMarkedNullable)
println("unwrapping value class $type -> $result")
// recursively unwrap...
val transformed = transformPropertyType(result, property, klass)
transformed.withNullability(false)
}
mappedType.withNullability(type.isMarkedNullable)
} else {
null
}
}
@OptIn(ExperimentalContracts::class)
private fun isValueClass(type: KType?): Boolean {
contract { returnsNotNull() implies (type != null) }
return (type?.classifier as? KClass<*>)?.isValue == true
}
}

View file

@ -0,0 +1,147 @@
package dev.adamko.kafkatorio.events.schema
//import dev.adamko.kafkatorio.schema.events.KafkatorioInstantPacketData
//import dev.adamko.kafkatorio.schema.events.KafkatorioKeyedPacketData
//import dev.adamko.kafkatorio.schema.events.KafkatorioKeyedPacketKey
////import dev.adamko.kafkatorio.schema.events.KafkatorioPacket
//import dev.adamko.kafkatorio.schema.prototypes.FactorioPrototype
//import kotlin.io.path.Path
//import kotlin.io.path.createDirectories
//import me.ntrrgc.tsGenerator.TypeScriptGenerator
//import org.intellij.lang.annotations.Language
//
///**
// * Convert Kotlin classes to Typescript classes, using [TypeScriptGenerator]
// */
//fun main(args: Array<String>) {
//
// val gen = TypeScriptGenerator(
// rootClasses = buildSet {
//// add(KafkatorioPacket::class)
//// addAll(KafkatorioPacket::class.sealedSubclasses)
//
// add(KafkatorioInstantPacketData::class)
// addAll(KafkatorioInstantPacketData::class.sealedSubclasses)
//
// add(KafkatorioKeyedPacketData::class)
// addAll(KafkatorioKeyedPacketData::class.sealedSubclasses)
// addAll(KafkatorioKeyedPacketKey::class.sealedSubclasses)
//
// add(FactorioPrototype::class)
// addAll(FactorioPrototype::class.sealedSubclasses)
// },
// mappings = mapOf(
// // builtin Factorio numeric types > `typed-factorio/generated/builtin-types.d.ts`
// Float::class to "float",
// Double::class to "double",
// Int::class to "int",
// Byte::class to "int8",
// UInt::class to "uint",
// UByte::class to "uint8",
// UShort::class to "uint16",
// ULong::class to "uint64",
// ),
// classTransformers = listOf(
// ValueClassTransformer,
//// PropertyNameTransformer,
// ),
//// voidType = VoidType.UNDEFINED,
// )
//
// val definitions = gen.definitionsText
//
// when (val filename = args.firstOrNull()) {
// null -> println(definitions)
// else -> {
// val outPath = Path(filename)
// outPath.createDirectories()
//
// splitDefinitions(definitions)
// .forEach { (defFile, def) ->
// val outFile = outPath.resolve(defFile).toFile()
// outFile.printWriter().use {
// it.println(kt2tsHeader)
// it.print(def)
// }
// println("Generated file: $outFile")
// }
//
// }
// }
//}
//
//
///** Split the TypeScript interfaces into different files */
//private fun splitDefinitions(definitions: String): Map<String, String> {
// return definitions
// .split("\n\n")
// .groupBy { def ->
//
// when {
// def isInterface "KafkatorioPacket"
// || def extendsAny listOf("KafkatorioPacket")
// || def isType "KafkatorioPacketDataType"
// -> "kafkatorio-packet.d.ts"
//
// def isInterface "KafkatorioInstantPacketData"
// || def extends "KafkatorioInstantPacketData"
// || def isType "KafkatorioInstantPacketDataType"
//
// || def isInterface "ConfigurationUpdateGameData"
// || def isInterface "ConfigurationUpdateModData"
// -> "kafkatorio-instant-packet.d.ts"
//
// def isInterface "KafkatorioKeyedPacket"
// || def isInterface "KafkatorioKeyedPacketData"
// || def extends "KafkatorioKeyedPacket"
// || def isType "KafkatorioKeyedPacketDataType"
//
// || def isInterface "KafkatorioKeyedPacketKey"
// -> "kafkatorio-keyed-packet.d.ts"
//
// def isInterface "FactorioPrototypes"
// || def isInterface "FactorioPrototype"
// || def extends "FactorioPrototype"
// || def isType "PrototypeObjectName"
// -> "prototype.d.ts"
//
// else -> "schema.d.ts"
// }
// }
// .mapValues { (_, def) ->
// def
// .sorted()
// .joinToString("\n\n", postfix = "\n")
// .replace(" ", " ")
// }
//}
//
//@Language("TypeScript")
//val kt2tsHeader = """
// |// Generated by TypeScriptGenerator - do not edit this file manually
// |
//""".trimMargin()
//
//private infix fun String.extends(types: String): Boolean =
// contains(Regex(".+extends.*(${types})"))
//
//private infix fun String.extendsAny(types: List<String>): Boolean =
// extends(types.joinToString("|") { "$it,".replaceAfterLast(',', "") })
//
//private infix fun String.isInterface(type: String): Boolean =
// contains(Regex("interface $type.*"))
//
//
//private infix fun String.isType(type: String): Boolean =
// contains(Regex("type $type.*"))
//
//
////object PropertyNameTransformer : ClassTransformer {
//// override fun transformPropertyName(
//// propertyName: String,
//// property: KProperty<*>,
//// klass: KClass<*>
//// ): String {
//// return camelCaseToSnakeCase(propertyName)
//// }
////}

View file

@ -0,0 +1,62 @@
package dev.adamko.kafkatorio.events.schema
import dev.adamko.kafkatorio.schema.packets.KafkatorioPacket
import dev.adamko.kxstsgen.KxsTsConfig
import dev.adamko.kxstsgen.KxsTsGenerator
import dev.adamko.kxstsgen.core.TsDeclaration.TsTypeAlias
import dev.adamko.kxstsgen.core.TsElementId
import dev.adamko.kxstsgen.core.TsLiteral
import dev.adamko.kxstsgen.core.TsTypeRef
import kotlin.io.path.Path
import kotlin.io.path.createDirectories
import kotlinx.serialization.builtins.serializer
import org.intellij.lang.annotations.Language
@ExperimentalUnsignedTypes
fun main(args: Array<String>) {
val config = KxsTsConfig(
// typeAliasTyping = BrandTyping
)
val gen2 = KxsTsGenerator(config)
gen2.descriptorOverrides += mapOf(
//@formatter:off
Float .serializer().descriptor to TsTypeAlias(TsElementId("kotlin.Float" ), TsTypeRef.Literal(TsLiteral.Custom("float" ), false)),
Double.serializer().descriptor to TsTypeAlias(TsElementId("kotlin.Double"), TsTypeRef.Literal(TsLiteral.Custom("double"), false)),
Int .serializer().descriptor to TsTypeAlias(TsElementId("kotlin.Int" ), TsTypeRef.Literal(TsLiteral.Custom("int" ), false)),
Byte .serializer().descriptor to TsTypeAlias(TsElementId("kotlin.Byte" ), TsTypeRef.Literal(TsLiteral.Custom("int8" ), false)),
UInt .serializer().descriptor to TsTypeAlias(TsElementId("kotlin.UInt" ), TsTypeRef.Literal(TsLiteral.Custom("uint" ), false)),
UByte .serializer().descriptor to TsTypeAlias(TsElementId("kotlin.UByte" ), TsTypeRef.Literal(TsLiteral.Custom("uint8" ), false)),
UShort.serializer().descriptor to TsTypeAlias(TsElementId("kotlin.UShort"), TsTypeRef.Literal(TsLiteral.Custom("uint16"), false)),
ULong .serializer().descriptor to TsTypeAlias(TsElementId("kotlin.ULong" ), TsTypeRef.Literal(TsLiteral.Custom("uint64"), false)),
//@formatter:on
)
val definitions = gen2.generate(
KafkatorioPacket.serializer(),
// KafkatorioKeyedPacketKey2.serializer(),
)
when (val filename = args.firstOrNull()) {
null -> println(definitions)
else -> {
val outPath = Path(filename)
outPath.createDirectories()
val outFile = outPath.resolve("kafkatorio-schema.ts").toFile()
outFile.printWriter().use {
it.println(kt2tsHeader)
it.print(definitions)
}
}
}
}
@Language("TypeScript")
val kt2tsHeader = """
|// Generated by kxs-ts-gen - do not edit this file manually
|
""".trimMargin()

View file

@ -0,0 +1,193 @@
package dev.adamko.kafkatorio.processor.misc
import java.io.File
import java.nio.file.FileSystems
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardWatchEventKinds.ENTRY_CREATE
import java.nio.file.StandardWatchEventKinds.ENTRY_DELETE
import java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY
import java.nio.file.WatchEvent
import java.nio.file.WatchKey
import java.nio.file.WatchService
import kotlin.coroutines.CoroutineContext
import kotlin.io.path.isReadable
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.runningFold
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
fun File.fileEventsFlow() = FileEventsFlow(
root = this.toPath(),
)
fun Path.fileEventsFlow() = FileEventsFlow(
root = this,
)
/** [SharedFlow] wrapper for Java's [WatchService] */
class FileEventsFlow private constructor(
private val root: Path,
private val fileEvents: MutableSharedFlow<KWatchEvent>,
) : SharedFlow<KWatchEvent> by fileEvents, CoroutineScope {
constructor(root: Path) : this(root, MutableSharedFlow(0, Int.MAX_VALUE, BufferOverflow.SUSPEND))
override val coroutineContext: CoroutineContext =
CoroutineName("FileEventsFlow") +
Dispatchers.Unconfined +
CoroutineExceptionHandler { _, e ->
println("handling exception in FileEventsFlow")
e.printStackTrace()
watchService.close()
watchKeys.update {
it.cancelAll()
emptyList()
}
}
private val watchService: WatchService = FileSystems.getDefault().newWatchService()
private val watchKeys: MutableStateFlow<List<WatchKey>> = MutableStateFlow(emptyList())
init {
println("launching FileEventsFlow... $root")
registerPaths()
pollFileEvents()
launch {
fileEvents.emit(KWatchEvent.Initialized)
}
println("launched FileEventsFlow $root")
root.toFile().walk()
// .filter { it.isFile }
.onEach {
launch {
fileEvents.emit(KWatchEvent.Created(it.toPath()))
}
}
.count()
.let {
println("initializing KWatchEvent.Created $it")
}
}
/** Watch for any file tree changes and re-register the [watchKeys] */
private fun registerPaths() = launch {
println("launching registerPaths")
fileEvents
// .onEach { println("file event! $it") }
.filter { event ->
when (event) {
is KWatchEvent.Initialized -> true
is KWatchEvent.Created -> event.path.isDirectory
is KWatchEvent.Deleted -> event.path.isDirectory
is KWatchEvent.Modified -> false
}
}.debounce(1.seconds)
.runningFold(emptyList<WatchKey>()) { previousKeys, directoryTreeAlteredEvent ->
println("re-registering paths $directoryTreeAlteredEvent")
previousKeys.cancelAll()
root.toFile().walk()
.filter { it.isDirectory }
.map {
// println("re-registering watch on $it")
it.toPath().register(watchService, ENTRY_CREATE, ENTRY_MODIFY, ENTRY_DELETE)
}
.toList()
.also {
println("${it.count()} registered watches")
}
}
.cancellable()
.collect(watchKeys)
}
/** Poll [watchService] for updates while [coroutineContext] is active. */
private fun pollFileEvents() = launch {
flow<WatchKey> {
while (coroutineContext.isActive) {
emit(
withContext(Dispatchers.IO) { watchService.take() }
)
println("emitted watchService.take()")
yield()
}
}.mapNotNull { monitorKey ->
when (val keyPath = monitorKey.watchable()) {
is Path -> monitorKey to keyPath
else -> null
}
}.filter { it.second.isReadable() }
.flatMapConcat { (monitorKey: WatchKey, keyPath: Path) ->
val events = monitorKey.pollEvents()
monitorKey.reset()
events
.onEach { println("monitorKey event $it") }
.filter { it.context() as? Path != null }
.mapNotNull { event ->
val eventPath = keyPath.resolve(event.context() as Path)
when (event.kind()) {
ENTRY_CREATE -> KWatchEvent.Created(eventPath)
ENTRY_DELETE -> KWatchEvent.Deleted(eventPath)
ENTRY_MODIFY -> KWatchEvent.Modified(eventPath)
else -> null
}
}.asFlow()
}.cancellable()
.collect(fileEvents)
}
companion object {
private val Path.isDirectory: Boolean get() = Files.isDirectory(this)
private fun List<WatchKey>.cancelAll() = forEach { it.cancel(); it.pollEvents() }
}
}
/** Wrapper around [WatchEvent] that comes with properly resolved absolute path */
sealed interface KWatchEvent {
object Initialized : KWatchEvent
sealed interface PathEvent : KWatchEvent {
/** path of modified file or directory */
val path: Path
}
data class Created(override val path: Path) : PathEvent
data class Modified(override val path: Path) : PathEvent
data class Deleted(override val path: Path) : PathEvent
}

View file

@ -0,0 +1,328 @@
package dev.adamko.kafkatorio.library
import dev.adamko.kafkatorio.schema.common.ServerMapChunkId
import dev.adamko.kafkatorio.schema.common.ChunkSize
import dev.adamko.kafkatorio.schema.common.ColourHex
import dev.adamko.kafkatorio.schema.common.FactorioServerId
import dev.adamko.kafkatorio.schema.common.MapChunkPosition
import dev.adamko.kafkatorio.schema.common.MapTilePosition
import dev.adamko.kafkatorio.schema.common.SurfaceIndex
import io.kotest.assertions.withClue
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.comparables.shouldBeEqualComparingTo
import io.kotest.matchers.equality.shouldBeEqualToComparingFields
import io.kotest.matchers.ints.shouldBeExactly
import io.kotest.matchers.longs.shouldBeExactly
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.boolean
import io.kotest.property.arbitrary.booleanArray
import io.kotest.property.arbitrary.byte
import io.kotest.property.arbitrary.byteArray
import io.kotest.property.arbitrary.char
import io.kotest.property.arbitrary.charArray
import io.kotest.property.arbitrary.double
import io.kotest.property.arbitrary.doubleArray
import io.kotest.property.arbitrary.float
import io.kotest.property.arbitrary.floatArray
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.intArray
import io.kotest.property.arbitrary.long
import io.kotest.property.arbitrary.longArray
import io.kotest.property.arbitrary.positiveInt
import io.kotest.property.arbitrary.short
import io.kotest.property.arbitrary.shortArray
import io.kotest.property.arbitrary.string
import io.kotest.property.arbitrary.uByte
import io.kotest.property.arbitrary.uInt
import io.kotest.property.arbitrary.uLong
import io.kotest.property.arbitrary.uShort
import io.kotest.property.checkAll
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.encodeToString
class KxsBinaryTests : FunSpec({
test("byte array") {
val ba = byteArrayOf(
4, 116, 101, 115, 116, 2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 3
)
val result = kxsBinary.decodeFromByteArray(ServerMapChunkId.serializer(), ba)
withClue(ba.toString(Charsets.UTF_8)) {
result shouldBeEqualToComparingFields ServerMapChunkId(
serverId = FactorioServerId("test"),
chunkPosition = MapChunkPosition(1, 2),
surfaceIndex = SurfaceIndex(1u),
chunkSize = ChunkSize.CHUNK_064,
)
}
}
test("encoding") {
val ba = kxsBinary.encodeToByteArray(
ServerMapChunkId(
serverId = FactorioServerId("test"),
chunkPosition = MapChunkPosition(1, 2),
surfaceIndex = SurfaceIndex(1u),
chunkSize = ChunkSize.CHUNK_064,
)
)
withClue(ba.toString(Charsets.UTF_8)) {
ba shouldBe byteArrayOf(
4, 116, 101, 115, 116, 2, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 3
)
}
}
context("inline value class") {
val uuid = "fa858d73-6205-44cf-aeeb-6bbf3148c4a7"
//@formatter:off
val expectedBytes = byteArrayOf(
36, 102, 97, 56, 53, 56, 100, 55, 51, 45, 54, 50, 48, 53, 45, 52, 52, 99,
102, 45, 97, 101, 101, 98, 45, 54, 98, 98, 102, 51, 49, 52, 56, 99, 52, 97, 55,
)
//@formatter:on
test("encode") {
val ba = kxsBinary.encodeToByteArray(FactorioServerId(uuid))
withClue(ba.toString(Charsets.UTF_8)) {
ba shouldBe expectedBytes
}
}
test("decode") {
val ba = kxsBinary.encodeToByteArray(FactorioServerId(uuid))
val id: FactorioServerId = kxsBinary.decodeFromByteArray(ba)
id shouldBeEqualToComparingFields FactorioServerId(uuid)
}
}
context("ServerMapChunkTiles") {
val initial = ServerMapChunkTiles(
ServerMapChunkId(
FactorioServerId("server-id"),
MapChunkPosition(33, 44),
SurfaceIndex(4u),
ChunkSize.CHUNK_032,
),
mapOf(
MapTilePosition(1, 2) to ColourHex(1u, 2u, 3u, 4u),
)
)
test("!json round trip") {
val jsonEncoded = jsonMapperKafkatorio.encodeToString(initial)
val jsonDecoded =
jsonMapperKafkatorio.decodeFromString<ServerMapChunkTiles<ColourHex>>(jsonEncoded)
jsonDecoded shouldBeEqualToComparingFields initial
}
test("binary round trip") {
val binaryEncoded = kxsBinary.encodeToByteArray(initial)
val binaryDecoded =
kxsBinary.decodeFromByteArray<ServerMapChunkTiles<ColourHex>>(binaryEncoded)
binaryDecoded shouldBeEqualToComparingFields initial
}
}
context("primitives round trip") {
test("double") {
checkAll(Arb.double()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: Double = kxsBinary.decodeFromByteArray(encoded)
decoded shouldBeEqualComparingTo input
}
}
test("float") {
checkAll(Arb.float()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: Float = kxsBinary.decodeFromByteArray(encoded)
decoded shouldBeEqualComparingTo input
}
}
test("int") {
checkAll(Arb.int()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: Int = kxsBinary.decodeFromByteArray(encoded)
decoded shouldBeExactly input
}
}
test("long") {
checkAll(Arb.long()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: Long = kxsBinary.decodeFromByteArray(encoded)
decoded shouldBeExactly input
}
}
test("short") {
checkAll(Arb.short()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: Short = kxsBinary.decodeFromByteArray(encoded)
decoded shouldBeEqualComparingTo input
}
}
test("byte") {
checkAll(Arb.byte()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: Byte = kxsBinary.decodeFromByteArray(encoded)
decoded shouldBeEqualComparingTo input
}
}
test("boolean") {
checkAll(Arb.boolean()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: Boolean = kxsBinary.decodeFromByteArray(encoded)
decoded shouldBeEqualComparingTo input
}
}
test("char") {
checkAll(Arb.char()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: Char = kxsBinary.decodeFromByteArray(encoded)
decoded shouldBeEqualComparingTo input
}
}
test("string") {
checkAll(Arb.string()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: String = kxsBinary.decodeFromByteArray(encoded)
decoded shouldBeEqualComparingTo input
}
}
}
@OptIn(ExperimentalUnsignedTypes::class)
context("unsigned numbers round trip") {
test("uint") {
checkAll(Arb.uInt()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(UInt.serializer(), input)
val decoded: UInt = kxsBinary.decodeFromByteArray(UInt.serializer(), encoded)
decoded shouldBeEqualComparingTo input
}
}
test("ulong") {
checkAll(Arb.uLong()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(ULong.serializer(), input)
val decoded: ULong = kxsBinary.decodeFromByteArray(ULong.serializer(), encoded)
decoded shouldBeEqualComparingTo input
}
}
test("ushort") {
checkAll(Arb.uShort()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(UShort.serializer(), input)
val decoded: UShort = kxsBinary.decodeFromByteArray(UShort.serializer(), encoded)
decoded shouldBeEqualComparingTo input
}
}
test("ubyte") {
checkAll(Arb.uByte()) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(UByte.serializer(), input)
val decoded: UByte = kxsBinary.decodeFromByteArray(UByte.serializer(), encoded)
decoded shouldBeEqualComparingTo input
}
}
}
context("primitive arrays round trip") {
test("double array") {
checkAll(Arb.doubleArray(Arb.positiveInt(50), Arb.double())) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: DoubleArray = kxsBinary.decodeFromByteArray(encoded)
decoded.asList() shouldContainExactly input.asList()
}
}
test("float array") {
checkAll(Arb.floatArray(Arb.positiveInt(50), Arb.float())) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: FloatArray = kxsBinary.decodeFromByteArray(encoded)
decoded.asList() shouldContainExactly input.asList()
}
}
test("int array") {
checkAll(Arb.intArray(Arb.positiveInt(50), Arb.int())) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: IntArray = kxsBinary.decodeFromByteArray(encoded)
decoded.asList() shouldContainExactly input.asList()
}
}
test("long array") {
checkAll(Arb.longArray(Arb.positiveInt(50), Arb.long())) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: LongArray = kxsBinary.decodeFromByteArray(encoded)
decoded.asList() shouldContainExactly input.asList()
}
}
test("short array") {
checkAll(Arb.shortArray(Arb.positiveInt(50), Arb.short())) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: ShortArray = kxsBinary.decodeFromByteArray(encoded)
decoded.asList() shouldContainExactly input.asList()
}
}
test("byte array") {
checkAll(Arb.byteArray(Arb.positiveInt(50), Arb.byte())) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: ByteArray = kxsBinary.decodeFromByteArray(encoded)
decoded.asList() shouldContainExactly input.asList()
}
}
test("boolean array") {
checkAll(Arb.booleanArray(Arb.positiveInt(50), Arb.boolean())) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: BooleanArray = kxsBinary.decodeFromByteArray(encoded)
decoded.asList() shouldContainExactly input.asList()
}
}
test("char array") {
checkAll(Arb.charArray(Arb.positiveInt(50), Arb.char())) { input ->
val encoded: ByteArray = kxsBinary.encodeToByteArray(input)
val decoded: CharArray = kxsBinary.decodeFromByteArray(encoded)
decoded.asList() shouldContainExactly input.asList()
}
}
}
}) {
@Serializable
data class ServerMapChunkTiles<Data>(
val chunkId: ServerMapChunkId,
val map: Map<MapTilePosition, Data>,
) {
operator fun plus(other: ServerMapChunkTiles<Data>) = copy(map = map + other.map)
}
}

View file

@ -0,0 +1,40 @@
package dev.adamko.kafkatorio.schema.common
import dev.adamko.kafkatorio.library.kxsBinary
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.equality.shouldBeEqualToComparingFields
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
class MapTileDictionaryTest : FunSpec({
context("encode MapTileDictionary") {
val actual = MapTileDictionary(
tilesXY = mapOf(
"-192" to mapOf("-65" to MapTileDictionary.PrototypeKey(2)),
"161" to mapOf(
"-96" to MapTileDictionary.PrototypeKey(1),
"-95" to MapTileDictionary.PrototypeKey(2),
"94" to MapTileDictionary.PrototypeKey(2),
"93" to MapTileDictionary.PrototypeKey(32),
),
// unknown prototype
"44" to mapOf("1" to MapTileDictionary.PrototypeKey(44)),
),
protos = mapOf(
PrototypeName("water") to MapTileDictionary.PrototypeKey(1),
PrototypeName("grass-4") to MapTileDictionary.PrototypeKey(2),
PrototypeName("grass-3") to MapTileDictionary.PrototypeKey(32),
PrototypeName("not-used") to MapTileDictionary.PrototypeKey(99),
)
)
test("binary round trip") {
val encoded: ByteArray = kxsBinary.encodeToByteArray(actual)
val decoded: MapTileDictionary = kxsBinary.decodeFromByteArray(encoded)
decoded.shouldBeEqualToComparingFields(actual)
}
}
})