Incorporate faction- and fleet-placing into cluster generator
authorTheSaminator <thesaminator@users.noreply.github.com>
Tue, 5 Jul 2022 23:03:52 +0000 (19:03 -0400)
committerTheSaminator <thesaminator@users.noreply.github.com>
Tue, 5 Jul 2022 23:03:52 +0000 (19:03 -0400)
build.gradle.kts
src/commonMain/kotlin/net/starshipfights/campaign/cluster_params.kt
src/jvmMain/kotlin/net/starshipfights/campaign/cluster_fleets.kt
src/jvmMain/kotlin/net/starshipfights/campaign/cluster_gen.kt
src/jvmMain/kotlin/net/starshipfights/campaign/cluster_test.kt [deleted file]
src/jvmMain/kotlin/net/starshipfights/campaign/endpoints_campaign.kt
src/jvmMain/kotlin/net/starshipfights/data/data_connection.kt
src/jvmMain/kotlin/net/starshipfights/data/space/star_names.kt
src/jvmMain/kotlin/net/starshipfights/info/views_user.kt
src/jvmTest/kotlin/net/starshipfights/campaign/ClusterGenTesting.kt [deleted file]

index 2e1af948c7b744a335f3c16da5c2a077f8aadc18..a415de818ab3912c9ea2fcfb55a36f1d89e2f91d 100644 (file)
@@ -187,9 +187,3 @@ tasks.create("runAiTest", JavaExec::class.java) {
        classpath = sourceSets.getByName("test").runtimeClasspath
        mainClass.set("net.starshipfights.game.ai.AITesting")
 }
-
-tasks.create("runClusterGenTest", JavaExec::class.java) {
-       group = "test"
-       classpath = sourceSets.getByName("test").runtimeClasspath
-       mainClass.set("net.starshipfights.campaign.ClusterGenTesting")
-}
index 489f4128c9e7407253658e5171fea0138d21cd2e..35db2d075bc4d05e163b264b4d996b02b8dd2379 100644 (file)
@@ -1,13 +1,15 @@
 package net.starshipfights.campaign
 
 import kotlinx.serialization.Serializable
+import net.starshipfights.game.FactionFlavor
+import kotlin.jvm.JvmInline
 import kotlin.math.ceil
 import kotlin.math.floor
 import kotlin.math.roundToInt
 import kotlin.random.Random
 
 enum class ClusterSize(val maxStars: Int, val maxHyperlaneDistanceFactor: Double) {
-       SMALL(15, 1.5), MEDIUM(25, 2.0), LARGE(35, 2.5);
+       SMALL(20, 1.5), MEDIUM(35, 2.0), LARGE(50, 2.5);
        
        val displayName: String
                get() = name.lowercase().replaceFirstChar { it.uppercase() }
@@ -47,11 +49,62 @@ enum class ClusterCorruption(val corruptedStarsPortion: Double) {
                get() = name.lowercase().replaceFirstChar { it.uppercase() }
 }
 
+enum class ClusterFactionMode {
+       ALLOW, REQUIRE, EXCLUDE;
+       
+       val displayName: String
+               get() = name.lowercase().replaceFirstChar { it.uppercase() }
+}
+
+@JvmInline
+@Serializable
+value class ClusterFactions private constructor(private val factions: Map<FactionFlavor, ClusterFactionMode>) {
+       operator fun get(factionFlavor: FactionFlavor) = factions[factionFlavor] ?: ClusterFactionMode.ALLOW
+       
+       operator fun plus(other: ClusterFactions) = ClusterFactions(factions + other.factions)
+       
+       val seedSize: Int
+               get() = factions.count { (_, it) -> it == ClusterFactionMode.REQUIRE }
+       
+       fun asGenerationSequence() = sequence {
+               val required = factions.filterValues { it == ClusterFactionMode.REQUIRE }.keys
+               val included = factions.filterValues { it != ClusterFactionMode.EXCLUDE }.keys
+               
+               // first, start with the required flavors
+               yieldAll(required.shuffled())
+               while (true) {
+                       // continue with the included flavors
+                       yieldAll(included.shuffled())
+               }
+       }
+       
+       fun getRelatedFaction(faction: FactionFlavor) = factions
+               .filterKeys { it.loyalties == faction.loyalties }
+               .filterValues { it != ClusterFactionMode.EXCLUDE }
+               .keys.random()
+       
+       companion object {
+               val Default: ClusterFactions
+                       get() = ClusterFactions(FactionFlavor.values().associateWith { ClusterFactionMode.ALLOW })
+               
+               operator fun invoke(factions: Map<FactionFlavor, ClusterFactionMode>) = Default + ClusterFactions(factions)
+       }
+}
+
+enum class ClusterContention(val controlSpreadChance: Double, val maxFleets: Int, val fleetStrengthMult: Double) {
+       BLOODBATH(0.9, 5, 1.0), CONTESTED(0.65, 3, 0.8), PEACEFUL(0.5, 2, 0.5);
+       
+       val displayName: String
+               get() = name.lowercase().replaceFirstChar { it.uppercase() }
+}
+
 @Serializable
 data class ClusterGenerationSettings(
        val background: StarClusterBackground,
        val size: ClusterSize,
        val laneDensity: ClusterLaneDensity,
        val planetDensity: ClusterPlanetDensity,
-       val corruption: ClusterCorruption
+       val corruption: ClusterCorruption,
+       val factions: ClusterFactions,
+       val contention: ClusterContention
 )
index 763c1b5b1c61e867d03b76627687c2c239a32c6a..e941c3b8f927e6eef32e944958b5dc7017315a94 100644 (file)
@@ -1,10 +1,15 @@
 package net.starshipfights.campaign
 
 import net.starshipfights.data.Id
+import net.starshipfights.data.admiralty.AdmiralNameFlavor
+import net.starshipfights.data.admiralty.AdmiralNames
 import net.starshipfights.data.admiralty.newShipName
 import net.starshipfights.data.invoke
+import net.starshipfights.data.space.generateFleetName
 import net.starshipfights.game.*
 import net.starshipfights.game.ai.weightedRandom
+import kotlin.math.roundToInt
+import kotlin.random.Random
 
 val FactionFlavor.shipSource: Faction
        get() = when (this) {
@@ -36,11 +41,11 @@ val FactionFlavor.shipSource: Faction
                FactionFlavor.COLEMAN_SF_BASE_VESTIGIUM -> Faction.VESTIGIUM
        }
 
-fun generateNPCFleet(owner: FactionFlavor, rank: AdmiralRank): Map<Id<Ship>, Ship> {
+fun generateNPCFleet(owner: FactionFlavor, rank: AdmiralRank, sizeMult: Double): Map<Id<Ship>, Ship> {
        val battleSize = BattleSize.values().filter { rank.maxShipTier >= it.maxTier }.associateWith { 100.0 / it.numPoints }.weightedRandom()
        
        val possibleShips = ShipType.values().filter { it.faction == owner.shipSource && it.weightClass.tier <= battleSize.maxTier }
-       val maxPoints = battleSize.numPoints
+       val maxPoints = (battleSize.numPoints * sizeMult).roundToInt()
        
        val chosenShipTypes = buildList {
                while (true)
@@ -61,3 +66,18 @@ fun generateNPCFleet(owner: FactionFlavor, rank: AdmiralRank): Map<Id<Ship>, Shi
                }
        }.associateBy { it.id }
 }
+
+fun generateFleetPresences(owner: FactionFlavor, maxFleets: Int, sizeMult: Double): Map<Id<FleetPresence>, FleetPresence> = (1..(maxFleets - Random.nextDiminishingInteger(maxFleets))).associate { _ ->
+       val admiralRank = AdmiralRank.values()[Random.nextIrwinHallInteger(AdmiralRank.values().size)]
+       val admiralIsFemale = owner == FactionFlavor.FELINAE_FELICES || Random.nextBoolean()
+       val admiralFleet = generateNPCFleet(owner, admiralRank, sizeMult)
+       
+       Id<FleetPresence>() to FleetPresence(
+               name = owner.generateFleetName(),
+               owner = owner,
+               ships = admiralFleet,
+               admiralName = AdmiralNames.randomName(AdmiralNameFlavor.forFactionFlavor(owner).random(), admiralIsFemale),
+               admiralIsFemale = admiralIsFemale,
+               admiralRank = admiralRank
+       )
+}
index 8e547bc02be70acc0a5899d3e7bf0481963697c8..e7fd3a34654a71096eb71fb1901ae39d1f9fddce 100644 (file)
@@ -46,8 +46,8 @@ class ClusterGenerator(val settings: ClusterGenerationSettings) {
                        
                        StarClusterView(
                                background = settings.background,
-                               systems = systems,
-                               lanes = warpLanes
+                               systems = generateFleets(assignFactions(systems, warpLanes)),
+                               lanes = warpLanes,
                        )
                } ?: generateCluster()
        }
@@ -346,6 +346,66 @@ class ClusterGenerator(val settings: ClusterGenerationSettings) {
                }
        }.toSet())
        
+       private suspend fun assignFactions(starSystems: Map<Id<StarSystem>, StarSystem>, warpLanes: Set<WarpLane>): Map<Id<StarSystem>, StarSystem> {
+               val systemControllers = (starSystems.keys.shuffled() zip settings.factions.asGenerationSequence().take(starSystems.size * 2 / 5).toList()).toMap().toMutableMap()
+               
+               val uncontrolledSystems = (starSystems.keys - systemControllers.keys).toMutableSet()
+               
+               while (uncontrolledSystems.size > starSystems.size / 5) {
+                       val controlledSystems = systemControllers.keys.shuffled()
+                       
+                       var shouldKeepLooping = false
+                       for (systemId in controlledSystems) {
+                               val borderingSystems = mutableSetOf<Id<StarSystem>>()
+                               
+                               for (lane in warpLanes) {
+                                       throttle()
+                                       
+                                       if (systemId == lane.systemA)
+                                               borderingSystems += lane.systemB
+                                       if (systemId == lane.systemB)
+                                               borderingSystems += lane.systemA
+                               }
+                               
+                               borderingSystems.retainAll(uncontrolledSystems)
+                               
+                               for (borderId in borderingSystems) {
+                                       throttle()
+                                       
+                                       uncontrolledSystems -= borderId
+                                       
+                                       if (Random.nextDouble() < settings.contention.controlSpreadChance)
+                                               systemControllers[borderId] = systemControllers.getValue(systemId).let { faction ->
+                                                       if (Random.nextBoolean())
+                                                               faction
+                                                       else
+                                                               settings.factions.getRelatedFaction(faction)
+                                               }
+                                       
+                                       shouldKeepLooping = true
+                               }
+                       }
+                       
+                       if (!shouldKeepLooping)
+                               break
+               }
+               
+               return starSystems.mapValues { (id, system) ->
+                       system.copy(holder = systemControllers[id])
+               }
+       }
+       
+       private suspend fun generateFleets(starSystems: Map<Id<StarSystem>, StarSystem>): Map<Id<StarSystem>, StarSystem> {
+               return starSystems.mapValues { (_, system) ->
+                       throttle()
+                       system.holder?.let { owner ->
+                               system.copy(
+                                       fleets = generateFleetPresences(owner, settings.contention.maxFleets, settings.contention.fleetStrengthMult)
+                               )
+                       } ?: system
+               }
+       }
+       
        companion object {
                const val SYSTEM_R = 1024.0
                const val SYSTEM_K = 4
diff --git a/src/jvmMain/kotlin/net/starshipfights/campaign/cluster_test.kt b/src/jvmMain/kotlin/net/starshipfights/campaign/cluster_test.kt
deleted file mode 100644 (file)
index 7379eae..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-package net.starshipfights.campaign
-
-import net.starshipfights.data.Id
-import net.starshipfights.data.admiralty.AdmiralNameFlavor
-import net.starshipfights.data.admiralty.AdmiralNames
-import net.starshipfights.data.invoke
-import net.starshipfights.data.space.generateFleetName
-import net.starshipfights.game.*
-import kotlin.random.Random
-
-fun StarClusterView.testPostProcess(): StarClusterView {
-       val ownerFlavors = FactionFlavor.values()
-               .toList()
-               .shuffled()
-               .repeatForever()
-               .take(systems.size)
-               .toList()
-       
-       val ownedSystems = (systems.toList().shuffled() zip ownerFlavors).associate { (systemWithId, flavor) ->
-               val (systemId, system) = systemWithId
-               
-               val numOfFleets = 3 - Random.nextDiminishingInteger(4)
-               if (numOfFleets == 0)
-                       return@associate systemId to system
-               
-               val fleets = (1..numOfFleets).associate { _ ->
-                       val admiralRank = AdmiralRank.values()[Random.nextIrwinHallInteger(AdmiralRank.values().size)]
-                       val admiralIsFemale = flavor == FactionFlavor.FELINAE_FELICES || Random.nextBoolean()
-                       val admiralFleet = generateNPCFleet(flavor, admiralRank)
-                       
-                       Id<FleetPresence>() to FleetPresence(
-                               name = flavor.generateFleetName(),
-                               owner = flavor,
-                               ships = admiralFleet,
-                               admiralName = AdmiralNames.randomName(AdmiralNameFlavor.forFactionFlavor(flavor).random(), admiralIsFemale),
-                               admiralIsFemale = admiralIsFemale,
-                               admiralRank = admiralRank
-                       )
-               }
-               
-               systemId to system.copy(holder = flavor, fleets = fleets)
-       }
-       
-       return copy(systems = ownedSystems)
-}
index d6e0ffb797c47f4eda52f4f8c1a06cc6b8223094..6729a972207aa288d4a5d7c742a30992990298bc 100644 (file)
@@ -8,9 +8,7 @@ import io.ktor.routing.*
 import io.ktor.util.*
 import kotlinx.html.*
 import net.starshipfights.data.Id
-import net.starshipfights.game.ClientMode
-import net.starshipfights.game.toUrlSlug
-import net.starshipfights.game.view
+import net.starshipfights.game.*
 import net.starshipfights.labs.lab
 import net.starshipfights.labs.labPost
 
@@ -31,7 +29,6 @@ fun Routing.installCampaign() {
                                                        value = color.name
                                                        required = true
                                                }
-                                               +Entities.nbsp
                                                +color.displayName
                                                br
                                        }
@@ -46,7 +43,6 @@ fun Routing.installCampaign() {
                                                        value = size.name
                                                        required = true
                                                }
-                                               +Entities.nbsp
                                                +size.displayName
                                                br
                                        }
@@ -61,7 +57,6 @@ fun Routing.installCampaign() {
                                                        value = density.name
                                                        required = true
                                                }
-                                               +Entities.nbsp
                                                +density.displayName
                                                br
                                        }
@@ -76,7 +71,6 @@ fun Routing.installCampaign() {
                                                        value = planets.name
                                                        required = true
                                                }
-                                               +Entities.nbsp
                                                +planets.displayName
                                                br
                                        }
@@ -91,11 +85,47 @@ fun Routing.installCampaign() {
                                                        value = corruption.name
                                                        required = true
                                                }
-                                               +Entities.nbsp
                                                +corruption.displayName
                                                br
                                        }
                                }
+                               h3 { +"Factional Contention" }
+                               for (contention in ClusterContention.values()) {
+                                       val contentionId = "contention-${contention.toUrlSlug()}"
+                                       label {
+                                               htmlFor = contentionId
+                                               radioInput(name = "contention") {
+                                                       id = contentionId
+                                                       value = contention.name
+                                                       required = true
+                                               }
+                                               +contention.displayName
+                                               br
+                                       }
+                               }
+                               h3 { +"Per-Faction Modes" }
+                               for (factionFlavor in FactionFlavor.values())
+                                       p {
+                                               strong { +factionFlavor.displayName }
+                                               br
+                                               +"Uses ${factionFlavor.shipSource.adjective} ships, is loyal to ${factionFlavor.loyalties.first().getDefiniteShortName()}."
+                                               br
+                                               for (mode in ClusterFactionMode.values()) {
+                                                       val modeId = "mode-${factionFlavor.toUrlSlug()}-${mode.toUrlSlug()}"
+                                                       label {
+                                                               htmlFor = modeId
+                                                               radioInput(name = "factions[${factionFlavor.toUrlSlug()}]") {
+                                                                       id = modeId
+                                                                       value = mode.name
+                                                                       required = true
+                                                                       if (mode == ClusterFactionMode.ALLOW)
+                                                                               checked = true
+                                                               }
+                                                               +mode.displayName
+                                                               +Entities.nbsp
+                                                       }
+                                               }
+                                       }
                                submitInput {
                                        value = "Generate Star Cluster"
                                }
@@ -111,10 +141,14 @@ fun Routing.installCampaign() {
                val density = ClusterLaneDensity.valueOf(parameters.getOrFail("density"))
                val planets = ClusterPlanetDensity.valueOf(parameters.getOrFail("planets"))
                val corruption = ClusterCorruption.valueOf(parameters.getOrFail("corruption"))
+               val contention = ClusterContention.valueOf(parameters.getOrFail("contention"))
+               val factions = ClusterFactions(FactionFlavor.values().mapNotNull { faction ->
+                       parameters["factions[${faction.toUrlSlug()}]"]?.let { faction to ClusterFactionMode.valueOf(it) }
+               }.toMap())
                
                val cluster = ClusterGenerator(
-                       ClusterGenerationSettings(color, size, density, planets, corruption)
-               ).generateCluster().testPostProcess()
+                       ClusterGenerationSettings(color, size, density, planets, corruption, factions, contention)
+               ).generateCluster()
                
                val clientMode = ClientMode.CampaignMap(
                        Id(""),
index 1a417f973e4a1bd4a79b1406935c4c47d74a8f8d..494cc5fb1d2d8f42f4fc8ad9e7ef149c18877a7b 100644 (file)
@@ -75,7 +75,7 @@ sealed class ConnectionType {
        
        @Serializable
        @SerialName("external")
-       data class External(val url: String) : ConnectionType() {
+       data class External(val url: String = "mongodb://localhost:27017") : ConnectionType() {
                override fun createUrl() = url
        }
 }
index 1a3abd4896f47e932f02fb20736d8f6c122055d8..f177084f40757692e16a4d7f1205771ae5713c38 100644 (file)
@@ -4,6 +4,9 @@ import net.starshipfights.data.admiralty.LatinAdjective
 import net.starshipfights.data.admiralty.LatinNoun
 import net.starshipfights.data.admiralty.LatinNounForm
 import net.starshipfights.data.admiralty.describedBy
+import net.starshipfights.game.nextDiminishingInteger
+import net.starshipfights.game.nextIrwinHallInteger
+import kotlin.random.Random
 
 fun newStarName(existingNames: MutableSet<String>) = generateSequence {
        randomStarName()
@@ -344,12 +347,10 @@ private val letters = 'A'..'Z'
 private fun Int.pow(x: Int) = (1..x).fold(1) { acc, _ -> acc * this }
 private fun generateNDigitNumber(n: Int) = (10.pow(n - 1) until 10.pow(n)).random()
 
-private fun generateConstellationStarName(): String {
-       val prefix = letters.shuffled().take((1..3).random()).joinToString(separator = "")
-       val infix = listOf(" ", "-", "").random()
-       val suffix = generateNDigitNumber((2..4).random())
-       return "$prefix$infix$suffix"
-}
+private fun generateConstellationStarNameLetters() = letters.shuffled().take(1 + Random.nextDiminishingInteger(3)).joinToString(separator = "")
+private fun generateConstellationStarNameNumbers() = generateNDigitNumber(2 + Random.nextIrwinHallInteger(4)).toString()
+
+private fun generateConstellationStarName(): String = "${generateConstellationStarNameLetters()}-${generateConstellationStarNameNumbers()}"
 
 private val constellationNamesWithGenitives = listOf(
        "Antlia" to "Antliae",
index ed52670ae8f1c6e645dbe1aced7242e94d26e2de..f333eb585db5c83c76c3a98e0aaf47322efb05b4 100644 (file)
@@ -130,7 +130,6 @@ suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit {
                                                required = true
                                                checked = currentUser.preferredTheme == PreferredTheme.SYSTEM
                                        }
-                                       +Entities.nbsp
                                        +"System Choice"
                                }
                                br
@@ -141,7 +140,6 @@ suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit {
                                                required = true
                                                checked = currentUser.preferredTheme == PreferredTheme.LIGHT
                                        }
-                                       +Entities.nbsp
                                        +"Light Theme"
                                }
                                br
@@ -152,7 +150,6 @@ suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit {
                                                required = true
                                                checked = currentUser.preferredTheme == PreferredTheme.DARK
                                        }
-                                       +Entities.nbsp
                                        +"Dark Theme"
                                }
                                h3 {
diff --git a/src/jvmTest/kotlin/net/starshipfights/campaign/ClusterGenTesting.kt b/src/jvmTest/kotlin/net/starshipfights/campaign/ClusterGenTesting.kt
deleted file mode 100644 (file)
index a768989..0000000
+++ /dev/null
@@ -1,139 +0,0 @@
-package net.starshipfights.campaign
-
-import kotlinx.coroutines.runBlocking
-import net.starshipfights.game.Vec2
-import net.starshipfights.game.magnitude
-import net.starshipfights.game.plus
-import net.starshipfights.game.times
-import java.awt.BasicStroke
-import java.awt.Color
-import java.awt.Font
-import java.awt.RenderingHints
-import java.awt.image.BufferedImage
-import java.io.File
-import javax.imageio.ImageIO
-import javax.swing.JOptionPane
-import javax.swing.UIManager
-import kotlin.math.roundToInt
-
-object ClusterGenTesting {
-       @JvmStatic
-       fun main(args: Array<String>) {
-               UIManager.setLookAndFeel("javax.swing.plaf.nimbus.NimbusLookAndFeel")
-               
-               val clusterSizes = ClusterSize.values().toList()
-               val clusterSizeOptions = clusterSizes.map { it.toString() }.toTypedArray()
-               
-               val clusterSizeIndex = JOptionPane.showOptionDialog(
-                       null, "Please select the size of your star cluster",
-                       "Generate Star Cluster", JOptionPane.DEFAULT_OPTION,
-                       JOptionPane.QUESTION_MESSAGE, null,
-                       clusterSizeOptions, clusterSizeOptions[0]
-               )
-               
-               if (clusterSizeIndex == JOptionPane.CLOSED_OPTION) return
-               
-               val laneDensities = ClusterLaneDensity.values().toList()
-               val laneDensityOptions = laneDensities.map { it.toString() }.toTypedArray()
-               
-               val laneDensityIndex = JOptionPane.showOptionDialog(
-                       null, "Please select the warp-lane density of your star cluster",
-                       "Generate Star Cluster", JOptionPane.DEFAULT_OPTION,
-                       JOptionPane.QUESTION_MESSAGE, null,
-                       laneDensityOptions, laneDensityOptions[0]
-               )
-               
-               if (laneDensityIndex == JOptionPane.CLOSED_OPTION) return
-               
-               val planetDensities = ClusterPlanetDensity.values().toList()
-               val planetDensityOptions = planetDensities.map { it.toString() }.toTypedArray()
-               
-               val planetDensityIndex = JOptionPane.showOptionDialog(
-                       null, "Please select the planet density of your star cluster",
-                       "Generate Star Cluster", JOptionPane.DEFAULT_OPTION,
-                       JOptionPane.QUESTION_MESSAGE, null,
-                       planetDensityOptions, planetDensityOptions[0]
-               )
-               
-               if (planetDensityIndex == JOptionPane.CLOSED_OPTION) return
-               
-               val settings = ClusterGenerationSettings(
-                       StarClusterBackground.RED,
-                       clusterSizes[clusterSizeIndex],
-                       laneDensities[laneDensityIndex],
-                       planetDensities[planetDensityIndex],
-                       ClusterCorruption.MATERIAL
-               )
-               
-               val starCluster = runBlocking {
-                       ClusterGenerator(settings).generateCluster()
-               }
-               
-               val radius = (starCluster.systems.maxOf { (_, system) -> system.position.vector.magnitude } * SCALE_FACTOR)
-               val radiusShift = Vec2(radius, radius)
-               
-               val imageSize = ((radius + IMAGE_MARGIN) * 2).roundToInt()
-               val image = BufferedImage(imageSize, imageSize, BufferedImage.TYPE_INT_ARGB)
-               val g2d = image.createGraphics()
-               try {
-                       g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
-                       
-                       g2d.font = Font(Font.SANS_SERIF, Font.BOLD, 16)
-                       
-                       g2d.color = Color.white
-                       g2d.fillRect(0, 0, imageSize, imageSize)
-                       
-                       g2d.color = Color.black
-                       g2d.fillRect(1, 1, imageSize - 2, imageSize - 2)
-                       
-                       g2d.color = Color.decode("#3366CC")
-                       g2d.stroke = BasicStroke(2.5f)
-                       for ((aId, bId) in starCluster.lanes) {
-                               val aPos = starCluster.systems.getValue(aId).position.vector * SCALE_FACTOR + radiusShift
-                               val bPos = starCluster.systems.getValue(bId).position.vector * SCALE_FACTOR + radiusShift
-                               
-                               g2d.drawLine(
-                                       (aPos.x + IMAGE_MARGIN).roundToInt(),
-                                       (aPos.y + IMAGE_MARGIN).roundToInt(),
-                                       (bPos.x + IMAGE_MARGIN).roundToInt(),
-                                       (bPos.y + IMAGE_MARGIN).roundToInt(),
-                               )
-                       }
-                       
-                       g2d.color = Color.decode("#CC9933")
-                       for (system in starCluster.systems.values) {
-                               val pos = system.position.vector * SCALE_FACTOR + radiusShift
-                               val r = system.radius * SCALE_FACTOR
-                               
-                               g2d.fillOval(
-                                       (pos.x + IMAGE_MARGIN - r).roundToInt(),
-                                       (pos.y + IMAGE_MARGIN - r).roundToInt(),
-                                       (r * 2).roundToInt(),
-                                       (r * 2).roundToInt(),
-                               )
-                       }
-                       
-                       // Draw names
-                       g2d.color = Color.white
-                       for (system in starCluster.systems.values) {
-                               val pos = system.position.vector * SCALE_FACTOR + radiusShift
-                               
-                               val minusX = g2d.fontMetrics.stringWidth(system.name) * 0.5
-                               val minusY = g2d.fontMetrics.ascent * -0.5
-                               
-                               g2d.drawString(
-                                       system.name,
-                                       (pos.x + IMAGE_MARGIN - minusX).roundToInt(),
-                                       (pos.y + IMAGE_MARGIN - minusY).roundToInt(),
-                               )
-                       }
-               } finally {
-                       g2d.dispose()
-               }
-               
-               ImageIO.write(image, "PNG", File("test_output/cluster_gen.png"))
-       }
-       
-       private const val IMAGE_MARGIN = 51.2
-       private const val SCALE_FACTOR = 0.32
-}