From: TheSaminator Date: Wed, 8 Jun 2022 17:17:10 +0000 (-0400) Subject: Change base package X-Git-Url: https://gitweb.starshipfights.net/?a=commitdiff_plain;h=5b029650bce61563ce367d1a984c958ed8f77243;p=starship-fights Change base package --- diff --git a/build.gradle.kts b/build.gradle.kts index 3fad64f..1d23ed9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -128,7 +128,7 @@ kotlin { } application { - mainClass.set("starshipfights.Server") + mainClass.set("net.starshipfights.Server") } tasks.named("jvmProcessResources") { @@ -185,5 +185,5 @@ tasks.named("run") { tasks.create("runAiTest", JavaExec::class.java) { group = "test" classpath = sourceSets.getByName("test").runtimeClasspath - mainClass.set("starshipfights.game.ai.AITesting") + mainClass.set("net.starshipfights.game.ai.AITesting") } diff --git a/src/commonMain/kotlin/net/starshipfights/data/admiralty/admiral_names.kt b/src/commonMain/kotlin/net/starshipfights/data/admiralty/admiral_names.kt new file mode 100644 index 0000000..dbb6816 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/data/admiralty/admiral_names.kt @@ -0,0 +1,873 @@ +package net.starshipfights.data.admiralty + +import net.starshipfights.game.Faction +import kotlin.random.Random + +enum class AdmiralNameFlavor { + MECHYRDIA, TYLA, CALIBOR, OLYMPIA, // Mechyrdia-aligned + DUTCH, // NdRC-aliged + NORTHERN_DIADOCHI, SOUTHERN_DIADOCHI, // Masra Draetsen-aligned + FULKREYKK, // Isarnareykk-aligned + AMERICAN, HISPANIC_AMERICAN; // Vestigium-aligned + + val displayName: String + get() = when (this) { + MECHYRDIA -> "Mechyrdian" + TYLA -> "Tylan" + CALIBOR -> "Caliborese" + OLYMPIA -> "Olympian" + DUTCH -> "Dutch" + NORTHERN_DIADOCHI -> "Northern Diadochi" + SOUTHERN_DIADOCHI -> "Southern Diadochi" + FULKREYKK -> "Thedish" + AMERICAN -> "American" + HISPANIC_AMERICAN -> "Hispanic-American" + } + + companion object { + fun forFaction(faction: Faction) = when (faction) { + Faction.MECHYRDIA -> setOf(MECHYRDIA, TYLA, CALIBOR, OLYMPIA, DUTCH) + Faction.NDRC -> setOf(DUTCH) + Faction.MASRA_DRAETSEN -> setOf(CALIBOR, NORTHERN_DIADOCHI, SOUTHERN_DIADOCHI) + Faction.FELINAE_FELICES -> setOf(OLYMPIA) + Faction.ISARNAREYKK -> setOf(FULKREYKK) + Faction.VESTIGIUM -> setOf(AMERICAN, HISPANIC_AMERICAN) + } + } +} + +object AdmiralNames { + // PERSONAL NAME to PATRONYMIC + private val mechyrdianMaleNames: List> = listOf( + "Marc" to "Marcówič", + "Anton" to "Antonówič", + "Bjarnarð" to "Bjarnarðówič", + "Carl" to "Carlówič", + "Þjutarix" to "Þjutarigówič", + "Friðurix" to "Friðurigówič", + "Iwan" to "Iwanówič", + "Wladimer" to "Wladimerówič", + "Giulius" to "Giuliówič", + "Nicólei" to "Nicóleiówič", + "Þjódor" to "Þjóderówič", + "Sigismund" to "Sigismundówič", + "Stefan" to "Stefanówič", + "Wilhelm" to "Wilhelmówič", + "Giórgj" to "Giórgiówič" + ) + + // PERSONAL NAME to MATRONYMIC + private val mechyrdianFemaleNames: List> = listOf( + "Octavia" to "Octaviówca", + "Annica" to "Annicówca", + "Astrið" to "Astriðówca", + "Caþarin" to "Caþarinówca", + "Signi" to "Signówca", + "Erica" to "Ericówca", + "Fréja" to "Fréjówca", + "Hilda" to "Hildówca", + "Žanna" to "Žannówca", + "Xenia" to "Xeniówca", + "Carina" to "Carinówca", + "Giadwiga" to "Giadwigówca", + "Ženia" to "Ženiówca" + ) + + private val mechyrdianFamilyNames: List> = listOf( + "Alexandrów", + "Antonów", + "Pogdanów", + "Hrusčjów", + "Caísarów", + "Carolów", + "Sócolów", + "Romanów", + "Nemeciów", + "Pjótrów", + "Brutów", + "Augustów", + "Calašniców", + "Anželów", + "Sigmarów", + "Dróganów", + "Coroljów", + "Wlasów" + ).map { it to "${it}a" } + + private fun randomMechyrdianName(isFemale: Boolean) = if (isFemale) + mechyrdianFemaleNames.random().first + " " + mechyrdianFemaleNames.random().second + " " + mechyrdianFamilyNames.random().second + else + mechyrdianMaleNames.random().first + " " + mechyrdianMaleNames.random().second + " " + mechyrdianFamilyNames.random().first + + private val tylanMaleNames = listOf( + "Althanar" to "Althanas", + "Aurans" to "Aurantes", + "Bochra" to "Bochranes", + "Chshaejar" to "Chshaejas", + "Hjofvachi" to "Hjovachines", + "Koldimar" to "Koldimas", + "Kor" to "Kores", + "Ljomas" to "Ljomates", + "Shajel" to "Shajel", + "Shokar" to "Shokas", + "Tolavajel" to "Tolavajel", + "Voskar" to "Voskas", + ) + + private val tylanFemaleNames = listOf( + "Althe" to "Althenes", + "Anaseil" to "Anaseil", + "Asetbur" to "Asetbus", + "Atautha" to "Atauthas", + "Aurantia" to "Aurantias", + "Ilasheva" to "Ilashevas", + "Kalora" to "Kaloras", + "Kotolva" to "Kotolvas", + "Psekna" to "Pseknas", + "Shenera" to "Sheneras", + "Reoka" to "Reokas", + "Velga" to "Velgas", + ) + + private val tylanFamilyNames = listOf( + "Kalevkar" to "Kalevka", + "Merku" to "Merkussa", + "Telet" to "Telet", + "Eutokar" to "Eutoka", + "Vsocha" to "Vsochessa", + "Vilar" to "Vilakauva", + "Nikasrar" to "Nika", + "Vlegamakar" to "Vlegamaka", + "Vtokassar" to "Vtoka", + "Theiar" to "Theia", + "Aretar" to "Areta", + "Derkas" to "Derkata", + "Vinsennas" to "Vinsenatta", + "Kleio" to "Kleona" + ) + + // Tylans use matronymics for both sons and daughters + private fun randomTylanName(isFemale: Boolean) = if (isFemale) + tylanFemaleNames.random().first + " " + tylanFemaleNames.random().second + "-Nahra " + tylanFamilyNames.random().second + else + tylanMaleNames.random().first + " " + tylanFemaleNames.random().second + "-Nensar " + tylanFamilyNames.random().first + + private val caliboreseNames = listOf( + "Jathee", + "Muly", + "Simoh", + "Laka", + "Foryn", + "Duxio", + "Xirio", + "Surmy", + "Datarme", + "Cloren", + "Tared", + "Quiliot", + "Attiol", + "Quarree", + "Guil", + "Miro", + "Yryys", + "Zarx", + "Karm", + "Mreek", + "Dulyy", + "Quorqui", + "Dreminor", + "Samitu", + "Lurmak", + "Quashi", + "Barsyn", + "Rymyo", + "Soli", + "Ickart", + "Woom", + "Qurquy", + "Ymiro", + "Rosiliq", + "Xant", + "Xateen", + "Mssly", + "Vixie", + "Quelynn", + "Plly", + "Tessy", + "Veekah", + "Quett", + "Xezeez", + "Xyph", + "Jixi", + "Jeekie", + "Meelen", + "Rasah", + "Reteeshy", + "Xinchie", + "Zae", + "Ziggy", + "Wurikah", + "Loppie", + "Tymma", + "Reely", + "Yjutee", + "Len", + "Vixirat", + "Xumie", + "Xilly", + "Liwwy", + "Gancee", + "Pamah", + "Zeryll", + "Luteet", + "Qusseet", + "Alixika", + "Sepirah", + "Luttrah", + "Aramynn", + "Laxerynn", + "Murylyt", + "Quarapyt", + "Tormiray", + "Daromynn", + "Zuleerynn", + "Quarimat", + "Dormaquazi", + "Tullequazi", + "Aleeray", + "Eppiquit", + "Wittirynn", + "Semiokolipan", + "Sosopurr", + "Quamixit", + "Croffet", + "Xaalit", + "Xemiolyt" + ) + + private val caliboreseVowels = "aeiouy".toSet() + private fun randomCaliboreseName(isFemale: Boolean) = caliboreseNames.filter { + it.length < 8 && (isFemale == (it.last() in caliboreseVowels)) + }.random() + " " + caliboreseNames.filter { it.length > 7 }.random() + + private val latinMaleCommonPraenomina = listOf( + "Gaius", + "Lucius", + "Marcus", + ) + + private val latinMaleUncommonPraenomina = listOf( + "Publius", + "Quintus", + "Titus", + "Gnaeus" + ) + + private val latinMaleRarePraenomina = listOf( + "Aulus", + "Spurius", + "Tiberius", + "Servius", + "Hostus" + ) + + private val latinFemaleCommonPraenomina = listOf( + "Gaia", + "Lucia", + "Marcia", + ) + + private val latinFemaleUncommonPraenomina = listOf( + "Prima", + "Secunda", + "Tertia", + "Quarta", + "Quinta", + "Sexta", + "Septima", + "Octavia", + "Nona", + "Decima" + ) + + private val latinFemaleRarePraenomina = listOf( + "Caesula", + "Titia", + "Tiberia", + "Tanaquil" + ) + + private val latinNominaGentilica = listOf( + "Aelius" to "Aelia", + "Aternius" to "Aternia", + "Caecilius" to "Caecilia", + "Cassius" to "Cassia", + "Claudius" to "Claudia", + "Cornelius" to "Cornelia", + "Calpurnius" to "Calpurnia", + "Fabius" to "Fabia", + "Flavius" to "Flavia", + "Fulvius" to "Fulvia", + "Haterius" to "Hateria", + "Hostilius" to "Hostilia", + "Iulius" to "Iulia", + "Iunius" to "Iunia", + "Iuventius" to "Iuventia", + "Lavinius" to "Lavinia", + "Licinius" to "Licinia", + "Marius" to "Maria", + "Octavius" to "Octavia", + "Pompeius" to "Pompeia", + "Porcius" to "Porcia", + "Salvius" to "Salvia", + "Sempronius" to "Sempronia", + "Spurius" to "Spuria", + "Terentius" to "Terentia", + "Tullius" to "Tullia", + "Ulpius" to "Ulpia", + "Valerius" to "Valeria" + ) + + private val latinCognomina = listOf( + "Agricola" to "Agricola", + "Agrippa" to "Agrippina", + "Aquilinus" to "Aquilina", + "Balbus" to "Balba", + "Bibulus" to "Bibula", + "Bucco" to "Bucco", + "Caecus" to "Caeca", + "Calidus" to "Calida", + "Catilina" to "Catilina", + "Catulus" to "Catula", + "Crassus" to "Crassa", + "Crispus" to "Crispa", + "Drusus" to "Drusilla", + "Flaccus" to "Flacca", + "Gracchus" to "Graccha", + "Laevinus" to "Laevina", + "Lanius" to "Lania", + "Lepidus" to "Lepida", + "Lucullus" to "Luculla", + "Marcellus" to "Marcella", + "Metellus" to "Metella", + "Nasica" to "Nasica", + "Nerva" to "Nerva", + "Paullus" to "Paulla", + "Piso" to "Piso", + "Priscus" to "Prisca", + "Publicola" to "Publicola", + "Pulcher" to "Pulchra", + "Regulus" to "Regula", + "Rufus" to "Rufa", + "Scaevola" to "Scaevola", + "Severus" to "Severa", + "Structus" to "Structa", + "Taurus" to "Taura", + "Varro" to "Varro", + "Vitulus" to "Vitula" + ) + + private fun randomLatinPraenomen(isFemale: Boolean) = when { + Random.nextBoolean() -> if (isFemale) latinFemaleCommonPraenomina else latinMaleCommonPraenomina + Random.nextInt(3) > 0 -> if (isFemale) latinFemaleUncommonPraenomina else latinMaleUncommonPraenomina + else -> if (isFemale) latinFemaleRarePraenomina else latinMaleRarePraenomina + }.random() + + private fun randomLatinName(isFemale: Boolean) = randomLatinPraenomen(isFemale) + " " + latinNominaGentilica.random().let { (m, f) -> if (isFemale) f else m } + " " + latinCognomina.random().let { (m, f) -> if (isFemale) f else m } + + private val dutchMaleNames = listOf( + "Aalderik", + "Andreas", + "Boudewijn", + "Bruno", + "Christiaan", + "Cornelius", + "Darnath", + "Dirk", + "Eren", + "Erwin", + "Frederik", + "Gerlach", + "Helbrant", + "Helbrecht", + "Hendrik", + "Jakob", + "Jochem", + "Joris", + "Koenraad", + "Koorland", + "Leopold", + "Lodewijk", + "Maarten", + "Michel", + "Niels", + "Pieter", + "Renaat", + "Rogal", + "Ruben", + "Sebastiaan", + "Sigismund", + "Sjaak", + "Tobias", + "Valentijn", + "Wiebrand", + ) + + private val dutchFemaleNames = listOf( + "Adelwijn", + "Amberlij", + "Annika", + "Arete", + "Eva", + "Gerda", + "Helga", + "Ida", + "Irene", + "Jacqueline", + "Josefien", + "Juliana", + "Katharijne", + "Lore", + "Margriet", + "Maximilia", + "Meike", + "Nora", + "Rebeka", + "Sara", + "Vera", + "Wilhelmina", + ) + + private val dutchMerchantHouses = listOf( + "Venetho", + "Luibeck", + "Birka", + "Heiðabýr", + "Rostok", + "Guistrov", + "Schverin", + "Koeln", + "Bruigge", + "Reval", + "Elbing", + "Dorpat", + "Stralsund", + "Mijdeborg", + "Breslaw", + "Dortmund", + "Antwerp", + "Falsterbo", + "Zwolle", + "Buchtehud", + "Bremen", + "Zutphen", + "Kampen", + "Grunn", + "Deventer", + "Wismer", + "Luinenburg", + + "Jager", + "Jastobaal", + "Varonius", + "Kupferberg", + "Dijn", + "Umboldt", + "Phalomor", + "Drijk", + "d'Wain", + "du Languille", + "Horstein", + "Jerulas", + "Kendar", + "Castellan", + "d'Aniasie", + "Gerrit", + "Hoed", + "lo Pan", + "Marchandrij", + "d'Aquairre", + "Terozzante", + "d'Argovon", + "de Monde", + "Paillender", + "Holstijn", + "d'Imperia", + "Borodin", + "Agranozza", + "d'Ortise", + "Ijzerhoorn", + "Dremel", + "Hinckel", + "Vuigens", + "Drazen", + "Marburg", + "Xardt", + "Lijze", + "Gerlach", + "Doorn", + "d'Arquebus", + "Alderic", + "Vogen" + ) + + private fun randomDutchName(isFemale: Boolean) = (if (isFemale) dutchFemaleNames else dutchMaleNames).random() + " van " + dutchMerchantHouses.random() + + private val diadochiMaleNames = listOf( + "Oqatai", + "Amogus", + "Nerokhan", + "Choghor", + "Aghonei", + "Martaq", + "Qaran", + "Khargh", + "Qolkhu", + "Ghauran", + "Woriv", + "Vorcha", + "Chagatai", + "Neghvar", + "Qitinga", + "Jimpaq", + "Bivat", + "Durash", + "Elifas", + "Ogus", + "Yuli", + "Saret", + "Mher", + "Tyver", + "Ghraq", + "Niran", + "Galik" + ) + + private val diadochiFemaleNames = listOf( + "Lursha", + "Jamoqena", + "Lokoria", + "Iekuna", + "Shara", + "Etugen", + "Maral", + "Temuln", + "Akhensari", + "Khadagan", + "Gherelma", + "Shechen", + "Althani", + "Tzyrina", + "Daghasi", + "Kloya", + ) + + private val northernDiadochiEpithetParts = listOf( + "Skull", + "Blood", + "Death", + "Claw", + "Doom", + "Dread", + "Soul", + "Spirit", + "Hell", + "Dread", + "Bale", + "Fire", + "Fist", + "Bear", + "Pyre", + "Dark", + "Vile", + "Heart", + "Murder", + "Gore", + "Daemon", + "Talon", + ) + + private fun randomNorthernDiadochiName(isFemale: Boolean) = (if (isFemale) diadochiFemaleNames else diadochiMaleNames).random() + " " + northernDiadochiEpithetParts.random() + northernDiadochiEpithetParts.random().lowercase() + + private val southernDiadochiClans = listOf( + "Arkai", + "Avado", + "Djahhim", + "Khankhen", + "Porok", + "Miras", + "Terok", + "Empok", + "Noragh", + "Nuunian", + "Soung", + "Akhero", + "Qozaq", + "Kherus", + "Axina", + "Ghaizas", + "Saxha", + "Meshu", + "Khopesh", + "Qitemar", + "Vang", + "Lugal", + "Galla", + "Hheka", + "Nesut", + "Koquon", + "Molekh" + ) + + private fun randomSouthernDiadochiClan() = when { + Random.nextInt(5) == 0 -> southernDiadochiClans.random() + "-" + southernDiadochiClans.random() + else -> southernDiadochiClans.random() + } + + private fun randomSouthernDiadochiName(isFemale: Boolean) = (if (isFemale) diadochiFemaleNames else diadochiMaleNames).random() + (if (isFemale && Random.nextBoolean()) " ka-" else " am-") + diadochiMaleNames.random() + " " + randomSouthernDiadochiClan() + + private val thedishMaleNames = listOf( + "Praethoris", + "Severus", + "Augast", + "Dagobar", + "Vrankenn", + "Kandar", + "Kleon", + "Glaius", + "Karul", + "Ylai", + "Toval", + "Ivon", + "Belis", + "Jorh", + "Svar", + "Alaric", + ) + + private val thedishFemaleNames = listOf( + "Serna", + "Veleska", + "Ielga", + "Glae", + "Rova", + "Ylia", + "Galera", + "Nerys", + "Veleer", + "Karuleyn", + "Amberli", + "Alysia", + "Lenera", + "Demeter", + ) + + private val thedishSurnames = listOf( + "Kassck", + "Orsh", + "Falk", + "Khorr", + "Vaskoman", + "Vholkazk", + "Brekoryn", + "Lorus", + "Karnas", + "Hathar", + "Takan", + "Pertona", + "Tefran", + "Arvi", + "Galvus", + "Voss", + "Mandanof", + "Ursali", + "Vytunn", + "Quesrinn", + ) + + private fun randomThedishName(isFemale: Boolean) = (if (isFemale) thedishFemaleNames else thedishMaleNames).random() + " " + thedishSurnames.random() + + private val americanMaleNames = listOf( + "George", + "John", + "Thomas", + "James", + "Quincy", + "Andrew", + "Martin", + "William", + "Henry", + "James", + "Zachary", + "Millard", + "Franklin", + "Abraham", + "Ulysses", + "Rutherford", + "Chester", + "Grover", + "Benjamin", + "Theodore", + "Warren", + "Calvin", + "Herbert", + "Harry", + "Dwight", + "Lyndon", + "Richard", + "Dick", + "Gerald", + "Jimmy", + "Ronald", + "Donald" + ) + + private val americanFemaleNames = listOf( + "Martha", + "Abigail", + "Elizabeth", + "Louisa", + "Emily", + "Sarah", + "Anna", + "Jane", + "Julia", + "Margaret", + "Harriet", + "Mary", + "Lucy", + "Rose", + "Caroline", + "Ida", + "Helen", + "Grace", + "Jacqueline", + "Thelma", + "Eleanor", + "Nancy", + "Barbara", + "Laura", + "Melania" + ) + + private val americanFamilyNames = listOf( + "Knox", + "Pickering", + "McHenry", + "Dexter", + "Drawborn", + "Eustis", + "Armstrong", + "Monroe", + "Crawford", + "Calhoun", + "Barbour", + "Porter", + "Eaton", + "Cass", + "Poinsett", + "Bell", + "Forrestal", + "Johnson", + "Marshall", + "Lovett", + "Wilson", + "McElroy", + "McNamara", + "Clifford", + "Richardson", + "Burndt", + ) + + private fun randomAmericanName(isFemale: Boolean) = (if (isFemale) americanFemaleNames else americanMaleNames).random() + " " + americanFamilyNames.random() + + private val hispanicMaleNames = listOf( + "Aaron", + "Antonio", + "Augusto", + "Eliseo", + "Manuel", + "Jose", + "Juan", + "Miguel", + "Rafael", + "Raul", + "Adriano", + "Emilio", + "Francisco", + "Ignacio", + "Marco", + "Pablo", + "Octavio", + "Victor", + "Vito", + "Valentin" + ) + + private val hispanicFemaleNames = listOf( + "Maria", + "Ana", + "Camila", + "Eva", + "Flora", + "Gloria", + "Julia", + "Marcelina", + "Rosalia", + "Victoria", + "Valentina", + "Cecilia", + "Francisca", + "Aurelia", + "Cristina", + "Magdalena", + "Margarita", + "Martina", + "Teresa" + ) + + private val hispanicFamilyNames = listOf( + "Acorda", + "Aguirre", + "Alzaga", + "Arriaga", + "Arrieta", + "Berroya", + "Barahona", + "Carranza", + "Carriaga", + "Elcano", + "Elizaga", + "Endaya", + "Franco", + "Garalde", + "Ibarra", + "Juarez", + "Lazarte", + "Legarda", + "Madariaga", + "Medrano", + "Narvaez", + "Olano", + "Ricarte", + "Salazar", + "Uriarte", + "Varona", + "Vergar", + ) + + private fun randomHispanicName(isFemale: Boolean) = (if (isFemale) hispanicFemaleNames else hispanicMaleNames).random() + " " + hispanicFamilyNames.random() + + fun randomName(flavor: AdmiralNameFlavor, isFemale: Boolean) = when (flavor) { + AdmiralNameFlavor.MECHYRDIA -> randomMechyrdianName(isFemale) + AdmiralNameFlavor.TYLA -> randomTylanName(isFemale) + AdmiralNameFlavor.CALIBOR -> randomCaliboreseName(isFemale) + AdmiralNameFlavor.OLYMPIA -> randomLatinName(isFemale) + AdmiralNameFlavor.DUTCH -> randomDutchName(isFemale) + AdmiralNameFlavor.NORTHERN_DIADOCHI -> randomNorthernDiadochiName(isFemale) + AdmiralNameFlavor.SOUTHERN_DIADOCHI -> randomSouthernDiadochiName(isFemale) + AdmiralNameFlavor.FULKREYKK -> randomThedishName(isFemale) + AdmiralNameFlavor.AMERICAN -> randomAmericanName(isFemale) + AdmiralNameFlavor.HISPANIC_AMERICAN -> randomHispanicName(isFemale) + } +} diff --git a/src/commonMain/kotlin/net/starshipfights/data/admiralty/ship_names.kt b/src/commonMain/kotlin/net/starshipfights/data/admiralty/ship_names.kt new file mode 100644 index 0000000..1e89acd --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/data/admiralty/ship_names.kt @@ -0,0 +1,452 @@ +package net.starshipfights.data.admiralty + +import net.starshipfights.game.Faction +import net.starshipfights.game.ShipWeightClass +import kotlin.random.Random + +fun newShipName(faction: Faction, shipWeightClass: ShipWeightClass, existingNames: MutableSet) = generateSequence { + nameShip(faction, shipWeightClass) +}.take(20).dropWhile { it in existingNames }.firstOrNull()?.also { existingNames.add(it) } + +private val mechyrdianFrigateNames1 = listOf( + "Unconquerable", + "Indomitable", + "Invincible", + "Imperial", + "Regal", + "Royal", + "Imperious", + "Honorable", + "Defiant", + "Eternal", + "Infinite", + "Dominant", + "Divine", + "Righteous", + "Resplendent", + "Protective", + "Innocent", + "August", + "Loyal" +) + +private val mechyrdianFrigateNames2 = listOf( + "Faith", + "Empire", + "Royalty", + "Regality", + "Honor", + "Defiance", + "Eternity", + "Dominator", + "Divinity", + "Right", + "Righteousness", + "Resplendency", + "Defender", + "Protector", + "Innocence", + "Victory", + "Duty", + "Loyalty" +) + +private val mechyrdianCruiserNames1 = listOf( + "Defender of", + "Protector of", + "Shield of", + "Sword of", + "Champion of", + "Hero of", + "Salvation of", + "Savior of", + "Shining Light of", + "Righteous Flame of", + "Eternal Glory of", +) + +private val mechyrdianCruiserNames2 = listOf( + "Mechyrd", + "Kaiserswelt", + "Tenno no Wakusei", + "Nova Roma", + "Mont Imperial", + "Tyla", + "Vensca", + "Kaltag", + "Languavarth Prime", + "Languavarth Secundum", + "Elcialot", + "Othon", + "Starport", + "Sacrilegum", + "New Constantinople", + "Fairhus", + "Praxagora", + "Karolina", + "Kozachnia", + "New New Amsterdam", + "Mundus Caesaris Divi", + "Saiwatta", + "Earth" +) + +private val mechyrdianBattleshipNames = listOf( + "Kaiser Wilhelm I", + "Kaiser Wilhelm II", + "Empereur Napoléon I Bonaparte", + "Tsar Nikolaj II Romanov", + "Seliger Kaiser Karl I von Habsburg", + "Emperor Joshua A. Norton I", + "Emperor Meiji the Great", + "Emperor Jack G. Coleman", + "Emperor Trevor C. Neer", + "Emperor Connor F. Vance", + "Emperor Jean-Bédel Bokassa I", + "King Charles XII", + "King William I the Conqueror", + "King Alfred the Great", + "Gustavus Adolphus Magnus Rex", + "Queen Victoria", + "Kōnstantînos XI Dragásēs Palaiológos", + "Ioustinianós I ho Mégas", + "Kjarossa Liha Vilakauva", + "Kjarossa Tarkona Sovasra", + "Great King Kūruš", + "Queen Elizabeth II", + "Kjarossa Karelka Helasra", + "Imperātor Cæsar Dīvī Fīlius Augustus", + "Cæsar Nerva Trāiānus", + "King Kaleb of Axum" +) + +private fun nameMechyrdianShip(weightClass: ShipWeightClass) = when (weightClass) { + ShipWeightClass.ESCORT -> "${mechyrdianFrigateNames1.random()} ${mechyrdianFrigateNames2.random()}" + ShipWeightClass.DESTROYER -> "${mechyrdianFrigateNames1.random()} ${mechyrdianFrigateNames2.random()}" + ShipWeightClass.CRUISER -> "${mechyrdianCruiserNames1.random()} ${mechyrdianCruiserNames2.random()}" + ShipWeightClass.BATTLECRUISER -> "${mechyrdianCruiserNames1.random()} ${mechyrdianCruiserNames2.random()}" + ShipWeightClass.BATTLESHIP -> mechyrdianBattleshipNames.random() + ShipWeightClass.BATTLE_BARGE -> mechyrdianBattleshipNames.random() + else -> error("Invalid Mechyrdian ship weight!") +} + +private val masraDraetsenFrigateNames1 = listOf( + "Murderous", + "Hateful", + "Heinous", + "Pestilent", + "Corrupting", + "Homicidal", + "Deadly", + "Primordial", + "Painful", + "Agonizing", + "Spiteful", + "Odious", + "Miserating", + "Damned", + "Condemned", + "Hellish", + "Dark", + "Impious", + "Unfaithful", + "Abyssal", + "Furious", + "Vengeful", + "Spiritous" +) + +private val masraDraetsenFrigateNames2 = listOf( + "Murder", + "Hate", + "Hatred", + "Pestilence", + "Corruption", + "Homicide", + "Massacre", + "Death", + "Agony", + "Pain", + "Suffering", + "Spite", + "Misery", + "Damnation", + "Hell", + "Darkness", + "Impiety", + "Faithlessness", + "Abyss", + "Fury", + "Vengeance", + "Spirit" +) + +private val masraDraetsenCruiserNames1 = listOf( + "Despoiler of", + "Desecrator of", + "Desolator of", + "Destroyer of", + "Executioner of", + "Pillager of", + "Villain of", + "Great Devil of", + "Infidelity of", + "Incineration of", + "Immolation of", + "Crucifixion of", + "Unending Darkness of", +) + +private val masraDraetsenCruiserNames2 = listOf( + // Diadochi space + "Eskhaton", + "Terminus", + "Tychiphage", + "Magaddu", + "Ghattusha", + "Three Suns", + "RB-5354", + "VT-3072", + "Siegsstern", + "Atzalstadt", + "Apex", + "Summit", + // Lyudareykk and Isarnareykk + "Vion Kann", + "Kasr Karul", + "Vladizapad", + // Chaebodes Star Empire + "Ultima Thule", + "Prenovez", + // Calibor and Vescar sectors + "Letum Angelorum", + "Pharsalus", + "Eutopia", + // Ferthlon and Olympia sectors + "Ferthlon Primus", + "Ferthlon Secundus", + "Nova Roma", + "Mont Imperial", +) + +private const val masraDraetsenColossusName = "Boukephalas" + +private fun nameMasraDraetsenShip(weightClass: ShipWeightClass) = when (weightClass) { + ShipWeightClass.ESCORT -> "${masraDraetsenFrigateNames1.random()} ${masraDraetsenFrigateNames2.random()}" + ShipWeightClass.DESTROYER -> "${masraDraetsenFrigateNames1.random()} ${masraDraetsenFrigateNames2.random()}" + ShipWeightClass.CRUISER -> "${masraDraetsenCruiserNames1.random()} ${masraDraetsenCruiserNames2.random()}" + ShipWeightClass.GRAND_CRUISER -> "${masraDraetsenCruiserNames1.random()} ${masraDraetsenCruiserNames2.random()}" + ShipWeightClass.COLOSSUS -> masraDraetsenColossusName + else -> error("Invalid Masra Draetsen ship weight!") +} + +private enum class LatinNounForm { + MAS_SG, + FEM_SG, + NEU_SG, + MAS_PL, + FEM_PL, + NEU_PL, +} + +private data class LatinNoun( + val noun: String, + val form: LatinNounForm +) + +private data class LatinAdjective( + val masculineSingular: String, + val feminineSingular: String, + val neuterSingular: String, + val masculinePlural: String, + val femininePlural: String, + val neuterPlural: String, +) { + fun get(form: LatinNounForm) = when (form) { + LatinNounForm.MAS_SG -> masculineSingular + LatinNounForm.FEM_SG -> feminineSingular + LatinNounForm.NEU_SG -> neuterSingular + LatinNounForm.MAS_PL -> masculinePlural + LatinNounForm.FEM_PL -> femininePlural + LatinNounForm.NEU_PL -> neuterPlural + } +} + +private infix fun LatinNoun.describedBy(adjective: LatinAdjective) = "$noun ${adjective.get(form)}" + +private fun felinaeFelicesEscortShipName() = "ES-" + (1000..9999).random().toString() + +private val felinaeFelicesLineShipNames1 = listOf( + LatinNoun("Aevum", LatinNounForm.NEU_SG), + LatinNoun("Aquila", LatinNounForm.FEM_SG), + LatinNoun("Argonauta", LatinNounForm.MAS_SG), + LatinNoun("Cattus", LatinNounForm.MAS_SG), + LatinNoun("Daemon", LatinNounForm.MAS_SG), + LatinNoun("Divitia", LatinNounForm.FEM_SG), + LatinNoun("Feles", LatinNounForm.FEM_SG), + LatinNoun("Imperium", LatinNounForm.NEU_SG), + LatinNoun("Ius", LatinNounForm.NEU_SG), + LatinNoun("Iustitia", LatinNounForm.FEM_SG), + LatinNoun("Leo", LatinNounForm.MAS_SG), + LatinNoun("Leopardus", LatinNounForm.MAS_SG), + LatinNoun("Lynx", LatinNounForm.FEM_SG), + LatinNoun("Panthera", LatinNounForm.FEM_SG), + LatinNoun("Salvator", LatinNounForm.MAS_SG), + LatinNoun("Scelus", LatinNounForm.NEU_SG), + LatinNoun("Tigris", LatinNounForm.MAS_SG), +) + +private val felinaeFelicesLineShipNames2 = listOf( + LatinAdjective("Animosus", "Animosa", "Animosum", "Animosi", "Animosae", "Animosa"), + LatinAdjective("Ardens", "Ardens", "Ardens", "Ardentes", "Ardentes", "Ardentia"), + LatinAdjective("Audax", "Audax", "Audax", "Audaces", "Audaces", "Audacia"), + LatinAdjective("Astutus", "Astuta", "Astutum", "Astuti", "Astutae", "Astuta"), + LatinAdjective("Calidus", "Calida", "Calidum", "Calidi", "Calidae", "Calida"), + LatinAdjective("Ferox", "Ferox", "Ferox", "Feroces", "Feroces", "Ferocia"), + LatinAdjective("Fortis", "Fortis", "Forte", "Fortes", "Fortes", "Fortia"), + LatinAdjective("Fugax", "Fugax", "Fugax", "Fugaces", "Fugaces", "Fugacia"), + LatinAdjective("Indomitus", "Indomita", "Indomitum", "Indomiti", "Indomitae", "Indomita"), + LatinAdjective("Intrepidus", "Intrepida", "Intrepidum", "Intrepidi", "Intrepidae", "Intrepida"), + LatinAdjective("Pervicax", "Pervicax", "Pervicax", "Pervicaces", "Pervicaces", "Pervicacia"), + LatinAdjective("Sagax", "Sagax", "Sagax", "Sagaces", "Sagaces", "Sagacia"), + LatinAdjective("Superbus", "Superba", "Superbum", "Superbi", "Superbae", "Superba"), + LatinAdjective("Trux", "Trux", "Trux", "Truces", "Truces", "Trucia"), +) + +private fun nameFelinaeFelicesShip(weightClass: ShipWeightClass) = when (weightClass) { + ShipWeightClass.FF_ESCORT -> felinaeFelicesEscortShipName() + ShipWeightClass.FF_DESTROYER -> felinaeFelicesLineShipNames1.random() describedBy felinaeFelicesLineShipNames2.random() + ShipWeightClass.FF_CRUISER -> felinaeFelicesLineShipNames1.random() describedBy felinaeFelicesLineShipNames2.random() + ShipWeightClass.FF_BATTLECRUISER -> felinaeFelicesLineShipNames1.random() describedBy felinaeFelicesLineShipNames2.random() + ShipWeightClass.FF_BATTLESHIP -> if (Random.nextDouble() < 0.01) "Big Floppa" else (felinaeFelicesLineShipNames1.random() describedBy felinaeFelicesLineShipNames2.random()) + else -> error("Invalid Felinae Felices ship weight!") +} + +private val isarnareykkShipNames = listOf( + "Professional with Standards", + "Online Game Cheater", + "Actually Made of Antimatter", + "Chucklehead", + "Guns Strapped to an Engine", + "Unidentified Comet", + "Deep Space Encounter", + "The Goggles Do Nothing", + "Sensor Error", + "ERROR SHIP NAME NOT FOUND", + "0x426F6174", + "Börgenkub", + "Instant Death", + "Assume The Position", + "Negative Space Wedgie", + "Tea, Earl Grey, Hot", + "There's Coffee In That Nebula", + "SPEHSS MEHREENS", + "Inconspicuous Asteroid", + "Inflatable Toy Ship", + "HELP TRAPPED IN SHIP FACTORY", + "Illegal Meme Dealer", + "Reverse the Polarity!", + "Send Your Bank Info To Win 10,000 Marks", + "STOP CALLING ABOUT MY STARSHIP WARRANTY", + "Somebody Once Told Me...", + "Praethoris Khorr Gaming", +) + +private fun nameIsarnareykskShip() = isarnareykkShipNames.random() + +private val vestigiumShipNames = listOf( + // NAMED AFTER SPACE SHUTTLES + "Enterprise", // OV-101 + "Columbia", // OV-102 + "Discovery", // OV-103 + "Atlantis", // OV-104 + "Endeavor", // OV-105 + "Conqueror", // OV-106 + "Homeland", // OV-107 + "Augustus", // OV-108 + "Avenger", // OV-109 + "Protector", // OV-110 + + // NAMED AFTER HISTORICAL SHIPS + "Yorktown", + "Lexington", + "Ranger", + "Hornet", + "Wasp", + "Antares", + "Belfast", + // NAMED AFTER PLACES + "Akron", + "Hudson", + "Cleveland", + "Baltimore", + "Bel Air", + "Cedar Rapids", + "McHenry", + "Rochester", + "Cuyahoga Valley", + "Catonsville", + "Ocean City", + "Philadelphia", + "Somerset", + "Pittsburgh", + + "Las Vegas", + "Reno", + "Boulder City", + "Goodsprings", + "Nipton", + "Primm", + "Nellis", + "Fortification Hill", + "McCarran", + "Fremont", + + // NAMED AFTER SPACE PROBES + "Voyager", + "Juno", + "Cassini", + "Hubble", + "Huygens", + "Pioneer", + + // NAMED AFTER PEOPLE + // Founding Fathers + "George Washington", + "Thomas Jefferson", + "John Adams", + "Alexander Hamilton", + "James Madison", + // US Presidents + "Andrew Jackson", + "Abraham Lincoln", + "Theodore Roosevelt", + "Calvin Coolidge", + "Dwight Eisenhower", + "Richard Nixon", + "Ronald Reagan", + "Donald Trump", + "Ron DeSantis", + "Gary Martison", + // IS Emperors + "Jack Coleman", + "Trevor Neer", + "Hadrey Trevison", + "Dio Audrey", + "Connor Vance", + // Vestigium Leaders + "Thomas Blackrock", + "Philip Mack", + "Ilya Korochenko" +) + +private fun nameAmericanShip() = vestigiumShipNames.random() + +fun nameShip(faction: Faction, weightClass: ShipWeightClass): String = when (faction) { + Faction.MECHYRDIA -> nameMechyrdianShip(weightClass) + Faction.NDRC -> nameMechyrdianShip(weightClass) + Faction.MASRA_DRAETSEN -> nameMasraDraetsenShip(weightClass) + Faction.FELINAE_FELICES -> nameFelinaeFelicesShip(weightClass) + Faction.ISARNAREYKK -> nameIsarnareykskShip() + Faction.VESTIGIUM -> nameAmericanShip() +} diff --git a/src/commonMain/kotlin/net/starshipfights/data/data.kt b/src/commonMain/kotlin/net/starshipfights/data/data.kt new file mode 100644 index 0000000..ba3f3c2 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/data/data.kt @@ -0,0 +1,36 @@ +package net.starshipfights.data + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlin.jvm.JvmInline + +@JvmInline +@Serializable(with = IdSerializer::class) +value class Id<@Suppress("unused") T>(val id: String) { + override fun toString() = id + + fun reinterpret() = Id(id) + + companion object { + fun serializer(): KSerializer> = IdSerializer + } +} + +object IdSerializer : KSerializer> { + private val inner = String.serializer() + + override val descriptor: SerialDescriptor + get() = inner.descriptor + + override fun serialize(encoder: Encoder, value: Id<*>) { + inner.serialize(encoder, value.id) + } + + override fun deserialize(decoder: Decoder): Id<*> { + return Id(inner.deserialize(decoder)) + } +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/admiralty.kt b/src/commonMain/kotlin/net/starshipfights/game/admiralty.kt new file mode 100644 index 0000000..e6506b6 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/admiralty.kt @@ -0,0 +1,113 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id + +enum class AdmiralRank { + REAR_ADMIRAL, + VICE_ADMIRAL, + ADMIRAL, + HIGH_ADMIRAL, + LORD_ADMIRAL; + + val maxShipWeightClass: ShipWeightClass + get() = when (this) { + REAR_ADMIRAL -> ShipWeightClass.CRUISER + VICE_ADMIRAL -> ShipWeightClass.BATTLECRUISER + ADMIRAL -> ShipWeightClass.BATTLESHIP + HIGH_ADMIRAL -> ShipWeightClass.BATTLESHIP + LORD_ADMIRAL -> ShipWeightClass.COLOSSUS + } + + val maxBattleSize: BattleSize + get() = BattleSize.values().last { it.maxWeightClass.tier <= maxShipWeightClass.tier } + + val minAcumen: Int + get() = when (this) { + REAR_ADMIRAL -> 0 + VICE_ADMIRAL -> 1000 + ADMIRAL -> 4000 + HIGH_ADMIRAL -> 9000 + LORD_ADMIRAL -> 16000 + } + + val dailyWage: Int + get() = when (this) { + REAR_ADMIRAL -> 40 + VICE_ADMIRAL -> 50 + ADMIRAL -> 60 + HIGH_ADMIRAL -> 70 + LORD_ADMIRAL -> 80 + } + + companion object { + fun fromAcumen(acumen: Int) = values().lastOrNull { it.minAcumen <= acumen } ?: values().first() + } +} + +fun AdmiralRank.getDisplayName(faction: Faction) = when (faction) { + Faction.MECHYRDIA -> when (this) { + AdmiralRank.REAR_ADMIRAL -> "Retrógardi Admiral" + AdmiralRank.VICE_ADMIRAL -> "Vicj Admiral" + AdmiralRank.ADMIRAL -> "Admiral" + AdmiralRank.HIGH_ADMIRAL -> "Altadmiral" + AdmiralRank.LORD_ADMIRAL -> "Dómin Admiral" + } + Faction.NDRC -> when (this) { + AdmiralRank.REAR_ADMIRAL -> "Commandeur" + AdmiralRank.VICE_ADMIRAL -> "Schout-bij-Nacht" + AdmiralRank.ADMIRAL -> "Vice-Admiraal" + AdmiralRank.HIGH_ADMIRAL -> "Luitenant-Admiraal" + AdmiralRank.LORD_ADMIRAL -> "Admiraal" + } + Faction.MASRA_DRAETSEN -> when (this) { + AdmiralRank.REAR_ADMIRAL -> "Syna Raquor" + AdmiralRank.VICE_ADMIRAL -> "Ruhn Raquor" + AdmiralRank.ADMIRAL -> "Raquor" + AdmiralRank.HIGH_ADMIRAL -> "Vosh Raquor" + AdmiralRank.LORD_ADMIRAL -> "Yauh Raquor" + } + Faction.FELINAE_FELICES -> when (this) { + AdmiralRank.REAR_ADMIRAL -> "Domina Iunior" + AdmiralRank.VICE_ADMIRAL -> "Domina Vicaria" + AdmiralRank.ADMIRAL -> "Domina" + AdmiralRank.HIGH_ADMIRAL -> "Domina Senior" + AdmiralRank.LORD_ADMIRAL -> "Ducissa" + } + Faction.ISARNAREYKK -> when (this) { + AdmiralRank.REAR_ADMIRAL -> "Maer nu Ambaght" + AdmiralRank.VICE_ADMIRAL -> "Neid Fletsleydar" + AdmiralRank.ADMIRAL -> "Fletsleydar" + AdmiralRank.HIGH_ADMIRAL -> "Hauk Fletsleydar" + AdmiralRank.LORD_ADMIRAL -> "Hokst Fletsleydar" + } + Faction.VESTIGIUM -> when (this) { + AdmiralRank.REAR_ADMIRAL -> "Rear Marshal" + AdmiralRank.VICE_ADMIRAL -> "Vice Marshal" + AdmiralRank.ADMIRAL -> "Marshal" + AdmiralRank.HIGH_ADMIRAL -> "Grand Marshal" + AdmiralRank.LORD_ADMIRAL -> "Chief Marshal" + } +} + +@Serializable +data class InGameUser( + val id: Id, + val username: String +) + +@Serializable +data class InGameAdmiral( + val id: Id, + + val user: InGameUser, + + val name: String, + val isFemale: Boolean, + + val faction: Faction, + val rank: AdmiralRank, +) { + val fullName: String + get() = "${rank.getDisplayName(faction)} $name" +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_behaviors.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_behaviors.kt new file mode 100644 index 0000000..803ae3a --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_behaviors.kt @@ -0,0 +1,422 @@ +package net.starshipfights.game.ai + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.produceIn +import net.starshipfights.data.Id +import net.starshipfights.game.* +import kotlin.math.pow +import kotlin.random.Random + +data class AIPlayer( + val gameState: StateFlow, + val doActions: SendChannel, + val getErrors: ReceiveChannel, + val onGameEnd: CompletableJob +) + +@OptIn(FlowPreview::class) +suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) { + try { + coroutineScope { + val brain = Brain() + + val phasePipe = Channel>(Channel.CONFLATED) + + launch(onGameEnd) { + var prevSentAt = Moment.now + + for (state in gameState.produceIn(this)) { + phasePipe.send(state.phase to (state.doneWithPhase != mySide && (!state.phase.usesInitiative || state.currentInitiative != mySide.other))) + + for (msg in state.chatBox.takeLastWhile { msg -> msg.sentAt > prevSentAt }) { + if (msg.sentAt > prevSentAt) + prevSentAt = msg.sentAt + + when (msg) { + is ChatEntry.PlayerMessage -> { + // ignore + } + is ChatEntry.ShipIdentified -> { + val identifiedShip = state.ships[msg.ship] ?: continue + if (identifiedShip.owner != mySide) + brain[shipAttackPriority forShip identifiedShip.id] += identifiedShip.ship.shipType.weightClass.tier.toDouble().pow(instincts[combatTargetShipWeight]) + } + is ChatEntry.ShipEscaped -> { + // handle escaping ship + } + is ChatEntry.ShipAttacked -> { + val targetedShip = state.ships[msg.ship] ?: continue + if (targetedShip.owner != mySide) + brain[shipAttackPriority forShip targetedShip.id] -= Random.nextDouble(msg.damageInflicted - 0.5, msg.damageInflicted + 0.5) * instincts[combatForgiveTarget] + else if (msg.attacker is ShipAttacker.EnemyShip) + brain[shipAttackPriority forShip msg.attacker.id] += Random.nextDouble(msg.damageInflicted - 0.5, msg.damageInflicted + 0.5) * instincts[combatAvengeAttacks] + } + is ChatEntry.ShipAttackFailed -> { + val targetedShip = state.ships[msg.ship] ?: continue + if (targetedShip.owner != mySide) + brain[shipAttackPriority forShip targetedShip.id] += instincts[combatFrustratedByFailedAttacks] + } + is ChatEntry.ShipBoarded -> { + val targetedShip = state.ships[msg.ship] ?: continue + if (targetedShip.owner != mySide) + brain[shipAttackPriority forShip targetedShip.id] -= Random.nextDouble(msg.damageAmount - 0.5, msg.damageAmount + 0.5) * instincts[combatForgiveTarget] + else + brain[shipAttackPriority forShip msg.boarder] += Random.nextDouble(msg.damageAmount - 0.5, msg.damageAmount + 0.5) * instincts[combatAvengeAttacks] + } + is ChatEntry.ShipDestroyed -> { + val targetedShip = state.ships[msg.ship] ?: continue + if (targetedShip.owner == mySide && msg.destroyedBy is ShipAttacker.EnemyShip) + brain[shipAttackPriority forShip msg.destroyedBy.id] += instincts[combatAvengeShipwrecks] * targetedShip.ship.shipType.weightClass.tier.toDouble().pow(instincts[combatAvengeShipWeight]) + } + } + } + } + } + + launch(onGameEnd) { + loop@ for ((phase, canAct) in phasePipe) { + if (!canAct) continue@loop + + val state = gameState.value + + when (phase) { + GamePhase.Deploy -> { + for ((shipId, position) in deploy(state, mySide, instincts)) { + val abilityType = PlayerAbilityType.DeployShip(shipId.reinterpret()) + val abilityData = PlayerAbilityData.DeployShip(position) + + doActions.send(PlayerAction.UseAbility(abilityType, abilityData)) + + withTimeoutOrNull(50L) { getErrors.receive() }?.let { errorMsg -> + logWarning("Error when deploying ship ID $shipId - $errorMsg") + } + } + + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) + } + is GamePhase.Power -> { + val powerableShips = state.ships.values.filter { ship -> + ship.owner == mySide && !ship.isDoneCurrentPhase + } + + for (ship in powerableShips) + when (val reactor = ship.ship.reactor) { + FelinaeShipReactor -> { + val newPowerMode = if (ship.hullAmount < ship.durability.maxHullPoints) + FelinaeShipPowerMode.HULL_RECOALESCENSE + else + FelinaeShipPowerMode.INERTIALESS_DRIVE + + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.ConfigurePower(ship.id, newPowerMode), PlayerAbilityData.ConfigurePower)) + } + is StandardShipReactor -> { + val enginesToShields = when { + ship.powerMode.engines == 0 -> -1 + ship.shieldAmount == 0 -> 2 + ship.shieldAmount < (ship.powerMode.shields / 2) -> 1 + ship.shieldAmount < ship.powerMode.shields -> (0..1).random() + else -> 0 + }.coerceIn(-reactor.gridEfficiency..reactor.gridEfficiency) + + val currPower = ship.powerMode + val nextPower = currPower + mapOf( + ShipSubsystem.SHIELDS to enginesToShields, + ShipSubsystem.ENGINES to -enginesToShields + ) + + val chosenPower = if (ship.validatePowerMode(nextPower)) nextPower else currPower + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DistributePower(ship.id), PlayerAbilityData.DistributePower(chosenPower))) + } + } + + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) + } + is GamePhase.Move -> { + val movableShips = state.ships.values.filter { ship -> + ship.owner == mySide && !ship.isDoneCurrentPhase + } + + val smallestShipTier = movableShips.minOfOrNull { ship -> ship.ship.shipType.weightClass.tier } + + if (smallestShipTier == null) { + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) + continue@loop + } + + val movableSmallestShips = movableShips.filter { ship -> + ship.ship.shipType.weightClass.tier == smallestShipTier + } + + val moveThisShip = movableSmallestShips.associateWith { it.calculateSuffering() + 1.0 }.weightedRandom() + doActions.send(navigate(state, moveThisShip, instincts, brain)) + + withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> + logWarning("Error when moving ship ID ${moveThisShip.id} - $error") + doActions.send( + PlayerAction.UseAbility( + PlayerAbilityType.MoveShip(moveThisShip.id), + PlayerAbilityData.MoveShip(moveThisShip.position) + ) + ) + } + } + is GamePhase.Attack -> { + val potentialAttacks = state.ships.values.flatMap { ship -> + if (ship.owner == mySide) + ship.armaments.keys.filter { + ship.canUseWeapon(it) + }.flatMap { weaponId -> + weaponId.validTargets(state, ship).map { target -> + Triple(ship, weaponId, target) + } + } + else emptyList() + }.associateWith { (ship, weaponId, target) -> + weaponId.expectedAdvantageFromWeaponUsage(state, ship, target) * smoothNegative(brain[shipAttackPriority forShip target.id].signedPow(instincts[combatPrioritization])) * (1 + target.calculateSuffering()).signedPow(instincts[combatPreyOnTheWeak]) + } + + if (potentialAttacks.isEmpty() || Random.nextInt(3) == 0) { + val potentialBoardings = state.ships.values.flatMap { ship -> + if (ship.owner == mySide && ship.canSendBoardingParty) { + val pickRequest = ship.getBoardingPickRequest() + state.ships.values.filter { target -> + target.owner == mySide.other && target.position.location in pickRequest.boundary + }.map { target -> ship to target } + } else emptyList() + }.associateWith { (ship, target) -> + ship.expectedBoardingSuccess(target) + } + + val board = potentialBoardings.weightedRandomOrNull() + + if (board != null) { + val (ship, target) = board + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.BoardingParty(ship.id), PlayerAbilityData.BoardingParty(target.id))) + + withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> + logWarning("Error when boarding target ship ID ${target.id} with assault parties of ship ID ${ship.id} - $error") + + val nextState = gameState.value + phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other))) + } + + continue@loop + } + } + + val attackWith = potentialAttacks.weightedRandomOrNull() + + if (attackWith == null) { + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) + continue@loop + } + + val (ship, weaponId, target) = attackWith + val targetPickResponse = when (val weaponSpec = ship.armaments[weaponId]?.weapon) { + is AreaWeapon -> { + val pickRequest = ship.getWeaponPickRequest(weaponSpec) + val targetLocation = target.position.location + val closestValidLocation = pickRequest.boundary.closestPointTo(targetLocation) + + val chosenLocation = if ((targetLocation - closestValidLocation).length >= EPSILON) + closestValidLocation + ((closestValidLocation - targetLocation) * 0.2) + else closestValidLocation + + PickResponse.Location(chosenLocation) + } + is ShipWeapon.Lance -> { + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.ChargeLance(ship.id, weaponId), PlayerAbilityData.ChargeLance)) + withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> + logWarning("Error when charging lance weapon $weaponId of ship ID ${ship.id} - $error") + } + + PickResponse.Ship(target.id) + } + else -> PickResponse.Ship(target.id) + } + + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.UseWeapon(ship.id, weaponId), PlayerAbilityData.UseWeapon(targetPickResponse))) + + withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> + logWarning("Error when attacking target ship ID ${target.id} with weapon $weaponId of ship ID ${ship.id} - $error") + + val remainingAllAreaWeapons = potentialAttacks.keys.map { (attacker, weaponId, _) -> + attacker to weaponId + }.toSet().all { (attacker, weaponId) -> + attacker.armaments[weaponId]?.weapon is AreaWeapon + } + + if (remainingAllAreaWeapons) + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) + else { + val nextState = gameState.value + phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other))) + } + } + } + is GamePhase.Repair -> { + val repairAbility = state.getPossibleAbilities(mySide).filter { + it !is PlayerAbilityType.DonePhase + }.randomOrNull() + + if (repairAbility == null) { + doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) + continue@loop + } + + when (repairAbility) { + is PlayerAbilityType.RepairShipModule -> PlayerAbilityData.RepairShipModule + is PlayerAbilityType.ExtinguishFire -> PlayerAbilityData.ExtinguishFire + is PlayerAbilityType.Recoalesce -> PlayerAbilityData.Recoalesce + else -> null + }?.let { repairData -> + doActions.send(PlayerAction.UseAbility(repairAbility, repairData)) + } + } + } + } + } + } + } catch (ex: Exception) { + logError(ex) + doActions.send(PlayerAction.SendChatMessage(ex.stackTraceToString())) + delay(2000L) + doActions.send(PlayerAction.Disconnect) + } +} + +fun deploy(gameState: GameState, mySide: GlobalSide, instincts: Instincts): Map, Position> { + val size = gameState.battleInfo.size + val totalPoints = size.numPoints + val maxWC = size.maxWeightClass + + val myStart = gameState.start.playerStart(mySide) + + val deployable = myStart.deployableFleet.values.filter { it.shipType.weightClass.tier <= maxWC.tier }.toMutableSet() + val deployed = mutableSetOf() + + while (true) { + val deployShip = deployable.filter { ship -> + deployed.sumOf { it.pointCost } + ship.pointCost <= totalPoints + }.associateWith { ship -> + instincts[ship.shipType.weightClass.focus] + }.weightedRandomOrNull() ?: break + + deployable -= deployShip + deployed += deployShip + } + + return placeShips(deployed, myStart.deployZone) +} + +fun navigate(gameState: GameState, ship: ShipInstance, instincts: Instincts, brain: Brain): PlayerAction.UseAbility { + val noEnemyShipsSeen = gameState.ships.values.none { it.owner != ship.owner && it.isIdentified } + + if (noEnemyShipsSeen || !ship.isIdentified) return engage(gameState, ship) + + val currPos = ship.position.location + val currAngle = ship.position.facing + + val movement = ship.movement + + if (movement is FelinaeShipMovement && Random.nextDouble() < 1.0 / (ship.usedInertialessDriveShots * 3 + 5)) { + val maxJump = movement.inertialessDriveRange * 0.99 + + val positions = listOf( + normalVector(currAngle), + normalVector(currAngle).let { (x, y) -> Vec2(-y, x) }, + -normalVector(currAngle), + normalVector(currAngle).let { (x, y) -> Vec2(y, -x) }, + ).flatMap { + listOf( + ShipPosition(currPos + (Distance(it) * maxJump), it.angle), + ShipPosition(currPos + (Distance(it) * (maxJump * 2 / 3)), it.angle), + ShipPosition(currPos + (Distance(it) * (maxJump / 3)), it.angle), + ) + }.filter { shipPos -> + (gameState.ships - ship.id).none { (_, otherShip) -> + (otherShip.position.location - shipPos.location).length <= SHIP_BASE_SIZE + } + } + + val position = positions.associateWith { + it.score(gameState, ship, instincts, brain) + }.weightedRandomOrNull() ?: return pursue(gameState, ship) + + return PlayerAction.UseAbility( + PlayerAbilityType.UseInertialessDrive(ship.id), + PlayerAbilityData.UseInertialessDrive(position.location) + ) + } + + val maxTurn = movement.turnAngle * 0.99 + val maxMove = movement.moveSpeed * 0.99 + val minMove = movement.moveSpeed * 0.51 + + val positions = (listOf( + normalDistance(currAngle) rotatedBy -maxTurn, + normalDistance(currAngle) rotatedBy (-maxTurn / 2), + normalDistance(currAngle), + normalDistance(currAngle) rotatedBy (maxTurn / 2), + normalDistance(currAngle) rotatedBy maxTurn, + ).flatMap { + listOf( + ShipPosition(currPos + (it * maxMove), it.angle), + ShipPosition(currPos + (it * minMove), it.angle), + ) + } + listOf(ship.position)).filter { shipPos -> + (gameState.ships - ship.id).none { (_, otherShip) -> + (otherShip.position.location - shipPos.location).length <= SHIP_BASE_SIZE + } + } + + val position = positions.associateWith { + it.score(gameState, ship, instincts, brain) + }.weightedRandomOrNull() ?: return pursue(gameState, ship) + + return PlayerAction.UseAbility( + PlayerAbilityType.MoveShip(ship.id), + PlayerAbilityData.MoveShip(position) + ) +} + +fun engage(gameState: GameState, ship: ShipInstance): PlayerAction.UseAbility { + val mySideMeanPosition = gameState.ships.values + .filter { it.owner == ship.owner } + .map { it.position.location.vector } + .mean() + + val enemySideMeanPosition = gameState.ships.values + .filter { it.owner != ship.owner } + .map { it.position.location.vector } + .mean() + + val angleTo = normalVector(ship.position.facing) angleTo (enemySideMeanPosition - mySideMeanPosition) + val maxTurn = ship.movement.turnAngle * 0.99 + val turnNormal = normalDistance(ship.position.facing) rotatedBy angleTo.coerceIn(-maxTurn..maxTurn) + + val move = (ship.movement.moveSpeed * 0.99) * turnNormal + val newLoc = ship.position.location + move + + val position = ShipPosition(newLoc, move.angle) + + return PlayerAction.UseAbility( + PlayerAbilityType.MoveShip(ship.id), + PlayerAbilityData.MoveShip(position) + ) +} + +fun pursue(gameState: GameState, ship: ShipInstance): PlayerAction.UseAbility { + val targetLocation = gameState.ships.values.filter { it.owner != ship.owner }.map { it.position.location }.minByOrNull { loc -> + (loc - ship.position.location).length + } ?: return PlayerAction.UseAbility( + PlayerAbilityType.MoveShip(ship.id), + PlayerAbilityData.MoveShip(ship.position) + ) + + return ship.navigateTo(targetLocation) +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_brainitude.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_brainitude.kt new file mode 100644 index 0000000..cd1339c --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_brainitude.kt @@ -0,0 +1,60 @@ +package net.starshipfights.game.ai + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import net.starshipfights.data.Id +import net.starshipfights.game.ShipInstance +import net.starshipfights.game.jsonSerializer +import kotlin.jvm.JvmInline +import kotlin.properties.ReadOnlyProperty + +@JvmInline +@Serializable +value class Instincts private constructor(private val numbers: MutableMap) { + constructor() : this(mutableMapOf()) + + operator fun get(instinct: Instinct) = numbers.getOrPut(instinct.key) { instinct.randRange.random() } + + companion object { + fun fromValues(values: Map) = Instincts(values.toMutableMap()) + } +} + +data class Instinct(val key: String, val randRange: ClosedFloatingPointRange) + +fun instinct(randRange: ClosedFloatingPointRange) = ReadOnlyProperty { _, property -> + Instinct(property.name, randRange) +} + +@JvmInline +@Serializable +value class Brain private constructor(private val data: MutableMap) { + constructor() : this(mutableMapOf()) + + operator fun get(neuron: Neuron) = jsonSerializer.decodeFromJsonElement( + neuron.codec, + data.getOrPut(neuron.key) { + jsonSerializer.encodeToJsonElement( + neuron.codec, + neuron.default() + ) + } + ) + + operator fun set(neuron: Neuron, value: T) = data.set( + neuron.key, + jsonSerializer.encodeToJsonElement( + neuron.codec, + value + ) + ) +} + +data class Neuron(val key: String, val codec: KSerializer, val default: () -> T) + +fun neuron(codec: KSerializer, default: () -> T) = ReadOnlyProperty> { _, property -> + Neuron(property.name, codec, default) +} + +infix fun Neuron.forShip(ship: Id) = copy(key = "$key[$ship]") diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_coroutine.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_coroutine.kt new file mode 100644 index 0000000..3644f13 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_coroutine.kt @@ -0,0 +1,64 @@ +package net.starshipfights.game.ai + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import net.starshipfights.game.GameEvent +import net.starshipfights.game.GameState +import net.starshipfights.game.GlobalSide +import net.starshipfights.game.PlayerAction + +data class AISession( + val mySide: GlobalSide, + val actions: SendChannel, + val events: ReceiveChannel, + val instincts: Instincts = Instincts(), +) + +suspend fun aiPlayer(session: AISession, initialState: GameState) = coroutineScope { + val gameDone = Job() + + val errors = Channel() + val gameStateFlow = MutableStateFlow(initialState) + val aiPlayer = AIPlayer( + gameStateFlow, + session.actions, + errors, + gameDone + ) + + val behavingJob = launch { + aiPlayer.behave(session.instincts, session.mySide) + } + + val handlingJob = launch { + for (event in session.events) { + when (event) { + is GameEvent.GameEnd -> gameDone.complete() + is GameEvent.InvalidAction -> launch { errors.send(event.message) } + is GameEvent.StateChange -> gameStateFlow.value = event.newState + } + } + } + + gameDone.join() + + try { + behavingJob.join() + } catch (_: CancellationException) { + // ignore it + } + + try { + handlingJob.join() + } catch (_: CancellationException) { + // ignore it again + } + + session.actions.close() +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization.kt new file mode 100644 index 0000000..8b755b2 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization.kt @@ -0,0 +1,323 @@ +package net.starshipfights.game.ai + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.starshipfights.data.Id +import net.starshipfights.game.* +import kotlin.math.PI +import kotlin.random.Random + +val allInstincts = listOf( + combatTargetShipWeight, + combatAvengeShipwrecks, + combatAvengeShipWeight, + combatPrioritization, + combatAvengeAttacks, + combatForgiveTarget, + combatPreyOnTheWeak, + combatFrustratedByFailedAttacks, + + deployEscortFocus, + deployCruiserFocus, + deployBattleshipFocus, + + navAggression, + navPassivity, + navLustForBlood, + navSqueamishness, + navTunnelVision, + navOptimality, +) + +fun genInstinctCandidates(count: Int): Set { + return Random.nextOrthonormalBasis(allInstincts.size).take(count).map { vector -> + Instincts.fromValues((allInstincts zip vector.values).associate { (key, value) -> + key.key to key.denormalize(value) + }) + }.toSet() +} + +class TestSession(gameState: GameState) { + private val stateMutable = MutableStateFlow(gameState) + private val stateMutex = Mutex() + + val state = stateMutable.asStateFlow() + + private val hostErrorMessages = Channel(Channel.UNLIMITED) + private val guestErrorMessages = Channel(Channel.UNLIMITED) + + private fun errorMessageChannel(player: GlobalSide) = when (player) { + GlobalSide.HOST -> hostErrorMessages + GlobalSide.GUEST -> guestErrorMessages + } + + fun errorMessages(player: GlobalSide): ReceiveChannel = when (player) { + GlobalSide.HOST -> hostErrorMessages + GlobalSide.GUEST -> guestErrorMessages + } + + private val gameEndMutable = CompletableDeferred() + val gameEnd: Deferred + get() = gameEndMutable + + suspend fun onPacket(player: GlobalSide, packet: PlayerAction) { + stateMutex.withLock { + when (val result = state.value.after(player, packet)) { + is GameEvent.StateChange -> { + stateMutable.value = result.newState + result.newState.checkVictory()?.let { gameEndMutable.complete(it) } + } + is GameEvent.InvalidAction -> { + errorMessageChannel(player).send(result.message) + } + is GameEvent.GameEnd -> { + gameEndMutable.complete(result) + } + } + } + } +} + +suspend fun performTestSession(gameState: GameState, hostInstincts: Instincts, guestInstincts: Instincts): GlobalSide? { + val testSession = TestSession(gameState) + + val hostActions = Channel() + val hostEvents = Channel() + val hostSession = AISession(GlobalSide.HOST, hostActions, hostEvents, hostInstincts) + + val guestActions = Channel() + val guestEvents = Channel() + val guestSession = AISession(GlobalSide.GUEST, guestActions, guestEvents, guestInstincts) + + return coroutineScope { + val hostHandlingJob = launch { + launch { + listOf( + // Game state changes + launch { + testSession.state.collect { state -> + hostEvents.send(GameEvent.StateChange(state)) + } + }, + // Invalid action messages + launch { + for (errorMessage in testSession.errorMessages(GlobalSide.HOST)) { + hostEvents.send(GameEvent.InvalidAction(errorMessage)) + } + } + ).joinAll() + } + + launch { + for (action in hostActions) + testSession.onPacket(GlobalSide.HOST, action) + } + + aiPlayer(hostSession, testSession.state.value) + } + + val guestHandlingJob = launch { + launch { + listOf( + // Game state changes + launch { + testSession.state.collect { state -> + guestEvents.send(GameEvent.StateChange(state)) + } + }, + // Invalid action messages + launch { + for (errorMessage in testSession.errorMessages(GlobalSide.GUEST)) { + guestEvents.send(GameEvent.InvalidAction(errorMessage)) + } + } + ).joinAll() + } + + launch { + for (action in guestActions) + testSession.onPacket(GlobalSide.GUEST, action) + } + + aiPlayer(guestSession, testSession.state.value) + } + + val gameEnd = testSession.gameEnd.await() + + hostHandlingJob.cancel() + guestHandlingJob.cancel() + + gameEnd.winner + } +} + +val BattleSize.minRank: AdmiralRank + get() = AdmiralRank.values().first { + it.maxShipWeightClass.tier >= maxWeightClass.tier + } + +fun generateFleet(faction: Faction, rank: AdmiralRank, side: GlobalSide): Map, Ship> = ShipWeightClass.values() + .flatMap { swc -> + val shipTypes = ShipType.values().filter { st -> + st.weightClass == swc && st.faction == faction + }.shuffled() + + if (shipTypes.isEmpty()) + emptyList() + else + (0 until ((rank.maxShipWeightClass.tier - swc.tier + 1) * 2).coerceAtLeast(0)).map { i -> + shipTypes[i % shipTypes.size] + } + } + .let { shipTypes -> + var shipCount = 0 + shipTypes.map { st -> + val name = "${side}_${++shipCount}" + Ship( + id = Id(name), + name = name, + shipType = st, + ) + }.associateBy { it.id } + } + +fun generateOptimizationInitialState(hostFaction: Faction, guestFaction: Faction, battleInfo: BattleInfo): GameState { + val battleWidth = (25..35).random() * 500.0 + val battleLength = (15..45).random() * 500.0 + + val deployWidth2 = battleWidth / 2 + val deployLength2 = 875.0 + + val hostDeployCenter = Position(Vec2(0.0, (-battleLength / 2) + deployLength2)) + val guestDeployCenter = Position(Vec2(0.0, (battleLength / 2) - deployLength2)) + + val rank = battleInfo.size.minRank + + return GameState( + start = GameStart( + battleWidth, battleLength, + + PlayerStart( + hostDeployCenter, + PI / 2, + PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2), + PI / 2, + generateFleet(hostFaction, rank, GlobalSide.HOST) + ), + + PlayerStart( + guestDeployCenter, + -PI / 2, + PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2), + -PI / 2, + generateFleet(guestFaction, rank, GlobalSide.GUEST) + ) + ), + hostInfo = InGameAdmiral( + id = Id(GlobalSide.HOST.name), + user = InGameUser( + id = Id(GlobalSide.HOST.name), + username = GlobalSide.HOST.name + ), + name = GlobalSide.HOST.name, + isFemale = false, + faction = hostFaction, + rank = rank + ), + guestInfo = InGameAdmiral( + id = Id(GlobalSide.GUEST.name), + user = InGameUser( + id = Id(GlobalSide.GUEST.name), + username = GlobalSide.GUEST.name + ), + name = GlobalSide.GUEST.name, + isFemale = false, + faction = guestFaction, + rank = rank + ), + battleInfo = battleInfo, + subplots = emptySet(), + ) +} + +data class InstinctGamePairing( + val host: Instincts, + val guest: Instincts +) + +suspend fun performTrials(numTrialsPerPairing: Int, instincts: Set, validBattleSizes: Set = BattleSize.values().toSet(), validFactions: Set = Faction.values().toSet(), cancellationJob: Job = Job(), onProgress: suspend () -> Unit = {}): Map { + return coroutineScope { + instincts.associateWith { host -> + async { + instincts.map { guest -> + async { + (1..numTrialsPerPairing).map { + async { + val battleSize = validBattleSizes.random() + + val hostFaction = validFactions.random() + val guestFaction = validFactions.random() + + val gameState = generateOptimizationInitialState(hostFaction, guestFaction, BattleInfo(battleSize, BattleBackground.BLUE_BROWN)) + val winner = withTimeoutOrNull((20_000L * numTrialsPerPairing) + (400L * numTrialsPerPairing * numTrialsPerPairing)) { + val deferred = async(cancellationJob) { + performTestSession(gameState, host, guest) + } + + select { + cancellationJob.onJoin { null } + deferred.onAwait { it } + } + } + + logInfo("A trial has ended! Winner: ${winner ?: "NEITHER"}") + onProgress() + + when (winner) { + GlobalSide.HOST -> 1 + GlobalSide.GUEST -> -1 + else -> 0 + } + } + }.map { guest to it.await() } + } + }.flatMap { it.await() }.toMap() + } + }.mapValues { (_, it) -> it.await() }.flatten().mapKeys { (k, _) -> + InstinctGamePairing(k.first, k.second) + } + } +} + +data class InstinctVictoryPairing( + val winner: Instincts, + val loser: Instincts +) + +fun Map.victoriesFor(instincts: Instincts) = filterKeys { (host, _) -> host == instincts }.values.sum() - filterKeys { (_, guest) -> guest == instincts }.values.sum() + +fun Map.toVictoryMap() = keys.associate { (host, _) -> host to victoriesFor(host) } + +fun Map.toVictoryPairingMap() = keys.associate { (host, guest) -> + InstinctVictoryPairing(host, guest) to ((get(InstinctGamePairing(host, guest)) ?: 0) - (get(InstinctGamePairing(guest, host)) ?: 0)) +} + +fun Map.successHistograms(numSegments: Int) = allInstincts.associateWith { instinct -> + val ranges = (0..numSegments).map { it.toDouble() / numSegments }.windowed(2) { (begin, end) -> + val rBegin = instinct.randRange.start + (begin * instinct.randRange.size) + val rEnd = instinct.randRange.start + (end * instinct.randRange.size) + rBegin..rEnd + } + + val perRange = ranges.associateWith { range -> + filterKeys { it[instinct] in range }.values.sum() + } + + perRange +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization_util.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization_util.kt new file mode 100644 index 0000000..da20d90 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_optimization_util.kt @@ -0,0 +1,95 @@ +package net.starshipfights.game.ai + +import net.starshipfights.game.EPSILON +import kotlin.jvm.JvmInline +import kotlin.math.abs +import kotlin.math.sqrt +import kotlin.random.Random + +@JvmInline +value class VecN(val values: List) + +val VecN.dimension: Int + get() = values.size + +// close enough +fun Random.nextGaussian() = (1..12).sumOf { nextDouble() } - 6 + +fun Random.nextUnitVector(size: Int): VecN { + if (size <= 0) + throw IllegalArgumentException("Cannot have vector of zero or negative dimension!") + + if (size == 1) + return VecN(listOf(if (nextBoolean()) 1.0 else -1.0)) + + val vector = VecN((1..size).map { nextGaussian() }) + + if (vector.isNullVector) // try again + return nextUnitVector(size) + + return vector.normalize() +} + +fun Random.nextOrthonormalBasis(size: Int): List { + if (size <= 0) + throw IllegalArgumentException("Cannot have orthonormal basis of zero or negative dimension!") + + if (size == 1) + return listOf(VecN(listOf(if (nextBoolean()) 1.0 else -1.0))) + + val orthogonalBasis = mutableListOf() + while (orthogonalBasis.size < size) { + val vector = nextUnitVector(size) + var orthogonal = vector + for (prevVector in orthogonalBasis) + orthogonal -= (vector project prevVector) + + if (!orthogonal.isNullVector) + orthogonalBasis.add(orthogonal) + } + + orthogonalBasis.shuffle(this) + return orthogonalBasis.map { it.normalize() } +} + +val VecN.isNullVector: Boolean + get() { + return values.all { abs(it) < EPSILON } + } + +fun VecN.normalize(): VecN { + if (isNullVector) + throw IllegalArgumentException("Cannot normalize the zero vector!") + + val magnitude = sqrt(this dot this) + + return this / magnitude +} + +infix fun VecN.dot(other: VecN): Double { + if (dimension != other.dimension) + throw IllegalArgumentException("Cannot take inner product of vectors of unequal dimensions!") + + return (this.values zip other.values).sumOf { (a, b) -> a * b } +} + +infix fun VecN.project(onto: VecN): VecN { + return this * ((this dot onto) / (this dot this)) +} + +operator fun VecN.plus(other: VecN): VecN { + if (dimension != other.dimension) + throw IllegalArgumentException("Cannot take sum of vectors of unequal dimensions!") + + return VecN((this.values zip other.values).map { (a, b) -> a + b }) +} + +operator fun VecN.minus(other: VecN) = this + (other * -1.0) + +operator fun VecN.times(scale: Double): VecN = VecN(values.map { it * scale }) +operator fun VecN.div(scale: Double): VecN = VecN(values.map { it / scale }) + +fun Instinct.denormalize(normalValue: Double): Double { + val zeroToOne = (normalValue + 1) / 2 + return (zeroToOne * (randRange.endInclusive - randRange.start)) + randRange.start +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util.kt new file mode 100644 index 0000000..a953554 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util.kt @@ -0,0 +1,5 @@ +package net.starshipfights.game.ai + +import kotlinx.serialization.builtins.serializer + +val shipAttackPriority by neuron(Double.serializer()) { 1.0 } diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util_combat.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util_combat.kt new file mode 100644 index 0000000..4055603 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util_combat.kt @@ -0,0 +1,33 @@ +package net.starshipfights.game.ai + +import net.starshipfights.game.* + +val combatTargetShipWeight by instinct(0.5..2.5) + +val combatAvengeShipwrecks by instinct(0.5..4.5) +val combatAvengeShipWeight by instinct(-0.5..1.5) + +val combatPrioritization by instinct(-1.5..2.5) + +val combatAvengeAttacks by instinct(0.5..4.5) +val combatForgiveTarget by instinct(-1.5..2.5) +val combatPreyOnTheWeak by instinct(-1.5..2.5) + +val combatFrustratedByFailedAttacks by instinct(-2.5..5.5) + +fun ShipInstance.calculateSuffering(): Double { + return (durability.maxHullPoints - hullAmount) + (if (ship.reactor is FelinaeShipReactor) + 0 + else powerMode.shields - shieldAmount) + (numFires * 0.5) + modulesStatus.statuses.values.sumOf { status -> + when (status) { + ShipModuleStatus.INTACT -> 0.0 + ShipModuleStatus.DAMAGED -> 0.75 + ShipModuleStatus.DESTROYED -> 1.5 + ShipModuleStatus.ABSENT -> 0.0 + } + } +} + +fun ShipInstance.expectedBoardingSuccess(against: ShipInstance): Double { + return smoothNegative((assaultModifier - against.defenseModifier).toDouble()) +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util_deploy.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util_deploy.kt new file mode 100644 index 0000000..097127a --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util_deploy.kt @@ -0,0 +1,87 @@ +package net.starshipfights.game.ai + +import net.starshipfights.data.Id +import net.starshipfights.game.* +import kotlin.math.sign + +val deployEscortFocus by instinct(1.0..5.0) +val deployCruiserFocus by instinct(1.0..5.0) +val deployBattleshipFocus by instinct(1.0..5.0) + +val ShipWeightClass.focus: Instinct + get() = when (this) { + ShipWeightClass.ESCORT -> deployEscortFocus + ShipWeightClass.DESTROYER -> deployCruiserFocus + ShipWeightClass.CRUISER -> deployCruiserFocus + ShipWeightClass.BATTLECRUISER -> deployCruiserFocus + ShipWeightClass.BATTLESHIP -> deployBattleshipFocus + + ShipWeightClass.BATTLE_BARGE -> deployBattleshipFocus + + ShipWeightClass.GRAND_CRUISER -> deployBattleshipFocus + ShipWeightClass.COLOSSUS -> deployBattleshipFocus + + ShipWeightClass.FF_ESCORT -> deployEscortFocus + ShipWeightClass.FF_DESTROYER -> deployCruiserFocus + ShipWeightClass.FF_CRUISER -> deployCruiserFocus + ShipWeightClass.FF_BATTLECRUISER -> deployCruiserFocus + ShipWeightClass.FF_BATTLESHIP -> deployBattleshipFocus + + ShipWeightClass.AUXILIARY_SHIP -> deployEscortFocus + ShipWeightClass.LIGHT_CRUISER -> deployEscortFocus + ShipWeightClass.MEDIUM_CRUISER -> deployCruiserFocus + ShipWeightClass.HEAVY_CRUISER -> deployBattleshipFocus + + ShipWeightClass.FRIGATE -> deployEscortFocus + ShipWeightClass.LINE_SHIP -> deployCruiserFocus + ShipWeightClass.DREADNOUGHT -> deployBattleshipFocus + } + +private val ShipWeightClass.rowIndex: Int + get() = when (this) { + ShipWeightClass.ESCORT -> 3 + ShipWeightClass.DESTROYER -> 2 + ShipWeightClass.CRUISER -> 2 + ShipWeightClass.BATTLECRUISER -> 1 + ShipWeightClass.BATTLESHIP -> 0 + + ShipWeightClass.BATTLE_BARGE -> 0 + + ShipWeightClass.GRAND_CRUISER -> 1 + ShipWeightClass.COLOSSUS -> 0 + + ShipWeightClass.FF_ESCORT -> 3 + ShipWeightClass.FF_DESTROYER -> 2 + ShipWeightClass.FF_CRUISER -> 1 + ShipWeightClass.FF_BATTLECRUISER -> 1 + ShipWeightClass.FF_BATTLESHIP -> 0 + + ShipWeightClass.AUXILIARY_SHIP -> 3 + ShipWeightClass.LIGHT_CRUISER -> 2 + ShipWeightClass.MEDIUM_CRUISER -> 1 + ShipWeightClass.HEAVY_CRUISER -> 0 + + ShipWeightClass.FRIGATE -> 2 + ShipWeightClass.LINE_SHIP -> 1 + ShipWeightClass.DREADNOUGHT -> 0 + } + +fun placeShips(ships: Set, deployRectangle: PickBoundary.Rectangle): Map, Position> { + val fieldBoundSign = deployRectangle.center.vector.y.sign + val fieldBoundary = deployRectangle.center.vector.y + (deployRectangle.length2 * fieldBoundSign) + val rows = listOf(125.0, 625.0, 1125.0, 1625.0).map { + fieldBoundary - (it * fieldBoundSign) + } + + val shipsByRow = ships.groupBy { it.shipType.weightClass.rowIndex } + return buildMap { + for ((rowIndex, rowShips) in shipsByRow) { + val row = rows[rowIndex] + val rowMax = rowShips.size - 1 + for ((shipIndex, ship) in rowShips.withIndex()) { + val col = (shipIndex * 500.0) - (rowMax * 250.0) + put(ship.id.reinterpret(), Position(Vec2(col, row))) + } + } + } +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util_nav.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util_nav.kt new file mode 100644 index 0000000..524b4d3 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/ai_util_nav.kt @@ -0,0 +1,120 @@ +package net.starshipfights.game.ai + +import net.starshipfights.data.Id +import net.starshipfights.game.* +import kotlin.math.expm1 +import kotlin.math.pow + +val navAggression by instinct(0.5..1.5) +val navPassivity by instinct(0.5..1.5) +val navLustForBlood by instinct(-0.5..0.5) +val navSqueamishness by instinct(0.25..1.25) +val navTunnelVision by instinct(-0.25..1.25) +val navOptimality by instinct(1.25..2.75) + +fun ShipPosition.score(gameState: GameState, shipInstance: ShipInstance, instincts: Instincts, brain: Brain): Double { + val ship = shipInstance.copy(position = this) + + val canAttack = ship.canAttackWithDamage(gameState) + val canBeAttackedBy = ship.attackableWithDamageBy(gameState) + + val opportunityScore = canAttack.map { (targetId, potentialDamage) -> + smoothNegative(brain[shipAttackPriority forShip targetId]).signedPow(instincts[navTunnelVision]) * potentialDamage + }.sum() + (ship.calculateSuffering() * instincts[navLustForBlood]) + + val vulnerabilityScore = canBeAttackedBy.map { (targetId, potentialDamage) -> + smoothNegative(brain[shipAttackPriority forShip targetId]).signedPow(instincts[navTunnelVision]) * potentialDamage + }.sum() * -expm1(-ship.calculateSuffering() * instincts[navSqueamishness]) + + return instincts[navOptimality].pow(opportunityScore.signedPow(instincts[navAggression]) - vulnerabilityScore.signedPow(instincts[navPassivity])) +} + +fun ShipInstance.canAttackWithDamage(gameState: GameState): Map, Double> { + return attackableTargets(gameState).mapValues { (targetId, weapons) -> + val target = gameState.ships[targetId] ?: return@mapValues 0.0 + + weapons.sumOf { weaponId -> + weaponId.expectedAdvantageFromWeaponUsage(gameState, this, target) + } + } +} + +fun ShipInstance.attackableTargets(gameState: GameState): Map, Set>> { + return armaments.keys.associateWith { weaponId -> + weaponId.validTargets(gameState, this).map { it.id }.toSet() + }.transpose() +} + +fun ShipInstance.attackableWithDamageBy(gameState: GameState): Map, Double> { + return gameState.getValidAttackersWith(this).mapValues { (attackerId, weapons) -> + val attacker = gameState.ships[attackerId] ?: return@mapValues 0.0 + + weapons.sumOf { weaponId -> + weaponId.expectedAdvantageFromWeaponUsage(gameState, attacker, this) + } + } +} + +fun Id.validTargets(gameState: GameState, ship: ShipInstance): List { + if (!ship.canUseWeapon(this)) return emptyList() + val weaponInstance = ship.armaments[this] ?: return emptyList() + + return gameState.getValidTargets(ship, weaponInstance) +} + +fun Id.expectedAdvantageFromWeaponUsage(gameState: GameState, ship: ShipInstance, target: ShipInstance): Double { + if (!ship.canUseWeapon(this)) return 0.0 + val weaponInstance = ship.armaments[this] ?: return 0.0 + val mustBeSameSide = weaponInstance is ShipWeaponInstance.Hangar && weaponInstance.weapon.wing == StrikeCraftWing.FIGHTERS + if ((ship.owner == target.owner) != mustBeSameSide) return 0.0 + + return when (weaponInstance) { + is ShipWeaponInstance.Cannon -> cannonChanceToHit(ship, target) * weaponInstance.weapon.numShots + is ShipWeaponInstance.Lance -> weaponInstance.charge * weaponInstance.weapon.numShots + is ShipWeaponInstance.Torpedo -> if (target.shieldAmount > 0) 0.5 else 2.0 + is ShipWeaponInstance.Hangar -> when (weaponInstance.weapon.wing) { + StrikeCraftWing.BOMBERS -> { + val calculatedPrevBombing = target.calculateBombing(gameState.ships) ?: 0.0 + val calculatedNextBombing = target.calculateBombing(gameState.ships, extraBombers = weaponInstance.wingHealth) ?: 0.0 + + calculateShipDamageChanceFromBombing(calculatedNextBombing) - calculateShipDamageChanceFromBombing(calculatedPrevBombing) + } + StrikeCraftWing.FIGHTERS -> { + val calculatedPrevBombing = target.calculateBombing(gameState.ships) ?: 0.0 + val calculatedNextBombing = target.calculateBombing(gameState.ships, extraFighters = weaponInstance.wingHealth) ?: 0.0 + + calculateShipDamageChanceFromBombing(calculatedPrevBombing) - calculateShipDamageChanceFromBombing(calculatedNextBombing) + } + } + is ShipWeaponInstance.ParticleClawLauncher -> (cannonChanceToHit(ship, target) + 1) * weaponInstance.weapon.numShots + is ShipWeaponInstance.LightningYarn -> weaponInstance.weapon.numShots.toDouble() + is ShipWeaponInstance.MegaCannon -> 5.0 + is ShipWeaponInstance.RevelationGun -> (target.shieldAmount + target.hullAmount).toDouble() + is ShipWeaponInstance.EmpAntenna -> target.shieldAmount * 0.5 + } +} + +private fun calculateShipDamageChanceFromBombing(calculatedBombing: Double): Double { + val maxBomberWingOutput = smoothNegative(calculatedBombing) + val maxFighterWingOutput = smoothNegative(-calculatedBombing) + + return smoothNegative(maxBomberWingOutput - maxFighterWingOutput) +} + +fun ShipInstance.navigateTo(targetLocation: Position): PlayerAction.UseAbility { + val myLocation = position.location + + val angleTo = normalDistance(position.facing) angleTo (targetLocation - myLocation) + val maxTurn = movement.turnAngle * 0.99 + val turnNormal = normalDistance(position.facing) rotatedBy angleTo.coerceIn(-maxTurn..maxTurn) + + val move = (movement.moveSpeed * if (turnNormal angleBetween (targetLocation - myLocation) < EPSILON) 0.99 else 0.51) * turnNormal + val newLoc = position.location + move + + val position = ShipPosition(newLoc, move.angle) + + return PlayerAction.UseAbility( + PlayerAbilityType.MoveShip(id), + PlayerAbilityData.MoveShip(position) + ) +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ai/util.kt b/src/commonMain/kotlin/net/starshipfights/game/ai/util.kt new file mode 100644 index 0000000..6d01dd4 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ai/util.kt @@ -0,0 +1,66 @@ +package net.starshipfights.game.ai + +import net.starshipfights.game.EPSILON +import net.starshipfights.game.Vec2 +import net.starshipfights.game.div +import kotlin.math.absoluteValue +import kotlin.math.nextUp +import kotlin.math.pow +import kotlin.math.sign +import kotlin.random.Random + +expect fun logDebug(message: Any?) +expect fun logInfo(message: Any?) +expect fun logWarning(message: Any?) +expect fun logError(message: Any?) + +fun ClosedFloatingPointRange.random(random: Random = Random) = random.nextDouble(start, endInclusive.nextUp()) + +val ClosedFloatingPointRange.size: Double + get() = endInclusive.nextUp() - start + +fun Map.weightedRandom(random: Random = Random): T { + return weightedRandomOrNull(random) ?: error("Cannot take weighted random of effectively-empty collection!") +} + +fun Map.weightedRandomOrNull(random: Random = Random): T? { + if (values.none { it >= EPSILON }) return null + + val total = values.sum() + + var hasChoice = false + var choice = random.nextDouble(total) + for ((result, chance) in this) { + if (chance < EPSILON) continue + if (chance >= choice) + return result + choice -= chance + hasChoice = true + } + + return if (hasChoice) + keys.last() + else null +} + +fun Map>.flatten(): Map, V> = + toList().flatMap { (k, v) -> + v.map { (l, w) -> + (k to l) to w + } + }.toMap() + +fun Map>.transpose(): Map> = + flatMap { (k, v) -> v.map { it to k } } + .groupBy(Pair::first, Pair::second) + .mapValues { (_, it) -> it.toSet() } + +fun Iterable.mean(): Vec2 { + if (none()) return Vec2(0.0, 0.0) + + val mx = sumOf { it.x } + val my = sumOf { it.y } + return Vec2(mx, my) / count().toDouble() +} + +fun Double.signedPow(x: Double) = if (absoluteValue < EPSILON) 0.0 else sign * absoluteValue.pow(x) diff --git a/src/commonMain/kotlin/net/starshipfights/game/client_mode.kt b/src/commonMain/kotlin/net/starshipfights/game/client_mode.kt new file mode 100644 index 0000000..246ab83 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/client_mode.kt @@ -0,0 +1,18 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable + +@Serializable +sealed class ClientMode { + @Serializable + data class MatchmakingMenu(val admirals: List) : ClientMode() + + @Serializable + data class InTrainingGame(val initialState: GameState) : ClientMode() + + @Serializable + data class InGame(val playerSide: GlobalSide, val connectToken: String, val initialState: GameState) : ClientMode() + + @Serializable + data class Error(val message: String) : ClientMode() +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_ability.kt b/src/commonMain/kotlin/net/starshipfights/game/game_ability.kt new file mode 100644 index 0000000..6b7fb1b --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_ability.kt @@ -0,0 +1,949 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id +import kotlin.math.abs +import kotlin.random.Random + +sealed interface ShipAbility { + val ship: Id +} + +sealed interface CombatAbility { + val ship: Id + val weapon: Id +} + +@Serializable +sealed class PlayerAbilityType { + abstract suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? + abstract fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent + + @Serializable + data class DonePhase(val phase: GamePhase) : PlayerAbilityType() { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase != phase) return null + return if (gameState.canFinishPhase(playerSide)) + PlayerAbilityData.DonePhase + else null + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + return if (phase == gameState.phase) { + if (gameState.canFinishPhase(playerSide)) + GameEvent.StateChange(gameState.afterPlayerReady(playerSide)) + else GameEvent.InvalidAction("You cannot complete the current phase yet") + } else GameEvent.InvalidAction("Cannot complete non-current phase") + } + } + + @Serializable + data class DeployShip(val ship: Id) : PlayerAbilityType() { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase != GamePhase.Deploy) return null + if (gameState.doneWithPhase == playerSide) return null + val pickBoundary = gameState.start.playerStart(playerSide).deployZone + + val playerStart = gameState.start.playerStart(playerSide) + val shipData = playerStart.deployableFleet[ship] ?: return null + val pickType = PickType.Location(gameState.ships.keys, PickHelper.Ship(shipData.shipType, playerStart.deployFacing)) + + val pickResponse = pick(PickRequest(pickType, pickBoundary)) + val shipPosition = (pickResponse as? PickResponse.Location)?.position ?: return null + return PlayerAbilityData.DeployShip(shipPosition) + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (data !is PlayerAbilityData.DeployShip) return GameEvent.InvalidAction("Internal error from using player ability") + val playerStart = gameState.start.playerStart(playerSide) + val shipData = playerStart.deployableFleet[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + + val position = data.position + + val pickRequest = PickRequest( + PickType.Location(gameState.ships.keys, PickHelper.Ship(shipData.shipType, playerStart.deployFacing)), + gameState.start.playerStart(playerSide).deployZone + ) + val pickResponse = PickResponse.Location(position) + + if (!gameState.isValidPick(pickRequest, pickResponse)) return GameEvent.InvalidAction("That ship cannot be deployed there") + + val shipPosition = ShipPosition(position, playerStart.deployFacing) + val shipInstance = ShipInstance(shipData, playerSide, shipPosition) + + val newShipSet = gameState.ships + mapOf(shipInstance.id to shipInstance) + + if (newShipSet.values.filter { it.owner == playerSide }.sumOf { it.ship.pointCost } > gameState.battleInfo.size.numPoints) + return GameEvent.InvalidAction("Not enough points to deploy this ship") + + val deployableShips = playerStart.deployableFleet - ship + val newPlayerStart = playerStart.copy(deployableFleet = deployableShips) + + return GameEvent.StateChange( + with(gameState) { + copy( + start = when (playerSide) { + GlobalSide.HOST -> start.copy(hostStart = newPlayerStart) + GlobalSide.GUEST -> start.copy(guestStart = newPlayerStart) + }, + ships = newShipSet + ) + } + ) + } + } + + @Serializable + data class UndeployShip(val ship: Id) : PlayerAbilityType() { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + return if (gameState.phase == GamePhase.Deploy && gameState.doneWithPhase != playerSide) PlayerAbilityData.UndeployShip else null + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship is not deployed") + val shipData = shipInstance.ship + + val newShipSet = gameState.ships - ship + + val playerStart = gameState.start.playerStart(playerSide) + + val deployableShips = playerStart.deployableFleet + mapOf(shipData.id to shipData) + val newPlayerStart = playerStart.copy(deployableFleet = deployableShips) + + return GameEvent.StateChange( + with(gameState) { + copy( + start = when (playerSide) { + GlobalSide.HOST -> start.copy(hostStart = newPlayerStart) + GlobalSide.GUEST -> start.copy(guestStart = newPlayerStart) + }, + ships = newShipSet + ) + } + ) + } + } + + @Serializable + data class DistributePower(override val ship: Id) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Power) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.ship.reactor !is StandardShipReactor) return null + + val data = ClientAbilityData.newShipPowerModes.remove(ship) ?: return null + if (!shipInstance.validatePowerMode(data)) return null + + return PlayerAbilityData.DistributePower(data) + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (data !is PlayerAbilityData.DistributePower) return GameEvent.InvalidAction("Internal error from using player ability") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.ship.reactor !is StandardShipReactor) return GameEvent.InvalidAction("Invalid ship reactor type") + if (!shipInstance.validatePowerMode(data.powerMode)) return GameEvent.InvalidAction("Invalid power distribution") + + val prevShieldDamage = shipInstance.powerMode.shields - shipInstance.shieldAmount + + val newShipInstance = shipInstance.copy( + powerMode = data.powerMode, + isDoneCurrentPhase = true, + + weaponAmount = data.powerMode.weapons, + shieldAmount = if (shipInstance.canUseShields) + (data.powerMode.shields - prevShieldDamage).coerceAtLeast(0) + else 0, + ) + val newShips = gameState.ships + mapOf(ship to newShipInstance) + + return GameEvent.StateChange( + gameState.copy(ships = newShips) + ) + } + } + + @Serializable + data class ConfigurePower(override val ship: Id, val powerMode: FelinaeShipPowerMode) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Power) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.ship.reactor != FelinaeShipReactor) return null + + return PlayerAbilityData.ConfigurePower + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.ship.reactor != FelinaeShipReactor) return GameEvent.InvalidAction("Invalid ship reactor type") + + val newShipInstance = shipInstance.copy( + felinaeShipPowerMode = powerMode, + ) + val newShips = gameState.ships + mapOf(ship to newShipInstance) + + return GameEvent.StateChange( + gameState.copy(ships = newShips) + ) + } + } + + @Serializable + data class MoveShip(override val ship: Id) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Move) return null + if (!gameState.canShipMove(ship)) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.isDoneCurrentPhase) return null + + val anglePickReq = PickRequest( + PickType.Location(emptySet(), PickHelper.None, shipInstance.position.location), + PickBoundary.Angle(shipInstance.position.location, shipInstance.position.facing, shipInstance.movement.turnAngle) + ) + val anglePickRes = (pick(anglePickReq) as? PickResponse.Location) ?: return null + + val newFacingNormal = (anglePickRes.position - shipInstance.position.location).normal + val newFacing = newFacingNormal.angle + + val oldFacingNormal = normalDistance(shipInstance.position.facing) + val angleDiff = (oldFacingNormal angleBetween newFacingNormal) + val maxMoveSpeed = shipInstance.movement.moveSpeed + val minMoveSpeed = maxMoveSpeed * (angleDiff / shipInstance.movement.turnAngle) / 2 + + val moveOrigin = shipInstance.position.location + val moveFrom = moveOrigin + (newFacingNormal * minMoveSpeed) + val moveTo = moveOrigin + (newFacingNormal * maxMoveSpeed) + + val positionPickReq = PickRequest( + PickType.Location(gameState.ships.keys - ship, PickHelper.Ship(shipInstance.ship.shipType, newFacing), null), + PickBoundary.AlongLine(moveFrom, moveTo) + ) + val positionPickRes = (pick(positionPickReq) as? PickResponse.Location) ?: return null + + val newPosition = ShipPosition( + positionPickRes.position, + newFacing + ) + + return PlayerAbilityData.MoveShip(newPosition) + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (data !is PlayerAbilityData.MoveShip) return GameEvent.InvalidAction("Internal error from using player ability") + if (!gameState.canShipMove(ship)) return GameEvent.InvalidAction("You do not have the initiative") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.isDoneCurrentPhase) return GameEvent.InvalidAction("Ships cannot be moved twice") + + if ((gameState.ships - ship).any { (_, otherShip) -> (otherShip.position.location - data.newPosition.location).length <= SHIP_BASE_SIZE }) + return GameEvent.InvalidAction("You cannot move that ship there") + + val moveOrigin = shipInstance.position.location + val newFacingNormal = normalDistance(data.newPosition.facing) + val oldFacingNormal = normalDistance(shipInstance.position.facing) + val angleDiff = (oldFacingNormal angleBetween newFacingNormal) + + if (angleDiff - shipInstance.movement.turnAngle > EPSILON) return GameEvent.InvalidAction("Illegal move - turn angle is too big") + + val maxMoveSpeed = shipInstance.movement.moveSpeed + val minMoveSpeed = if (maxMoveSpeed < EPSILON) maxMoveSpeed else (maxMoveSpeed * (angleDiff / shipInstance.movement.turnAngle) / 2) + + val moveFrom = moveOrigin + (newFacingNormal * minMoveSpeed) + val moveTo = moveOrigin + (newFacingNormal * maxMoveSpeed) + + if (data.newPosition.location.distanceToLineSegment(moveFrom, moveTo) > EPSILON) return GameEvent.InvalidAction("Illegal move - must be on facing line") + + val newShipInstance = shipInstance.copy( + position = data.newPosition, + currentVelocity = (data.newPosition.location - shipInstance.position.location).length, + isDoneCurrentPhase = true + ) + + // Identify enemy ships + val identifiedEnemyShips = gameState.ships.filterValues { enemyShip -> + enemyShip.owner != playerSide && (enemyShip.position.location - newShipInstance.position.location).length <= SHIP_SENSOR_RANGE + } + + // Be identified by enemy ships + val shipsToBeIdentified = identifiedEnemyShips + if (!newShipInstance.isIdentified && identifiedEnemyShips.isNotEmpty()) + mapOf(ship to newShipInstance) + else emptyMap() + + val identifiedShips = shipsToBeIdentified + .filterValues { !it.isIdentified } + .mapValues { (_, shipInstance) -> shipInstance.copy(isIdentified = true) } + + // Ships that move off the battlefield are considered to disengage + val isDisengaged = newShipInstance.position.location.vector.let { (x, y) -> + val mx = gameState.start.battlefieldWidth / 2 + val my = gameState.start.battlefieldLength / 2 + abs(x) > mx || abs(y) > my + } + + val newChatEntries = gameState.chatBox + identifiedShips.map { (id, _) -> + ChatEntry.ShipIdentified(id, Moment.now) + } + (if (isDisengaged) + listOf(ChatEntry.ShipEscaped(ship, Moment.now)) + else emptyList()) + + val newShips = (gameState.ships + mapOf(ship to newShipInstance) + identifiedShips) - (if (isDisengaged) + setOf(ship) + else emptySet()) + + val newWrecks = gameState.destroyedShips + (if (isDisengaged) + mapOf(ship to ShipWreck(newShipInstance.ship, newShipInstance.owner, true)) + else emptyMap()) + + return GameEvent.StateChange( + gameState.copy( + ships = newShips, + destroyedShips = newWrecks, + chatBox = newChatEntries, + ).withRecalculatedInitiative { calculateMovePhaseInitiative() } + ) + } + } + + @Serializable + data class UseInertialessDrive(override val ship: Id) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Move) return null + if (!gameState.canShipMove(ship)) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.isDoneCurrentPhase) return null + + if (!shipInstance.canUseInertialessDrive) return null + val movement = shipInstance.movement + if (movement !is FelinaeShipMovement) return null + + val positionPickReq = PickRequest( + PickType.Location(gameState.ships.keys - ship, PickHelper.Circle(SHIP_BASE_SIZE), shipInstance.position.location), + PickBoundary.Circle( + shipInstance.position.location, + movement.inertialessDriveRange, + ) + ) + val positionPickRes = (pick(positionPickReq) as? PickResponse.Location) ?: return null + + return PlayerAbilityData.UseInertialessDrive(positionPickRes.position) + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (data !is PlayerAbilityData.UseInertialessDrive) return GameEvent.InvalidAction("Internal error from using player ability") + if (!gameState.canShipMove(ship)) return GameEvent.InvalidAction("You do not have the initiative") + + if ((gameState.ships - ship).any { (_, otherShip) -> (otherShip.position.location - data.newPosition).length <= SHIP_BASE_SIZE }) + return GameEvent.InvalidAction("You cannot move that ship there") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.isDoneCurrentPhase) return GameEvent.InvalidAction("Ships cannot be moved twice") + + if (!shipInstance.canUseInertialessDrive) return GameEvent.InvalidAction("That ship cannot use its inertialess drive") + val movement = shipInstance.movement + if (movement !is FelinaeShipMovement) return GameEvent.InvalidAction("That ship does not have an inertialess drive") + + val oldPos = shipInstance.position.location + val newPos = data.newPosition + + val deltaPos = newPos - oldPos + val velocity = deltaPos.length + + if (velocity > movement.inertialessDriveRange) return GameEvent.InvalidAction("That move is out of range") + + val newFacing = deltaPos.angle + + val newShipInstance = shipInstance.copy( + position = ShipPosition(newPos, newFacing), + currentVelocity = velocity, + isDoneCurrentPhase = true, + usedInertialessDriveShots = shipInstance.usedInertialessDriveShots + 1 + ) + + // Identify enemy ships + val identifiedEnemyShips = gameState.ships.filterValues { enemyShip -> + enemyShip.owner != playerSide && (enemyShip.position.location - newShipInstance.position.location).length <= SHIP_SENSOR_RANGE + } + + // Be identified by enemy ships (Inertialess Drive automatically reveals your ship) + val shipsToBeIdentified = identifiedEnemyShips + if (!newShipInstance.isIdentified) + mapOf(ship to newShipInstance) + else emptyMap() + + val identifiedShips = shipsToBeIdentified + .filterValues { !it.isIdentified } + .mapValues { (_, shipInstance) -> shipInstance.copy(isIdentified = true) } + + // Ships that move off the battlefield are considered to disengage + val isDisengaged = newShipInstance.position.location.vector.let { (x, y) -> + val mx = gameState.start.battlefieldWidth / 2 + val my = gameState.start.battlefieldLength / 2 + abs(x) > mx || abs(y) > my + } + + val newChatEntries = gameState.chatBox + identifiedShips.map { (id, _) -> + ChatEntry.ShipIdentified(id, Moment.now) + } + (if (isDisengaged) + listOf(ChatEntry.ShipEscaped(ship, Moment.now)) + else emptyList()) + + val newShips = (gameState.ships + mapOf(ship to newShipInstance) + identifiedShips) - (if (isDisengaged) + setOf(ship) + else emptySet()) + + val newWrecks = gameState.destroyedShips + (if (isDisengaged) + mapOf(ship to ShipWreck(newShipInstance.ship, newShipInstance.owner, true)) + else emptyMap()) + + return GameEvent.StateChange( + gameState.copy( + ships = newShips, + destroyedShips = newWrecks, + chatBox = newChatEntries, + ).withRecalculatedInitiative { calculateMovePhaseInitiative() } + ) + } + } + + @Serializable + data class ChargeLance(override val ship: Id, override val weapon: Id) : PlayerAbilityType(), CombatAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Attack) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.weaponAmount <= 0) return null + if (weapon in shipInstance.usedArmaments) return null + + val shipWeapon = shipInstance.armaments[weapon] ?: return null + if (shipWeapon !is ShipWeaponInstance.Lance) return null + + return PlayerAbilityData.ChargeLance + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only charge lances during Phase III") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.weaponAmount <= 0) return GameEvent.InvalidAction("Not enough power to charge lances") + if (weapon in shipInstance.usedArmaments) return GameEvent.InvalidAction("Cannot charge used lances") + + val shipWeapon = shipInstance.armaments[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") + if (shipWeapon !is ShipWeaponInstance.Lance) return GameEvent.InvalidAction("Cannot charge non-lance weapons") + + return GameEvent.StateChange( + gameState.copy( + ships = gameState.ships + mapOf( + ship to shipInstance.copy( + weaponAmount = shipInstance.weaponAmount - 1, + armaments = shipInstance.armaments + mapOf( + weapon to shipWeapon.copy(numCharges = shipWeapon.numCharges + shipInstance.firepower.lanceCharging) + ) + ) + ) + ) + ) + } + } + + @Serializable + data class UseWeapon(override val ship: Id, override val weapon: Id) : PlayerAbilityType(), CombatAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Attack) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (!shipInstance.canUseWeapon(weapon)) return null + + val shipWeapon = shipInstance.armaments[weapon] ?: return null + + val pickResponse = pick(shipInstance.getWeaponPickRequest(shipWeapon.weapon)) + + return pickResponse?.let { PlayerAbilityData.UseWeapon(it) } + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (data !is PlayerAbilityData.UseWeapon) return GameEvent.InvalidAction("Internal error from using player ability") + + if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only attack during Phase III") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That attacking ship does not exist") + if (!shipInstance.canUseWeapon(weapon)) return GameEvent.InvalidAction("That weapon cannot be used") + + val shipWeapon = shipInstance.armaments[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") + + val pickRequest = shipInstance.getWeaponPickRequest(shipWeapon.weapon) + val pickResponse = data.target + + if (!gameState.isValidPick(pickRequest, pickResponse)) return GameEvent.InvalidAction("Invalid target") + + return gameState.useWeaponPickResponse(shipInstance, weapon, pickResponse) + } + } + + @Serializable + data class RecallStrikeCraft(override val ship: Id, override val weapon: Id) : PlayerAbilityType(), CombatAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Attack) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (weapon !in shipInstance.usedArmaments) return null + + val shipWeapon = shipInstance.armaments[weapon] ?: return null + if (shipWeapon !is ShipWeaponInstance.Hangar) return null + + return PlayerAbilityData.RecallStrikeCraft + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only recall strike craft during Phase III") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (weapon !in shipInstance.usedArmaments) return GameEvent.InvalidAction("Cannot recall unused strike craft") + + val shipWeapon = shipInstance.armaments[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") + if (shipWeapon !is ShipWeaponInstance.Hangar) return GameEvent.InvalidAction("Cannot recall non-hangar weapons") + + val hangarWing = ShipHangarWing(ship, weapon) + + val newShip = shipInstance.copy( + usedArmaments = shipInstance.usedArmaments - weapon + ) + + return GameEvent.StateChange( + gameState.copy( + ships = gameState.ships.mapValues { (_, targetShip) -> + targetShip.copy( + fighterWings = targetShip.fighterWings - hangarWing, + bomberWings = targetShip.bomberWings - hangarWing, + ) + } + mapOf(ship to newShip) + ).withRecalculatedInitiative { calculateAttackPhaseInitiative() } + ) + } + } + + @Serializable + data class DisruptionPulse(override val ship: Id) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Attack) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (!shipInstance.canUseDisruptionPulse) return null + if (shipInstance.hasUsedDisruptionPulse) return null + + return PlayerAbilityData.DisruptionPulse + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only emit Disruption Pulses during Phase III") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (!shipInstance.canUseDisruptionPulse) return GameEvent.InvalidAction("Cannot use Disruption Pulse") + if (shipInstance.hasUsedDisruptionPulse) return GameEvent.InvalidAction("Cannot use Disruption Pulse twice") + + val durability = shipInstance.durability + if (durability !is FelinaeShipDurability) return GameEvent.InvalidAction("That ship does not have a Disruption Pulse emitter") + + val targetedShips = gameState.ships.filterValues { + (it.position.location - shipInstance.position.location).length < durability.disruptionPulseRange + } + + val hangars = targetedShips.values.flatMap { target -> + target.fighterWings + target.bomberWings + } + + val changedShips = hangars.groupBy { it.ship }.mapNotNull { (shipId, hangarWings) -> + val changedShip = gameState.ships[shipId] ?: return@mapNotNull null + changedShip.copy( + armaments = changedShip.armaments + hangarWings.associate { + it.hangar to ShipWeaponInstance.Hangar( + changedShip.ship.armaments[it.hangar] as ShipWeapon.Hangar, + 0.0 + ) + } + ) + }.associateBy { it.id } + mapOf( + ship to shipInstance.copy( + hasUsedDisruptionPulse = true, + usedDisruptionPulseShots = shipInstance.usedDisruptionPulseShots + 1 + ) + ) + + return GameEvent.StateChange( + gameState.copy( + ships = gameState.ships + changedShips + ).withRecalculatedInitiative { calculateAttackPhaseInitiative() } + ) + } + } + + @Serializable + data class BoardingParty(override val ship: Id) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Attack) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (!shipInstance.canSendBoardingParty) return null + + val pickResponse = pick(shipInstance.getBoardingPickRequest()) as? PickResponse.Ship ?: return null + return PlayerAbilityData.BoardingParty(pickResponse.id) + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (data !is PlayerAbilityData.BoardingParty) return GameEvent.InvalidAction("Internal error from using player ability") + + if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only send Boarding Parties during Phase III") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (!shipInstance.canSendBoardingParty) return GameEvent.InvalidAction("Cannot send a boarding party") + + val afterBoarding = shipInstance.afterBoarding() ?: return GameEvent.InvalidAction("Cannot send a boarding party") + + val boarded = gameState.ships[data.target] ?: return GameEvent.InvalidAction("That ship does not exist") + val afterBoarded = shipInstance.board(boarded) + + val newShips = (if (afterBoarded is ImpactResult.Damaged) + gameState.ships + mapOf(data.target to afterBoarded.ship) + else gameState.ships - data.target) + mapOf(ship to afterBoarding) + + val newWrecks = gameState.destroyedShips + (if (afterBoarded is ImpactResult.Destroyed) + mapOf(data.target to afterBoarded.ship) + else emptyMap()) + + val newChatEntries = gameState.chatBox + reportBoardingResult(afterBoarded, ship) + + return GameEvent.StateChange( + gameState.copy( + ships = newShips, + destroyedShips = newWrecks, + chatBox = newChatEntries + ).withRecalculatedInitiative { calculateAttackPhaseInitiative() } + ) + } + } + + @Serializable + data class RepairShipModule(override val ship: Id, val module: ShipModule) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Repair) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.durability !is StandardShipDurability) return null + if (shipInstance.remainingRepairTokens <= 0) return null + if (!shipInstance.modulesStatus[module].canBeRepaired) return null + + return PlayerAbilityData.RepairShipModule + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only repair modules during Phase IV") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.durability !is StandardShipDurability) return GameEvent.InvalidAction("That ship cannot manually repair subsystems") + if (shipInstance.remainingRepairTokens <= 0) return GameEvent.InvalidAction("That ship has no remaining repair tokens") + if (!shipInstance.modulesStatus[module].canBeRepaired) return GameEvent.InvalidAction("That module cannot be repaired") + + val newShip = shipInstance.copy( + modulesStatus = shipInstance.modulesStatus.repair(module), + usedRepairTokens = shipInstance.usedRepairTokens + 1 + ) + + return GameEvent.StateChange( + gameState.copy( + ships = gameState.ships + mapOf( + ship to newShip + ) + ) + ) + } + } + + @Serializable + data class ExtinguishFire(override val ship: Id) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Repair) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.durability !is StandardShipDurability) return null + if (shipInstance.remainingRepairTokens <= 0) return null + if (shipInstance.numFires <= 0) return null + + return PlayerAbilityData.ExtinguishFire + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only extinguish fires during Phase IV") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.durability !is StandardShipDurability) return GameEvent.InvalidAction("That ship cannot manually extinguish fires") + if (shipInstance.remainingRepairTokens <= 0) return GameEvent.InvalidAction("That ship has no remaining repair tokens") + if (shipInstance.numFires <= 0) return GameEvent.InvalidAction("Cannot extinguish non-existent fires") + + val newShip = shipInstance.copy( + numFires = shipInstance.numFires - 1, + usedRepairTokens = shipInstance.usedRepairTokens + 1 + ) + + return GameEvent.StateChange( + gameState.copy( + ships = gameState.ships + mapOf( + ship to newShip + ) + ) + ) + } + } + + @Serializable + data class Recoalesce(override val ship: Id) : PlayerAbilityType(), ShipAbility { + override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { + if (gameState.phase !is GamePhase.Repair) return null + + val shipInstance = gameState.ships[ship] ?: return null + if (shipInstance.durability !is FelinaeShipDurability) return null + if (!shipInstance.canUseRecoalescence) return null + + return PlayerAbilityData.Recoalesce + } + + override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { + if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only extinguish fires during Phase IV") + + val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") + if (shipInstance.durability !is FelinaeShipDurability) return GameEvent.InvalidAction("That ship cannot recoalesce its hull") + if (!shipInstance.canUseRecoalescence) return GameEvent.InvalidAction("That ship is not in Recoalescence mode") + + val newHullAmountRange = (shipInstance.hullAmount + 1) until shipInstance.durability.maxHullPoints + val (newHullAmount, newMaxHullDamage) = if (newHullAmountRange.isEmpty()) + shipInstance.hullAmount to shipInstance.recoalescenceMaxHullDamage + else + newHullAmountRange.random() to (shipInstance.recoalescenceMaxHullDamage + 1) + + val repairs = shipInstance.modulesStatus.statuses.filterValues { + it == ShipModuleStatus.DAMAGED || it == ShipModuleStatus.DESTROYED + }.keys + + var newModules = shipInstance.modulesStatus + for (repair in repairs) { + if (Random.nextBoolean()) + newModules = newModules.repair(repair, repairUnrepairable = true) + } + + val newShip = shipInstance.copy( + hullAmount = newHullAmount, + recoalescenceMaxHullDamage = newMaxHullDamage, + modulesStatus = newModules, + isDoneCurrentPhase = true + ) + + return GameEvent.StateChange( + gameState.copy( + ships = gameState.ships + mapOf( + ship to newShip + ) + ) + ) + } + } +} + +@Serializable +sealed class PlayerAbilityData { + @Serializable + object DonePhase : PlayerAbilityData() + + @Serializable + data class DeployShip(val position: Position) : PlayerAbilityData() + + @Serializable + object UndeployShip : PlayerAbilityData() + + @Serializable + data class DistributePower(val powerMode: ShipPowerMode) : PlayerAbilityData() + + @Serializable + object ConfigurePower : PlayerAbilityData() + + @Serializable + data class MoveShip(val newPosition: ShipPosition) : PlayerAbilityData() + + @Serializable + data class UseInertialessDrive(val newPosition: Position) : PlayerAbilityData() + + @Serializable + object ChargeLance : PlayerAbilityData() + + @Serializable + data class UseWeapon(val target: PickResponse) : PlayerAbilityData() + + @Serializable + object RecallStrikeCraft : PlayerAbilityData() + + @Serializable + object DisruptionPulse : PlayerAbilityData() + + @Serializable + data class BoardingParty(val target: Id) : PlayerAbilityData() + + @Serializable + object RepairShipModule : PlayerAbilityData() + + @Serializable + object ExtinguishFire : PlayerAbilityData() + + @Serializable + object Recoalesce : PlayerAbilityData() +} + +fun GameState.getPossibleAbilities(forPlayer: GlobalSide): List = if (doneWithPhase == forPlayer) + emptyList() +else when (phase) { + GamePhase.Deploy -> { + val usedPoints = ships.values + .filter { it.owner == forPlayer } + .sumOf { it.ship.pointCost } + + val deployShips = start.playerStart(forPlayer).deployableFleet + .filterValues { usedPoints + it.pointCost <= battleInfo.size.numPoints }.keys + .map { PlayerAbilityType.DeployShip(it) } + + val undeployShips = ships + .filterValues { it.owner == forPlayer } + .keys + .map { PlayerAbilityType.UndeployShip(it) } + + val finishDeploying = if (canFinishPhase(forPlayer)) + listOf(PlayerAbilityType.DonePhase(GamePhase.Deploy)) + else emptyList() + + deployShips + undeployShips + finishDeploying + } + is GamePhase.Power -> { + val powerableShips = ships + .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.ship.reactor is StandardShipReactor } + .keys + .map { PlayerAbilityType.DistributePower(it) } + + val configurableShips = ships + .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.ship.reactor is FelinaeShipReactor } + .keys + .flatMap { + FelinaeShipPowerMode.values().map { mode -> + PlayerAbilityType.ConfigurePower(it, mode) + } + } + + val finishPowering = if (canFinishPhase(forPlayer)) + listOf(PlayerAbilityType.DonePhase(GamePhase.Power(phase.turn))) + else emptyList() + + powerableShips + configurableShips + finishPowering + } + is GamePhase.Move -> { + val movableShips = ships + .filterKeys { canShipMove(it) } + .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase } + .keys + .map { PlayerAbilityType.MoveShip(it) } + + val inertialessShips = ships + .filterKeys { canShipMove(it) } + .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.canUseInertialessDrive } + .keys + .map { PlayerAbilityType.UseInertialessDrive(it) } + + val finishMoving = if (canFinishPhase(forPlayer)) + listOf(PlayerAbilityType.DonePhase(GamePhase.Move(phase.turn))) + else emptyList() + + movableShips + inertialessShips + finishMoving + } + is GamePhase.Attack -> { + val chargeableLances = ships + .filterValues { it.owner == forPlayer && it.weaponAmount > 0 } + .flatMap { (id, ship) -> + ship.armaments.mapNotNull { (weaponId, weapon) -> + PlayerAbilityType.ChargeLance(id, weaponId).takeIf { + when (weapon) { + is ShipWeaponInstance.Lance -> weapon.numCharges < 7.0 && weaponId !in ship.usedArmaments + else -> false + } + } + } + } + + val usableWeapons = ships + .filterKeys { canShipAttack(it) } + .filterValues { it.owner == forPlayer } + .flatMap { (id, ship) -> + ship.armaments.keys.mapNotNull { weaponId -> + PlayerAbilityType.UseWeapon(id, weaponId).takeIf { + weaponId !in ship.usedArmaments && ship.canUseWeapon(weaponId) + } + } + } + + val usableDisruptionPulses = ships + .filterKeys { canShipAttack(it) } + .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.canUseDisruptionPulse } + .keys + .map { PlayerAbilityType.DisruptionPulse(it) } + + val usableBoardingTransportaria = ships + .filterKeys { canShipAttack(it) } + .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.canSendBoardingParty } + .keys + .map { PlayerAbilityType.BoardingParty(it) } + + val recallableStrikeWings = ships + .filterValues { it.owner == forPlayer } + .flatMap { (id, ship) -> + ship.armaments.mapNotNull { (weaponId, weapon) -> + PlayerAbilityType.RecallStrikeCraft(id, weaponId).takeIf { + weaponId in ship.usedArmaments && weapon is ShipWeaponInstance.Hangar + } + } + } + + val finishAttacking = if (canFinishPhase(forPlayer)) + listOf(PlayerAbilityType.DonePhase(GamePhase.Attack(phase.turn))) + else emptyList() + + usableBoardingTransportaria + chargeableLances + usableWeapons + recallableStrikeWings + usableDisruptionPulses + finishAttacking + } + is GamePhase.Repair -> { + val repairableModules = ships + .filterValues { it.owner == forPlayer && it.remainingRepairTokens > 0 } + .flatMap { (id, ship) -> + ship.modulesStatus.statuses.filterValues { it.canBeRepaired }.keys.map { module -> + PlayerAbilityType.RepairShipModule(id, module) + } + } + + val extinguishableFires = ships + .filterValues { it.owner == forPlayer && it.remainingRepairTokens > 0 && it.numFires > 0 } + .keys + .map { + PlayerAbilityType.ExtinguishFire(it) + } + + val recoalescibleShips = ships + .filterValues { it.owner == forPlayer && it.canUseRecoalescence } + .keys + .map { + PlayerAbilityType.Recoalesce(it) + } + + val finishRepairing = if (canFinishPhase(forPlayer)) + listOf(PlayerAbilityType.DonePhase(GamePhase.Repair(phase.turn))) + else emptyList() + + repairableModules + extinguishableFires + recoalescibleShips + finishRepairing + } +} + +object ClientAbilityData { + val newShipPowerModes = mutableMapOf, ShipPowerMode>() +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_chat.kt b/src/commonMain/kotlin/net/starshipfights/game/game_chat.kt new file mode 100644 index 0000000..5426847 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_chat.kt @@ -0,0 +1,99 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id + +@Serializable +sealed class ChatEntry { + abstract val sentAt: Moment + + @Serializable + data class PlayerMessage( + val senderSide: GlobalSide, + override val sentAt: Moment, + val message: String + ) : ChatEntry() + + @Serializable + data class ShipIdentified( + val ship: Id, + override val sentAt: Moment, + ) : ChatEntry() + + @Serializable + data class ShipEscaped( + val ship: Id, + override val sentAt: Moment + ) : ChatEntry() + + @Serializable + data class ShipAttacked( + val ship: Id, + val attacker: ShipAttacker, + override val sentAt: Moment, + val damageInflicted: Int, + val weapon: ShipWeapon?, + val critical: ShipCritical?, + ) : ChatEntry() + + @Serializable + data class ShipAttackFailed( + val ship: Id, + val attacker: ShipAttacker, + override val sentAt: Moment, + val weapon: ShipWeapon?, + val damageIgnoreType: DamageIgnoreType, + ) : ChatEntry() + + @Serializable + data class ShipBoarded( + val ship: Id, + val boarder: Id, + override val sentAt: Moment, + val critical: ShipCritical?, + val damageAmount: Int = 0, + ) : ChatEntry() + + @Serializable + data class ShipDestroyed( + val ship: Id, + override val sentAt: Moment, + val destroyedBy: ShipAttacker + ) : ChatEntry() +} + +@Serializable +sealed class ShipAttacker { + @Serializable + data class EnemyShip(val id: Id) : ShipAttacker() + + @Serializable + object Bombers : ShipAttacker() + + @Serializable + object Fire : ShipAttacker() +} + +@Serializable +sealed class ShipCritical { + @Serializable + object ExtraDamage : ShipCritical() + + @Serializable + object Fire : ShipCritical() + + @Serializable + data class TroopsKilled(val number: Int) : ShipCritical() + + @Serializable + data class ModulesHit(val module: Set) : ShipCritical() +} + +fun CritResult.report(): ShipCritical? = when (this) { + CritResult.NoEffect -> null + is CritResult.FireStarted -> ShipCritical.Fire + is CritResult.ModulesDisabled -> ShipCritical.ModulesHit(modules) + is CritResult.TroopsKilled -> ShipCritical.TroopsKilled(amount) + is CritResult.HullDamaged -> ShipCritical.ExtraDamage + is CritResult.Destroyed -> null +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_initiative.kt b/src/commonMain/kotlin/net/starshipfights/game/game_initiative.kt new file mode 100644 index 0000000..185632f --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_initiative.kt @@ -0,0 +1,112 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id + +@Serializable +data class InitiativePair( + val hostSide: Double, + val guestSide: Double +) { + constructor(map: Map) : this( + map[GlobalSide.HOST] ?: 0.0, + map[GlobalSide.GUEST] ?: 0.0, + ) + + operator fun get(side: GlobalSide) = when (side) { + GlobalSide.HOST -> hostSide + GlobalSide.GUEST -> guestSide + } + + fun copy(map: Map) = copy( + hostSide = map[GlobalSide.HOST] ?: hostSide, + guestSide = map[GlobalSide.GUEST] ?: guestSide, + ) +} + +fun GameState.calculateMovePhaseInitiative(): InitiativePair = InitiativePair( + ships + .values + .groupBy { it.owner } + .mapValues { (_, shipList) -> + shipList + .filter { !it.isDoneCurrentPhase } + .sumOf { it.ship.pointCost * it.movementCoefficient } + } +) + +fun GameState.getValidAttackersWith(target: ShipInstance): Map, Set>> { + return ships.mapValues { (_, ship) -> isValidAttackerWith(ship, target) } +} + +fun GameState.isValidAttackerWith(attacker: ShipInstance, target: ShipInstance): Set> { + return attacker.armaments.filterValues { + isValidTarget(attacker, it, attacker.getWeaponPickRequest(it.weapon), target) + }.keys +} + +fun GameState.isValidTarget(ship: ShipInstance, weapon: ShipWeaponInstance, pickRequest: PickRequest, target: ShipInstance): Boolean { + val targetPos = target.position.location + + return when (val weaponSpec = weapon.weapon) { + is AreaWeapon -> + target.owner != ship.owner && (targetPos - pickRequest.boundary.closestPointTo(targetPos)).length < weaponSpec.areaRadius + else -> + target.owner in (pickRequest.type as PickType.Ship).allowSides && isValidPick(pickRequest, PickResponse.Ship(target.id)) + } +} + +inline fun GameState.aggregateValidTargets(ship: ShipInstance, weapon: ShipWeaponInstance, aggregate: Iterable.((ShipInstance) -> Boolean) -> T): T { + val pickRequest = ship.getWeaponPickRequest(weapon.weapon) + return ships.values.aggregate { target -> isValidTarget(ship, weapon, pickRequest, target) } +} + +fun GameState.hasValidTargets(ship: ShipInstance, weapon: ShipWeaponInstance): Boolean { + return aggregateValidTargets(ship, weapon) { any(it) } +} + +fun GameState.getValidTargets(ship: ShipInstance, weapon: ShipWeaponInstance): List { + return aggregateValidTargets(ship, weapon) { filter(it) } +} + +fun GameState.calculateAttackPhaseInitiative(): InitiativePair = InitiativePair( + ships + .values + .groupBy { it.owner } + .mapValues { (_, shipList) -> + shipList + .filter { !it.isDoneCurrentPhase } + .sumOf { ship -> + val allWeapons = ship.armaments + .filterValues { weapon -> hasValidTargets(ship, weapon) } + val usableWeapons = allWeapons - ship.usedArmaments + + val allWeaponShots = allWeapons.values.sumOf { it.weapon.numShots } + ship.troopsAmount + val usableWeaponShots = usableWeapons.values.sumOf { it.weapon.numShots } + (if (ship.canSendBoardingParty) ship.troopsAmount else 0) + + ship.ship.pointCost * (usableWeaponShots.toDouble() / allWeaponShots) + } + } +) + +fun GameState.withRecalculatedInitiative(initiativePairAccessor: GameState.() -> InitiativePair): GameState { + val initiativePair = initiativePairAccessor() + + return copy( + calculatedInitiative = when { + initiativePair.hostSide > initiativePair.guestSide -> GlobalSide.HOST + initiativePair.hostSide < initiativePair.guestSide -> GlobalSide.GUEST + else -> calculatedInitiative?.other + } + ) +} + +fun GameState.canShipMove(ship: Id): Boolean { + val shipInstance = ships[ship] ?: return false + return currentInitiative != shipInstance.owner.other +} + +fun GameState.canShipAttack(ship: Id): Boolean { + val shipInstance = ships[ship] ?: return false + return currentInitiative != shipInstance.owner.other +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_packet.kt b/src/commonMain/kotlin/net/starshipfights/game/game_packet.kt new file mode 100644 index 0000000..0634d3e --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_packet.kt @@ -0,0 +1,81 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable + +@Serializable +sealed class PlayerAction { + @Serializable + data class SendChatMessage(val message: String) : PlayerAction() + + @Serializable + data class UseAbility(val type: PlayerAbilityType, val data: PlayerAbilityData) : PlayerAction() + + @Serializable + object TimeOut : PlayerAction() + + @Serializable + object Disconnect : PlayerAction() +} + +fun isInternalPlayerAction(playerAction: PlayerAction) = playerAction in setOf(PlayerAction.TimeOut, PlayerAction.Disconnect) + +@Serializable +data class GameBeginning(val opponentJoined: Boolean) + +@Serializable +sealed class GameEvent { + @Serializable + data class StateChange(val newState: GameState) : GameEvent() + + @Serializable + data class InvalidAction(val message: String) : GameEvent() + + @Serializable + data class GameEnd( + val winner: GlobalSide?, + val message: String, + @Serializable(with = MapAsListSerializer::class) + val subplotOutcomes: Map = emptyMap() + ) : GameEvent() +} + +fun GameState.after(player: GlobalSide, packet: PlayerAction): GameEvent = when (packet) { + is PlayerAction.SendChatMessage -> { + GameEvent.StateChange( + copy( + chatBox = chatBox + ChatEntry.PlayerMessage( + senderSide = player, + sentAt = Moment.now, + message = packet.message + ) + ) + ) + } + is PlayerAction.UseAbility -> { + if (packet.type in getPossibleAbilities(player)) + packet.type.finishOnServer(this, player, packet.data) + else + GameEvent.InvalidAction("That ability cannot be used right now") + } + PlayerAction.TimeOut -> { + val loserName = admiralInfo(player).fullName + val winnerName = admiralInfo(player.other).fullName + + GameEvent.GameEnd(player.other, "$loserName never joined the battle, yielding victory to $winnerName!", emptyMap()) + } + PlayerAction.Disconnect -> { + val loserName = admiralInfo(player).fullName + val winnerName = admiralInfo(player.other).fullName + + GameEvent.GameEnd(player.other, "$loserName has disconnected from the battle, yielding victory to $winnerName!", emptyMap()) + } +}.let { event -> + if (event is GameEvent.StateChange) { + val subplotKeys = event.newState.subplots.map { it.key } + val finalState = subplotKeys.fold(event.newState) { newState, key -> + val subplot = newState.subplots.single { it.key == key } + subplot.onGameStateChanged(newState) + } + GameEvent.StateChange(finalState) + } else event +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_phase.kt b/src/commonMain/kotlin/net/starshipfights/game/game_phase.kt new file mode 100644 index 0000000..a32ece2 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_phase.kt @@ -0,0 +1,40 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable + +@Serializable +sealed class GamePhase { + abstract val turn: Int + abstract fun next(): GamePhase + + @Serializable + object Deploy : GamePhase() { + override val turn: Int + get() = 0 + + override fun next() = Power(turn + 1) + } + + @Serializable + data class Power(override val turn: Int) : GamePhase() { + override fun next() = Move(turn) + } + + @Serializable + data class Move(override val turn: Int) : GamePhase() { + override fun next() = Attack(turn) + } + + @Serializable + data class Attack(override val turn: Int) : GamePhase() { + override fun next() = Repair(turn) + } + + @Serializable + data class Repair(override val turn: Int) : GamePhase() { + override fun next() = Power(turn + 1) + } +} + +val GamePhase.usesInitiative: Boolean + get() = this is GamePhase.Move || this is GamePhase.Attack diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_start.kt b/src/commonMain/kotlin/net/starshipfights/game/game_start.kt new file mode 100644 index 0000000..744349c --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_start.kt @@ -0,0 +1,29 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id + +@Serializable +data class GameStart( + val battlefieldWidth: Double, + val battlefieldLength: Double, + + val hostStart: PlayerStart, + val guestStart: PlayerStart +) + +fun GameStart.playerStart(side: GlobalSide) = when (side) { + GlobalSide.HOST -> hostStart + GlobalSide.GUEST -> guestStart +} + +@Serializable +data class PlayerStart( + val cameraPosition: Position, + val cameraFacing: Double, + + val deployZone: PickBoundary.Rectangle, + val deployFacing: Double, + + val deployableFleet: Map, Ship> +) diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_state.kt b/src/commonMain/kotlin/net/starshipfights/game/game_state.kt new file mode 100644 index 0000000..6cfee8f --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_state.kt @@ -0,0 +1,234 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id + +@Serializable +data class GameState( + val start: GameStart, + + val hostInfo: InGameAdmiral, + val guestInfo: InGameAdmiral, + val battleInfo: BattleInfo, + + val subplots: Set, + + val phase: GamePhase = GamePhase.Deploy, + val doneWithPhase: GlobalSide? = null, + val calculatedInitiative: GlobalSide? = null, + + val ships: Map, ShipInstance> = emptyMap(), + val destroyedShips: Map, ShipWreck> = emptyMap(), + + val chatBox: List = emptyList(), +) { + fun getShipInfo(id: Id) = destroyedShips[id]?.ship ?: ships.getValue(id).ship + fun getShipInfoOrNull(id: Id) = destroyedShips[id]?.ship ?: ships[id]?.ship + + fun getShipOwner(id: Id) = destroyedShips[id]?.owner ?: ships.getValue(id).owner + fun getShipOwnerOrNull(id: Id) = destroyedShips[id]?.owner ?: ships[id]?.owner +} + +val GameState.currentInitiative: GlobalSide? + get() = calculatedInitiative?.takeIf { it != doneWithPhase } + +fun GameState.canFinishPhase(side: GlobalSide): Boolean { + return when (phase) { + GamePhase.Deploy -> { + val usedPoints = ships.values + .filter { it.owner == side } + .sumOf { it.ship.pointCost } + + start.playerStart(side).deployableFleet.values.none { usedPoints + it.pointCost <= battleInfo.size.numPoints } + } + else -> true + } +} + +private fun GameState.afterPhase(): GameState { + var newShips = ships + val newWrecks = destroyedShips.toMutableMap() + val newChatEntries = mutableListOf() + var newInitiative: GameState.() -> InitiativePair = { InitiativePair(emptyMap()) } + + when (phase) { + GamePhase.Deploy -> { + return subplots.map { it.key }.fold(this) { newState, key -> + val subplot = newState.subplots.single { it.key == key } + subplot.onAfterDeployShips(newState) + }.copy( + phase = phase.next(), + ships = ships.mapValues { (_, ship) -> + ship.copy(isDoneCurrentPhase = false) + }, + ) + } + is GamePhase.Power -> { + // Prepare for move phase + newInitiative = { calculateMovePhaseInitiative() } + } + is GamePhase.Move -> { + // Set velocity to 0 for halted ships + newShips = newShips.mapValues { (_, ship) -> + if (ship.ship.shipType.faction == Faction.FELINAE_FELICES && !ship.isDoneCurrentPhase) + ship.copy(currentVelocity = 0.0) + else ship + } + + // Recharge inertialess drive + newShips = newShips.mapValues { (_, ship) -> + if (ship.ship.canUseInertialessDrive && ship.usedInertialessDriveShots > 0 && ship.felinaeShipPowerMode != FelinaeShipPowerMode.INERTIALESS_DRIVE) + ship.copy(usedInertialessDriveShots = ship.usedInertialessDriveShots - 1) + else ship + } + + // Prepare for attack phase + newInitiative = { calculateAttackPhaseInitiative() } + } + is GamePhase.Attack -> { + val strikeWingDamage = mutableMapOf() + + // Apply damage to ships from strike craft + newShips = newShips.mapNotNull strikeBombard@{ (id, ship) -> + val impact = ship.afterBombed(newShips, strikeWingDamage) + newChatEntries += listOfNotNull(impact.toChatEntry(ShipAttacker.Bombers, null)) + when (impact) { + is ImpactResult.Damaged -> { + id to impact.ship + } + is ImpactResult.Destroyed -> { + newWrecks[id] = impact.ship + null + } + } + }.toMap() + + // Apply damage to strike craft wings + newShips = newShips.mapValues { (_, ship) -> + ship.afterBombing(strikeWingDamage) + } + + // Deal fire damage + newShips = newShips.mapNotNull fireDamage@{ (id, ship) -> + if (ship.numFires <= 0) + return@fireDamage id to ship + + val hits = (0..ship.numFires).random() + + val impactResult = ship.impact(hits, true) + newChatEntries += listOfNotNull(impactResult.toChatEntry(ShipAttacker.Fire, null)) + when (impactResult) { + is ImpactResult.Damaged -> { + id to impactResult.ship + } + is ImpactResult.Destroyed -> { + newWrecks[id] = impactResult.ship + null + } + } + }.toMap() + + // Replenish repair tokens, recall strike craft, and regenerate weapons and shields power + newShips = newShips.mapValues { (_, ship) -> + ship.copy( + weaponAmount = ship.powerMode.weapons, + shieldAmount = if (ship.canUseShields) (ship.shieldAmount..ship.powerMode.shields).random() else 0, + usedRepairTokens = 0, + + hasUsedDisruptionPulse = false, + + fighterWings = emptySet(), + bomberWings = emptySet(), + usedArmaments = emptySet(), + + hasSentBoardingParty = false, + ) + } + } + else -> { + // do nothing + } + } + + return copy( + phase = phase.next(), + ships = newShips.mapValues { (_, ship) -> + ship.copy(isDoneCurrentPhase = false) + }, + destroyedShips = newWrecks, + chatBox = chatBox + newChatEntries + ).withRecalculatedInitiative(newInitiative) +} + +fun GameState.afterPlayerReady(playerSide: GlobalSide) = if (doneWithPhase == playerSide.other) { + afterPhase().copy(doneWithPhase = null) +} else + copy(doneWithPhase = playerSide) + +private fun GameState.victoryMessage(winner: GlobalSide): String { + val winnerName = admiralInfo(winner).fullName + val loserName = admiralInfo(winner.other).fullName + + return "$winnerName has won the battle by destroying the fleet of $loserName!" +} + +fun GameState.checkVictory(): GameEvent.GameEnd? { + if (phase == GamePhase.Deploy) return null + + val hostDefeated = ships.none { (_, it) -> it.owner == GlobalSide.HOST } + val guestDefeated = ships.none { (_, it) -> it.owner == GlobalSide.GUEST } + + val winner = if (hostDefeated && guestDefeated) + null + else if (hostDefeated) + GlobalSide.GUEST + else if (guestDefeated) + GlobalSide.HOST + else return null + + val subplotsOutcomes = subplots.associate { subplot -> + subplot.key to subplot.getFinalGameResult(this, winner) + } + + return if (hostDefeated && guestDefeated) + GameEvent.GameEnd(null, "Stalemate: both sides have been completely destroyed!", subplotsOutcomes) + else if (hostDefeated) + GameEvent.GameEnd(GlobalSide.GUEST, victoryMessage(GlobalSide.GUEST), subplotsOutcomes) + else if (guestDefeated) + GameEvent.GameEnd(GlobalSide.HOST, victoryMessage(GlobalSide.HOST), subplotsOutcomes) + else + null +} + +fun GameState.admiralInfo(side: GlobalSide) = when (side) { + GlobalSide.HOST -> hostInfo + GlobalSide.GUEST -> guestInfo +} + +enum class GlobalSide { + HOST, GUEST; + + val other: GlobalSide + get() = when (this) { + HOST -> GUEST + GUEST -> HOST + } +} + +fun GlobalSide.relativeTo(me: GlobalSide) = if (this == me) LocalSide.GREEN else LocalSide.RED + +enum class LocalSide { + GREEN, RED; + + val other: LocalSide + get() = when (this) { + GREEN -> RED + RED -> GREEN + } +} + +val LocalSide.htmlColor: String + get() = when (this) { + LocalSide.GREEN -> "#55FF55" + LocalSide.RED -> "#FF5555" + } diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_subplots.kt b/src/commonMain/kotlin/net/starshipfights/game/game_subplots.kt new file mode 100644 index 0000000..e47ef82 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_subplots.kt @@ -0,0 +1,296 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id + +@Serializable +data class GameObjective( + val displayText: String, + val succeeded: Boolean? +) + +fun GameState.objectives(forPlayer: GlobalSide): List = listOf( + GameObjective("Destroy or rout the enemy fleet", null) +) + subplots.filter { it.forPlayer == forPlayer }.mapNotNull { it.displayObjective(this) } + +@Serializable +data class SubplotKey( + val type: SubplotType, + val player: GlobalSide, +) + +val Subplot.key: SubplotKey + get() = SubplotKey(type, forPlayer) + +@Serializable +sealed class Subplot { + abstract val type: SubplotType + abstract val displayName: String + abstract val forPlayer: GlobalSide + + override fun equals(other: Any?): Boolean { + return other is Subplot && other.key == key + } + + override fun hashCode(): Int { + return key.hashCode() + } + + abstract fun displayObjective(gameState: GameState): GameObjective? + + abstract fun onAfterDeployShips(gameState: GameState): GameState + abstract fun onGameStateChanged(gameState: GameState): GameState + abstract fun getFinalGameResult(gameState: GameState, winner: GlobalSide?): SubplotOutcome + + protected fun GameState.modifySubplotData(newSubplot: Subplot) = copy(subplots = (subplots - this@Subplot) + newSubplot) + + @Serializable + class ExtendedDuty(override val forPlayer: GlobalSide) : Subplot() { + override val type: SubplotType + get() = SubplotType.EXTENDED_DUTY + + override val displayName: String + get() = "Extended Duty" + + override fun displayObjective(gameState: GameState) = GameObjective("Win the battle with your fleet worn out from extended duty", null) + + private fun ShipInstance.preBattleDamage(): ShipInstance = when ((0..4).random()) { + 0 -> copy( + hullAmount = (2..hullAmount).random(), + troopsAmount = (2..troopsAmount).random(), + modulesStatus = ShipModulesStatus( + modulesStatus.statuses.mapValues { (_, status) -> + if (status != ShipModuleStatus.ABSENT && (1..3).random() == 1) + ShipModuleStatus.DESTROYED + else + status + } + ) + ) + 1 -> copy( + hullAmount = (2..hullAmount).random(), + troopsAmount = (2..troopsAmount).random(), + ) + 2 -> copy( + troopsAmount = (2..troopsAmount).random(), + ) + else -> this + } + + override fun onAfterDeployShips(gameState: GameState) = gameState.copy(ships = gameState.ships.mapValues { (_, ship) -> + if (ship.owner == forPlayer) + ship.preBattleDamage() + else ship + }) + + override fun onGameStateChanged(gameState: GameState) = gameState + + override fun getFinalGameResult(gameState: GameState, winner: GlobalSide?) = SubplotOutcome.fromBattleWinner(winner, forPlayer) + } + + @Serializable + class NoQuarter(override val forPlayer: GlobalSide) : Subplot() { + override val type: SubplotType + get() = SubplotType.NO_QUARTER + + override val displayName: String + get() = "Leave No Quarter!" + + override fun displayObjective(gameState: GameState): GameObjective { + val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other } + val enemyWrecks = gameState.destroyedShips.values.filter { it.owner == forPlayer.other } + + val totalEnemyShipPointCount = enemyShips.sumOf { it.ship.pointCost } + enemyWrecks.sumOf { it.ship.pointCost } + val escapedShipPointCount = enemyWrecks.filter { it.isEscape }.sumOf { it.ship.pointCost } + val destroyedShipPointCount = enemyWrecks.filter { !it.isEscape }.sumOf { it.ship.pointCost } + + val success = when { + destroyedShipPointCount * 2 >= totalEnemyShipPointCount -> true + escapedShipPointCount * 2 >= totalEnemyShipPointCount -> false + else -> null + } + + return GameObjective("Destroy at least half of the enemy fleet's point value - do not let them escape!", success) + } + + override fun onAfterDeployShips(gameState: GameState) = gameState + + override fun onGameStateChanged(gameState: GameState) = gameState + + override fun getFinalGameResult(gameState: GameState, winner: GlobalSide?): SubplotOutcome { + val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other } + val enemyWrecks = gameState.destroyedShips.values.filter { it.owner == forPlayer.other } + + val totalEnemyShipPointCount = enemyShips.sumOf { it.ship.pointCost } + enemyWrecks.sumOf { it.ship.pointCost } + val destroyedShipPointCount = enemyWrecks.filter { !it.isEscape }.sumOf { it.ship.pointCost } + + return if (destroyedShipPointCount * 2 >= totalEnemyShipPointCount) + SubplotOutcome.WON + else + SubplotOutcome.LOST + } + } + + @Serializable + class Vendetta private constructor(override val forPlayer: GlobalSide, private val againstShip: Id?, private val outcome: SubplotOutcome) : Subplot() { + constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED) + constructor(forPlayer: GlobalSide, againstShip: Id) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED) + + override val type: SubplotType + get() = SubplotType.VENDETTA + + override val displayName: String + get() = "Vendetta!" + + override fun displayObjective(gameState: GameState): GameObjective? { + val shipName = gameState.getShipInfoOrNull(againstShip ?: return null)?.fullName ?: return null + return GameObjective("Destroy the $shipName", outcome.toSuccess) + } + + override fun onAfterDeployShips(gameState: GameState): GameState { + if (gameState.ships[againstShip] != null) return gameState + + val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other } + val highestEnemyShipTier = enemyShips.maxOf { it.ship.shipType.weightClass } + val enemyShipsOfHighestTier = enemyShips.filter { it.ship.shipType.weightClass == highestEnemyShipTier } + + val vendettaShip = enemyShipsOfHighestTier.random().id + return gameState.modifySubplotData(Vendetta(forPlayer, vendettaShip, SubplotOutcome.UNDECIDED)) + } + + override fun onGameStateChanged(gameState: GameState): GameState { + if (outcome != SubplotOutcome.UNDECIDED) return gameState + + val vendettaShipWreck = gameState.destroyedShips[againstShip ?: return gameState] ?: return gameState + return if (vendettaShipWreck.isEscape) + gameState.modifySubplotData(Vendetta(forPlayer, againstShip, SubplotOutcome.LOST)) + else + gameState.modifySubplotData(Vendetta(forPlayer, againstShip, SubplotOutcome.WON)) + } + + override fun getFinalGameResult(gameState: GameState, winner: GlobalSide?) = if (outcome == SubplotOutcome.UNDECIDED) + SubplotOutcome.LOST + else outcome + } + + @Serializable + class RecoverInformant private constructor(override val forPlayer: GlobalSide, private val onBoardShip: Id?, private val outcome: SubplotOutcome, private val mostRecentChatMessages: Moment?) : Subplot() { + constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED, null) + constructor(forPlayer: GlobalSide, onBoardShip: Id) : this(forPlayer, onBoardShip, SubplotOutcome.UNDECIDED, null) + + override val type: SubplotType + get() = SubplotType.RECOVER_INFORMANT + + override val displayName: String + get() = "Recover Informant" + + override fun displayObjective(gameState: GameState): GameObjective? { + val shipName = gameState.getShipInfoOrNull(onBoardShip ?: return null)?.fullName ?: return null + return GameObjective("Board the $shipName and recover your informant", outcome.toSuccess) + } + + override fun onAfterDeployShips(gameState: GameState): GameState { + if (gameState.ships[onBoardShip] != null) return gameState + + val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other } + val lowestEnemyShipTier = enemyShips.minOf { it.ship.shipType.weightClass } + val enemyShipsNotOfLowestTier = enemyShips.filter { it.ship.shipType.weightClass != lowestEnemyShipTier }.ifEmpty { enemyShips } + + val informantShip = enemyShipsNotOfLowestTier.random().id + return gameState.modifySubplotData(RecoverInformant(forPlayer, informantShip, SubplotOutcome.UNDECIDED, null)) + } + + private fun GameState.getNewMessages(readTime: Moment?) = if (readTime == null) + chatBox + else + chatBox.filter { it.sentAt > readTime } + + override fun onGameStateChanged(gameState: GameState): GameState { + if (outcome != SubplotOutcome.UNDECIDED) return gameState + + var readTime = mostRecentChatMessages + for (message in gameState.getNewMessages(mostRecentChatMessages)) { + when (message) { + is ChatEntry.ShipEscaped -> if (message.ship == onBoardShip) + return gameState.modifySubplotData(RecoverInformant(forPlayer, onBoardShip, SubplotOutcome.LOST, null)) + is ChatEntry.ShipDestroyed -> if (message.ship == onBoardShip) + return gameState.modifySubplotData(RecoverInformant(forPlayer, onBoardShip, SubplotOutcome.LOST, null)) + is ChatEntry.ShipBoarded -> if (message.ship == onBoardShip && (1..3).random() == 1) + return gameState.modifySubplotData(RecoverInformant(forPlayer, onBoardShip, SubplotOutcome.WON, null)) + else -> { + // do nothing + } + } + readTime = if (readTime == null || readTime < message.sentAt) message.sentAt else readTime + } + + return gameState.modifySubplotData(RecoverInformant(forPlayer, onBoardShip, SubplotOutcome.UNDECIDED, readTime)) + } + + override fun getFinalGameResult(gameState: GameState, winner: GlobalSide?) = if (outcome == SubplotOutcome.UNDECIDED) + SubplotOutcome.LOST + else outcome + } +} + +enum class SubplotType(val factory: (GlobalSide) -> Subplot) { + EXTENDED_DUTY(Subplot::ExtendedDuty), + NO_QUARTER(Subplot::NoQuarter), + VENDETTA(Subplot::Vendetta), + RECOVER_INFORMANT(Subplot::RecoverInformant), +} + +fun generateSubplots(battleSize: BattleSize, forPlayer: GlobalSide): Set = + (1..battleSize.numSubplotsPerPlayer).map { + SubplotType.values().random().factory(forPlayer) + }.toSet() + +@Serializable +enum class SubplotOutcome { + UNDECIDED, WON, LOST; + + val toSuccess: Boolean? + get() = when (this) { + UNDECIDED -> null + WON -> true + LOST -> false + } + + companion object { + fun fromBattleWinner(winner: GlobalSide?, subplotForPlayer: GlobalSide) = when (winner) { + subplotForPlayer -> WON + subplotForPlayer.other -> LOST + else -> UNDECIDED + } + } +} + +fun victoryTitle(player: GlobalSide, winner: GlobalSide?, subplotOutcomes: Map): String { + val myOutcomes = subplotOutcomes.filterKeys { it.player == player } + + return when (winner) { + player -> { + val isGlorious = myOutcomes.all { (_, outcome) -> outcome == SubplotOutcome.WON } + val isPyrrhic = myOutcomes.size >= 2 && myOutcomes.none { (_, outcome) -> outcome == SubplotOutcome.WON } + + if (isGlorious) + "Glorious Victory" + else if (isPyrrhic) + "Pyrrhic Victory" + else + "Victory" + } + player.other -> { + val isHeroic = myOutcomes.all { (_, outcome) -> outcome == SubplotOutcome.WON } + val isHumiliating = myOutcomes.size >= 2 && myOutcomes.none { (_, outcome) -> outcome == SubplotOutcome.WON } + + if (isHeroic) + "Heroic Defeat" + else if (isHumiliating) + "Humiliating Defeat" + else + "Defeat" + } + else -> "Stalemate" + } +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/game_time.kt b/src/commonMain/kotlin/net/starshipfights/game/game_time.kt new file mode 100644 index 0000000..ca00e24 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/game_time.kt @@ -0,0 +1,34 @@ +package net.starshipfights.game + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable(with = MomentSerializer::class) +expect class Moment(millis: Double) : Comparable { + fun toMillis(): Double + + override fun compareTo(other: Moment): Int + + companion object { + val now: Moment + } +} + +object MomentSerializer : KSerializer { + private val inner = Double.serializer() + + override val descriptor: SerialDescriptor + get() = inner.descriptor + + override fun serialize(encoder: Encoder, value: Moment) { + inner.serialize(encoder, value.toMillis()) + } + + override fun deserialize(decoder: Decoder): Moment { + return Moment(inner.deserialize(decoder)) + } +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/matchmaking.kt b/src/commonMain/kotlin/net/starshipfights/game/matchmaking.kt new file mode 100644 index 0000000..a3fda7f --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/matchmaking.kt @@ -0,0 +1,110 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id + +enum class BattleSize(val numPoints: Int, val maxWeightClass: ShipWeightClass, val displayName: String) { + SKIRMISH(600, ShipWeightClass.CRUISER, "Skirmish"), + RAID(800, ShipWeightClass.CRUISER, "Raid"), + FIREFIGHT(1000, ShipWeightClass.BATTLECRUISER, "Firefight"), + BATTLE(1300, ShipWeightClass.BATTLECRUISER, "Battle"), + GRAND_CLASH(1600, ShipWeightClass.BATTLESHIP, "Grand Clash"), + APOCALYPSE(2000, ShipWeightClass.BATTLESHIP, "Apocalypse"), + LEGENDARY_STRUGGLE(2400, ShipWeightClass.COLOSSUS, "Legendary Struggle"), + CRUCIBLE_OF_HISTORY(3000, ShipWeightClass.COLOSSUS, "Crucible of History"); +} + +val BattleSize.numSubplotsPerPlayer: Int + get() = when (this) { + BattleSize.SKIRMISH -> 0 + BattleSize.RAID -> 0 + BattleSize.FIREFIGHT -> 0 + BattleSize.BATTLE -> (0..1).random() + BattleSize.GRAND_CLASH -> 1 + BattleSize.APOCALYPSE -> 1 + BattleSize.LEGENDARY_STRUGGLE -> 1 + BattleSize.CRUCIBLE_OF_HISTORY -> (1..2).random() + } + +enum class BattleBackground(val displayName: String, val color: String) { + BLUE_BROWN("Milky Way", "#335577"), + BLUE_MAGENTA("Arcane Anomaly", "#553377"), + BLUE_PURPLE("Vensca Wormhole", "#444477"), + BLUE_GREEN("Radiation Risk", "#337755"), + GRAYBLUE_GRAYBROWN("Fulkreyksk Bloc", "#445566"), + MAGENTA_PURPLE("Aedon Vortex", "#773355"), + ORANGE_ORANGE("Solar Flare", "#775533"), + PURPLE_MAGENTA("Veil Rift", "#663366"), +} + +@Serializable +data class BattleInfo( + val size: BattleSize, + val bg: BattleBackground, +) + +// PACKETS +@Serializable +data class PlayerLogin( + val admiral: Id, + val login: LoginMode, +) + +@Serializable +sealed class LoginMode { + abstract val globalSide: GlobalSide? + + @Serializable + data class Train(val battleInfo: BattleInfo, val enemyFaction: Faction?) : LoginMode() { + override val globalSide: GlobalSide? + get() = null + } + + @Serializable + data class Host(val battleInfo: BattleInfo) : LoginMode() { + override val globalSide: GlobalSide + get() = GlobalSide.HOST + } + + @Serializable + object Join : LoginMode() { + override val globalSide: GlobalSide + get() = GlobalSide.GUEST + } +} + +@Serializable +data class GameReady(val connectToken: String) + +// HOST FLOW +@Serializable +data class JoinRequest( + val joiner: InGameAdmiral +) + +@Serializable +data class JoinResponse( + val accepted: Boolean +) + +@Serializable +data class JoinResponseResponse( + val connected: Boolean +) + +// GUEST FLOW +@Serializable +data class JoinListing( + val openGames: Map +) + +@Serializable +data class Joinable( + val admiral: InGameAdmiral, + val battleInfo: BattleInfo, +) + +@Serializable +data class JoinSelection( + val selectedId: String +) diff --git a/src/commonMain/kotlin/net/starshipfights/game/math.kt b/src/commonMain/kotlin/net/starshipfights/game/math.kt new file mode 100644 index 0000000..e89324c --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/math.kt @@ -0,0 +1,107 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline +import kotlin.math.* + +// PLAIN OLD 2D VECTORS + +@Serializable +data class Vec2(val x: Double, val y: Double) + +inline operator fun Vec2.plus(other: Vec2) = Vec2(x + other.x, y + other.y) +inline operator fun Vec2.minus(other: Vec2) = Vec2(x - other.x, y - other.y) +inline operator fun Vec2.times(scale: Double) = Vec2(x * scale, y * scale) +inline operator fun Vec2.div(scale: Double) = Vec2(x / scale, y / scale) + +inline operator fun Double.times(vec: Vec2) = vec * this + +inline operator fun Vec2.unaryPlus() = this +inline operator fun Vec2.unaryMinus() = this * -1.0 + +inline infix fun Vec2.dot(other: Vec2) = x * other.x + y * other.y +inline infix fun Vec2.cross(other: Vec2) = x * other.y - y * other.x + +inline infix fun Vec2.angleTo(other: Vec2) = atan2(this cross other, this dot other) +inline infix fun Vec2.angleBetween(other: Vec2) = abs(this angleTo other) + +inline infix fun Vec2.rotatedBy(angle: Double) = normalVector(angle).let { (c, s) -> Vec2(c * x - s * y, c * y + s * x) } + +inline fun normalVector(angle: Double) = Vec2(cos(angle), sin(angle)) +inline fun polarVector(radius: Double, angle: Double) = Vec2(radius * cos(angle), radius * sin(angle)) + +inline val Vec2.magnitude: Double + get() = hypot(x, y) + +inline val Vec2.angle: Double + get() = atan2(y, x) + +inline val Vec2.normal: Vec2 + get() { + val thisMagnitude = this.magnitude + return if (thisMagnitude < EPSILON) + Vec2(0.0, 0.0) + else this / thisMagnitude + } + +// AFFINE vs DISPLACEMENT QUANTITIES + +@Serializable +@JvmInline +value class Position(val vector: Vec2) + +@Serializable +@JvmInline +value class Distance(val vector: Vec2) + +inline operator fun Position.plus(distance: Distance) = Position(vector + distance.vector) +inline operator fun Distance.plus(position: Position) = Position(vector + position.vector) +inline operator fun Distance.plus(other: Distance) = Distance(vector + other.vector) + +inline operator fun Position.minus(relativeTo: Position) = Distance(vector - relativeTo.vector) +inline operator fun Position.minus(distance: Distance) = Position(vector - distance.vector) +inline operator fun Distance.minus(other: Distance) = Distance(vector - other.vector) + +inline operator fun Distance.times(scale: Double) = Distance(vector * scale) +inline operator fun Distance.div(scale: Double) = Distance(vector / scale) + +inline operator fun Double.times(dist: Distance) = dist * this +inline operator fun Double.div(dist: Distance) = dist / this + +inline operator fun Distance.unaryPlus() = this +inline operator fun Distance.unaryMinus() = Distance(-vector) + +inline infix fun Distance.dot(other: Distance) = vector dot other.vector +inline infix fun Distance.cross(other: Distance) = vector cross other.vector + +inline infix fun Distance.angleBetween(other: Distance) = vector angleBetween other.vector +inline infix fun Distance.angleTo(other: Distance) = vector angleTo other.vector + +inline infix fun Distance.rotatedBy(angle: Double) = Distance(vector rotatedBy angle) + +inline fun normalDistance(angle: Double) = Distance(normalVector(angle)) +inline fun polarDistance(radius: Double, angle: Double) = Distance(polarVector(radius, angle)) + +inline val Distance.length: Double + get() = vector.magnitude + +inline val Distance.angle: Double + get() = vector.angle + +inline val Distance.normal: Distance + get() = Distance(vector.normal) + +inline fun Position.clampOnLineSegment(a: Position, b: Position): Position { + val ab = b - a + val ar = this - a + + val abHat = ab.normal + val abLen = ab.length + + val proj = (ar dot abHat).coerceIn(0.0..abLen) + return proj * abHat + a +} + +inline fun Position.distanceToLineSegment(a: Position, b: Position) = (this - clampOnLineSegment(a, b)).length diff --git a/src/commonMain/kotlin/net/starshipfights/game/pick_bounds.kt b/src/commonMain/kotlin/net/starshipfights/game/pick_bounds.kt new file mode 100644 index 0000000..1ec2af6 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/pick_bounds.kt @@ -0,0 +1,224 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id +import kotlin.math.PI +import kotlin.math.abs + +fun FiringArc.getStartAngle(shipFacing: Double) = (when (this) { + FiringArc.BOW -> Vec2(1.0, -1.0) + FiringArc.ABEAM_PORT -> Vec2(-1.0, -1.0) + FiringArc.ABEAM_STARBOARD -> Vec2(1.0, 1.0) + FiringArc.STERN -> Vec2(-1.0, 1.0) +} rotatedBy shipFacing).angle + +fun FiringArc.getEndAngle(shipFacing: Double) = (when (this) { + FiringArc.BOW -> Vec2(1.0, 1.0) + FiringArc.ABEAM_PORT -> Vec2(1.0, -1.0) + FiringArc.ABEAM_STARBOARD -> Vec2(-1.0, 1.0) + FiringArc.STERN -> Vec2(-1.0, -1.0) +} rotatedBy shipFacing).angle + +fun GameState.isValidPick(request: PickRequest, response: PickResponse): Boolean { + if (request.type is PickType.Ship != response is PickResponse.Ship) + return false + + when (response) { + is PickResponse.Location -> { + if (request.type !is PickType.Location) return false + + if (response.position !in request.boundary) return false + if (ships.values.any { + it.id in request.type.excludesNearShips && (it.position.location - response.position).length <= SHIP_BASE_SIZE + }) return false + + return true + } + is PickResponse.Ship -> { + if (request.type !is PickType.Ship) return false + + if (response.id !in ships) return false + + val ship = ships.getValue(response.id) + if (ship.position.location !in request.boundary) return false + if (ship.owner !in request.type.allowSides) return false + + return true + } + } +} + +@Serializable +data class PickRequest(val type: PickType, val boundary: PickBoundary) + +@Serializable +sealed class PickResponse { + @Serializable + data class Location(val position: Position) : PickResponse() + + @Serializable + data class Ship(val id: Id) : PickResponse() +} + +@Serializable +sealed class PickType { + @Serializable + data class Location(val excludesNearShips: Set>, val helper: PickHelper, val drawLineFrom: Position? = null) : PickType() + + @Serializable + data class Ship(val allowSides: Set) : PickType() +} + +@Serializable +sealed class PickBoundary { + abstract operator fun contains(point: Position): Boolean + open fun normalize(point: Position) = point + + @Serializable + data class Angle( + val center: Position, + val midAngle: Double, + val maxAngle: Double + ) : PickBoundary() { + override fun contains(point: Position): Boolean { + val midNormal = normalDistance(midAngle) + return (point - center) angleBetween midNormal <= maxAngle + } + } + + @Serializable + data class AlongLine( + val pointA: Position, + val pointB: Position + ) : PickBoundary() { + override fun contains(point: Position) = true + + override fun normalize(point: Position): Position { + return point.clampOnLineSegment(pointA, pointB) + } + } + + @Serializable + data class Rectangle( + val center: Position, + val width2: Double, + val length2: Double + ) : PickBoundary() { + override fun contains(point: Position): Boolean { + return (point - center).vector.let { (x, y) -> + abs(x) <= width2 && abs(y) <= length2 + } + } + } + + @Serializable + data class Circle( + val center: Position, + val radius: Double, + ) : PickBoundary() { + override fun contains(point: Position): Boolean { + return (point - center).length < radius + } + } + + @Serializable + data class WeaponsFire( + val center: Position, + val facing: Double, + val minDistance: Double, + val maxDistance: Double, + val firingArcs: Set, + + val canSelfSelect: Boolean = false + ) : PickBoundary() { + override fun contains(point: Position): Boolean { + if (canSelfSelect && (point - center).length < EPSILON) + return true + + val r = point - center + if (r.length !in minDistance..maxDistance) + return false + + val rHat = r.normal + val thetaHat = normalDistance(facing) + + val deltaTheta = thetaHat angleTo rHat + val firingArc: FiringArc = when { + abs(deltaTheta) < PI / 4 -> FiringArc.BOW + abs(deltaTheta) > PI * 3 / 4 -> FiringArc.STERN + deltaTheta < 0 -> FiringArc.ABEAM_PORT + else -> FiringArc.ABEAM_STARBOARD + } + + return firingArc in firingArcs + } + } +} + +@Serializable +sealed class PickHelper { + @Serializable + object None : PickHelper() + + @Serializable + data class Ship(val type: ShipType, val facing: Double) : PickHelper() + + @Serializable + data class Circle(val radius: Double) : PickHelper() +} + +fun PickBoundary.closestPointTo(position: Position): Position = when (this) { + is PickBoundary.AlongLine -> position.clampOnLineSegment(pointA, pointB) + is PickBoundary.Angle -> { + val distance = position - center + val midNormal = normalDistance(midAngle) + + if ((distance angleBetween midNormal) <= maxAngle) + position + else + ((midNormal rotatedBy (midNormal angleTo distance).coerceIn(-maxAngle..maxAngle)) * distance.length) + center + } + is PickBoundary.Circle -> { + val distance = position - center + if (distance.length <= radius) + position + else + (distance.normal * radius) + center + } + is PickBoundary.Rectangle -> { + Distance((position - center).vector.let { (x, y) -> + Vec2(x.coerceIn(-width2..width2), y.coerceIn(-length2..length2)) + }) + center + } + is PickBoundary.WeaponsFire -> { + val distance = position - center + + val thetaHat = normalDistance(facing) + + val deltaTheta = thetaHat angleTo distance + val firingArc: FiringArc = when { + abs(deltaTheta) < PI / 4 -> FiringArc.BOW + abs(deltaTheta) > PI * 3 / 4 -> FiringArc.STERN + deltaTheta < 0 -> FiringArc.ABEAM_PORT + else -> FiringArc.ABEAM_STARBOARD + } + + if (firingArc in firingArcs) { + if (distance.length in minDistance..maxDistance) + position + else + (distance.normal * (if (distance.length < minDistance) minDistance else maxDistance)) + center + } else + firingArcs.flatMap { + val startNormal = normalDistance(it.getStartAngle(facing)) + val endNormal = normalDistance(it.getEndAngle(facing)) + + listOf( + (startNormal * minDistance) + center, + (endNormal * minDistance) + center, + (startNormal * maxDistance) + center, + (endNormal * maxDistance) + center, + ) + }.minByOrNull { (it - position).length } ?: position + } +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship.kt b/src/commonMain/kotlin/net/starshipfights/game/ship.kt new file mode 100644 index 0000000..a1eaf9b --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship.kt @@ -0,0 +1,231 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id +import kotlin.math.PI + +@Serializable +data class Ship( + val id: Id, + + val name: String, + val shipType: ShipType +) { + val fullName: String + get() = "${shipType.faction.shipPrefix}$name" + + val pointCost: Int + get() = shipType.pointCost + + val reactor: ShipReactor + get() = shipType.weightClass.reactor + + val movement: ShipMovement + get() = shipType.weightClass.movement + + val durability: ShipDurability + get() = shipType.weightClass.durability + + val firepower: ShipFirepower + get() = shipType.weightClass.firepower + + val armaments: ShipArmaments + get() = shipType.armaments + + val hasShields: Boolean + get() = shipType.faction != Faction.FELINAE_FELICES + + val canUseInertialessDrive: Boolean + get() = shipType.faction == Faction.FELINAE_FELICES + + val canUseDisruptionPulse: Boolean + get() = shipType.faction == Faction.FELINAE_FELICES + + val canUseRecoalescence: Boolean + get() = shipType.faction == Faction.FELINAE_FELICES +} + +@Serializable +sealed class ShipReactor + +@Serializable +data class StandardShipReactor( + val subsystemAmount: Int, + val gridEfficiency: Int +) : ShipReactor() { + val powerOutput: Int + get() = subsystemAmount * 3 +} + +@Serializable +object FelinaeShipReactor : ShipReactor() + +val ShipWeightClass.reactor: ShipReactor + get() = when (this) { + ShipWeightClass.ESCORT -> StandardShipReactor(2, 1) + ShipWeightClass.DESTROYER -> StandardShipReactor(3, 1) + ShipWeightClass.CRUISER -> StandardShipReactor(4, 2) + ShipWeightClass.BATTLECRUISER -> StandardShipReactor(6, 3) + ShipWeightClass.BATTLESHIP -> StandardShipReactor(7, 4) + + ShipWeightClass.BATTLE_BARGE -> StandardShipReactor(5, 3) + + ShipWeightClass.GRAND_CRUISER -> StandardShipReactor(6, 4) + ShipWeightClass.COLOSSUS -> StandardShipReactor(9, 6) + + ShipWeightClass.FF_ESCORT -> FelinaeShipReactor + ShipWeightClass.FF_DESTROYER -> FelinaeShipReactor + ShipWeightClass.FF_CRUISER -> FelinaeShipReactor + ShipWeightClass.FF_BATTLECRUISER -> FelinaeShipReactor + ShipWeightClass.FF_BATTLESHIP -> FelinaeShipReactor + + ShipWeightClass.AUXILIARY_SHIP -> StandardShipReactor(2, 1) + ShipWeightClass.LIGHT_CRUISER -> StandardShipReactor(3, 1) + ShipWeightClass.MEDIUM_CRUISER -> StandardShipReactor(4, 2) + ShipWeightClass.HEAVY_CRUISER -> StandardShipReactor(6, 3) + + ShipWeightClass.FRIGATE -> StandardShipReactor(4, 1) + ShipWeightClass.LINE_SHIP -> StandardShipReactor(6, 3) + ShipWeightClass.DREADNOUGHT -> StandardShipReactor(8, 5) + } + +@Serializable +sealed class ShipMovement { + abstract val turnAngle: Double + abstract val moveSpeed: Double +} + +@Serializable +data class StandardShipMovement( + override val turnAngle: Double, + override val moveSpeed: Double, +) : ShipMovement() + +@Serializable +data class FelinaeShipMovement( + override val turnAngle: Double, + override val moveSpeed: Double, + val inertialessDriveRange: Double, + val inertialessDriveShots: Int +) : ShipMovement() + +val ShipWeightClass.movement: ShipMovement + get() = when (this) { + ShipWeightClass.ESCORT -> StandardShipMovement(PI / 2, 2500.0) + ShipWeightClass.DESTROYER -> StandardShipMovement(PI / 2, 2200.0) + ShipWeightClass.CRUISER -> StandardShipMovement(PI / 3, 1900.0) + ShipWeightClass.BATTLECRUISER -> StandardShipMovement(PI / 3, 1900.0) + ShipWeightClass.BATTLESHIP -> StandardShipMovement(PI / 4, 1600.0) + + ShipWeightClass.BATTLE_BARGE -> StandardShipMovement(PI / 4, 1600.0) + + ShipWeightClass.GRAND_CRUISER -> StandardShipMovement(PI / 4, 1750.0) + ShipWeightClass.COLOSSUS -> StandardShipMovement(PI / 6, 1300.0) + + ShipWeightClass.FF_ESCORT -> FelinaeShipMovement(PI / 3, 1600.0, 4000.0, 1) + ShipWeightClass.FF_DESTROYER -> FelinaeShipMovement(PI / 4, 1400.0, 3750.0, 1) + ShipWeightClass.FF_CRUISER -> FelinaeShipMovement(PI / 6, 1200.0, 3250.0, 1) + ShipWeightClass.FF_BATTLECRUISER -> FelinaeShipMovement(PI / 6, 1200.0, 3000.0, 2) + ShipWeightClass.FF_BATTLESHIP -> FelinaeShipMovement(PI / 8, 800.0, 2500.0, 2) + + ShipWeightClass.AUXILIARY_SHIP -> StandardShipMovement(PI / 2, 2500.0) + ShipWeightClass.LIGHT_CRUISER -> StandardShipMovement(PI / 2, 2250.0) + ShipWeightClass.MEDIUM_CRUISER -> StandardShipMovement(PI / 3, 2000.0) + ShipWeightClass.HEAVY_CRUISER -> StandardShipMovement(PI / 3, 1750.0) + + ShipWeightClass.FRIGATE -> StandardShipMovement(PI * 2 / 3, 2750.0) + ShipWeightClass.LINE_SHIP -> StandardShipMovement(PI / 2, 2250.0) + ShipWeightClass.DREADNOUGHT -> StandardShipMovement(PI / 3, 1750.0) + } + +@Serializable +sealed class ShipDurability { + abstract val maxHullPoints: Int + abstract val turretDefense: Double + abstract val troopsDefense: Int +} + +@Serializable +data class StandardShipDurability( + override val maxHullPoints: Int, + override val turretDefense: Double, + override val troopsDefense: Int, + val repairTokens: Int, +) : ShipDurability() + +@Serializable +data class FelinaeShipDurability( + override val maxHullPoints: Int, + override val troopsDefense: Int, + val disruptionPulseRange: Double, + val disruptionPulseShots: Int +) : ShipDurability() { + override val turretDefense: Double + get() = 0.0 +} + +val ShipWeightClass.durability: ShipDurability + get() = when (this) { + ShipWeightClass.ESCORT -> StandardShipDurability(4, 0.5, 5, 1) + ShipWeightClass.DESTROYER -> StandardShipDurability(8, 0.5, 7, 1) + ShipWeightClass.CRUISER -> StandardShipDurability(12, 1.0, 10, 2) + ShipWeightClass.BATTLECRUISER -> StandardShipDurability(14, 1.5, 10, 2) + ShipWeightClass.BATTLESHIP -> StandardShipDurability(16, 2.0, 15, 3) + + ShipWeightClass.BATTLE_BARGE -> StandardShipDurability(16, 1.5, 15, 3) + + ShipWeightClass.GRAND_CRUISER -> StandardShipDurability(15, 1.75, 12, 3) + ShipWeightClass.COLOSSUS -> StandardShipDurability(27, 3.0, 25, 4) + + ShipWeightClass.FF_ESCORT -> FelinaeShipDurability(6, 3, 1000.0, 3) + ShipWeightClass.FF_DESTROYER -> FelinaeShipDurability(9, 4, 1000.0, 4) + ShipWeightClass.FF_CRUISER -> FelinaeShipDurability(12, 5, 750.0, 2) + ShipWeightClass.FF_BATTLECRUISER -> FelinaeShipDurability(15, 6, 875.0, 2) + ShipWeightClass.FF_BATTLESHIP -> FelinaeShipDurability(18, 7, 1250.0, 3) + + ShipWeightClass.AUXILIARY_SHIP -> StandardShipDurability(4, 2.0, 6, 1) + ShipWeightClass.LIGHT_CRUISER -> StandardShipDurability(8, 3.0, 9, 2) + ShipWeightClass.MEDIUM_CRUISER -> StandardShipDurability(12, 3.5, 12, 2) + ShipWeightClass.HEAVY_CRUISER -> StandardShipDurability(16, 4.0, 15, 3) + + ShipWeightClass.FRIGATE -> StandardShipDurability(10, 1.5, 7, 1) + ShipWeightClass.LINE_SHIP -> StandardShipDurability(15, 2.0, 9, 1) + ShipWeightClass.DREADNOUGHT -> StandardShipDurability(20, 2.5, 11, 1) + } + +@Serializable +data class ShipFirepower( + val rangeMultiplier: Double, + val criticalChance: Double, + val cannonAccuracy: Double, + val lanceCharging: Double, +) + +val ShipWeightClass.firepower: ShipFirepower + get() = when (this) { + ShipWeightClass.ESCORT -> ShipFirepower(0.75, 0.75, 0.875, 0.875) + ShipWeightClass.DESTROYER -> ShipFirepower(0.75, 0.75, 1.0, 1.0) + ShipWeightClass.CRUISER -> ShipFirepower(1.0, 1.0, 1.0, 1.0) + ShipWeightClass.BATTLECRUISER -> ShipFirepower(1.25, 1.25, 1.25, 1.25) + ShipWeightClass.BATTLESHIP -> ShipFirepower(1.25, 1.25, 1.25, 1.25) + + ShipWeightClass.BATTLE_BARGE -> ShipFirepower(1.25, 1.25, 1.25, 1.25) + + ShipWeightClass.GRAND_CRUISER -> ShipFirepower(1.25, 1.25, 1.25, 1.25) + ShipWeightClass.COLOSSUS -> ShipFirepower(1.5, 1.5, 1.5, 1.5) + + ShipWeightClass.FF_ESCORT -> ShipFirepower(1.0, 0.6, 0.5, -1.0) + ShipWeightClass.FF_DESTROYER -> ShipFirepower(1.0, 0.8, 0.625, -1.0) + ShipWeightClass.FF_CRUISER -> ShipFirepower(1.0, 1.0, 0.75, -1.0) + ShipWeightClass.FF_BATTLECRUISER -> ShipFirepower(1.0, 1.0, 0.875, -1.0) + ShipWeightClass.FF_BATTLESHIP -> ShipFirepower(1.5, 1.2, 1.0, -1.0) + + ShipWeightClass.AUXILIARY_SHIP -> ShipFirepower(1.0, 1.0, 1.0, 1.0) + ShipWeightClass.LIGHT_CRUISER -> ShipFirepower(1.0, 1.0, 1.0, 1.0) + ShipWeightClass.MEDIUM_CRUISER -> ShipFirepower(1.0, 1.0, 1.0, 1.0) + ShipWeightClass.HEAVY_CRUISER -> ShipFirepower(1.0, 1.0, 1.0, 1.0) + + ShipWeightClass.FRIGATE -> ShipFirepower(0.8, 0.8, 1.0, 1.0) + ShipWeightClass.LINE_SHIP -> ShipFirepower(1.0, 1.0, 1.0, 1.0) + ShipWeightClass.DREADNOUGHT -> ShipFirepower(1.2, 1.2, 1.0, 1.0) + } diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_boarding.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_boarding.kt new file mode 100644 index 0000000..599e299 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_boarding.kt @@ -0,0 +1,169 @@ +package net.starshipfights.game + +import net.starshipfights.data.Id +import kotlin.math.roundToInt + +fun factionBoardingModifier(faction: Faction): Int = when (faction) { + Faction.MECHYRDIA -> 7 + Faction.NDRC -> 10 + Faction.MASRA_DRAETSEN -> 8 + Faction.FELINAE_FELICES -> 0 + Faction.ISARNAREYKK -> 3 + Faction.VESTIGIUM -> 2 +} + +fun weightClassBoardingModifier(weightClass: ShipWeightClass): Int = when (weightClass) { + ShipWeightClass.ESCORT -> 2 + ShipWeightClass.DESTROYER -> 2 + ShipWeightClass.CRUISER -> 4 + ShipWeightClass.BATTLECRUISER -> 4 + ShipWeightClass.BATTLESHIP -> 6 + + ShipWeightClass.BATTLE_BARGE -> 8 + + ShipWeightClass.GRAND_CRUISER -> 6 + ShipWeightClass.COLOSSUS -> 10 + + ShipWeightClass.FF_ESCORT -> 0 + ShipWeightClass.FF_DESTROYER -> 2 + ShipWeightClass.FF_CRUISER -> 2 + ShipWeightClass.FF_BATTLECRUISER -> 4 + ShipWeightClass.FF_BATTLESHIP -> 6 + + ShipWeightClass.AUXILIARY_SHIP -> 0 + ShipWeightClass.LIGHT_CRUISER -> 2 + ShipWeightClass.MEDIUM_CRUISER -> 4 + ShipWeightClass.HEAVY_CRUISER -> 6 + + ShipWeightClass.FRIGATE -> 0 + ShipWeightClass.LINE_SHIP -> 2 + ShipWeightClass.DREADNOUGHT -> 4 +} + +fun troopsBoardingModifier(troopsAmount: Int, totalTroops: Int): Int = when { + troopsAmount < totalTroops / 3 -> 0 + troopsAmount < (totalTroops * 2) / 3 -> 2 + troopsAmount < totalTroops -> 3 + troopsAmount == totalTroops -> 4 + else -> 4 +} + +fun hullBoardingModifier(hullAmount: Int, totalHull: Int): Int = when { + hullAmount < totalHull / 2 -> 1 + hullAmount < totalHull -> 3 + hullAmount == totalHull -> 5 + else -> 5 +} + +fun turretsBoardingModifier(turretsDefense: Double, turretsStatus: ShipModuleStatus): Int = when (turretsStatus) { + ShipModuleStatus.INTACT -> turretsDefense.roundToInt() + ShipModuleStatus.DAMAGED -> (turretsDefense * 0.5).roundToInt() + else -> 0 +} + +fun shieldsBoardingAssaultModifier(shieldsAmount: Int, totalShields: Int): Int = when { + shieldsAmount == 0 -> 2 + shieldsAmount < totalShields -> 1 + else -> 0 +} + +fun shieldsBoardingDefenseModifier(shieldsAmount: Int, totalShields: Int): Int = when { + shieldsAmount == 0 -> 0 + shieldsAmount <= totalShields / 2 -> 1 + shieldsAmount < totalShields -> 2 + else -> 3 +} + +fun assaultBoardingModifier(assaultModuleStatus: ShipModuleStatus): Int = when (assaultModuleStatus) { + ShipModuleStatus.INTACT -> 5 + ShipModuleStatus.DAMAGED -> 3 + ShipModuleStatus.DESTROYED -> 0 + else -> 0 +} + +fun defenseBoardingModifier(defenseModuleStatus: ShipModuleStatus): Int = when (defenseModuleStatus) { + ShipModuleStatus.INTACT -> 3 + ShipModuleStatus.DAMAGED -> 2 + ShipModuleStatus.DESTROYED -> 0 + else -> 0 +} + +val ShipInstance.assaultModifier: Int + get() = listOf( + factionBoardingModifier(ship.shipType.faction), + weightClassBoardingModifier(ship.shipType.weightClass), + troopsBoardingModifier(troopsAmount, durability.troopsDefense), + hullBoardingModifier(hullAmount, durability.maxHullPoints), + turretsBoardingModifier(durability.turretDefense, modulesStatus[ShipModule.Turrets]), + if (canUseShields) + shieldsBoardingAssaultModifier(shieldAmount, powerMode.shields) + else shieldsBoardingAssaultModifier(0, powerMode.shields), + assaultBoardingModifier(modulesStatus[ShipModule.Assault]), + ).sum() + +val ShipInstance.defenseModifier: Int + get() = listOf( + factionBoardingModifier(ship.shipType.faction), + weightClassBoardingModifier(ship.shipType.weightClass), + troopsBoardingModifier(troopsAmount, durability.troopsDefense), + hullBoardingModifier(hullAmount, durability.maxHullPoints), + turretsBoardingModifier(durability.turretDefense, modulesStatus[ShipModule.Turrets]), + if (canUseShields) + shieldsBoardingDefenseModifier(shieldAmount, powerMode.shields) + else shieldsBoardingDefenseModifier(0, powerMode.shields), + defenseBoardingModifier(modulesStatus[ShipModule.Defense]), + ).sum() + +fun boardingRoll(): Int = (0..4).random() + (0..4).random() + +fun ShipInstance.board(defender: ShipInstance): ImpactResult { + val myValue = assaultModifier + boardingRoll() + val otherValue = defender.defenseModifier + boardingRoll() + + return when { + otherValue * 2 < myValue -> { + when (val firstImpact = ImpactResult.Intact(defender).withCritResult(defender.doCriticalDamage())) { + is ImpactResult.Damaged -> firstImpact.withCritResult(firstImpact.ship.doCriticalDamage()) + else -> firstImpact + } + } + otherValue <= myValue -> { + ImpactResult.Intact(defender).withCritResult(defender.doCriticalDamage()) + } + else -> { + val troopsKilled = (1..(myValue / 2)).randomOrNull() ?: 0 + ImpactResult.Intact(defender).withCritResult(defender.killTroops(troopsKilled)) + } + } +} + +fun ShipInstance.afterBoarding() = if (troopsAmount <= 1) null else copy( + troopsAmount = troopsAmount - 1, + hasSentBoardingParty = true, +) + +fun reportBoardingResult(impactResult: ImpactResult, attacker: Id) = when (impactResult) { + is ImpactResult.Destroyed -> ChatEntry.ShipDestroyed( + ship = impactResult.ship.id, + sentAt = Moment.now, + destroyedBy = ShipAttacker.EnemyShip(attacker) + ) + is ImpactResult.Damaged -> ChatEntry.ShipBoarded( + ship = impactResult.ship.id, + boarder = attacker, + sentAt = Moment.now, + critical = impactResult.critical.report(), + damageAmount = impactResult.damage.amount + ) +} + +fun ShipInstance.getBoardingPickRequest() = PickRequest( + PickType.Ship(allowSides = setOf(owner.other)), + PickBoundary.WeaponsFire( + center = position.location, + facing = position.facing, + minDistance = SHIP_BASE_SIZE, + maxDistance = firepower.rangeMultiplier * SHIP_TRANSPORTARIUM_RANGE, + firingArcs = FiringArc.FIRE_FORE_270, + ) +) diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_factions.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_factions.kt new file mode 100644 index 0000000..8074813 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_factions.kt @@ -0,0 +1,218 @@ +package net.starshipfights.game + +import kotlinx.html.TagConsumer +import kotlinx.html.i +import kotlinx.html.p + +enum class Faction( + val shortName: String, + val shortNameIsDefinite: Boolean, + val navyName: String, + val polityName: String, + val adjective: String, + val currencyName: String, + val shipPrefix: String, + val blurbDesc: TagConsumer<*>.() -> Unit, +) { + MECHYRDIA( + shortName = "Mechyrdia", + shortNameIsDefinite = false, + navyName = "Mechyrdian Star Fleet", + polityName = "Empire of Mechyrdia", + adjective = "Mechyrdian", + currencyName = "thrones", + shipPrefix = "CMŠ ", // Ciarstuos Mehurdiasi Štelnau + blurbDesc = { + p { + +"Having spent much of its history coming under threat from oppressive theocracies, conquering hordes, rebelling sectors, and invading syndicalists, the Empire of Mechyrdia now enjoys a place in the stars as the foremost power of the galaxy." + } + p { + +"Do not be confused by the name \"Empire\", Mechyrdia is a free and liberal democratic republic. While they once had an emperor, Nicólei the First and Only, he declared that the people of Mechyrdia would inherit the throne, thus abolishing the monarchy upon his death. Now the Empire runs on a semi-presidential democracy; the government does not have any office named \"President\", rather there is a Chancellor, the head of state who is elected by the people, and a Prime Minister, the head of government who is appointed by the Chancellor and confirmed by the tricameral Senate. " + } + p { + +"But things are not so ideal for Mechyrdia. The western menace, the Diadochus Masra Draetsen, threatens to upend this peaceful order and conquer Mechyrdia, to succeed where their predecessors the Arkant Horde had failed. Their new leader, Ogus Khan, has made many connections with the disgraced nations of the galaxy, and will stop at nothing to see Mechyrdia fall. Isarnareykk is making waves in its neighboring states of Theudareykk and Stahlareykk, states that are now within Mechyrdia's sphere of influence. Vestigium forces are being spotted in deep space throughout the Empire, and the Corvus Cluster sect has ended its radio silence." + } + p { + +"External problems are not the only issues faced by the Empire. Mechyrdia is also having internal troubles - corruption, erosion of liberty, concentration of wealth and power into an oligarchic elite - all problems that the current Chancellor, Marc Adlerówič Basileiów, and his populist Freedom Party are trying to fix. But his solutions are not without opposition, as various sectors of the Empire: Calibor, Vescar, Texandria, among others, are waging a campaign of passive resistance against Basileiów and his populist Chancery." + } + p { + +"It is the eleventh hour for the Empire of Mechyrdia; shall they enter a new golden age, or a new dark age? Only time will tell." + } + }, + ), + NDRC( + shortName = "NdRC", + shortNameIsDefinite = true, + navyName = "Dutch Marines", + polityName = "Dutch Outer Space Company", + adjective = "Dutch", + currencyName = "guldens", + shipPrefix = "NKS ", // Nederlandse Koopvaardijschip + blurbDesc = { + p { + +"The history of the Dutch Outer Space Company (Dutch: " + foreign("nl") { +"Nederlandse der Ruimte Compagnie" } + +") extends almost as far back as that of the American Vestigium. Founded in 2079 to provide space-colonization services to the European continent, the Dutch Outer Space Company has come into frequent conflict with the Imperial States of America." + } + p { + +"They survived during, and fought back against, the Drakhassi and Tylan occupations, waging a guerilla war against the oppressive regimes, as well as supplying other local humans with weapons to rebel too. In doing so, they put aside their differences with the Americans and formed a united front." + } + p { + +"Now, the Dutch Outer Space Company prospers, and so too do their business partners: the Empire of Mechyrdia. But with the imperilment of Mechyrdia to threats both within and without, the Company finds itself in the same danger. Shall it be liberty, or shall it be death?" + } + p { + i { +"Gameside note: Dutch admirals may purchase ships from other factions at a marked-up price, in addition to ships from their own faction." } + } + }, + ), + MASRA_DRAETSEN( + shortName = "Masra Draetsen", + shortNameIsDefinite = true, + navyName = "Masra Draetsen Khoy'qan", + polityName = "Diadochus Masra Draetsen", + adjective = "Diadochi", + currencyName = "sylaphs", + shipPrefix = "", // The Diadochi don't use ship prefixes + blurbDesc = { + p { + +"The Arkant Horde was once the greatest power of its time. Having conquered half of the galaxy in less than a decade, blessed by their dark god Aedon, the end of the Horde came when the Mechyrdians' trickery resulted in the death of the Great Khagan, and the Arkant Horde broke into hundreds of petty, feuding Diadochi." + } + p { + +"But now, one of these Diadochi has come to the forefront: the Diadochus Masra Draetsen. Known by their friends as freedom-fighters or liberators, and by their enemies as terrorists or barbarian khans from the galactic west, the Masra Draetsen rose to prominence under their current leader Ogus Khan." + } + p { + +"Having conquered 87 other Diadochi star-tribes, Ogus is making alliances with the various oppressed nations and outcast civilizations of the galaxy; groups as diverse as Isarnareyksk tech barons, Vestigium sects, Ilkhan syndicalist intellectuals, Ferthlon rebel remnants, and Olympian pagan elites, have all flocked to the cause of the Masra Draetsen. Soon, the conquest of Mechyrdia will begin. May there be woe to the vanquished!" + } + }, + ), + FELINAE_FELICES( + shortName = "Felinae Felices", + shortNameIsDefinite = true, + navyName = "Felinae Felices", + polityName = "Felinae Felices", + adjective = "Felinae", + currencyName = "thrones", + shipPrefix = "NFF ", // Navis Felinarum Felicium + blurbDesc = { + p { + +"The " + foreign("la") { +"Felinae Felices" } + +" (fey-LEE-nye fey-LEE-case) are quite the unusual power among the stars. Not a proper nation or state, the " + foreign("la") { +"Felinae" } + +" are an organized crime syndicate originating in the Mechyrdian sector of Olympia. They are the second most powerful mafia-like organization in the Empire, second to only their allies of convenience, the " + foreign("la") { +"Res Nostra" } + +"." + } + p { + +"Formerly a rival of the " + foreign("la") { +"Res Nostra" } + +", the " + foreign("la") { +"Felinae Felices" } + +" have turned their attitude 180-degrees under their new " + foreign("la") { +"Maxima" } + +", Tanaquil Cassia Pulchra. Now, the " + foreign("la") { +"Felinae" } + +" work as shipbuilders for the " + foreign("la") { +"Res Nostra" } + +" and other crime syndicates in need of starship fleets, though many are unhappy with the ships they receive, since the " + foreign("la") { +"Felinae" } + +" only build cat-themed starships with very little in the way of customizability." + } + p { + +"While the " + foreign("la") { +"Res Nostra" } + +" maintain good publicity by being charitable to poor individuals, they do not share this same attitude with competing organizations. The primary reason why they accepted the offer to ally with the " + foreign("la") { +"Felinae Felices" } + +" is because the " + foreign("la") { +"Felinae" } + +" are one of the most technologically-advanced organizations in the galaxy. " + foreign("la") { +"Felinae" } + +" ships have inertialess drives like the Vestigium, but unlike the Vestigium, the syndicate's ships can activate it anywhere, even inside the gravity wells of star systems. Advanced relativistic armor that denies more damage the faster the ship is moving, and weapons such as Particle Claws that can deal multiple critical hits in a single attack, and Lightning Yarn that ignores shields entirely, represent the peak of " + foreign("la") { +"Felinae" } + +" high technology." + } + p { + +"The " + foreign("la") { +"Felinae Felices" } + +" are a rather secretive organization. The people who observe them, whether they be high-ranking members of anti-mafia organizations or obsessive conspiracy theorists, speculate on how the syndicate gains new members: some believe that the " + foreign("la") { +"Felinae" } + +" kidnap, gene-mod, and brainwash people into serving them. Others think that the " + foreign("la") { +"Felinae" } + +" invite prominent political figures to join them, offering great power similar to what the Freesysadmins do. No one truly knows what the origin or grand goal of the " + foreign("la") { +"Felinae Felices" } + +" is. The only thing that is known for certain, is that their cat-themed starships are making more and more frequent appearances throughout deep space." + } + }, + ), + ISARNAREYKK( + shortName = "Isarnareykk", + shortNameIsDefinite = false, + navyName = "Isarnareyksk Styurnamariyn", + polityName = "Isarnareyksk Federation", + adjective = "Isarnareyksk", + currencyName = "marks", + shipPrefix = "ISS ", // Isarnareyksk Styurnamariyn nu Skyf + blurbDesc = { + p { + +"The Isarnareyksk Federation is the largest and most populous successor state to the Fulkreyksk Authoritariat. A shadow of its former glory, Isarnareykk is led by Faurasitand Demeter Ursalia and ruled by dissenting factions such as the tech barons and the revanchist military, that hate each other more than they hate Ursalia." + } + p { + +"The Fulkreyksk Authoritariat was one of the oldest civilizations in galactic history, second (within the current cycle, at least) to only the Drakhassi Federation. Early on in Fulkreyksk history, their first Forarr, Vrankenn Kassck, developed the ideology that would characterize the Authoritariat for the rest of its existence: entire cadres of the population would be genetically modified to fit into a randomly-chosen caste: leaders, speakers, bureaucrats, enforcers, warriors, and laborers - families were assigned at random to one of these, and then would receive a retroviral injection to enhance the traits relevant to that caste's work." + } + p { + +"Under their fourth Forarr, Praethoris Khorr, Fulkreykk defeated the daemon warlord Aedonau, who had previously been ravaging the northern half of Drakhassi space. Their next Forarr, Toval Brekoryn, conquered the alien races to the galactic south-east: the Ilkhans, Niska, and Tylans; Brekoryn also reversed some of the totalitarian centralizations that Khorr had instated. Serna Pertona reinstated those Khorrian reforms, which Kor Tefran continued. Eventually, the final Forarr of the First Authoritariat, Augast Voss, would lead Fulkreykk to its demise, and the humans of the galactic north would isolate their entire civilization for over a millennium." + } + p { + +"Fulkreykk returned to galactic politics during the Great Galactic War between the Empire of Mechyrdia and the Ilkhan Commune. The Second Authoritariat invaded the Commune from the north, opening another front that allowed the Mechyrdians to counterattack into the eastern Tylan space. Fulkreyksk and Mechyrdian fleets met at the Ilkhai system, and the space of the Commune was partitioned into a northern, Fulkreykk-aligned Ilkhan Potentate, and a southern Mechyrdia-aligned Ilkhan Republic. A cold war ensued between Fulkreykk and Mechyrdia, resulting in the collapse of the Second Authoritariat. Now, Isarnareykk is left to either pick up the pieces, or forge its own legacy independent of the Fulkreyksk shadow." + } + p { + +"Isarnareykk is at a crossroads now. Shall they embrace democracy and join forces with Mechyrdia? Shall they give the Faurasitand a perpetual dictatorship to end the crisis? Or shall one of the Federation's factions win out: the military reclaiming the former glory of Fulkreykk, or the tech barons to gain fatter and fatter profits?" + } + }, + ), + VESTIGIUM( + shortName = "Vestigium", + shortNameIsDefinite = true, + navyName = "Imperial States Space Force", + polityName = "Imperial States of America", + adjective = "American", + currencyName = "dollars", + shipPrefix = "ASC ", // American Space Craft + blurbDesc = { + p { + +"The Imperial States of America was once the political hyperpower of Earth and beyond, and the ideological bulwark of the Caesarism of its time. They were strong, they were proud... they were hated. Hated to the point that entire nations fled from Earth and colonized the stars just to escape American hegemony." + } + p { + +"The American Empire has its origins in the Second American Civil War, which saw the fall of the American Republic to the first American Emperor, Jack G. Coleman. The Empire was became the new shining city on a hill, the brightest example of what the strong leadership of Caesarism can accomplish. Under Emperor Trevor Neer, the American Empire reached its greatest extent, both in size of territory, and in prosperity. From there, things could only get worse." + } + p { + +"The Second Protestant Reformation started in the mid-21st century AD. At first, it was suppressed by the American government, peaking with Emperor Dio Audrey. However, it would become legal under the first Neoprotestant Emperor, Connor Vance, who also founded the new capital of the Empire in Connor City, on top of old Toronto, which has once been a part of Canada before the conquest of the North." + } + p { + +"Experts within the imperial government realized that the American Empire would fall just like the Roman Empire did, and so they hatched a plan. Creating a secret organization called the Vestigium, these experts evacuated top government and intelligence officials off of Earth, to starbases and planetary colonies operated by the Imperial States Space Force. Eventually, the American Empire finally fell to warlord Odoacro Grande, founder of the " + i { +"Reino de Columbia" } + +"." + } + p { + +"The American Empire has been fallen for a long time to barbarian warlords, and its homeworld Earth has been turned into a historical site by the Mechyrdian government. But the government lives on; hidden away in secret space stations, they desire nothing less than to conquer the stars and establish a ten-thousand-year empire." + } + }, + ); + + val flagUrl: String + get() = "/static/images/flag/${toUrlSlug()}.svg" +} + +fun Faction.getDefiniteShortName(capitalized: Boolean = false) = if (shortNameIsDefinite) { + (if (capitalized) "The " else "the ") + shortName +} else shortName + +val Faction.meshTag: String + get() = when (this) { + Faction.MECHYRDIA -> "mechyrdia" + Faction.NDRC -> "ndrc" + Faction.MASRA_DRAETSEN -> "diadochi" + Faction.FELINAE_FELICES -> "felinae" + Faction.ISARNAREYKK -> "fulkreykk" + Faction.VESTIGIUM -> "usa" + } diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_instances.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_instances.kt new file mode 100644 index 0000000..c3b5d48 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_instances.kt @@ -0,0 +1,278 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id +import kotlin.math.abs +import kotlin.math.pow +import kotlin.math.sqrt + +@Serializable +data class ShipInstance( + val ship: Ship, + val owner: GlobalSide, + val position: ShipPosition, + + val isIdentified: Boolean = false, + + val isDoneCurrentPhase: Boolean = true, + + val powerMode: ShipPowerMode = ship.defaultPowerMode(), + + val weaponAmount: Int = powerMode.weapons, + val shieldAmount: Int = powerMode.shields, + val hullAmount: Int = ship.durability.maxHullPoints, + val troopsAmount: Int = ship.durability.troopsDefense, + + val modulesStatus: ShipModulesStatus = ShipModulesStatus.forShip(ship), + val numFires: Int = 0, + val usedRepairTokens: Int = 0, + + val felinaeShipPowerMode: FelinaeShipPowerMode = FelinaeShipPowerMode.INERTIALESS_DRIVE, + val currentVelocity: Double = 0.0, + val usedInertialessDriveShots: Int = 0, + val usedDisruptionPulseShots: Int = 0, + val hasUsedDisruptionPulse: Boolean = false, + val recoalescenceMaxHullDamage: Int = 0, + + val armaments: ShipInstanceArmaments = ship.armaments.instantiate(), + val usedArmaments: Set> = emptySet(), + + val fighterWings: Set = emptySet(), + val bomberWings: Set = emptySet(), + + val hasSentBoardingParty: Boolean = false, +) { + val canUseShields: Boolean + get() = ship.hasShields && modulesStatus[ShipModule.Shields].canBeUsed + + val canUseTurrets: Boolean + get() = modulesStatus[ShipModule.Turrets].canBeUsed + + val canSendBoardingParty: Boolean + get() = modulesStatus[ShipModule.Assault].canBeUsed && troopsAmount > 1 && !hasSentBoardingParty + + val canUseInertialessDrive: Boolean + get() = ship.canUseInertialessDrive && modulesStatus[ShipModule.Engines].canBeUsed && when (val movement = ship.movement) { + is FelinaeShipMovement -> usedInertialessDriveShots < movement.inertialessDriveShots + else -> false + } && felinaeShipPowerMode == FelinaeShipPowerMode.INERTIALESS_DRIVE + + val remainingInertialessDriveJumps: Int + get() = when (val movement = ship.movement) { + is FelinaeShipMovement -> movement.inertialessDriveShots - usedInertialessDriveShots + else -> 0 + } + + val canUseDisruptionPulse: Boolean + get() = ship.canUseDisruptionPulse && modulesStatus[ShipModule.Turrets].canBeUsed && when (val durability = ship.durability) { + is FelinaeShipDurability -> usedDisruptionPulseShots < durability.disruptionPulseShots + else -> false + } && felinaeShipPowerMode == FelinaeShipPowerMode.DISRUPTION_PULSE && !hasUsedDisruptionPulse + + val remainingDisruptionPulseEmissions: Int + get() = when (val durability = ship.durability) { + is FelinaeShipDurability -> durability.disruptionPulseShots - usedDisruptionPulseShots + else -> 0 + } + + val canUseRecoalescence: Boolean + get() = ship.canUseRecoalescence && felinaeShipPowerMode == FelinaeShipPowerMode.HULL_RECOALESCENSE && !isDoneCurrentPhase && hullAmount < durability.maxHullPoints && recoalescenceMaxHullDamage < (ship.durability.maxHullPoints - 1) + + fun canUseWeapon(weaponId: Id): Boolean { + if (weaponId in usedArmaments) + return false + + if (!modulesStatus[ShipModule.Weapon(weaponId)].canBeUsed) + return false + + val weapon = armaments[weaponId] ?: return false + + return when (weapon) { + is ShipWeaponInstance.Cannon -> weaponAmount > 0 + is ShipWeaponInstance.Lance -> weapon.numCharges > EPSILON + is ShipWeaponInstance.Torpedo -> true + is ShipWeaponInstance.Hangar -> weapon.wingHealth > 0.0 + is ShipWeaponInstance.ParticleClawLauncher -> true + is ShipWeaponInstance.LightningYarn -> true + is ShipWeaponInstance.MegaCannon -> weapon.remainingShots > 0 + is ShipWeaponInstance.RevelationGun -> weapon.remainingShots > 0 + is ShipWeaponInstance.EmpAntenna -> weapon.remainingShots > 0 + } + } + + val remainingRepairTokens: Int + get() = when (val durability = durability) { + is StandardShipDurability -> durability.repairTokens - usedRepairTokens + else -> 0 + } + + val id: Id + get() = ship.id.reinterpret() +} + +@Serializable +data class ShipWreck( + val ship: Ship, + val owner: GlobalSide, + val isEscape: Boolean = false, + val wreckedAt: Moment = Moment.now +) { + val id: Id + get() = ship.id.reinterpret() +} + +@Serializable +data class ShipPosition( + val location: Position, + val facing: Double +) + +enum class ShipSubsystem { + WEAPONS, SHIELDS, ENGINES; + + val displayName: String + get() = name.lowercase().replaceFirstChar { it.uppercase() } + + val htmlColor: String + get() = when (this) { + WEAPONS -> "#FF6633" + SHIELDS -> "#6699FF" + ENGINES -> "#FFCC33" + } + + val imageUrl: String + get() = "/static/game/images/subsystem-${name.lowercase()}.svg" + + companion object { + val transferImageUrl: String + get() = "/static/game/images/subsystems-power-transfer.svg" + } +} + +@Serializable +data class ShipPowerMode( + val weapons: Int, + val shields: Int, + val engines: Int, +) { + operator fun plus(delta: Map) = copy( + weapons = weapons + (delta[ShipSubsystem.WEAPONS] ?: 0), + shields = shields + (delta[ShipSubsystem.SHIELDS] ?: 0), + engines = engines + (delta[ShipSubsystem.ENGINES] ?: 0), + ) + + operator fun minus(delta: Map) = this + delta.mapValues { (_, d) -> -d } + + operator fun get(key: ShipSubsystem): Int = when (key) { + ShipSubsystem.WEAPONS -> weapons + ShipSubsystem.SHIELDS -> shields + ShipSubsystem.ENGINES -> engines + } + + val total: Int + get() = weapons + shields + engines + + infix fun distanceTo(other: ShipPowerMode) = ShipSubsystem.values().sumOf { subsystem -> abs(this[subsystem] - other[subsystem]) } +} + +@Serializable +enum class FelinaeShipPowerMode { + INERTIALESS_DRIVE, + DISRUPTION_PULSE, + HULL_RECOALESCENSE; + + val displayName: String + get() = when (this) { + INERTIALESS_DRIVE -> "Inertialess Drive" + DISRUPTION_PULSE -> "Disruption Pulse" + HULL_RECOALESCENSE -> "Hull Recoalescence" + } +} + +fun ShipInstance.remainingGridEfficiency(newPowerMode: ShipPowerMode) = when (val reactor = ship.reactor) { + is StandardShipReactor -> (reactor.gridEfficiency * 2 - (newPowerMode distanceTo powerMode)) / 2 + else -> 0 +} + +fun ShipInstance.validatePowerMode(newPowerMode: ShipPowerMode) = when (val reactor = ship.reactor) { + is StandardShipReactor -> newPowerMode.total == reactor.powerOutput && ShipSubsystem.values().none { newPowerMode[it] < 0 } && (newPowerMode distanceTo powerMode) <= reactor.gridEfficiency * 2 + else -> true +} + +val ShipInstance.movementCoefficient: Double + get() = when (val reactor = ship.reactor) { + is StandardShipReactor -> sqrt(powerMode.engines.toDouble() / reactor.subsystemAmount) + else -> 1.0 + } * if (modulesStatus[ShipModule.Engines].canBeUsed) + 1.0 + else if (ship.movement is FelinaeShipMovement) + 0.75 + else + 0.5 + +val ShipInstance.movement: ShipMovement + get() = when (val m = ship.movement) { + is StandardShipMovement -> { + val coefficient = movementCoefficient + with(m) { + copy(turnAngle = turnAngle * coefficient, moveSpeed = moveSpeed * coefficient) + } + } + is FelinaeShipMovement -> { + val coefficient = movementCoefficient + with(m) { + copy( + turnAngle = turnAngle * coefficient, + moveSpeed = moveSpeed * coefficient, + inertialessDriveRange = inertialessDriveRange * coefficient.pow(2) + ) + } + } + } + +val ShipInstance.durability: ShipDurability + get() = when (val d = ship.durability) { + is FelinaeShipDurability -> d.copy( + maxHullPoints = d.maxHullPoints - recoalescenceMaxHullDamage, + ) + is StandardShipDurability -> d.copy( + turretDefense = if (canUseTurrets) d.turretDefense else 0.0 + ) + } + +val ShipInstance.firepower: ShipFirepower + get() = ship.firepower + +fun Ship.defaultPowerMode(): ShipPowerMode { + val amount = when (val r = reactor) { + is StandardShipReactor -> r.subsystemAmount + else -> 0 + } + return ShipPowerMode(amount, amount, amount) +} + +enum class ShipRenderMode { + NONE, + SIGNAL, + FULL; +} + +fun GameState.renderShipAs(ship: ShipInstance, forPlayer: GlobalSide) = if (ship.owner == forPlayer) + ShipRenderMode.FULL +else if (phase == GamePhase.Deploy) + ShipRenderMode.NONE +else if (ship.isIdentified) + ShipRenderMode.FULL +else + ShipRenderMode.SIGNAL + +const val SHIP_BASE_SIZE = 250.0 + +const val SHIP_TRANSPORTARIUM_RANGE = 1_500.0 + +const val SHIP_TORPEDO_RANGE = 2_000.0 +const val SHIP_CANNON_RANGE = 2_500.0 +const val SHIP_LANCE_RANGE = 3_000.0 +const val SHIP_HANGAR_RANGE = 3_500.0 + +const val SHIP_SENSOR_RANGE = 4_000.0 diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_modifiers.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_modifiers.kt new file mode 100644 index 0000000..8b3711e --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_modifiers.kt @@ -0,0 +1,3 @@ +package net.starshipfights.game + + diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_modules.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_modules.kt new file mode 100644 index 0000000..4916247 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_modules.kt @@ -0,0 +1,365 @@ +package net.starshipfights.game + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.PairSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import net.starshipfights.data.Id +import kotlin.jvm.JvmInline + +@Serializable +sealed class ShipModule { + abstract fun getDisplayName(ship: Ship): String + + @Serializable + data class Weapon(val weaponId: Id) : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return ship.armaments[weaponId]?.displayName ?: "" + } + } + + @Serializable + object Assault : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Boarding Transportarium" + } + } + + @Serializable + object Defense : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Internal Defenses" + } + } + + @Serializable + object Shields : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Shield Generators" + } + } + + @Serializable + object Engines : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Mach-Effect Thrusters" + } + } + + @Serializable + object Turrets : ShipModule() { + override fun getDisplayName(ship: Ship): String { + return "Point Defense Turrets" + } + } +} + +@Serializable +enum class ShipModuleStatus(val canBeUsed: Boolean, val canBeRepaired: Boolean) { + INTACT(true, false), + DAMAGED(false, true), + DESTROYED(false, false), + ABSENT(false, false) +} + +@JvmInline +@Serializable(with = ShipModulesStatusSerializer::class) +value class ShipModulesStatus(val statuses: Map) { + operator fun get(module: ShipModule) = statuses[module] ?: ShipModuleStatus.ABSENT + + fun repair(module: ShipModule, repairUnrepairable: Boolean = false) = ShipModulesStatus( + statuses + if (this[module].canBeRepaired || (repairUnrepairable && this[module] in ShipModuleStatus.DAMAGED..ShipModuleStatus.DESTROYED)) + mapOf(module to ShipModuleStatus.values()[this[module].ordinal - 1]) + else emptyMap() + ) + + fun damage(module: ShipModule) = ShipModulesStatus( + statuses + mapOf( + module to when (this[module]) { + ShipModuleStatus.INTACT -> ShipModuleStatus.DAMAGED + ShipModuleStatus.DAMAGED -> ShipModuleStatus.DESTROYED + ShipModuleStatus.DESTROYED -> ShipModuleStatus.DESTROYED + ShipModuleStatus.ABSENT -> ShipModuleStatus.ABSENT + } + ) + ) + + fun damageMany(modules: Iterable) = ShipModulesStatus( + statuses + modules.associateWith { module -> + when (this[module]) { + ShipModuleStatus.INTACT -> ShipModuleStatus.DAMAGED + ShipModuleStatus.DAMAGED -> ShipModuleStatus.DESTROYED + ShipModuleStatus.DESTROYED -> ShipModuleStatus.DESTROYED + ShipModuleStatus.ABSENT -> ShipModuleStatus.ABSENT + } + } + ) + + companion object { + fun forShip(ship: Ship) = ShipModulesStatus( + mapOf( + ShipModule.Assault to ShipModuleStatus.INTACT, + ShipModule.Defense to ShipModuleStatus.INTACT, + ShipModule.Shields to if (ship.hasShields) ShipModuleStatus.INTACT else ShipModuleStatus.ABSENT, + ShipModule.Engines to ShipModuleStatus.INTACT, + ShipModule.Turrets to ShipModuleStatus.INTACT, + ) + ship.armaments.keys.associate { + ShipModule.Weapon(it) to ShipModuleStatus.INTACT + } + ) + } +} + +object ShipModulesStatusSerializer : KSerializer { + private val inner = ListSerializer(PairSerializer(ShipModule.serializer(), ShipModuleStatus.serializer())) + + override val descriptor: SerialDescriptor + get() = inner.descriptor + + override fun serialize(encoder: Encoder, value: ShipModulesStatus) { + inner.serialize(encoder, value.statuses.toList()) + } + + override fun deserialize(decoder: Decoder): ShipModulesStatus { + return ShipModulesStatus(inner.deserialize(decoder).toMap()) + } +} + +sealed class CritResult { + object NoEffect : CritResult() + data class FireStarted(val ship: ShipInstance) : CritResult() + data class ModulesDisabled(val ship: ShipInstance, val modules: Set) : CritResult() + data class TroopsKilled(val ship: ShipInstance, val amount: Int) : CritResult() + data class HullDamaged(val ship: ShipInstance, val amount: Int) : CritResult() + data class Destroyed(val ship: ShipWreck) : CritResult() + + companion object { + fun fromImpactResult(impactResult: ImpactResult) = when (impactResult) { + is ImpactResult.Damaged -> impactResult.damage.amount.takeIf { it > 0 }?.let { HullDamaged(impactResult.ship, it) } ?: NoEffect + is ImpactResult.Destroyed -> Destroyed(impactResult.ship) + } + } +} + +fun ShipInstance.doCriticalDamage(): CritResult { + if (ship.shipType.faction == Faction.FELINAE_FELICES) + return doCriticalDamageFelinae() + + return when ((0..8).random() + (0..8).random()) { // Ranges in 0..16, probability density peaks at 8 + 0 -> { + // Damage ALL the modules! + val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 1 -> { + // Damage 3 weapons + val modulesDamaged = armaments.keys.shuffled().take(3).map { ShipModule.Weapon(it) } + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 2 -> { + // Damage 2 weapons + val modulesDamaged = armaments.keys.shuffled().take(2).map { ShipModule.Weapon(it) } + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 3 -> { + // Damage 2 random modules + val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys.shuffled().take(2) + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 4 -> { + // Damage 1 weapon + val modulesDamaged = armaments.keys.shuffled().take(1).map { ShipModule.Weapon(it) } + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 5 -> { + // Damage engines + val moduleDamaged = ShipModule.Engines + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 6 -> { + // Damage transportarium + val moduleDamaged = ShipModule.Assault + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 7 -> { + // Lose a few troops + val deaths = (1..2).random() + killTroops(deaths) + } + 8 -> { + // Fire! + CritResult.FireStarted( + copy(numFires = numFires + 1) + ) + } + 9 -> { + // Two fires! + CritResult.FireStarted( + copy(numFires = numFires + 2) + ) + } + 10 -> { + // Damage turrets + val moduleDamaged = ShipModule.Turrets + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 11 -> { + // Lose many troops + val deaths = (1..2).random() + (1..2).random() + killTroops(deaths) + } + 12 -> { + // Damage security system + val moduleDamaged = ShipModule.Defense + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 13 -> { + // Damage random module + val moduleDamaged = modulesStatus.statuses.keys.filter { it !is ShipModule.Weapon }.random() + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 14 -> { + // Damage shields + val moduleDamaged = ShipModule.Shields + if (ship.hasShields) + CritResult.ModulesDisabled( + copy( + shieldAmount = 0, + modulesStatus = modulesStatus.damage(moduleDamaged) + ), + setOf(moduleDamaged) + ) + else + CritResult.NoEffect + } + 15 -> { + // Hull breach + val damage = (0..2).random() + (1..3).random() + CritResult.fromImpactResult(impact(damage, true)) + } + 16 -> { + // Bulkhead collapse + val damage = (2..4).random() + (3..5).random() + CritResult.fromImpactResult(impact(damage, true)) + } + else -> CritResult.NoEffect + } +} + +private fun ShipInstance.doCriticalDamageFelinae(): CritResult { + return when ((0..5).random() + (0..5).random()) { + 0 -> { + // Damage ALL the modules! + val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 1 -> { + // Damage 3 weapons + val modulesDamaged = armaments.keys.shuffled().take(3).map { ShipModule.Weapon(it) } + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 2 -> { + // Damage 2 weapons + val modulesDamaged = armaments.keys.shuffled().take(2).map { ShipModule.Weapon(it) } + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 3 -> { + // Damage 2 random modules + val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys.shuffled().take(2) + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 4 -> { + // Damage 1 weapon + val modulesDamaged = armaments.keys.shuffled().take(1).map { ShipModule.Weapon(it) } + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), + modulesDamaged.toSet() + ) + } + 5 -> { + // Damage engines + val moduleDamaged = ShipModule.Engines + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 6 -> { + // Damage turrets + val moduleDamaged = ShipModule.Turrets + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 7 -> { + // Damage random module + val moduleDamaged = modulesStatus.statuses.keys.filter { it !is ShipModule.Weapon }.random() + CritResult.ModulesDisabled( + copy(modulesStatus = modulesStatus.damage(moduleDamaged)), + setOf(moduleDamaged) + ) + } + 8 -> { + // Lose some troops + val deaths = (1..3).random() + killTroops(deaths) + } + 9 -> { + // Hull breach + val damage = (0..2).random() + (1..3).random() + CritResult.fromImpactResult(impact(damage)) + } + 10 -> { + // Bulkhead collapse + val damage = (2..4).random() + (3..5).random() + CritResult.fromImpactResult(impact(damage)) + } + else -> CritResult.NoEffect + } +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_types.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_types.kt new file mode 100644 index 0000000..a88f433 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_types.kt @@ -0,0 +1,206 @@ +package net.starshipfights.game + +enum class ShipWeightClass( + val meshIndex: Int, + val tier: Int +) { + // General + ESCORT(1, 0), + DESTROYER(2, 1), + CRUISER(3, 2), + BATTLECRUISER(4, 3), + BATTLESHIP(5, 4), + + // NdRC-specific + BATTLE_BARGE(5, 3), + + // Masra Draetsen-specific + GRAND_CRUISER(4, 3), + COLOSSUS(5, 5), + + // Felinae Felices-specific + FF_ESCORT(1, 1), + FF_DESTROYER(2, 2), + FF_CRUISER(3, 3), + FF_BATTLECRUISER(4, 4), + FF_BATTLESHIP(5, 5), + + // Isarnareykk-specific + AUXILIARY_SHIP(1, 0), + LIGHT_CRUISER(2, 1), + MEDIUM_CRUISER(3, 2), + HEAVY_CRUISER(4, 4), + + // Vestigium-specific + FRIGATE(1, 0), + LINE_SHIP(3, 2), + DREADNOUGHT(5, 4), + ; + + val displayName: String + get() = if (this in FF_ESCORT..FF_BATTLESHIP) + name.lowercase().split('_').joinToString(separator = " ") { word -> word.replaceFirstChar { c -> c.uppercase() } }.removePrefix("Ff ") + else + name.lowercase().split('_').joinToString(separator = " ") { word -> word.replaceFirstChar { c -> c.uppercase() } } + + val basePointCost: Int + get() = when (this) { + ESCORT -> 50 + DESTROYER -> 100 + CRUISER -> 200 + BATTLECRUISER -> 250 + BATTLESHIP -> 350 + + BATTLE_BARGE -> 300 + + GRAND_CRUISER -> 300 + COLOSSUS -> 370 + + FF_ESCORT -> 50 + FF_DESTROYER -> 100 + FF_CRUISER -> 200 + FF_BATTLECRUISER -> 250 + FF_BATTLESHIP -> 300 + + AUXILIARY_SHIP -> 50 + LIGHT_CRUISER -> 100 + MEDIUM_CRUISER -> 200 + HEAVY_CRUISER -> 400 + + FRIGATE -> 150 + LINE_SHIP -> 275 + DREADNOUGHT -> 400 + } + + val isUnique: Boolean + get() = this == COLOSSUS +} + +enum class ShipType( + val faction: Faction, + val weightClass: ShipWeightClass, +) { + // Mechyrdia + MICRO(Faction.MECHYRDIA, ShipWeightClass.ESCORT), + NANO(Faction.MECHYRDIA, ShipWeightClass.ESCORT), + PICO(Faction.MECHYRDIA, ShipWeightClass.ESCORT), + + GLADIUS(Faction.MECHYRDIA, ShipWeightClass.DESTROYER), + PILUM(Faction.MECHYRDIA, ShipWeightClass.DESTROYER), + SICA(Faction.MECHYRDIA, ShipWeightClass.DESTROYER), + + KAISERSWELT(Faction.MECHYRDIA, ShipWeightClass.CRUISER), + KAROLINA(Faction.MECHYRDIA, ShipWeightClass.CRUISER), + KOZACHNIA(Faction.MECHYRDIA, ShipWeightClass.CRUISER), + MONT_IMPERIAL(Faction.MECHYRDIA, ShipWeightClass.CRUISER), + MUNDUS_CAESARIS_DIVI(Faction.MECHYRDIA, ShipWeightClass.CRUISER), + VENSCA(Faction.MECHYRDIA, ShipWeightClass.CRUISER), + + AUCTORITAS(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), + CIVITAS(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), + HONOS(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), + IMPERIUM(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), + PAX(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), + PIETAS(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), + + EARTH(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), + LANGUAVARTH(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), + MECHYRDIA(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), + NOVA_ROMA(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), + TYLA(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), + + // NdRC + JAGER(Faction.NDRC, ShipWeightClass.DESTROYER), + NOVAATJE(Faction.NDRC, ShipWeightClass.DESTROYER), + ZWAARD(Faction.NDRC, ShipWeightClass.DESTROYER), + SLAGSCHIP(Faction.NDRC, ShipWeightClass.CRUISER), + VOORHOEDE(Faction.NDRC, ShipWeightClass.CRUISER), + KRIJGSCHUIT(Faction.NDRC, ShipWeightClass.BATTLE_BARGE), + + // Masra Draetsen + ERIS(Faction.MASRA_DRAETSEN, ShipWeightClass.ESCORT), + TYPHON(Faction.MASRA_DRAETSEN, ShipWeightClass.ESCORT), + + AHRIMAN(Faction.MASRA_DRAETSEN, ShipWeightClass.DESTROYER), + APOPHIS(Faction.MASRA_DRAETSEN, ShipWeightClass.DESTROYER), + AZATHOTH(Faction.MASRA_DRAETSEN, ShipWeightClass.DESTROYER), + + CHERNOBOG(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), + CIPACTLI(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), + LAMASHTU(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), + LOTAN(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), + MORGOTH(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), + TIAMAT(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), + + CHARYBDIS(Faction.MASRA_DRAETSEN, ShipWeightClass.GRAND_CRUISER), + KAKIA(Faction.MASRA_DRAETSEN, ShipWeightClass.GRAND_CRUISER), + MOLOCH(Faction.MASRA_DRAETSEN, ShipWeightClass.GRAND_CRUISER), + SCYLLA(Faction.MASRA_DRAETSEN, ShipWeightClass.GRAND_CRUISER), + + AEDON(Faction.MASRA_DRAETSEN, ShipWeightClass.COLOSSUS), + + // Felinae Felices + KODKOD(Faction.FELINAE_FELICES, ShipWeightClass.FF_ESCORT), + ONCILLA(Faction.FELINAE_FELICES, ShipWeightClass.FF_ESCORT), + + MARGAY(Faction.FELINAE_FELICES, ShipWeightClass.FF_DESTROYER), + OCELOT(Faction.FELINAE_FELICES, ShipWeightClass.FF_DESTROYER), + + BOBCAT(Faction.FELINAE_FELICES, ShipWeightClass.FF_CRUISER), + LYNX(Faction.FELINAE_FELICES, ShipWeightClass.FF_CRUISER), + + LEOPARD(Faction.FELINAE_FELICES, ShipWeightClass.FF_BATTLECRUISER), + TIGER(Faction.FELINAE_FELICES, ShipWeightClass.FF_BATTLECRUISER), + + CARACAL(Faction.FELINAE_FELICES, ShipWeightClass.FF_BATTLESHIP), + + // Isarnareykk + GANNAN(Faction.ISARNAREYKK, ShipWeightClass.AUXILIARY_SHIP), + LODOVIK(Faction.ISARNAREYKK, ShipWeightClass.AUXILIARY_SHIP), + + KARNAS(Faction.ISARNAREYKK, ShipWeightClass.LIGHT_CRUISER), + PERTONA(Faction.ISARNAREYKK, ShipWeightClass.LIGHT_CRUISER), + VOSS(Faction.ISARNAREYKK, ShipWeightClass.LIGHT_CRUISER), + + BREKORYN(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), + FALK(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), + LORUS(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), + ORSH(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), + TEFRAN(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), + + KASSCK(Faction.ISARNAREYKK, ShipWeightClass.HEAVY_CRUISER), + KHORR(Faction.ISARNAREYKK, ShipWeightClass.HEAVY_CRUISER), + + // Vestigium + COLEMAN(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), + JEFFERSON(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), + QUENNEY(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), + ROOSEVELT(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), + WASHINGTON(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), + + ARLINGTON(Faction.VESTIGIUM, ShipWeightClass.LINE_SHIP), + CONCORD(Faction.VESTIGIUM, ShipWeightClass.LINE_SHIP), + LEXINGTON(Faction.VESTIGIUM, ShipWeightClass.LINE_SHIP), + RAVEN_ROCK(Faction.VESTIGIUM, ShipWeightClass.LINE_SHIP), + + IOWA(Faction.VESTIGIUM, ShipWeightClass.DREADNOUGHT), + MARYLAND(Faction.VESTIGIUM, ShipWeightClass.DREADNOUGHT), + NEW_YORK(Faction.VESTIGIUM, ShipWeightClass.DREADNOUGHT), + OHIO(Faction.VESTIGIUM, ShipWeightClass.DREADNOUGHT), + ; + + val displayName: String + get() = name.lowercase().split('_').joinToString(separator = " ") { word -> word.replaceFirstChar { c -> c.uppercase() } } + + val fullDisplayName: String + get() = "$displayName-class ${weightClass.displayName}" + + val fullerDisplayName: String + get() = "$displayName-class ${faction.adjective} ${weightClass.displayName}" +} + +val ShipType.pointCost: Int + get() = weightClass.basePointCost + armaments.values.sumOf { it.addsPointCost } + +val ShipType.meshName: String + get() = "${faction.meshTag}-${weightClass.meshIndex}-${toUrlSlug()}-class" diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_weapons.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_weapons.kt new file mode 100644 index 0000000..f96d84c --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_weapons.kt @@ -0,0 +1,781 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import net.starshipfights.data.Id +import kotlin.math.* +import kotlin.random.Random + +enum class FiringArc { + BOW, ABEAM_PORT, ABEAM_STARBOARD, STERN; + + val displayName: String + get() = when (this) { + BOW -> "Fore" + ABEAM_PORT -> "Port" + ABEAM_STARBOARD -> "Starboard" + STERN -> "Aft" + } + + companion object { + val FIRE_360: Set = setOf(BOW, ABEAM_PORT, ABEAM_STARBOARD, STERN) + val FIRE_BROADSIDE: Set = setOf(ABEAM_PORT, ABEAM_STARBOARD) + val FIRE_FORE_270: Set = setOf(BOW, ABEAM_PORT, ABEAM_STARBOARD) + } +} + +sealed interface AreaWeapon { + val areaRadius: Double + val isLine: Boolean + get() = false +} + +@Serializable +sealed class ShipWeapon { + abstract val numShots: Int + + open val minRange: Double + get() = SHIP_BASE_SIZE + abstract val maxRange: Double + abstract val firingArcs: Set + + abstract val groupLabel: String + + abstract val addsPointCost: Int + + abstract fun instantiate(): ShipWeaponInstance + + @Serializable + data class Cannon( + override val numShots: Int, + override val firingArcs: Set, + override val groupLabel: String, + ) : ShipWeapon() { + override val maxRange: Double + get() = SHIP_CANNON_RANGE + + override val addsPointCost: Int + get() = numShots * 5 + + override fun instantiate() = ShipWeaponInstance.Cannon(this) + } + + @Serializable + data class Lance( + override val numShots: Int, + override val firingArcs: Set, + override val groupLabel: String, + ) : ShipWeapon() { + override val maxRange: Double + get() = SHIP_LANCE_RANGE + + override val addsPointCost: Int + get() = numShots * 10 + + override fun instantiate() = ShipWeaponInstance.Lance(this, 10.0) + } + + @Serializable + data class Torpedo( + override val firingArcs: Set, + override val groupLabel: String, + ) : ShipWeapon() { + override val numShots: Int + get() = 1 + + override val maxRange: Double + get() = SHIP_TORPEDO_RANGE + + override val addsPointCost: Int + get() = 5 + + override fun instantiate() = ShipWeaponInstance.Torpedo(this) + } + + @Serializable + data class Hangar( + val wing: StrikeCraftWing, + override val groupLabel: String, + ) : ShipWeapon() { + override val numShots: Int + get() = 1 + + override val maxRange: Double + get() = SHIP_HANGAR_RANGE + + override val firingArcs: Set + get() = FiringArc.FIRE_360 + + override val addsPointCost: Int + get() = when (wing) { + StrikeCraftWing.FIGHTERS -> 5 + StrikeCraftWing.BOMBERS -> 10 + } + + override fun instantiate() = ShipWeaponInstance.Hangar(this, 1.0) + } + + // FELINAE FELICES ADVANCED WEAPONS + + @Serializable + data class ParticleClawLauncher( + override val numShots: Int, + override val firingArcs: Set, + override val groupLabel: String, + ) : ShipWeapon() { + override val maxRange: Double + get() = 1750.0 + + override val addsPointCost: Int + get() = numShots * 10 + + override fun instantiate() = ShipWeaponInstance.ParticleClawLauncher(this) + } + + @Serializable + data class LightningYarn( + override val numShots: Int, + override val firingArcs: Set, + override val groupLabel: String, + ) : ShipWeapon() { + override val maxRange: Double + get() = 1250.0 + + override val addsPointCost: Int + get() = numShots * 5 * firingArcs.size + + override fun instantiate() = ShipWeaponInstance.LightningYarn(this) + } + + // HEAVY WEAPONS + + @Serializable + object MegaCannon : ShipWeapon(), AreaWeapon { + override val numShots: Int + get() = 3 + + override val minRange: Double + get() = 3_000.0 + + override val maxRange: Double + get() = 7_000.0 + + override val areaRadius: Double + get() = 450.0 + + override val firingArcs: Set + get() = setOf(FiringArc.BOW) + + override val groupLabel: String + get() = "Mega Giga Cannon" + + override val addsPointCost: Int + get() = 50 + + override fun instantiate() = ShipWeaponInstance.MegaCannon(numShots) + } + + @Serializable + object RevelationGun : ShipWeapon(), AreaWeapon { + override val numShots: Int + get() = 1 + + override val maxRange: Double + get() = 2_000.0 + + override val areaRadius: Double + get() = SHIP_BASE_SIZE + + override val isLine: Boolean + get() = true + + override val firingArcs: Set + get() = setOf(FiringArc.BOW) + + override val groupLabel: String + get() = "Revelation Gun" + + override val addsPointCost: Int + get() = 66 + + override fun instantiate() = ShipWeaponInstance.RevelationGun(numShots) + } + + @Serializable + object EmpAntenna : ShipWeapon(), AreaWeapon { + override val numShots: Int + get() = 5 + + override val maxRange: Double + get() = 3_000.0 + + override val areaRadius: Double + get() = 650.0 + + override val firingArcs: Set + get() = setOf(FiringArc.BOW) + + override val groupLabel: String + get() = "EMP Emitter" + + override val addsPointCost: Int + get() = 40 + + override fun instantiate() = ShipWeaponInstance.EmpAntenna(numShots) + } +} + +enum class StrikeCraftWing { + FIGHTERS, BOMBERS; + + val displayName: String + get() = name.lowercase().replaceFirstChar { it.uppercase() } + + val iconUrl: String + get() = "/static/game/images/strike-craft-${toUrlSlug()}.svg" +} + +@Serializable +sealed class ShipWeaponInstance { + abstract val weapon: ShipWeapon + + @Serializable + data class Cannon(override val weapon: ShipWeapon.Cannon) : ShipWeaponInstance() + + @Serializable + data class Lance(override val weapon: ShipWeapon.Lance, val numCharges: Double) : ShipWeaponInstance() { + val charge: Double + get() = -expm1(-numCharges) + } + + @Serializable + data class Torpedo(override val weapon: ShipWeapon.Torpedo) : ShipWeaponInstance() + + @Serializable + data class Hangar(override val weapon: ShipWeapon.Hangar, val wingHealth: Double) : ShipWeaponInstance() + + // FELINAE FELICES ADVANCED WEAPONS + @Serializable + data class ParticleClawLauncher(override val weapon: ShipWeapon.ParticleClawLauncher) : ShipWeaponInstance() + + @Serializable + data class LightningYarn(override val weapon: ShipWeapon.LightningYarn) : ShipWeaponInstance() + + // HEAVY WEAPONS + + @Serializable + data class MegaCannon(val remainingShots: Int) : ShipWeaponInstance() { + override val weapon: ShipWeapon + get() = ShipWeapon.MegaCannon + } + + @Serializable + data class RevelationGun(val remainingShots: Int) : ShipWeaponInstance() { + override val weapon: ShipWeapon + get() = ShipWeapon.RevelationGun + } + + @Serializable + data class EmpAntenna(val remainingShots: Int) : ShipWeaponInstance() { + override val weapon: ShipWeapon + get() = ShipWeapon.EmpAntenna + } +} + +typealias ShipArmaments = Map, ShipWeapon> + +fun ShipArmaments.instantiate() = mapValues { (_, weapon) -> weapon.instantiate() } + +typealias ShipInstanceArmaments = Map, ShipWeaponInstance> + +fun cannonChanceToHit(attacker: ShipInstance, targeted: ShipInstance): Double { + val relativeDistance = attacker.position.location - targeted.position.location + return sqrt(SHIP_BASE_SIZE / relativeDistance.length) * attacker.firepower.cannonAccuracy +} + +enum class DamageIgnoreType { + FELINAE_ARMOR +} + +sealed class ImpactDamage { + abstract val amount: Int + abstract val ignore: DamageIgnoreType? + + data class Success(override val amount: Int) : ImpactDamage() { + override val ignore: DamageIgnoreType? + get() = null + } + + data class Failed(override val ignore: DamageIgnoreType) : ImpactDamage() { + override val amount: Int + get() = 0 + } + + object OtherEffect : ImpactDamage() { + override val amount: Int + get() = 0 + override val ignore: DamageIgnoreType? + get() = null + } +} + +operator fun ImpactDamage.plus(amount: Int) = if (amount > 0) ImpactDamage.Success(this.amount + amount) else this + +sealed class ImpactResult { + data class Damaged(val ship: ShipInstance, val damage: ImpactDamage, val critical: CritResult = CritResult.NoEffect) : ImpactResult() + data class Destroyed(val ship: ShipWreck) : ImpactResult() + + companion object { + @Suppress("FunctionName") + fun Intact(ship: ShipInstance, ignoreType: DamageIgnoreType? = null) = Damaged(ship, if (ignoreType == null) ImpactDamage.OtherEffect else ImpactDamage.Failed(ignoreType), CritResult.NoEffect) + } +} + +fun ShipInstance.felinaeArmorIgnoreDamageChance(): Double { + if (ship.shipType.faction != Faction.FELINAE_FELICES) return 0.0 + + val maxVelocity = movement.moveSpeed + val curVelocity = currentVelocity + val ratio = curVelocity / maxVelocity + val exponent = ratio / sqrt(1 + abs(4 * ratio)) + return -expm1(-exponent) +} + +fun ShipInstance.killTroops(damage: Int) = if (damage >= troopsAmount) + CritResult.Destroyed(ShipWreck(ship, owner)) +else CritResult.TroopsKilled(copy(troopsAmount = troopsAmount - damage), damage) + +fun ShipInstance.impact(damage: Int, ignoreShields: Boolean = false) = if (durability is FelinaeShipDurability && Random.nextDouble() < felinaeArmorIgnoreDamageChance()) + ImpactResult.Intact(this, DamageIgnoreType.FELINAE_ARMOR) +else if (ignoreShields) { + if (damage >= hullAmount) + ImpactResult.Destroyed(ShipWreck(ship, owner)) + else ImpactResult.Damaged(copy(hullAmount = hullAmount - damage), damage = ImpactDamage.Success(damage)) +} else if (damage > shieldAmount) { + if (damage - shieldAmount >= hullAmount) + ImpactResult.Destroyed(ShipWreck(ship, owner)) + else ImpactResult.Damaged(copy(shieldAmount = 0, hullAmount = hullAmount - (damage - shieldAmount)), damage = ImpactDamage.Success(damage)) +} else ImpactResult.Damaged(copy(shieldAmount = shieldAmount - damage), damage = ImpactDamage.Success(damage)) + +@Serializable +data class ShipHangarWing( + val ship: Id, + val hangar: Id +) + +fun ShipInstance.afterUsing(weaponId: Id) = when (val weapon = armaments.getValue(weaponId)) { + is ShipWeaponInstance.Cannon -> { + copy(weaponAmount = weaponAmount - 1, usedArmaments = usedArmaments + setOf(weaponId)) + } + is ShipWeaponInstance.Lance -> { + val newWeapons = armaments + mapOf( + weaponId to weapon.copy(numCharges = 0.0) + ) + + copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) + } + is ShipWeaponInstance.MegaCannon -> { + val newWeapons = armaments + mapOf( + weaponId to weapon.copy(remainingShots = weapon.remainingShots - 1) + ) + + copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) + } + is ShipWeaponInstance.RevelationGun -> { + val newWeapons = armaments + mapOf( + weaponId to weapon.copy(remainingShots = weapon.remainingShots - 1) + ) + + copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) + } + is ShipWeaponInstance.EmpAntenna -> { + val newWeapons = armaments + mapOf( + weaponId to weapon.copy(remainingShots = weapon.remainingShots - 1) + ) + + copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) + } + else -> copy(usedArmaments = usedArmaments + setOf(weaponId)) +} + +fun ShipInstance.afterTargeted(by: ShipInstance, weaponId: Id) = when (val weapon = by.armaments.getValue(weaponId)) { + is ShipWeaponInstance.Cannon -> { + var hits = 0 + + repeat(weapon.weapon.numShots) { + if (Random.nextDouble() < cannonChanceToHit(by, this)) + hits++ + } + + impact(hits).applyCriticals(by, weaponId) + } + is ShipWeaponInstance.Lance -> { + var hits = 0 + + repeat(weapon.weapon.numShots) { + if (Random.nextDouble() < weapon.charge) + hits++ + } + + impact(hits).applyCriticals(by, weaponId) + } + is ShipWeaponInstance.Torpedo -> { + if (shieldAmount > 0) { + if (Random.nextBoolean()) + impact(1).applyCriticals(by, weaponId) + else + ImpactResult.Damaged(this, ImpactDamage.Success(0)) + } else + impact(2).applyCriticals(by, weaponId) + } + is ShipWeaponInstance.Hangar -> { + ImpactResult.Damaged( + if (weapon.weapon.wing == StrikeCraftWing.FIGHTERS) + copy(fighterWings = fighterWings + setOf(ShipHangarWing(by.id, weaponId))) + else + copy(bomberWings = bomberWings + setOf(ShipHangarWing(by.id, weaponId))), + ImpactDamage.OtherEffect + ) + } + is ShipWeaponInstance.ParticleClawLauncher -> { + var impactResult = impact(weapon.weapon.numShots) + + repeat(weapon.weapon.numShots) { + if (Random.nextDouble() < cannonChanceToHit(by, this)) + impactResult = when (val ir = impactResult) { + is ImpactResult.Damaged -> ir.withCritResult(ir.ship.doCriticalDamage()) + else -> ir + } + } + + impactResult + } + is ShipWeaponInstance.LightningYarn -> { + impact(weapon.weapon.numShots, true).applyCriticals(by, weaponId) + } + is ShipWeaponInstance.MegaCannon -> { + impact((3..7).random()).applyCriticals(by, weaponId) + } + is ShipWeaponInstance.RevelationGun -> { + ImpactResult.Destroyed(ShipWreck(ship, owner)) + } + is ShipWeaponInstance.EmpAntenna -> { + ImpactResult.Damaged( + copy( + weaponAmount = (0..weaponAmount).random(), + shieldAmount = (0..shieldAmount).random(), + ), + ImpactDamage.OtherEffect + ) + } +} + +fun ShipInstance.calculateBombing(otherShips: Map, ShipInstance>, extraBombers: Double = 0.0, extraFighters: Double = 0.0): Double? { + if (bomberWings.isEmpty() && extraBombers < EPSILON) + return null + + val totalFighterHealth = fighterWings.sumOf { (carrierId, wingId) -> + (otherShips[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 + } + durability.turretDefense + extraFighters + + val totalBomberHealth = bomberWings.sumOf { (carrierId, wingId) -> + (otherShips[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 + } + extraBombers + + if (totalBomberHealth < EPSILON) + return null + + return totalBomberHealth - totalFighterHealth +} + +fun ShipInstance.afterBombed(otherShips: Map, ShipInstance>, strikeWingDamage: MutableMap): ImpactResult { + val calculatedBombing = calculateBombing(otherShips) ?: return ImpactResult.Damaged(this, ImpactDamage.OtherEffect) + + val maxBomberWingOutput = smoothNegative(calculatedBombing) + val maxFighterWingOutput = smoothNegative(-calculatedBombing) + + for (it in fighterWings) + strikeWingDamage[it] = Random.nextDouble() * maxBomberWingOutput + + for (it in bomberWings) + strikeWingDamage[it] = Random.nextDouble() * maxFighterWingOutput + + val chanceOfShipDamage = smoothNegative(maxBomberWingOutput - maxFighterWingOutput) + val hits = floor(chanceOfShipDamage).let { floored -> + floored.roundToInt() + (if (Random.nextDouble() < chanceOfShipDamage - floored) 1 else 0) + } + + val criticalChance = smoothMinus1To1(chanceOfShipDamage, exponent = 0.5) + return impact(hits).applyStrikeCraftCriticals(criticalChance) +} + +fun ShipInstance.afterBombing(strikeWingDamage: Map): ShipInstance { + val newArmaments = armaments.mapValues { (weaponId, weapon) -> + if (weapon is ShipWeaponInstance.Hangar) + weapon.copy(wingHealth = weapon.wingHealth - (strikeWingDamage[ShipHangarWing(id, weaponId)] ?: 0.0)) + else weapon + }.filterValues { it !is ShipWeaponInstance.Hangar || it.wingHealth > 0.0 } + + return copy(armaments = newArmaments) +} + +fun ImpactResult.Damaged.withCritResult(critical: CritResult): ImpactResult = when (critical) { + is CritResult.NoEffect -> this + is CritResult.FireStarted -> copy( + ship = critical.ship, + damage = damage, + critical = critical + ) + is CritResult.ModulesDisabled -> copy( + ship = critical.ship, + damage = damage, + critical = critical + ) + is CritResult.TroopsKilled -> copy( + ship = critical.ship, + damage = damage, + critical = critical + ) + is CritResult.HullDamaged -> copy( + ship = critical.ship, + damage = damage + critical.amount, + critical = critical + ) + is CritResult.Destroyed -> ImpactResult.Destroyed(critical.ship) +} + +fun ImpactResult.applyCriticals(attacker: ShipInstance, weaponId: Id): ImpactResult { + return when (this) { + is ImpactResult.Destroyed -> this + is ImpactResult.Damaged -> { + if (damage is ImpactDamage.Failed) + return this + + val critChance = criticalChance(attacker, weaponId, ship) + if (Random.nextDouble() > critChance) + this + else + withCritResult(ship.doCriticalDamage()) + } + } +} + +fun ImpactResult.applyStrikeCraftCriticals(criticalChance: Double): ImpactResult { + return when (this) { + is ImpactResult.Destroyed -> this + is ImpactResult.Damaged -> { + if (Random.nextDouble() > criticalChance) + this + else + withCritResult(ship.doCriticalDamage()) + } + } +} + +fun criticalChance(attacker: ShipInstance, weaponId: Id, targeted: ShipInstance): Double { + val targetHasShields = targeted.canUseShields && targeted.shieldAmount > 0 + val weapon = attacker.armaments[weaponId] ?: return 0.0 + + return when (weapon) { + is ShipWeaponInstance.Torpedo -> if (targetHasShields) 0.0 else 0.375 + is ShipWeaponInstance.Hangar -> 0.0 // implemented elsewhere + is ShipWeaponInstance.MegaCannon -> 0.5 + else -> if (targetHasShields) 0.125 else 0.25 + } * attacker.firepower.criticalChance +} + +fun ShipInstance.getWeaponPickRequest(weapon: ShipWeapon): PickRequest = when (weapon) { + is AreaWeapon -> PickRequest( + type = PickType.Location( + excludesNearShips = emptySet(), + helper = PickHelper.Circle(radius = weapon.areaRadius), + drawLineFrom = if (weapon.isLine) null else position.location + ), + boundary = if (weapon.isLine) + PickBoundary.AlongLine( + pointA = position.location + polarDistance(weapon.minRange, position.facing), + pointB = position.location + polarDistance(weapon.maxRange, position.facing) + ) + else + PickBoundary.WeaponsFire( + center = position.location, + facing = position.facing, + minDistance = weapon.minRange, + maxDistance = weapon.maxRange, + firingArcs = weapon.firingArcs, + ), + ) + else -> { + val targetSet = if ((weapon as? ShipWeapon.Hangar)?.wing == StrikeCraftWing.FIGHTERS) + setOf(owner) + else + setOf(owner.other) + + val weaponRangeMult = when (weapon) { + is ShipWeapon.Cannon -> firepower.rangeMultiplier + is ShipWeapon.Lance -> firepower.rangeMultiplier + is ShipWeapon.ParticleClawLauncher -> firepower.rangeMultiplier + is ShipWeapon.LightningYarn -> firepower.rangeMultiplier + else -> 1.0 + } + + PickRequest( + PickType.Ship(targetSet), + PickBoundary.WeaponsFire( + center = position.location, + facing = position.facing, + minDistance = weapon.minRange, + maxDistance = weapon.maxRange * weaponRangeMult, + firingArcs = weapon.firingArcs, + canSelfSelect = owner in targetSet + ) + ) + } +} + +fun ImpactResult.toChatEntry(attacker: ShipAttacker, weapon: ShipWeaponInstance?) = when (this) { + is ImpactResult.Damaged -> when (damage) { + is ImpactDamage.Success -> ChatEntry.ShipAttacked( + ship = ship.id, + attacker = attacker, + sentAt = Moment.now, + damageInflicted = damage.amount, + weapon = weapon?.weapon, + critical = critical.report(), + ) + is ImpactDamage.Failed -> ChatEntry.ShipAttackFailed( + ship = ship.id, + attacker = attacker, + sentAt = Moment.now, + weapon = weapon?.weapon, + damageIgnoreType = damage.ignore + ) + else -> null + } + is ImpactResult.Destroyed -> { + ChatEntry.ShipDestroyed( + ship = ship.id, + sentAt = Moment.now, + destroyedBy = attacker, + ) + } +} + +fun GameState.useWeaponPickResponse(attacker: ShipInstance, weaponId: Id, target: PickResponse): GameEvent { + val weapon = attacker.armaments[weaponId] ?: return GameEvent.InvalidAction("That weapon does not exist") + + return when (val weaponType = weapon.weapon) { + is AreaWeapon -> { + val targetedLocation = (target as? PickResponse.Location)?.position ?: return GameEvent.InvalidAction("Invalid pick response type") + val targetedShips = ships.filterValues { (it.position.location - targetedLocation).length < weaponType.areaRadius } + + if (targetedShips.isEmpty()) return GameEvent.InvalidAction("No ships targeted - aborting fire") + + val newAttacker = attacker.afterUsing(weaponId) + + val impacts = targetedShips.mapValues { (_, ship) -> + ship.afterTargeted(attacker, weaponId) + } + + val newShips = ships.filterKeys { id -> + id !in impacts + } + impacts.mapNotNull { (id, impact) -> + (impact as? ImpactResult.Damaged)?.ship?.let { id to it } + }.toMap() + mapOf(attacker.id to newAttacker) + + val newWrecks = destroyedShips + impacts.mapNotNull { (id, impact) -> + (impact as? ImpactResult.Destroyed)?.ship?.let { id to it } + }.toMap() + + val newChatMessages = chatBox + impacts.mapNotNull { (_, impact) -> + impact.toChatEntry(ShipAttacker.EnemyShip(newAttacker.id), weapon) + } + + GameEvent.StateChange( + copy(ships = newShips, destroyedShips = newWrecks, chatBox = newChatMessages) + .withRecalculatedInitiative { calculateAttackPhaseInitiative() } + ) + } + else -> { + val targetedShipId = (target as? PickResponse.Ship)?.id ?: return GameEvent.InvalidAction("Invalid pick response type") + val targetedShip = ships[targetedShipId] ?: return GameEvent.InvalidAction("That ship does not exist") + + val impact = targetedShip.afterTargeted(attacker, weaponId) + + val newAttacker = if (targetedShipId == attacker.id) { + if (impact is ImpactResult.Damaged) + impact.ship.afterUsing(weaponId) + else + null + } else + attacker.afterUsing(weaponId) + + val newShips = (if (impact is ImpactResult.Damaged) + ships + mapOf(targetedShipId to impact.ship) + else ships - targetedShipId) + (if (newAttacker != null) + mapOf(attacker.id to newAttacker) + else emptyMap()) + + val newWrecks = destroyedShips + if (impact is ImpactResult.Destroyed) + mapOf(targetedShipId to impact.ship) + else emptyMap() + + val newChatMessages = chatBox + listOfNotNull( + impact.toChatEntry(ShipAttacker.EnemyShip(attacker.id), weapon) + ) + + GameEvent.StateChange( + copy(ships = newShips, destroyedShips = newWrecks, chatBox = newChatMessages) + .withRecalculatedInitiative { calculateAttackPhaseInitiative() } + ) + } + } +} + +val ShipWeapon.displayName: String + get() { + val firingArcsDesc = when (firingArcs) { + FiringArc.FIRE_360 -> "360-Degree " + FiringArc.FIRE_BROADSIDE -> "Broadside " + FiringArc.FIRE_FORE_270 -> "Dorsal " + setOf(FiringArc.ABEAM_PORT) -> "Port " + setOf(FiringArc.ABEAM_STARBOARD) -> "Starboard " + setOf(FiringArc.BOW) -> "Fore " + setOf(FiringArc.STERN) -> "Rear " + setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) -> "Wide-Angle Port " + setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) -> "Wide-Angle Starboard " + else -> null + }.takeIf { this !is ShipWeapon.Hangar } ?: "" + + val weaponIsPlural = numShots > 1 + + val weaponDesc = when (this) { + is ShipWeapon.Cannon -> "Cannon" + (if (weaponIsPlural) "s" else "") + is ShipWeapon.Lance -> "Lance" + (if (weaponIsPlural) "s" else "") + is ShipWeapon.Hangar -> when (wing) { + StrikeCraftWing.FIGHTERS -> "Fighters" + StrikeCraftWing.BOMBERS -> "Bombers" + } + is ShipWeapon.Torpedo -> "Torpedo" + (if (weaponIsPlural) "es" else "") + is ShipWeapon.ParticleClawLauncher -> "Particle Claw" + (if (weaponIsPlural) "s" else "") + is ShipWeapon.LightningYarn -> "Lightning Yarn" + is ShipWeapon.MegaCannon -> "Mega Giga Cannon" + is ShipWeapon.RevelationGun -> "Revelation Gun" + is ShipWeapon.EmpAntenna -> "EMP Antenna" + } + + return "$firingArcsDesc$weaponDesc" + } + +val ShipWeaponInstance.displayName: String + get() { + val weaponParam = when (this) { + is ShipWeaponInstance.Lance -> " (${charge.toPercent()})" + is ShipWeaponInstance.Hangar -> " (${wingHealth.toPercent()})" + is ShipWeaponInstance.MegaCannon -> " ($remainingShots)" + is ShipWeaponInstance.RevelationGun -> " ($remainingShots)" + is ShipWeaponInstance.EmpAntenna -> " ($remainingShots)" + else -> "" + } + + return "${weapon.displayName}$weaponParam" + } diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_weapons_formats.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_weapons_formats.kt new file mode 100644 index 0000000..cff3fd0 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_weapons_formats.kt @@ -0,0 +1,246 @@ +package net.starshipfights.game + +import net.starshipfights.data.Id + +private class ShipWeaponIdCounter { + private var numCannons = 0 + private var numLances = 0 + private var numHangars = 0 + private var numTorpedoes = 0 + private var numParticleClaws = 0 + private var numLightningYarn = 0 + + fun nextId(shipWeapon: ShipWeapon): Id = Id( + when (shipWeapon) { + is ShipWeapon.Cannon -> "cannons-${++numCannons}" + is ShipWeapon.Lance -> "lances-${++numLances}" + is ShipWeapon.Hangar -> "hangar-${++numHangars}" + is ShipWeapon.Torpedo -> "torpedo-${++numTorpedoes}" + is ShipWeapon.ParticleClawLauncher -> "particle-claw-${++numParticleClaws}" + is ShipWeapon.LightningYarn -> "lightning-yarn-${++numLightningYarn}" + else -> "super-weapon" + } + ) + + fun add(addTo: MutableMap, ShipWeapon>, shipWeapon: ShipWeapon) { + if (shipWeapon.numShots <= 0) return + addTo[nextId(shipWeapon)] = shipWeapon + } +} + +fun mechyrdiaShipWeapons( + torpedoRows: Int, + hasMegaCannon: Boolean, + + cannonSections: Int, + lanceSections: Int, + hangarSections: Int, + dorsalLances: Int, +): ShipArmaments { + val idCounter = ShipWeaponIdCounter() + val weapons = mutableMapOf, ShipWeapon>() + + repeat(torpedoRows * 2) { + idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launchers")) + } + + if (hasMegaCannon) + idCounter.add(weapons, ShipWeapon.MegaCannon) + + repeat(cannonSections) { + idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) + idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) + } + + repeat(lanceSections) { + idCounter.add(weapons, ShipWeapon.Lance(2, setOf(FiringArc.ABEAM_PORT), "Port lance battery")) + idCounter.add(weapons, ShipWeapon.Lance(2, setOf(FiringArc.ABEAM_STARBOARD), "Starboard lance battery")) + } + + repeat(hangarSections * 2) { w -> + if (w % 2 == 0) + idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.FIGHTERS, "Fighter complement")) + else + idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.BOMBERS, "Bomber complement")) + } + + repeat(dorsalLances) { + idCounter.add(weapons, ShipWeapon.Lance(1, FiringArc.FIRE_BROADSIDE, "Dorsal lance turrets")) + } + + return weapons +} + +fun mechyrdiaNanoClassWeapons(): ShipArmaments { + val idCounter = ShipWeaponIdCounter() + val weapons = mutableMapOf, ShipWeapon>() + + idCounter.add(weapons, ShipWeapon.Lance(2, FiringArc.FIRE_FORE_270, "Dorsal lance turrets")) + + return weapons +} + +fun mechyrdiaPicoClassWeapons(): ShipArmaments { + val idCounter = ShipWeaponIdCounter() + val weapons = mutableMapOf, ShipWeapon>() + + idCounter.add(weapons, ShipWeapon.Cannon(2, FiringArc.FIRE_FORE_270, "Double-barrel cannon turret")) + idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launcher")) + + return weapons +} + +fun ndrcShipWeapons( + torpedoes: Int, + hasMegaCannon: Boolean, + + numDorsalLances: Int, + foreFiringDorsalLances: Boolean, + + numBroadsideCannons: Int, + numBroadsideLances: Int +): ShipArmaments { + val idCounter = ShipWeaponIdCounter() + val weapons = mutableMapOf, ShipWeapon>() + + repeat(torpedoes) { + idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launchers")) + } + + if (hasMegaCannon) + idCounter.add(weapons, ShipWeapon.MegaCannon) + + if (numDorsalLances > 0) + idCounter.add(weapons, ShipWeapon.Lance(numDorsalLances, if (foreFiringDorsalLances) FiringArc.FIRE_FORE_270 else FiringArc.FIRE_BROADSIDE, "Dorsal lance batteries")) + + if (numBroadsideCannons > 0) { + idCounter.add(weapons, ShipWeapon.Cannon(numBroadsideCannons, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) + idCounter.add(weapons, ShipWeapon.Cannon(numBroadsideCannons, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) + } + + if (numBroadsideLances > 0) { + idCounter.add(weapons, ShipWeapon.Lance(numBroadsideLances, setOf(FiringArc.ABEAM_PORT), "Port lance battery")) + idCounter.add(weapons, ShipWeapon.Lance(numBroadsideLances, setOf(FiringArc.ABEAM_STARBOARD), "Starboard lance battery")) + } + + return weapons +} + +fun diadochiShipWeapons( + torpedoes: Int, + hasRevelationGun: Boolean, + + cannonSections: Int, + lanceSections: Int, + hangarSections: Int, + dorsalLances: Int, +): ShipArmaments { + val idCounter = ShipWeaponIdCounter() + val weapons = mutableMapOf, ShipWeapon>() + + repeat(torpedoes) { + idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launchers")) + } + + if (hasRevelationGun) + idCounter.add(weapons, ShipWeapon.RevelationGun) + + repeat(cannonSections) { + idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) + idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) + } + + repeat(lanceSections) { + idCounter.add(weapons, ShipWeapon.Lance(2, setOf(FiringArc.ABEAM_PORT), "Port lance battery")) + idCounter.add(weapons, ShipWeapon.Lance(2, setOf(FiringArc.ABEAM_STARBOARD), "Starboard lance battery")) + } + + repeat(hangarSections * 2) { w -> + if (w % 2 == 0) + idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.FIGHTERS, "Fighter complement")) + else + idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.BOMBERS, "Bomber complement")) + } + + repeat(dorsalLances) { + idCounter.add(weapons, ShipWeapon.Lance(2, FiringArc.FIRE_FORE_270, "Dorsal lance batteries")) + } + + return weapons +} + +fun felinaeShipWeapons( + particleClaws: Map, + lightningYarn: Map, Int> +): ShipArmaments { + val idCounter = ShipWeaponIdCounter() + val weapons = mutableMapOf, ShipWeapon>() + + for ((arc, num) in particleClaws) { + idCounter.add(weapons, ShipWeapon.ParticleClawLauncher(num, setOf(arc), "${arc.displayName} particle claws")) + } + + for ((arcs, num) in lightningYarn) { + val displayName = arcs.joinToString(separator = "/") { it.displayName } + idCounter.add(weapons, ShipWeapon.LightningYarn(num, arcs, "$displayName lightning yarn")) + } + + return weapons +} + +fun fulkreykkShipWeapons( + torpedoes: Int, + hasPulseBeam: Boolean, + + cannonSections: Int, + lanceSections: Int, +): ShipArmaments { + val idCounter = ShipWeaponIdCounter() + val weapons = mutableMapOf, ShipWeapon>() + + repeat(torpedoes) { + idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launchers")) + } + + if (hasPulseBeam) + idCounter.add(weapons, ShipWeapon.EmpAntenna) + + repeat(cannonSections) { + idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) + idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) + } + + repeat(lanceSections) { + idCounter.add(weapons, ShipWeapon.Lance(2, FiringArc.FIRE_BROADSIDE, "Broadside lance battery")) + } + + return weapons +} + +fun vestigiumShipWeapons( + foreCannons: Int, + foreHangars: Int, + + dorsalCannons: Int, + dorsalLances: Int, + dorsalHangars: Int, +): ShipArmaments { + val idCounter = ShipWeaponIdCounter() + val weapons = mutableMapOf, ShipWeapon>() + + idCounter.add(weapons, ShipWeapon.Cannon(foreCannons, setOf(FiringArc.BOW), "Fore cannon battery")) + + idCounter.add(weapons, ShipWeapon.Cannon(dorsalCannons, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) + idCounter.add(weapons, ShipWeapon.Cannon(dorsalCannons, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) + + idCounter.add(weapons, ShipWeapon.Lance(dorsalLances, FiringArc.FIRE_BROADSIDE, "Broadside lance battery")) + + repeat(foreHangars + dorsalHangars) { w -> + if (w % 2 == 0) + idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.FIGHTERS, "Fighter complement")) + else + idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.BOMBERS, "Bomber complement")) + } + + return weapons +} diff --git a/src/commonMain/kotlin/net/starshipfights/game/ship_weapons_list.kt b/src/commonMain/kotlin/net/starshipfights/game/ship_weapons_list.kt new file mode 100644 index 0000000..1508a94 --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/ship_weapons_list.kt @@ -0,0 +1,174 @@ +package net.starshipfights.game + +val ShipType.armaments: ShipArmaments + get() = when (this) { + ShipType.MICRO -> mechyrdiaShipWeapons(1, false, 1, 0, 0, 0) + ShipType.NANO -> mechyrdiaNanoClassWeapons() + ShipType.PICO -> mechyrdiaPicoClassWeapons() + ShipType.GLADIUS -> mechyrdiaShipWeapons(2, false, 1, 0, 0, 1) + ShipType.PILUM -> mechyrdiaShipWeapons(2, false, 0, 1, 0, 1) + ShipType.SICA -> mechyrdiaShipWeapons(2, false, 0, 0, 1, 1) + ShipType.KAISERSWELT -> mechyrdiaShipWeapons(2, false, 2, 0, 0, 0) + ShipType.KAROLINA -> mechyrdiaShipWeapons(2, false, 0, 1, 1, 0) + ShipType.KOZACHNIA -> mechyrdiaShipWeapons(2, false, 1, 0, 1, 0) + ShipType.MONT_IMPERIAL -> mechyrdiaShipWeapons(2, false, 0, 2, 0, 0) + ShipType.MUNDUS_CAESARIS_DIVI -> mechyrdiaShipWeapons(0, true, 2, 0, 0, 0) + ShipType.VENSCA -> mechyrdiaShipWeapons(2, false, 1, 1, 0, 0) + ShipType.AUCTORITAS -> mechyrdiaShipWeapons(3, false, 1, 1, 0, 2) + ShipType.CIVITAS -> mechyrdiaShipWeapons(3, false, 1, 0, 1, 2) + ShipType.HONOS -> mechyrdiaShipWeapons(0, true, 1, 0, 1, 2) + ShipType.IMPERIUM -> mechyrdiaShipWeapons(3, false, 0, 0, 2, 2) + ShipType.PAX -> mechyrdiaShipWeapons(3, false, 2, 0, 0, 2) + ShipType.PIETAS -> mechyrdiaShipWeapons(0, true, 2, 0, 0, 2) + ShipType.EARTH -> mechyrdiaShipWeapons(3, false, 1, 1, 1, 1) + ShipType.LANGUAVARTH -> mechyrdiaShipWeapons(3, false, 1, 2, 0, 1) + ShipType.MECHYRDIA -> mechyrdiaShipWeapons(3, false, 3, 0, 0, 1) + ShipType.NOVA_ROMA -> mechyrdiaShipWeapons(0, true, 0, 3, 0, 1) + ShipType.TYLA -> mechyrdiaShipWeapons(3, false, 1, 0, 2, 1) + + ShipType.JAGER -> ndrcShipWeapons(2, true, 0, false, 0, 2) + ShipType.NOVAATJE -> ndrcShipWeapons(0, true, 2, true, 3, 0) + ShipType.ZWAARD -> ndrcShipWeapons(2, false, 2, true, 3, 0) + ShipType.SLAGSCHIP -> ndrcShipWeapons(3, false, 2, true, 5, 0) + ShipType.VOORHOEDE -> ndrcShipWeapons(3, true, 0, false, 3, 1) + ShipType.KRIJGSCHUIT -> ndrcShipWeapons(4, true, 2, false, 6, 0) + + ShipType.ERIS -> diadochiShipWeapons(2, false, 1, 0, 0, 0) + ShipType.TYPHON -> diadochiShipWeapons(0, false, 1, 0, 0, 1) + ShipType.AHRIMAN -> diadochiShipWeapons(1, false, 0, 1, 0, 0) + ShipType.APOPHIS -> diadochiShipWeapons(1, false, 0, 0, 1, 1) + ShipType.AZATHOTH -> diadochiShipWeapons(1, false, 1, 0, 0, 0) + ShipType.CHERNOBOG -> diadochiShipWeapons(2, false, 0, 2, 0, 0) + ShipType.CIPACTLI -> diadochiShipWeapons(2, false, 2, 0, 0, 0) + ShipType.LAMASHTU -> diadochiShipWeapons(2, false, 0, 1, 1, 0) + ShipType.LOTAN -> diadochiShipWeapons(2, false, 0, 0, 2, 2) + ShipType.MORGOTH -> diadochiShipWeapons(2, false, 1, 1, 0, 0) + ShipType.TIAMAT -> diadochiShipWeapons(2, false, 1, 0, 1, 1) + ShipType.CHARYBDIS -> diadochiShipWeapons(3, false, 3, 0, 0, 3) + ShipType.KAKIA -> diadochiShipWeapons(3, false, 1, 2, 0, 3) + ShipType.MOLOCH -> diadochiShipWeapons(3, false, 1, 1, 1, 3) + ShipType.SCYLLA -> diadochiShipWeapons(3, false, 1, 0, 2, 3) + ShipType.AEDON -> diadochiShipWeapons(0, true, 4, 0, 2, 4) + + ShipType.KODKOD -> felinaeShipWeapons( + mapOf( + FiringArc.BOW to 4, + FiringArc.ABEAM_PORT to 3, + FiringArc.ABEAM_STARBOARD to 3, + ), + emptyMap() + ) + ShipType.ONCILLA -> felinaeShipWeapons( + mapOf( + FiringArc.BOW to 2, + ), + mapOf( + setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) to 1, + setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) to 1, + ) + ) + ShipType.MARGAY -> felinaeShipWeapons( + mapOf( + FiringArc.BOW to 2, + FiringArc.ABEAM_PORT to 2, + FiringArc.ABEAM_STARBOARD to 2, + ), + mapOf( + FiringArc.FIRE_FORE_270 to 1, + setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) to 1, + setOf(FiringArc.ABEAM_PORT) to 1, + setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) to 1, + setOf(FiringArc.ABEAM_STARBOARD) to 1, + ) + ) + ShipType.OCELOT -> felinaeShipWeapons( + mapOf( + FiringArc.BOW to 4, + FiringArc.ABEAM_PORT to 4, + FiringArc.ABEAM_STARBOARD to 4, + ), + mapOf( + FiringArc.FIRE_FORE_270 to 1, + FiringArc.FIRE_BROADSIDE to 1, + ) + ) + ShipType.BOBCAT -> felinaeShipWeapons( + mapOf( + FiringArc.BOW to 3, + FiringArc.ABEAM_PORT to 3, + FiringArc.ABEAM_STARBOARD to 3, + ), + mapOf( + setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) to 3, + setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) to 3, + FiringArc.FIRE_BROADSIDE to 3, + ) + ) + ShipType.LYNX -> felinaeShipWeapons( + mapOf( + FiringArc.BOW to 5, + FiringArc.ABEAM_PORT to 5, + FiringArc.ABEAM_STARBOARD to 5, + ), + emptyMap() + ) + ShipType.LEOPARD -> felinaeShipWeapons( + mapOf( + FiringArc.BOW to 5, + FiringArc.ABEAM_PORT to 5, + FiringArc.ABEAM_STARBOARD to 5, + ), + emptyMap() + ) + ShipType.TIGER -> felinaeShipWeapons( + mapOf( + FiringArc.BOW to 3, + FiringArc.ABEAM_PORT to 3, + FiringArc.ABEAM_STARBOARD to 3, + ), + mapOf( + setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) to 3, + setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) to 3, + FiringArc.FIRE_BROADSIDE to 3, + ) + ) + ShipType.CARACAL -> felinaeShipWeapons( + mapOf( + FiringArc.BOW to 10, + FiringArc.ABEAM_PORT to 10, + FiringArc.ABEAM_STARBOARD to 10, + ), + mapOf( + setOf(FiringArc.BOW) to 5, + setOf(FiringArc.ABEAM_PORT) to 5, + setOf(FiringArc.ABEAM_STARBOARD) to 5, + ) + ) + + ShipType.GANNAN -> fulkreykkShipWeapons(0, true, 0, 0) + ShipType.LODOVIK -> fulkreykkShipWeapons(4, false, 0, 0) + ShipType.KARNAS -> fulkreykkShipWeapons(2, false, 2, 0) + ShipType.PERTONA -> fulkreykkShipWeapons(2, false, 1, 1) + ShipType.VOSS -> fulkreykkShipWeapons(2, false, 0, 2) + ShipType.BREKORYN -> fulkreykkShipWeapons(3, false, 2, 0) + ShipType.FALK -> fulkreykkShipWeapons(0, true, 1, 1) + ShipType.LORUS -> fulkreykkShipWeapons(0, true, 2, 0) + ShipType.ORSH -> fulkreykkShipWeapons(3, false, 0, 2) + ShipType.TEFRAN -> fulkreykkShipWeapons(3, false, 1, 1) + ShipType.KASSCK -> fulkreykkShipWeapons(4, false, 3, 0) + ShipType.KHORR -> fulkreykkShipWeapons(4, false, 1, 2) + + ShipType.COLEMAN -> vestigiumShipWeapons(4, 0, 1, 1, 0) + ShipType.JEFFERSON -> vestigiumShipWeapons(4, 0, 0, 1, 1) + ShipType.QUENNEY -> vestigiumShipWeapons(4, 0, 0, 0, 2) + ShipType.ROOSEVELT -> vestigiumShipWeapons(4, 0, 0, 2, 0) + ShipType.WASHINGTON -> vestigiumShipWeapons(4, 0, 3, 0, 0) + ShipType.ARLINGTON -> vestigiumShipWeapons(7, 0, 1, 0, 0) + ShipType.CONCORD -> vestigiumShipWeapons(7, 0, 0, 0, 1) + ShipType.LEXINGTON -> vestigiumShipWeapons(7, 0, 0, 1, 0) + ShipType.RAVEN_ROCK -> vestigiumShipWeapons(1, 4, 0, 1, 0) + ShipType.IOWA -> vestigiumShipWeapons(9, 0, 0, 2, 0) + ShipType.MARYLAND -> vestigiumShipWeapons(3, 4, 0, 2, 0) + ShipType.NEW_YORK -> vestigiumShipWeapons(0, 6, 0, 0, 2) + ShipType.OHIO -> vestigiumShipWeapons(9, 0, 3, 0, 0) + } diff --git a/src/commonMain/kotlin/net/starshipfights/game/util.kt b/src/commonMain/kotlin/net/starshipfights/game/util.kt new file mode 100644 index 0000000..e53fc7b --- /dev/null +++ b/src/commonMain/kotlin/net/starshipfights/game/util.kt @@ -0,0 +1,59 @@ +package net.starshipfights.game + +import kotlinx.html.* +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.PairSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.Json +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.pow +import kotlin.math.roundToInt + +val jsonSerializer = Json { + classDiscriminator = "\$ktClass" + coerceInputValues = true + encodeDefaults = false + ignoreUnknownKeys = true + useAlternativeNames = false +} + +class MapAsListSerializer(keySerializer: KSerializer, valueSerializer: KSerializer) : KSerializer> { + private val inner = ListSerializer(PairSerializer(keySerializer, valueSerializer)) + + override val descriptor: SerialDescriptor + get() = inner.descriptor + + override fun serialize(encoder: Encoder, value: Map) { + inner.serialize(encoder, value.toList()) + } + + override fun deserialize(decoder: Decoder): Map { + return inner.deserialize(decoder).toMap() + } +} + +const val EPSILON = 0.00_001 + +fun > T.toUrlSlug() = name.replace('_', '-').lowercase() + +fun Double.toPercent() = "${(this * 100).roundToInt()}%" + +fun smoothMinus1To1(x: Double, exponent: Double = 1.0) = x / (1 + abs(x).pow(exponent)).pow(1 / exponent) +fun smoothNegative(x: Double) = if (x < 0) exp(x) else x + 1 + +fun Iterable.joinToDisplayString(oxfordComma: Boolean = true, transform: (T) -> String = { it.toString() }): String = when (val size = count()) { + 0 -> "" + 1 -> transform(single()) + 2 -> "${transform(first())} and ${transform(last())}" + else -> "${take(size - 1).joinToString { transform(it) }}${if (oxfordComma) "," else ""} and ${transform(last())}" +} + +inline fun T.foreign(language: String, crossinline block: SPAN.() -> Unit) = span { + lang = language + style = "font-style: italic" + block() +} diff --git a/src/commonMain/kotlin/starshipfights/data/admiralty/admiral_names.kt b/src/commonMain/kotlin/starshipfights/data/admiralty/admiral_names.kt deleted file mode 100644 index 520829d..0000000 --- a/src/commonMain/kotlin/starshipfights/data/admiralty/admiral_names.kt +++ /dev/null @@ -1,873 +0,0 @@ -package starshipfights.data.admiralty - -import starshipfights.game.Faction -import kotlin.random.Random - -enum class AdmiralNameFlavor { - MECHYRDIA, TYLA, CALIBOR, OLYMPIA, // Mechyrdia-aligned - DUTCH, // NdRC-aliged - NORTHERN_DIADOCHI, SOUTHERN_DIADOCHI, // Masra Draetsen-aligned - FULKREYKK, // Isarnareykk-aligned - AMERICAN, HISPANIC_AMERICAN; // Vestigium-aligned - - val displayName: String - get() = when (this) { - MECHYRDIA -> "Mechyrdian" - TYLA -> "Tylan" - CALIBOR -> "Caliborese" - OLYMPIA -> "Olympian" - DUTCH -> "Dutch" - NORTHERN_DIADOCHI -> "Northern Diadochi" - SOUTHERN_DIADOCHI -> "Southern Diadochi" - FULKREYKK -> "Thedish" - AMERICAN -> "American" - HISPANIC_AMERICAN -> "Hispanic-American" - } - - companion object { - fun forFaction(faction: Faction) = when (faction) { - Faction.MECHYRDIA -> setOf(MECHYRDIA, TYLA, CALIBOR, OLYMPIA, DUTCH) - Faction.NDRC -> setOf(DUTCH) - Faction.MASRA_DRAETSEN -> setOf(CALIBOR, NORTHERN_DIADOCHI, SOUTHERN_DIADOCHI) - Faction.FELINAE_FELICES -> setOf(OLYMPIA) - Faction.ISARNAREYKK -> setOf(FULKREYKK) - Faction.VESTIGIUM -> setOf(AMERICAN, HISPANIC_AMERICAN) - } - } -} - -object AdmiralNames { - // PERSONAL NAME to PATRONYMIC - private val mechyrdianMaleNames: List> = listOf( - "Marc" to "Marcówič", - "Anton" to "Antonówič", - "Bjarnarð" to "Bjarnarðówič", - "Carl" to "Carlówič", - "Þjutarix" to "Þjutarigówič", - "Friðurix" to "Friðurigówič", - "Iwan" to "Iwanówič", - "Wladimer" to "Wladimerówič", - "Giulius" to "Giuliówič", - "Nicólei" to "Nicóleiówič", - "Þjódor" to "Þjóderówič", - "Sigismund" to "Sigismundówič", - "Stefan" to "Stefanówič", - "Wilhelm" to "Wilhelmówič", - "Giórgj" to "Giórgiówič" - ) - - // PERSONAL NAME to MATRONYMIC - private val mechyrdianFemaleNames: List> = listOf( - "Octavia" to "Octaviówca", - "Annica" to "Annicówca", - "Astrið" to "Astriðówca", - "Caþarin" to "Caþarinówca", - "Signi" to "Signówca", - "Erica" to "Ericówca", - "Fréja" to "Fréjówca", - "Hilda" to "Hildówca", - "Žanna" to "Žannówca", - "Xenia" to "Xeniówca", - "Carina" to "Carinówca", - "Giadwiga" to "Giadwigówca", - "Ženia" to "Ženiówca" - ) - - private val mechyrdianFamilyNames: List> = listOf( - "Alexandrów", - "Antonów", - "Pogdanów", - "Hrusčjów", - "Caísarów", - "Carolów", - "Sócolów", - "Romanów", - "Nemeciów", - "Pjótrów", - "Brutów", - "Augustów", - "Calašniców", - "Anželów", - "Sigmarów", - "Dróganów", - "Coroljów", - "Wlasów" - ).map { it to "${it}a" } - - private fun randomMechyrdianName(isFemale: Boolean) = if (isFemale) - mechyrdianFemaleNames.random().first + " " + mechyrdianFemaleNames.random().second + " " + mechyrdianFamilyNames.random().second - else - mechyrdianMaleNames.random().first + " " + mechyrdianMaleNames.random().second + " " + mechyrdianFamilyNames.random().first - - private val tylanMaleNames = listOf( - "Althanar" to "Althanas", - "Aurans" to "Aurantes", - "Bochra" to "Bochranes", - "Chshaejar" to "Chshaejas", - "Hjofvachi" to "Hjovachines", - "Koldimar" to "Koldimas", - "Kor" to "Kores", - "Ljomas" to "Ljomates", - "Shajel" to "Shajel", - "Shokar" to "Shokas", - "Tolavajel" to "Tolavajel", - "Voskar" to "Voskas", - ) - - private val tylanFemaleNames = listOf( - "Althe" to "Althenes", - "Anaseil" to "Anaseil", - "Asetbur" to "Asetbus", - "Atautha" to "Atauthas", - "Aurantia" to "Aurantias", - "Ilasheva" to "Ilashevas", - "Kalora" to "Kaloras", - "Kotolva" to "Kotolvas", - "Psekna" to "Pseknas", - "Shenera" to "Sheneras", - "Reoka" to "Reokas", - "Velga" to "Velgas", - ) - - private val tylanFamilyNames = listOf( - "Kalevkar" to "Kalevka", - "Merku" to "Merkussa", - "Telet" to "Telet", - "Eutokar" to "Eutoka", - "Vsocha" to "Vsochessa", - "Vilar" to "Vilakauva", - "Nikasrar" to "Nika", - "Vlegamakar" to "Vlegamaka", - "Vtokassar" to "Vtoka", - "Theiar" to "Theia", - "Aretar" to "Areta", - "Derkas" to "Derkata", - "Vinsennas" to "Vinsenatta", - "Kleio" to "Kleona" - ) - - // Tylans use matronymics for both sons and daughters - private fun randomTylanName(isFemale: Boolean) = if (isFemale) - tylanFemaleNames.random().first + " " + tylanFemaleNames.random().second + "-Nahra " + tylanFamilyNames.random().second - else - tylanMaleNames.random().first + " " + tylanFemaleNames.random().second + "-Nensar " + tylanFamilyNames.random().first - - private val caliboreseNames = listOf( - "Jathee", - "Muly", - "Simoh", - "Laka", - "Foryn", - "Duxio", - "Xirio", - "Surmy", - "Datarme", - "Cloren", - "Tared", - "Quiliot", - "Attiol", - "Quarree", - "Guil", - "Miro", - "Yryys", - "Zarx", - "Karm", - "Mreek", - "Dulyy", - "Quorqui", - "Dreminor", - "Samitu", - "Lurmak", - "Quashi", - "Barsyn", - "Rymyo", - "Soli", - "Ickart", - "Woom", - "Qurquy", - "Ymiro", - "Rosiliq", - "Xant", - "Xateen", - "Mssly", - "Vixie", - "Quelynn", - "Plly", - "Tessy", - "Veekah", - "Quett", - "Xezeez", - "Xyph", - "Jixi", - "Jeekie", - "Meelen", - "Rasah", - "Reteeshy", - "Xinchie", - "Zae", - "Ziggy", - "Wurikah", - "Loppie", - "Tymma", - "Reely", - "Yjutee", - "Len", - "Vixirat", - "Xumie", - "Xilly", - "Liwwy", - "Gancee", - "Pamah", - "Zeryll", - "Luteet", - "Qusseet", - "Alixika", - "Sepirah", - "Luttrah", - "Aramynn", - "Laxerynn", - "Murylyt", - "Quarapyt", - "Tormiray", - "Daromynn", - "Zuleerynn", - "Quarimat", - "Dormaquazi", - "Tullequazi", - "Aleeray", - "Eppiquit", - "Wittirynn", - "Semiokolipan", - "Sosopurr", - "Quamixit", - "Croffet", - "Xaalit", - "Xemiolyt" - ) - - private val caliboreseVowels = "aeiouy".toSet() - private fun randomCaliboreseName(isFemale: Boolean) = caliboreseNames.filter { - it.length < 8 && (isFemale == (it.last() in caliboreseVowels)) - }.random() + " " + caliboreseNames.filter { it.length > 7 }.random() - - private val latinMaleCommonPraenomina = listOf( - "Gaius", - "Lucius", - "Marcus", - ) - - private val latinMaleUncommonPraenomina = listOf( - "Publius", - "Quintus", - "Titus", - "Gnaeus" - ) - - private val latinMaleRarePraenomina = listOf( - "Aulus", - "Spurius", - "Tiberius", - "Servius", - "Hostus" - ) - - private val latinFemaleCommonPraenomina = listOf( - "Gaia", - "Lucia", - "Marcia", - ) - - private val latinFemaleUncommonPraenomina = listOf( - "Prima", - "Secunda", - "Tertia", - "Quarta", - "Quinta", - "Sexta", - "Septima", - "Octavia", - "Nona", - "Decima" - ) - - private val latinFemaleRarePraenomina = listOf( - "Caesula", - "Titia", - "Tiberia", - "Tanaquil" - ) - - private val latinNominaGentilica = listOf( - "Aelius" to "Aelia", - "Aternius" to "Aternia", - "Caecilius" to "Caecilia", - "Cassius" to "Cassia", - "Claudius" to "Claudia", - "Cornelius" to "Cornelia", - "Calpurnius" to "Calpurnia", - "Fabius" to "Fabia", - "Flavius" to "Flavia", - "Fulvius" to "Fulvia", - "Haterius" to "Hateria", - "Hostilius" to "Hostilia", - "Iulius" to "Iulia", - "Iunius" to "Iunia", - "Iuventius" to "Iuventia", - "Lavinius" to "Lavinia", - "Licinius" to "Licinia", - "Marius" to "Maria", - "Octavius" to "Octavia", - "Pompeius" to "Pompeia", - "Porcius" to "Porcia", - "Salvius" to "Salvia", - "Sempronius" to "Sempronia", - "Spurius" to "Spuria", - "Terentius" to "Terentia", - "Tullius" to "Tullia", - "Ulpius" to "Ulpia", - "Valerius" to "Valeria" - ) - - private val latinCognomina = listOf( - "Agricola" to "Agricola", - "Agrippa" to "Agrippina", - "Aquilinus" to "Aquilina", - "Balbus" to "Balba", - "Bibulus" to "Bibula", - "Bucco" to "Bucco", - "Caecus" to "Caeca", - "Calidus" to "Calida", - "Catilina" to "Catilina", - "Catulus" to "Catula", - "Crassus" to "Crassa", - "Crispus" to "Crispa", - "Drusus" to "Drusilla", - "Flaccus" to "Flacca", - "Gracchus" to "Graccha", - "Laevinus" to "Laevina", - "Lanius" to "Lania", - "Lepidus" to "Lepida", - "Lucullus" to "Luculla", - "Marcellus" to "Marcella", - "Metellus" to "Metella", - "Nasica" to "Nasica", - "Nerva" to "Nerva", - "Paullus" to "Paulla", - "Piso" to "Piso", - "Priscus" to "Prisca", - "Publicola" to "Publicola", - "Pulcher" to "Pulchra", - "Regulus" to "Regula", - "Rufus" to "Rufa", - "Scaevola" to "Scaevola", - "Severus" to "Severa", - "Structus" to "Structa", - "Taurus" to "Taura", - "Varro" to "Varro", - "Vitulus" to "Vitula" - ) - - private fun randomLatinPraenomen(isFemale: Boolean) = when { - Random.nextBoolean() -> if (isFemale) latinFemaleCommonPraenomina else latinMaleCommonPraenomina - Random.nextInt(3) > 0 -> if (isFemale) latinFemaleUncommonPraenomina else latinMaleUncommonPraenomina - else -> if (isFemale) latinFemaleRarePraenomina else latinMaleRarePraenomina - }.random() - - private fun randomLatinName(isFemale: Boolean) = randomLatinPraenomen(isFemale) + " " + latinNominaGentilica.random().let { (m, f) -> if (isFemale) f else m } + " " + latinCognomina.random().let { (m, f) -> if (isFemale) f else m } - - private val dutchMaleNames = listOf( - "Aalderik", - "Andreas", - "Boudewijn", - "Bruno", - "Christiaan", - "Cornelius", - "Darnath", - "Dirk", - "Eren", - "Erwin", - "Frederik", - "Gerlach", - "Helbrant", - "Helbrecht", - "Hendrik", - "Jakob", - "Jochem", - "Joris", - "Koenraad", - "Koorland", - "Leopold", - "Lodewijk", - "Maarten", - "Michel", - "Niels", - "Pieter", - "Renaat", - "Rogal", - "Ruben", - "Sebastiaan", - "Sigismund", - "Sjaak", - "Tobias", - "Valentijn", - "Wiebrand", - ) - - private val dutchFemaleNames = listOf( - "Adelwijn", - "Amberlij", - "Annika", - "Arete", - "Eva", - "Gerda", - "Helga", - "Ida", - "Irene", - "Jacqueline", - "Josefien", - "Juliana", - "Katharijne", - "Lore", - "Margriet", - "Maximilia", - "Meike", - "Nora", - "Rebeka", - "Sara", - "Vera", - "Wilhelmina", - ) - - private val dutchMerchantHouses = listOf( - "Venetho", - "Luibeck", - "Birka", - "Heiðabýr", - "Rostok", - "Guistrov", - "Schverin", - "Koeln", - "Bruigge", - "Reval", - "Elbing", - "Dorpat", - "Stralsund", - "Mijdeborg", - "Breslaw", - "Dortmund", - "Antwerp", - "Falsterbo", - "Zwolle", - "Buchtehud", - "Bremen", - "Zutphen", - "Kampen", - "Grunn", - "Deventer", - "Wismer", - "Luinenburg", - - "Jager", - "Jastobaal", - "Varonius", - "Kupferberg", - "Dijn", - "Umboldt", - "Phalomor", - "Drijk", - "d'Wain", - "du Languille", - "Horstein", - "Jerulas", - "Kendar", - "Castellan", - "d'Aniasie", - "Gerrit", - "Hoed", - "lo Pan", - "Marchandrij", - "d'Aquairre", - "Terozzante", - "d'Argovon", - "de Monde", - "Paillender", - "Holstijn", - "d'Imperia", - "Borodin", - "Agranozza", - "d'Ortise", - "Ijzerhoorn", - "Dremel", - "Hinckel", - "Vuigens", - "Drazen", - "Marburg", - "Xardt", - "Lijze", - "Gerlach", - "Doorn", - "d'Arquebus", - "Alderic", - "Vogen" - ) - - private fun randomDutchName(isFemale: Boolean) = (if (isFemale) dutchFemaleNames else dutchMaleNames).random() + " van " + dutchMerchantHouses.random() - - private val diadochiMaleNames = listOf( - "Oqatai", - "Amogus", - "Nerokhan", - "Choghor", - "Aghonei", - "Martaq", - "Qaran", - "Khargh", - "Qolkhu", - "Ghauran", - "Woriv", - "Vorcha", - "Chagatai", - "Neghvar", - "Qitinga", - "Jimpaq", - "Bivat", - "Durash", - "Elifas", - "Ogus", - "Yuli", - "Saret", - "Mher", - "Tyver", - "Ghraq", - "Niran", - "Galik" - ) - - private val diadochiFemaleNames = listOf( - "Lursha", - "Jamoqena", - "Lokoria", - "Iekuna", - "Shara", - "Etugen", - "Maral", - "Temuln", - "Akhensari", - "Khadagan", - "Gherelma", - "Shechen", - "Althani", - "Tzyrina", - "Daghasi", - "Kloya", - ) - - private val northernDiadochiEpithetParts = listOf( - "Skull", - "Blood", - "Death", - "Claw", - "Doom", - "Dread", - "Soul", - "Spirit", - "Hell", - "Dread", - "Bale", - "Fire", - "Fist", - "Bear", - "Pyre", - "Dark", - "Vile", - "Heart", - "Murder", - "Gore", - "Daemon", - "Talon", - ) - - private fun randomNorthernDiadochiName(isFemale: Boolean) = (if (isFemale) diadochiFemaleNames else diadochiMaleNames).random() + " " + northernDiadochiEpithetParts.random() + northernDiadochiEpithetParts.random().lowercase() - - private val southernDiadochiClans = listOf( - "Arkai", - "Avado", - "Djahhim", - "Khankhen", - "Porok", - "Miras", - "Terok", - "Empok", - "Noragh", - "Nuunian", - "Soung", - "Akhero", - "Qozaq", - "Kherus", - "Axina", - "Ghaizas", - "Saxha", - "Meshu", - "Khopesh", - "Qitemar", - "Vang", - "Lugal", - "Galla", - "Hheka", - "Nesut", - "Koquon", - "Molekh" - ) - - private fun randomSouthernDiadochiClan() = when { - Random.nextInt(5) == 0 -> southernDiadochiClans.random() + "-" + southernDiadochiClans.random() - else -> southernDiadochiClans.random() - } - - private fun randomSouthernDiadochiName(isFemale: Boolean) = (if (isFemale) diadochiFemaleNames else diadochiMaleNames).random() + (if (isFemale && Random.nextBoolean()) " ka-" else " am-") + diadochiMaleNames.random() + " " + randomSouthernDiadochiClan() - - private val thedishMaleNames = listOf( - "Praethoris", - "Severus", - "Augast", - "Dagobar", - "Vrankenn", - "Kandar", - "Kleon", - "Glaius", - "Karul", - "Ylai", - "Toval", - "Ivon", - "Belis", - "Jorh", - "Svar", - "Alaric", - ) - - private val thedishFemaleNames = listOf( - "Serna", - "Veleska", - "Ielga", - "Glae", - "Rova", - "Ylia", - "Galera", - "Nerys", - "Veleer", - "Karuleyn", - "Amberli", - "Alysia", - "Lenera", - "Demeter", - ) - - private val thedishSurnames = listOf( - "Kassck", - "Orsh", - "Falk", - "Khorr", - "Vaskoman", - "Vholkazk", - "Brekoryn", - "Lorus", - "Karnas", - "Hathar", - "Takan", - "Pertona", - "Tefran", - "Arvi", - "Galvus", - "Voss", - "Mandanof", - "Ursali", - "Vytunn", - "Quesrinn", - ) - - private fun randomThedishName(isFemale: Boolean) = (if (isFemale) thedishFemaleNames else thedishMaleNames).random() + " " + thedishSurnames.random() - - private val americanMaleNames = listOf( - "George", - "John", - "Thomas", - "James", - "Quincy", - "Andrew", - "Martin", - "William", - "Henry", - "James", - "Zachary", - "Millard", - "Franklin", - "Abraham", - "Ulysses", - "Rutherford", - "Chester", - "Grover", - "Benjamin", - "Theodore", - "Warren", - "Calvin", - "Herbert", - "Harry", - "Dwight", - "Lyndon", - "Richard", - "Dick", - "Gerald", - "Jimmy", - "Ronald", - "Donald" - ) - - private val americanFemaleNames = listOf( - "Martha", - "Abigail", - "Elizabeth", - "Louisa", - "Emily", - "Sarah", - "Anna", - "Jane", - "Julia", - "Margaret", - "Harriet", - "Mary", - "Lucy", - "Rose", - "Caroline", - "Ida", - "Helen", - "Grace", - "Jacqueline", - "Thelma", - "Eleanor", - "Nancy", - "Barbara", - "Laura", - "Melania" - ) - - private val americanFamilyNames = listOf( - "Knox", - "Pickering", - "McHenry", - "Dexter", - "Drawborn", - "Eustis", - "Armstrong", - "Monroe", - "Crawford", - "Calhoun", - "Barbour", - "Porter", - "Eaton", - "Cass", - "Poinsett", - "Bell", - "Forrestal", - "Johnson", - "Marshall", - "Lovett", - "Wilson", - "McElroy", - "McNamara", - "Clifford", - "Richardson", - "Burndt", - ) - - private fun randomAmericanName(isFemale: Boolean) = (if (isFemale) americanFemaleNames else americanMaleNames).random() + " " + americanFamilyNames.random() - - private val hispanicMaleNames = listOf( - "Aaron", - "Antonio", - "Augusto", - "Eliseo", - "Manuel", - "Jose", - "Juan", - "Miguel", - "Rafael", - "Raul", - "Adriano", - "Emilio", - "Francisco", - "Ignacio", - "Marco", - "Pablo", - "Octavio", - "Victor", - "Vito", - "Valentin" - ) - - private val hispanicFemaleNames = listOf( - "Maria", - "Ana", - "Camila", - "Eva", - "Flora", - "Gloria", - "Julia", - "Marcelina", - "Rosalia", - "Victoria", - "Valentina", - "Cecilia", - "Francisca", - "Aurelia", - "Cristina", - "Magdalena", - "Margarita", - "Martina", - "Teresa" - ) - - private val hispanicFamilyNames = listOf( - "Acorda", - "Aguirre", - "Alzaga", - "Arriaga", - "Arrieta", - "Berroya", - "Barahona", - "Carranza", - "Carriaga", - "Elcano", - "Elizaga", - "Endaya", - "Franco", - "Garalde", - "Ibarra", - "Juarez", - "Lazarte", - "Legarda", - "Madariaga", - "Medrano", - "Narvaez", - "Olano", - "Ricarte", - "Salazar", - "Uriarte", - "Varona", - "Vergar", - ) - - private fun randomHispanicName(isFemale: Boolean) = (if (isFemale) hispanicFemaleNames else hispanicMaleNames).random() + " " + hispanicFamilyNames.random() - - fun randomName(flavor: AdmiralNameFlavor, isFemale: Boolean) = when (flavor) { - AdmiralNameFlavor.MECHYRDIA -> randomMechyrdianName(isFemale) - AdmiralNameFlavor.TYLA -> randomTylanName(isFemale) - AdmiralNameFlavor.CALIBOR -> randomCaliboreseName(isFemale) - AdmiralNameFlavor.OLYMPIA -> randomLatinName(isFemale) - AdmiralNameFlavor.DUTCH -> randomDutchName(isFemale) - AdmiralNameFlavor.NORTHERN_DIADOCHI -> randomNorthernDiadochiName(isFemale) - AdmiralNameFlavor.SOUTHERN_DIADOCHI -> randomSouthernDiadochiName(isFemale) - AdmiralNameFlavor.FULKREYKK -> randomThedishName(isFemale) - AdmiralNameFlavor.AMERICAN -> randomAmericanName(isFemale) - AdmiralNameFlavor.HISPANIC_AMERICAN -> randomHispanicName(isFemale) - } -} diff --git a/src/commonMain/kotlin/starshipfights/data/admiralty/ship_names.kt b/src/commonMain/kotlin/starshipfights/data/admiralty/ship_names.kt deleted file mode 100644 index d09ce98..0000000 --- a/src/commonMain/kotlin/starshipfights/data/admiralty/ship_names.kt +++ /dev/null @@ -1,452 +0,0 @@ -package starshipfights.data.admiralty - -import starshipfights.game.Faction -import starshipfights.game.ShipWeightClass -import kotlin.random.Random - -fun newShipName(faction: Faction, shipWeightClass: ShipWeightClass, existingNames: MutableSet) = generateSequence { - nameShip(faction, shipWeightClass) -}.take(20).dropWhile { it in existingNames }.firstOrNull()?.also { existingNames.add(it) } - -private val mechyrdianFrigateNames1 = listOf( - "Unconquerable", - "Indomitable", - "Invincible", - "Imperial", - "Regal", - "Royal", - "Imperious", - "Honorable", - "Defiant", - "Eternal", - "Infinite", - "Dominant", - "Divine", - "Righteous", - "Resplendent", - "Protective", - "Innocent", - "August", - "Loyal" -) - -private val mechyrdianFrigateNames2 = listOf( - "Faith", - "Empire", - "Royalty", - "Regality", - "Honor", - "Defiance", - "Eternity", - "Dominator", - "Divinity", - "Right", - "Righteousness", - "Resplendency", - "Defender", - "Protector", - "Innocence", - "Victory", - "Duty", - "Loyalty" -) - -private val mechyrdianCruiserNames1 = listOf( - "Defender of", - "Protector of", - "Shield of", - "Sword of", - "Champion of", - "Hero of", - "Salvation of", - "Savior of", - "Shining Light of", - "Righteous Flame of", - "Eternal Glory of", -) - -private val mechyrdianCruiserNames2 = listOf( - "Mechyrd", - "Kaiserswelt", - "Tenno no Wakusei", - "Nova Roma", - "Mont Imperial", - "Tyla", - "Vensca", - "Kaltag", - "Languavarth Prime", - "Languavarth Secundum", - "Elcialot", - "Othon", - "Starport", - "Sacrilegum", - "New Constantinople", - "Fairhus", - "Praxagora", - "Karolina", - "Kozachnia", - "New New Amsterdam", - "Mundus Caesaris Divi", - "Saiwatta", - "Earth" -) - -private val mechyrdianBattleshipNames = listOf( - "Kaiser Wilhelm I", - "Kaiser Wilhelm II", - "Empereur Napoléon I Bonaparte", - "Tsar Nikolaj II Romanov", - "Seliger Kaiser Karl I von Habsburg", - "Emperor Joshua A. Norton I", - "Emperor Meiji the Great", - "Emperor Jack G. Coleman", - "Emperor Trevor C. Neer", - "Emperor Connor F. Vance", - "Emperor Jean-Bédel Bokassa I", - "King Charles XII", - "King William I the Conqueror", - "King Alfred the Great", - "Gustavus Adolphus Magnus Rex", - "Queen Victoria", - "Kōnstantînos XI Dragásēs Palaiológos", - "Ioustinianós I ho Mégas", - "Kjarossa Liha Vilakauva", - "Kjarossa Tarkona Sovasra", - "Great King Kūruš", - "Queen Elizabeth II", - "Kjarossa Karelka Helasra", - "Imperātor Cæsar Dīvī Fīlius Augustus", - "Cæsar Nerva Trāiānus", - "King Kaleb of Axum" -) - -private fun nameMechyrdianShip(weightClass: ShipWeightClass) = when (weightClass) { - ShipWeightClass.ESCORT -> "${mechyrdianFrigateNames1.random()} ${mechyrdianFrigateNames2.random()}" - ShipWeightClass.DESTROYER -> "${mechyrdianFrigateNames1.random()} ${mechyrdianFrigateNames2.random()}" - ShipWeightClass.CRUISER -> "${mechyrdianCruiserNames1.random()} ${mechyrdianCruiserNames2.random()}" - ShipWeightClass.BATTLECRUISER -> "${mechyrdianCruiserNames1.random()} ${mechyrdianCruiserNames2.random()}" - ShipWeightClass.BATTLESHIP -> mechyrdianBattleshipNames.random() - ShipWeightClass.BATTLE_BARGE -> mechyrdianBattleshipNames.random() - else -> error("Invalid Mechyrdian ship weight!") -} - -private val masraDraetsenFrigateNames1 = listOf( - "Murderous", - "Hateful", - "Heinous", - "Pestilent", - "Corrupting", - "Homicidal", - "Deadly", - "Primordial", - "Painful", - "Agonizing", - "Spiteful", - "Odious", - "Miserating", - "Damned", - "Condemned", - "Hellish", - "Dark", - "Impious", - "Unfaithful", - "Abyssal", - "Furious", - "Vengeful", - "Spiritous" -) - -private val masraDraetsenFrigateNames2 = listOf( - "Murder", - "Hate", - "Hatred", - "Pestilence", - "Corruption", - "Homicide", - "Massacre", - "Death", - "Agony", - "Pain", - "Suffering", - "Spite", - "Misery", - "Damnation", - "Hell", - "Darkness", - "Impiety", - "Faithlessness", - "Abyss", - "Fury", - "Vengeance", - "Spirit" -) - -private val masraDraetsenCruiserNames1 = listOf( - "Despoiler of", - "Desecrator of", - "Desolator of", - "Destroyer of", - "Executioner of", - "Pillager of", - "Villain of", - "Great Devil of", - "Infidelity of", - "Incineration of", - "Immolation of", - "Crucifixion of", - "Unending Darkness of", -) - -private val masraDraetsenCruiserNames2 = listOf( - // Diadochi space - "Eskhaton", - "Terminus", - "Tychiphage", - "Magaddu", - "Ghattusha", - "Three Suns", - "RB-5354", - "VT-3072", - "Siegsstern", - "Atzalstadt", - "Apex", - "Summit", - // Lyudareykk and Isarnareykk - "Vion Kann", - "Kasr Karul", - "Vladizapad", - // Chaebodes Star Empire - "Ultima Thule", - "Prenovez", - // Calibor and Vescar sectors - "Letum Angelorum", - "Pharsalus", - "Eutopia", - // Ferthlon and Olympia sectors - "Ferthlon Primus", - "Ferthlon Secundus", - "Nova Roma", - "Mont Imperial", -) - -private const val masraDraetsenColossusName = "Boukephalas" - -private fun nameMasraDraetsenShip(weightClass: ShipWeightClass) = when (weightClass) { - ShipWeightClass.ESCORT -> "${masraDraetsenFrigateNames1.random()} ${masraDraetsenFrigateNames2.random()}" - ShipWeightClass.DESTROYER -> "${masraDraetsenFrigateNames1.random()} ${masraDraetsenFrigateNames2.random()}" - ShipWeightClass.CRUISER -> "${masraDraetsenCruiserNames1.random()} ${masraDraetsenCruiserNames2.random()}" - ShipWeightClass.GRAND_CRUISER -> "${masraDraetsenCruiserNames1.random()} ${masraDraetsenCruiserNames2.random()}" - ShipWeightClass.COLOSSUS -> masraDraetsenColossusName - else -> error("Invalid Masra Draetsen ship weight!") -} - -private enum class LatinNounForm { - MAS_SG, - FEM_SG, - NEU_SG, - MAS_PL, - FEM_PL, - NEU_PL, -} - -private data class LatinNoun( - val noun: String, - val form: LatinNounForm -) - -private data class LatinAdjective( - val masculineSingular: String, - val feminineSingular: String, - val neuterSingular: String, - val masculinePlural: String, - val femininePlural: String, - val neuterPlural: String, -) { - fun get(form: LatinNounForm) = when (form) { - LatinNounForm.MAS_SG -> masculineSingular - LatinNounForm.FEM_SG -> feminineSingular - LatinNounForm.NEU_SG -> neuterSingular - LatinNounForm.MAS_PL -> masculinePlural - LatinNounForm.FEM_PL -> femininePlural - LatinNounForm.NEU_PL -> neuterPlural - } -} - -private infix fun LatinNoun.describedBy(adjective: LatinAdjective) = "$noun ${adjective.get(form)}" - -private fun felinaeFelicesEscortShipName() = "ES-" + (1000..9999).random().toString() - -private val felinaeFelicesLineShipNames1 = listOf( - LatinNoun("Aevum", LatinNounForm.NEU_SG), - LatinNoun("Aquila", LatinNounForm.FEM_SG), - LatinNoun("Argonauta", LatinNounForm.MAS_SG), - LatinNoun("Cattus", LatinNounForm.MAS_SG), - LatinNoun("Daemon", LatinNounForm.MAS_SG), - LatinNoun("Divitia", LatinNounForm.FEM_SG), - LatinNoun("Feles", LatinNounForm.FEM_SG), - LatinNoun("Imperium", LatinNounForm.NEU_SG), - LatinNoun("Ius", LatinNounForm.NEU_SG), - LatinNoun("Iustitia", LatinNounForm.FEM_SG), - LatinNoun("Leo", LatinNounForm.MAS_SG), - LatinNoun("Leopardus", LatinNounForm.MAS_SG), - LatinNoun("Lynx", LatinNounForm.FEM_SG), - LatinNoun("Panthera", LatinNounForm.FEM_SG), - LatinNoun("Salvator", LatinNounForm.MAS_SG), - LatinNoun("Scelus", LatinNounForm.NEU_SG), - LatinNoun("Tigris", LatinNounForm.MAS_SG), -) - -private val felinaeFelicesLineShipNames2 = listOf( - LatinAdjective("Animosus", "Animosa", "Animosum", "Animosi", "Animosae", "Animosa"), - LatinAdjective("Ardens", "Ardens", "Ardens", "Ardentes", "Ardentes", "Ardentia"), - LatinAdjective("Audax", "Audax", "Audax", "Audaces", "Audaces", "Audacia"), - LatinAdjective("Astutus", "Astuta", "Astutum", "Astuti", "Astutae", "Astuta"), - LatinAdjective("Calidus", "Calida", "Calidum", "Calidi", "Calidae", "Calida"), - LatinAdjective("Ferox", "Ferox", "Ferox", "Feroces", "Feroces", "Ferocia"), - LatinAdjective("Fortis", "Fortis", "Forte", "Fortes", "Fortes", "Fortia"), - LatinAdjective("Fugax", "Fugax", "Fugax", "Fugaces", "Fugaces", "Fugacia"), - LatinAdjective("Indomitus", "Indomita", "Indomitum", "Indomiti", "Indomitae", "Indomita"), - LatinAdjective("Intrepidus", "Intrepida", "Intrepidum", "Intrepidi", "Intrepidae", "Intrepida"), - LatinAdjective("Pervicax", "Pervicax", "Pervicax", "Pervicaces", "Pervicaces", "Pervicacia"), - LatinAdjective("Sagax", "Sagax", "Sagax", "Sagaces", "Sagaces", "Sagacia"), - LatinAdjective("Superbus", "Superba", "Superbum", "Superbi", "Superbae", "Superba"), - LatinAdjective("Trux", "Trux", "Trux", "Truces", "Truces", "Trucia"), -) - -private fun nameFelinaeFelicesShip(weightClass: ShipWeightClass) = when (weightClass) { - ShipWeightClass.FF_ESCORT -> felinaeFelicesEscortShipName() - ShipWeightClass.FF_DESTROYER -> felinaeFelicesLineShipNames1.random() describedBy felinaeFelicesLineShipNames2.random() - ShipWeightClass.FF_CRUISER -> felinaeFelicesLineShipNames1.random() describedBy felinaeFelicesLineShipNames2.random() - ShipWeightClass.FF_BATTLECRUISER -> felinaeFelicesLineShipNames1.random() describedBy felinaeFelicesLineShipNames2.random() - ShipWeightClass.FF_BATTLESHIP -> if (Random.nextDouble() < 0.01) "Big Floppa" else (felinaeFelicesLineShipNames1.random() describedBy felinaeFelicesLineShipNames2.random()) - else -> error("Invalid Felinae Felices ship weight!") -} - -private val isarnareykkShipNames = listOf( - "Professional with Standards", - "Online Game Cheater", - "Actually Made of Antimatter", - "Chucklehead", - "Guns Strapped to an Engine", - "Unidentified Comet", - "Deep Space Encounter", - "The Goggles Do Nothing", - "Sensor Error", - "ERROR SHIP NAME NOT FOUND", - "0x426F6174", - "Börgenkub", - "Instant Death", - "Assume The Position", - "Negative Space Wedgie", - "Tea, Earl Grey, Hot", - "There's Coffee In That Nebula", - "SPEHSS MEHREENS", - "Inconspicuous Asteroid", - "Inflatable Toy Ship", - "HELP TRAPPED IN SHIP FACTORY", - "Illegal Meme Dealer", - "Reverse the Polarity!", - "Send Your Bank Info To Win 10,000 Marks", - "STOP CALLING ABOUT MY STARSHIP WARRANTY", - "Somebody Once Told Me...", - "Praethoris Khorr Gaming", -) - -private fun nameIsarnareykskShip() = isarnareykkShipNames.random() - -private val vestigiumShipNames = listOf( - // NAMED AFTER SPACE SHUTTLES - "Enterprise", // OV-101 - "Columbia", // OV-102 - "Discovery", // OV-103 - "Atlantis", // OV-104 - "Endeavor", // OV-105 - "Conqueror", // OV-106 - "Homeland", // OV-107 - "Augustus", // OV-108 - "Avenger", // OV-109 - "Protector", // OV-110 - - // NAMED AFTER HISTORICAL SHIPS - "Yorktown", - "Lexington", - "Ranger", - "Hornet", - "Wasp", - "Antares", - "Belfast", - // NAMED AFTER PLACES - "Akron", - "Hudson", - "Cleveland", - "Baltimore", - "Bel Air", - "Cedar Rapids", - "McHenry", - "Rochester", - "Cuyahoga Valley", - "Catonsville", - "Ocean City", - "Philadelphia", - "Somerset", - "Pittsburgh", - - "Las Vegas", - "Reno", - "Boulder City", - "Goodsprings", - "Nipton", - "Primm", - "Nellis", - "Fortification Hill", - "McCarran", - "Fremont", - - // NAMED AFTER SPACE PROBES - "Voyager", - "Juno", - "Cassini", - "Hubble", - "Huygens", - "Pioneer", - - // NAMED AFTER PEOPLE - // Founding Fathers - "George Washington", - "Thomas Jefferson", - "John Adams", - "Alexander Hamilton", - "James Madison", - // US Presidents - "Andrew Jackson", - "Abraham Lincoln", - "Theodore Roosevelt", - "Calvin Coolidge", - "Dwight Eisenhower", - "Richard Nixon", - "Ronald Reagan", - "Donald Trump", - "Ron DeSantis", - "Gary Martison", - // IS Emperors - "Jack Coleman", - "Trevor Neer", - "Hadrey Trevison", - "Dio Audrey", - "Connor Vance", - // Vestigium Leaders - "Thomas Blackrock", - "Philip Mack", - "Ilya Korochenko" -) - -private fun nameAmericanShip() = vestigiumShipNames.random() - -fun nameShip(faction: Faction, weightClass: ShipWeightClass): String = when (faction) { - Faction.MECHYRDIA -> nameMechyrdianShip(weightClass) - Faction.NDRC -> nameMechyrdianShip(weightClass) - Faction.MASRA_DRAETSEN -> nameMasraDraetsenShip(weightClass) - Faction.FELINAE_FELICES -> nameFelinaeFelicesShip(weightClass) - Faction.ISARNAREYKK -> nameIsarnareykskShip() - Faction.VESTIGIUM -> nameAmericanShip() -} diff --git a/src/commonMain/kotlin/starshipfights/data/data.kt b/src/commonMain/kotlin/starshipfights/data/data.kt deleted file mode 100644 index 7f67836..0000000 --- a/src/commonMain/kotlin/starshipfights/data/data.kt +++ /dev/null @@ -1,36 +0,0 @@ -package starshipfights.data - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlin.jvm.JvmInline - -@JvmInline -@Serializable(with = IdSerializer::class) -value class Id<@Suppress("unused") T>(val id: String) { - override fun toString() = id - - fun reinterpret() = Id(id) - - companion object { - fun serializer(): KSerializer> = IdSerializer - } -} - -object IdSerializer : KSerializer> { - private val inner = String.serializer() - - override val descriptor: SerialDescriptor - get() = inner.descriptor - - override fun serialize(encoder: Encoder, value: Id<*>) { - inner.serialize(encoder, value.id) - } - - override fun deserialize(decoder: Decoder): Id<*> { - return Id(inner.deserialize(decoder)) - } -} diff --git a/src/commonMain/kotlin/starshipfights/game/admiralty.kt b/src/commonMain/kotlin/starshipfights/game/admiralty.kt deleted file mode 100644 index bef1076..0000000 --- a/src/commonMain/kotlin/starshipfights/game/admiralty.kt +++ /dev/null @@ -1,113 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id - -enum class AdmiralRank { - REAR_ADMIRAL, - VICE_ADMIRAL, - ADMIRAL, - HIGH_ADMIRAL, - LORD_ADMIRAL; - - val maxShipWeightClass: ShipWeightClass - get() = when (this) { - REAR_ADMIRAL -> ShipWeightClass.CRUISER - VICE_ADMIRAL -> ShipWeightClass.BATTLECRUISER - ADMIRAL -> ShipWeightClass.BATTLESHIP - HIGH_ADMIRAL -> ShipWeightClass.BATTLESHIP - LORD_ADMIRAL -> ShipWeightClass.COLOSSUS - } - - val maxBattleSize: BattleSize - get() = BattleSize.values().last { it.maxWeightClass.tier <= maxShipWeightClass.tier } - - val minAcumen: Int - get() = when (this) { - REAR_ADMIRAL -> 0 - VICE_ADMIRAL -> 1000 - ADMIRAL -> 4000 - HIGH_ADMIRAL -> 9000 - LORD_ADMIRAL -> 16000 - } - - val dailyWage: Int - get() = when (this) { - REAR_ADMIRAL -> 40 - VICE_ADMIRAL -> 50 - ADMIRAL -> 60 - HIGH_ADMIRAL -> 70 - LORD_ADMIRAL -> 80 - } - - companion object { - fun fromAcumen(acumen: Int) = values().lastOrNull { it.minAcumen <= acumen } ?: values().first() - } -} - -fun AdmiralRank.getDisplayName(faction: Faction) = when (faction) { - Faction.MECHYRDIA -> when (this) { - AdmiralRank.REAR_ADMIRAL -> "Retrógardi Admiral" - AdmiralRank.VICE_ADMIRAL -> "Vicj Admiral" - AdmiralRank.ADMIRAL -> "Admiral" - AdmiralRank.HIGH_ADMIRAL -> "Altadmiral" - AdmiralRank.LORD_ADMIRAL -> "Dómin Admiral" - } - Faction.NDRC -> when (this) { - AdmiralRank.REAR_ADMIRAL -> "Commandeur" - AdmiralRank.VICE_ADMIRAL -> "Schout-bij-Nacht" - AdmiralRank.ADMIRAL -> "Vice-Admiraal" - AdmiralRank.HIGH_ADMIRAL -> "Luitenant-Admiraal" - AdmiralRank.LORD_ADMIRAL -> "Admiraal" - } - Faction.MASRA_DRAETSEN -> when (this) { - AdmiralRank.REAR_ADMIRAL -> "Syna Raquor" - AdmiralRank.VICE_ADMIRAL -> "Ruhn Raquor" - AdmiralRank.ADMIRAL -> "Raquor" - AdmiralRank.HIGH_ADMIRAL -> "Vosh Raquor" - AdmiralRank.LORD_ADMIRAL -> "Yauh Raquor" - } - Faction.FELINAE_FELICES -> when (this) { - AdmiralRank.REAR_ADMIRAL -> "Domina Iunior" - AdmiralRank.VICE_ADMIRAL -> "Domina Vicaria" - AdmiralRank.ADMIRAL -> "Domina" - AdmiralRank.HIGH_ADMIRAL -> "Domina Senior" - AdmiralRank.LORD_ADMIRAL -> "Ducissa" - } - Faction.ISARNAREYKK -> when (this) { - AdmiralRank.REAR_ADMIRAL -> "Maer nu Ambaght" - AdmiralRank.VICE_ADMIRAL -> "Neid Fletsleydar" - AdmiralRank.ADMIRAL -> "Fletsleydar" - AdmiralRank.HIGH_ADMIRAL -> "Hauk Fletsleydar" - AdmiralRank.LORD_ADMIRAL -> "Hokst Fletsleydar" - } - Faction.VESTIGIUM -> when (this) { - AdmiralRank.REAR_ADMIRAL -> "Rear Marshal" - AdmiralRank.VICE_ADMIRAL -> "Vice Marshal" - AdmiralRank.ADMIRAL -> "Marshal" - AdmiralRank.HIGH_ADMIRAL -> "Grand Marshal" - AdmiralRank.LORD_ADMIRAL -> "Chief Marshal" - } -} - -@Serializable -data class InGameUser( - val id: Id, - val username: String -) - -@Serializable -data class InGameAdmiral( - val id: Id, - - val user: InGameUser, - - val name: String, - val isFemale: Boolean, - - val faction: Faction, - val rank: AdmiralRank, -) { - val fullName: String - get() = "${rank.getDisplayName(faction)} $name" -} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt deleted file mode 100644 index a2f392a..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_behaviors.kt +++ /dev/null @@ -1,422 +0,0 @@ -package starshipfights.game.ai - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.produceIn -import starshipfights.data.Id -import starshipfights.game.* -import kotlin.math.pow -import kotlin.random.Random - -data class AIPlayer( - val gameState: StateFlow, - val doActions: SendChannel, - val getErrors: ReceiveChannel, - val onGameEnd: CompletableJob -) - -@OptIn(FlowPreview::class) -suspend fun AIPlayer.behave(instincts: Instincts, mySide: GlobalSide) { - try { - coroutineScope { - val brain = Brain() - - val phasePipe = Channel>(Channel.CONFLATED) - - launch(onGameEnd) { - var prevSentAt = Moment.now - - for (state in gameState.produceIn(this)) { - phasePipe.send(state.phase to (state.doneWithPhase != mySide && (!state.phase.usesInitiative || state.currentInitiative != mySide.other))) - - for (msg in state.chatBox.takeLastWhile { msg -> msg.sentAt > prevSentAt }) { - if (msg.sentAt > prevSentAt) - prevSentAt = msg.sentAt - - when (msg) { - is ChatEntry.PlayerMessage -> { - // ignore - } - is ChatEntry.ShipIdentified -> { - val identifiedShip = state.ships[msg.ship] ?: continue - if (identifiedShip.owner != mySide) - brain[shipAttackPriority forShip identifiedShip.id] += identifiedShip.ship.shipType.weightClass.tier.toDouble().pow(instincts[combatTargetShipWeight]) - } - is ChatEntry.ShipEscaped -> { - // handle escaping ship - } - is ChatEntry.ShipAttacked -> { - val targetedShip = state.ships[msg.ship] ?: continue - if (targetedShip.owner != mySide) - brain[shipAttackPriority forShip targetedShip.id] -= Random.nextDouble(msg.damageInflicted - 0.5, msg.damageInflicted + 0.5) * instincts[combatForgiveTarget] - else if (msg.attacker is ShipAttacker.EnemyShip) - brain[shipAttackPriority forShip msg.attacker.id] += Random.nextDouble(msg.damageInflicted - 0.5, msg.damageInflicted + 0.5) * instincts[combatAvengeAttacks] - } - is ChatEntry.ShipAttackFailed -> { - val targetedShip = state.ships[msg.ship] ?: continue - if (targetedShip.owner != mySide) - brain[shipAttackPriority forShip targetedShip.id] += instincts[combatFrustratedByFailedAttacks] - } - is ChatEntry.ShipBoarded -> { - val targetedShip = state.ships[msg.ship] ?: continue - if (targetedShip.owner != mySide) - brain[shipAttackPriority forShip targetedShip.id] -= Random.nextDouble(msg.damageAmount - 0.5, msg.damageAmount + 0.5) * instincts[combatForgiveTarget] - else - brain[shipAttackPriority forShip msg.boarder] += Random.nextDouble(msg.damageAmount - 0.5, msg.damageAmount + 0.5) * instincts[combatAvengeAttacks] - } - is ChatEntry.ShipDestroyed -> { - val targetedShip = state.ships[msg.ship] ?: continue - if (targetedShip.owner == mySide && msg.destroyedBy is ShipAttacker.EnemyShip) - brain[shipAttackPriority forShip msg.destroyedBy.id] += instincts[combatAvengeShipwrecks] * targetedShip.ship.shipType.weightClass.tier.toDouble().pow(instincts[combatAvengeShipWeight]) - } - } - } - } - } - - launch(onGameEnd) { - loop@ for ((phase, canAct) in phasePipe) { - if (!canAct) continue@loop - - val state = gameState.value - - when (phase) { - GamePhase.Deploy -> { - for ((shipId, position) in deploy(state, mySide, instincts)) { - val abilityType = PlayerAbilityType.DeployShip(shipId.reinterpret()) - val abilityData = PlayerAbilityData.DeployShip(position) - - doActions.send(PlayerAction.UseAbility(abilityType, abilityData)) - - withTimeoutOrNull(50L) { getErrors.receive() }?.let { errorMsg -> - logWarning("Error when deploying ship ID $shipId - $errorMsg") - } - } - - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) - } - is GamePhase.Power -> { - val powerableShips = state.ships.values.filter { ship -> - ship.owner == mySide && !ship.isDoneCurrentPhase - } - - for (ship in powerableShips) - when (val reactor = ship.ship.reactor) { - FelinaeShipReactor -> { - val newPowerMode = if (ship.hullAmount < ship.durability.maxHullPoints) - FelinaeShipPowerMode.HULL_RECOALESCENSE - else - FelinaeShipPowerMode.INERTIALESS_DRIVE - - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.ConfigurePower(ship.id, newPowerMode), PlayerAbilityData.ConfigurePower)) - } - is StandardShipReactor -> { - val enginesToShields = when { - ship.powerMode.engines == 0 -> -1 - ship.shieldAmount == 0 -> 2 - ship.shieldAmount < (ship.powerMode.shields / 2) -> 1 - ship.shieldAmount < ship.powerMode.shields -> (0..1).random() - else -> 0 - }.coerceIn(-reactor.gridEfficiency..reactor.gridEfficiency) - - val currPower = ship.powerMode - val nextPower = currPower + mapOf( - ShipSubsystem.SHIELDS to enginesToShields, - ShipSubsystem.ENGINES to -enginesToShields - ) - - val chosenPower = if (ship.validatePowerMode(nextPower)) nextPower else currPower - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DistributePower(ship.id), PlayerAbilityData.DistributePower(chosenPower))) - } - } - - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) - } - is GamePhase.Move -> { - val movableShips = state.ships.values.filter { ship -> - ship.owner == mySide && !ship.isDoneCurrentPhase - } - - val smallestShipTier = movableShips.minOfOrNull { ship -> ship.ship.shipType.weightClass.tier } - - if (smallestShipTier == null) { - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) - continue@loop - } - - val movableSmallestShips = movableShips.filter { ship -> - ship.ship.shipType.weightClass.tier == smallestShipTier - } - - val moveThisShip = movableSmallestShips.associateWith { it.calculateSuffering() + 1.0 }.weightedRandom() - doActions.send(navigate(state, moveThisShip, instincts, brain)) - - withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> - logWarning("Error when moving ship ID ${moveThisShip.id} - $error") - doActions.send( - PlayerAction.UseAbility( - PlayerAbilityType.MoveShip(moveThisShip.id), - PlayerAbilityData.MoveShip(moveThisShip.position) - ) - ) - } - } - is GamePhase.Attack -> { - val potentialAttacks = state.ships.values.flatMap { ship -> - if (ship.owner == mySide) - ship.armaments.keys.filter { - ship.canUseWeapon(it) - }.flatMap { weaponId -> - weaponId.validTargets(state, ship).map { target -> - Triple(ship, weaponId, target) - } - } - else emptyList() - }.associateWith { (ship, weaponId, target) -> - weaponId.expectedAdvantageFromWeaponUsage(state, ship, target) * smoothNegative(brain[shipAttackPriority forShip target.id].signedPow(instincts[combatPrioritization])) * (1 + target.calculateSuffering()).signedPow(instincts[combatPreyOnTheWeak]) - } - - if (potentialAttacks.isEmpty() || Random.nextInt(3) == 0) { - val potentialBoardings = state.ships.values.flatMap { ship -> - if (ship.owner == mySide && ship.canSendBoardingParty) { - val pickRequest = ship.getBoardingPickRequest() - state.ships.values.filter { target -> - target.owner == mySide.other && target.position.location in pickRequest.boundary - }.map { target -> ship to target } - } else emptyList() - }.associateWith { (ship, target) -> - ship.expectedBoardingSuccess(target) - } - - val board = potentialBoardings.weightedRandomOrNull() - - if (board != null) { - val (ship, target) = board - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.BoardingParty(ship.id), PlayerAbilityData.BoardingParty(target.id))) - - withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> - logWarning("Error when boarding target ship ID ${target.id} with assault parties of ship ID ${ship.id} - $error") - - val nextState = gameState.value - phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other))) - } - - continue@loop - } - } - - val attackWith = potentialAttacks.weightedRandomOrNull() - - if (attackWith == null) { - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) - continue@loop - } - - val (ship, weaponId, target) = attackWith - val targetPickResponse = when (val weaponSpec = ship.armaments[weaponId]?.weapon) { - is AreaWeapon -> { - val pickRequest = ship.getWeaponPickRequest(weaponSpec) - val targetLocation = target.position.location - val closestValidLocation = pickRequest.boundary.closestPointTo(targetLocation) - - val chosenLocation = if ((targetLocation - closestValidLocation).length >= EPSILON) - closestValidLocation + ((closestValidLocation - targetLocation) * 0.2) - else closestValidLocation - - PickResponse.Location(chosenLocation) - } - is ShipWeapon.Lance -> { - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.ChargeLance(ship.id, weaponId), PlayerAbilityData.ChargeLance)) - withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> - logWarning("Error when charging lance weapon $weaponId of ship ID ${ship.id} - $error") - } - - PickResponse.Ship(target.id) - } - else -> PickResponse.Ship(target.id) - } - - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.UseWeapon(ship.id, weaponId), PlayerAbilityData.UseWeapon(targetPickResponse))) - - withTimeoutOrNull(50L) { getErrors.receive() }?.let { error -> - logWarning("Error when attacking target ship ID ${target.id} with weapon $weaponId of ship ID ${ship.id} - $error") - - val remainingAllAreaWeapons = potentialAttacks.keys.map { (attacker, weaponId, _) -> - attacker to weaponId - }.toSet().all { (attacker, weaponId) -> - attacker.armaments[weaponId]?.weapon is AreaWeapon - } - - if (remainingAllAreaWeapons) - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) - else { - val nextState = gameState.value - phasePipe.send(nextState.phase to (nextState.doneWithPhase != mySide && (!nextState.phase.usesInitiative || nextState.currentInitiative != mySide.other))) - } - } - } - is GamePhase.Repair -> { - val repairAbility = state.getPossibleAbilities(mySide).filter { - it !is PlayerAbilityType.DonePhase - }.randomOrNull() - - if (repairAbility == null) { - doActions.send(PlayerAction.UseAbility(PlayerAbilityType.DonePhase(phase), PlayerAbilityData.DonePhase)) - continue@loop - } - - when (repairAbility) { - is PlayerAbilityType.RepairShipModule -> PlayerAbilityData.RepairShipModule - is PlayerAbilityType.ExtinguishFire -> PlayerAbilityData.ExtinguishFire - is PlayerAbilityType.Recoalesce -> PlayerAbilityData.Recoalesce - else -> null - }?.let { repairData -> - doActions.send(PlayerAction.UseAbility(repairAbility, repairData)) - } - } - } - } - } - } - } catch (ex: Exception) { - logError(ex) - doActions.send(PlayerAction.SendChatMessage(ex.stackTraceToString())) - delay(2000L) - doActions.send(PlayerAction.Disconnect) - } -} - -fun deploy(gameState: GameState, mySide: GlobalSide, instincts: Instincts): Map, Position> { - val size = gameState.battleInfo.size - val totalPoints = size.numPoints - val maxWC = size.maxWeightClass - - val myStart = gameState.start.playerStart(mySide) - - val deployable = myStart.deployableFleet.values.filter { it.shipType.weightClass.tier <= maxWC.tier }.toMutableSet() - val deployed = mutableSetOf() - - while (true) { - val deployShip = deployable.filter { ship -> - deployed.sumOf { it.pointCost } + ship.pointCost <= totalPoints - }.associateWith { ship -> - instincts[ship.shipType.weightClass.focus] - }.weightedRandomOrNull() ?: break - - deployable -= deployShip - deployed += deployShip - } - - return placeShips(deployed, myStart.deployZone) -} - -fun navigate(gameState: GameState, ship: ShipInstance, instincts: Instincts, brain: Brain): PlayerAction.UseAbility { - val noEnemyShipsSeen = gameState.ships.values.none { it.owner != ship.owner && it.isIdentified } - - if (noEnemyShipsSeen || !ship.isIdentified) return engage(gameState, ship) - - val currPos = ship.position.location - val currAngle = ship.position.facing - - val movement = ship.movement - - if (movement is FelinaeShipMovement && Random.nextDouble() < 1.0 / (ship.usedInertialessDriveShots * 3 + 5)) { - val maxJump = movement.inertialessDriveRange * 0.99 - - val positions = listOf( - normalVector(currAngle), - normalVector(currAngle).let { (x, y) -> Vec2(-y, x) }, - -normalVector(currAngle), - normalVector(currAngle).let { (x, y) -> Vec2(y, -x) }, - ).flatMap { - listOf( - ShipPosition(currPos + (Distance(it) * maxJump), it.angle), - ShipPosition(currPos + (Distance(it) * (maxJump * 2 / 3)), it.angle), - ShipPosition(currPos + (Distance(it) * (maxJump / 3)), it.angle), - ) - }.filter { shipPos -> - (gameState.ships - ship.id).none { (_, otherShip) -> - (otherShip.position.location - shipPos.location).length <= SHIP_BASE_SIZE - } - } - - val position = positions.associateWith { - it.score(gameState, ship, instincts, brain) - }.weightedRandomOrNull() ?: return pursue(gameState, ship) - - return PlayerAction.UseAbility( - PlayerAbilityType.UseInertialessDrive(ship.id), - PlayerAbilityData.UseInertialessDrive(position.location) - ) - } - - val maxTurn = movement.turnAngle * 0.99 - val maxMove = movement.moveSpeed * 0.99 - val minMove = movement.moveSpeed * 0.51 - - val positions = (listOf( - normalDistance(currAngle) rotatedBy -maxTurn, - normalDistance(currAngle) rotatedBy (-maxTurn / 2), - normalDistance(currAngle), - normalDistance(currAngle) rotatedBy (maxTurn / 2), - normalDistance(currAngle) rotatedBy maxTurn, - ).flatMap { - listOf( - ShipPosition(currPos + (it * maxMove), it.angle), - ShipPosition(currPos + (it * minMove), it.angle), - ) - } + listOf(ship.position)).filter { shipPos -> - (gameState.ships - ship.id).none { (_, otherShip) -> - (otherShip.position.location - shipPos.location).length <= SHIP_BASE_SIZE - } - } - - val position = positions.associateWith { - it.score(gameState, ship, instincts, brain) - }.weightedRandomOrNull() ?: return pursue(gameState, ship) - - return PlayerAction.UseAbility( - PlayerAbilityType.MoveShip(ship.id), - PlayerAbilityData.MoveShip(position) - ) -} - -fun engage(gameState: GameState, ship: ShipInstance): PlayerAction.UseAbility { - val mySideMeanPosition = gameState.ships.values - .filter { it.owner == ship.owner } - .map { it.position.location.vector } - .mean() - - val enemySideMeanPosition = gameState.ships.values - .filter { it.owner != ship.owner } - .map { it.position.location.vector } - .mean() - - val angleTo = normalVector(ship.position.facing) angleTo (enemySideMeanPosition - mySideMeanPosition) - val maxTurn = ship.movement.turnAngle * 0.99 - val turnNormal = normalDistance(ship.position.facing) rotatedBy angleTo.coerceIn(-maxTurn..maxTurn) - - val move = (ship.movement.moveSpeed * 0.99) * turnNormal - val newLoc = ship.position.location + move - - val position = ShipPosition(newLoc, move.angle) - - return PlayerAction.UseAbility( - PlayerAbilityType.MoveShip(ship.id), - PlayerAbilityData.MoveShip(position) - ) -} - -fun pursue(gameState: GameState, ship: ShipInstance): PlayerAction.UseAbility { - val targetLocation = gameState.ships.values.filter { it.owner != ship.owner }.map { it.position.location }.minByOrNull { loc -> - (loc - ship.position.location).length - } ?: return PlayerAction.UseAbility( - PlayerAbilityType.MoveShip(ship.id), - PlayerAbilityData.MoveShip(ship.position) - ) - - return ship.navigateTo(targetLocation) -} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt deleted file mode 100644 index e5cd88b..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_brainitude.kt +++ /dev/null @@ -1,60 +0,0 @@ -package starshipfights.game.ai - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement -import starshipfights.data.Id -import starshipfights.game.ShipInstance -import starshipfights.game.jsonSerializer -import kotlin.jvm.JvmInline -import kotlin.properties.ReadOnlyProperty - -@JvmInline -@Serializable -value class Instincts private constructor(private val numbers: MutableMap) { - constructor() : this(mutableMapOf()) - - operator fun get(instinct: Instinct) = numbers.getOrPut(instinct.key) { instinct.randRange.random() } - - companion object { - fun fromValues(values: Map) = Instincts(values.toMutableMap()) - } -} - -data class Instinct(val key: String, val randRange: ClosedFloatingPointRange) - -fun instinct(randRange: ClosedFloatingPointRange) = ReadOnlyProperty { _, property -> - Instinct(property.name, randRange) -} - -@JvmInline -@Serializable -value class Brain private constructor(private val data: MutableMap) { - constructor() : this(mutableMapOf()) - - operator fun get(neuron: Neuron) = jsonSerializer.decodeFromJsonElement( - neuron.codec, - data.getOrPut(neuron.key) { - jsonSerializer.encodeToJsonElement( - neuron.codec, - neuron.default() - ) - } - ) - - operator fun set(neuron: Neuron, value: T) = data.set( - neuron.key, - jsonSerializer.encodeToJsonElement( - neuron.codec, - value - ) - ) -} - -data class Neuron(val key: String, val codec: KSerializer, val default: () -> T) - -fun neuron(codec: KSerializer, default: () -> T) = ReadOnlyProperty> { _, property -> - Neuron(property.name, codec, default) -} - -infix fun Neuron.forShip(ship: Id) = copy(key = "$key[$ship]") diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt deleted file mode 100644 index 0c3cfa5..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_coroutine.kt +++ /dev/null @@ -1,64 +0,0 @@ -package starshipfights.game.ai - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import starshipfights.game.GameEvent -import starshipfights.game.GameState -import starshipfights.game.GlobalSide -import starshipfights.game.PlayerAction - -data class AISession( - val mySide: GlobalSide, - val actions: SendChannel, - val events: ReceiveChannel, - val instincts: Instincts = Instincts(), -) - -suspend fun aiPlayer(session: AISession, initialState: GameState) = coroutineScope { - val gameDone = Job() - - val errors = Channel() - val gameStateFlow = MutableStateFlow(initialState) - val aiPlayer = AIPlayer( - gameStateFlow, - session.actions, - errors, - gameDone - ) - - val behavingJob = launch { - aiPlayer.behave(session.instincts, session.mySide) - } - - val handlingJob = launch { - for (event in session.events) { - when (event) { - is GameEvent.GameEnd -> gameDone.complete() - is GameEvent.InvalidAction -> launch { errors.send(event.message) } - is GameEvent.StateChange -> gameStateFlow.value = event.newState - } - } - } - - gameDone.join() - - try { - behavingJob.join() - } catch (_: CancellationException) { - // ignore it - } - - try { - handlingJob.join() - } catch (_: CancellationException) { - // ignore it again - } - - session.actions.close() -} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization.kt deleted file mode 100644 index 3b01eca..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization.kt +++ /dev/null @@ -1,323 +0,0 @@ -package starshipfights.game.ai - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.selects.select -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import starshipfights.data.Id -import starshipfights.game.* -import kotlin.math.PI -import kotlin.random.Random - -val allInstincts = listOf( - combatTargetShipWeight, - combatAvengeShipwrecks, - combatAvengeShipWeight, - combatPrioritization, - combatAvengeAttacks, - combatForgiveTarget, - combatPreyOnTheWeak, - combatFrustratedByFailedAttacks, - - deployEscortFocus, - deployCruiserFocus, - deployBattleshipFocus, - - navAggression, - navPassivity, - navLustForBlood, - navSqueamishness, - navTunnelVision, - navOptimality, -) - -fun genInstinctCandidates(count: Int): Set { - return Random.nextOrthonormalBasis(allInstincts.size).take(count).map { vector -> - Instincts.fromValues((allInstincts zip vector.values).associate { (key, value) -> - key.key to key.denormalize(value) - }) - }.toSet() -} - -class TestSession(gameState: GameState) { - private val stateMutable = MutableStateFlow(gameState) - private val stateMutex = Mutex() - - val state = stateMutable.asStateFlow() - - private val hostErrorMessages = Channel(Channel.UNLIMITED) - private val guestErrorMessages = Channel(Channel.UNLIMITED) - - private fun errorMessageChannel(player: GlobalSide) = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages - } - - fun errorMessages(player: GlobalSide): ReceiveChannel = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages - } - - private val gameEndMutable = CompletableDeferred() - val gameEnd: Deferred - get() = gameEndMutable - - suspend fun onPacket(player: GlobalSide, packet: PlayerAction) { - stateMutex.withLock { - when (val result = state.value.after(player, packet)) { - is GameEvent.StateChange -> { - stateMutable.value = result.newState - result.newState.checkVictory()?.let { gameEndMutable.complete(it) } - } - is GameEvent.InvalidAction -> { - errorMessageChannel(player).send(result.message) - } - is GameEvent.GameEnd -> { - gameEndMutable.complete(result) - } - } - } - } -} - -suspend fun performTestSession(gameState: GameState, hostInstincts: Instincts, guestInstincts: Instincts): GlobalSide? { - val testSession = TestSession(gameState) - - val hostActions = Channel() - val hostEvents = Channel() - val hostSession = AISession(GlobalSide.HOST, hostActions, hostEvents, hostInstincts) - - val guestActions = Channel() - val guestEvents = Channel() - val guestSession = AISession(GlobalSide.GUEST, guestActions, guestEvents, guestInstincts) - - return coroutineScope { - val hostHandlingJob = launch { - launch { - listOf( - // Game state changes - launch { - testSession.state.collect { state -> - hostEvents.send(GameEvent.StateChange(state)) - } - }, - // Invalid action messages - launch { - for (errorMessage in testSession.errorMessages(GlobalSide.HOST)) { - hostEvents.send(GameEvent.InvalidAction(errorMessage)) - } - } - ).joinAll() - } - - launch { - for (action in hostActions) - testSession.onPacket(GlobalSide.HOST, action) - } - - aiPlayer(hostSession, testSession.state.value) - } - - val guestHandlingJob = launch { - launch { - listOf( - // Game state changes - launch { - testSession.state.collect { state -> - guestEvents.send(GameEvent.StateChange(state)) - } - }, - // Invalid action messages - launch { - for (errorMessage in testSession.errorMessages(GlobalSide.GUEST)) { - guestEvents.send(GameEvent.InvalidAction(errorMessage)) - } - } - ).joinAll() - } - - launch { - for (action in guestActions) - testSession.onPacket(GlobalSide.GUEST, action) - } - - aiPlayer(guestSession, testSession.state.value) - } - - val gameEnd = testSession.gameEnd.await() - - hostHandlingJob.cancel() - guestHandlingJob.cancel() - - gameEnd.winner - } -} - -val BattleSize.minRank: AdmiralRank - get() = AdmiralRank.values().first { - it.maxShipWeightClass.tier >= maxWeightClass.tier - } - -fun generateFleet(faction: Faction, rank: AdmiralRank, side: GlobalSide): Map, Ship> = ShipWeightClass.values() - .flatMap { swc -> - val shipTypes = ShipType.values().filter { st -> - st.weightClass == swc && st.faction == faction - }.shuffled() - - if (shipTypes.isEmpty()) - emptyList() - else - (0 until ((rank.maxShipWeightClass.tier - swc.tier + 1) * 2).coerceAtLeast(0)).map { i -> - shipTypes[i % shipTypes.size] - } - } - .let { shipTypes -> - var shipCount = 0 - shipTypes.map { st -> - val name = "${side}_${++shipCount}" - Ship( - id = Id(name), - name = name, - shipType = st, - ) - }.associateBy { it.id } - } - -fun generateOptimizationInitialState(hostFaction: Faction, guestFaction: Faction, battleInfo: BattleInfo): GameState { - val battleWidth = (25..35).random() * 500.0 - val battleLength = (15..45).random() * 500.0 - - val deployWidth2 = battleWidth / 2 - val deployLength2 = 875.0 - - val hostDeployCenter = Position(Vec2(0.0, (-battleLength / 2) + deployLength2)) - val guestDeployCenter = Position(Vec2(0.0, (battleLength / 2) - deployLength2)) - - val rank = battleInfo.size.minRank - - return GameState( - start = GameStart( - battleWidth, battleLength, - - PlayerStart( - hostDeployCenter, - PI / 2, - PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2), - PI / 2, - generateFleet(hostFaction, rank, GlobalSide.HOST) - ), - - PlayerStart( - guestDeployCenter, - -PI / 2, - PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2), - -PI / 2, - generateFleet(guestFaction, rank, GlobalSide.GUEST) - ) - ), - hostInfo = InGameAdmiral( - id = Id(GlobalSide.HOST.name), - user = InGameUser( - id = Id(GlobalSide.HOST.name), - username = GlobalSide.HOST.name - ), - name = GlobalSide.HOST.name, - isFemale = false, - faction = hostFaction, - rank = rank - ), - guestInfo = InGameAdmiral( - id = Id(GlobalSide.GUEST.name), - user = InGameUser( - id = Id(GlobalSide.GUEST.name), - username = GlobalSide.GUEST.name - ), - name = GlobalSide.GUEST.name, - isFemale = false, - faction = guestFaction, - rank = rank - ), - battleInfo = battleInfo, - subplots = emptySet(), - ) -} - -data class InstinctGamePairing( - val host: Instincts, - val guest: Instincts -) - -suspend fun performTrials(numTrialsPerPairing: Int, instincts: Set, validBattleSizes: Set = BattleSize.values().toSet(), validFactions: Set = Faction.values().toSet(), cancellationJob: Job = Job(), onProgress: suspend () -> Unit = {}): Map { - return coroutineScope { - instincts.associateWith { host -> - async { - instincts.map { guest -> - async { - (1..numTrialsPerPairing).map { - async { - val battleSize = validBattleSizes.random() - - val hostFaction = validFactions.random() - val guestFaction = validFactions.random() - - val gameState = generateOptimizationInitialState(hostFaction, guestFaction, BattleInfo(battleSize, BattleBackground.BLUE_BROWN)) - val winner = withTimeoutOrNull((20_000L * numTrialsPerPairing) + (400L * numTrialsPerPairing * numTrialsPerPairing)) { - val deferred = async(cancellationJob) { - performTestSession(gameState, host, guest) - } - - select { - cancellationJob.onJoin { null } - deferred.onAwait { it } - } - } - - logInfo("A trial has ended! Winner: ${winner ?: "NEITHER"}") - onProgress() - - when (winner) { - GlobalSide.HOST -> 1 - GlobalSide.GUEST -> -1 - else -> 0 - } - } - }.map { guest to it.await() } - } - }.flatMap { it.await() }.toMap() - } - }.mapValues { (_, it) -> it.await() }.flatten().mapKeys { (k, _) -> - InstinctGamePairing(k.first, k.second) - } - } -} - -data class InstinctVictoryPairing( - val winner: Instincts, - val loser: Instincts -) - -fun Map.victoriesFor(instincts: Instincts) = filterKeys { (host, _) -> host == instincts }.values.sum() - filterKeys { (_, guest) -> guest == instincts }.values.sum() - -fun Map.toVictoryMap() = keys.associate { (host, _) -> host to victoriesFor(host) } - -fun Map.toVictoryPairingMap() = keys.associate { (host, guest) -> - InstinctVictoryPairing(host, guest) to ((get(InstinctGamePairing(host, guest)) ?: 0) - (get(InstinctGamePairing(guest, host)) ?: 0)) -} - -fun Map.successHistograms(numSegments: Int) = allInstincts.associateWith { instinct -> - val ranges = (0..numSegments).map { it.toDouble() / numSegments }.windowed(2) { (begin, end) -> - val rBegin = instinct.randRange.start + (begin * instinct.randRange.size) - val rEnd = instinct.randRange.start + (end * instinct.randRange.size) - rBegin..rEnd - } - - val perRange = ranges.associateWith { range -> - filterKeys { it[instinct] in range }.values.sum() - } - - perRange -} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization_util.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization_util.kt deleted file mode 100644 index 216f6cb..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_optimization_util.kt +++ /dev/null @@ -1,95 +0,0 @@ -package starshipfights.game.ai - -import starshipfights.game.EPSILON -import kotlin.jvm.JvmInline -import kotlin.math.abs -import kotlin.math.sqrt -import kotlin.random.Random - -@JvmInline -value class VecN(val values: List) - -val VecN.dimension: Int - get() = values.size - -// close enough -fun Random.nextGaussian() = (1..12).sumOf { nextDouble() } - 6 - -fun Random.nextUnitVector(size: Int): VecN { - if (size <= 0) - throw IllegalArgumentException("Cannot have vector of zero or negative dimension!") - - if (size == 1) - return VecN(listOf(if (nextBoolean()) 1.0 else -1.0)) - - val vector = VecN((1..size).map { nextGaussian() }) - - if (vector.isNullVector) // try again - return nextUnitVector(size) - - return vector.normalize() -} - -fun Random.nextOrthonormalBasis(size: Int): List { - if (size <= 0) - throw IllegalArgumentException("Cannot have orthonormal basis of zero or negative dimension!") - - if (size == 1) - return listOf(VecN(listOf(if (nextBoolean()) 1.0 else -1.0))) - - val orthogonalBasis = mutableListOf() - while (orthogonalBasis.size < size) { - val vector = nextUnitVector(size) - var orthogonal = vector - for (prevVector in orthogonalBasis) - orthogonal -= (vector project prevVector) - - if (!orthogonal.isNullVector) - orthogonalBasis.add(orthogonal) - } - - orthogonalBasis.shuffle(this) - return orthogonalBasis.map { it.normalize() } -} - -val VecN.isNullVector: Boolean - get() { - return values.all { abs(it) < EPSILON } - } - -fun VecN.normalize(): VecN { - if (isNullVector) - throw IllegalArgumentException("Cannot normalize the zero vector!") - - val magnitude = sqrt(this dot this) - - return this / magnitude -} - -infix fun VecN.dot(other: VecN): Double { - if (dimension != other.dimension) - throw IllegalArgumentException("Cannot take inner product of vectors of unequal dimensions!") - - return (this.values zip other.values).sumOf { (a, b) -> a * b } -} - -infix fun VecN.project(onto: VecN): VecN { - return this * ((this dot onto) / (this dot this)) -} - -operator fun VecN.plus(other: VecN): VecN { - if (dimension != other.dimension) - throw IllegalArgumentException("Cannot take sum of vectors of unequal dimensions!") - - return VecN((this.values zip other.values).map { (a, b) -> a + b }) -} - -operator fun VecN.minus(other: VecN) = this + (other * -1.0) - -operator fun VecN.times(scale: Double): VecN = VecN(values.map { it * scale }) -operator fun VecN.div(scale: Double): VecN = VecN(values.map { it / scale }) - -fun Instinct.denormalize(normalValue: Double): Double { - val zeroToOne = (normalValue + 1) / 2 - return (zeroToOne * (randRange.endInclusive - randRange.start)) + randRange.start -} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util.kt deleted file mode 100644 index 7f9c525..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_util.kt +++ /dev/null @@ -1,5 +0,0 @@ -package starshipfights.game.ai - -import kotlinx.serialization.builtins.serializer - -val shipAttackPriority by neuron(Double.serializer()) { 1.0 } diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt deleted file mode 100644 index 926ed08..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_combat.kt +++ /dev/null @@ -1,33 +0,0 @@ -package starshipfights.game.ai - -import starshipfights.game.* - -val combatTargetShipWeight by instinct(0.5..2.5) - -val combatAvengeShipwrecks by instinct(0.5..4.5) -val combatAvengeShipWeight by instinct(-0.5..1.5) - -val combatPrioritization by instinct(-1.5..2.5) - -val combatAvengeAttacks by instinct(0.5..4.5) -val combatForgiveTarget by instinct(-1.5..2.5) -val combatPreyOnTheWeak by instinct(-1.5..2.5) - -val combatFrustratedByFailedAttacks by instinct(-2.5..5.5) - -fun ShipInstance.calculateSuffering(): Double { - return (durability.maxHullPoints - hullAmount) + (if (ship.reactor is FelinaeShipReactor) - 0 - else powerMode.shields - shieldAmount) + (numFires * 0.5) + modulesStatus.statuses.values.sumOf { status -> - when (status) { - ShipModuleStatus.INTACT -> 0.0 - ShipModuleStatus.DAMAGED -> 0.75 - ShipModuleStatus.DESTROYED -> 1.5 - ShipModuleStatus.ABSENT -> 0.0 - } - } -} - -fun ShipInstance.expectedBoardingSuccess(against: ShipInstance): Double { - return smoothNegative((assaultModifier - against.defenseModifier).toDouble()) -} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt deleted file mode 100644 index 8bb6e23..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_deploy.kt +++ /dev/null @@ -1,87 +0,0 @@ -package starshipfights.game.ai - -import starshipfights.data.Id -import starshipfights.game.* -import kotlin.math.sign - -val deployEscortFocus by instinct(1.0..5.0) -val deployCruiserFocus by instinct(1.0..5.0) -val deployBattleshipFocus by instinct(1.0..5.0) - -val ShipWeightClass.focus: Instinct - get() = when (this) { - ShipWeightClass.ESCORT -> deployEscortFocus - ShipWeightClass.DESTROYER -> deployCruiserFocus - ShipWeightClass.CRUISER -> deployCruiserFocus - ShipWeightClass.BATTLECRUISER -> deployCruiserFocus - ShipWeightClass.BATTLESHIP -> deployBattleshipFocus - - ShipWeightClass.BATTLE_BARGE -> deployBattleshipFocus - - ShipWeightClass.GRAND_CRUISER -> deployBattleshipFocus - ShipWeightClass.COLOSSUS -> deployBattleshipFocus - - ShipWeightClass.FF_ESCORT -> deployEscortFocus - ShipWeightClass.FF_DESTROYER -> deployCruiserFocus - ShipWeightClass.FF_CRUISER -> deployCruiserFocus - ShipWeightClass.FF_BATTLECRUISER -> deployCruiserFocus - ShipWeightClass.FF_BATTLESHIP -> deployBattleshipFocus - - ShipWeightClass.AUXILIARY_SHIP -> deployEscortFocus - ShipWeightClass.LIGHT_CRUISER -> deployEscortFocus - ShipWeightClass.MEDIUM_CRUISER -> deployCruiserFocus - ShipWeightClass.HEAVY_CRUISER -> deployBattleshipFocus - - ShipWeightClass.FRIGATE -> deployEscortFocus - ShipWeightClass.LINE_SHIP -> deployCruiserFocus - ShipWeightClass.DREADNOUGHT -> deployBattleshipFocus - } - -private val ShipWeightClass.rowIndex: Int - get() = when (this) { - ShipWeightClass.ESCORT -> 3 - ShipWeightClass.DESTROYER -> 2 - ShipWeightClass.CRUISER -> 2 - ShipWeightClass.BATTLECRUISER -> 1 - ShipWeightClass.BATTLESHIP -> 0 - - ShipWeightClass.BATTLE_BARGE -> 0 - - ShipWeightClass.GRAND_CRUISER -> 1 - ShipWeightClass.COLOSSUS -> 0 - - ShipWeightClass.FF_ESCORT -> 3 - ShipWeightClass.FF_DESTROYER -> 2 - ShipWeightClass.FF_CRUISER -> 1 - ShipWeightClass.FF_BATTLECRUISER -> 1 - ShipWeightClass.FF_BATTLESHIP -> 0 - - ShipWeightClass.AUXILIARY_SHIP -> 3 - ShipWeightClass.LIGHT_CRUISER -> 2 - ShipWeightClass.MEDIUM_CRUISER -> 1 - ShipWeightClass.HEAVY_CRUISER -> 0 - - ShipWeightClass.FRIGATE -> 2 - ShipWeightClass.LINE_SHIP -> 1 - ShipWeightClass.DREADNOUGHT -> 0 - } - -fun placeShips(ships: Set, deployRectangle: PickBoundary.Rectangle): Map, Position> { - val fieldBoundSign = deployRectangle.center.vector.y.sign - val fieldBoundary = deployRectangle.center.vector.y + (deployRectangle.length2 * fieldBoundSign) - val rows = listOf(125.0, 625.0, 1125.0, 1625.0).map { - fieldBoundary - (it * fieldBoundSign) - } - - val shipsByRow = ships.groupBy { it.shipType.weightClass.rowIndex } - return buildMap { - for ((rowIndex, rowShips) in shipsByRow) { - val row = rows[rowIndex] - val rowMax = rowShips.size - 1 - for ((shipIndex, ship) in rowShips.withIndex()) { - val col = (shipIndex * 500.0) - (rowMax * 250.0) - put(ship.id.reinterpret(), Position(Vec2(col, row))) - } - } - } -} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt b/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt deleted file mode 100644 index 55fb9ee..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/ai_util_nav.kt +++ /dev/null @@ -1,120 +0,0 @@ -package starshipfights.game.ai - -import starshipfights.data.Id -import starshipfights.game.* -import kotlin.math.expm1 -import kotlin.math.pow - -val navAggression by instinct(0.5..1.5) -val navPassivity by instinct(0.5..1.5) -val navLustForBlood by instinct(-0.5..0.5) -val navSqueamishness by instinct(0.25..1.25) -val navTunnelVision by instinct(-0.25..1.25) -val navOptimality by instinct(1.25..2.75) - -fun ShipPosition.score(gameState: GameState, shipInstance: ShipInstance, instincts: Instincts, brain: Brain): Double { - val ship = shipInstance.copy(position = this) - - val canAttack = ship.canAttackWithDamage(gameState) - val canBeAttackedBy = ship.attackableWithDamageBy(gameState) - - val opportunityScore = canAttack.map { (targetId, potentialDamage) -> - smoothNegative(brain[shipAttackPriority forShip targetId]).signedPow(instincts[navTunnelVision]) * potentialDamage - }.sum() + (ship.calculateSuffering() * instincts[navLustForBlood]) - - val vulnerabilityScore = canBeAttackedBy.map { (targetId, potentialDamage) -> - smoothNegative(brain[shipAttackPriority forShip targetId]).signedPow(instincts[navTunnelVision]) * potentialDamage - }.sum() * -expm1(-ship.calculateSuffering() * instincts[navSqueamishness]) - - return instincts[navOptimality].pow(opportunityScore.signedPow(instincts[navAggression]) - vulnerabilityScore.signedPow(instincts[navPassivity])) -} - -fun ShipInstance.canAttackWithDamage(gameState: GameState): Map, Double> { - return attackableTargets(gameState).mapValues { (targetId, weapons) -> - val target = gameState.ships[targetId] ?: return@mapValues 0.0 - - weapons.sumOf { weaponId -> - weaponId.expectedAdvantageFromWeaponUsage(gameState, this, target) - } - } -} - -fun ShipInstance.attackableTargets(gameState: GameState): Map, Set>> { - return armaments.keys.associateWith { weaponId -> - weaponId.validTargets(gameState, this).map { it.id }.toSet() - }.transpose() -} - -fun ShipInstance.attackableWithDamageBy(gameState: GameState): Map, Double> { - return gameState.getValidAttackersWith(this).mapValues { (attackerId, weapons) -> - val attacker = gameState.ships[attackerId] ?: return@mapValues 0.0 - - weapons.sumOf { weaponId -> - weaponId.expectedAdvantageFromWeaponUsage(gameState, attacker, this) - } - } -} - -fun Id.validTargets(gameState: GameState, ship: ShipInstance): List { - if (!ship.canUseWeapon(this)) return emptyList() - val weaponInstance = ship.armaments[this] ?: return emptyList() - - return gameState.getValidTargets(ship, weaponInstance) -} - -fun Id.expectedAdvantageFromWeaponUsage(gameState: GameState, ship: ShipInstance, target: ShipInstance): Double { - if (!ship.canUseWeapon(this)) return 0.0 - val weaponInstance = ship.armaments[this] ?: return 0.0 - val mustBeSameSide = weaponInstance is ShipWeaponInstance.Hangar && weaponInstance.weapon.wing == StrikeCraftWing.FIGHTERS - if ((ship.owner == target.owner) != mustBeSameSide) return 0.0 - - return when (weaponInstance) { - is ShipWeaponInstance.Cannon -> cannonChanceToHit(ship, target) * weaponInstance.weapon.numShots - is ShipWeaponInstance.Lance -> weaponInstance.charge * weaponInstance.weapon.numShots - is ShipWeaponInstance.Torpedo -> if (target.shieldAmount > 0) 0.5 else 2.0 - is ShipWeaponInstance.Hangar -> when (weaponInstance.weapon.wing) { - StrikeCraftWing.BOMBERS -> { - val calculatedPrevBombing = target.calculateBombing(gameState.ships) ?: 0.0 - val calculatedNextBombing = target.calculateBombing(gameState.ships, extraBombers = weaponInstance.wingHealth) ?: 0.0 - - calculateShipDamageChanceFromBombing(calculatedNextBombing) - calculateShipDamageChanceFromBombing(calculatedPrevBombing) - } - StrikeCraftWing.FIGHTERS -> { - val calculatedPrevBombing = target.calculateBombing(gameState.ships) ?: 0.0 - val calculatedNextBombing = target.calculateBombing(gameState.ships, extraFighters = weaponInstance.wingHealth) ?: 0.0 - - calculateShipDamageChanceFromBombing(calculatedPrevBombing) - calculateShipDamageChanceFromBombing(calculatedNextBombing) - } - } - is ShipWeaponInstance.ParticleClawLauncher -> (cannonChanceToHit(ship, target) + 1) * weaponInstance.weapon.numShots - is ShipWeaponInstance.LightningYarn -> weaponInstance.weapon.numShots.toDouble() - is ShipWeaponInstance.MegaCannon -> 5.0 - is ShipWeaponInstance.RevelationGun -> (target.shieldAmount + target.hullAmount).toDouble() - is ShipWeaponInstance.EmpAntenna -> target.shieldAmount * 0.5 - } -} - -private fun calculateShipDamageChanceFromBombing(calculatedBombing: Double): Double { - val maxBomberWingOutput = smoothNegative(calculatedBombing) - val maxFighterWingOutput = smoothNegative(-calculatedBombing) - - return smoothNegative(maxBomberWingOutput - maxFighterWingOutput) -} - -fun ShipInstance.navigateTo(targetLocation: Position): PlayerAction.UseAbility { - val myLocation = position.location - - val angleTo = normalDistance(position.facing) angleTo (targetLocation - myLocation) - val maxTurn = movement.turnAngle * 0.99 - val turnNormal = normalDistance(position.facing) rotatedBy angleTo.coerceIn(-maxTurn..maxTurn) - - val move = (movement.moveSpeed * if (turnNormal angleBetween (targetLocation - myLocation) < EPSILON) 0.99 else 0.51) * turnNormal - val newLoc = position.location + move - - val position = ShipPosition(newLoc, move.angle) - - return PlayerAction.UseAbility( - PlayerAbilityType.MoveShip(id), - PlayerAbilityData.MoveShip(position) - ) -} diff --git a/src/commonMain/kotlin/starshipfights/game/ai/util.kt b/src/commonMain/kotlin/starshipfights/game/ai/util.kt deleted file mode 100644 index ddd1425..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ai/util.kt +++ /dev/null @@ -1,66 +0,0 @@ -package starshipfights.game.ai - -import starshipfights.game.EPSILON -import starshipfights.game.Vec2 -import starshipfights.game.div -import kotlin.math.absoluteValue -import kotlin.math.nextUp -import kotlin.math.pow -import kotlin.math.sign -import kotlin.random.Random - -expect fun logDebug(message: Any?) -expect fun logInfo(message: Any?) -expect fun logWarning(message: Any?) -expect fun logError(message: Any?) - -fun ClosedFloatingPointRange.random(random: Random = Random) = random.nextDouble(start, endInclusive.nextUp()) - -val ClosedFloatingPointRange.size: Double - get() = endInclusive.nextUp() - start - -fun Map.weightedRandom(random: Random = Random): T { - return weightedRandomOrNull(random) ?: error("Cannot take weighted random of effectively-empty collection!") -} - -fun Map.weightedRandomOrNull(random: Random = Random): T? { - if (values.none { it >= EPSILON }) return null - - val total = values.sum() - - var hasChoice = false - var choice = random.nextDouble(total) - for ((result, chance) in this) { - if (chance < EPSILON) continue - if (chance >= choice) - return result - choice -= chance - hasChoice = true - } - - return if (hasChoice) - keys.last() - else null -} - -fun Map>.flatten(): Map, V> = - toList().flatMap { (k, v) -> - v.map { (l, w) -> - (k to l) to w - } - }.toMap() - -fun Map>.transpose(): Map> = - flatMap { (k, v) -> v.map { it to k } } - .groupBy(Pair::first, Pair::second) - .mapValues { (_, it) -> it.toSet() } - -fun Iterable.mean(): Vec2 { - if (none()) return Vec2(0.0, 0.0) - - val mx = sumOf { it.x } - val my = sumOf { it.y } - return Vec2(mx, my) / count().toDouble() -} - -fun Double.signedPow(x: Double) = if (absoluteValue < EPSILON) 0.0 else sign * absoluteValue.pow(x) diff --git a/src/commonMain/kotlin/starshipfights/game/client_mode.kt b/src/commonMain/kotlin/starshipfights/game/client_mode.kt deleted file mode 100644 index b4e9198..0000000 --- a/src/commonMain/kotlin/starshipfights/game/client_mode.kt +++ /dev/null @@ -1,18 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable - -@Serializable -sealed class ClientMode { - @Serializable - data class MatchmakingMenu(val admirals: List) : ClientMode() - - @Serializable - data class InTrainingGame(val initialState: GameState) : ClientMode() - - @Serializable - data class InGame(val playerSide: GlobalSide, val connectToken: String, val initialState: GameState) : ClientMode() - - @Serializable - data class Error(val message: String) : ClientMode() -} diff --git a/src/commonMain/kotlin/starshipfights/game/game_ability.kt b/src/commonMain/kotlin/starshipfights/game/game_ability.kt deleted file mode 100644 index 108c367..0000000 --- a/src/commonMain/kotlin/starshipfights/game/game_ability.kt +++ /dev/null @@ -1,949 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id -import kotlin.math.abs -import kotlin.random.Random - -sealed interface ShipAbility { - val ship: Id -} - -sealed interface CombatAbility { - val ship: Id - val weapon: Id -} - -@Serializable -sealed class PlayerAbilityType { - abstract suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? - abstract fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent - - @Serializable - data class DonePhase(val phase: GamePhase) : PlayerAbilityType() { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase != phase) return null - return if (gameState.canFinishPhase(playerSide)) - PlayerAbilityData.DonePhase - else null - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - return if (phase == gameState.phase) { - if (gameState.canFinishPhase(playerSide)) - GameEvent.StateChange(gameState.afterPlayerReady(playerSide)) - else GameEvent.InvalidAction("You cannot complete the current phase yet") - } else GameEvent.InvalidAction("Cannot complete non-current phase") - } - } - - @Serializable - data class DeployShip(val ship: Id) : PlayerAbilityType() { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase != GamePhase.Deploy) return null - if (gameState.doneWithPhase == playerSide) return null - val pickBoundary = gameState.start.playerStart(playerSide).deployZone - - val playerStart = gameState.start.playerStart(playerSide) - val shipData = playerStart.deployableFleet[ship] ?: return null - val pickType = PickType.Location(gameState.ships.keys, PickHelper.Ship(shipData.shipType, playerStart.deployFacing)) - - val pickResponse = pick(PickRequest(pickType, pickBoundary)) - val shipPosition = (pickResponse as? PickResponse.Location)?.position ?: return null - return PlayerAbilityData.DeployShip(shipPosition) - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (data !is PlayerAbilityData.DeployShip) return GameEvent.InvalidAction("Internal error from using player ability") - val playerStart = gameState.start.playerStart(playerSide) - val shipData = playerStart.deployableFleet[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - - val position = data.position - - val pickRequest = PickRequest( - PickType.Location(gameState.ships.keys, PickHelper.Ship(shipData.shipType, playerStart.deployFacing)), - gameState.start.playerStart(playerSide).deployZone - ) - val pickResponse = PickResponse.Location(position) - - if (!gameState.isValidPick(pickRequest, pickResponse)) return GameEvent.InvalidAction("That ship cannot be deployed there") - - val shipPosition = ShipPosition(position, playerStart.deployFacing) - val shipInstance = ShipInstance(shipData, playerSide, shipPosition) - - val newShipSet = gameState.ships + mapOf(shipInstance.id to shipInstance) - - if (newShipSet.values.filter { it.owner == playerSide }.sumOf { it.ship.pointCost } > gameState.battleInfo.size.numPoints) - return GameEvent.InvalidAction("Not enough points to deploy this ship") - - val deployableShips = playerStart.deployableFleet - ship - val newPlayerStart = playerStart.copy(deployableFleet = deployableShips) - - return GameEvent.StateChange( - with(gameState) { - copy( - start = when (playerSide) { - GlobalSide.HOST -> start.copy(hostStart = newPlayerStart) - GlobalSide.GUEST -> start.copy(guestStart = newPlayerStart) - }, - ships = newShipSet - ) - } - ) - } - } - - @Serializable - data class UndeployShip(val ship: Id) : PlayerAbilityType() { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - return if (gameState.phase == GamePhase.Deploy && gameState.doneWithPhase != playerSide) PlayerAbilityData.UndeployShip else null - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship is not deployed") - val shipData = shipInstance.ship - - val newShipSet = gameState.ships - ship - - val playerStart = gameState.start.playerStart(playerSide) - - val deployableShips = playerStart.deployableFleet + mapOf(shipData.id to shipData) - val newPlayerStart = playerStart.copy(deployableFleet = deployableShips) - - return GameEvent.StateChange( - with(gameState) { - copy( - start = when (playerSide) { - GlobalSide.HOST -> start.copy(hostStart = newPlayerStart) - GlobalSide.GUEST -> start.copy(guestStart = newPlayerStart) - }, - ships = newShipSet - ) - } - ) - } - } - - @Serializable - data class DistributePower(override val ship: Id) : PlayerAbilityType(), ShipAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Power) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (shipInstance.ship.reactor !is StandardShipReactor) return null - - val data = ClientAbilityData.newShipPowerModes.remove(ship) ?: return null - if (!shipInstance.validatePowerMode(data)) return null - - return PlayerAbilityData.DistributePower(data) - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (data !is PlayerAbilityData.DistributePower) return GameEvent.InvalidAction("Internal error from using player ability") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (shipInstance.ship.reactor !is StandardShipReactor) return GameEvent.InvalidAction("Invalid ship reactor type") - if (!shipInstance.validatePowerMode(data.powerMode)) return GameEvent.InvalidAction("Invalid power distribution") - - val prevShieldDamage = shipInstance.powerMode.shields - shipInstance.shieldAmount - - val newShipInstance = shipInstance.copy( - powerMode = data.powerMode, - isDoneCurrentPhase = true, - - weaponAmount = data.powerMode.weapons, - shieldAmount = if (shipInstance.canUseShields) - (data.powerMode.shields - prevShieldDamage).coerceAtLeast(0) - else 0, - ) - val newShips = gameState.ships + mapOf(ship to newShipInstance) - - return GameEvent.StateChange( - gameState.copy(ships = newShips) - ) - } - } - - @Serializable - data class ConfigurePower(override val ship: Id, val powerMode: FelinaeShipPowerMode) : PlayerAbilityType(), ShipAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Power) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (shipInstance.ship.reactor != FelinaeShipReactor) return null - - return PlayerAbilityData.ConfigurePower - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (shipInstance.ship.reactor != FelinaeShipReactor) return GameEvent.InvalidAction("Invalid ship reactor type") - - val newShipInstance = shipInstance.copy( - felinaeShipPowerMode = powerMode, - ) - val newShips = gameState.ships + mapOf(ship to newShipInstance) - - return GameEvent.StateChange( - gameState.copy(ships = newShips) - ) - } - } - - @Serializable - data class MoveShip(override val ship: Id) : PlayerAbilityType(), ShipAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Move) return null - if (!gameState.canShipMove(ship)) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (shipInstance.isDoneCurrentPhase) return null - - val anglePickReq = PickRequest( - PickType.Location(emptySet(), PickHelper.None, shipInstance.position.location), - PickBoundary.Angle(shipInstance.position.location, shipInstance.position.facing, shipInstance.movement.turnAngle) - ) - val anglePickRes = (pick(anglePickReq) as? PickResponse.Location) ?: return null - - val newFacingNormal = (anglePickRes.position - shipInstance.position.location).normal - val newFacing = newFacingNormal.angle - - val oldFacingNormal = normalDistance(shipInstance.position.facing) - val angleDiff = (oldFacingNormal angleBetween newFacingNormal) - val maxMoveSpeed = shipInstance.movement.moveSpeed - val minMoveSpeed = maxMoveSpeed * (angleDiff / shipInstance.movement.turnAngle) / 2 - - val moveOrigin = shipInstance.position.location - val moveFrom = moveOrigin + (newFacingNormal * minMoveSpeed) - val moveTo = moveOrigin + (newFacingNormal * maxMoveSpeed) - - val positionPickReq = PickRequest( - PickType.Location(gameState.ships.keys - ship, PickHelper.Ship(shipInstance.ship.shipType, newFacing), null), - PickBoundary.AlongLine(moveFrom, moveTo) - ) - val positionPickRes = (pick(positionPickReq) as? PickResponse.Location) ?: return null - - val newPosition = ShipPosition( - positionPickRes.position, - newFacing - ) - - return PlayerAbilityData.MoveShip(newPosition) - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (data !is PlayerAbilityData.MoveShip) return GameEvent.InvalidAction("Internal error from using player ability") - if (!gameState.canShipMove(ship)) return GameEvent.InvalidAction("You do not have the initiative") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (shipInstance.isDoneCurrentPhase) return GameEvent.InvalidAction("Ships cannot be moved twice") - - if ((gameState.ships - ship).any { (_, otherShip) -> (otherShip.position.location - data.newPosition.location).length <= SHIP_BASE_SIZE }) - return GameEvent.InvalidAction("You cannot move that ship there") - - val moveOrigin = shipInstance.position.location - val newFacingNormal = normalDistance(data.newPosition.facing) - val oldFacingNormal = normalDistance(shipInstance.position.facing) - val angleDiff = (oldFacingNormal angleBetween newFacingNormal) - - if (angleDiff - shipInstance.movement.turnAngle > EPSILON) return GameEvent.InvalidAction("Illegal move - turn angle is too big") - - val maxMoveSpeed = shipInstance.movement.moveSpeed - val minMoveSpeed = if (maxMoveSpeed < EPSILON) maxMoveSpeed else (maxMoveSpeed * (angleDiff / shipInstance.movement.turnAngle) / 2) - - val moveFrom = moveOrigin + (newFacingNormal * minMoveSpeed) - val moveTo = moveOrigin + (newFacingNormal * maxMoveSpeed) - - if (data.newPosition.location.distanceToLineSegment(moveFrom, moveTo) > EPSILON) return GameEvent.InvalidAction("Illegal move - must be on facing line") - - val newShipInstance = shipInstance.copy( - position = data.newPosition, - currentVelocity = (data.newPosition.location - shipInstance.position.location).length, - isDoneCurrentPhase = true - ) - - // Identify enemy ships - val identifiedEnemyShips = gameState.ships.filterValues { enemyShip -> - enemyShip.owner != playerSide && (enemyShip.position.location - newShipInstance.position.location).length <= SHIP_SENSOR_RANGE - } - - // Be identified by enemy ships - val shipsToBeIdentified = identifiedEnemyShips + if (!newShipInstance.isIdentified && identifiedEnemyShips.isNotEmpty()) - mapOf(ship to newShipInstance) - else emptyMap() - - val identifiedShips = shipsToBeIdentified - .filterValues { !it.isIdentified } - .mapValues { (_, shipInstance) -> shipInstance.copy(isIdentified = true) } - - // Ships that move off the battlefield are considered to disengage - val isDisengaged = newShipInstance.position.location.vector.let { (x, y) -> - val mx = gameState.start.battlefieldWidth / 2 - val my = gameState.start.battlefieldLength / 2 - abs(x) > mx || abs(y) > my - } - - val newChatEntries = gameState.chatBox + identifiedShips.map { (id, _) -> - ChatEntry.ShipIdentified(id, Moment.now) - } + (if (isDisengaged) - listOf(ChatEntry.ShipEscaped(ship, Moment.now)) - else emptyList()) - - val newShips = (gameState.ships + mapOf(ship to newShipInstance) + identifiedShips) - (if (isDisengaged) - setOf(ship) - else emptySet()) - - val newWrecks = gameState.destroyedShips + (if (isDisengaged) - mapOf(ship to ShipWreck(newShipInstance.ship, newShipInstance.owner, true)) - else emptyMap()) - - return GameEvent.StateChange( - gameState.copy( - ships = newShips, - destroyedShips = newWrecks, - chatBox = newChatEntries, - ).withRecalculatedInitiative { calculateMovePhaseInitiative() } - ) - } - } - - @Serializable - data class UseInertialessDrive(override val ship: Id) : PlayerAbilityType(), ShipAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Move) return null - if (!gameState.canShipMove(ship)) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (shipInstance.isDoneCurrentPhase) return null - - if (!shipInstance.canUseInertialessDrive) return null - val movement = shipInstance.movement - if (movement !is FelinaeShipMovement) return null - - val positionPickReq = PickRequest( - PickType.Location(gameState.ships.keys - ship, PickHelper.Circle(SHIP_BASE_SIZE), shipInstance.position.location), - PickBoundary.Circle( - shipInstance.position.location, - movement.inertialessDriveRange, - ) - ) - val positionPickRes = (pick(positionPickReq) as? PickResponse.Location) ?: return null - - return PlayerAbilityData.UseInertialessDrive(positionPickRes.position) - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (data !is PlayerAbilityData.UseInertialessDrive) return GameEvent.InvalidAction("Internal error from using player ability") - if (!gameState.canShipMove(ship)) return GameEvent.InvalidAction("You do not have the initiative") - - if ((gameState.ships - ship).any { (_, otherShip) -> (otherShip.position.location - data.newPosition).length <= SHIP_BASE_SIZE }) - return GameEvent.InvalidAction("You cannot move that ship there") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (shipInstance.isDoneCurrentPhase) return GameEvent.InvalidAction("Ships cannot be moved twice") - - if (!shipInstance.canUseInertialessDrive) return GameEvent.InvalidAction("That ship cannot use its inertialess drive") - val movement = shipInstance.movement - if (movement !is FelinaeShipMovement) return GameEvent.InvalidAction("That ship does not have an inertialess drive") - - val oldPos = shipInstance.position.location - val newPos = data.newPosition - - val deltaPos = newPos - oldPos - val velocity = deltaPos.length - - if (velocity > movement.inertialessDriveRange) return GameEvent.InvalidAction("That move is out of range") - - val newFacing = deltaPos.angle - - val newShipInstance = shipInstance.copy( - position = ShipPosition(newPos, newFacing), - currentVelocity = velocity, - isDoneCurrentPhase = true, - usedInertialessDriveShots = shipInstance.usedInertialessDriveShots + 1 - ) - - // Identify enemy ships - val identifiedEnemyShips = gameState.ships.filterValues { enemyShip -> - enemyShip.owner != playerSide && (enemyShip.position.location - newShipInstance.position.location).length <= SHIP_SENSOR_RANGE - } - - // Be identified by enemy ships (Inertialess Drive automatically reveals your ship) - val shipsToBeIdentified = identifiedEnemyShips + if (!newShipInstance.isIdentified) - mapOf(ship to newShipInstance) - else emptyMap() - - val identifiedShips = shipsToBeIdentified - .filterValues { !it.isIdentified } - .mapValues { (_, shipInstance) -> shipInstance.copy(isIdentified = true) } - - // Ships that move off the battlefield are considered to disengage - val isDisengaged = newShipInstance.position.location.vector.let { (x, y) -> - val mx = gameState.start.battlefieldWidth / 2 - val my = gameState.start.battlefieldLength / 2 - abs(x) > mx || abs(y) > my - } - - val newChatEntries = gameState.chatBox + identifiedShips.map { (id, _) -> - ChatEntry.ShipIdentified(id, Moment.now) - } + (if (isDisengaged) - listOf(ChatEntry.ShipEscaped(ship, Moment.now)) - else emptyList()) - - val newShips = (gameState.ships + mapOf(ship to newShipInstance) + identifiedShips) - (if (isDisengaged) - setOf(ship) - else emptySet()) - - val newWrecks = gameState.destroyedShips + (if (isDisengaged) - mapOf(ship to ShipWreck(newShipInstance.ship, newShipInstance.owner, true)) - else emptyMap()) - - return GameEvent.StateChange( - gameState.copy( - ships = newShips, - destroyedShips = newWrecks, - chatBox = newChatEntries, - ).withRecalculatedInitiative { calculateMovePhaseInitiative() } - ) - } - } - - @Serializable - data class ChargeLance(override val ship: Id, override val weapon: Id) : PlayerAbilityType(), CombatAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Attack) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (shipInstance.weaponAmount <= 0) return null - if (weapon in shipInstance.usedArmaments) return null - - val shipWeapon = shipInstance.armaments[weapon] ?: return null - if (shipWeapon !is ShipWeaponInstance.Lance) return null - - return PlayerAbilityData.ChargeLance - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only charge lances during Phase III") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (shipInstance.weaponAmount <= 0) return GameEvent.InvalidAction("Not enough power to charge lances") - if (weapon in shipInstance.usedArmaments) return GameEvent.InvalidAction("Cannot charge used lances") - - val shipWeapon = shipInstance.armaments[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") - if (shipWeapon !is ShipWeaponInstance.Lance) return GameEvent.InvalidAction("Cannot charge non-lance weapons") - - return GameEvent.StateChange( - gameState.copy( - ships = gameState.ships + mapOf( - ship to shipInstance.copy( - weaponAmount = shipInstance.weaponAmount - 1, - armaments = shipInstance.armaments + mapOf( - weapon to shipWeapon.copy(numCharges = shipWeapon.numCharges + shipInstance.firepower.lanceCharging) - ) - ) - ) - ) - ) - } - } - - @Serializable - data class UseWeapon(override val ship: Id, override val weapon: Id) : PlayerAbilityType(), CombatAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Attack) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (!shipInstance.canUseWeapon(weapon)) return null - - val shipWeapon = shipInstance.armaments[weapon] ?: return null - - val pickResponse = pick(shipInstance.getWeaponPickRequest(shipWeapon.weapon)) - - return pickResponse?.let { PlayerAbilityData.UseWeapon(it) } - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (data !is PlayerAbilityData.UseWeapon) return GameEvent.InvalidAction("Internal error from using player ability") - - if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only attack during Phase III") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That attacking ship does not exist") - if (!shipInstance.canUseWeapon(weapon)) return GameEvent.InvalidAction("That weapon cannot be used") - - val shipWeapon = shipInstance.armaments[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") - - val pickRequest = shipInstance.getWeaponPickRequest(shipWeapon.weapon) - val pickResponse = data.target - - if (!gameState.isValidPick(pickRequest, pickResponse)) return GameEvent.InvalidAction("Invalid target") - - return gameState.useWeaponPickResponse(shipInstance, weapon, pickResponse) - } - } - - @Serializable - data class RecallStrikeCraft(override val ship: Id, override val weapon: Id) : PlayerAbilityType(), CombatAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Attack) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (weapon !in shipInstance.usedArmaments) return null - - val shipWeapon = shipInstance.armaments[weapon] ?: return null - if (shipWeapon !is ShipWeaponInstance.Hangar) return null - - return PlayerAbilityData.RecallStrikeCraft - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only recall strike craft during Phase III") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (weapon !in shipInstance.usedArmaments) return GameEvent.InvalidAction("Cannot recall unused strike craft") - - val shipWeapon = shipInstance.armaments[weapon] ?: return GameEvent.InvalidAction("That weapon does not exist") - if (shipWeapon !is ShipWeaponInstance.Hangar) return GameEvent.InvalidAction("Cannot recall non-hangar weapons") - - val hangarWing = ShipHangarWing(ship, weapon) - - val newShip = shipInstance.copy( - usedArmaments = shipInstance.usedArmaments - weapon - ) - - return GameEvent.StateChange( - gameState.copy( - ships = gameState.ships.mapValues { (_, targetShip) -> - targetShip.copy( - fighterWings = targetShip.fighterWings - hangarWing, - bomberWings = targetShip.bomberWings - hangarWing, - ) - } + mapOf(ship to newShip) - ).withRecalculatedInitiative { calculateAttackPhaseInitiative() } - ) - } - } - - @Serializable - data class DisruptionPulse(override val ship: Id) : PlayerAbilityType(), ShipAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Attack) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (!shipInstance.canUseDisruptionPulse) return null - if (shipInstance.hasUsedDisruptionPulse) return null - - return PlayerAbilityData.DisruptionPulse - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only emit Disruption Pulses during Phase III") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (!shipInstance.canUseDisruptionPulse) return GameEvent.InvalidAction("Cannot use Disruption Pulse") - if (shipInstance.hasUsedDisruptionPulse) return GameEvent.InvalidAction("Cannot use Disruption Pulse twice") - - val durability = shipInstance.durability - if (durability !is FelinaeShipDurability) return GameEvent.InvalidAction("That ship does not have a Disruption Pulse emitter") - - val targetedShips = gameState.ships.filterValues { - (it.position.location - shipInstance.position.location).length < durability.disruptionPulseRange - } - - val hangars = targetedShips.values.flatMap { target -> - target.fighterWings + target.bomberWings - } - - val changedShips = hangars.groupBy { it.ship }.mapNotNull { (shipId, hangarWings) -> - val changedShip = gameState.ships[shipId] ?: return@mapNotNull null - changedShip.copy( - armaments = changedShip.armaments + hangarWings.associate { - it.hangar to ShipWeaponInstance.Hangar( - changedShip.ship.armaments[it.hangar] as ShipWeapon.Hangar, - 0.0 - ) - } - ) - }.associateBy { it.id } + mapOf( - ship to shipInstance.copy( - hasUsedDisruptionPulse = true, - usedDisruptionPulseShots = shipInstance.usedDisruptionPulseShots + 1 - ) - ) - - return GameEvent.StateChange( - gameState.copy( - ships = gameState.ships + changedShips - ).withRecalculatedInitiative { calculateAttackPhaseInitiative() } - ) - } - } - - @Serializable - data class BoardingParty(override val ship: Id) : PlayerAbilityType(), ShipAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Attack) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (!shipInstance.canSendBoardingParty) return null - - val pickResponse = pick(shipInstance.getBoardingPickRequest()) as? PickResponse.Ship ?: return null - return PlayerAbilityData.BoardingParty(pickResponse.id) - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (data !is PlayerAbilityData.BoardingParty) return GameEvent.InvalidAction("Internal error from using player ability") - - if (gameState.phase !is GamePhase.Attack) return GameEvent.InvalidAction("Ships can only send Boarding Parties during Phase III") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (!shipInstance.canSendBoardingParty) return GameEvent.InvalidAction("Cannot send a boarding party") - - val afterBoarding = shipInstance.afterBoarding() ?: return GameEvent.InvalidAction("Cannot send a boarding party") - - val boarded = gameState.ships[data.target] ?: return GameEvent.InvalidAction("That ship does not exist") - val afterBoarded = shipInstance.board(boarded) - - val newShips = (if (afterBoarded is ImpactResult.Damaged) - gameState.ships + mapOf(data.target to afterBoarded.ship) - else gameState.ships - data.target) + mapOf(ship to afterBoarding) - - val newWrecks = gameState.destroyedShips + (if (afterBoarded is ImpactResult.Destroyed) - mapOf(data.target to afterBoarded.ship) - else emptyMap()) - - val newChatEntries = gameState.chatBox + reportBoardingResult(afterBoarded, ship) - - return GameEvent.StateChange( - gameState.copy( - ships = newShips, - destroyedShips = newWrecks, - chatBox = newChatEntries - ).withRecalculatedInitiative { calculateAttackPhaseInitiative() } - ) - } - } - - @Serializable - data class RepairShipModule(override val ship: Id, val module: ShipModule) : PlayerAbilityType(), ShipAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Repair) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (shipInstance.durability !is StandardShipDurability) return null - if (shipInstance.remainingRepairTokens <= 0) return null - if (!shipInstance.modulesStatus[module].canBeRepaired) return null - - return PlayerAbilityData.RepairShipModule - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only repair modules during Phase IV") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (shipInstance.durability !is StandardShipDurability) return GameEvent.InvalidAction("That ship cannot manually repair subsystems") - if (shipInstance.remainingRepairTokens <= 0) return GameEvent.InvalidAction("That ship has no remaining repair tokens") - if (!shipInstance.modulesStatus[module].canBeRepaired) return GameEvent.InvalidAction("That module cannot be repaired") - - val newShip = shipInstance.copy( - modulesStatus = shipInstance.modulesStatus.repair(module), - usedRepairTokens = shipInstance.usedRepairTokens + 1 - ) - - return GameEvent.StateChange( - gameState.copy( - ships = gameState.ships + mapOf( - ship to newShip - ) - ) - ) - } - } - - @Serializable - data class ExtinguishFire(override val ship: Id) : PlayerAbilityType(), ShipAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Repair) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (shipInstance.durability !is StandardShipDurability) return null - if (shipInstance.remainingRepairTokens <= 0) return null - if (shipInstance.numFires <= 0) return null - - return PlayerAbilityData.ExtinguishFire - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only extinguish fires during Phase IV") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (shipInstance.durability !is StandardShipDurability) return GameEvent.InvalidAction("That ship cannot manually extinguish fires") - if (shipInstance.remainingRepairTokens <= 0) return GameEvent.InvalidAction("That ship has no remaining repair tokens") - if (shipInstance.numFires <= 0) return GameEvent.InvalidAction("Cannot extinguish non-existent fires") - - val newShip = shipInstance.copy( - numFires = shipInstance.numFires - 1, - usedRepairTokens = shipInstance.usedRepairTokens + 1 - ) - - return GameEvent.StateChange( - gameState.copy( - ships = gameState.ships + mapOf( - ship to newShip - ) - ) - ) - } - } - - @Serializable - data class Recoalesce(override val ship: Id) : PlayerAbilityType(), ShipAbility { - override suspend fun beginOnClient(gameState: GameState, playerSide: GlobalSide, pick: suspend (PickRequest) -> PickResponse?): PlayerAbilityData? { - if (gameState.phase !is GamePhase.Repair) return null - - val shipInstance = gameState.ships[ship] ?: return null - if (shipInstance.durability !is FelinaeShipDurability) return null - if (!shipInstance.canUseRecoalescence) return null - - return PlayerAbilityData.Recoalesce - } - - override fun finishOnServer(gameState: GameState, playerSide: GlobalSide, data: PlayerAbilityData): GameEvent { - if (gameState.phase !is GamePhase.Repair) return GameEvent.InvalidAction("Ships can only extinguish fires during Phase IV") - - val shipInstance = gameState.ships[ship] ?: return GameEvent.InvalidAction("That ship does not exist") - if (shipInstance.durability !is FelinaeShipDurability) return GameEvent.InvalidAction("That ship cannot recoalesce its hull") - if (!shipInstance.canUseRecoalescence) return GameEvent.InvalidAction("That ship is not in Recoalescence mode") - - val newHullAmountRange = (shipInstance.hullAmount + 1) until shipInstance.durability.maxHullPoints - val (newHullAmount, newMaxHullDamage) = if (newHullAmountRange.isEmpty()) - shipInstance.hullAmount to shipInstance.recoalescenceMaxHullDamage - else - newHullAmountRange.random() to (shipInstance.recoalescenceMaxHullDamage + 1) - - val repairs = shipInstance.modulesStatus.statuses.filterValues { - it == ShipModuleStatus.DAMAGED || it == ShipModuleStatus.DESTROYED - }.keys - - var newModules = shipInstance.modulesStatus - for (repair in repairs) { - if (Random.nextBoolean()) - newModules = newModules.repair(repair, repairUnrepairable = true) - } - - val newShip = shipInstance.copy( - hullAmount = newHullAmount, - recoalescenceMaxHullDamage = newMaxHullDamage, - modulesStatus = newModules, - isDoneCurrentPhase = true - ) - - return GameEvent.StateChange( - gameState.copy( - ships = gameState.ships + mapOf( - ship to newShip - ) - ) - ) - } - } -} - -@Serializable -sealed class PlayerAbilityData { - @Serializable - object DonePhase : PlayerAbilityData() - - @Serializable - data class DeployShip(val position: Position) : PlayerAbilityData() - - @Serializable - object UndeployShip : PlayerAbilityData() - - @Serializable - data class DistributePower(val powerMode: ShipPowerMode) : PlayerAbilityData() - - @Serializable - object ConfigurePower : PlayerAbilityData() - - @Serializable - data class MoveShip(val newPosition: ShipPosition) : PlayerAbilityData() - - @Serializable - data class UseInertialessDrive(val newPosition: Position) : PlayerAbilityData() - - @Serializable - object ChargeLance : PlayerAbilityData() - - @Serializable - data class UseWeapon(val target: PickResponse) : PlayerAbilityData() - - @Serializable - object RecallStrikeCraft : PlayerAbilityData() - - @Serializable - object DisruptionPulse : PlayerAbilityData() - - @Serializable - data class BoardingParty(val target: Id) : PlayerAbilityData() - - @Serializable - object RepairShipModule : PlayerAbilityData() - - @Serializable - object ExtinguishFire : PlayerAbilityData() - - @Serializable - object Recoalesce : PlayerAbilityData() -} - -fun GameState.getPossibleAbilities(forPlayer: GlobalSide): List = if (doneWithPhase == forPlayer) - emptyList() -else when (phase) { - GamePhase.Deploy -> { - val usedPoints = ships.values - .filter { it.owner == forPlayer } - .sumOf { it.ship.pointCost } - - val deployShips = start.playerStart(forPlayer).deployableFleet - .filterValues { usedPoints + it.pointCost <= battleInfo.size.numPoints }.keys - .map { PlayerAbilityType.DeployShip(it) } - - val undeployShips = ships - .filterValues { it.owner == forPlayer } - .keys - .map { PlayerAbilityType.UndeployShip(it) } - - val finishDeploying = if (canFinishPhase(forPlayer)) - listOf(PlayerAbilityType.DonePhase(GamePhase.Deploy)) - else emptyList() - - deployShips + undeployShips + finishDeploying - } - is GamePhase.Power -> { - val powerableShips = ships - .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.ship.reactor is StandardShipReactor } - .keys - .map { PlayerAbilityType.DistributePower(it) } - - val configurableShips = ships - .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.ship.reactor is FelinaeShipReactor } - .keys - .flatMap { - FelinaeShipPowerMode.values().map { mode -> - PlayerAbilityType.ConfigurePower(it, mode) - } - } - - val finishPowering = if (canFinishPhase(forPlayer)) - listOf(PlayerAbilityType.DonePhase(GamePhase.Power(phase.turn))) - else emptyList() - - powerableShips + configurableShips + finishPowering - } - is GamePhase.Move -> { - val movableShips = ships - .filterKeys { canShipMove(it) } - .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase } - .keys - .map { PlayerAbilityType.MoveShip(it) } - - val inertialessShips = ships - .filterKeys { canShipMove(it) } - .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.canUseInertialessDrive } - .keys - .map { PlayerAbilityType.UseInertialessDrive(it) } - - val finishMoving = if (canFinishPhase(forPlayer)) - listOf(PlayerAbilityType.DonePhase(GamePhase.Move(phase.turn))) - else emptyList() - - movableShips + inertialessShips + finishMoving - } - is GamePhase.Attack -> { - val chargeableLances = ships - .filterValues { it.owner == forPlayer && it.weaponAmount > 0 } - .flatMap { (id, ship) -> - ship.armaments.mapNotNull { (weaponId, weapon) -> - PlayerAbilityType.ChargeLance(id, weaponId).takeIf { - when (weapon) { - is ShipWeaponInstance.Lance -> weapon.numCharges < 7.0 && weaponId !in ship.usedArmaments - else -> false - } - } - } - } - - val usableWeapons = ships - .filterKeys { canShipAttack(it) } - .filterValues { it.owner == forPlayer } - .flatMap { (id, ship) -> - ship.armaments.keys.mapNotNull { weaponId -> - PlayerAbilityType.UseWeapon(id, weaponId).takeIf { - weaponId !in ship.usedArmaments && ship.canUseWeapon(weaponId) - } - } - } - - val usableDisruptionPulses = ships - .filterKeys { canShipAttack(it) } - .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.canUseDisruptionPulse } - .keys - .map { PlayerAbilityType.DisruptionPulse(it) } - - val usableBoardingTransportaria = ships - .filterKeys { canShipAttack(it) } - .filterValues { it.owner == forPlayer && !it.isDoneCurrentPhase && it.canSendBoardingParty } - .keys - .map { PlayerAbilityType.BoardingParty(it) } - - val recallableStrikeWings = ships - .filterValues { it.owner == forPlayer } - .flatMap { (id, ship) -> - ship.armaments.mapNotNull { (weaponId, weapon) -> - PlayerAbilityType.RecallStrikeCraft(id, weaponId).takeIf { - weaponId in ship.usedArmaments && weapon is ShipWeaponInstance.Hangar - } - } - } - - val finishAttacking = if (canFinishPhase(forPlayer)) - listOf(PlayerAbilityType.DonePhase(GamePhase.Attack(phase.turn))) - else emptyList() - - usableBoardingTransportaria + chargeableLances + usableWeapons + recallableStrikeWings + usableDisruptionPulses + finishAttacking - } - is GamePhase.Repair -> { - val repairableModules = ships - .filterValues { it.owner == forPlayer && it.remainingRepairTokens > 0 } - .flatMap { (id, ship) -> - ship.modulesStatus.statuses.filterValues { it.canBeRepaired }.keys.map { module -> - PlayerAbilityType.RepairShipModule(id, module) - } - } - - val extinguishableFires = ships - .filterValues { it.owner == forPlayer && it.remainingRepairTokens > 0 && it.numFires > 0 } - .keys - .map { - PlayerAbilityType.ExtinguishFire(it) - } - - val recoalescibleShips = ships - .filterValues { it.owner == forPlayer && it.canUseRecoalescence } - .keys - .map { - PlayerAbilityType.Recoalesce(it) - } - - val finishRepairing = if (canFinishPhase(forPlayer)) - listOf(PlayerAbilityType.DonePhase(GamePhase.Repair(phase.turn))) - else emptyList() - - repairableModules + extinguishableFires + recoalescibleShips + finishRepairing - } -} - -object ClientAbilityData { - val newShipPowerModes = mutableMapOf, ShipPowerMode>() -} diff --git a/src/commonMain/kotlin/starshipfights/game/game_chat.kt b/src/commonMain/kotlin/starshipfights/game/game_chat.kt deleted file mode 100644 index a1b2d68..0000000 --- a/src/commonMain/kotlin/starshipfights/game/game_chat.kt +++ /dev/null @@ -1,99 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id - -@Serializable -sealed class ChatEntry { - abstract val sentAt: Moment - - @Serializable - data class PlayerMessage( - val senderSide: GlobalSide, - override val sentAt: Moment, - val message: String - ) : ChatEntry() - - @Serializable - data class ShipIdentified( - val ship: Id, - override val sentAt: Moment, - ) : ChatEntry() - - @Serializable - data class ShipEscaped( - val ship: Id, - override val sentAt: Moment - ) : ChatEntry() - - @Serializable - data class ShipAttacked( - val ship: Id, - val attacker: ShipAttacker, - override val sentAt: Moment, - val damageInflicted: Int, - val weapon: ShipWeapon?, - val critical: ShipCritical?, - ) : ChatEntry() - - @Serializable - data class ShipAttackFailed( - val ship: Id, - val attacker: ShipAttacker, - override val sentAt: Moment, - val weapon: ShipWeapon?, - val damageIgnoreType: DamageIgnoreType, - ) : ChatEntry() - - @Serializable - data class ShipBoarded( - val ship: Id, - val boarder: Id, - override val sentAt: Moment, - val critical: ShipCritical?, - val damageAmount: Int = 0, - ) : ChatEntry() - - @Serializable - data class ShipDestroyed( - val ship: Id, - override val sentAt: Moment, - val destroyedBy: ShipAttacker - ) : ChatEntry() -} - -@Serializable -sealed class ShipAttacker { - @Serializable - data class EnemyShip(val id: Id) : ShipAttacker() - - @Serializable - object Bombers : ShipAttacker() - - @Serializable - object Fire : ShipAttacker() -} - -@Serializable -sealed class ShipCritical { - @Serializable - object ExtraDamage : ShipCritical() - - @Serializable - object Fire : ShipCritical() - - @Serializable - data class TroopsKilled(val number: Int) : ShipCritical() - - @Serializable - data class ModulesHit(val module: Set) : ShipCritical() -} - -fun CritResult.report(): ShipCritical? = when (this) { - CritResult.NoEffect -> null - is CritResult.FireStarted -> ShipCritical.Fire - is CritResult.ModulesDisabled -> ShipCritical.ModulesHit(modules) - is CritResult.TroopsKilled -> ShipCritical.TroopsKilled(amount) - is CritResult.HullDamaged -> ShipCritical.ExtraDamage - is CritResult.Destroyed -> null -} diff --git a/src/commonMain/kotlin/starshipfights/game/game_initiative.kt b/src/commonMain/kotlin/starshipfights/game/game_initiative.kt deleted file mode 100644 index 3e9b700..0000000 --- a/src/commonMain/kotlin/starshipfights/game/game_initiative.kt +++ /dev/null @@ -1,112 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id - -@Serializable -data class InitiativePair( - val hostSide: Double, - val guestSide: Double -) { - constructor(map: Map) : this( - map[GlobalSide.HOST] ?: 0.0, - map[GlobalSide.GUEST] ?: 0.0, - ) - - operator fun get(side: GlobalSide) = when (side) { - GlobalSide.HOST -> hostSide - GlobalSide.GUEST -> guestSide - } - - fun copy(map: Map) = copy( - hostSide = map[GlobalSide.HOST] ?: hostSide, - guestSide = map[GlobalSide.GUEST] ?: guestSide, - ) -} - -fun GameState.calculateMovePhaseInitiative(): InitiativePair = InitiativePair( - ships - .values - .groupBy { it.owner } - .mapValues { (_, shipList) -> - shipList - .filter { !it.isDoneCurrentPhase } - .sumOf { it.ship.pointCost * it.movementCoefficient } - } -) - -fun GameState.getValidAttackersWith(target: ShipInstance): Map, Set>> { - return ships.mapValues { (_, ship) -> isValidAttackerWith(ship, target) } -} - -fun GameState.isValidAttackerWith(attacker: ShipInstance, target: ShipInstance): Set> { - return attacker.armaments.filterValues { - isValidTarget(attacker, it, attacker.getWeaponPickRequest(it.weapon), target) - }.keys -} - -fun GameState.isValidTarget(ship: ShipInstance, weapon: ShipWeaponInstance, pickRequest: PickRequest, target: ShipInstance): Boolean { - val targetPos = target.position.location - - return when (val weaponSpec = weapon.weapon) { - is AreaWeapon -> - target.owner != ship.owner && (targetPos - pickRequest.boundary.closestPointTo(targetPos)).length < weaponSpec.areaRadius - else -> - target.owner in (pickRequest.type as PickType.Ship).allowSides && isValidPick(pickRequest, PickResponse.Ship(target.id)) - } -} - -inline fun GameState.aggregateValidTargets(ship: ShipInstance, weapon: ShipWeaponInstance, aggregate: Iterable.((ShipInstance) -> Boolean) -> T): T { - val pickRequest = ship.getWeaponPickRequest(weapon.weapon) - return ships.values.aggregate { target -> isValidTarget(ship, weapon, pickRequest, target) } -} - -fun GameState.hasValidTargets(ship: ShipInstance, weapon: ShipWeaponInstance): Boolean { - return aggregateValidTargets(ship, weapon) { any(it) } -} - -fun GameState.getValidTargets(ship: ShipInstance, weapon: ShipWeaponInstance): List { - return aggregateValidTargets(ship, weapon) { filter(it) } -} - -fun GameState.calculateAttackPhaseInitiative(): InitiativePair = InitiativePair( - ships - .values - .groupBy { it.owner } - .mapValues { (_, shipList) -> - shipList - .filter { !it.isDoneCurrentPhase } - .sumOf { ship -> - val allWeapons = ship.armaments - .filterValues { weapon -> hasValidTargets(ship, weapon) } - val usableWeapons = allWeapons - ship.usedArmaments - - val allWeaponShots = allWeapons.values.sumOf { it.weapon.numShots } + ship.troopsAmount - val usableWeaponShots = usableWeapons.values.sumOf { it.weapon.numShots } + (if (ship.canSendBoardingParty) ship.troopsAmount else 0) - - ship.ship.pointCost * (usableWeaponShots.toDouble() / allWeaponShots) - } - } -) - -fun GameState.withRecalculatedInitiative(initiativePairAccessor: GameState.() -> InitiativePair): GameState { - val initiativePair = initiativePairAccessor() - - return copy( - calculatedInitiative = when { - initiativePair.hostSide > initiativePair.guestSide -> GlobalSide.HOST - initiativePair.hostSide < initiativePair.guestSide -> GlobalSide.GUEST - else -> calculatedInitiative?.other - } - ) -} - -fun GameState.canShipMove(ship: Id): Boolean { - val shipInstance = ships[ship] ?: return false - return currentInitiative != shipInstance.owner.other -} - -fun GameState.canShipAttack(ship: Id): Boolean { - val shipInstance = ships[ship] ?: return false - return currentInitiative != shipInstance.owner.other -} diff --git a/src/commonMain/kotlin/starshipfights/game/game_packet.kt b/src/commonMain/kotlin/starshipfights/game/game_packet.kt deleted file mode 100644 index 5231351..0000000 --- a/src/commonMain/kotlin/starshipfights/game/game_packet.kt +++ /dev/null @@ -1,81 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable - -@Serializable -sealed class PlayerAction { - @Serializable - data class SendChatMessage(val message: String) : PlayerAction() - - @Serializable - data class UseAbility(val type: PlayerAbilityType, val data: PlayerAbilityData) : PlayerAction() - - @Serializable - object TimeOut : PlayerAction() - - @Serializable - object Disconnect : PlayerAction() -} - -fun isInternalPlayerAction(playerAction: PlayerAction) = playerAction in setOf(PlayerAction.TimeOut, PlayerAction.Disconnect) - -@Serializable -data class GameBeginning(val opponentJoined: Boolean) - -@Serializable -sealed class GameEvent { - @Serializable - data class StateChange(val newState: GameState) : GameEvent() - - @Serializable - data class InvalidAction(val message: String) : GameEvent() - - @Serializable - data class GameEnd( - val winner: GlobalSide?, - val message: String, - @Serializable(with = MapAsListSerializer::class) - val subplotOutcomes: Map = emptyMap() - ) : GameEvent() -} - -fun GameState.after(player: GlobalSide, packet: PlayerAction): GameEvent = when (packet) { - is PlayerAction.SendChatMessage -> { - GameEvent.StateChange( - copy( - chatBox = chatBox + ChatEntry.PlayerMessage( - senderSide = player, - sentAt = Moment.now, - message = packet.message - ) - ) - ) - } - is PlayerAction.UseAbility -> { - if (packet.type in getPossibleAbilities(player)) - packet.type.finishOnServer(this, player, packet.data) - else - GameEvent.InvalidAction("That ability cannot be used right now") - } - PlayerAction.TimeOut -> { - val loserName = admiralInfo(player).fullName - val winnerName = admiralInfo(player.other).fullName - - GameEvent.GameEnd(player.other, "$loserName never joined the battle, yielding victory to $winnerName!", emptyMap()) - } - PlayerAction.Disconnect -> { - val loserName = admiralInfo(player).fullName - val winnerName = admiralInfo(player.other).fullName - - GameEvent.GameEnd(player.other, "$loserName has disconnected from the battle, yielding victory to $winnerName!", emptyMap()) - } -}.let { event -> - if (event is GameEvent.StateChange) { - val subplotKeys = event.newState.subplots.map { it.key } - val finalState = subplotKeys.fold(event.newState) { newState, key -> - val subplot = newState.subplots.single { it.key == key } - subplot.onGameStateChanged(newState) - } - GameEvent.StateChange(finalState) - } else event -} diff --git a/src/commonMain/kotlin/starshipfights/game/game_phase.kt b/src/commonMain/kotlin/starshipfights/game/game_phase.kt deleted file mode 100644 index 51b4b93..0000000 --- a/src/commonMain/kotlin/starshipfights/game/game_phase.kt +++ /dev/null @@ -1,40 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable - -@Serializable -sealed class GamePhase { - abstract val turn: Int - abstract fun next(): GamePhase - - @Serializable - object Deploy : GamePhase() { - override val turn: Int - get() = 0 - - override fun next() = Power(turn + 1) - } - - @Serializable - data class Power(override val turn: Int) : GamePhase() { - override fun next() = Move(turn) - } - - @Serializable - data class Move(override val turn: Int) : GamePhase() { - override fun next() = Attack(turn) - } - - @Serializable - data class Attack(override val turn: Int) : GamePhase() { - override fun next() = Repair(turn) - } - - @Serializable - data class Repair(override val turn: Int) : GamePhase() { - override fun next() = Power(turn + 1) - } -} - -val GamePhase.usesInitiative: Boolean - get() = this is GamePhase.Move || this is GamePhase.Attack diff --git a/src/commonMain/kotlin/starshipfights/game/game_start.kt b/src/commonMain/kotlin/starshipfights/game/game_start.kt deleted file mode 100644 index 64a921b..0000000 --- a/src/commonMain/kotlin/starshipfights/game/game_start.kt +++ /dev/null @@ -1,29 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id - -@Serializable -data class GameStart( - val battlefieldWidth: Double, - val battlefieldLength: Double, - - val hostStart: PlayerStart, - val guestStart: PlayerStart -) - -fun GameStart.playerStart(side: GlobalSide) = when (side) { - GlobalSide.HOST -> hostStart - GlobalSide.GUEST -> guestStart -} - -@Serializable -data class PlayerStart( - val cameraPosition: Position, - val cameraFacing: Double, - - val deployZone: PickBoundary.Rectangle, - val deployFacing: Double, - - val deployableFleet: Map, Ship> -) diff --git a/src/commonMain/kotlin/starshipfights/game/game_state.kt b/src/commonMain/kotlin/starshipfights/game/game_state.kt deleted file mode 100644 index 231362b..0000000 --- a/src/commonMain/kotlin/starshipfights/game/game_state.kt +++ /dev/null @@ -1,234 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id - -@Serializable -data class GameState( - val start: GameStart, - - val hostInfo: InGameAdmiral, - val guestInfo: InGameAdmiral, - val battleInfo: BattleInfo, - - val subplots: Set, - - val phase: GamePhase = GamePhase.Deploy, - val doneWithPhase: GlobalSide? = null, - val calculatedInitiative: GlobalSide? = null, - - val ships: Map, ShipInstance> = emptyMap(), - val destroyedShips: Map, ShipWreck> = emptyMap(), - - val chatBox: List = emptyList(), -) { - fun getShipInfo(id: Id) = destroyedShips[id]?.ship ?: ships.getValue(id).ship - fun getShipInfoOrNull(id: Id) = destroyedShips[id]?.ship ?: ships[id]?.ship - - fun getShipOwner(id: Id) = destroyedShips[id]?.owner ?: ships.getValue(id).owner - fun getShipOwnerOrNull(id: Id) = destroyedShips[id]?.owner ?: ships[id]?.owner -} - -val GameState.currentInitiative: GlobalSide? - get() = calculatedInitiative?.takeIf { it != doneWithPhase } - -fun GameState.canFinishPhase(side: GlobalSide): Boolean { - return when (phase) { - GamePhase.Deploy -> { - val usedPoints = ships.values - .filter { it.owner == side } - .sumOf { it.ship.pointCost } - - start.playerStart(side).deployableFleet.values.none { usedPoints + it.pointCost <= battleInfo.size.numPoints } - } - else -> true - } -} - -private fun GameState.afterPhase(): GameState { - var newShips = ships - val newWrecks = destroyedShips.toMutableMap() - val newChatEntries = mutableListOf() - var newInitiative: GameState.() -> InitiativePair = { InitiativePair(emptyMap()) } - - when (phase) { - GamePhase.Deploy -> { - return subplots.map { it.key }.fold(this) { newState, key -> - val subplot = newState.subplots.single { it.key == key } - subplot.onAfterDeployShips(newState) - }.copy( - phase = phase.next(), - ships = ships.mapValues { (_, ship) -> - ship.copy(isDoneCurrentPhase = false) - }, - ) - } - is GamePhase.Power -> { - // Prepare for move phase - newInitiative = { calculateMovePhaseInitiative() } - } - is GamePhase.Move -> { - // Set velocity to 0 for halted ships - newShips = newShips.mapValues { (_, ship) -> - if (ship.ship.shipType.faction == Faction.FELINAE_FELICES && !ship.isDoneCurrentPhase) - ship.copy(currentVelocity = 0.0) - else ship - } - - // Recharge inertialess drive - newShips = newShips.mapValues { (_, ship) -> - if (ship.ship.canUseInertialessDrive && ship.usedInertialessDriveShots > 0 && ship.felinaeShipPowerMode != FelinaeShipPowerMode.INERTIALESS_DRIVE) - ship.copy(usedInertialessDriveShots = ship.usedInertialessDriveShots - 1) - else ship - } - - // Prepare for attack phase - newInitiative = { calculateAttackPhaseInitiative() } - } - is GamePhase.Attack -> { - val strikeWingDamage = mutableMapOf() - - // Apply damage to ships from strike craft - newShips = newShips.mapNotNull strikeBombard@{ (id, ship) -> - val impact = ship.afterBombed(newShips, strikeWingDamage) - newChatEntries += listOfNotNull(impact.toChatEntry(ShipAttacker.Bombers, null)) - when (impact) { - is ImpactResult.Damaged -> { - id to impact.ship - } - is ImpactResult.Destroyed -> { - newWrecks[id] = impact.ship - null - } - } - }.toMap() - - // Apply damage to strike craft wings - newShips = newShips.mapValues { (_, ship) -> - ship.afterBombing(strikeWingDamage) - } - - // Deal fire damage - newShips = newShips.mapNotNull fireDamage@{ (id, ship) -> - if (ship.numFires <= 0) - return@fireDamage id to ship - - val hits = (0..ship.numFires).random() - - val impactResult = ship.impact(hits, true) - newChatEntries += listOfNotNull(impactResult.toChatEntry(ShipAttacker.Fire, null)) - when (impactResult) { - is ImpactResult.Damaged -> { - id to impactResult.ship - } - is ImpactResult.Destroyed -> { - newWrecks[id] = impactResult.ship - null - } - } - }.toMap() - - // Replenish repair tokens, recall strike craft, and regenerate weapons and shields power - newShips = newShips.mapValues { (_, ship) -> - ship.copy( - weaponAmount = ship.powerMode.weapons, - shieldAmount = if (ship.canUseShields) (ship.shieldAmount..ship.powerMode.shields).random() else 0, - usedRepairTokens = 0, - - hasUsedDisruptionPulse = false, - - fighterWings = emptySet(), - bomberWings = emptySet(), - usedArmaments = emptySet(), - - hasSentBoardingParty = false, - ) - } - } - else -> { - // do nothing - } - } - - return copy( - phase = phase.next(), - ships = newShips.mapValues { (_, ship) -> - ship.copy(isDoneCurrentPhase = false) - }, - destroyedShips = newWrecks, - chatBox = chatBox + newChatEntries - ).withRecalculatedInitiative(newInitiative) -} - -fun GameState.afterPlayerReady(playerSide: GlobalSide) = if (doneWithPhase == playerSide.other) { - afterPhase().copy(doneWithPhase = null) -} else - copy(doneWithPhase = playerSide) - -private fun GameState.victoryMessage(winner: GlobalSide): String { - val winnerName = admiralInfo(winner).fullName - val loserName = admiralInfo(winner.other).fullName - - return "$winnerName has won the battle by destroying the fleet of $loserName!" -} - -fun GameState.checkVictory(): GameEvent.GameEnd? { - if (phase == GamePhase.Deploy) return null - - val hostDefeated = ships.none { (_, it) -> it.owner == GlobalSide.HOST } - val guestDefeated = ships.none { (_, it) -> it.owner == GlobalSide.GUEST } - - val winner = if (hostDefeated && guestDefeated) - null - else if (hostDefeated) - GlobalSide.GUEST - else if (guestDefeated) - GlobalSide.HOST - else return null - - val subplotsOutcomes = subplots.associate { subplot -> - subplot.key to subplot.getFinalGameResult(this, winner) - } - - return if (hostDefeated && guestDefeated) - GameEvent.GameEnd(null, "Stalemate: both sides have been completely destroyed!", subplotsOutcomes) - else if (hostDefeated) - GameEvent.GameEnd(GlobalSide.GUEST, victoryMessage(GlobalSide.GUEST), subplotsOutcomes) - else if (guestDefeated) - GameEvent.GameEnd(GlobalSide.HOST, victoryMessage(GlobalSide.HOST), subplotsOutcomes) - else - null -} - -fun GameState.admiralInfo(side: GlobalSide) = when (side) { - GlobalSide.HOST -> hostInfo - GlobalSide.GUEST -> guestInfo -} - -enum class GlobalSide { - HOST, GUEST; - - val other: GlobalSide - get() = when (this) { - HOST -> GUEST - GUEST -> HOST - } -} - -fun GlobalSide.relativeTo(me: GlobalSide) = if (this == me) LocalSide.GREEN else LocalSide.RED - -enum class LocalSide { - GREEN, RED; - - val other: LocalSide - get() = when (this) { - GREEN -> RED - RED -> GREEN - } -} - -val LocalSide.htmlColor: String - get() = when (this) { - LocalSide.GREEN -> "#55FF55" - LocalSide.RED -> "#FF5555" - } diff --git a/src/commonMain/kotlin/starshipfights/game/game_subplots.kt b/src/commonMain/kotlin/starshipfights/game/game_subplots.kt deleted file mode 100644 index 6d68742..0000000 --- a/src/commonMain/kotlin/starshipfights/game/game_subplots.kt +++ /dev/null @@ -1,296 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id - -@Serializable -data class GameObjective( - val displayText: String, - val succeeded: Boolean? -) - -fun GameState.objectives(forPlayer: GlobalSide): List = listOf( - GameObjective("Destroy or rout the enemy fleet", null) -) + subplots.filter { it.forPlayer == forPlayer }.mapNotNull { it.displayObjective(this) } - -@Serializable -data class SubplotKey( - val type: SubplotType, - val player: GlobalSide, -) - -val Subplot.key: SubplotKey - get() = SubplotKey(type, forPlayer) - -@Serializable -sealed class Subplot { - abstract val type: SubplotType - abstract val displayName: String - abstract val forPlayer: GlobalSide - - override fun equals(other: Any?): Boolean { - return other is Subplot && other.key == key - } - - override fun hashCode(): Int { - return key.hashCode() - } - - abstract fun displayObjective(gameState: GameState): GameObjective? - - abstract fun onAfterDeployShips(gameState: GameState): GameState - abstract fun onGameStateChanged(gameState: GameState): GameState - abstract fun getFinalGameResult(gameState: GameState, winner: GlobalSide?): SubplotOutcome - - protected fun GameState.modifySubplotData(newSubplot: Subplot) = copy(subplots = (subplots - this@Subplot) + newSubplot) - - @Serializable - class ExtendedDuty(override val forPlayer: GlobalSide) : Subplot() { - override val type: SubplotType - get() = SubplotType.EXTENDED_DUTY - - override val displayName: String - get() = "Extended Duty" - - override fun displayObjective(gameState: GameState) = GameObjective("Win the battle with your fleet worn out from extended duty", null) - - private fun ShipInstance.preBattleDamage(): ShipInstance = when ((0..4).random()) { - 0 -> copy( - hullAmount = (2..hullAmount).random(), - troopsAmount = (2..troopsAmount).random(), - modulesStatus = ShipModulesStatus( - modulesStatus.statuses.mapValues { (_, status) -> - if (status != ShipModuleStatus.ABSENT && (1..3).random() == 1) - ShipModuleStatus.DESTROYED - else - status - } - ) - ) - 1 -> copy( - hullAmount = (2..hullAmount).random(), - troopsAmount = (2..troopsAmount).random(), - ) - 2 -> copy( - troopsAmount = (2..troopsAmount).random(), - ) - else -> this - } - - override fun onAfterDeployShips(gameState: GameState) = gameState.copy(ships = gameState.ships.mapValues { (_, ship) -> - if (ship.owner == forPlayer) - ship.preBattleDamage() - else ship - }) - - override fun onGameStateChanged(gameState: GameState) = gameState - - override fun getFinalGameResult(gameState: GameState, winner: GlobalSide?) = SubplotOutcome.fromBattleWinner(winner, forPlayer) - } - - @Serializable - class NoQuarter(override val forPlayer: GlobalSide) : Subplot() { - override val type: SubplotType - get() = SubplotType.NO_QUARTER - - override val displayName: String - get() = "Leave No Quarter!" - - override fun displayObjective(gameState: GameState): GameObjective { - val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other } - val enemyWrecks = gameState.destroyedShips.values.filter { it.owner == forPlayer.other } - - val totalEnemyShipPointCount = enemyShips.sumOf { it.ship.pointCost } + enemyWrecks.sumOf { it.ship.pointCost } - val escapedShipPointCount = enemyWrecks.filter { it.isEscape }.sumOf { it.ship.pointCost } - val destroyedShipPointCount = enemyWrecks.filter { !it.isEscape }.sumOf { it.ship.pointCost } - - val success = when { - destroyedShipPointCount * 2 >= totalEnemyShipPointCount -> true - escapedShipPointCount * 2 >= totalEnemyShipPointCount -> false - else -> null - } - - return GameObjective("Destroy at least half of the enemy fleet's point value - do not let them escape!", success) - } - - override fun onAfterDeployShips(gameState: GameState) = gameState - - override fun onGameStateChanged(gameState: GameState) = gameState - - override fun getFinalGameResult(gameState: GameState, winner: GlobalSide?): SubplotOutcome { - val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other } - val enemyWrecks = gameState.destroyedShips.values.filter { it.owner == forPlayer.other } - - val totalEnemyShipPointCount = enemyShips.sumOf { it.ship.pointCost } + enemyWrecks.sumOf { it.ship.pointCost } - val destroyedShipPointCount = enemyWrecks.filter { !it.isEscape }.sumOf { it.ship.pointCost } - - return if (destroyedShipPointCount * 2 >= totalEnemyShipPointCount) - SubplotOutcome.WON - else - SubplotOutcome.LOST - } - } - - @Serializable - class Vendetta private constructor(override val forPlayer: GlobalSide, private val againstShip: Id?, private val outcome: SubplotOutcome) : Subplot() { - constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED) - constructor(forPlayer: GlobalSide, againstShip: Id) : this(forPlayer, againstShip, SubplotOutcome.UNDECIDED) - - override val type: SubplotType - get() = SubplotType.VENDETTA - - override val displayName: String - get() = "Vendetta!" - - override fun displayObjective(gameState: GameState): GameObjective? { - val shipName = gameState.getShipInfoOrNull(againstShip ?: return null)?.fullName ?: return null - return GameObjective("Destroy the $shipName", outcome.toSuccess) - } - - override fun onAfterDeployShips(gameState: GameState): GameState { - if (gameState.ships[againstShip] != null) return gameState - - val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other } - val highestEnemyShipTier = enemyShips.maxOf { it.ship.shipType.weightClass } - val enemyShipsOfHighestTier = enemyShips.filter { it.ship.shipType.weightClass == highestEnemyShipTier } - - val vendettaShip = enemyShipsOfHighestTier.random().id - return gameState.modifySubplotData(Vendetta(forPlayer, vendettaShip, SubplotOutcome.UNDECIDED)) - } - - override fun onGameStateChanged(gameState: GameState): GameState { - if (outcome != SubplotOutcome.UNDECIDED) return gameState - - val vendettaShipWreck = gameState.destroyedShips[againstShip ?: return gameState] ?: return gameState - return if (vendettaShipWreck.isEscape) - gameState.modifySubplotData(Vendetta(forPlayer, againstShip, SubplotOutcome.LOST)) - else - gameState.modifySubplotData(Vendetta(forPlayer, againstShip, SubplotOutcome.WON)) - } - - override fun getFinalGameResult(gameState: GameState, winner: GlobalSide?) = if (outcome == SubplotOutcome.UNDECIDED) - SubplotOutcome.LOST - else outcome - } - - @Serializable - class RecoverInformant private constructor(override val forPlayer: GlobalSide, private val onBoardShip: Id?, private val outcome: SubplotOutcome, private val mostRecentChatMessages: Moment?) : Subplot() { - constructor(forPlayer: GlobalSide) : this(forPlayer, null, SubplotOutcome.UNDECIDED, null) - constructor(forPlayer: GlobalSide, onBoardShip: Id) : this(forPlayer, onBoardShip, SubplotOutcome.UNDECIDED, null) - - override val type: SubplotType - get() = SubplotType.RECOVER_INFORMANT - - override val displayName: String - get() = "Recover Informant" - - override fun displayObjective(gameState: GameState): GameObjective? { - val shipName = gameState.getShipInfoOrNull(onBoardShip ?: return null)?.fullName ?: return null - return GameObjective("Board the $shipName and recover your informant", outcome.toSuccess) - } - - override fun onAfterDeployShips(gameState: GameState): GameState { - if (gameState.ships[onBoardShip] != null) return gameState - - val enemyShips = gameState.ships.values.filter { it.owner == forPlayer.other } - val lowestEnemyShipTier = enemyShips.minOf { it.ship.shipType.weightClass } - val enemyShipsNotOfLowestTier = enemyShips.filter { it.ship.shipType.weightClass != lowestEnemyShipTier }.ifEmpty { enemyShips } - - val informantShip = enemyShipsNotOfLowestTier.random().id - return gameState.modifySubplotData(RecoverInformant(forPlayer, informantShip, SubplotOutcome.UNDECIDED, null)) - } - - private fun GameState.getNewMessages(readTime: Moment?) = if (readTime == null) - chatBox - else - chatBox.filter { it.sentAt > readTime } - - override fun onGameStateChanged(gameState: GameState): GameState { - if (outcome != SubplotOutcome.UNDECIDED) return gameState - - var readTime = mostRecentChatMessages - for (message in gameState.getNewMessages(mostRecentChatMessages)) { - when (message) { - is ChatEntry.ShipEscaped -> if (message.ship == onBoardShip) - return gameState.modifySubplotData(RecoverInformant(forPlayer, onBoardShip, SubplotOutcome.LOST, null)) - is ChatEntry.ShipDestroyed -> if (message.ship == onBoardShip) - return gameState.modifySubplotData(RecoverInformant(forPlayer, onBoardShip, SubplotOutcome.LOST, null)) - is ChatEntry.ShipBoarded -> if (message.ship == onBoardShip && (1..3).random() == 1) - return gameState.modifySubplotData(RecoverInformant(forPlayer, onBoardShip, SubplotOutcome.WON, null)) - else -> { - // do nothing - } - } - readTime = if (readTime == null || readTime < message.sentAt) message.sentAt else readTime - } - - return gameState.modifySubplotData(RecoverInformant(forPlayer, onBoardShip, SubplotOutcome.UNDECIDED, readTime)) - } - - override fun getFinalGameResult(gameState: GameState, winner: GlobalSide?) = if (outcome == SubplotOutcome.UNDECIDED) - SubplotOutcome.LOST - else outcome - } -} - -enum class SubplotType(val factory: (GlobalSide) -> Subplot) { - EXTENDED_DUTY(Subplot::ExtendedDuty), - NO_QUARTER(Subplot::NoQuarter), - VENDETTA(Subplot::Vendetta), - RECOVER_INFORMANT(Subplot::RecoverInformant), -} - -fun generateSubplots(battleSize: BattleSize, forPlayer: GlobalSide): Set = - (1..battleSize.numSubplotsPerPlayer).map { - SubplotType.values().random().factory(forPlayer) - }.toSet() - -@Serializable -enum class SubplotOutcome { - UNDECIDED, WON, LOST; - - val toSuccess: Boolean? - get() = when (this) { - UNDECIDED -> null - WON -> true - LOST -> false - } - - companion object { - fun fromBattleWinner(winner: GlobalSide?, subplotForPlayer: GlobalSide) = when (winner) { - subplotForPlayer -> WON - subplotForPlayer.other -> LOST - else -> UNDECIDED - } - } -} - -fun victoryTitle(player: GlobalSide, winner: GlobalSide?, subplotOutcomes: Map): String { - val myOutcomes = subplotOutcomes.filterKeys { it.player == player } - - return when (winner) { - player -> { - val isGlorious = myOutcomes.all { (_, outcome) -> outcome == SubplotOutcome.WON } - val isPyrrhic = myOutcomes.size >= 2 && myOutcomes.none { (_, outcome) -> outcome == SubplotOutcome.WON } - - if (isGlorious) - "Glorious Victory" - else if (isPyrrhic) - "Pyrrhic Victory" - else - "Victory" - } - player.other -> { - val isHeroic = myOutcomes.all { (_, outcome) -> outcome == SubplotOutcome.WON } - val isHumiliating = myOutcomes.size >= 2 && myOutcomes.none { (_, outcome) -> outcome == SubplotOutcome.WON } - - if (isHeroic) - "Heroic Defeat" - else if (isHumiliating) - "Humiliating Defeat" - else - "Defeat" - } - else -> "Stalemate" - } -} diff --git a/src/commonMain/kotlin/starshipfights/game/game_time.kt b/src/commonMain/kotlin/starshipfights/game/game_time.kt deleted file mode 100644 index 583745d..0000000 --- a/src/commonMain/kotlin/starshipfights/game/game_time.kt +++ /dev/null @@ -1,34 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.serializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -@Serializable(with = MomentSerializer::class) -expect class Moment(millis: Double) : Comparable { - fun toMillis(): Double - - override fun compareTo(other: Moment): Int - - companion object { - val now: Moment - } -} - -object MomentSerializer : KSerializer { - private val inner = Double.serializer() - - override val descriptor: SerialDescriptor - get() = inner.descriptor - - override fun serialize(encoder: Encoder, value: Moment) { - inner.serialize(encoder, value.toMillis()) - } - - override fun deserialize(decoder: Decoder): Moment { - return Moment(inner.deserialize(decoder)) - } -} diff --git a/src/commonMain/kotlin/starshipfights/game/matchmaking.kt b/src/commonMain/kotlin/starshipfights/game/matchmaking.kt deleted file mode 100644 index bbfe00a..0000000 --- a/src/commonMain/kotlin/starshipfights/game/matchmaking.kt +++ /dev/null @@ -1,110 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id - -enum class BattleSize(val numPoints: Int, val maxWeightClass: ShipWeightClass, val displayName: String) { - SKIRMISH(600, ShipWeightClass.CRUISER, "Skirmish"), - RAID(800, ShipWeightClass.CRUISER, "Raid"), - FIREFIGHT(1000, ShipWeightClass.BATTLECRUISER, "Firefight"), - BATTLE(1300, ShipWeightClass.BATTLECRUISER, "Battle"), - GRAND_CLASH(1600, ShipWeightClass.BATTLESHIP, "Grand Clash"), - APOCALYPSE(2000, ShipWeightClass.BATTLESHIP, "Apocalypse"), - LEGENDARY_STRUGGLE(2400, ShipWeightClass.COLOSSUS, "Legendary Struggle"), - CRUCIBLE_OF_HISTORY(3000, ShipWeightClass.COLOSSUS, "Crucible of History"); -} - -val BattleSize.numSubplotsPerPlayer: Int - get() = when (this) { - BattleSize.SKIRMISH -> 0 - BattleSize.RAID -> 0 - BattleSize.FIREFIGHT -> 0 - BattleSize.BATTLE -> (0..1).random() - BattleSize.GRAND_CLASH -> 1 - BattleSize.APOCALYPSE -> 1 - BattleSize.LEGENDARY_STRUGGLE -> 1 - BattleSize.CRUCIBLE_OF_HISTORY -> (1..2).random() - } - -enum class BattleBackground(val displayName: String, val color: String) { - BLUE_BROWN("Milky Way", "#335577"), - BLUE_MAGENTA("Arcane Anomaly", "#553377"), - BLUE_PURPLE("Vensca Wormhole", "#444477"), - BLUE_GREEN("Radiation Risk", "#337755"), - GRAYBLUE_GRAYBROWN("Fulkreyksk Bloc", "#445566"), - MAGENTA_PURPLE("Aedon Vortex", "#773355"), - ORANGE_ORANGE("Solar Flare", "#775533"), - PURPLE_MAGENTA("Veil Rift", "#663366"), -} - -@Serializable -data class BattleInfo( - val size: BattleSize, - val bg: BattleBackground, -) - -// PACKETS -@Serializable -data class PlayerLogin( - val admiral: Id, - val login: LoginMode, -) - -@Serializable -sealed class LoginMode { - abstract val globalSide: GlobalSide? - - @Serializable - data class Train(val battleInfo: BattleInfo, val enemyFaction: Faction?) : LoginMode() { - override val globalSide: GlobalSide? - get() = null - } - - @Serializable - data class Host(val battleInfo: BattleInfo) : LoginMode() { - override val globalSide: GlobalSide - get() = GlobalSide.HOST - } - - @Serializable - object Join : LoginMode() { - override val globalSide: GlobalSide - get() = GlobalSide.GUEST - } -} - -@Serializable -data class GameReady(val connectToken: String) - -// HOST FLOW -@Serializable -data class JoinRequest( - val joiner: InGameAdmiral -) - -@Serializable -data class JoinResponse( - val accepted: Boolean -) - -@Serializable -data class JoinResponseResponse( - val connected: Boolean -) - -// GUEST FLOW -@Serializable -data class JoinListing( - val openGames: Map -) - -@Serializable -data class Joinable( - val admiral: InGameAdmiral, - val battleInfo: BattleInfo, -) - -@Serializable -data class JoinSelection( - val selectedId: String -) diff --git a/src/commonMain/kotlin/starshipfights/game/math.kt b/src/commonMain/kotlin/starshipfights/game/math.kt deleted file mode 100644 index ea91db8..0000000 --- a/src/commonMain/kotlin/starshipfights/game/math.kt +++ /dev/null @@ -1,107 +0,0 @@ -@file:Suppress("NOTHING_TO_INLINE") - -package starshipfights.game - -import kotlinx.serialization.Serializable -import kotlin.jvm.JvmInline -import kotlin.math.* - -// PLAIN OLD 2D VECTORS - -@Serializable -data class Vec2(val x: Double, val y: Double) - -inline operator fun Vec2.plus(other: Vec2) = Vec2(x + other.x, y + other.y) -inline operator fun Vec2.minus(other: Vec2) = Vec2(x - other.x, y - other.y) -inline operator fun Vec2.times(scale: Double) = Vec2(x * scale, y * scale) -inline operator fun Vec2.div(scale: Double) = Vec2(x / scale, y / scale) - -inline operator fun Double.times(vec: Vec2) = vec * this - -inline operator fun Vec2.unaryPlus() = this -inline operator fun Vec2.unaryMinus() = this * -1.0 - -inline infix fun Vec2.dot(other: Vec2) = x * other.x + y * other.y -inline infix fun Vec2.cross(other: Vec2) = x * other.y - y * other.x - -inline infix fun Vec2.angleTo(other: Vec2) = atan2(this cross other, this dot other) -inline infix fun Vec2.angleBetween(other: Vec2) = abs(this angleTo other) - -inline infix fun Vec2.rotatedBy(angle: Double) = normalVector(angle).let { (c, s) -> Vec2(c * x - s * y, c * y + s * x) } - -inline fun normalVector(angle: Double) = Vec2(cos(angle), sin(angle)) -inline fun polarVector(radius: Double, angle: Double) = Vec2(radius * cos(angle), radius * sin(angle)) - -inline val Vec2.magnitude: Double - get() = hypot(x, y) - -inline val Vec2.angle: Double - get() = atan2(y, x) - -inline val Vec2.normal: Vec2 - get() { - val thisMagnitude = this.magnitude - return if (thisMagnitude < EPSILON) - Vec2(0.0, 0.0) - else this / thisMagnitude - } - -// AFFINE vs DISPLACEMENT QUANTITIES - -@Serializable -@JvmInline -value class Position(val vector: Vec2) - -@Serializable -@JvmInline -value class Distance(val vector: Vec2) - -inline operator fun Position.plus(distance: Distance) = Position(vector + distance.vector) -inline operator fun Distance.plus(position: Position) = Position(vector + position.vector) -inline operator fun Distance.plus(other: Distance) = Distance(vector + other.vector) - -inline operator fun Position.minus(relativeTo: Position) = Distance(vector - relativeTo.vector) -inline operator fun Position.minus(distance: Distance) = Position(vector - distance.vector) -inline operator fun Distance.minus(other: Distance) = Distance(vector - other.vector) - -inline operator fun Distance.times(scale: Double) = Distance(vector * scale) -inline operator fun Distance.div(scale: Double) = Distance(vector / scale) - -inline operator fun Double.times(dist: Distance) = dist * this -inline operator fun Double.div(dist: Distance) = dist / this - -inline operator fun Distance.unaryPlus() = this -inline operator fun Distance.unaryMinus() = Distance(-vector) - -inline infix fun Distance.dot(other: Distance) = vector dot other.vector -inline infix fun Distance.cross(other: Distance) = vector cross other.vector - -inline infix fun Distance.angleBetween(other: Distance) = vector angleBetween other.vector -inline infix fun Distance.angleTo(other: Distance) = vector angleTo other.vector - -inline infix fun Distance.rotatedBy(angle: Double) = Distance(vector rotatedBy angle) - -inline fun normalDistance(angle: Double) = Distance(normalVector(angle)) -inline fun polarDistance(radius: Double, angle: Double) = Distance(polarVector(radius, angle)) - -inline val Distance.length: Double - get() = vector.magnitude - -inline val Distance.angle: Double - get() = vector.angle - -inline val Distance.normal: Distance - get() = Distance(vector.normal) - -inline fun Position.clampOnLineSegment(a: Position, b: Position): Position { - val ab = b - a - val ar = this - a - - val abHat = ab.normal - val abLen = ab.length - - val proj = (ar dot abHat).coerceIn(0.0..abLen) - return proj * abHat + a -} - -inline fun Position.distanceToLineSegment(a: Position, b: Position) = (this - clampOnLineSegment(a, b)).length diff --git a/src/commonMain/kotlin/starshipfights/game/pick_bounds.kt b/src/commonMain/kotlin/starshipfights/game/pick_bounds.kt deleted file mode 100644 index 4afa103..0000000 --- a/src/commonMain/kotlin/starshipfights/game/pick_bounds.kt +++ /dev/null @@ -1,224 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id -import kotlin.math.PI -import kotlin.math.abs - -fun FiringArc.getStartAngle(shipFacing: Double) = (when (this) { - FiringArc.BOW -> Vec2(1.0, -1.0) - FiringArc.ABEAM_PORT -> Vec2(-1.0, -1.0) - FiringArc.ABEAM_STARBOARD -> Vec2(1.0, 1.0) - FiringArc.STERN -> Vec2(-1.0, 1.0) -} rotatedBy shipFacing).angle - -fun FiringArc.getEndAngle(shipFacing: Double) = (when (this) { - FiringArc.BOW -> Vec2(1.0, 1.0) - FiringArc.ABEAM_PORT -> Vec2(1.0, -1.0) - FiringArc.ABEAM_STARBOARD -> Vec2(-1.0, 1.0) - FiringArc.STERN -> Vec2(-1.0, -1.0) -} rotatedBy shipFacing).angle - -fun GameState.isValidPick(request: PickRequest, response: PickResponse): Boolean { - if (request.type is PickType.Ship != response is PickResponse.Ship) - return false - - when (response) { - is PickResponse.Location -> { - if (request.type !is PickType.Location) return false - - if (response.position !in request.boundary) return false - if (ships.values.any { - it.id in request.type.excludesNearShips && (it.position.location - response.position).length <= SHIP_BASE_SIZE - }) return false - - return true - } - is PickResponse.Ship -> { - if (request.type !is PickType.Ship) return false - - if (response.id !in ships) return false - - val ship = ships.getValue(response.id) - if (ship.position.location !in request.boundary) return false - if (ship.owner !in request.type.allowSides) return false - - return true - } - } -} - -@Serializable -data class PickRequest(val type: PickType, val boundary: PickBoundary) - -@Serializable -sealed class PickResponse { - @Serializable - data class Location(val position: Position) : PickResponse() - - @Serializable - data class Ship(val id: Id) : PickResponse() -} - -@Serializable -sealed class PickType { - @Serializable - data class Location(val excludesNearShips: Set>, val helper: PickHelper, val drawLineFrom: Position? = null) : PickType() - - @Serializable - data class Ship(val allowSides: Set) : PickType() -} - -@Serializable -sealed class PickBoundary { - abstract operator fun contains(point: Position): Boolean - open fun normalize(point: Position) = point - - @Serializable - data class Angle( - val center: Position, - val midAngle: Double, - val maxAngle: Double - ) : PickBoundary() { - override fun contains(point: Position): Boolean { - val midNormal = normalDistance(midAngle) - return (point - center) angleBetween midNormal <= maxAngle - } - } - - @Serializable - data class AlongLine( - val pointA: Position, - val pointB: Position - ) : PickBoundary() { - override fun contains(point: Position) = true - - override fun normalize(point: Position): Position { - return point.clampOnLineSegment(pointA, pointB) - } - } - - @Serializable - data class Rectangle( - val center: Position, - val width2: Double, - val length2: Double - ) : PickBoundary() { - override fun contains(point: Position): Boolean { - return (point - center).vector.let { (x, y) -> - abs(x) <= width2 && abs(y) <= length2 - } - } - } - - @Serializable - data class Circle( - val center: Position, - val radius: Double, - ) : PickBoundary() { - override fun contains(point: Position): Boolean { - return (point - center).length < radius - } - } - - @Serializable - data class WeaponsFire( - val center: Position, - val facing: Double, - val minDistance: Double, - val maxDistance: Double, - val firingArcs: Set, - - val canSelfSelect: Boolean = false - ) : PickBoundary() { - override fun contains(point: Position): Boolean { - if (canSelfSelect && (point - center).length < EPSILON) - return true - - val r = point - center - if (r.length !in minDistance..maxDistance) - return false - - val rHat = r.normal - val thetaHat = normalDistance(facing) - - val deltaTheta = thetaHat angleTo rHat - val firingArc: FiringArc = when { - abs(deltaTheta) < PI / 4 -> FiringArc.BOW - abs(deltaTheta) > PI * 3 / 4 -> FiringArc.STERN - deltaTheta < 0 -> FiringArc.ABEAM_PORT - else -> FiringArc.ABEAM_STARBOARD - } - - return firingArc in firingArcs - } - } -} - -@Serializable -sealed class PickHelper { - @Serializable - object None : PickHelper() - - @Serializable - data class Ship(val type: ShipType, val facing: Double) : PickHelper() - - @Serializable - data class Circle(val radius: Double) : PickHelper() -} - -fun PickBoundary.closestPointTo(position: Position): Position = when (this) { - is PickBoundary.AlongLine -> position.clampOnLineSegment(pointA, pointB) - is PickBoundary.Angle -> { - val distance = position - center - val midNormal = normalDistance(midAngle) - - if ((distance angleBetween midNormal) <= maxAngle) - position - else - ((midNormal rotatedBy (midNormal angleTo distance).coerceIn(-maxAngle..maxAngle)) * distance.length) + center - } - is PickBoundary.Circle -> { - val distance = position - center - if (distance.length <= radius) - position - else - (distance.normal * radius) + center - } - is PickBoundary.Rectangle -> { - Distance((position - center).vector.let { (x, y) -> - Vec2(x.coerceIn(-width2..width2), y.coerceIn(-length2..length2)) - }) + center - } - is PickBoundary.WeaponsFire -> { - val distance = position - center - - val thetaHat = normalDistance(facing) - - val deltaTheta = thetaHat angleTo distance - val firingArc: FiringArc = when { - abs(deltaTheta) < PI / 4 -> FiringArc.BOW - abs(deltaTheta) > PI * 3 / 4 -> FiringArc.STERN - deltaTheta < 0 -> FiringArc.ABEAM_PORT - else -> FiringArc.ABEAM_STARBOARD - } - - if (firingArc in firingArcs) { - if (distance.length in minDistance..maxDistance) - position - else - (distance.normal * (if (distance.length < minDistance) minDistance else maxDistance)) + center - } else - firingArcs.flatMap { - val startNormal = normalDistance(it.getStartAngle(facing)) - val endNormal = normalDistance(it.getEndAngle(facing)) - - listOf( - (startNormal * minDistance) + center, - (endNormal * minDistance) + center, - (startNormal * maxDistance) + center, - (endNormal * maxDistance) + center, - ) - }.minByOrNull { (it - position).length } ?: position - } -} diff --git a/src/commonMain/kotlin/starshipfights/game/ship.kt b/src/commonMain/kotlin/starshipfights/game/ship.kt deleted file mode 100644 index 123b197..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship.kt +++ /dev/null @@ -1,231 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id -import kotlin.math.PI - -@Serializable -data class Ship( - val id: Id, - - val name: String, - val shipType: ShipType -) { - val fullName: String - get() = "${shipType.faction.shipPrefix}$name" - - val pointCost: Int - get() = shipType.pointCost - - val reactor: ShipReactor - get() = shipType.weightClass.reactor - - val movement: ShipMovement - get() = shipType.weightClass.movement - - val durability: ShipDurability - get() = shipType.weightClass.durability - - val firepower: ShipFirepower - get() = shipType.weightClass.firepower - - val armaments: ShipArmaments - get() = shipType.armaments - - val hasShields: Boolean - get() = shipType.faction != Faction.FELINAE_FELICES - - val canUseInertialessDrive: Boolean - get() = shipType.faction == Faction.FELINAE_FELICES - - val canUseDisruptionPulse: Boolean - get() = shipType.faction == Faction.FELINAE_FELICES - - val canUseRecoalescence: Boolean - get() = shipType.faction == Faction.FELINAE_FELICES -} - -@Serializable -sealed class ShipReactor - -@Serializable -data class StandardShipReactor( - val subsystemAmount: Int, - val gridEfficiency: Int -) : ShipReactor() { - val powerOutput: Int - get() = subsystemAmount * 3 -} - -@Serializable -object FelinaeShipReactor : ShipReactor() - -val ShipWeightClass.reactor: ShipReactor - get() = when (this) { - ShipWeightClass.ESCORT -> StandardShipReactor(2, 1) - ShipWeightClass.DESTROYER -> StandardShipReactor(3, 1) - ShipWeightClass.CRUISER -> StandardShipReactor(4, 2) - ShipWeightClass.BATTLECRUISER -> StandardShipReactor(6, 3) - ShipWeightClass.BATTLESHIP -> StandardShipReactor(7, 4) - - ShipWeightClass.BATTLE_BARGE -> StandardShipReactor(5, 3) - - ShipWeightClass.GRAND_CRUISER -> StandardShipReactor(6, 4) - ShipWeightClass.COLOSSUS -> StandardShipReactor(9, 6) - - ShipWeightClass.FF_ESCORT -> FelinaeShipReactor - ShipWeightClass.FF_DESTROYER -> FelinaeShipReactor - ShipWeightClass.FF_CRUISER -> FelinaeShipReactor - ShipWeightClass.FF_BATTLECRUISER -> FelinaeShipReactor - ShipWeightClass.FF_BATTLESHIP -> FelinaeShipReactor - - ShipWeightClass.AUXILIARY_SHIP -> StandardShipReactor(2, 1) - ShipWeightClass.LIGHT_CRUISER -> StandardShipReactor(3, 1) - ShipWeightClass.MEDIUM_CRUISER -> StandardShipReactor(4, 2) - ShipWeightClass.HEAVY_CRUISER -> StandardShipReactor(6, 3) - - ShipWeightClass.FRIGATE -> StandardShipReactor(4, 1) - ShipWeightClass.LINE_SHIP -> StandardShipReactor(6, 3) - ShipWeightClass.DREADNOUGHT -> StandardShipReactor(8, 5) - } - -@Serializable -sealed class ShipMovement { - abstract val turnAngle: Double - abstract val moveSpeed: Double -} - -@Serializable -data class StandardShipMovement( - override val turnAngle: Double, - override val moveSpeed: Double, -) : ShipMovement() - -@Serializable -data class FelinaeShipMovement( - override val turnAngle: Double, - override val moveSpeed: Double, - val inertialessDriveRange: Double, - val inertialessDriveShots: Int -) : ShipMovement() - -val ShipWeightClass.movement: ShipMovement - get() = when (this) { - ShipWeightClass.ESCORT -> StandardShipMovement(PI / 2, 2500.0) - ShipWeightClass.DESTROYER -> StandardShipMovement(PI / 2, 2200.0) - ShipWeightClass.CRUISER -> StandardShipMovement(PI / 3, 1900.0) - ShipWeightClass.BATTLECRUISER -> StandardShipMovement(PI / 3, 1900.0) - ShipWeightClass.BATTLESHIP -> StandardShipMovement(PI / 4, 1600.0) - - ShipWeightClass.BATTLE_BARGE -> StandardShipMovement(PI / 4, 1600.0) - - ShipWeightClass.GRAND_CRUISER -> StandardShipMovement(PI / 4, 1750.0) - ShipWeightClass.COLOSSUS -> StandardShipMovement(PI / 6, 1300.0) - - ShipWeightClass.FF_ESCORT -> FelinaeShipMovement(PI / 3, 1600.0, 4000.0, 1) - ShipWeightClass.FF_DESTROYER -> FelinaeShipMovement(PI / 4, 1400.0, 3750.0, 1) - ShipWeightClass.FF_CRUISER -> FelinaeShipMovement(PI / 6, 1200.0, 3250.0, 1) - ShipWeightClass.FF_BATTLECRUISER -> FelinaeShipMovement(PI / 6, 1200.0, 3000.0, 2) - ShipWeightClass.FF_BATTLESHIP -> FelinaeShipMovement(PI / 8, 800.0, 2500.0, 2) - - ShipWeightClass.AUXILIARY_SHIP -> StandardShipMovement(PI / 2, 2500.0) - ShipWeightClass.LIGHT_CRUISER -> StandardShipMovement(PI / 2, 2250.0) - ShipWeightClass.MEDIUM_CRUISER -> StandardShipMovement(PI / 3, 2000.0) - ShipWeightClass.HEAVY_CRUISER -> StandardShipMovement(PI / 3, 1750.0) - - ShipWeightClass.FRIGATE -> StandardShipMovement(PI * 2 / 3, 2750.0) - ShipWeightClass.LINE_SHIP -> StandardShipMovement(PI / 2, 2250.0) - ShipWeightClass.DREADNOUGHT -> StandardShipMovement(PI / 3, 1750.0) - } - -@Serializable -sealed class ShipDurability { - abstract val maxHullPoints: Int - abstract val turretDefense: Double - abstract val troopsDefense: Int -} - -@Serializable -data class StandardShipDurability( - override val maxHullPoints: Int, - override val turretDefense: Double, - override val troopsDefense: Int, - val repairTokens: Int, -) : ShipDurability() - -@Serializable -data class FelinaeShipDurability( - override val maxHullPoints: Int, - override val troopsDefense: Int, - val disruptionPulseRange: Double, - val disruptionPulseShots: Int -) : ShipDurability() { - override val turretDefense: Double - get() = 0.0 -} - -val ShipWeightClass.durability: ShipDurability - get() = when (this) { - ShipWeightClass.ESCORT -> StandardShipDurability(4, 0.5, 5, 1) - ShipWeightClass.DESTROYER -> StandardShipDurability(8, 0.5, 7, 1) - ShipWeightClass.CRUISER -> StandardShipDurability(12, 1.0, 10, 2) - ShipWeightClass.BATTLECRUISER -> StandardShipDurability(14, 1.5, 10, 2) - ShipWeightClass.BATTLESHIP -> StandardShipDurability(16, 2.0, 15, 3) - - ShipWeightClass.BATTLE_BARGE -> StandardShipDurability(16, 1.5, 15, 3) - - ShipWeightClass.GRAND_CRUISER -> StandardShipDurability(15, 1.75, 12, 3) - ShipWeightClass.COLOSSUS -> StandardShipDurability(27, 3.0, 25, 4) - - ShipWeightClass.FF_ESCORT -> FelinaeShipDurability(6, 3, 1000.0, 3) - ShipWeightClass.FF_DESTROYER -> FelinaeShipDurability(9, 4, 1000.0, 4) - ShipWeightClass.FF_CRUISER -> FelinaeShipDurability(12, 5, 750.0, 2) - ShipWeightClass.FF_BATTLECRUISER -> FelinaeShipDurability(15, 6, 875.0, 2) - ShipWeightClass.FF_BATTLESHIP -> FelinaeShipDurability(18, 7, 1250.0, 3) - - ShipWeightClass.AUXILIARY_SHIP -> StandardShipDurability(4, 2.0, 6, 1) - ShipWeightClass.LIGHT_CRUISER -> StandardShipDurability(8, 3.0, 9, 2) - ShipWeightClass.MEDIUM_CRUISER -> StandardShipDurability(12, 3.5, 12, 2) - ShipWeightClass.HEAVY_CRUISER -> StandardShipDurability(16, 4.0, 15, 3) - - ShipWeightClass.FRIGATE -> StandardShipDurability(10, 1.5, 7, 1) - ShipWeightClass.LINE_SHIP -> StandardShipDurability(15, 2.0, 9, 1) - ShipWeightClass.DREADNOUGHT -> StandardShipDurability(20, 2.5, 11, 1) - } - -@Serializable -data class ShipFirepower( - val rangeMultiplier: Double, - val criticalChance: Double, - val cannonAccuracy: Double, - val lanceCharging: Double, -) - -val ShipWeightClass.firepower: ShipFirepower - get() = when (this) { - ShipWeightClass.ESCORT -> ShipFirepower(0.75, 0.75, 0.875, 0.875) - ShipWeightClass.DESTROYER -> ShipFirepower(0.75, 0.75, 1.0, 1.0) - ShipWeightClass.CRUISER -> ShipFirepower(1.0, 1.0, 1.0, 1.0) - ShipWeightClass.BATTLECRUISER -> ShipFirepower(1.25, 1.25, 1.25, 1.25) - ShipWeightClass.BATTLESHIP -> ShipFirepower(1.25, 1.25, 1.25, 1.25) - - ShipWeightClass.BATTLE_BARGE -> ShipFirepower(1.25, 1.25, 1.25, 1.25) - - ShipWeightClass.GRAND_CRUISER -> ShipFirepower(1.25, 1.25, 1.25, 1.25) - ShipWeightClass.COLOSSUS -> ShipFirepower(1.5, 1.5, 1.5, 1.5) - - ShipWeightClass.FF_ESCORT -> ShipFirepower(1.0, 0.6, 0.5, -1.0) - ShipWeightClass.FF_DESTROYER -> ShipFirepower(1.0, 0.8, 0.625, -1.0) - ShipWeightClass.FF_CRUISER -> ShipFirepower(1.0, 1.0, 0.75, -1.0) - ShipWeightClass.FF_BATTLECRUISER -> ShipFirepower(1.0, 1.0, 0.875, -1.0) - ShipWeightClass.FF_BATTLESHIP -> ShipFirepower(1.5, 1.2, 1.0, -1.0) - - ShipWeightClass.AUXILIARY_SHIP -> ShipFirepower(1.0, 1.0, 1.0, 1.0) - ShipWeightClass.LIGHT_CRUISER -> ShipFirepower(1.0, 1.0, 1.0, 1.0) - ShipWeightClass.MEDIUM_CRUISER -> ShipFirepower(1.0, 1.0, 1.0, 1.0) - ShipWeightClass.HEAVY_CRUISER -> ShipFirepower(1.0, 1.0, 1.0, 1.0) - - ShipWeightClass.FRIGATE -> ShipFirepower(0.8, 0.8, 1.0, 1.0) - ShipWeightClass.LINE_SHIP -> ShipFirepower(1.0, 1.0, 1.0, 1.0) - ShipWeightClass.DREADNOUGHT -> ShipFirepower(1.2, 1.2, 1.0, 1.0) - } diff --git a/src/commonMain/kotlin/starshipfights/game/ship_boarding.kt b/src/commonMain/kotlin/starshipfights/game/ship_boarding.kt deleted file mode 100644 index de7eccc..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship_boarding.kt +++ /dev/null @@ -1,169 +0,0 @@ -package starshipfights.game - -import starshipfights.data.Id -import kotlin.math.roundToInt - -fun factionBoardingModifier(faction: Faction): Int = when (faction) { - Faction.MECHYRDIA -> 7 - Faction.NDRC -> 10 - Faction.MASRA_DRAETSEN -> 8 - Faction.FELINAE_FELICES -> 0 - Faction.ISARNAREYKK -> 3 - Faction.VESTIGIUM -> 2 -} - -fun weightClassBoardingModifier(weightClass: ShipWeightClass): Int = when (weightClass) { - ShipWeightClass.ESCORT -> 2 - ShipWeightClass.DESTROYER -> 2 - ShipWeightClass.CRUISER -> 4 - ShipWeightClass.BATTLECRUISER -> 4 - ShipWeightClass.BATTLESHIP -> 6 - - ShipWeightClass.BATTLE_BARGE -> 8 - - ShipWeightClass.GRAND_CRUISER -> 6 - ShipWeightClass.COLOSSUS -> 10 - - ShipWeightClass.FF_ESCORT -> 0 - ShipWeightClass.FF_DESTROYER -> 2 - ShipWeightClass.FF_CRUISER -> 2 - ShipWeightClass.FF_BATTLECRUISER -> 4 - ShipWeightClass.FF_BATTLESHIP -> 6 - - ShipWeightClass.AUXILIARY_SHIP -> 0 - ShipWeightClass.LIGHT_CRUISER -> 2 - ShipWeightClass.MEDIUM_CRUISER -> 4 - ShipWeightClass.HEAVY_CRUISER -> 6 - - ShipWeightClass.FRIGATE -> 0 - ShipWeightClass.LINE_SHIP -> 2 - ShipWeightClass.DREADNOUGHT -> 4 -} - -fun troopsBoardingModifier(troopsAmount: Int, totalTroops: Int): Int = when { - troopsAmount < totalTroops / 3 -> 0 - troopsAmount < (totalTroops * 2) / 3 -> 2 - troopsAmount < totalTroops -> 3 - troopsAmount == totalTroops -> 4 - else -> 4 -} - -fun hullBoardingModifier(hullAmount: Int, totalHull: Int): Int = when { - hullAmount < totalHull / 2 -> 1 - hullAmount < totalHull -> 3 - hullAmount == totalHull -> 5 - else -> 5 -} - -fun turretsBoardingModifier(turretsDefense: Double, turretsStatus: ShipModuleStatus): Int = when (turretsStatus) { - ShipModuleStatus.INTACT -> turretsDefense.roundToInt() - ShipModuleStatus.DAMAGED -> (turretsDefense * 0.5).roundToInt() - else -> 0 -} - -fun shieldsBoardingAssaultModifier(shieldsAmount: Int, totalShields: Int): Int = when { - shieldsAmount == 0 -> 2 - shieldsAmount < totalShields -> 1 - else -> 0 -} - -fun shieldsBoardingDefenseModifier(shieldsAmount: Int, totalShields: Int): Int = when { - shieldsAmount == 0 -> 0 - shieldsAmount <= totalShields / 2 -> 1 - shieldsAmount < totalShields -> 2 - else -> 3 -} - -fun assaultBoardingModifier(assaultModuleStatus: ShipModuleStatus): Int = when (assaultModuleStatus) { - ShipModuleStatus.INTACT -> 5 - ShipModuleStatus.DAMAGED -> 3 - ShipModuleStatus.DESTROYED -> 0 - else -> 0 -} - -fun defenseBoardingModifier(defenseModuleStatus: ShipModuleStatus): Int = when (defenseModuleStatus) { - ShipModuleStatus.INTACT -> 3 - ShipModuleStatus.DAMAGED -> 2 - ShipModuleStatus.DESTROYED -> 0 - else -> 0 -} - -val ShipInstance.assaultModifier: Int - get() = listOf( - factionBoardingModifier(ship.shipType.faction), - weightClassBoardingModifier(ship.shipType.weightClass), - troopsBoardingModifier(troopsAmount, durability.troopsDefense), - hullBoardingModifier(hullAmount, durability.maxHullPoints), - turretsBoardingModifier(durability.turretDefense, modulesStatus[ShipModule.Turrets]), - if (canUseShields) - shieldsBoardingAssaultModifier(shieldAmount, powerMode.shields) - else shieldsBoardingAssaultModifier(0, powerMode.shields), - assaultBoardingModifier(modulesStatus[ShipModule.Assault]), - ).sum() - -val ShipInstance.defenseModifier: Int - get() = listOf( - factionBoardingModifier(ship.shipType.faction), - weightClassBoardingModifier(ship.shipType.weightClass), - troopsBoardingModifier(troopsAmount, durability.troopsDefense), - hullBoardingModifier(hullAmount, durability.maxHullPoints), - turretsBoardingModifier(durability.turretDefense, modulesStatus[ShipModule.Turrets]), - if (canUseShields) - shieldsBoardingDefenseModifier(shieldAmount, powerMode.shields) - else shieldsBoardingDefenseModifier(0, powerMode.shields), - defenseBoardingModifier(modulesStatus[ShipModule.Defense]), - ).sum() - -fun boardingRoll(): Int = (0..4).random() + (0..4).random() - -fun ShipInstance.board(defender: ShipInstance): ImpactResult { - val myValue = assaultModifier + boardingRoll() - val otherValue = defender.defenseModifier + boardingRoll() - - return when { - otherValue * 2 < myValue -> { - when (val firstImpact = ImpactResult.Intact(defender).withCritResult(defender.doCriticalDamage())) { - is ImpactResult.Damaged -> firstImpact.withCritResult(firstImpact.ship.doCriticalDamage()) - else -> firstImpact - } - } - otherValue <= myValue -> { - ImpactResult.Intact(defender).withCritResult(defender.doCriticalDamage()) - } - else -> { - val troopsKilled = (1..(myValue / 2)).randomOrNull() ?: 0 - ImpactResult.Intact(defender).withCritResult(defender.killTroops(troopsKilled)) - } - } -} - -fun ShipInstance.afterBoarding() = if (troopsAmount <= 1) null else copy( - troopsAmount = troopsAmount - 1, - hasSentBoardingParty = true, -) - -fun reportBoardingResult(impactResult: ImpactResult, attacker: Id) = when (impactResult) { - is ImpactResult.Destroyed -> ChatEntry.ShipDestroyed( - ship = impactResult.ship.id, - sentAt = Moment.now, - destroyedBy = ShipAttacker.EnemyShip(attacker) - ) - is ImpactResult.Damaged -> ChatEntry.ShipBoarded( - ship = impactResult.ship.id, - boarder = attacker, - sentAt = Moment.now, - critical = impactResult.critical.report(), - damageAmount = impactResult.damage.amount - ) -} - -fun ShipInstance.getBoardingPickRequest() = PickRequest( - PickType.Ship(allowSides = setOf(owner.other)), - PickBoundary.WeaponsFire( - center = position.location, - facing = position.facing, - minDistance = SHIP_BASE_SIZE, - maxDistance = firepower.rangeMultiplier * SHIP_TRANSPORTARIUM_RANGE, - firingArcs = FiringArc.FIRE_FORE_270, - ) -) diff --git a/src/commonMain/kotlin/starshipfights/game/ship_factions.kt b/src/commonMain/kotlin/starshipfights/game/ship_factions.kt deleted file mode 100644 index 9791bdf..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship_factions.kt +++ /dev/null @@ -1,218 +0,0 @@ -package starshipfights.game - -import kotlinx.html.TagConsumer -import kotlinx.html.i -import kotlinx.html.p - -enum class Faction( - val shortName: String, - val shortNameIsDefinite: Boolean, - val navyName: String, - val polityName: String, - val adjective: String, - val currencyName: String, - val shipPrefix: String, - val blurbDesc: TagConsumer<*>.() -> Unit, -) { - MECHYRDIA( - shortName = "Mechyrdia", - shortNameIsDefinite = false, - navyName = "Mechyrdian Star Fleet", - polityName = "Empire of Mechyrdia", - adjective = "Mechyrdian", - currencyName = "thrones", - shipPrefix = "CMŠ ", // Ciarstuos Mehurdiasi Štelnau - blurbDesc = { - p { - +"Having spent much of its history coming under threat from oppressive theocracies, conquering hordes, rebelling sectors, and invading syndicalists, the Empire of Mechyrdia now enjoys a place in the stars as the foremost power of the galaxy." - } - p { - +"Do not be confused by the name \"Empire\", Mechyrdia is a free and liberal democratic republic. While they once had an emperor, Nicólei the First and Only, he declared that the people of Mechyrdia would inherit the throne, thus abolishing the monarchy upon his death. Now the Empire runs on a semi-presidential democracy; the government does not have any office named \"President\", rather there is a Chancellor, the head of state who is elected by the people, and a Prime Minister, the head of government who is appointed by the Chancellor and confirmed by the tricameral Senate. " - } - p { - +"But things are not so ideal for Mechyrdia. The western menace, the Diadochus Masra Draetsen, threatens to upend this peaceful order and conquer Mechyrdia, to succeed where their predecessors the Arkant Horde had failed. Their new leader, Ogus Khan, has made many connections with the disgraced nations of the galaxy, and will stop at nothing to see Mechyrdia fall. Isarnareykk is making waves in its neighboring states of Theudareykk and Stahlareykk, states that are now within Mechyrdia's sphere of influence. Vestigium forces are being spotted in deep space throughout the Empire, and the Corvus Cluster sect has ended its radio silence." - } - p { - +"External problems are not the only issues faced by the Empire. Mechyrdia is also having internal troubles - corruption, erosion of liberty, concentration of wealth and power into an oligarchic elite - all problems that the current Chancellor, Marc Adlerówič Basileiów, and his populist Freedom Party are trying to fix. But his solutions are not without opposition, as various sectors of the Empire: Calibor, Vescar, Texandria, among others, are waging a campaign of passive resistance against Basileiów and his populist Chancery." - } - p { - +"It is the eleventh hour for the Empire of Mechyrdia; shall they enter a new golden age, or a new dark age? Only time will tell." - } - }, - ), - NDRC( - shortName = "NdRC", - shortNameIsDefinite = true, - navyName = "Dutch Marines", - polityName = "Dutch Outer Space Company", - adjective = "Dutch", - currencyName = "guldens", - shipPrefix = "NKS ", // Nederlandse Koopvaardijschip - blurbDesc = { - p { - +"The history of the Dutch Outer Space Company (Dutch: " - foreign("nl") { +"Nederlandse der Ruimte Compagnie" } - +") extends almost as far back as that of the American Vestigium. Founded in 2079 to provide space-colonization services to the European continent, the Dutch Outer Space Company has come into frequent conflict with the Imperial States of America." - } - p { - +"They survived during, and fought back against, the Drakhassi and Tylan occupations, waging a guerilla war against the oppressive regimes, as well as supplying other local humans with weapons to rebel too. In doing so, they put aside their differences with the Americans and formed a united front." - } - p { - +"Now, the Dutch Outer Space Company prospers, and so too do their business partners: the Empire of Mechyrdia. But with the imperilment of Mechyrdia to threats both within and without, the Company finds itself in the same danger. Shall it be liberty, or shall it be death?" - } - p { - i { +"Gameside note: Dutch admirals may purchase ships from other factions at a marked-up price, in addition to ships from their own faction." } - } - }, - ), - MASRA_DRAETSEN( - shortName = "Masra Draetsen", - shortNameIsDefinite = true, - navyName = "Masra Draetsen Khoy'qan", - polityName = "Diadochus Masra Draetsen", - adjective = "Diadochi", - currencyName = "sylaphs", - shipPrefix = "", // The Diadochi don't use ship prefixes - blurbDesc = { - p { - +"The Arkant Horde was once the greatest power of its time. Having conquered half of the galaxy in less than a decade, blessed by their dark god Aedon, the end of the Horde came when the Mechyrdians' trickery resulted in the death of the Great Khagan, and the Arkant Horde broke into hundreds of petty, feuding Diadochi." - } - p { - +"But now, one of these Diadochi has come to the forefront: the Diadochus Masra Draetsen. Known by their friends as freedom-fighters or liberators, and by their enemies as terrorists or barbarian khans from the galactic west, the Masra Draetsen rose to prominence under their current leader Ogus Khan." - } - p { - +"Having conquered 87 other Diadochi star-tribes, Ogus is making alliances with the various oppressed nations and outcast civilizations of the galaxy; groups as diverse as Isarnareyksk tech barons, Vestigium sects, Ilkhan syndicalist intellectuals, Ferthlon rebel remnants, and Olympian pagan elites, have all flocked to the cause of the Masra Draetsen. Soon, the conquest of Mechyrdia will begin. May there be woe to the vanquished!" - } - }, - ), - FELINAE_FELICES( - shortName = "Felinae Felices", - shortNameIsDefinite = true, - navyName = "Felinae Felices", - polityName = "Felinae Felices", - adjective = "Felinae", - currencyName = "thrones", - shipPrefix = "NFF ", // Navis Felinarum Felicium - blurbDesc = { - p { - +"The " - foreign("la") { +"Felinae Felices" } - +" (fey-LEE-nye fey-LEE-case) are quite the unusual power among the stars. Not a proper nation or state, the " - foreign("la") { +"Felinae" } - +" are an organized crime syndicate originating in the Mechyrdian sector of Olympia. They are the second most powerful mafia-like organization in the Empire, second to only their allies of convenience, the " - foreign("la") { +"Res Nostra" } - +"." - } - p { - +"Formerly a rival of the " - foreign("la") { +"Res Nostra" } - +", the " - foreign("la") { +"Felinae Felices" } - +" have turned their attitude 180-degrees under their new " - foreign("la") { +"Maxima" } - +", Tanaquil Cassia Pulchra. Now, the " - foreign("la") { +"Felinae" } - +" work as shipbuilders for the " - foreign("la") { +"Res Nostra" } - +" and other crime syndicates in need of starship fleets, though many are unhappy with the ships they receive, since the " - foreign("la") { +"Felinae" } - +" only build cat-themed starships with very little in the way of customizability." - } - p { - +"While the " - foreign("la") { +"Res Nostra" } - +" maintain good publicity by being charitable to poor individuals, they do not share this same attitude with competing organizations. The primary reason why they accepted the offer to ally with the " - foreign("la") { +"Felinae Felices" } - +" is because the " - foreign("la") { +"Felinae" } - +" are one of the most technologically-advanced organizations in the galaxy. " - foreign("la") { +"Felinae" } - +" ships have inertialess drives like the Vestigium, but unlike the Vestigium, the syndicate's ships can activate it anywhere, even inside the gravity wells of star systems. Advanced relativistic armor that denies more damage the faster the ship is moving, and weapons such as Particle Claws that can deal multiple critical hits in a single attack, and Lightning Yarn that ignores shields entirely, represent the peak of " - foreign("la") { +"Felinae" } - +" high technology." - } - p { - +"The " - foreign("la") { +"Felinae Felices" } - +" are a rather secretive organization. The people who observe them, whether they be high-ranking members of anti-mafia organizations or obsessive conspiracy theorists, speculate on how the syndicate gains new members: some believe that the " - foreign("la") { +"Felinae" } - +" kidnap, gene-mod, and brainwash people into serving them. Others think that the " - foreign("la") { +"Felinae" } - +" invite prominent political figures to join them, offering great power similar to what the Freesysadmins do. No one truly knows what the origin or grand goal of the " - foreign("la") { +"Felinae Felices" } - +" is. The only thing that is known for certain, is that their cat-themed starships are making more and more frequent appearances throughout deep space." - } - }, - ), - ISARNAREYKK( - shortName = "Isarnareykk", - shortNameIsDefinite = false, - navyName = "Isarnareyksk Styurnamariyn", - polityName = "Isarnareyksk Federation", - adjective = "Isarnareyksk", - currencyName = "marks", - shipPrefix = "ISS ", // Isarnareyksk Styurnamariyn nu Skyf - blurbDesc = { - p { - +"The Isarnareyksk Federation is the largest and most populous successor state to the Fulkreyksk Authoritariat. A shadow of its former glory, Isarnareykk is led by Faurasitand Demeter Ursalia and ruled by dissenting factions such as the tech barons and the revanchist military, that hate each other more than they hate Ursalia." - } - p { - +"The Fulkreyksk Authoritariat was one of the oldest civilizations in galactic history, second (within the current cycle, at least) to only the Drakhassi Federation. Early on in Fulkreyksk history, their first Forarr, Vrankenn Kassck, developed the ideology that would characterize the Authoritariat for the rest of its existence: entire cadres of the population would be genetically modified to fit into a randomly-chosen caste: leaders, speakers, bureaucrats, enforcers, warriors, and laborers - families were assigned at random to one of these, and then would receive a retroviral injection to enhance the traits relevant to that caste's work." - } - p { - +"Under their fourth Forarr, Praethoris Khorr, Fulkreykk defeated the daemon warlord Aedonau, who had previously been ravaging the northern half of Drakhassi space. Their next Forarr, Toval Brekoryn, conquered the alien races to the galactic south-east: the Ilkhans, Niska, and Tylans; Brekoryn also reversed some of the totalitarian centralizations that Khorr had instated. Serna Pertona reinstated those Khorrian reforms, which Kor Tefran continued. Eventually, the final Forarr of the First Authoritariat, Augast Voss, would lead Fulkreykk to its demise, and the humans of the galactic north would isolate their entire civilization for over a millennium." - } - p { - +"Fulkreykk returned to galactic politics during the Great Galactic War between the Empire of Mechyrdia and the Ilkhan Commune. The Second Authoritariat invaded the Commune from the north, opening another front that allowed the Mechyrdians to counterattack into the eastern Tylan space. Fulkreyksk and Mechyrdian fleets met at the Ilkhai system, and the space of the Commune was partitioned into a northern, Fulkreykk-aligned Ilkhan Potentate, and a southern Mechyrdia-aligned Ilkhan Republic. A cold war ensued between Fulkreykk and Mechyrdia, resulting in the collapse of the Second Authoritariat. Now, Isarnareykk is left to either pick up the pieces, or forge its own legacy independent of the Fulkreyksk shadow." - } - p { - +"Isarnareykk is at a crossroads now. Shall they embrace democracy and join forces with Mechyrdia? Shall they give the Faurasitand a perpetual dictatorship to end the crisis? Or shall one of the Federation's factions win out: the military reclaiming the former glory of Fulkreykk, or the tech barons to gain fatter and fatter profits?" - } - }, - ), - VESTIGIUM( - shortName = "Vestigium", - shortNameIsDefinite = true, - navyName = "Imperial States Space Force", - polityName = "Imperial States of America", - adjective = "American", - currencyName = "dollars", - shipPrefix = "ASC ", // American Space Craft - blurbDesc = { - p { - +"The Imperial States of America was once the political hyperpower of Earth and beyond, and the ideological bulwark of the Caesarism of its time. They were strong, they were proud... they were hated. Hated to the point that entire nations fled from Earth and colonized the stars just to escape American hegemony." - } - p { - +"The American Empire has its origins in the Second American Civil War, which saw the fall of the American Republic to the first American Emperor, Jack G. Coleman. The Empire was became the new shining city on a hill, the brightest example of what the strong leadership of Caesarism can accomplish. Under Emperor Trevor Neer, the American Empire reached its greatest extent, both in size of territory, and in prosperity. From there, things could only get worse." - } - p { - +"The Second Protestant Reformation started in the mid-21st century AD. At first, it was suppressed by the American government, peaking with Emperor Dio Audrey. However, it would become legal under the first Neoprotestant Emperor, Connor Vance, who also founded the new capital of the Empire in Connor City, on top of old Toronto, which has once been a part of Canada before the conquest of the North." - } - p { - +"Experts within the imperial government realized that the American Empire would fall just like the Roman Empire did, and so they hatched a plan. Creating a secret organization called the Vestigium, these experts evacuated top government and intelligence officials off of Earth, to starbases and planetary colonies operated by the Imperial States Space Force. Eventually, the American Empire finally fell to warlord Odoacro Grande, founder of the " - i { +"Reino de Columbia" } - +"." - } - p { - +"The American Empire has been fallen for a long time to barbarian warlords, and its homeworld Earth has been turned into a historical site by the Mechyrdian government. But the government lives on; hidden away in secret space stations, they desire nothing less than to conquer the stars and establish a ten-thousand-year empire." - } - }, - ); - - val flagUrl: String - get() = "/static/images/flag/${toUrlSlug()}.svg" -} - -fun Faction.getDefiniteShortName(capitalized: Boolean = false) = if (shortNameIsDefinite) { - (if (capitalized) "The " else "the ") + shortName -} else shortName - -val Faction.meshTag: String - get() = when (this) { - Faction.MECHYRDIA -> "mechyrdia" - Faction.NDRC -> "ndrc" - Faction.MASRA_DRAETSEN -> "diadochi" - Faction.FELINAE_FELICES -> "felinae" - Faction.ISARNAREYKK -> "fulkreykk" - Faction.VESTIGIUM -> "usa" - } diff --git a/src/commonMain/kotlin/starshipfights/game/ship_instances.kt b/src/commonMain/kotlin/starshipfights/game/ship_instances.kt deleted file mode 100644 index c1a41bd..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship_instances.kt +++ /dev/null @@ -1,278 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id -import kotlin.math.abs -import kotlin.math.pow -import kotlin.math.sqrt - -@Serializable -data class ShipInstance( - val ship: Ship, - val owner: GlobalSide, - val position: ShipPosition, - - val isIdentified: Boolean = false, - - val isDoneCurrentPhase: Boolean = true, - - val powerMode: ShipPowerMode = ship.defaultPowerMode(), - - val weaponAmount: Int = powerMode.weapons, - val shieldAmount: Int = powerMode.shields, - val hullAmount: Int = ship.durability.maxHullPoints, - val troopsAmount: Int = ship.durability.troopsDefense, - - val modulesStatus: ShipModulesStatus = ShipModulesStatus.forShip(ship), - val numFires: Int = 0, - val usedRepairTokens: Int = 0, - - val felinaeShipPowerMode: FelinaeShipPowerMode = FelinaeShipPowerMode.INERTIALESS_DRIVE, - val currentVelocity: Double = 0.0, - val usedInertialessDriveShots: Int = 0, - val usedDisruptionPulseShots: Int = 0, - val hasUsedDisruptionPulse: Boolean = false, - val recoalescenceMaxHullDamage: Int = 0, - - val armaments: ShipInstanceArmaments = ship.armaments.instantiate(), - val usedArmaments: Set> = emptySet(), - - val fighterWings: Set = emptySet(), - val bomberWings: Set = emptySet(), - - val hasSentBoardingParty: Boolean = false, -) { - val canUseShields: Boolean - get() = ship.hasShields && modulesStatus[ShipModule.Shields].canBeUsed - - val canUseTurrets: Boolean - get() = modulesStatus[ShipModule.Turrets].canBeUsed - - val canSendBoardingParty: Boolean - get() = modulesStatus[ShipModule.Assault].canBeUsed && troopsAmount > 1 && !hasSentBoardingParty - - val canUseInertialessDrive: Boolean - get() = ship.canUseInertialessDrive && modulesStatus[ShipModule.Engines].canBeUsed && when (val movement = ship.movement) { - is FelinaeShipMovement -> usedInertialessDriveShots < movement.inertialessDriveShots - else -> false - } && felinaeShipPowerMode == FelinaeShipPowerMode.INERTIALESS_DRIVE - - val remainingInertialessDriveJumps: Int - get() = when (val movement = ship.movement) { - is FelinaeShipMovement -> movement.inertialessDriveShots - usedInertialessDriveShots - else -> 0 - } - - val canUseDisruptionPulse: Boolean - get() = ship.canUseDisruptionPulse && modulesStatus[ShipModule.Turrets].canBeUsed && when (val durability = ship.durability) { - is FelinaeShipDurability -> usedDisruptionPulseShots < durability.disruptionPulseShots - else -> false - } && felinaeShipPowerMode == FelinaeShipPowerMode.DISRUPTION_PULSE && !hasUsedDisruptionPulse - - val remainingDisruptionPulseEmissions: Int - get() = when (val durability = ship.durability) { - is FelinaeShipDurability -> durability.disruptionPulseShots - usedDisruptionPulseShots - else -> 0 - } - - val canUseRecoalescence: Boolean - get() = ship.canUseRecoalescence && felinaeShipPowerMode == FelinaeShipPowerMode.HULL_RECOALESCENSE && !isDoneCurrentPhase && hullAmount < durability.maxHullPoints && recoalescenceMaxHullDamage < (ship.durability.maxHullPoints - 1) - - fun canUseWeapon(weaponId: Id): Boolean { - if (weaponId in usedArmaments) - return false - - if (!modulesStatus[ShipModule.Weapon(weaponId)].canBeUsed) - return false - - val weapon = armaments[weaponId] ?: return false - - return when (weapon) { - is ShipWeaponInstance.Cannon -> weaponAmount > 0 - is ShipWeaponInstance.Lance -> weapon.numCharges > EPSILON - is ShipWeaponInstance.Torpedo -> true - is ShipWeaponInstance.Hangar -> weapon.wingHealth > 0.0 - is ShipWeaponInstance.ParticleClawLauncher -> true - is ShipWeaponInstance.LightningYarn -> true - is ShipWeaponInstance.MegaCannon -> weapon.remainingShots > 0 - is ShipWeaponInstance.RevelationGun -> weapon.remainingShots > 0 - is ShipWeaponInstance.EmpAntenna -> weapon.remainingShots > 0 - } - } - - val remainingRepairTokens: Int - get() = when (val durability = durability) { - is StandardShipDurability -> durability.repairTokens - usedRepairTokens - else -> 0 - } - - val id: Id - get() = ship.id.reinterpret() -} - -@Serializable -data class ShipWreck( - val ship: Ship, - val owner: GlobalSide, - val isEscape: Boolean = false, - val wreckedAt: Moment = Moment.now -) { - val id: Id - get() = ship.id.reinterpret() -} - -@Serializable -data class ShipPosition( - val location: Position, - val facing: Double -) - -enum class ShipSubsystem { - WEAPONS, SHIELDS, ENGINES; - - val displayName: String - get() = name.lowercase().replaceFirstChar { it.uppercase() } - - val htmlColor: String - get() = when (this) { - WEAPONS -> "#FF6633" - SHIELDS -> "#6699FF" - ENGINES -> "#FFCC33" - } - - val imageUrl: String - get() = "/static/game/images/subsystem-${name.lowercase()}.svg" - - companion object { - val transferImageUrl: String - get() = "/static/game/images/subsystems-power-transfer.svg" - } -} - -@Serializable -data class ShipPowerMode( - val weapons: Int, - val shields: Int, - val engines: Int, -) { - operator fun plus(delta: Map) = copy( - weapons = weapons + (delta[ShipSubsystem.WEAPONS] ?: 0), - shields = shields + (delta[ShipSubsystem.SHIELDS] ?: 0), - engines = engines + (delta[ShipSubsystem.ENGINES] ?: 0), - ) - - operator fun minus(delta: Map) = this + delta.mapValues { (_, d) -> -d } - - operator fun get(key: ShipSubsystem): Int = when (key) { - ShipSubsystem.WEAPONS -> weapons - ShipSubsystem.SHIELDS -> shields - ShipSubsystem.ENGINES -> engines - } - - val total: Int - get() = weapons + shields + engines - - infix fun distanceTo(other: ShipPowerMode) = ShipSubsystem.values().sumOf { subsystem -> abs(this[subsystem] - other[subsystem]) } -} - -@Serializable -enum class FelinaeShipPowerMode { - INERTIALESS_DRIVE, - DISRUPTION_PULSE, - HULL_RECOALESCENSE; - - val displayName: String - get() = when (this) { - INERTIALESS_DRIVE -> "Inertialess Drive" - DISRUPTION_PULSE -> "Disruption Pulse" - HULL_RECOALESCENSE -> "Hull Recoalescence" - } -} - -fun ShipInstance.remainingGridEfficiency(newPowerMode: ShipPowerMode) = when (val reactor = ship.reactor) { - is StandardShipReactor -> (reactor.gridEfficiency * 2 - (newPowerMode distanceTo powerMode)) / 2 - else -> 0 -} - -fun ShipInstance.validatePowerMode(newPowerMode: ShipPowerMode) = when (val reactor = ship.reactor) { - is StandardShipReactor -> newPowerMode.total == reactor.powerOutput && ShipSubsystem.values().none { newPowerMode[it] < 0 } && (newPowerMode distanceTo powerMode) <= reactor.gridEfficiency * 2 - else -> true -} - -val ShipInstance.movementCoefficient: Double - get() = when (val reactor = ship.reactor) { - is StandardShipReactor -> sqrt(powerMode.engines.toDouble() / reactor.subsystemAmount) - else -> 1.0 - } * if (modulesStatus[ShipModule.Engines].canBeUsed) - 1.0 - else if (ship.movement is FelinaeShipMovement) - 0.75 - else - 0.5 - -val ShipInstance.movement: ShipMovement - get() = when (val m = ship.movement) { - is StandardShipMovement -> { - val coefficient = movementCoefficient - with(m) { - copy(turnAngle = turnAngle * coefficient, moveSpeed = moveSpeed * coefficient) - } - } - is FelinaeShipMovement -> { - val coefficient = movementCoefficient - with(m) { - copy( - turnAngle = turnAngle * coefficient, - moveSpeed = moveSpeed * coefficient, - inertialessDriveRange = inertialessDriveRange * coefficient.pow(2) - ) - } - } - } - -val ShipInstance.durability: ShipDurability - get() = when (val d = ship.durability) { - is FelinaeShipDurability -> d.copy( - maxHullPoints = d.maxHullPoints - recoalescenceMaxHullDamage, - ) - is StandardShipDurability -> d.copy( - turretDefense = if (canUseTurrets) d.turretDefense else 0.0 - ) - } - -val ShipInstance.firepower: ShipFirepower - get() = ship.firepower - -fun Ship.defaultPowerMode(): ShipPowerMode { - val amount = when (val r = reactor) { - is StandardShipReactor -> r.subsystemAmount - else -> 0 - } - return ShipPowerMode(amount, amount, amount) -} - -enum class ShipRenderMode { - NONE, - SIGNAL, - FULL; -} - -fun GameState.renderShipAs(ship: ShipInstance, forPlayer: GlobalSide) = if (ship.owner == forPlayer) - ShipRenderMode.FULL -else if (phase == GamePhase.Deploy) - ShipRenderMode.NONE -else if (ship.isIdentified) - ShipRenderMode.FULL -else - ShipRenderMode.SIGNAL - -const val SHIP_BASE_SIZE = 250.0 - -const val SHIP_TRANSPORTARIUM_RANGE = 1_500.0 - -const val SHIP_TORPEDO_RANGE = 2_000.0 -const val SHIP_CANNON_RANGE = 2_500.0 -const val SHIP_LANCE_RANGE = 3_000.0 -const val SHIP_HANGAR_RANGE = 3_500.0 - -const val SHIP_SENSOR_RANGE = 4_000.0 diff --git a/src/commonMain/kotlin/starshipfights/game/ship_modifiers.kt b/src/commonMain/kotlin/starshipfights/game/ship_modifiers.kt deleted file mode 100644 index 6d6cbd1..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship_modifiers.kt +++ /dev/null @@ -1,3 +0,0 @@ -package starshipfights.game - - diff --git a/src/commonMain/kotlin/starshipfights/game/ship_modules.kt b/src/commonMain/kotlin/starshipfights/game/ship_modules.kt deleted file mode 100644 index ded8f80..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship_modules.kt +++ /dev/null @@ -1,365 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.PairSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import starshipfights.data.Id -import kotlin.jvm.JvmInline - -@Serializable -sealed class ShipModule { - abstract fun getDisplayName(ship: Ship): String - - @Serializable - data class Weapon(val weaponId: Id) : ShipModule() { - override fun getDisplayName(ship: Ship): String { - return ship.armaments[weaponId]?.displayName ?: "" - } - } - - @Serializable - object Assault : ShipModule() { - override fun getDisplayName(ship: Ship): String { - return "Boarding Transportarium" - } - } - - @Serializable - object Defense : ShipModule() { - override fun getDisplayName(ship: Ship): String { - return "Internal Defenses" - } - } - - @Serializable - object Shields : ShipModule() { - override fun getDisplayName(ship: Ship): String { - return "Shield Generators" - } - } - - @Serializable - object Engines : ShipModule() { - override fun getDisplayName(ship: Ship): String { - return "Mach-Effect Thrusters" - } - } - - @Serializable - object Turrets : ShipModule() { - override fun getDisplayName(ship: Ship): String { - return "Point Defense Turrets" - } - } -} - -@Serializable -enum class ShipModuleStatus(val canBeUsed: Boolean, val canBeRepaired: Boolean) { - INTACT(true, false), - DAMAGED(false, true), - DESTROYED(false, false), - ABSENT(false, false) -} - -@JvmInline -@Serializable(with = ShipModulesStatusSerializer::class) -value class ShipModulesStatus(val statuses: Map) { - operator fun get(module: ShipModule) = statuses[module] ?: ShipModuleStatus.ABSENT - - fun repair(module: ShipModule, repairUnrepairable: Boolean = false) = ShipModulesStatus( - statuses + if (this[module].canBeRepaired || (repairUnrepairable && this[module] in ShipModuleStatus.DAMAGED..ShipModuleStatus.DESTROYED)) - mapOf(module to ShipModuleStatus.values()[this[module].ordinal - 1]) - else emptyMap() - ) - - fun damage(module: ShipModule) = ShipModulesStatus( - statuses + mapOf( - module to when (this[module]) { - ShipModuleStatus.INTACT -> ShipModuleStatus.DAMAGED - ShipModuleStatus.DAMAGED -> ShipModuleStatus.DESTROYED - ShipModuleStatus.DESTROYED -> ShipModuleStatus.DESTROYED - ShipModuleStatus.ABSENT -> ShipModuleStatus.ABSENT - } - ) - ) - - fun damageMany(modules: Iterable) = ShipModulesStatus( - statuses + modules.associateWith { module -> - when (this[module]) { - ShipModuleStatus.INTACT -> ShipModuleStatus.DAMAGED - ShipModuleStatus.DAMAGED -> ShipModuleStatus.DESTROYED - ShipModuleStatus.DESTROYED -> ShipModuleStatus.DESTROYED - ShipModuleStatus.ABSENT -> ShipModuleStatus.ABSENT - } - } - ) - - companion object { - fun forShip(ship: Ship) = ShipModulesStatus( - mapOf( - ShipModule.Assault to ShipModuleStatus.INTACT, - ShipModule.Defense to ShipModuleStatus.INTACT, - ShipModule.Shields to if (ship.hasShields) ShipModuleStatus.INTACT else ShipModuleStatus.ABSENT, - ShipModule.Engines to ShipModuleStatus.INTACT, - ShipModule.Turrets to ShipModuleStatus.INTACT, - ) + ship.armaments.keys.associate { - ShipModule.Weapon(it) to ShipModuleStatus.INTACT - } - ) - } -} - -object ShipModulesStatusSerializer : KSerializer { - private val inner = ListSerializer(PairSerializer(ShipModule.serializer(), ShipModuleStatus.serializer())) - - override val descriptor: SerialDescriptor - get() = inner.descriptor - - override fun serialize(encoder: Encoder, value: ShipModulesStatus) { - inner.serialize(encoder, value.statuses.toList()) - } - - override fun deserialize(decoder: Decoder): ShipModulesStatus { - return ShipModulesStatus(inner.deserialize(decoder).toMap()) - } -} - -sealed class CritResult { - object NoEffect : CritResult() - data class FireStarted(val ship: ShipInstance) : CritResult() - data class ModulesDisabled(val ship: ShipInstance, val modules: Set) : CritResult() - data class TroopsKilled(val ship: ShipInstance, val amount: Int) : CritResult() - data class HullDamaged(val ship: ShipInstance, val amount: Int) : CritResult() - data class Destroyed(val ship: ShipWreck) : CritResult() - - companion object { - fun fromImpactResult(impactResult: ImpactResult) = when (impactResult) { - is ImpactResult.Damaged -> impactResult.damage.amount.takeIf { it > 0 }?.let { HullDamaged(impactResult.ship, it) } ?: NoEffect - is ImpactResult.Destroyed -> Destroyed(impactResult.ship) - } - } -} - -fun ShipInstance.doCriticalDamage(): CritResult { - if (ship.shipType.faction == Faction.FELINAE_FELICES) - return doCriticalDamageFelinae() - - return when ((0..8).random() + (0..8).random()) { // Ranges in 0..16, probability density peaks at 8 - 0 -> { - // Damage ALL the modules! - val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 1 -> { - // Damage 3 weapons - val modulesDamaged = armaments.keys.shuffled().take(3).map { ShipModule.Weapon(it) } - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 2 -> { - // Damage 2 weapons - val modulesDamaged = armaments.keys.shuffled().take(2).map { ShipModule.Weapon(it) } - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 3 -> { - // Damage 2 random modules - val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys.shuffled().take(2) - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 4 -> { - // Damage 1 weapon - val modulesDamaged = armaments.keys.shuffled().take(1).map { ShipModule.Weapon(it) } - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 5 -> { - // Damage engines - val moduleDamaged = ShipModule.Engines - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damage(moduleDamaged)), - setOf(moduleDamaged) - ) - } - 6 -> { - // Damage transportarium - val moduleDamaged = ShipModule.Assault - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damage(moduleDamaged)), - setOf(moduleDamaged) - ) - } - 7 -> { - // Lose a few troops - val deaths = (1..2).random() - killTroops(deaths) - } - 8 -> { - // Fire! - CritResult.FireStarted( - copy(numFires = numFires + 1) - ) - } - 9 -> { - // Two fires! - CritResult.FireStarted( - copy(numFires = numFires + 2) - ) - } - 10 -> { - // Damage turrets - val moduleDamaged = ShipModule.Turrets - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damage(moduleDamaged)), - setOf(moduleDamaged) - ) - } - 11 -> { - // Lose many troops - val deaths = (1..2).random() + (1..2).random() - killTroops(deaths) - } - 12 -> { - // Damage security system - val moduleDamaged = ShipModule.Defense - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damage(moduleDamaged)), - setOf(moduleDamaged) - ) - } - 13 -> { - // Damage random module - val moduleDamaged = modulesStatus.statuses.keys.filter { it !is ShipModule.Weapon }.random() - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damage(moduleDamaged)), - setOf(moduleDamaged) - ) - } - 14 -> { - // Damage shields - val moduleDamaged = ShipModule.Shields - if (ship.hasShields) - CritResult.ModulesDisabled( - copy( - shieldAmount = 0, - modulesStatus = modulesStatus.damage(moduleDamaged) - ), - setOf(moduleDamaged) - ) - else - CritResult.NoEffect - } - 15 -> { - // Hull breach - val damage = (0..2).random() + (1..3).random() - CritResult.fromImpactResult(impact(damage, true)) - } - 16 -> { - // Bulkhead collapse - val damage = (2..4).random() + (3..5).random() - CritResult.fromImpactResult(impact(damage, true)) - } - else -> CritResult.NoEffect - } -} - -private fun ShipInstance.doCriticalDamageFelinae(): CritResult { - return when ((0..5).random() + (0..5).random()) { - 0 -> { - // Damage ALL the modules! - val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 1 -> { - // Damage 3 weapons - val modulesDamaged = armaments.keys.shuffled().take(3).map { ShipModule.Weapon(it) } - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 2 -> { - // Damage 2 weapons - val modulesDamaged = armaments.keys.shuffled().take(2).map { ShipModule.Weapon(it) } - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 3 -> { - // Damage 2 random modules - val modulesDamaged = modulesStatus.statuses.filter { (k, v) -> k !is ShipModule.Weapon && v != ShipModuleStatus.ABSENT }.keys.shuffled().take(2) - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 4 -> { - // Damage 1 weapon - val modulesDamaged = armaments.keys.shuffled().take(1).map { ShipModule.Weapon(it) } - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damageMany(modulesDamaged)), - modulesDamaged.toSet() - ) - } - 5 -> { - // Damage engines - val moduleDamaged = ShipModule.Engines - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damage(moduleDamaged)), - setOf(moduleDamaged) - ) - } - 6 -> { - // Damage turrets - val moduleDamaged = ShipModule.Turrets - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damage(moduleDamaged)), - setOf(moduleDamaged) - ) - } - 7 -> { - // Damage random module - val moduleDamaged = modulesStatus.statuses.keys.filter { it !is ShipModule.Weapon }.random() - CritResult.ModulesDisabled( - copy(modulesStatus = modulesStatus.damage(moduleDamaged)), - setOf(moduleDamaged) - ) - } - 8 -> { - // Lose some troops - val deaths = (1..3).random() - killTroops(deaths) - } - 9 -> { - // Hull breach - val damage = (0..2).random() + (1..3).random() - CritResult.fromImpactResult(impact(damage)) - } - 10 -> { - // Bulkhead collapse - val damage = (2..4).random() + (3..5).random() - CritResult.fromImpactResult(impact(damage)) - } - else -> CritResult.NoEffect - } -} diff --git a/src/commonMain/kotlin/starshipfights/game/ship_types.kt b/src/commonMain/kotlin/starshipfights/game/ship_types.kt deleted file mode 100644 index dace42a..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship_types.kt +++ /dev/null @@ -1,206 +0,0 @@ -package starshipfights.game - -enum class ShipWeightClass( - val meshIndex: Int, - val tier: Int -) { - // General - ESCORT(1, 0), - DESTROYER(2, 1), - CRUISER(3, 2), - BATTLECRUISER(4, 3), - BATTLESHIP(5, 4), - - // NdRC-specific - BATTLE_BARGE(5, 3), - - // Masra Draetsen-specific - GRAND_CRUISER(4, 3), - COLOSSUS(5, 5), - - // Felinae Felices-specific - FF_ESCORT(1, 1), - FF_DESTROYER(2, 2), - FF_CRUISER(3, 3), - FF_BATTLECRUISER(4, 4), - FF_BATTLESHIP(5, 5), - - // Isarnareykk-specific - AUXILIARY_SHIP(1, 0), - LIGHT_CRUISER(2, 1), - MEDIUM_CRUISER(3, 2), - HEAVY_CRUISER(4, 4), - - // Vestigium-specific - FRIGATE(1, 0), - LINE_SHIP(3, 2), - DREADNOUGHT(5, 4), - ; - - val displayName: String - get() = if (this in FF_ESCORT..FF_BATTLESHIP) - name.lowercase().split('_').joinToString(separator = " ") { word -> word.replaceFirstChar { c -> c.uppercase() } }.removePrefix("Ff ") - else - name.lowercase().split('_').joinToString(separator = " ") { word -> word.replaceFirstChar { c -> c.uppercase() } } - - val basePointCost: Int - get() = when (this) { - ESCORT -> 50 - DESTROYER -> 100 - CRUISER -> 200 - BATTLECRUISER -> 250 - BATTLESHIP -> 350 - - BATTLE_BARGE -> 300 - - GRAND_CRUISER -> 300 - COLOSSUS -> 370 - - FF_ESCORT -> 50 - FF_DESTROYER -> 100 - FF_CRUISER -> 200 - FF_BATTLECRUISER -> 250 - FF_BATTLESHIP -> 300 - - AUXILIARY_SHIP -> 50 - LIGHT_CRUISER -> 100 - MEDIUM_CRUISER -> 200 - HEAVY_CRUISER -> 400 - - FRIGATE -> 150 - LINE_SHIP -> 275 - DREADNOUGHT -> 400 - } - - val isUnique: Boolean - get() = this == COLOSSUS -} - -enum class ShipType( - val faction: Faction, - val weightClass: ShipWeightClass, -) { - // Mechyrdia - MICRO(Faction.MECHYRDIA, ShipWeightClass.ESCORT), - NANO(Faction.MECHYRDIA, ShipWeightClass.ESCORT), - PICO(Faction.MECHYRDIA, ShipWeightClass.ESCORT), - - GLADIUS(Faction.MECHYRDIA, ShipWeightClass.DESTROYER), - PILUM(Faction.MECHYRDIA, ShipWeightClass.DESTROYER), - SICA(Faction.MECHYRDIA, ShipWeightClass.DESTROYER), - - KAISERSWELT(Faction.MECHYRDIA, ShipWeightClass.CRUISER), - KAROLINA(Faction.MECHYRDIA, ShipWeightClass.CRUISER), - KOZACHNIA(Faction.MECHYRDIA, ShipWeightClass.CRUISER), - MONT_IMPERIAL(Faction.MECHYRDIA, ShipWeightClass.CRUISER), - MUNDUS_CAESARIS_DIVI(Faction.MECHYRDIA, ShipWeightClass.CRUISER), - VENSCA(Faction.MECHYRDIA, ShipWeightClass.CRUISER), - - AUCTORITAS(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), - CIVITAS(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), - HONOS(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), - IMPERIUM(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), - PAX(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), - PIETAS(Faction.MECHYRDIA, ShipWeightClass.BATTLECRUISER), - - EARTH(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), - LANGUAVARTH(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), - MECHYRDIA(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), - NOVA_ROMA(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), - TYLA(Faction.MECHYRDIA, ShipWeightClass.BATTLESHIP), - - // NdRC - JAGER(Faction.NDRC, ShipWeightClass.DESTROYER), - NOVAATJE(Faction.NDRC, ShipWeightClass.DESTROYER), - ZWAARD(Faction.NDRC, ShipWeightClass.DESTROYER), - SLAGSCHIP(Faction.NDRC, ShipWeightClass.CRUISER), - VOORHOEDE(Faction.NDRC, ShipWeightClass.CRUISER), - KRIJGSCHUIT(Faction.NDRC, ShipWeightClass.BATTLE_BARGE), - - // Masra Draetsen - ERIS(Faction.MASRA_DRAETSEN, ShipWeightClass.ESCORT), - TYPHON(Faction.MASRA_DRAETSEN, ShipWeightClass.ESCORT), - - AHRIMAN(Faction.MASRA_DRAETSEN, ShipWeightClass.DESTROYER), - APOPHIS(Faction.MASRA_DRAETSEN, ShipWeightClass.DESTROYER), - AZATHOTH(Faction.MASRA_DRAETSEN, ShipWeightClass.DESTROYER), - - CHERNOBOG(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), - CIPACTLI(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), - LAMASHTU(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), - LOTAN(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), - MORGOTH(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), - TIAMAT(Faction.MASRA_DRAETSEN, ShipWeightClass.CRUISER), - - CHARYBDIS(Faction.MASRA_DRAETSEN, ShipWeightClass.GRAND_CRUISER), - KAKIA(Faction.MASRA_DRAETSEN, ShipWeightClass.GRAND_CRUISER), - MOLOCH(Faction.MASRA_DRAETSEN, ShipWeightClass.GRAND_CRUISER), - SCYLLA(Faction.MASRA_DRAETSEN, ShipWeightClass.GRAND_CRUISER), - - AEDON(Faction.MASRA_DRAETSEN, ShipWeightClass.COLOSSUS), - - // Felinae Felices - KODKOD(Faction.FELINAE_FELICES, ShipWeightClass.FF_ESCORT), - ONCILLA(Faction.FELINAE_FELICES, ShipWeightClass.FF_ESCORT), - - MARGAY(Faction.FELINAE_FELICES, ShipWeightClass.FF_DESTROYER), - OCELOT(Faction.FELINAE_FELICES, ShipWeightClass.FF_DESTROYER), - - BOBCAT(Faction.FELINAE_FELICES, ShipWeightClass.FF_CRUISER), - LYNX(Faction.FELINAE_FELICES, ShipWeightClass.FF_CRUISER), - - LEOPARD(Faction.FELINAE_FELICES, ShipWeightClass.FF_BATTLECRUISER), - TIGER(Faction.FELINAE_FELICES, ShipWeightClass.FF_BATTLECRUISER), - - CARACAL(Faction.FELINAE_FELICES, ShipWeightClass.FF_BATTLESHIP), - - // Isarnareykk - GANNAN(Faction.ISARNAREYKK, ShipWeightClass.AUXILIARY_SHIP), - LODOVIK(Faction.ISARNAREYKK, ShipWeightClass.AUXILIARY_SHIP), - - KARNAS(Faction.ISARNAREYKK, ShipWeightClass.LIGHT_CRUISER), - PERTONA(Faction.ISARNAREYKK, ShipWeightClass.LIGHT_CRUISER), - VOSS(Faction.ISARNAREYKK, ShipWeightClass.LIGHT_CRUISER), - - BREKORYN(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), - FALK(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), - LORUS(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), - ORSH(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), - TEFRAN(Faction.ISARNAREYKK, ShipWeightClass.MEDIUM_CRUISER), - - KASSCK(Faction.ISARNAREYKK, ShipWeightClass.HEAVY_CRUISER), - KHORR(Faction.ISARNAREYKK, ShipWeightClass.HEAVY_CRUISER), - - // Vestigium - COLEMAN(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), - JEFFERSON(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), - QUENNEY(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), - ROOSEVELT(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), - WASHINGTON(Faction.VESTIGIUM, ShipWeightClass.FRIGATE), - - ARLINGTON(Faction.VESTIGIUM, ShipWeightClass.LINE_SHIP), - CONCORD(Faction.VESTIGIUM, ShipWeightClass.LINE_SHIP), - LEXINGTON(Faction.VESTIGIUM, ShipWeightClass.LINE_SHIP), - RAVEN_ROCK(Faction.VESTIGIUM, ShipWeightClass.LINE_SHIP), - - IOWA(Faction.VESTIGIUM, ShipWeightClass.DREADNOUGHT), - MARYLAND(Faction.VESTIGIUM, ShipWeightClass.DREADNOUGHT), - NEW_YORK(Faction.VESTIGIUM, ShipWeightClass.DREADNOUGHT), - OHIO(Faction.VESTIGIUM, ShipWeightClass.DREADNOUGHT), - ; - - val displayName: String - get() = name.lowercase().split('_').joinToString(separator = " ") { word -> word.replaceFirstChar { c -> c.uppercase() } } - - val fullDisplayName: String - get() = "$displayName-class ${weightClass.displayName}" - - val fullerDisplayName: String - get() = "$displayName-class ${faction.adjective} ${weightClass.displayName}" -} - -val ShipType.pointCost: Int - get() = weightClass.basePointCost + armaments.values.sumOf { it.addsPointCost } - -val ShipType.meshName: String - get() = "${faction.meshTag}-${weightClass.meshIndex}-${toUrlSlug()}-class" diff --git a/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt b/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt deleted file mode 100644 index 73cf8f9..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship_weapons.kt +++ /dev/null @@ -1,781 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import starshipfights.data.Id -import kotlin.math.* -import kotlin.random.Random - -enum class FiringArc { - BOW, ABEAM_PORT, ABEAM_STARBOARD, STERN; - - val displayName: String - get() = when (this) { - BOW -> "Fore" - ABEAM_PORT -> "Port" - ABEAM_STARBOARD -> "Starboard" - STERN -> "Aft" - } - - companion object { - val FIRE_360: Set = setOf(BOW, ABEAM_PORT, ABEAM_STARBOARD, STERN) - val FIRE_BROADSIDE: Set = setOf(ABEAM_PORT, ABEAM_STARBOARD) - val FIRE_FORE_270: Set = setOf(BOW, ABEAM_PORT, ABEAM_STARBOARD) - } -} - -sealed interface AreaWeapon { - val areaRadius: Double - val isLine: Boolean - get() = false -} - -@Serializable -sealed class ShipWeapon { - abstract val numShots: Int - - open val minRange: Double - get() = SHIP_BASE_SIZE - abstract val maxRange: Double - abstract val firingArcs: Set - - abstract val groupLabel: String - - abstract val addsPointCost: Int - - abstract fun instantiate(): ShipWeaponInstance - - @Serializable - data class Cannon( - override val numShots: Int, - override val firingArcs: Set, - override val groupLabel: String, - ) : ShipWeapon() { - override val maxRange: Double - get() = SHIP_CANNON_RANGE - - override val addsPointCost: Int - get() = numShots * 5 - - override fun instantiate() = ShipWeaponInstance.Cannon(this) - } - - @Serializable - data class Lance( - override val numShots: Int, - override val firingArcs: Set, - override val groupLabel: String, - ) : ShipWeapon() { - override val maxRange: Double - get() = SHIP_LANCE_RANGE - - override val addsPointCost: Int - get() = numShots * 10 - - override fun instantiate() = ShipWeaponInstance.Lance(this, 10.0) - } - - @Serializable - data class Torpedo( - override val firingArcs: Set, - override val groupLabel: String, - ) : ShipWeapon() { - override val numShots: Int - get() = 1 - - override val maxRange: Double - get() = SHIP_TORPEDO_RANGE - - override val addsPointCost: Int - get() = 5 - - override fun instantiate() = ShipWeaponInstance.Torpedo(this) - } - - @Serializable - data class Hangar( - val wing: StrikeCraftWing, - override val groupLabel: String, - ) : ShipWeapon() { - override val numShots: Int - get() = 1 - - override val maxRange: Double - get() = SHIP_HANGAR_RANGE - - override val firingArcs: Set - get() = FiringArc.FIRE_360 - - override val addsPointCost: Int - get() = when (wing) { - StrikeCraftWing.FIGHTERS -> 5 - StrikeCraftWing.BOMBERS -> 10 - } - - override fun instantiate() = ShipWeaponInstance.Hangar(this, 1.0) - } - - // FELINAE FELICES ADVANCED WEAPONS - - @Serializable - data class ParticleClawLauncher( - override val numShots: Int, - override val firingArcs: Set, - override val groupLabel: String, - ) : ShipWeapon() { - override val maxRange: Double - get() = 1750.0 - - override val addsPointCost: Int - get() = numShots * 10 - - override fun instantiate() = ShipWeaponInstance.ParticleClawLauncher(this) - } - - @Serializable - data class LightningYarn( - override val numShots: Int, - override val firingArcs: Set, - override val groupLabel: String, - ) : ShipWeapon() { - override val maxRange: Double - get() = 1250.0 - - override val addsPointCost: Int - get() = numShots * 5 * firingArcs.size - - override fun instantiate() = ShipWeaponInstance.LightningYarn(this) - } - - // HEAVY WEAPONS - - @Serializable - object MegaCannon : ShipWeapon(), AreaWeapon { - override val numShots: Int - get() = 3 - - override val minRange: Double - get() = 3_000.0 - - override val maxRange: Double - get() = 7_000.0 - - override val areaRadius: Double - get() = 450.0 - - override val firingArcs: Set - get() = setOf(FiringArc.BOW) - - override val groupLabel: String - get() = "Mega Giga Cannon" - - override val addsPointCost: Int - get() = 50 - - override fun instantiate() = ShipWeaponInstance.MegaCannon(numShots) - } - - @Serializable - object RevelationGun : ShipWeapon(), AreaWeapon { - override val numShots: Int - get() = 1 - - override val maxRange: Double - get() = 2_000.0 - - override val areaRadius: Double - get() = SHIP_BASE_SIZE - - override val isLine: Boolean - get() = true - - override val firingArcs: Set - get() = setOf(FiringArc.BOW) - - override val groupLabel: String - get() = "Revelation Gun" - - override val addsPointCost: Int - get() = 66 - - override fun instantiate() = ShipWeaponInstance.RevelationGun(numShots) - } - - @Serializable - object EmpAntenna : ShipWeapon(), AreaWeapon { - override val numShots: Int - get() = 5 - - override val maxRange: Double - get() = 3_000.0 - - override val areaRadius: Double - get() = 650.0 - - override val firingArcs: Set - get() = setOf(FiringArc.BOW) - - override val groupLabel: String - get() = "EMP Emitter" - - override val addsPointCost: Int - get() = 40 - - override fun instantiate() = ShipWeaponInstance.EmpAntenna(numShots) - } -} - -enum class StrikeCraftWing { - FIGHTERS, BOMBERS; - - val displayName: String - get() = name.lowercase().replaceFirstChar { it.uppercase() } - - val iconUrl: String - get() = "/static/game/images/strike-craft-${toUrlSlug()}.svg" -} - -@Serializable -sealed class ShipWeaponInstance { - abstract val weapon: ShipWeapon - - @Serializable - data class Cannon(override val weapon: ShipWeapon.Cannon) : ShipWeaponInstance() - - @Serializable - data class Lance(override val weapon: ShipWeapon.Lance, val numCharges: Double) : ShipWeaponInstance() { - val charge: Double - get() = -expm1(-numCharges) - } - - @Serializable - data class Torpedo(override val weapon: ShipWeapon.Torpedo) : ShipWeaponInstance() - - @Serializable - data class Hangar(override val weapon: ShipWeapon.Hangar, val wingHealth: Double) : ShipWeaponInstance() - - // FELINAE FELICES ADVANCED WEAPONS - @Serializable - data class ParticleClawLauncher(override val weapon: ShipWeapon.ParticleClawLauncher) : ShipWeaponInstance() - - @Serializable - data class LightningYarn(override val weapon: ShipWeapon.LightningYarn) : ShipWeaponInstance() - - // HEAVY WEAPONS - - @Serializable - data class MegaCannon(val remainingShots: Int) : ShipWeaponInstance() { - override val weapon: ShipWeapon - get() = ShipWeapon.MegaCannon - } - - @Serializable - data class RevelationGun(val remainingShots: Int) : ShipWeaponInstance() { - override val weapon: ShipWeapon - get() = ShipWeapon.RevelationGun - } - - @Serializable - data class EmpAntenna(val remainingShots: Int) : ShipWeaponInstance() { - override val weapon: ShipWeapon - get() = ShipWeapon.EmpAntenna - } -} - -typealias ShipArmaments = Map, ShipWeapon> - -fun ShipArmaments.instantiate() = mapValues { (_, weapon) -> weapon.instantiate() } - -typealias ShipInstanceArmaments = Map, ShipWeaponInstance> - -fun cannonChanceToHit(attacker: ShipInstance, targeted: ShipInstance): Double { - val relativeDistance = attacker.position.location - targeted.position.location - return sqrt(SHIP_BASE_SIZE / relativeDistance.length) * attacker.firepower.cannonAccuracy -} - -enum class DamageIgnoreType { - FELINAE_ARMOR -} - -sealed class ImpactDamage { - abstract val amount: Int - abstract val ignore: DamageIgnoreType? - - data class Success(override val amount: Int) : ImpactDamage() { - override val ignore: DamageIgnoreType? - get() = null - } - - data class Failed(override val ignore: DamageIgnoreType) : ImpactDamage() { - override val amount: Int - get() = 0 - } - - object OtherEffect : ImpactDamage() { - override val amount: Int - get() = 0 - override val ignore: DamageIgnoreType? - get() = null - } -} - -operator fun ImpactDamage.plus(amount: Int) = if (amount > 0) ImpactDamage.Success(this.amount + amount) else this - -sealed class ImpactResult { - data class Damaged(val ship: ShipInstance, val damage: ImpactDamage, val critical: CritResult = CritResult.NoEffect) : ImpactResult() - data class Destroyed(val ship: ShipWreck) : ImpactResult() - - companion object { - @Suppress("FunctionName") - fun Intact(ship: ShipInstance, ignoreType: DamageIgnoreType? = null) = Damaged(ship, if (ignoreType == null) ImpactDamage.OtherEffect else ImpactDamage.Failed(ignoreType), CritResult.NoEffect) - } -} - -fun ShipInstance.felinaeArmorIgnoreDamageChance(): Double { - if (ship.shipType.faction != Faction.FELINAE_FELICES) return 0.0 - - val maxVelocity = movement.moveSpeed - val curVelocity = currentVelocity - val ratio = curVelocity / maxVelocity - val exponent = ratio / sqrt(1 + abs(4 * ratio)) - return -expm1(-exponent) -} - -fun ShipInstance.killTroops(damage: Int) = if (damage >= troopsAmount) - CritResult.Destroyed(ShipWreck(ship, owner)) -else CritResult.TroopsKilled(copy(troopsAmount = troopsAmount - damage), damage) - -fun ShipInstance.impact(damage: Int, ignoreShields: Boolean = false) = if (durability is FelinaeShipDurability && Random.nextDouble() < felinaeArmorIgnoreDamageChance()) - ImpactResult.Intact(this, DamageIgnoreType.FELINAE_ARMOR) -else if (ignoreShields) { - if (damage >= hullAmount) - ImpactResult.Destroyed(ShipWreck(ship, owner)) - else ImpactResult.Damaged(copy(hullAmount = hullAmount - damage), damage = ImpactDamage.Success(damage)) -} else if (damage > shieldAmount) { - if (damage - shieldAmount >= hullAmount) - ImpactResult.Destroyed(ShipWreck(ship, owner)) - else ImpactResult.Damaged(copy(shieldAmount = 0, hullAmount = hullAmount - (damage - shieldAmount)), damage = ImpactDamage.Success(damage)) -} else ImpactResult.Damaged(copy(shieldAmount = shieldAmount - damage), damage = ImpactDamage.Success(damage)) - -@Serializable -data class ShipHangarWing( - val ship: Id, - val hangar: Id -) - -fun ShipInstance.afterUsing(weaponId: Id) = when (val weapon = armaments.getValue(weaponId)) { - is ShipWeaponInstance.Cannon -> { - copy(weaponAmount = weaponAmount - 1, usedArmaments = usedArmaments + setOf(weaponId)) - } - is ShipWeaponInstance.Lance -> { - val newWeapons = armaments + mapOf( - weaponId to weapon.copy(numCharges = 0.0) - ) - - copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) - } - is ShipWeaponInstance.MegaCannon -> { - val newWeapons = armaments + mapOf( - weaponId to weapon.copy(remainingShots = weapon.remainingShots - 1) - ) - - copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) - } - is ShipWeaponInstance.RevelationGun -> { - val newWeapons = armaments + mapOf( - weaponId to weapon.copy(remainingShots = weapon.remainingShots - 1) - ) - - copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) - } - is ShipWeaponInstance.EmpAntenna -> { - val newWeapons = armaments + mapOf( - weaponId to weapon.copy(remainingShots = weapon.remainingShots - 1) - ) - - copy(armaments = newWeapons, usedArmaments = usedArmaments + setOf(weaponId)) - } - else -> copy(usedArmaments = usedArmaments + setOf(weaponId)) -} - -fun ShipInstance.afterTargeted(by: ShipInstance, weaponId: Id) = when (val weapon = by.armaments.getValue(weaponId)) { - is ShipWeaponInstance.Cannon -> { - var hits = 0 - - repeat(weapon.weapon.numShots) { - if (Random.nextDouble() < cannonChanceToHit(by, this)) - hits++ - } - - impact(hits).applyCriticals(by, weaponId) - } - is ShipWeaponInstance.Lance -> { - var hits = 0 - - repeat(weapon.weapon.numShots) { - if (Random.nextDouble() < weapon.charge) - hits++ - } - - impact(hits).applyCriticals(by, weaponId) - } - is ShipWeaponInstance.Torpedo -> { - if (shieldAmount > 0) { - if (Random.nextBoolean()) - impact(1).applyCriticals(by, weaponId) - else - ImpactResult.Damaged(this, ImpactDamage.Success(0)) - } else - impact(2).applyCriticals(by, weaponId) - } - is ShipWeaponInstance.Hangar -> { - ImpactResult.Damaged( - if (weapon.weapon.wing == StrikeCraftWing.FIGHTERS) - copy(fighterWings = fighterWings + setOf(ShipHangarWing(by.id, weaponId))) - else - copy(bomberWings = bomberWings + setOf(ShipHangarWing(by.id, weaponId))), - ImpactDamage.OtherEffect - ) - } - is ShipWeaponInstance.ParticleClawLauncher -> { - var impactResult = impact(weapon.weapon.numShots) - - repeat(weapon.weapon.numShots) { - if (Random.nextDouble() < cannonChanceToHit(by, this)) - impactResult = when (val ir = impactResult) { - is ImpactResult.Damaged -> ir.withCritResult(ir.ship.doCriticalDamage()) - else -> ir - } - } - - impactResult - } - is ShipWeaponInstance.LightningYarn -> { - impact(weapon.weapon.numShots, true).applyCriticals(by, weaponId) - } - is ShipWeaponInstance.MegaCannon -> { - impact((3..7).random()).applyCriticals(by, weaponId) - } - is ShipWeaponInstance.RevelationGun -> { - ImpactResult.Destroyed(ShipWreck(ship, owner)) - } - is ShipWeaponInstance.EmpAntenna -> { - ImpactResult.Damaged( - copy( - weaponAmount = (0..weaponAmount).random(), - shieldAmount = (0..shieldAmount).random(), - ), - ImpactDamage.OtherEffect - ) - } -} - -fun ShipInstance.calculateBombing(otherShips: Map, ShipInstance>, extraBombers: Double = 0.0, extraFighters: Double = 0.0): Double? { - if (bomberWings.isEmpty() && extraBombers < EPSILON) - return null - - val totalFighterHealth = fighterWings.sumOf { (carrierId, wingId) -> - (otherShips[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 - } + durability.turretDefense + extraFighters - - val totalBomberHealth = bomberWings.sumOf { (carrierId, wingId) -> - (otherShips[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 - } + extraBombers - - if (totalBomberHealth < EPSILON) - return null - - return totalBomberHealth - totalFighterHealth -} - -fun ShipInstance.afterBombed(otherShips: Map, ShipInstance>, strikeWingDamage: MutableMap): ImpactResult { - val calculatedBombing = calculateBombing(otherShips) ?: return ImpactResult.Damaged(this, ImpactDamage.OtherEffect) - - val maxBomberWingOutput = smoothNegative(calculatedBombing) - val maxFighterWingOutput = smoothNegative(-calculatedBombing) - - for (it in fighterWings) - strikeWingDamage[it] = Random.nextDouble() * maxBomberWingOutput - - for (it in bomberWings) - strikeWingDamage[it] = Random.nextDouble() * maxFighterWingOutput - - val chanceOfShipDamage = smoothNegative(maxBomberWingOutput - maxFighterWingOutput) - val hits = floor(chanceOfShipDamage).let { floored -> - floored.roundToInt() + (if (Random.nextDouble() < chanceOfShipDamage - floored) 1 else 0) - } - - val criticalChance = smoothMinus1To1(chanceOfShipDamage, exponent = 0.5) - return impact(hits).applyStrikeCraftCriticals(criticalChance) -} - -fun ShipInstance.afterBombing(strikeWingDamage: Map): ShipInstance { - val newArmaments = armaments.mapValues { (weaponId, weapon) -> - if (weapon is ShipWeaponInstance.Hangar) - weapon.copy(wingHealth = weapon.wingHealth - (strikeWingDamage[ShipHangarWing(id, weaponId)] ?: 0.0)) - else weapon - }.filterValues { it !is ShipWeaponInstance.Hangar || it.wingHealth > 0.0 } - - return copy(armaments = newArmaments) -} - -fun ImpactResult.Damaged.withCritResult(critical: CritResult): ImpactResult = when (critical) { - is CritResult.NoEffect -> this - is CritResult.FireStarted -> copy( - ship = critical.ship, - damage = damage, - critical = critical - ) - is CritResult.ModulesDisabled -> copy( - ship = critical.ship, - damage = damage, - critical = critical - ) - is CritResult.TroopsKilled -> copy( - ship = critical.ship, - damage = damage, - critical = critical - ) - is CritResult.HullDamaged -> copy( - ship = critical.ship, - damage = damage + critical.amount, - critical = critical - ) - is CritResult.Destroyed -> ImpactResult.Destroyed(critical.ship) -} - -fun ImpactResult.applyCriticals(attacker: ShipInstance, weaponId: Id): ImpactResult { - return when (this) { - is ImpactResult.Destroyed -> this - is ImpactResult.Damaged -> { - if (damage is ImpactDamage.Failed) - return this - - val critChance = criticalChance(attacker, weaponId, ship) - if (Random.nextDouble() > critChance) - this - else - withCritResult(ship.doCriticalDamage()) - } - } -} - -fun ImpactResult.applyStrikeCraftCriticals(criticalChance: Double): ImpactResult { - return when (this) { - is ImpactResult.Destroyed -> this - is ImpactResult.Damaged -> { - if (Random.nextDouble() > criticalChance) - this - else - withCritResult(ship.doCriticalDamage()) - } - } -} - -fun criticalChance(attacker: ShipInstance, weaponId: Id, targeted: ShipInstance): Double { - val targetHasShields = targeted.canUseShields && targeted.shieldAmount > 0 - val weapon = attacker.armaments[weaponId] ?: return 0.0 - - return when (weapon) { - is ShipWeaponInstance.Torpedo -> if (targetHasShields) 0.0 else 0.375 - is ShipWeaponInstance.Hangar -> 0.0 // implemented elsewhere - is ShipWeaponInstance.MegaCannon -> 0.5 - else -> if (targetHasShields) 0.125 else 0.25 - } * attacker.firepower.criticalChance -} - -fun ShipInstance.getWeaponPickRequest(weapon: ShipWeapon): PickRequest = when (weapon) { - is AreaWeapon -> PickRequest( - type = PickType.Location( - excludesNearShips = emptySet(), - helper = PickHelper.Circle(radius = weapon.areaRadius), - drawLineFrom = if (weapon.isLine) null else position.location - ), - boundary = if (weapon.isLine) - PickBoundary.AlongLine( - pointA = position.location + polarDistance(weapon.minRange, position.facing), - pointB = position.location + polarDistance(weapon.maxRange, position.facing) - ) - else - PickBoundary.WeaponsFire( - center = position.location, - facing = position.facing, - minDistance = weapon.minRange, - maxDistance = weapon.maxRange, - firingArcs = weapon.firingArcs, - ), - ) - else -> { - val targetSet = if ((weapon as? ShipWeapon.Hangar)?.wing == StrikeCraftWing.FIGHTERS) - setOf(owner) - else - setOf(owner.other) - - val weaponRangeMult = when (weapon) { - is ShipWeapon.Cannon -> firepower.rangeMultiplier - is ShipWeapon.Lance -> firepower.rangeMultiplier - is ShipWeapon.ParticleClawLauncher -> firepower.rangeMultiplier - is ShipWeapon.LightningYarn -> firepower.rangeMultiplier - else -> 1.0 - } - - PickRequest( - PickType.Ship(targetSet), - PickBoundary.WeaponsFire( - center = position.location, - facing = position.facing, - minDistance = weapon.minRange, - maxDistance = weapon.maxRange * weaponRangeMult, - firingArcs = weapon.firingArcs, - canSelfSelect = owner in targetSet - ) - ) - } -} - -fun ImpactResult.toChatEntry(attacker: ShipAttacker, weapon: ShipWeaponInstance?) = when (this) { - is ImpactResult.Damaged -> when (damage) { - is ImpactDamage.Success -> ChatEntry.ShipAttacked( - ship = ship.id, - attacker = attacker, - sentAt = Moment.now, - damageInflicted = damage.amount, - weapon = weapon?.weapon, - critical = critical.report(), - ) - is ImpactDamage.Failed -> ChatEntry.ShipAttackFailed( - ship = ship.id, - attacker = attacker, - sentAt = Moment.now, - weapon = weapon?.weapon, - damageIgnoreType = damage.ignore - ) - else -> null - } - is ImpactResult.Destroyed -> { - ChatEntry.ShipDestroyed( - ship = ship.id, - sentAt = Moment.now, - destroyedBy = attacker, - ) - } -} - -fun GameState.useWeaponPickResponse(attacker: ShipInstance, weaponId: Id, target: PickResponse): GameEvent { - val weapon = attacker.armaments[weaponId] ?: return GameEvent.InvalidAction("That weapon does not exist") - - return when (val weaponType = weapon.weapon) { - is AreaWeapon -> { - val targetedLocation = (target as? PickResponse.Location)?.position ?: return GameEvent.InvalidAction("Invalid pick response type") - val targetedShips = ships.filterValues { (it.position.location - targetedLocation).length < weaponType.areaRadius } - - if (targetedShips.isEmpty()) return GameEvent.InvalidAction("No ships targeted - aborting fire") - - val newAttacker = attacker.afterUsing(weaponId) - - val impacts = targetedShips.mapValues { (_, ship) -> - ship.afterTargeted(attacker, weaponId) - } - - val newShips = ships.filterKeys { id -> - id !in impacts - } + impacts.mapNotNull { (id, impact) -> - (impact as? ImpactResult.Damaged)?.ship?.let { id to it } - }.toMap() + mapOf(attacker.id to newAttacker) - - val newWrecks = destroyedShips + impacts.mapNotNull { (id, impact) -> - (impact as? ImpactResult.Destroyed)?.ship?.let { id to it } - }.toMap() - - val newChatMessages = chatBox + impacts.mapNotNull { (_, impact) -> - impact.toChatEntry(ShipAttacker.EnemyShip(newAttacker.id), weapon) - } - - GameEvent.StateChange( - copy(ships = newShips, destroyedShips = newWrecks, chatBox = newChatMessages) - .withRecalculatedInitiative { calculateAttackPhaseInitiative() } - ) - } - else -> { - val targetedShipId = (target as? PickResponse.Ship)?.id ?: return GameEvent.InvalidAction("Invalid pick response type") - val targetedShip = ships[targetedShipId] ?: return GameEvent.InvalidAction("That ship does not exist") - - val impact = targetedShip.afterTargeted(attacker, weaponId) - - val newAttacker = if (targetedShipId == attacker.id) { - if (impact is ImpactResult.Damaged) - impact.ship.afterUsing(weaponId) - else - null - } else - attacker.afterUsing(weaponId) - - val newShips = (if (impact is ImpactResult.Damaged) - ships + mapOf(targetedShipId to impact.ship) - else ships - targetedShipId) + (if (newAttacker != null) - mapOf(attacker.id to newAttacker) - else emptyMap()) - - val newWrecks = destroyedShips + if (impact is ImpactResult.Destroyed) - mapOf(targetedShipId to impact.ship) - else emptyMap() - - val newChatMessages = chatBox + listOfNotNull( - impact.toChatEntry(ShipAttacker.EnemyShip(attacker.id), weapon) - ) - - GameEvent.StateChange( - copy(ships = newShips, destroyedShips = newWrecks, chatBox = newChatMessages) - .withRecalculatedInitiative { calculateAttackPhaseInitiative() } - ) - } - } -} - -val ShipWeapon.displayName: String - get() { - val firingArcsDesc = when (firingArcs) { - FiringArc.FIRE_360 -> "360-Degree " - FiringArc.FIRE_BROADSIDE -> "Broadside " - FiringArc.FIRE_FORE_270 -> "Dorsal " - setOf(FiringArc.ABEAM_PORT) -> "Port " - setOf(FiringArc.ABEAM_STARBOARD) -> "Starboard " - setOf(FiringArc.BOW) -> "Fore " - setOf(FiringArc.STERN) -> "Rear " - setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) -> "Wide-Angle Port " - setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) -> "Wide-Angle Starboard " - else -> null - }.takeIf { this !is ShipWeapon.Hangar } ?: "" - - val weaponIsPlural = numShots > 1 - - val weaponDesc = when (this) { - is ShipWeapon.Cannon -> "Cannon" + (if (weaponIsPlural) "s" else "") - is ShipWeapon.Lance -> "Lance" + (if (weaponIsPlural) "s" else "") - is ShipWeapon.Hangar -> when (wing) { - StrikeCraftWing.FIGHTERS -> "Fighters" - StrikeCraftWing.BOMBERS -> "Bombers" - } - is ShipWeapon.Torpedo -> "Torpedo" + (if (weaponIsPlural) "es" else "") - is ShipWeapon.ParticleClawLauncher -> "Particle Claw" + (if (weaponIsPlural) "s" else "") - is ShipWeapon.LightningYarn -> "Lightning Yarn" - is ShipWeapon.MegaCannon -> "Mega Giga Cannon" - is ShipWeapon.RevelationGun -> "Revelation Gun" - is ShipWeapon.EmpAntenna -> "EMP Antenna" - } - - return "$firingArcsDesc$weaponDesc" - } - -val ShipWeaponInstance.displayName: String - get() { - val weaponParam = when (this) { - is ShipWeaponInstance.Lance -> " (${charge.toPercent()})" - is ShipWeaponInstance.Hangar -> " (${wingHealth.toPercent()})" - is ShipWeaponInstance.MegaCannon -> " ($remainingShots)" - is ShipWeaponInstance.RevelationGun -> " ($remainingShots)" - is ShipWeaponInstance.EmpAntenna -> " ($remainingShots)" - else -> "" - } - - return "${weapon.displayName}$weaponParam" - } diff --git a/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt b/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt deleted file mode 100644 index f8fac6d..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship_weapons_formats.kt +++ /dev/null @@ -1,246 +0,0 @@ -package starshipfights.game - -import starshipfights.data.Id - -private class ShipWeaponIdCounter { - private var numCannons = 0 - private var numLances = 0 - private var numHangars = 0 - private var numTorpedoes = 0 - private var numParticleClaws = 0 - private var numLightningYarn = 0 - - fun nextId(shipWeapon: ShipWeapon): Id = Id( - when (shipWeapon) { - is ShipWeapon.Cannon -> "cannons-${++numCannons}" - is ShipWeapon.Lance -> "lances-${++numLances}" - is ShipWeapon.Hangar -> "hangar-${++numHangars}" - is ShipWeapon.Torpedo -> "torpedo-${++numTorpedoes}" - is ShipWeapon.ParticleClawLauncher -> "particle-claw-${++numParticleClaws}" - is ShipWeapon.LightningYarn -> "lightning-yarn-${++numLightningYarn}" - else -> "super-weapon" - } - ) - - fun add(addTo: MutableMap, ShipWeapon>, shipWeapon: ShipWeapon) { - if (shipWeapon.numShots <= 0) return - addTo[nextId(shipWeapon)] = shipWeapon - } -} - -fun mechyrdiaShipWeapons( - torpedoRows: Int, - hasMegaCannon: Boolean, - - cannonSections: Int, - lanceSections: Int, - hangarSections: Int, - dorsalLances: Int, -): ShipArmaments { - val idCounter = ShipWeaponIdCounter() - val weapons = mutableMapOf, ShipWeapon>() - - repeat(torpedoRows * 2) { - idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launchers")) - } - - if (hasMegaCannon) - idCounter.add(weapons, ShipWeapon.MegaCannon) - - repeat(cannonSections) { - idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) - idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) - } - - repeat(lanceSections) { - idCounter.add(weapons, ShipWeapon.Lance(2, setOf(FiringArc.ABEAM_PORT), "Port lance battery")) - idCounter.add(weapons, ShipWeapon.Lance(2, setOf(FiringArc.ABEAM_STARBOARD), "Starboard lance battery")) - } - - repeat(hangarSections * 2) { w -> - if (w % 2 == 0) - idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.FIGHTERS, "Fighter complement")) - else - idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.BOMBERS, "Bomber complement")) - } - - repeat(dorsalLances) { - idCounter.add(weapons, ShipWeapon.Lance(1, FiringArc.FIRE_BROADSIDE, "Dorsal lance turrets")) - } - - return weapons -} - -fun mechyrdiaNanoClassWeapons(): ShipArmaments { - val idCounter = ShipWeaponIdCounter() - val weapons = mutableMapOf, ShipWeapon>() - - idCounter.add(weapons, ShipWeapon.Lance(2, FiringArc.FIRE_FORE_270, "Dorsal lance turrets")) - - return weapons -} - -fun mechyrdiaPicoClassWeapons(): ShipArmaments { - val idCounter = ShipWeaponIdCounter() - val weapons = mutableMapOf, ShipWeapon>() - - idCounter.add(weapons, ShipWeapon.Cannon(2, FiringArc.FIRE_FORE_270, "Double-barrel cannon turret")) - idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launcher")) - - return weapons -} - -fun ndrcShipWeapons( - torpedoes: Int, - hasMegaCannon: Boolean, - - numDorsalLances: Int, - foreFiringDorsalLances: Boolean, - - numBroadsideCannons: Int, - numBroadsideLances: Int -): ShipArmaments { - val idCounter = ShipWeaponIdCounter() - val weapons = mutableMapOf, ShipWeapon>() - - repeat(torpedoes) { - idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launchers")) - } - - if (hasMegaCannon) - idCounter.add(weapons, ShipWeapon.MegaCannon) - - if (numDorsalLances > 0) - idCounter.add(weapons, ShipWeapon.Lance(numDorsalLances, if (foreFiringDorsalLances) FiringArc.FIRE_FORE_270 else FiringArc.FIRE_BROADSIDE, "Dorsal lance batteries")) - - if (numBroadsideCannons > 0) { - idCounter.add(weapons, ShipWeapon.Cannon(numBroadsideCannons, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) - idCounter.add(weapons, ShipWeapon.Cannon(numBroadsideCannons, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) - } - - if (numBroadsideLances > 0) { - idCounter.add(weapons, ShipWeapon.Lance(numBroadsideLances, setOf(FiringArc.ABEAM_PORT), "Port lance battery")) - idCounter.add(weapons, ShipWeapon.Lance(numBroadsideLances, setOf(FiringArc.ABEAM_STARBOARD), "Starboard lance battery")) - } - - return weapons -} - -fun diadochiShipWeapons( - torpedoes: Int, - hasRevelationGun: Boolean, - - cannonSections: Int, - lanceSections: Int, - hangarSections: Int, - dorsalLances: Int, -): ShipArmaments { - val idCounter = ShipWeaponIdCounter() - val weapons = mutableMapOf, ShipWeapon>() - - repeat(torpedoes) { - idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launchers")) - } - - if (hasRevelationGun) - idCounter.add(weapons, ShipWeapon.RevelationGun) - - repeat(cannonSections) { - idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) - idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) - } - - repeat(lanceSections) { - idCounter.add(weapons, ShipWeapon.Lance(2, setOf(FiringArc.ABEAM_PORT), "Port lance battery")) - idCounter.add(weapons, ShipWeapon.Lance(2, setOf(FiringArc.ABEAM_STARBOARD), "Starboard lance battery")) - } - - repeat(hangarSections * 2) { w -> - if (w % 2 == 0) - idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.FIGHTERS, "Fighter complement")) - else - idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.BOMBERS, "Bomber complement")) - } - - repeat(dorsalLances) { - idCounter.add(weapons, ShipWeapon.Lance(2, FiringArc.FIRE_FORE_270, "Dorsal lance batteries")) - } - - return weapons -} - -fun felinaeShipWeapons( - particleClaws: Map, - lightningYarn: Map, Int> -): ShipArmaments { - val idCounter = ShipWeaponIdCounter() - val weapons = mutableMapOf, ShipWeapon>() - - for ((arc, num) in particleClaws) { - idCounter.add(weapons, ShipWeapon.ParticleClawLauncher(num, setOf(arc), "${arc.displayName} particle claws")) - } - - for ((arcs, num) in lightningYarn) { - val displayName = arcs.joinToString(separator = "/") { it.displayName } - idCounter.add(weapons, ShipWeapon.LightningYarn(num, arcs, "$displayName lightning yarn")) - } - - return weapons -} - -fun fulkreykkShipWeapons( - torpedoes: Int, - hasPulseBeam: Boolean, - - cannonSections: Int, - lanceSections: Int, -): ShipArmaments { - val idCounter = ShipWeaponIdCounter() - val weapons = mutableMapOf, ShipWeapon>() - - repeat(torpedoes) { - idCounter.add(weapons, ShipWeapon.Torpedo(setOf(FiringArc.BOW), "Fore torpedo launchers")) - } - - if (hasPulseBeam) - idCounter.add(weapons, ShipWeapon.EmpAntenna) - - repeat(cannonSections) { - idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) - idCounter.add(weapons, ShipWeapon.Cannon(3, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) - } - - repeat(lanceSections) { - idCounter.add(weapons, ShipWeapon.Lance(2, FiringArc.FIRE_BROADSIDE, "Broadside lance battery")) - } - - return weapons -} - -fun vestigiumShipWeapons( - foreCannons: Int, - foreHangars: Int, - - dorsalCannons: Int, - dorsalLances: Int, - dorsalHangars: Int, -): ShipArmaments { - val idCounter = ShipWeaponIdCounter() - val weapons = mutableMapOf, ShipWeapon>() - - idCounter.add(weapons, ShipWeapon.Cannon(foreCannons, setOf(FiringArc.BOW), "Fore cannon battery")) - - idCounter.add(weapons, ShipWeapon.Cannon(dorsalCannons, setOf(FiringArc.ABEAM_PORT), "Port cannon battery")) - idCounter.add(weapons, ShipWeapon.Cannon(dorsalCannons, setOf(FiringArc.ABEAM_STARBOARD), "Starboard cannon battery")) - - idCounter.add(weapons, ShipWeapon.Lance(dorsalLances, FiringArc.FIRE_BROADSIDE, "Broadside lance battery")) - - repeat(foreHangars + dorsalHangars) { w -> - if (w % 2 == 0) - idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.FIGHTERS, "Fighter complement")) - else - idCounter.add(weapons, ShipWeapon.Hangar(StrikeCraftWing.BOMBERS, "Bomber complement")) - } - - return weapons -} diff --git a/src/commonMain/kotlin/starshipfights/game/ship_weapons_list.kt b/src/commonMain/kotlin/starshipfights/game/ship_weapons_list.kt deleted file mode 100644 index 234e141..0000000 --- a/src/commonMain/kotlin/starshipfights/game/ship_weapons_list.kt +++ /dev/null @@ -1,174 +0,0 @@ -package starshipfights.game - -val ShipType.armaments: ShipArmaments - get() = when (this) { - ShipType.MICRO -> mechyrdiaShipWeapons(1, false, 1, 0, 0, 0) - ShipType.NANO -> mechyrdiaNanoClassWeapons() - ShipType.PICO -> mechyrdiaPicoClassWeapons() - ShipType.GLADIUS -> mechyrdiaShipWeapons(2, false, 1, 0, 0, 1) - ShipType.PILUM -> mechyrdiaShipWeapons(2, false, 0, 1, 0, 1) - ShipType.SICA -> mechyrdiaShipWeapons(2, false, 0, 0, 1, 1) - ShipType.KAISERSWELT -> mechyrdiaShipWeapons(2, false, 2, 0, 0, 0) - ShipType.KAROLINA -> mechyrdiaShipWeapons(2, false, 0, 1, 1, 0) - ShipType.KOZACHNIA -> mechyrdiaShipWeapons(2, false, 1, 0, 1, 0) - ShipType.MONT_IMPERIAL -> mechyrdiaShipWeapons(2, false, 0, 2, 0, 0) - ShipType.MUNDUS_CAESARIS_DIVI -> mechyrdiaShipWeapons(0, true, 2, 0, 0, 0) - ShipType.VENSCA -> mechyrdiaShipWeapons(2, false, 1, 1, 0, 0) - ShipType.AUCTORITAS -> mechyrdiaShipWeapons(3, false, 1, 1, 0, 2) - ShipType.CIVITAS -> mechyrdiaShipWeapons(3, false, 1, 0, 1, 2) - ShipType.HONOS -> mechyrdiaShipWeapons(0, true, 1, 0, 1, 2) - ShipType.IMPERIUM -> mechyrdiaShipWeapons(3, false, 0, 0, 2, 2) - ShipType.PAX -> mechyrdiaShipWeapons(3, false, 2, 0, 0, 2) - ShipType.PIETAS -> mechyrdiaShipWeapons(0, true, 2, 0, 0, 2) - ShipType.EARTH -> mechyrdiaShipWeapons(3, false, 1, 1, 1, 1) - ShipType.LANGUAVARTH -> mechyrdiaShipWeapons(3, false, 1, 2, 0, 1) - ShipType.MECHYRDIA -> mechyrdiaShipWeapons(3, false, 3, 0, 0, 1) - ShipType.NOVA_ROMA -> mechyrdiaShipWeapons(0, true, 0, 3, 0, 1) - ShipType.TYLA -> mechyrdiaShipWeapons(3, false, 1, 0, 2, 1) - - ShipType.JAGER -> ndrcShipWeapons(2, true, 0, false, 0, 2) - ShipType.NOVAATJE -> ndrcShipWeapons(0, true, 2, true, 3, 0) - ShipType.ZWAARD -> ndrcShipWeapons(2, false, 2, true, 3, 0) - ShipType.SLAGSCHIP -> ndrcShipWeapons(3, false, 2, true, 5, 0) - ShipType.VOORHOEDE -> ndrcShipWeapons(3, true, 0, false, 3, 1) - ShipType.KRIJGSCHUIT -> ndrcShipWeapons(4, true, 2, false, 6, 0) - - ShipType.ERIS -> diadochiShipWeapons(2, false, 1, 0, 0, 0) - ShipType.TYPHON -> diadochiShipWeapons(0, false, 1, 0, 0, 1) - ShipType.AHRIMAN -> diadochiShipWeapons(1, false, 0, 1, 0, 0) - ShipType.APOPHIS -> diadochiShipWeapons(1, false, 0, 0, 1, 1) - ShipType.AZATHOTH -> diadochiShipWeapons(1, false, 1, 0, 0, 0) - ShipType.CHERNOBOG -> diadochiShipWeapons(2, false, 0, 2, 0, 0) - ShipType.CIPACTLI -> diadochiShipWeapons(2, false, 2, 0, 0, 0) - ShipType.LAMASHTU -> diadochiShipWeapons(2, false, 0, 1, 1, 0) - ShipType.LOTAN -> diadochiShipWeapons(2, false, 0, 0, 2, 2) - ShipType.MORGOTH -> diadochiShipWeapons(2, false, 1, 1, 0, 0) - ShipType.TIAMAT -> diadochiShipWeapons(2, false, 1, 0, 1, 1) - ShipType.CHARYBDIS -> diadochiShipWeapons(3, false, 3, 0, 0, 3) - ShipType.KAKIA -> diadochiShipWeapons(3, false, 1, 2, 0, 3) - ShipType.MOLOCH -> diadochiShipWeapons(3, false, 1, 1, 1, 3) - ShipType.SCYLLA -> diadochiShipWeapons(3, false, 1, 0, 2, 3) - ShipType.AEDON -> diadochiShipWeapons(0, true, 4, 0, 2, 4) - - ShipType.KODKOD -> felinaeShipWeapons( - mapOf( - FiringArc.BOW to 4, - FiringArc.ABEAM_PORT to 3, - FiringArc.ABEAM_STARBOARD to 3, - ), - emptyMap() - ) - ShipType.ONCILLA -> felinaeShipWeapons( - mapOf( - FiringArc.BOW to 2, - ), - mapOf( - setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) to 1, - setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) to 1, - ) - ) - ShipType.MARGAY -> felinaeShipWeapons( - mapOf( - FiringArc.BOW to 2, - FiringArc.ABEAM_PORT to 2, - FiringArc.ABEAM_STARBOARD to 2, - ), - mapOf( - FiringArc.FIRE_FORE_270 to 1, - setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) to 1, - setOf(FiringArc.ABEAM_PORT) to 1, - setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) to 1, - setOf(FiringArc.ABEAM_STARBOARD) to 1, - ) - ) - ShipType.OCELOT -> felinaeShipWeapons( - mapOf( - FiringArc.BOW to 4, - FiringArc.ABEAM_PORT to 4, - FiringArc.ABEAM_STARBOARD to 4, - ), - mapOf( - FiringArc.FIRE_FORE_270 to 1, - FiringArc.FIRE_BROADSIDE to 1, - ) - ) - ShipType.BOBCAT -> felinaeShipWeapons( - mapOf( - FiringArc.BOW to 3, - FiringArc.ABEAM_PORT to 3, - FiringArc.ABEAM_STARBOARD to 3, - ), - mapOf( - setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) to 3, - setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) to 3, - FiringArc.FIRE_BROADSIDE to 3, - ) - ) - ShipType.LYNX -> felinaeShipWeapons( - mapOf( - FiringArc.BOW to 5, - FiringArc.ABEAM_PORT to 5, - FiringArc.ABEAM_STARBOARD to 5, - ), - emptyMap() - ) - ShipType.LEOPARD -> felinaeShipWeapons( - mapOf( - FiringArc.BOW to 5, - FiringArc.ABEAM_PORT to 5, - FiringArc.ABEAM_STARBOARD to 5, - ), - emptyMap() - ) - ShipType.TIGER -> felinaeShipWeapons( - mapOf( - FiringArc.BOW to 3, - FiringArc.ABEAM_PORT to 3, - FiringArc.ABEAM_STARBOARD to 3, - ), - mapOf( - setOf(FiringArc.BOW, FiringArc.ABEAM_PORT) to 3, - setOf(FiringArc.BOW, FiringArc.ABEAM_STARBOARD) to 3, - FiringArc.FIRE_BROADSIDE to 3, - ) - ) - ShipType.CARACAL -> felinaeShipWeapons( - mapOf( - FiringArc.BOW to 10, - FiringArc.ABEAM_PORT to 10, - FiringArc.ABEAM_STARBOARD to 10, - ), - mapOf( - setOf(FiringArc.BOW) to 5, - setOf(FiringArc.ABEAM_PORT) to 5, - setOf(FiringArc.ABEAM_STARBOARD) to 5, - ) - ) - - ShipType.GANNAN -> fulkreykkShipWeapons(0, true, 0, 0) - ShipType.LODOVIK -> fulkreykkShipWeapons(4, false, 0, 0) - ShipType.KARNAS -> fulkreykkShipWeapons(2, false, 2, 0) - ShipType.PERTONA -> fulkreykkShipWeapons(2, false, 1, 1) - ShipType.VOSS -> fulkreykkShipWeapons(2, false, 0, 2) - ShipType.BREKORYN -> fulkreykkShipWeapons(3, false, 2, 0) - ShipType.FALK -> fulkreykkShipWeapons(0, true, 1, 1) - ShipType.LORUS -> fulkreykkShipWeapons(0, true, 2, 0) - ShipType.ORSH -> fulkreykkShipWeapons(3, false, 0, 2) - ShipType.TEFRAN -> fulkreykkShipWeapons(3, false, 1, 1) - ShipType.KASSCK -> fulkreykkShipWeapons(4, false, 3, 0) - ShipType.KHORR -> fulkreykkShipWeapons(4, false, 1, 2) - - ShipType.COLEMAN -> vestigiumShipWeapons(4, 0, 1, 1, 0) - ShipType.JEFFERSON -> vestigiumShipWeapons(4, 0, 0, 1, 1) - ShipType.QUENNEY -> vestigiumShipWeapons(4, 0, 0, 0, 2) - ShipType.ROOSEVELT -> vestigiumShipWeapons(4, 0, 0, 2, 0) - ShipType.WASHINGTON -> vestigiumShipWeapons(4, 0, 3, 0, 0) - ShipType.ARLINGTON -> vestigiumShipWeapons(7, 0, 1, 0, 0) - ShipType.CONCORD -> vestigiumShipWeapons(7, 0, 0, 0, 1) - ShipType.LEXINGTON -> vestigiumShipWeapons(7, 0, 0, 1, 0) - ShipType.RAVEN_ROCK -> vestigiumShipWeapons(1, 4, 0, 1, 0) - ShipType.IOWA -> vestigiumShipWeapons(9, 0, 0, 2, 0) - ShipType.MARYLAND -> vestigiumShipWeapons(3, 4, 0, 2, 0) - ShipType.NEW_YORK -> vestigiumShipWeapons(0, 6, 0, 0, 2) - ShipType.OHIO -> vestigiumShipWeapons(9, 0, 3, 0, 0) - } diff --git a/src/commonMain/kotlin/starshipfights/game/util.kt b/src/commonMain/kotlin/starshipfights/game/util.kt deleted file mode 100644 index 6618d98..0000000 --- a/src/commonMain/kotlin/starshipfights/game/util.kt +++ /dev/null @@ -1,59 +0,0 @@ -package starshipfights.game - -import kotlinx.html.* -import kotlinx.serialization.KSerializer -import kotlinx.serialization.builtins.ListSerializer -import kotlinx.serialization.builtins.PairSerializer -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.Json -import kotlin.math.abs -import kotlin.math.exp -import kotlin.math.pow -import kotlin.math.roundToInt - -val jsonSerializer = Json { - classDiscriminator = "\$ktClass" - coerceInputValues = true - encodeDefaults = false - ignoreUnknownKeys = true - useAlternativeNames = false -} - -class MapAsListSerializer(keySerializer: KSerializer, valueSerializer: KSerializer) : KSerializer> { - private val inner = ListSerializer(PairSerializer(keySerializer, valueSerializer)) - - override val descriptor: SerialDescriptor - get() = inner.descriptor - - override fun serialize(encoder: Encoder, value: Map) { - inner.serialize(encoder, value.toList()) - } - - override fun deserialize(decoder: Decoder): Map { - return inner.deserialize(decoder).toMap() - } -} - -const val EPSILON = 0.00_001 - -fun > T.toUrlSlug() = name.replace('_', '-').lowercase() - -fun Double.toPercent() = "${(this * 100).roundToInt()}%" - -fun smoothMinus1To1(x: Double, exponent: Double = 1.0) = x / (1 + abs(x).pow(exponent)).pow(1 / exponent) -fun smoothNegative(x: Double) = if (x < 0) exp(x) else x + 1 - -fun Iterable.joinToDisplayString(oxfordComma: Boolean = true, transform: (T) -> String = { it.toString() }): String = when (val size = count()) { - 0 -> "" - 1 -> transform(single()) - 2 -> "${transform(first())} and ${transform(last())}" - else -> "${take(size - 1).joinToString { transform(it) }}${if (oxfordComma) "," else ""} and ${transform(last())}" -} - -inline fun T.foreign(language: String, crossinline block: SPAN.() -> Unit) = span { - lang = language - style = "font-style: italic" - block() -} diff --git a/src/jsMain/kotlin/net/starshipfights/game/ai/util_js.kt b/src/jsMain/kotlin/net/starshipfights/game/ai/util_js.kt new file mode 100644 index 0000000..06f9f77 --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/ai/util_js.kt @@ -0,0 +1,17 @@ +package net.starshipfights.game.ai + +actual fun logDebug(message: Any?) { + console.log(message) +} + +actual fun logInfo(message: Any?) { + console.info(message) +} + +actual fun logWarning(message: Any?) { + console.warn(message) +} + +actual fun logError(message: Any?) { + console.error(message) +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/client.kt b/src/jsMain/kotlin/net/starshipfights/game/client.kt new file mode 100644 index 0000000..edbe1ad --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/client.kt @@ -0,0 +1,61 @@ +package net.starshipfights.game + +import io.ktor.client.* +import io.ktor.client.engine.js.* +import io.ktor.client.features.websocket.* +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import org.w3c.dom.HTMLScriptElement + +val rootPathWs = "ws" + window.location.origin.removePrefix("http") + +val clientMode: ClientMode = try { + jsonSerializer.decodeFromString( + ClientMode.serializer(), + document.getElementById("sf-client-mode").unsafeCast().text + ) +} catch (ex: Exception) { + ex.printStackTrace() + ClientMode.Error("Invalid client mode sent from server") +} catch (dyn: dynamic) { + console.error(dyn) + ClientMode.Error("Invalid client mode sent from server") +} + +val AppScope = MainScope() + CoroutineExceptionHandler { _, error -> + console.error("Unhandled coroutine exception", error.stackTraceToString()) +} + +val httpClient = HttpClient(Js) { + install(WebSockets) { + pingInterval = 500L + } +} + +fun main() { + window.addEventListener("beforeunload", { e -> + if (interruptExit) { + e.preventDefault() + e.asDynamic().returnValue = "" + } + }) + + AppScope.launch { + Popup.LoadingScreen("Loading resources...") { + RenderResources.load(clientMode.isSmallLoad) + }.display() + + when (clientMode) { + is ClientMode.MatchmakingMenu -> matchmakingMain(clientMode.admirals) + is ClientMode.InTrainingGame -> trainingMain(clientMode.initialState) + is ClientMode.InGame -> gameMain(clientMode.playerSide, clientMode.connectToken, clientMode.initialState) + is ClientMode.Error -> errorMain(clientMode.message) + } + } +} + +var interruptExit: Boolean = false diff --git a/src/jsMain/kotlin/net/starshipfights/game/client_error.kt b/src/jsMain/kotlin/net/starshipfights/game/client_error.kt new file mode 100644 index 0000000..a2194cd --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/client_error.kt @@ -0,0 +1,11 @@ +package net.starshipfights.game + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +suspend fun errorMain(message: String) { + coroutineScope { + launch { setupBackground() } + launch { Popup.Error(message).display() } + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/client_game.kt b/src/jsMain/kotlin/net/starshipfights/game/client_game.kt new file mode 100644 index 0000000..9b95edb --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/client_game.kt @@ -0,0 +1,204 @@ +package net.starshipfights.game + +import externals.threejs.* +import io.ktor.client.features.websocket.* +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.SendChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.math.PI + +class GameNetworkInteraction( + val gameState: MutableStateFlow, + val playerActions: ReceiveChannel, + val errorMessages: SendChannel +) + +class GameRenderInteraction( + val gameState: StateFlow, + val playerActions: SendChannel, + val errorMessages: ReceiveChannel +) + +lateinit var mySide: GlobalSide + +private val pickContextDeferred = CompletableDeferred() + +suspend fun GameRenderInteraction.execute(scope: CoroutineScope) { + GameUI.initGameUI(scope.uiResponder(playerActions)) + + GameUI.drawGameUI(gameState.value) + + val gameStart = gameState.value.start + val playerStart = gameStart.playerStart(mySide) + + val camera = PerspectiveCamera(69, window.aspectRatio, 0.01, 1_000) + + camera.rotateX(PI / 4) + RenderScaling.toWorldRotation(playerStart.cameraFacing, camera) + + val renderer = WebGLRenderer(configure { + canvas = document.getElementById("three-canvas") + antialias = true + }) + + renderer.setPixelRatio(window.devicePixelRatio) + renderer.setSize(window.innerWidth, window.innerHeight) + + renderer.shadowMap.enabled = true + renderer.shadowMap.type = VSMShadowMap + + val scene = Scene() + val battleGrid = RenderResources.battleGrid.generate(gameStart.battlefieldWidth to gameStart.battlefieldLength) + scene.add(battleGrid) + scene.add(camera) + + val cameraControls = CameraControls(camera, configure { + domElement = renderer.domElement + keyDomElement = window + + cameraXBound = RenderScaling.toWorldLength(gameStart.battlefieldLength / 2) + cameraZBound = RenderScaling.toWorldLength(gameStart.battlefieldWidth / 2) + }) + + cameraControls.cameraParent.position.copy(RenderScaling.toWorldPosition(playerStart.cameraPosition)) + + cameraControls.camera.add(PointLight("#ffffff", 0.3, 60, 1.5)) + cameraControls.camera.add(DirectionalLight("#ffffff", 0.45).apply { + position.setScalar(0) + target = cameraControls.cameraParent + }) + + GameRender.renderGameState(scene, gameState.value) + + window.addEventListener("resize", { + camera.aspect = window.aspectRatio + camera.updateProjectionMatrix() + + renderer.setSize(window.innerWidth, window.innerHeight) + }) + + pickContextDeferred.complete(PickContext(scene, camera) { gameState.value }) + + coroutineScope { + launch { + deltaTimeFlow.collect { dt -> + cameraControls.update(dt) + renderer.render(scene, camera) + GameUI.updateGameUI(cameraControls) + } + } + + launch { + for (errorMessage in errorMessages) + GameUI.displayErrorMessage(errorMessage) + } + + launch { + gameState.collect { state -> + GameRender.renderGameState(scene, state) + GameUI.drawGameUI(state) + + if (state.phase != GamePhase.Deploy) + launch { + val pickContext = pickContextDeferred.await() + beginSelecting(pickContext) + handleSelections(pickContext) + } + } + } + } +} + +private suspend fun GameNetworkInteraction.execute(token: String): GameEvent.GameEnd { + val gameEnd = CompletableDeferred() + + try { + httpClient.webSocket("$rootPathWs/game/$token") { + val opponentJoined = Popup.LoadingScreen("Waiting for opponent to enter...") { + receiveObject(GameBeginning.serializer()) { closeAndReturn { return@LoadingScreen false } }.opponentJoined + }.display() + + if (!opponentJoined) + Popup.GameOver(mySide, "Unfortunately, your opponent never entered the battle.", emptyMap(), gameState.value).display() + + val sendActionsJob = launch { + for (action in playerActions) + launch { + sendObject(PlayerAction.serializer(), action) + } + } + + while (true) { + when (val event = receiveObject(GameEvent.serializer()) { closeAndReturn { return@webSocket sendActionsJob.cancel() } }) { + is GameEvent.StateChange -> { + gameState.value = event.newState + } + is GameEvent.InvalidAction -> { + errorMessages.send(event.message) + } + is GameEvent.GameEnd -> { + gameEnd.complete(event) + closeAndReturn { return@webSocket sendActionsJob.cancel() } + } + } + } + } + } catch (ex: WebSocketException) { + gameEnd.complete(GameEvent.GameEnd(null, "Server closed connection abruptly", emptyMap())) + } + + if (gameEnd.isActive) + gameEnd.complete(GameEvent.GameEnd(null, "Connection closed", emptyMap())) + + return gameEnd.await() +} + +private class GameUIResponderImpl(scope: CoroutineScope, private val actions: SendChannel) : GameUIResponder, CoroutineScope by scope { + override fun doAction(action: PlayerAction) { + launch { + actions.send(action) + } + } + + override fun useAbility(ability: PlayerAbilityType) { + launch { + val ctx = pickContextDeferred.await() + val abilityData = ability.beginOnClient(ctx.getGameState(), mySide) { it.pick(ctx) } ?: return@launch + val action = PlayerAction.UseAbility(ability, abilityData) + actions.send(action) + } + } +} + +private fun CoroutineScope.uiResponder(actions: SendChannel) = GameUIResponderImpl(this, actions) + +suspend fun gameMain(side: GlobalSide, token: String, state: GameState) { + interruptExit = true + + initializePicking() + + mySide = side + + val gameState = MutableStateFlow(state) + val playerActions = Channel(Channel.UNLIMITED) + val errorMessages = Channel(Channel.UNLIMITED) + + val gameConnection = GameNetworkInteraction(gameState, playerActions, errorMessages) + val gameRendering = GameRenderInteraction(gameState, playerActions, errorMessages) + + coroutineScope { + val connectionJob = async { gameConnection.execute(token) } + val renderingJob = launch { gameRendering.execute(this@coroutineScope) } + + val (finalWinner, finalMessage, finalSubplots) = connectionJob.await() + renderingJob.cancel() + + interruptExit = false + Popup.GameOver(finalWinner, finalMessage, finalSubplots, gameState.value).display() + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/client_matchmaking.kt b/src/jsMain/kotlin/net/starshipfights/game/client_matchmaking.kt new file mode 100644 index 0000000..5c6341e --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/client_matchmaking.kt @@ -0,0 +1,160 @@ +package net.starshipfights.game + +import externals.threejs.PerspectiveCamera +import externals.threejs.Scene +import externals.threejs.WebGLRenderer +import io.ktor.client.features.websocket.* +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.html.FormEncType +import kotlinx.html.FormMethod +import kotlinx.html.dom.append +import kotlinx.html.hiddenInput +import kotlinx.html.js.form +import kotlinx.html.style +import net.starshipfights.data.Id + +suspend fun setupBackground() { + val camera = PerspectiveCamera(69, window.aspectRatio, 0.01, 1_000) + + val renderer = WebGLRenderer(configure { + canvas = document.getElementById("three-canvas") + antialias = true + }) + + renderer.setPixelRatio(window.devicePixelRatio) + renderer.setSize(window.innerWidth, window.innerHeight) + + val scene = Scene() + scene.background = RenderResources.spaceboxes.values.random() + scene.add(camera) + + window.addEventListener("resize", { + camera.aspect = window.aspectRatio + camera.updateProjectionMatrix() + + renderer.setSize(window.innerWidth, window.innerHeight) + }) + + deltaTimeFlow.collect { dt -> + renderer.render(scene, camera) + camera.rotateY(dt * 0.25) + } +} + +private suspend fun enterTraining(admiral: Id, battleInfo: BattleInfo, faction: Faction?): Nothing { + interruptExit = false + + document.body!!.append.form(action = "/train", method = FormMethod.post, encType = FormEncType.applicationXWwwFormUrlEncoded) { + style = "display:none" + hiddenInput { + name = "admiral" + value = admiral.toString() + } + hiddenInput { + name = "battle-size" + value = battleInfo.size.toUrlSlug() + } + hiddenInput { + name = "battle-bg" + value = battleInfo.bg.toUrlSlug() + } + hiddenInput { + name = "enemy-faction" + value = faction?.toUrlSlug() ?: "-random" + } + }.submit() + awaitCancellation() +} + +private suspend fun enterGame(connectToken: String): Nothing { + interruptExit = false + + document.body!!.append.form(action = "/play", method = FormMethod.post, encType = FormEncType.applicationXWwwFormUrlEncoded) { + style = "display:none" + hiddenInput { + name = "token" + value = connectToken + } + }.submit() + awaitCancellation() +} + +private suspend fun usePlayerLogin(admirals: List) { + val playerLogin = Popup.getPlayerLogin(admirals) + val playerLoginSide = playerLogin.login.globalSide + + if (playerLoginSide == null) { + val (battleInfo, enemyFaction) = playerLogin.login as LoginMode.Train + enterTraining(playerLogin.admiral, battleInfo, enemyFaction) + } + + val admiral = admirals.single { it.id == playerLogin.admiral } + + try { + httpClient.webSocket("$rootPathWs/matchmaking") { + sendObject(PlayerLogin.serializer(), playerLogin) + + when (playerLoginSide) { + GlobalSide.HOST -> { + var loadingText = "Awaiting join request..." + + do { + val joinRequest = Popup.CancellableLoadingScreen(loadingText) { + receiveObject(JoinRequest.serializer()) { closeAndReturn { return@CancellableLoadingScreen null } } + }.display() ?: closeAndReturn("Battle hosting cancelled") { return@webSocket } + + val joinAcceptance = Popup.GuestRequestScreen(admiral, joinRequest.joiner).display() ?: closeAndReturn("Battle hosting cancelled") { return@webSocket } + sendObject(JoinResponse.serializer(), JoinResponse(joinAcceptance)) + + val joinConnected = joinAcceptance && receiveObject(JoinResponseResponse.serializer()) { closeAndReturn { return@webSocket } }.connected + + loadingText = if (joinAcceptance) + "${joinRequest.joiner.name} cancelled join. Awaiting join request..." + else + "Awaiting join request..." + } while (!joinConnected) + + val connectToken = receiveObject(GameReady.serializer()) { closeAndReturn { return@webSocket } }.connectToken + enterGame(connectToken) + } + GlobalSide.GUEST -> { + val listOfHosts = receiveObject(JoinListing.serializer()) { closeAndReturn { return@webSocket } }.openGames + + do { + val selectedHost = Popup.HostSelectScreen(listOfHosts).display() ?: closeAndReturn("Battle joining cancelled") { return@webSocket } + sendObject(JoinSelection.serializer(), JoinSelection(selectedHost)) + + val joinAcceptance = Popup.CancellableLoadingScreen("Awaiting join response...") { + receiveObject(JoinResponse.serializer()) { closeAndReturn { return@CancellableLoadingScreen null } }.accepted + }.display() ?: closeAndReturn("Battle joining cancelled") { return@webSocket } + + if (!joinAcceptance) { + val hostInfo = listOfHosts.getValue(selectedHost).admiral + Popup.JoinRejectedScreen(hostInfo).display() + } + } while (!joinAcceptance) + + val connectToken = receiveObject(GameReady.serializer()) { closeAndReturn { return@webSocket } }.connectToken + enterGame(connectToken) + } + } + } + } catch (ex: WebSocketException) { + Popup.Error("Server abruptly closed connection").display() + } + + usePlayerLogin(admirals) +} + +suspend fun matchmakingMain(admirals: List) { + interruptExit = true + + coroutineScope { + launch { setupBackground() } + launch { usePlayerLogin(admirals) } + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/client_training.kt b/src/jsMain/kotlin/net/starshipfights/game/client_training.kt new file mode 100644 index 0000000..8086704 --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/client_training.kt @@ -0,0 +1,144 @@ +package net.starshipfights.game + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.starshipfights.game.ai.AISession +import net.starshipfights.game.ai.aiPlayer + +class GameSession(gameState: GameState) { + private val stateMutable = MutableStateFlow(gameState) + private val stateMutex = Mutex() + + val state = stateMutable.asStateFlow() + + private val hostErrorMessages = Channel(Channel.UNLIMITED) + private val guestErrorMessages = Channel(Channel.UNLIMITED) + + private fun errorMessageChannel(player: GlobalSide) = when (player) { + GlobalSide.HOST -> hostErrorMessages + GlobalSide.GUEST -> guestErrorMessages + } + + fun errorMessages(player: GlobalSide): ReceiveChannel = when (player) { + GlobalSide.HOST -> hostErrorMessages + GlobalSide.GUEST -> guestErrorMessages + } + + private val gameEndMutable = CompletableDeferred() + val gameEnd: Deferred + get() = gameEndMutable + + suspend fun onPacket(player: GlobalSide, packet: PlayerAction) { + stateMutex.withLock { + when (val result = state.value.after(player, packet)) { + is GameEvent.StateChange -> { + stateMutable.value = result.newState + result.newState.checkVictory()?.let { gameEndMutable.complete(it) } + } + is GameEvent.InvalidAction -> { + errorMessageChannel(player).send(result.message) + } + is GameEvent.GameEnd -> { + gameEndMutable.complete(result) + } + } + } + } +} + +private suspend fun GameNetworkInteraction.execute(): GameEvent.GameEnd { + val gameSession = GameSession(gameState.value) + + val aiSide = mySide.other + val aiActions = Channel() + val aiEvents = Channel() + val aiSession = AISession(aiSide, aiActions, aiEvents) + + return coroutineScope { + val aiHandlingJob = launch { + launch { + listOf( + // Game state changes + launch { + gameSession.state.collect { state -> + aiEvents.send(GameEvent.StateChange(state)) + } + }, + // Invalid action messages + launch { + for (errorMessage in gameSession.errorMessages(aiSide)) { + aiEvents.send(GameEvent.InvalidAction(errorMessage)) + } + } + ).joinAll() + } + + launch { + for (action in aiActions) + gameSession.onPacket(aiSide, action) + } + + aiPlayer(aiSession, gameState.value) + } + + val playerHandlingJob = launch { + launch { + listOf( + // Game state changes + launch { + gameSession.state.collect { state -> + gameState.value = state + } + }, + // Invalid action messages + launch { + for (errorMessage in gameSession.errorMessages(mySide)) { + errorMessages.send(errorMessage) + } + } + ).joinAll() + } + + for (action in playerActions) + gameSession.onPacket(mySide, action) + } + + val gameEnd = gameSession.gameEnd.await() + + aiHandlingJob.cancel() + playerHandlingJob.cancel() + + gameEnd + } +} + +suspend fun trainingMain(state: GameState) { + interruptExit = true + + initializePicking() + + mySide = GlobalSide.HOST + + val gameState = MutableStateFlow(state) + val playerActions = Channel(Channel.UNLIMITED) + val errorMessages = Channel(Channel.UNLIMITED) + + val gameConnection = GameNetworkInteraction(gameState, playerActions, errorMessages) + val gameRendering = GameRenderInteraction(gameState, playerActions, errorMessages) + + coroutineScope { + val connectionJob = async { gameConnection.execute() } + val renderingJob = launch { gameRendering.execute(this@coroutineScope) } + + val (finalWinner, finalMessage, finalSubplots) = connectionJob.await() + renderingJob.cancel() + + interruptExit = false + Popup.GameOver(finalWinner, finalMessage, finalSubplots, gameState.value).display() + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/game_render.kt b/src/jsMain/kotlin/net/starshipfights/game/game_render.kt new file mode 100644 index 0000000..d0ff67f --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/game_render.kt @@ -0,0 +1,43 @@ +package net.starshipfights.game + +import externals.threejs.* + +object GameRender { + fun renderGameState(scene: Scene, state: GameState) { + scene.background = RenderResources.spaceboxes.getValue(state.battleInfo.bg) + scene.getObjectByName("light")?.removeFromParent() + scene.add(AmbientLight(state.battleInfo.bg.color).apply { name = "light" }) + + val shipGroup = scene.getObjectByName("ships") ?: Group().apply { name = "ships" }.also { scene.add(it) } + + shipGroup.clear() + + for (ship in state.ships.values) { + when (state.renderShipAs(ship, mySide)) { + ShipRenderMode.NONE -> {} + ShipRenderMode.SIGNAL -> shipGroup.add(RenderResources.enemySignal.generate(ship.position.location)) + ShipRenderMode.FULL -> shipGroup.add(RenderResources.shipMesh.generate(ship)) + } + } + } +} + +object RenderScaling { + const val METERS_PER_THREEJS_UNIT = 100.0 + const val METERS_PER_3D_MESH_UNIT = 6.9 + + fun toWorldRotation(facing: Double, obj: Object3D) { + obj.rotateY(-facing) + } + + fun toBattleLength(length3js: Double) = length3js * METERS_PER_THREEJS_UNIT + fun toWorldLength(lengthSf: Double) = lengthSf / METERS_PER_THREEJS_UNIT + + fun toBattlePosition(v3: Vector3) = Position( + Vec2(v3.z.toDouble(), -v3.x.toDouble()) * METERS_PER_THREEJS_UNIT + ) + + fun toWorldPosition(pos: Position) = (pos.vector / METERS_PER_THREEJS_UNIT).let { (x, y) -> + Vector3(-y, 0, x) + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/game_resources.kt b/src/jsMain/kotlin/net/starshipfights/game/game_resources.kt new file mode 100644 index 0000000..0d73b67 --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/game_resources.kt @@ -0,0 +1,350 @@ +package net.starshipfights.game + +import externals.threejs.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import net.starshipfights.data.Id +import org.w3c.dom.Image +import kotlin.math.PI +import kotlin.math.roundToInt + +fun interface RenderFactory { + fun generate(): Object3D +} + +fun interface CustomRenderFactory { + fun generate(parameter: T): Object3D +} + +val ClientMode.isSmallLoad: Boolean + get() = this !is ClientMode.InGame && this !is ClientMode.InTrainingGame + +object RenderResources { + const val LOGO_URL = "/static/images/logo.svg" + + private val spaceboxUrls = BattleBackground.values().associateWith { "spacebox-${it.toUrlSlug()}" } + + lateinit var spaceboxes: Map + private set + + private const val enemySignalUrl = "enemy-signal" + + lateinit var enemySignal: CustomRenderFactory + private set + + private const val gridTileUrl = "grid-tile" + lateinit var battleGrid: CustomRenderFactory> + private set + + private const val friendlyMarkerUrl = "friendly-marker" + private const val hostileMarkerUrl = "hostile-marker" + + lateinit var markerFactory: CustomRenderFactory + private set + + private val shipMeshesRaw = mutableMapOf() + + lateinit var shipMeshes: Map> + private set + + lateinit var shipMesh: CustomRenderFactory + private set + + lateinit var shipHologramFactory: CustomRenderFactory + private set + + suspend fun load(isSmallLoad: Boolean = false) { + coroutineScope { + launch { + val img = Image() + val job = launch { img.awaitEvent("load") } + img.src = LOGO_URL + job.join() + } + + launch { + Faction.values().map { faction -> + val img = Image() + val job = launch { img.awaitEvent("load") } + img.src = faction.flagUrl + job + }.joinAll() + } + + launch { + spaceboxes = spaceboxUrls.mapValues { (_, it) -> + async { loadTexture(it) } + }.mapValues { (_, it) -> + it.await() + }.onEach { (_, it) -> + it.mapping = EquirectangularReflectionMapping + } + } + + if (isSmallLoad) return@coroutineScope + + initResCache() + + launch { + val texture = loadTexture(enemySignalUrl) + val material = SpriteMaterial(configure { + map = texture + blending = CustomBlending + blendEquation = AddEquation + blendSrc = OneFactor + blendDst = OneMinusSrcColorFactor + }) + + val sprite = Sprite(material) + sprite.scale.setScalar(4) + sprite.position.set(0, 4, 0) + + enemySignal = CustomRenderFactory { pos -> + Group().apply { + add(sprite.clone(true)) + position.copy(RenderScaling.toWorldPosition(pos)) + } + } + } + + launch { + val texture = loadTexture(gridTileUrl) + texture.minFilter = LinearFilter + texture.magFilter = LinearFilter + + battleGrid = CustomRenderFactory { (bfW, bfL) -> + val w3d = RenderScaling.toWorldLength(bfW) + val l3d = RenderScaling.toWorldLength(bfL) + + val gridTex = texture.clone().apply { + wrapS = RepeatWrapping + wrapT = RepeatWrapping + repeat.set((w3d / 5).roundToInt(), (l3d / 5).roundToInt()) + needsUpdate = true + } + + val material = MeshBasicMaterial(configure { + map = gridTex + + side = DoubleSide + depthWrite = false + + blending = CustomBlending + blendEquation = AddEquation + blendSrc = OneFactor + blendDst = OneMinusSrcColorFactor + }) + + val plane = PlaneGeometry(w3d, l3d) + val mesh = Mesh(plane, material) + mesh.rotateX(PI / 2) + + Group().also { + it.add(Group().apply { + RenderScaling.toWorldRotation(PI / 2, this) + add(mesh) + }) + + it.position.set(0, -0.02, 0) + + it.name = "plane" + } + } + } + + launch { + val friendlyMarkerPromise = async { loadTexture(friendlyMarkerUrl) } + val hostileMarkerPromise = async { loadTexture(hostileMarkerUrl) } + + val friendlyMarkerTexture = friendlyMarkerPromise.await() + friendlyMarkerTexture.minFilter = LinearFilter + friendlyMarkerTexture.magFilter = LinearFilter + + val hostileMarkerTexture = hostileMarkerPromise.await() + hostileMarkerTexture.minFilter = LinearFilter + hostileMarkerTexture.magFilter = LinearFilter + + val friendlyMarkerMaterial = MeshBasicMaterial(configure { + map = friendlyMarkerTexture + alphaTest = 0.5 + side = DoubleSide + }) + + val hostileMarkerMaterial = MeshBasicMaterial(configure { + map = hostileMarkerTexture + alphaTest = 0.5 + side = DoubleSide + }) + + val plane = PlaneGeometry(4, 4) + + val friendlyMarkerMesh = Mesh(plane, friendlyMarkerMaterial) + val hostileMarkerMesh = Mesh(plane, hostileMarkerMaterial) + + friendlyMarkerMesh.rotateX(PI / 2) + hostileMarkerMesh.rotateX(PI / 2) + + markerFactory = CustomRenderFactory { side -> + when (side) { + LocalSide.GREEN -> friendlyMarkerMesh + LocalSide.RED -> hostileMarkerMesh + }.clone(true) + } + } + + launch { + val outlineMaterial = ShaderMaterial(configure { + vertexShader = """ + |uniform float outlineGrow; + + |void main() { + | vec3 grownPosition = position + (normal * outlineGrow); + | gl_Position = projectionMatrix * modelViewMatrix * vec4(grownPosition, 1.0); + |} + """.trimMargin() + + fragmentShader = """ + |uniform vec3 outlineColor; + + |void main () { + | gl_FragColor = vec4( outlineColor, 1.0 ); + |} + """.trimMargin() + + uniforms = configure { + this["outlineGrow"] = configure { + value = 0.75 + } + this["outlineColor"] = configure { + value = Color("#AAAAAA") + } + } + }).apply { + side = BackSide + } + + shipMeshes = ShipType.values().associateWith { st -> + async { loadModel(st.meshName) } + }.mapValues { (st, loadingMesh) -> + val mesh = loadingMesh.await() + mesh.scale.setScalar(RenderScaling.METERS_PER_3D_MESH_UNIT / RenderScaling.METERS_PER_THREEJS_UNIT) + mesh.position.set(0, 4, 0) + + shipMeshesRaw[st] = RenderFactory { mesh.clone(true) } + + val greenOutlineMaterial = outlineMaterial.clone().unsafeCast().apply { + uniforms["outlineColor"]!!.value = Color(LocalSide.GREEN.htmlColor) + } + + val redOutlineMaterial = outlineMaterial.clone().unsafeCast().apply { + uniforms["outlineColor"]!!.value = Color(LocalSide.RED.htmlColor) + } + + val outlineGreen = mesh.clone(true).unsafeCast() + outlineGreen.material = greenOutlineMaterial + + val outlineRed = mesh.clone(true).unsafeCast() + outlineRed.material = redOutlineMaterial + + CustomRenderFactory { ship -> + val side = ship.owner.relativeTo(mySide) + + ShipRender( + ship.id, + markerFactory.generate(side).unsafeCast(), + mesh.clone(true).unsafeCast().apply { + receiveShadow = true + castShadow = true + }, + when (side) { + LocalSide.GREEN -> outlineGreen + LocalSide.RED -> outlineRed + }.clone(true).unsafeCast() + ).group + } + } + + shipMesh = CustomRenderFactory { shipInstance -> + shipMeshes.getValue(shipInstance.ship.shipType).generate(shipInstance).also { render -> + RenderScaling.toWorldRotation(shipInstance.position.facing, render) + render.position.copy(RenderScaling.toWorldPosition(shipInstance.position.location)) + } + } + } + + launch { + val hologramMaterial = ShaderMaterial(configure { + extensions = configure { + derivatives = true + } + + vertexShader = """ + |varying float vNormalZ; + + |void main() { + | vNormalZ = normalize( normalMatrix * normal ).z; + | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + |} + """.trimMargin() + + fragmentShader = """ + |uniform vec3 glowColor; + |uniform float glowAmount; + |varying float vNormalZ; + + |void main () { + | float colorCoeff = smoothstep( 0.0, 1.0, fwidth( vNormalZ * glowAmount ) ); + | gl_FragColor = vec4( glowColor * colorCoeff, 1.0 ); + |} + """.trimMargin() + + uniforms = configure { + this["glowColor"] = configure { + value = Color("#ffffff") + } + this["glowAmount"] = configure { + value = 3.5 + } + } + }).apply { + userData = "hologram" + + blending = CustomBlending + blendEquation = AddEquation + blendSrc = OneFactor + blendDst = OneMinusSrcColorFactor + } + + shipHologramFactory = CustomRenderFactory { shipHelper -> + val shipMesh = shipMeshesRaw.getValue(shipHelper.type).generate().unsafeCast() + + shipMesh.material = hologramMaterial.clone() + + shipMesh + } + } + } + } +} + +data class ShipRender( + val shipId: Id, + + val bottomMarker: Mesh, + val shipMesh: Mesh, + val shipOutline: Mesh +) { + val group: Group = Group().also { g -> + bottomMarker.userData = this + shipMesh.userData = this + shipOutline.userData = this + + shipOutline.visible = false + + g.add(bottomMarker, shipMesh, shipOutline) + g.name = shipId.toString() + g.userData = this + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/game_time_js.kt b/src/jsMain/kotlin/net/starshipfights/game/game_time_js.kt new file mode 100644 index 0000000..ac246bd --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/game_time_js.kt @@ -0,0 +1,20 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import kotlin.js.Date + +@Serializable(with = MomentSerializer::class) +actual class Moment(val date: Date) : Comparable { + actual constructor(millis: Double) : this(Date(millis)) + + actual fun toMillis(): Double { + return date.getTime() + } + + actual override fun compareTo(other: Moment) = toMillis().compareTo(other.toMillis()) + + actual companion object { + actual val now: Moment + get() = Moment(Date()) + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/game_ui.kt b/src/jsMain/kotlin/net/starshipfights/game/game_ui.kt new file mode 100644 index 0000000..fd2dc23 --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/game_ui.kt @@ -0,0 +1,1139 @@ +package net.starshipfights.game + +import externals.textfit.textFit +import externals.threejs.* +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.coroutines.delay +import kotlinx.dom.clear +import kotlinx.html.* +import kotlinx.html.dom.append +import kotlinx.html.dom.create +import kotlinx.html.js.div +import kotlinx.html.js.onClickFunction +import net.starshipfights.data.Id +import org.w3c.dom.* +import org.w3c.dom.events.KeyboardEvent + +interface GameUIResponder { + fun doAction(action: PlayerAction) + fun useAbility(ability: PlayerAbilityType) +} + +object GameUI { + private lateinit var responder: GameUIResponder + + private val gameUI = document.getElementById("ui").unsafeCast() + private lateinit var chatHistory: HTMLDivElement + private lateinit var chatInput: HTMLInputElement + private lateinit var chatSend: HTMLButtonElement + + private lateinit var topMiddleInfo: HTMLDivElement + private lateinit var topRightBar: HTMLDivElement + + private lateinit var objectives: HTMLDivElement + + private lateinit var errorMessages: HTMLParagraphElement + private lateinit var helpMessages: HTMLParagraphElement + + private lateinit var shipsOverlay: HTMLElement + private lateinit var shipsOverlayRenderer: CSS3DRenderer + private lateinit var shipsOverlayCamera: PerspectiveCamera + private lateinit var shipsOverlayScene: Scene + + fun initGameUI(uiResponder: GameUIResponder) { + responder = uiResponder + + gameUI.clear() + + gameUI.append { + div(classes = "panel") { + id = "chat-box" + + div(classes = "inset") { + id = "chat-history" + } + + div { + id = "chat-entry" + + textInput { + id = "chat-input" + placeholder = "Write a friendly chat message" + } + + button { + id = "chat-send" + +"Send" + } + } + } + + div(classes = "panel") { + id = "top-middle-info" + + p { + +"Battle has not started yet" + } + } + + div(classes = "panel") { + id = "top-right-bar" + } + + div { + id = "objectives" + } + + p { + id = "error-messages" + } + + p { + id = "help-messages" + } + } + + chatHistory = document.getElementById("chat-history").unsafeCast() + chatInput = document.getElementById("chat-input").unsafeCast() + chatSend = document.getElementById("chat-send").unsafeCast() + + chatSend.addEventListener("click", { e -> + e.preventDefault() + + val chatAction = PlayerAction.SendChatMessage(chatInput.value) + responder.doAction(chatAction) + chatInput.value = "" + }) + + chatInput.addEventListener("keydown", { e -> + val ke = e.unsafeCast() + if (ke.key == "Enter") { + val chatAction = PlayerAction.SendChatMessage(chatInput.value) + responder.doAction(chatAction) + chatInput.value = "" + } + if (document.activeElement == chatInput && document.hasFocus()) + ke.stopPropagation() + }) + + topMiddleInfo = document.getElementById("top-middle-info").unsafeCast() + topRightBar = document.getElementById("top-right-bar").unsafeCast() + + objectives = document.getElementById("objectives").unsafeCast() + + errorMessages = document.getElementById("error-messages").unsafeCast() + helpMessages = document.getElementById("help-messages").unsafeCast() + + shipsOverlayRenderer = CSS3DRenderer() + shipsOverlayRenderer.setSize(window.innerWidth, window.innerHeight) + + shipsOverlay = shipsOverlayRenderer.domElement + gameUI.prepend(shipsOverlay) + + shipsOverlayCamera = PerspectiveCamera(69, window.aspectRatio, 0.01, 1_000) + shipsOverlayScene = Scene() + shipsOverlayScene.add(shipsOverlayCamera) + + window.addEventListener("resize", { + shipsOverlayCamera.aspect = window.aspectRatio + shipsOverlayCamera.updateProjectionMatrix() + + shipsOverlayRenderer.setSize(window.innerWidth, window.innerHeight) + }) + } + + suspend fun displayErrorMessage(message: String) { + errorMessages.textContent = message + + delay(5000) + + errorMessages.textContent = "" + } + + var currentHelpMessage: String + get() = helpMessages.textContent ?: "" + set(value) { + helpMessages.textContent = value + } + + fun updateGameUI(controls: CameraControls) { + shipsOverlayCamera.position.copy(controls.camera.getWorldPosition(shipsOverlayCamera.position)) + shipsOverlayCamera.quaternion.copy(controls.camera.getWorldQuaternion(shipsOverlayCamera.quaternion)) + shipsOverlayRenderer.render(shipsOverlayScene, shipsOverlayCamera) + + textFit(document.getElementsByClassName("ship-label")) + } + + fun drawGameUI(state: GameState) { + chatHistory.clear() + chatHistory.append { + for (entry in state.chatBox.sortedBy { it.sentAt }) { + p { + title = "At ${entry.sentAt.date}" + + when (entry) { + is ChatEntry.PlayerMessage -> { + val senderInfo = state.admiralInfo(entry.senderSide) + val senderSide = entry.senderSide.relativeTo(mySide) + strong { + style = "color:${senderSide.htmlColor}" + +senderInfo.user.username + +Entities.nbsp + img(alt = senderInfo.faction.shortName, src = senderInfo.faction.flagUrl) { + style = "height:0.75em;width:1.2em" + } + } + +Entities.nbsp + +entry.message + } + is ChatEntry.ShipIdentified -> { + val ship = state.getShipInfo(entry.ship) + val owner = state.getShipOwner(entry.ship).relativeTo(mySide) + +"The " + if (owner == LocalSide.RED) + +"enemy ship " + strong { + style = "color:${owner.htmlColor}" + +ship.fullName + } + +" has been sighted" + if (owner == LocalSide.GREEN) + +" by the enemy" + +"!" + } + is ChatEntry.ShipEscaped -> { + val ship = state.getShipInfo(entry.ship) + val owner = state.getShipOwner(entry.ship).relativeTo(mySide) + +if (owner == LocalSide.RED) + "The enemy ship " + else + "Our ship, the " + strong { + style = "color:${owner.htmlColor}" + +ship.fullName + } + +" has " + +if (owner == LocalSide.RED) + "fled like a coward" + else + "disengaged" + +" from the battlefield!" + } + is ChatEntry.ShipAttacked -> { + val ship = state.getShipInfo(entry.ship) + val owner = state.getShipOwner(entry.ship).relativeTo(mySide) + +if (owner == LocalSide.RED) + "The enemy ship " + else + "Our ship, the " + strong { + style = "color:${owner.htmlColor}" + +ship.fullName + } + +" has taken " + + +if (entry.weapon is ShipWeapon.EmpAntenna) + "subsystem-draining" + else + entry.damageInflicted.toString() + + if (entry.critical != null) + +" critical" + + +" damage from " + when (entry.attacker) { + is ShipAttacker.EnemyShip -> { + if (entry.weapon != null) { + +"the " + +when (entry.weapon) { + is ShipWeapon.Cannon -> "cannons" + is ShipWeapon.Lance -> "lances" + is ShipWeapon.Hangar -> "bombers" + is ShipWeapon.Torpedo -> "torpedoes" + is ShipWeapon.ParticleClawLauncher -> "particle claws" + is ShipWeapon.LightningYarn -> "lightning yarn" + ShipWeapon.MegaCannon -> "Mega Giga Cannon" + ShipWeapon.RevelationGun -> "Revelation Gun" + ShipWeapon.EmpAntenna -> "EMP antenna" + } + +" of " + } + +"the " + strong { + style = "color:${owner.other.htmlColor}" + +state.getShipInfo(entry.attacker.id).fullName + } + } + ShipAttacker.Fire -> { + +"onboard fires" + } + ShipAttacker.Bombers -> { + if (owner == LocalSide.RED) + +"our " + else + +"enemy " + +"bombers" + } + } + + +when (entry.critical) { + ShipCritical.Fire -> ", starting a fire" + is ShipCritical.TroopsKilled -> ", killing ${entry.critical.number} troops" + is ShipCritical.ModulesHit -> ", disabling ${entry.critical.module.joinToDisplayString { it.getDisplayName(ship) }}" + else -> "" + } + +"." + } + is ChatEntry.ShipAttackFailed -> { + val ship = state.getShipInfo(entry.ship) + val owner = state.getShipOwner(entry.ship).relativeTo(mySide) + +if (owner == LocalSide.RED) + "The enemy ship " + else + "Our ship, the " + strong { + style = "color:${owner.htmlColor}" + +ship.fullName + } + +" has ignored an attack from " + when (entry.attacker) { + is ShipAttacker.EnemyShip -> { + if (entry.weapon != null) { + +"the " + +when (entry.weapon) { + is ShipWeapon.Cannon -> "cannons" + is ShipWeapon.Lance -> "lances" + is ShipWeapon.Hangar -> "bombers" + is ShipWeapon.Torpedo -> "torpedoes" + is ShipWeapon.ParticleClawLauncher -> "particle claws" + is ShipWeapon.LightningYarn -> "lightning yarn" + ShipWeapon.MegaCannon -> "Mega Giga Cannon" + ShipWeapon.RevelationGun -> "Revelation Gun" + ShipWeapon.EmpAntenna -> "EMP antenna" + } + +" of " + } + +"the " + strong { + style = "color:${owner.other.htmlColor}" + +state.getShipInfo(entry.attacker.id).fullName + } + } + ShipAttacker.Fire -> { + +"onboard fires" + } + ShipAttacker.Bombers -> { + if (owner == LocalSide.RED) + +"our " + else + +"enemy " + +"bombers" + } + } + + +when (entry.damageIgnoreType) { + DamageIgnoreType.FELINAE_ARMOR -> " using its relativistic armor" + } + +"." + } + is ChatEntry.ShipBoarded -> { + val ship = state.getShipInfo(entry.ship) + val owner = state.getShipOwner(entry.ship).relativeTo(mySide) + +if (owner == LocalSide.RED) + "The enemy ship " + else + "Our ship, the " + strong { + style = "color:${owner.htmlColor}" + +ship.fullName + } + + +" has been boarded by the " + strong { + style = "color:${owner.other.htmlColor}" + +state.getShipInfo(entry.boarder).fullName + } + + +when (entry.critical) { + ShipCritical.ExtraDamage -> ", dealing ${entry.damageAmount} hull damage" + ShipCritical.Fire -> ", starting a fire" + is ShipCritical.TroopsKilled -> ", killing ${entry.critical.number} troops" + is ShipCritical.ModulesHit -> ", disabling ${entry.critical.module.joinToDisplayString { it.getDisplayName(ship) }}" + else -> ", to no effect" + } + +"." + } + is ChatEntry.ShipDestroyed -> { + val ship = state.getShipInfo(entry.ship) + val owner = state.getShipOwner(entry.ship).relativeTo(mySide) + +if (owner == LocalSide.RED) + "The enemy ship " + else + "Our ship, the " + strong { + style = "color:${owner.htmlColor}" + +ship.fullName + } + +" has been destroyed by " + when (entry.destroyedBy) { + is ShipAttacker.EnemyShip -> { + +"the " + strong { + style = "color:${owner.other.htmlColor}" + +state.getShipInfo(entry.destroyedBy.id).fullName + } + } + ShipAttacker.Fire -> { + +"onboard fires" + } + ShipAttacker.Bombers -> { + +if (owner == LocalSide.RED) + "our " + else + "enemy " + +"bombers" + } + } + +"!" + } + } + } + } + }.lastOrNull()?.scrollIntoView() + + objectives.clear() + objectives.append { + for (objective in state.objectives(mySide)) { + val classes = when (objective.succeeded) { + true -> "item succeeded" + false -> "item failed" + else -> "item" + } + div(classes = classes) { + +objective.displayText + } + } + } + + val abilities = state.getPossibleAbilities(mySide) + + topMiddleInfo.clear() + topMiddleInfo.append { + p { + when (state.phase) { + GamePhase.Deploy -> { + strong(classes = "heading") { + +"Pre-Battle Deployment" + } + } + is GamePhase.Power -> { + strong(classes = "heading") { + +"Turn ${state.phase.turn}" + } + br + +"Phase I - Power Distribution" + } + is GamePhase.Move -> { + strong(classes = "heading") { + +"Turn ${state.phase.turn}" + } + br + +"Phase II - Ship Movement" + } + is GamePhase.Attack -> { + strong(classes = "heading") { + +"Turn ${state.phase.turn}" + } + br + +"Phase III - Weapons Fire" + } + is GamePhase.Repair -> { + strong(classes = "heading") { + +"Turn ${state.phase.turn}" + } + br + +"Phase IV - Onboard Repairs" + } + } + + if (state.phase.usesInitiative) { + br + +if (state.doneWithPhase == mySide) + "You have ended your phase" + else if (state.currentInitiative != mySide.other) + "You have the initiative!" + else "Your opponent has the initiative" + } else if (state.doneWithPhase == mySide) { + br + +"You have ended your phase" + } + } + } + + topRightBar.clear() + topRightBar.append { + when (state.phase) { + GamePhase.Deploy -> { + drawDeployPhase(state, abilities) + } + else -> { + drawShipActions(state, selectedShip.value) + } + } + } + + shipsOverlayScene.clear() + for ((shipId, ship) in state.ships) { + if (state.renderShipAs(ship, mySide) == ShipRenderMode.FULL) + shipsOverlayScene.add(CSS3DSprite(document.create.div { + drawShipLabel(state, abilities, shipId, ship) + }).apply { + scale.setScalar(0.00625) + + element.style.asDynamic().pointerEvents = "none" + + position.copy(RenderScaling.toWorldPosition(ship.position.location)) + position.y = 7.5 + }) + } + } + + private fun DIV.drawShipLabel(state: GameState, abilities: List, shipId: Id, ship: ShipInstance) { + id = "ship-overlay-$shipId" + classes = setOf("ship-overlay") + style = "background-color:#999;width:800px;height:300px;opacity:0.8;font-size:4em;text-align:center;vertical-align:middle" + attributes["data-ship-id"] = shipId.toString() + + p(classes = "ship-label") { + style = "color:#fff;margin:0;white-space:nowrap;width:800px;height:100px" + +ship.ship.fullName + } + p(classes = "ship-label") { + style = "color:#fff;margin:0;white-space:nowrap;width:800px;height:75px" + +ship.ship.shipType.fullDisplayName + } + + p { + style = "margin:0;white-space:nowrap;width:800px;height:125px" + when (state.phase) { + GamePhase.Deploy -> { + button { + style = "pointer-events:auto" + +"Undeploy" + + val undeployAbility = PlayerAbilityType.UndeployShip(shipId) + if (undeployAbility in abilities) + onClickFunction = { e -> + e.preventDefault() + + responder.useAbility(undeployAbility) + } + } + } + else -> { + if (ship.canUseShields) { + val totalShield = ship.powerMode.shields + val activeShield = ship.shieldAmount + val downShield = totalShield - activeShield + + table { + style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px" + + tr { + repeat(activeShield) { + td { + style = "background-color:#69F;height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + repeat(downShield) { + td { + style = "background-color:#46A;height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + } + } + } + + val totalHull = ship.durability.maxHullPoints + val activeHull = ship.hullAmount + val downHull = totalHull - activeHull + + table { + style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px" + + tr { + repeat(activeHull) { + td { + style = "background-color:${if (ship.owner == mySide) "#5F5" else "#F55"};height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + repeat(downHull) { + td { + style = "background-color:${if (ship.owner == mySide) "#262" else "#622"};height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + } + } + + val totalTroops = ship.durability.troopsDefense + val activeTroops = ship.troopsAmount + val downTroops = totalTroops - activeTroops + + table { + style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px" + + tr { + repeat(activeTroops) { + td { + style = "background-color:#AAA;height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + repeat(downTroops) { + td { + style = "background-color:#444;height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + } + } + + if (ship.ship.reactor is StandardShipReactor) { + if (ship.owner == mySide) { + val totalWeapons = ship.powerMode.weapons + val activeWeapons = ship.weaponAmount + val downWeapons = totalWeapons - activeWeapons + + table { + style = "width:100%;table-layout:fixed;background-color:#555;margin:0" + + tr { + repeat(activeWeapons) { + td { + style = "background-color:#F63;height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + repeat(downWeapons) { + td { + style = "background-color:#A42;height:15px;box-shadow:inset 0 0 0 3px #555" + } + } + } + } + } + } + } + } + } + + if (state.phase is GamePhase.Attack) { + div { + style = "margin:0;white-space:nowrap;text-align:center" + br + + val fighterSide = ship.owner.relativeTo(mySide) + val bomberSide = ship.owner.other.relativeTo(mySide) + + if (ship.fighterWings.isNotEmpty()) { + span { + val (borderColor, fillColor) = when (fighterSide) { + LocalSide.GREEN -> "#5F5" to "#262" + LocalSide.RED -> "#F55" to "#622" + } + + style = "display:inline-block;border:5px solid $borderColor;border-radius:15px;background-color:$fillColor;color:#fff" + + img(src = StrikeCraftWing.FIGHTERS.iconUrl, alt = StrikeCraftWing.FIGHTERS.displayName) { + style = "width:1.125em" + } + + +Entities.nbsp + + +ship.fighterWings.sumOf { (carrierId, wingId) -> + (state.ships[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 + }.toPercent() + } + } + + +Entities.nbsp + + if (ship.bomberWings.isNotEmpty()) { + span { + val (borderColor, fillColor) = when (bomberSide) { + LocalSide.GREEN -> "#5F5" to "#262" + LocalSide.RED -> "#F55" to "#622" + } + + style = "display:inline-block;border:5px solid $borderColor;border-radius:15px;background-color:$fillColor;color:#fff" + + img(src = StrikeCraftWing.BOMBERS.iconUrl, alt = StrikeCraftWing.BOMBERS.displayName) { + style = "width:1.125em" + } + + +Entities.nbsp + + +ship.bomberWings.sumOf { (carrierId, wingId) -> + (state.ships[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 + }.toPercent() + } + } + } + } + } + + private fun TagConsumer<*>.drawDeployPhase(state: GameState, abilities: List) { + val deployableShips = state.start.playerStart(mySide).deployableFleet + val remainingPoints = state.battleInfo.size.numPoints - state.ships.values.filter { it.owner == mySide }.sumOf { it.ship.pointCost } + + div { + style = "height:19%;font-size:0.9em" + + p { + style = "text-align:center;margin:1.25em" + +"Deploy your fleet" + br + +"Points remaining: $remainingPoints" + } + } + + div { + style = "height:69%;overflow-y:auto;font-size:0.9em" + + hr { style = "border-color:#555" } + for ((id, ship) in deployableShips.toList().sortedBy { (_, ship) -> ship.pointCost }) { + p { + style = "text-align:center;margin:0" + +ship.name + br + +ship.shipType.fullDisplayName + br + +"${ship.pointCost} points | " + + val deployAbility = PlayerAbilityType.DeployShip(id) + if (deployAbility in abilities) + a(href = "#") { + +"Deploy" + + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(deployAbility) + } + } + else + span { + style = "color:#333;cursor:not-allowed" + +"Deploy" + } + } + + hr { style = "border-color:#555" } + } + } + + div { + style = "height:9%;font-size:0.9em" + + p { + style = "text-align:center;margin:0.75em" + + val donePhase = abilities.filterIsInstance().singleOrNull() + if (donePhase != null) + a(href = "#") { + id = "done-phase" + +"Confirm Deployment" + + onClickFunction = { e -> + e.preventDefault() + + finalizePhase(donePhase, abilities) + } + } + else + span { + style = "color:#333;cursor:not-allowed" + +"Confirm Deployment" + } + } + } + } + + private fun TagConsumer<*>.drawShipActions(gameState: GameState, selectedId: Id?) { + val ship = selectedId?.let { gameState.ships[it] } + + div { + style = "text-align:center" + + val abilities = gameState + .getPossibleAbilities(mySide) + + if (ship == null) { + p { + style = "text-align:center;margin:0" + + +"No ship selected. Click on a ship to select it." + } + } else { + val shipAbilities = abilities + .filterIsInstance() + .filter { it.ship == ship.id } + + val combatAbilities = abilities + .filterIsInstance() + .filter { it.ship == ship.id } + + p { + style = "height:19%;margin:0" + + strong(classes = "heading") { +ship.ship.fullName } + br + + +ship.ship.shipType.fullerDisplayName + br + + if (ship.owner == mySide) { + when (ship.ship.reactor) { + is StandardShipReactor -> table { + style = "width:100%;table-layout:fixed;background-color:#555" + tr { + for (subsystem in ShipSubsystem.values()) { + val amount = ship.powerMode[subsystem] + + repeat(amount) { + td { + style = "background-color:${subsystem.htmlColor};margin:1px;height:0.55em" + } + } + } + } + } + is FelinaeShipReactor -> p { + +"Reactor Priority: ${ship.felinaeShipPowerMode.displayName}" + } + } + } + + for ((module, status) in ship.modulesStatus.statuses) { + when (status) { + ShipModuleStatus.INTACT -> {} + ShipModuleStatus.DAMAGED -> { + span { + style = "color:#fd4" + +"${module.getDisplayName(ship.ship)} Damaged" + } + br + } + ShipModuleStatus.DESTROYED -> { + span { + style = "color:#d22" + +"${module.getDisplayName(ship.ship)} Destroyed" + } + br + } + ShipModuleStatus.ABSENT -> {} + } + } + + if (ship.numFires > 0) + span { + style = "color:#e94" + +"${ship.numFires} Onboard Fire${if (ship.numFires == 1) "" else "s"}" + } + } + + if (ship.owner == mySide) { + hr { style = "border-color:#555" } + + p { + style = "height:69%;margin:0" + + if (gameState.phase is GamePhase.Repair && ship.durability is StandardShipDurability) { + +"${ship.remainingRepairTokens} Repair Tokens" + br + } + + for (ability in shipAbilities) { + when (ability) { + is PlayerAbilityType.DistributePower -> { + val shipReactor = ship.ship.reactor as StandardShipReactor + val shipPowerMode = ClientAbilityData.newShipPowerModes[ship.id] ?: ship.powerMode + + table { + style = "width:100%;table-layout:fixed;background-color:#555" + tr { + for (subsystem in ShipSubsystem.values()) { + val amount = shipPowerMode[subsystem] + + repeat(amount) { + td { + style = "background-color:${subsystem.htmlColor};margin:1px;height:0.55em" + } + } + } + } + } + + p { + style = "text-align:center" + +"Power Output: ${shipReactor.powerOutput}" + br + +"Remaining Transfers: ${ship.remainingGridEfficiency(shipPowerMode)}" + } + + for (transferFrom in ShipSubsystem.values()) { + div(classes = "button-set row") { + for (transferTo in ShipSubsystem.values()) { + if (transferFrom == transferTo) continue + + button { + style = "font-size:0.8em;padding:0 0.25em" + title = "${transferFrom.displayName} to ${transferTo.displayName}" + + img(src = transferFrom.imageUrl, alt = transferFrom.displayName) { + style = "width:0.95em" + } + +Entities.nbsp + img(src = ShipSubsystem.transferImageUrl, alt = " to ") { + style = "width:0.95em" + } + +Entities.nbsp + img(src = transferTo.imageUrl, alt = transferTo.displayName) { + style = "width:0.95em" + } + + val delta = mapOf(transferFrom to -1, transferTo to 1) + val newPowerMode = shipPowerMode + delta + + if (ship.validatePowerMode(newPowerMode)) + onClickFunction = { e -> + e.preventDefault() + ClientAbilityData.newShipPowerModes[ship.id] = newPowerMode + updateAbilityData(gameState) + } + else { + disabled = true + style += ";cursor:not-allowed" + } + } + } + } + } + + button { + +"Confirm" + if (ship.validatePowerMode(shipPowerMode)) + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + else { + disabled = true + style = "cursor:not-allowed" + } + } + + button { + +"Reset" + onClickFunction = { e -> + e.preventDefault() + ClientAbilityData.newShipPowerModes[ship.id] = ship.powerMode + updateAbilityData(gameState) + } + } + } + is PlayerAbilityType.ConfigurePower -> { + a(href = "#") { + +"Set Priority: ${ability.powerMode.displayName}" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } + is PlayerAbilityType.MoveShip -> { + a(href = "#") { + +"Move Ship" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } + is PlayerAbilityType.UseInertialessDrive -> { + a(href = "#") { + +"Activate Inertialess Drive (${ship.remainingInertialessDriveJumps})" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } + is PlayerAbilityType.DisruptionPulse -> { + a(href = "#") { + +"Activate Strike-Craft Disruption Pulse (${ship.remainingDisruptionPulseEmissions})" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } + is PlayerAbilityType.BoardingParty -> { + a(href = "#") { + +"Board Enemy Vessel" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } + is PlayerAbilityType.RepairShipModule -> { + a(href = "#") { + +"Repair ${ability.module.getDisplayName(ship.ship)}" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } + is PlayerAbilityType.ExtinguishFire -> { + a(href = "#") { + +"Extinguish Fire" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } + is PlayerAbilityType.Recoalesce -> { + a(href = "#") { + +"Activate Recoalescence" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + br + } + } + } + + for (ability in combatAbilities) { + br + + val weaponInstance = ship.armaments.getValue(ability.weapon) + + val weaponVerb = if (weaponInstance is ShipWeaponInstance.Hangar) "Release" else "Fire" + val weaponDesc = weaponInstance.displayName + + when (ability) { + is PlayerAbilityType.ChargeLance -> { + a(href = "#") { + +"Charge $weaponDesc" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + } + is PlayerAbilityType.UseWeapon -> { + a(href = "#") { + +"$weaponVerb $weaponDesc" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + } + is PlayerAbilityType.RecallStrikeCraft -> { + a(href = "#") { + +"Recall $weaponDesc" + onClickFunction = { e -> + e.preventDefault() + responder.useAbility(ability) + } + } + } + } + } + } + } + } + + p { + style = "height:9%;margin:0" + + hr { style = "border-color:#555" } + + val finishPhase = abilities.filterIsInstance().singleOrNull() + if (finishPhase != null) + a(href = "#") { + +"End Phase" + id = "done-phase" + + onClickFunction = { e -> + e.preventDefault() + finalizePhase(finishPhase, abilities) + } + } + else + span { + style = "color:#333;cursor:not-allowed" + +"End Phase" + } + } + } + } + + fun changeShipSelection(gameState: GameState, selectedId: Id?) { + topRightBar.clear() + topRightBar.append { + drawShipActions(gameState, selectedId) + } + } + + private fun updateAbilityData(gameState: GameState) { + window.requestAnimationFrame { + changeShipSelection(gameState, selectedShip.value) + } + } + + private fun finalizePhase(finishPhase: PlayerAbilityType.DonePhase, otherAbilities: List) { + val donePhase = document.getElementById("done-phase").unsafeCast() + donePhase.replaceWith(document.create.span { + style = "color:#333;cursor:not-allowed" + +"Waiting..." + }) + + var shouldWait = false + + when (finishPhase.phase) { + is GamePhase.Power -> { + val powerAbilities = otherAbilities.filterIsInstance().associateBy { it.ship } + + for (shipId in ClientAbilityData.newShipPowerModes.keys) { + val powerAbility = powerAbilities[shipId] ?: continue + responder.useAbility(powerAbility) + + shouldWait = true + } + + ClientAbilityData.newShipPowerModes.clear() + } + else -> { + // nothing needs to be done + } + } + + if (shouldWait) + window.setTimeout({ + responder.useAbility(finishPhase) + }, 500) + else + responder.useAbility(finishPhase) + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/loaders.kt b/src/jsMain/kotlin/net/starshipfights/game/loaders.kt new file mode 100644 index 0000000..3c0f45b --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/loaders.kt @@ -0,0 +1,107 @@ +package net.starshipfights.game + +import com.juul.indexeddb.Database +import com.juul.indexeddb.Key +import com.juul.indexeddb.KeyPath +import com.juul.indexeddb.openDatabase +import externals.threejs.* +import kotlinx.browser.window +import kotlinx.coroutines.async +import kotlinx.coroutines.await +import kotlinx.coroutines.coroutineScope +import kotlin.js.Date + +const val MESH_PATH = "/static/game/meshes/" + +private val fileLoader: FileLoader + get() = FileLoader() + .setPath(MESH_PATH) + .setResourcePath(MESH_PATH) + .unsafeCast() + +private val mtlLoader: MTLLoader + get() = MTLLoader() + .setPath(MESH_PATH) + .setResourcePath(MESH_PATH) + .unsafeCast() + +private val meshLoader: OBJLoader + get() = OBJLoader() + .setPath(MESH_PATH) + .setResourcePath(MESH_PATH) + .unsafeCast() + +suspend fun loadModel(name: String): Mesh { + val (mtlText, objText) = coroutineScope { + val mtlAsync = async { loadCacheEntry("mtl", name, ::cacheMissHandler).content!! } + val objAsync = async { loadCacheEntry("obj", name, ::cacheMissHandler).content!! } + + mtlAsync.await() to objAsync.await() + } + val mtl = mtlLoader.parse(mtlText, MESH_PATH) + mtl.preload() + + return meshLoader + .setMaterials(mtl) + .parse(objText) + .children + .single { it.type == "Mesh" } + .unsafeCast() + .apply { removeFromParent() } +} + +private val textureLoader: TextureLoader + get() = TextureLoader() + .setPath("/static/game/textures/") + .setResourcePath("/static/game/textures/") + .unsafeCast() + +suspend fun loadTexture(name: String): Texture = textureLoader.loadAsync("$name.png").await() + +private var database: Database? = null +private var cacheTime: Double = 0.0 + +private external interface CacheEntry { + var name: String? + var content: String? + var updated: Double? +} + +private suspend fun cacheMissHandler(collection: String, name: String) = configure { + this.name = name + this.content = fileLoader.loadAsync("$name.$collection").await().unsafeCast() + this.updated = Date.now() +} + +private suspend fun loadCacheEntry(collection: String, name: String, onMiss: suspend (String, String) -> CacheEntry): CacheEntry { + return database?.let { db -> + db.transaction(collection) { + objectStore(collection).getAll(Key(name)).singleOrNull()?.unsafeCast() + }?.takeIf { entry -> + entry.updated?.let { updated -> updated >= cacheTime } ?: false + } ?: onMiss(collection, name).also { entry -> + db.writeTransaction(collection) { + objectStore(collection).put(entry) + } + } + } ?: onMiss(collection, name) +} + +suspend fun initResCache() { + cacheTime = window.fetch("/cache-time").await().text().await().toDouble() + + database = try { + openDatabase("resource-cache", 1) { database, oldVersion, newVersion -> + if (oldVersion < 1) { + database.createObjectStore("obj", KeyPath("name")) + database.createObjectStore("mtl", KeyPath("name")) + } + } + } catch (ex: Throwable) { + ex.printStackTrace() + null + } catch (ex: dynamic) { + console.error(ex) + null + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/pick_bounds_js.kt b/src/jsMain/kotlin/net/starshipfights/game/pick_bounds_js.kt new file mode 100644 index 0000000..ad794c5 --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/pick_bounds_js.kt @@ -0,0 +1,413 @@ +package net.starshipfights.game + +import externals.threejs.* +import kotlinx.browser.window +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.w3c.dom.events.KeyboardEvent +import org.w3c.dom.events.MouseEvent +import kotlin.coroutines.resume +import kotlin.math.PI + +private val validColor = Color("#3399ff") +private val invalidColor = Color("#ff6666") + +private fun PickHelper.generateHologram(): Group = when (this) { + PickHelper.None -> Group() + is PickHelper.Ship -> { + Group().also { + it.add(RenderResources.shipHologramFactory.generate(this)) + RenderScaling.toWorldRotation(facing, it) + } + } + is PickHelper.Circle -> { + val shape = Shape() + .ellipse( + 0, + 0, + RenderScaling.toWorldLength(radius), + RenderScaling.toWorldLength(radius), + 0, + 2 * PI, + false, + 0 + ) + .unsafeCast() + val geometry = ShapeGeometry(shape) + + val material = MeshBasicMaterial(configure { + side = DoubleSide + + color = "#AAAAAA" + + depthWrite = false + + blending = CustomBlending + blendEquation = AddEquation + blendSrc = OneFactor + blendDst = OneMinusSrcColorFactor + + userData = "screen" + }) + + Group().apply { + add(Mesh(geometry, material).apply { + rotateX(PI / 2) + }) + } + } +} + +private fun Line.drawLocation(drawFrom: Position?, drawTo: Position?) { + if (drawFrom == null || drawTo == null) { + geometry.setFromPoints(emptyArray()) + return + } + + geometry.setFromPoints( + arrayOf( + RenderScaling.toWorldPosition(drawFrom), + RenderScaling.toWorldPosition(drawTo), + ) + ) +} + +private fun Object3D.setLocation(context: PickContext, request: PickRequest, location: Position?) { + if (children.isEmpty()) return + + if (location == null) { + visible = false + return + } + + visible = true + position.copy(RenderScaling.toWorldPosition(location)) + + val response = PickResponse.Location(location) + + val material = children.single().unsafeCast().material.unsafeCast() + val materialColor = if (context.getGameState().isValidPick(request, response)) + validColor + else + invalidColor + + if (material.userData == "hologram") + material.unsafeCast().uniforms["glowColor"]!!.value = materialColor + else + material.unsafeCast().color = materialColor +} + +private fun Raycaster.intersectXZPlane(pickRequest: PickRequest): Position? { + val denominator = -ray.direction.y.toDouble() + if (denominator > EPSILON) { + val t = ray.origin.y.toDouble() / denominator + if (t >= 0) { + val worldPos = Vector3().add(ray.direction).multiplyScalar(t).add(ray.origin) + return pickRequest.boundary.normalize(RenderScaling.toBattlePosition(worldPos)) + } + } + + return null +} + +private var handleCanvasMouseMove: (MouseEvent) -> Unit = { _ -> } +private var handleCanvasMouseDown: (MouseEvent) -> Boolean = { _ -> false } +private var handleWindowEscapeKey: (KeyboardEvent) -> Unit = { _ -> } + +fun Raycaster.initializeFromMouse(camera: Camera) { + setFromCamera(configure { + x = mouseLocation.x + y = mouseLocation.y + }, camera) +} + +private var mouseLocation: Vector2 = Vector2(0, 0) + +fun initializePicking() { + threeCanvas.addEventListener("mousemove", { e -> + val me = e.unsafeCast() + + val normalX = (me.clientX.toDouble() / window.innerWidth) * 2 - 1 + val normalY = 1 - (me.clientY.toDouble() / window.innerHeight) * 2 + mouseLocation = Vector2(normalX, normalY) + + handleCanvasMouseMove(me) + }) + + threeCanvas.addEventListener("mousedown", { e -> + val me = e.unsafeCast() + if (me.button.toInt() == 0) + if (handleCanvasMouseDown(me)) + me.stopImmediatePropagation() + }) + + window.addEventListener("keydown", { e -> + val ke = e.unsafeCast() + if (ke.key == "Escape") + handleWindowEscapeKey(ke) + }) +} + +data class PickContext( + val threeScene: Scene, + val threeCamera: Camera, + val getGameState: () -> GameState, +) + +private fun Position?.toIntersectionArray(): Array = listOfNotNull( + this?.let { pos -> + configure { + point = RenderScaling.toWorldPosition(pos) + } + } +).toTypedArray() + +private fun PickRequest.verify(context: PickContext, intersections: Array): PickResponse? { + return when (type) { + is PickType.Location -> { + val intersection3d = (intersections.firstOrNull() ?: return null).point + val intersection = RenderScaling.toBattlePosition(intersection3d) + val pickResponse = PickResponse.Location(boundary.normalize(intersection)) + + pickResponse.takeIf { context.getGameState().isValidPick(this, it) } + } + is PickType.Ship -> { + val shipId = (intersections.firstOrNull() ?: return null).`object`.userData.unsafeCast().shipId + val pickResponse = PickResponse.Ship(shipId) + + pickResponse.takeIf { context.getGameState().isValidPick(this, it) } + } + } +} + +var isPicking: Boolean = false + private set + +private fun beginPick(context: PickContext, pickRequest: PickRequest, responseHandler: (PickResponse?) -> Unit) { + isPicking = true + + if (pickRequest.boundary is PickBoundary.AlongLine) { + val pointA = RenderScaling.toWorldPosition(pickRequest.boundary.pointA) + val pointB = RenderScaling.toWorldPosition(pickRequest.boundary.pointB) + val bufferGeometry = BufferGeometry().setFromPoints(arrayOf(pointA, pointB)) + val material = LineBasicMaterial(configure { + color = "#4477DD" + }) + + val line = Line(bufferGeometry, material) + line.name = "bound" + context.threeScene.add(line) + } else { + val meshGroup = Group().also { + it.position.set(0, -0.01, 0) + + it.name = "bound" + } + + for (shape in pickRequest.boundary.render()) { + val shapeGeometry = ShapeGeometry(shape) + val material = MeshBasicMaterial(configure { + side = DoubleSide + + color = "#3366CC" + + depthWrite = false + + blending = CustomBlending + blendEquation = AddEquation + blendSrc = OneFactor + blendDst = OneMinusSrcColorFactor + }) + + val mesh = Mesh(shapeGeometry, material) + mesh.rotateX(PI / 2) + + meshGroup.add(Group().apply { + RenderScaling.toWorldRotation(PI / 2, this) + add(mesh) + }) + } + + context.threeScene.add(meshGroup) + } + + val raycaster = Raycaster() + + when (pickRequest.type) { + is PickType.Location -> { + raycaster.initializeFromMouse(context.threeCamera) + val firstLocation = raycaster.intersectXZPlane(pickRequest) + + val pickHelperMesh = pickRequest.type.helper.generateHologram() + pickHelperMesh.name = "pick-helper" + pickHelperMesh.setLocation(context, pickRequest, firstLocation) + + val drawnLine = Line(BufferGeometry(), LineBasicMaterial(configure { + color = "#6699FF" + })).apply { + name = "pick-line" + } + drawnLine.drawLocation(pickRequest.type.drawLineFrom, firstLocation) + + context.threeScene.add(pickHelperMesh, drawnLine) + + handleCanvasMouseMove = { _ -> + raycaster.initializeFromMouse(context.threeCamera) + + val location = raycaster.intersectXZPlane(pickRequest) + + pickHelperMesh.setLocation(context, pickRequest, location) + drawnLine.drawLocation(pickRequest.type.drawLineFrom, location) + + threeCanvas.style.cursor = if (pickRequest.verify(context, location.toIntersectionArray()) != null) + "pointer" + else "not-allowed" + } + + handleCanvasMouseDown = { _ -> + raycaster.initializeFromMouse(context.threeCamera) + + val location = raycaster.intersectXZPlane(pickRequest) + + pickHelperMesh.setLocation(context, pickRequest, location) + drawnLine.drawLocation(pickRequest.type.drawLineFrom, location) + + responseHandler(pickRequest.verify(context, location.toIntersectionArray())) + true + } + } + is PickType.Ship -> { + val ships = context.threeScene.getObjectByName("ships").unsafeCast() + + handleCanvasMouseMove = { _ -> + raycaster.initializeFromMouse(context.threeCamera) + + val intersections = raycaster.intersectObjects(ships.children, true) + + threeCanvas.style.cursor = if (pickRequest.verify(context, intersections) != null) + "pointer" + else "not-allowed" + } + + handleCanvasMouseDown = { _ -> + raycaster.initializeFromMouse(context.threeCamera) + + val intersections = raycaster.intersectObjects(ships.children, true) + + responseHandler(pickRequest.verify(context, intersections)) + true + } + } + } + + handleWindowEscapeKey = { responseHandler(null) } + + GameUI.currentHelpMessage = "Press Escape to cancel current action" +} + +private fun endPick(scene: Scene) { + isPicking = false + + handleCanvasMouseMove = { _ -> } + handleCanvasMouseDown = { _ -> false } + handleWindowEscapeKey = { _ -> } + + threeCanvas.style.cursor = "auto" + + scene.getObjectByName("bound")?.removeFromParent() + scene.getObjectByName("pick-helper")?.removeFromParent() + scene.getObjectByName("pick-line")?.removeFromParent() + + GameUI.currentHelpMessage = "" +} + +private val pickMutex = Mutex() +suspend fun PickRequest.pick(context: PickContext): PickResponse? = pickMutex.withLock { + suspendCancellableCoroutine { cancellableContinuation -> + beginPick(context, this) { + endPick(context.threeScene) + cancellableContinuation.resume(it) + } + + cancellableContinuation.invokeOnCancellation { + endPick(context.threeScene) + } + } +} + +private fun PickBoundary.render(): List { + return when (this) { + is PickBoundary.Angle -> { + val position = center.vector + + val startTheta = (normalVector(midAngle) rotatedBy -maxAngle).angle + val endTheta = (normalVector(midAngle) rotatedBy maxAngle).angle + + val innerDistance = 275.0 + val outerDistance = 350.0 + + val startVec = polarVector(innerDistance, startTheta) + position + + listOf( + Shape() + .moveTo(RenderScaling.toWorldLength(startVec.x), RenderScaling.toWorldLength(startVec.y)) + .absarc(RenderScaling.toWorldLength(position.x), RenderScaling.toWorldLength(position.y), RenderScaling.toWorldLength(innerDistance), startTheta, endTheta, false) + .absarc(RenderScaling.toWorldLength(position.x), RenderScaling.toWorldLength(position.y), RenderScaling.toWorldLength(outerDistance), endTheta, startTheta, true) + .unsafeCast() + ) + } + is PickBoundary.AlongLine -> emptyList() // Handled in a special case + is PickBoundary.Rectangle -> listOf( + Shape() + .moveTo(RenderScaling.toWorldLength(center.vector.x + width2), RenderScaling.toWorldLength(center.vector.y + length2)) + .lineTo(RenderScaling.toWorldLength(center.vector.x - width2), RenderScaling.toWorldLength(center.vector.y + length2)) + .lineTo(RenderScaling.toWorldLength(center.vector.x - width2), RenderScaling.toWorldLength(center.vector.y - length2)) + .lineTo(RenderScaling.toWorldLength(center.vector.x + width2), RenderScaling.toWorldLength(center.vector.y - length2)) + .unsafeCast() + ) + is PickBoundary.Circle -> listOf( + Shape() + .ellipse( + RenderScaling.toWorldLength(center.vector.x), + RenderScaling.toWorldLength(center.vector.y), + RenderScaling.toWorldLength(radius), + RenderScaling.toWorldLength(radius), + 0, + 2 * PI, + false, + 0 + ) + .unsafeCast() + ) + is PickBoundary.WeaponsFire -> firingArcs.map { firingArc -> + val position = center.vector + + val startTheta = firingArc.getStartAngle(facing) + val endTheta = firingArc.getEndAngle(facing) + + val startPosMin = polarVector(minDistance, startTheta) + position + + Shape() + .moveTo(RenderScaling.toWorldLength(startPosMin.x), RenderScaling.toWorldLength(startPosMin.y)) + .absarc(RenderScaling.toWorldLength(position.x), RenderScaling.toWorldLength(position.y), RenderScaling.toWorldLength(minDistance), startTheta, endTheta, false) + .absarc(RenderScaling.toWorldLength(position.x), RenderScaling.toWorldLength(position.y), RenderScaling.toWorldLength(maxDistance), endTheta, startTheta, true) + .unsafeCast() + } + (if (canSelfSelect) + listOf( + Shape() + .ellipse( + RenderScaling.toWorldLength(center.vector.x), + RenderScaling.toWorldLength(center.vector.y), + RenderScaling.toWorldLength(SHIP_BASE_SIZE), + RenderScaling.toWorldLength(SHIP_BASE_SIZE), + 0, + 2 * PI, + false, + 0 + ) + .unsafeCast() + ) + else emptyList()) + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/popup.kt b/src/jsMain/kotlin/net/starshipfights/game/popup.kt new file mode 100644 index 0000000..931508a --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/popup.kt @@ -0,0 +1,525 @@ +package net.starshipfights.game + +import kotlinx.browser.document +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.dom.addClass +import kotlinx.dom.clear +import kotlinx.dom.removeClass +import kotlinx.html.* +import kotlinx.html.dom.append +import kotlinx.html.js.onClickFunction +import org.w3c.dom.HTMLDivElement +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.resume + +sealed class Popup { + protected abstract fun TagConsumer<*>.render(context: CoroutineContext, callback: (T) -> Unit) + private fun renderInto(consumer: TagConsumer<*>, context: CoroutineContext, callback: (T) -> Unit) { + consumer.render(context, callback) + } + + suspend fun display(): T = popupMutex.withLock { + suspendCancellableCoroutine { continuation -> + popupBox.clear() + + popupBox.append { + renderInto(this, continuation.context) { + hide() + continuation.resume(it) + } + } + + continuation.invokeOnCancellation { + hide() + } + + show() + } + } + + companion object { + private val popupMutex = Mutex() + + private val popup by lazy { + document.getElementById("popup").unsafeCast() + } + + private val popupBox by lazy { + document.getElementById("popup-box").unsafeCast() + } + + private fun show() { + popup.removeClass("hide") + } + + private fun hide() { + popup.addClass("hide") + } + } + + class ChooseAdmiralScreen(private val admirals: List) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (InGameAdmiral) -> Unit) { + if (admirals.isEmpty()) { + p { + style = "text-align:center" + +"You do not have any admirals! You can fix that by " + a("/admiral/new") { +"creating one" } + +"." + } + return + } + + p { + style = "text-align:center" + + +"Select one of your admirals to continue:" + } + + div(classes = "button-set col") { + for (admiral in admirals) { + button { + +admiral.fullName + +Entities.nbsp + img(alt = "(${admiral.faction.shortName})", src = admiral.faction.flagUrl) { + style = "width:1.2em;height:0.75em" + } + + onClickFunction = { e -> + e.preventDefault() + + callback(admiral) + } + } + } + } + + p { + style = "text-align:center" + + +"Or return to " + a(href = "/me") { +"your user page" } + +"." + } + } + } + + class MainMenuScreen(private val admiralInfo: InGameAdmiral) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (MainMenuOption?) -> Unit) { + p { + style = "text-align:center" + + img(alt = "Starship Fights", src = RenderResources.LOGO_URL) { + style = "width:70%" + } + } + + p { + style = "text-align:center" + + +"Welcome to Starship Fights! You are " + +admiralInfo.fullName + +", fighting for " + +admiralInfo.faction.getDefiniteShortName() + +". " + a(href = "#") { + +"Not you?" + onClickFunction = { e -> + e.preventDefault() + callback(null) + } + } + } + + div(classes = "button-set col") { + button { + +"Play Singleplayer Battle" + onClickFunction = { e -> + e.preventDefault() + + callback(MainMenuOption.Singleplayer) + } + } + button { + +"Host Multiplayer Battle" + onClickFunction = { e -> + e.preventDefault() + + callback(MainMenuOption.Multiplayer(GlobalSide.HOST)) + } + } + button { + +"Join Multiplayer Battle" + onClickFunction = { e -> + e.preventDefault() + + callback(MainMenuOption.Multiplayer(GlobalSide.GUEST)) + } + } + } + } + } + + class ChooseBattleSizeScreen(private val maxBattleSize: BattleSize) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (BattleSize?) -> Unit) { + p { + style = "text-align:center" + + +"Select a battle size" + } + + div(classes = "button-set col") { + for (battleSize in BattleSize.values()) { + if (battleSize <= maxBattleSize) + button { + +battleSize.displayName + +" (" + +battleSize.numPoints.toString() + +")" + onClickFunction = { e -> + e.preventDefault() + callback(battleSize) + } + } + } + button { + +"Cancel" + onClickFunction = { e -> + e.preventDefault() + callback(null) + } + } + } + } + } + + object ChooseBattleBackgroundScreen : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (BattleBackground?) -> Unit) { + p { + style = "text-align:center" + + +"Select a battle background" + } + + div(classes = "button-set col") { + for (bg in BattleBackground.values()) { + button { + +bg.displayName + onClickFunction = { e -> + e.preventDefault() + callback(bg) + } + } + } + button { + +"Cancel" + onClickFunction = { e -> + e.preventDefault() + callback(null) + } + } + } + } + } + + object ChooseEnemyFactionScreen : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (AIFactionChoice?) -> Unit) { + p { + style = "text-align:center" + + +"Select an enemy faction" + } + + div(classes = "button-set col") { + button { + +"Random" + onClickFunction = { e -> + e.preventDefault() + callback(AIFactionChoice.Random) + } + } + for (faction in Faction.values()) { + button { + +faction.navyName + +Entities.nbsp + img(alt = faction.shortName, src = faction.flagUrl) { + style = "width:1.2em;height:0.75em" + } + + onClickFunction = { e -> + e.preventDefault() + callback(AIFactionChoice.Chosen(faction)) + } + } + } + button { + +"Cancel" + onClickFunction = { e -> + e.preventDefault() + callback(null) + } + } + } + } + } + + class GuestRequestScreen(private val hostInfo: InGameAdmiral, private val guestInfo: InGameAdmiral) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Boolean?) -> Unit) { + p { + style = "text-align:center" + + +guestInfo.fullName + +" (" + +guestInfo.user.username + +") wishes to join your battle." + } + table { + style = "table-layout:fixed;width:100%" + + tr { + th { +"HOST" } + th { +"GUEST" } + } + tr { + td { + style = "text-align:center" + + img(alt = hostInfo.faction.shortName, src = hostInfo.faction.flagUrl) { style = "width:65%;" } + } + td { + style = "text-align:center" + + img(alt = guestInfo.faction.shortName, src = guestInfo.faction.flagUrl) { style = "width:65%;" } + } + } + tr { + td { + style = "text-align:center" + + +hostInfo.fullName + } + td { + style = "text-align:center" + + +guestInfo.fullName + } + } + tr { + td { + style = "text-align:center" + + +"(${hostInfo.user.username})" + } + td { + style = "text-align:center" + + +"(${guestInfo.user.username})" + } + } + } + + div(classes = "button-set row") { + button { + +"Accept" + onClickFunction = { e -> + e.preventDefault() + callback(true) + } + } + button { + +"Reject" + onClickFunction = { e -> + e.preventDefault() + callback(false) + } + } + button { + +"Cancel" + onClickFunction = { e -> + e.preventDefault() + callback(null) + } + } + } + } + } + + class HostSelectScreen(private val hosts: Map) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (String?) -> Unit) { + table { + style = "table-layout:fixed;width:100%" + + tr { + th { +"Host Player" } + th { +"Host Admiral" } + th { +"Host Faction" } + th { +"Battle Size" } + th { +"Battle Background" } + th { +Entities.nbsp } + } + for ((id, joinable) in hosts) { + tr { + td { + style = "text-align:center" + + +joinable.admiral.user.username + } + td { + style = "text-align:center" + + +joinable.admiral.fullName + } + td { + style = "text-align:center" + + img(alt = joinable.admiral.faction.shortName, src = joinable.admiral.faction.flagUrl) { + style = "width:4em;height:2.5em" + } + } + td { + style = "text-align:center" + + +joinable.battleInfo.size.displayName + +" (" + +joinable.battleInfo.size.numPoints.toString() + +")" + } + td { + style = "text-align:center" + + +joinable.battleInfo.bg.displayName + } + td { + style = "text-align:center" + + a(href = "#") { + +"Join" + onClickFunction = { e -> + e.preventDefault() + callback(id) + } + } + } + } + } + tr { + td { +Entities.nbsp } + td { +Entities.nbsp } + td { +Entities.nbsp } + td { +Entities.nbsp } + td { +Entities.nbsp } + td { + style = "text-align:center" + + a(href = "#") { + +"Cancel" + onClickFunction = { e -> + e.preventDefault() + callback(null) + } + } + } + } + } + } + } + + class JoinRejectedScreen(private val hostInfo: InGameAdmiral) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Unit) -> Unit) { + p { + style = "text-align:center" + + +hostInfo.fullName + +" has rejected your request to join." + } + + div(classes = "button-set row") { + button { + +"Continue" + onClickFunction = { e -> + e.preventDefault() + callback(Unit) + } + } + } + } + } + + class LoadingScreen(private val loadingText: String, private val loadAction: suspend () -> T) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (T) -> Unit) { + p { + style = "text-align:center" + + +loadingText + } + + AppScope.launch(context) { + callback(loadAction()) + } + } + } + + class CancellableLoadingScreen(private val loadingText: String, private val loadAction: suspend () -> T) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (T?) -> Unit) { + p { + style = "text-align:center" + + +loadingText + } + + val loading = AppScope.launch(context) { + callback(loadAction()) + } + + div(classes = "button-set row") { + button { + +"Cancel" + onClickFunction = { e -> + e.preventDefault() + + loading.cancel() + callback(null) + } + } + } + } + } + + class Error(private val errorMessage: String) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Nothing) -> Unit) { + p(classes = "error") { + style = "text-align:center" + + +errorMessage + } + } + } + + class GameOver(private val winner: GlobalSide?, private val outcome: String, private val subplotStatuses: Map, private val finalState: GameState) : Popup() { + override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Nothing) -> Unit) { + p { + style = "text-align:center" + + strong(classes = "heading") { + +"${victoryTitle(mySide, winner, subplotStatuses)}!" + } + } + p { + style = "text-align:center" + + +outcome + } + p { + style = "text-align:center" + + val admiralId = finalState.admiralInfo(mySide).id + + a(href = "/admiral/${admiralId}") { + +"Exit Battle" + } + } + } + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/popup_util.kt b/src/jsMain/kotlin/net/starshipfights/game/popup_util.kt new file mode 100644 index 0000000..548254d --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/popup_util.kt @@ -0,0 +1,46 @@ +package net.starshipfights.game + +sealed class MainMenuOption { + object Singleplayer : MainMenuOption() + + data class Multiplayer(val side: GlobalSide) : MainMenuOption() +} + +sealed class AIFactionChoice { + object Random : AIFactionChoice() + + data class Chosen(val faction: Faction) : AIFactionChoice() +} + +private suspend fun Popup.Companion.getPlayerInfo(admirals: List): InGameAdmiral { + return Popup.ChooseAdmiralScreen(admirals).display() +} + +private suspend fun Popup.Companion.getBattleInfo(admiral: InGameAdmiral): BattleInfo? { + val battleSize = Popup.ChooseBattleSizeScreen(admiral.rank.maxBattleSize).display() ?: return null + val battleBackground = Popup.ChooseBattleBackgroundScreen.display() ?: return getBattleInfo(admiral) + return BattleInfo(battleSize, battleBackground) +} + +private suspend fun Popup.Companion.getTrainingInfo(admiral: InGameAdmiral): LoginMode? { + val battleInfo = getBattleInfo(admiral) ?: return getLoginMode(admiral) + val faction = Popup.ChooseEnemyFactionScreen.display() ?: return getLoginMode(admiral) + return LoginMode.Train(battleInfo, (faction as? AIFactionChoice.Chosen)?.faction) +} + +private suspend fun Popup.Companion.getLoginMode(admiral: InGameAdmiral): LoginMode? { + val mainMenuOption = Popup.MainMenuScreen(admiral).display() ?: return null + return when (mainMenuOption) { + MainMenuOption.Singleplayer -> getTrainingInfo(admiral) + is MainMenuOption.Multiplayer -> when (mainMenuOption.side) { + GlobalSide.HOST -> LoginMode.Host(getBattleInfo(admiral) ?: return getLoginMode(admiral)) + GlobalSide.GUEST -> LoginMode.Join + } + } +} + +suspend fun Popup.Companion.getPlayerLogin(admirals: List, cachedAdmiral: InGameAdmiral? = null): PlayerLogin { + val admiral = cachedAdmiral ?: getPlayerInfo(admirals) + val loginMode = getLoginMode(admiral) ?: return getPlayerLogin(admirals, null) + return PlayerLogin(admiral.id, loginMode) +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/ship_selecting.kt b/src/jsMain/kotlin/net/starshipfights/game/ship_selecting.kt new file mode 100644 index 0000000..341a256 --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/ship_selecting.kt @@ -0,0 +1,55 @@ +package net.starshipfights.game + +import externals.threejs.Group +import externals.threejs.Raycaster +import kotlinx.browser.document +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import net.starshipfights.data.Id +import org.w3c.dom.HTMLCanvasElement +import org.w3c.dom.events.MouseEvent + +val threeCanvas = document.getElementById("three-canvas").unsafeCast() + +private val selectedShipMutable = MutableStateFlow?>(null) +val selectedShip = selectedShipMutable.asStateFlow() + +var isSelecting: Boolean = false + private set + +fun beginSelecting(context: PickContext) { + if (isSelecting) return + isSelecting = true + + val raycaster = Raycaster() + + val ships = context.threeScene.getObjectByName("ships").unsafeCast() + + threeCanvas.addEventListener("mousedown", { e -> + if (isPicking) return@addEventListener + if (e.unsafeCast().button.toInt() != 0) return@addEventListener + + raycaster.initializeFromMouse(context.threeCamera) + val intersections = raycaster.intersectObjects(ships.children, true) + selectedShipMutable.value = intersections.firstOrNull()?.`object`?.userData.unsafeCast()?.shipId + }) +} + +suspend fun handleSelections(context: PickContext) { + val ships = context.threeScene.getObjectByName("ships").unsafeCast() + + var prevId: Id? = null + selectedShip.collect { shipId -> + prevId?.let { id -> + ships.getObjectByName(id.toString())?.userData.unsafeCast()?.shipOutline?.visible = false + } + + shipId?.let { id -> + ships.getObjectByName(id.toString())?.userData.unsafeCast()?.shipOutline?.visible = true + } + + prevId = shipId + + GameUI.changeShipSelection(context.getGameState(), shipId) + } +} diff --git a/src/jsMain/kotlin/net/starshipfights/game/util_js.kt b/src/jsMain/kotlin/net/starshipfights/game/util_js.kt new file mode 100644 index 0000000..0897c54 --- /dev/null +++ b/src/jsMain/kotlin/net/starshipfights/game/util_js.kt @@ -0,0 +1,73 @@ +package net.starshipfights.game + +import io.ktor.http.cio.websocket.* +import kotlinx.browser.window +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.isActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy +import org.w3c.dom.Window +import org.w3c.dom.events.Event +import org.w3c.dom.events.EventListener +import org.w3c.dom.events.EventTarget +import kotlin.coroutines.resume + +inline fun configure(builder: T.() -> Unit) = js("{}").unsafeCast().apply(builder) + +val Window.aspectRatio: Double + get() = innerWidth.toDouble() / innerHeight + +suspend fun EventTarget.awaitEvent(eventName: String, shouldPreventDefault: Boolean = false): Event = suspendCancellableCoroutine { continuation -> + val listener = object : EventListener { + override fun handleEvent(event: Event) { + if (shouldPreventDefault) + event.preventDefault() + + removeEventListener(eventName, this) + continuation.resume(event) + } + } + + continuation.invokeOnCancellation { + removeEventListener(eventName, listener) + } + + addEventListener(eventName, listener) +} + +suspend fun awaitAnimationFrame(): Double = suspendCancellableCoroutine { continuation -> + val handle = window.requestAnimationFrame { t -> + continuation.resume(t) + } + + continuation.invokeOnCancellation { + window.cancelAnimationFrame(handle) + } +} + +val deltaTimeFlow: Flow + get() = flow { + var prevTime = awaitAnimationFrame() + while (currentCoroutineContext().isActive) { + val currTime = awaitAnimationFrame() + emit((currTime - prevTime) / 1000.0) + prevTime = currTime + } + } + +suspend inline fun DefaultWebSocketSession.receiveObject(serializer: DeserializationStrategy, exitOnError: () -> Nothing): T { + val text = incoming.receiveAsFlow().filterIsInstance().firstOrNull()?.readText() ?: exitOnError() + return jsonSerializer.decodeFromString(serializer, text) +} + +suspend inline fun DefaultWebSocketSession.sendObject(serializer: SerializationStrategy, value: T) { + outgoing.send(Frame.Text(jsonSerializer.encodeToString(serializer, value))) + flush() +} + +suspend inline fun DefaultWebSocketSession.closeAndReturn(closeMessage: String = "", exitFunction: () -> Nothing): Nothing { + close(CloseReason(CloseReason.Codes.NORMAL, closeMessage)) + exitFunction() +} diff --git a/src/jsMain/kotlin/starshipfights/game/ai/util_js.kt b/src/jsMain/kotlin/starshipfights/game/ai/util_js.kt deleted file mode 100644 index be36797..0000000 --- a/src/jsMain/kotlin/starshipfights/game/ai/util_js.kt +++ /dev/null @@ -1,17 +0,0 @@ -package starshipfights.game.ai - -actual fun logDebug(message: Any?) { - console.log(message) -} - -actual fun logInfo(message: Any?) { - console.info(message) -} - -actual fun logWarning(message: Any?) { - console.warn(message) -} - -actual fun logError(message: Any?) { - console.error(message) -} diff --git a/src/jsMain/kotlin/starshipfights/game/client.kt b/src/jsMain/kotlin/starshipfights/game/client.kt deleted file mode 100644 index 5534cc8..0000000 --- a/src/jsMain/kotlin/starshipfights/game/client.kt +++ /dev/null @@ -1,61 +0,0 @@ -package starshipfights.game - -import io.ktor.client.* -import io.ktor.client.engine.js.* -import io.ktor.client.features.websocket.* -import kotlinx.browser.document -import kotlinx.browser.window -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.MainScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.plus -import org.w3c.dom.HTMLScriptElement - -val rootPathWs = "ws" + window.location.origin.removePrefix("http") - -val clientMode: ClientMode = try { - jsonSerializer.decodeFromString( - ClientMode.serializer(), - document.getElementById("sf-client-mode").unsafeCast().text - ) -} catch (ex: Exception) { - ex.printStackTrace() - ClientMode.Error("Invalid client mode sent from server") -} catch (dyn: dynamic) { - console.error(dyn) - ClientMode.Error("Invalid client mode sent from server") -} - -val AppScope = MainScope() + CoroutineExceptionHandler { _, error -> - console.error("Unhandled coroutine exception", error.stackTraceToString()) -} - -val httpClient = HttpClient(Js) { - install(WebSockets) { - pingInterval = 500L - } -} - -fun main() { - window.addEventListener("beforeunload", { e -> - if (interruptExit) { - e.preventDefault() - e.asDynamic().returnValue = "" - } - }) - - AppScope.launch { - Popup.LoadingScreen("Loading resources...") { - RenderResources.load(clientMode.isSmallLoad) - }.display() - - when (clientMode) { - is ClientMode.MatchmakingMenu -> matchmakingMain(clientMode.admirals) - is ClientMode.InTrainingGame -> trainingMain(clientMode.initialState) - is ClientMode.InGame -> gameMain(clientMode.playerSide, clientMode.connectToken, clientMode.initialState) - is ClientMode.Error -> errorMain(clientMode.message) - } - } -} - -var interruptExit: Boolean = false diff --git a/src/jsMain/kotlin/starshipfights/game/client_error.kt b/src/jsMain/kotlin/starshipfights/game/client_error.kt deleted file mode 100644 index 0618cc6..0000000 --- a/src/jsMain/kotlin/starshipfights/game/client_error.kt +++ /dev/null @@ -1,11 +0,0 @@ -package starshipfights.game - -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch - -suspend fun errorMain(message: String) { - coroutineScope { - launch { setupBackground() } - launch { Popup.Error(message).display() } - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/client_game.kt b/src/jsMain/kotlin/starshipfights/game/client_game.kt deleted file mode 100644 index 2a43721..0000000 --- a/src/jsMain/kotlin/starshipfights/game/client_game.kt +++ /dev/null @@ -1,204 +0,0 @@ -package starshipfights.game - -import externals.threejs.* -import io.ktor.client.features.websocket.* -import kotlinx.browser.document -import kotlinx.browser.window -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlin.math.PI - -class GameNetworkInteraction( - val gameState: MutableStateFlow, - val playerActions: ReceiveChannel, - val errorMessages: SendChannel -) - -class GameRenderInteraction( - val gameState: StateFlow, - val playerActions: SendChannel, - val errorMessages: ReceiveChannel -) - -lateinit var mySide: GlobalSide - -private val pickContextDeferred = CompletableDeferred() - -suspend fun GameRenderInteraction.execute(scope: CoroutineScope) { - GameUI.initGameUI(scope.uiResponder(playerActions)) - - GameUI.drawGameUI(gameState.value) - - val gameStart = gameState.value.start - val playerStart = gameStart.playerStart(mySide) - - val camera = PerspectiveCamera(69, window.aspectRatio, 0.01, 1_000) - - camera.rotateX(PI / 4) - RenderScaling.toWorldRotation(playerStart.cameraFacing, camera) - - val renderer = WebGLRenderer(configure { - canvas = document.getElementById("three-canvas") - antialias = true - }) - - renderer.setPixelRatio(window.devicePixelRatio) - renderer.setSize(window.innerWidth, window.innerHeight) - - renderer.shadowMap.enabled = true - renderer.shadowMap.type = VSMShadowMap - - val scene = Scene() - val battleGrid = RenderResources.battleGrid.generate(gameStart.battlefieldWidth to gameStart.battlefieldLength) - scene.add(battleGrid) - scene.add(camera) - - val cameraControls = CameraControls(camera, configure { - domElement = renderer.domElement - keyDomElement = window - - cameraXBound = RenderScaling.toWorldLength(gameStart.battlefieldLength / 2) - cameraZBound = RenderScaling.toWorldLength(gameStart.battlefieldWidth / 2) - }) - - cameraControls.cameraParent.position.copy(RenderScaling.toWorldPosition(playerStart.cameraPosition)) - - cameraControls.camera.add(PointLight("#ffffff", 0.3, 60, 1.5)) - cameraControls.camera.add(DirectionalLight("#ffffff", 0.45).apply { - position.setScalar(0) - target = cameraControls.cameraParent - }) - - GameRender.renderGameState(scene, gameState.value) - - window.addEventListener("resize", { - camera.aspect = window.aspectRatio - camera.updateProjectionMatrix() - - renderer.setSize(window.innerWidth, window.innerHeight) - }) - - pickContextDeferred.complete(PickContext(scene, camera) { gameState.value }) - - coroutineScope { - launch { - deltaTimeFlow.collect { dt -> - cameraControls.update(dt) - renderer.render(scene, camera) - GameUI.updateGameUI(cameraControls) - } - } - - launch { - for (errorMessage in errorMessages) - GameUI.displayErrorMessage(errorMessage) - } - - launch { - gameState.collect { state -> - GameRender.renderGameState(scene, state) - GameUI.drawGameUI(state) - - if (state.phase != GamePhase.Deploy) - launch { - val pickContext = pickContextDeferred.await() - beginSelecting(pickContext) - handleSelections(pickContext) - } - } - } - } -} - -private suspend fun GameNetworkInteraction.execute(token: String): GameEvent.GameEnd { - val gameEnd = CompletableDeferred() - - try { - httpClient.webSocket("$rootPathWs/game/$token") { - val opponentJoined = Popup.LoadingScreen("Waiting for opponent to enter...") { - receiveObject(GameBeginning.serializer()) { closeAndReturn { return@LoadingScreen false } }.opponentJoined - }.display() - - if (!opponentJoined) - Popup.GameOver(mySide, "Unfortunately, your opponent never entered the battle.", emptyMap(), gameState.value).display() - - val sendActionsJob = launch { - for (action in playerActions) - launch { - sendObject(PlayerAction.serializer(), action) - } - } - - while (true) { - when (val event = receiveObject(GameEvent.serializer()) { closeAndReturn { return@webSocket sendActionsJob.cancel() } }) { - is GameEvent.StateChange -> { - gameState.value = event.newState - } - is GameEvent.InvalidAction -> { - errorMessages.send(event.message) - } - is GameEvent.GameEnd -> { - gameEnd.complete(event) - closeAndReturn { return@webSocket sendActionsJob.cancel() } - } - } - } - } - } catch (ex: WebSocketException) { - gameEnd.complete(GameEvent.GameEnd(null, "Server closed connection abruptly", emptyMap())) - } - - if (gameEnd.isActive) - gameEnd.complete(GameEvent.GameEnd(null, "Connection closed", emptyMap())) - - return gameEnd.await() -} - -private class GameUIResponderImpl(scope: CoroutineScope, private val actions: SendChannel) : GameUIResponder, CoroutineScope by scope { - override fun doAction(action: PlayerAction) { - launch { - actions.send(action) - } - } - - override fun useAbility(ability: PlayerAbilityType) { - launch { - val ctx = pickContextDeferred.await() - val abilityData = ability.beginOnClient(ctx.getGameState(), mySide) { it.pick(ctx) } ?: return@launch - val action = PlayerAction.UseAbility(ability, abilityData) - actions.send(action) - } - } -} - -private fun CoroutineScope.uiResponder(actions: SendChannel) = GameUIResponderImpl(this, actions) - -suspend fun gameMain(side: GlobalSide, token: String, state: GameState) { - interruptExit = true - - initializePicking() - - mySide = side - - val gameState = MutableStateFlow(state) - val playerActions = Channel(Channel.UNLIMITED) - val errorMessages = Channel(Channel.UNLIMITED) - - val gameConnection = GameNetworkInteraction(gameState, playerActions, errorMessages) - val gameRendering = GameRenderInteraction(gameState, playerActions, errorMessages) - - coroutineScope { - val connectionJob = async { gameConnection.execute(token) } - val renderingJob = launch { gameRendering.execute(this@coroutineScope) } - - val (finalWinner, finalMessage, finalSubplots) = connectionJob.await() - renderingJob.cancel() - - interruptExit = false - Popup.GameOver(finalWinner, finalMessage, finalSubplots, gameState.value).display() - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/client_matchmaking.kt b/src/jsMain/kotlin/starshipfights/game/client_matchmaking.kt deleted file mode 100644 index c110bfa..0000000 --- a/src/jsMain/kotlin/starshipfights/game/client_matchmaking.kt +++ /dev/null @@ -1,160 +0,0 @@ -package starshipfights.game - -import externals.threejs.PerspectiveCamera -import externals.threejs.Scene -import externals.threejs.WebGLRenderer -import io.ktor.client.features.websocket.* -import kotlinx.browser.document -import kotlinx.browser.window -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.html.FormEncType -import kotlinx.html.FormMethod -import kotlinx.html.dom.append -import kotlinx.html.hiddenInput -import kotlinx.html.js.form -import kotlinx.html.style -import starshipfights.data.Id - -suspend fun setupBackground() { - val camera = PerspectiveCamera(69, window.aspectRatio, 0.01, 1_000) - - val renderer = WebGLRenderer(configure { - canvas = document.getElementById("three-canvas") - antialias = true - }) - - renderer.setPixelRatio(window.devicePixelRatio) - renderer.setSize(window.innerWidth, window.innerHeight) - - val scene = Scene() - scene.background = RenderResources.spaceboxes.values.random() - scene.add(camera) - - window.addEventListener("resize", { - camera.aspect = window.aspectRatio - camera.updateProjectionMatrix() - - renderer.setSize(window.innerWidth, window.innerHeight) - }) - - deltaTimeFlow.collect { dt -> - renderer.render(scene, camera) - camera.rotateY(dt * 0.25) - } -} - -private suspend fun enterTraining(admiral: Id, battleInfo: BattleInfo, faction: Faction?): Nothing { - interruptExit = false - - document.body!!.append.form(action = "/train", method = FormMethod.post, encType = FormEncType.applicationXWwwFormUrlEncoded) { - style = "display:none" - hiddenInput { - name = "admiral" - value = admiral.toString() - } - hiddenInput { - name = "battle-size" - value = battleInfo.size.toUrlSlug() - } - hiddenInput { - name = "battle-bg" - value = battleInfo.bg.toUrlSlug() - } - hiddenInput { - name = "enemy-faction" - value = faction?.toUrlSlug() ?: "-random" - } - }.submit() - awaitCancellation() -} - -private suspend fun enterGame(connectToken: String): Nothing { - interruptExit = false - - document.body!!.append.form(action = "/play", method = FormMethod.post, encType = FormEncType.applicationXWwwFormUrlEncoded) { - style = "display:none" - hiddenInput { - name = "token" - value = connectToken - } - }.submit() - awaitCancellation() -} - -private suspend fun usePlayerLogin(admirals: List) { - val playerLogin = Popup.getPlayerLogin(admirals) - val playerLoginSide = playerLogin.login.globalSide - - if (playerLoginSide == null) { - val (battleInfo, enemyFaction) = playerLogin.login as LoginMode.Train - enterTraining(playerLogin.admiral, battleInfo, enemyFaction) - } - - val admiral = admirals.single { it.id == playerLogin.admiral } - - try { - httpClient.webSocket("$rootPathWs/matchmaking") { - sendObject(PlayerLogin.serializer(), playerLogin) - - when (playerLoginSide) { - GlobalSide.HOST -> { - var loadingText = "Awaiting join request..." - - do { - val joinRequest = Popup.CancellableLoadingScreen(loadingText) { - receiveObject(JoinRequest.serializer()) { closeAndReturn { return@CancellableLoadingScreen null } } - }.display() ?: closeAndReturn("Battle hosting cancelled") { return@webSocket } - - val joinAcceptance = Popup.GuestRequestScreen(admiral, joinRequest.joiner).display() ?: closeAndReturn("Battle hosting cancelled") { return@webSocket } - sendObject(JoinResponse.serializer(), JoinResponse(joinAcceptance)) - - val joinConnected = joinAcceptance && receiveObject(JoinResponseResponse.serializer()) { closeAndReturn { return@webSocket } }.connected - - loadingText = if (joinAcceptance) - "${joinRequest.joiner.name} cancelled join. Awaiting join request..." - else - "Awaiting join request..." - } while (!joinConnected) - - val connectToken = receiveObject(GameReady.serializer()) { closeAndReturn { return@webSocket } }.connectToken - enterGame(connectToken) - } - GlobalSide.GUEST -> { - val listOfHosts = receiveObject(JoinListing.serializer()) { closeAndReturn { return@webSocket } }.openGames - - do { - val selectedHost = Popup.HostSelectScreen(listOfHosts).display() ?: closeAndReturn("Battle joining cancelled") { return@webSocket } - sendObject(JoinSelection.serializer(), JoinSelection(selectedHost)) - - val joinAcceptance = Popup.CancellableLoadingScreen("Awaiting join response...") { - receiveObject(JoinResponse.serializer()) { closeAndReturn { return@CancellableLoadingScreen null } }.accepted - }.display() ?: closeAndReturn("Battle joining cancelled") { return@webSocket } - - if (!joinAcceptance) { - val hostInfo = listOfHosts.getValue(selectedHost).admiral - Popup.JoinRejectedScreen(hostInfo).display() - } - } while (!joinAcceptance) - - val connectToken = receiveObject(GameReady.serializer()) { closeAndReturn { return@webSocket } }.connectToken - enterGame(connectToken) - } - } - } - } catch (ex: WebSocketException) { - Popup.Error("Server abruptly closed connection").display() - } - - usePlayerLogin(admirals) -} - -suspend fun matchmakingMain(admirals: List) { - interruptExit = true - - coroutineScope { - launch { setupBackground() } - launch { usePlayerLogin(admirals) } - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/client_training.kt b/src/jsMain/kotlin/starshipfights/game/client_training.kt deleted file mode 100644 index b6f178c..0000000 --- a/src/jsMain/kotlin/starshipfights/game/client_training.kt +++ /dev/null @@ -1,144 +0,0 @@ -package starshipfights.game - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import starshipfights.game.ai.AISession -import starshipfights.game.ai.aiPlayer - -class GameSession(gameState: GameState) { - private val stateMutable = MutableStateFlow(gameState) - private val stateMutex = Mutex() - - val state = stateMutable.asStateFlow() - - private val hostErrorMessages = Channel(Channel.UNLIMITED) - private val guestErrorMessages = Channel(Channel.UNLIMITED) - - private fun errorMessageChannel(player: GlobalSide) = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages - } - - fun errorMessages(player: GlobalSide): ReceiveChannel = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages - } - - private val gameEndMutable = CompletableDeferred() - val gameEnd: Deferred - get() = gameEndMutable - - suspend fun onPacket(player: GlobalSide, packet: PlayerAction) { - stateMutex.withLock { - when (val result = state.value.after(player, packet)) { - is GameEvent.StateChange -> { - stateMutable.value = result.newState - result.newState.checkVictory()?.let { gameEndMutable.complete(it) } - } - is GameEvent.InvalidAction -> { - errorMessageChannel(player).send(result.message) - } - is GameEvent.GameEnd -> { - gameEndMutable.complete(result) - } - } - } - } -} - -private suspend fun GameNetworkInteraction.execute(): GameEvent.GameEnd { - val gameSession = GameSession(gameState.value) - - val aiSide = mySide.other - val aiActions = Channel() - val aiEvents = Channel() - val aiSession = AISession(aiSide, aiActions, aiEvents) - - return coroutineScope { - val aiHandlingJob = launch { - launch { - listOf( - // Game state changes - launch { - gameSession.state.collect { state -> - aiEvents.send(GameEvent.StateChange(state)) - } - }, - // Invalid action messages - launch { - for (errorMessage in gameSession.errorMessages(aiSide)) { - aiEvents.send(GameEvent.InvalidAction(errorMessage)) - } - } - ).joinAll() - } - - launch { - for (action in aiActions) - gameSession.onPacket(aiSide, action) - } - - aiPlayer(aiSession, gameState.value) - } - - val playerHandlingJob = launch { - launch { - listOf( - // Game state changes - launch { - gameSession.state.collect { state -> - gameState.value = state - } - }, - // Invalid action messages - launch { - for (errorMessage in gameSession.errorMessages(mySide)) { - errorMessages.send(errorMessage) - } - } - ).joinAll() - } - - for (action in playerActions) - gameSession.onPacket(mySide, action) - } - - val gameEnd = gameSession.gameEnd.await() - - aiHandlingJob.cancel() - playerHandlingJob.cancel() - - gameEnd - } -} - -suspend fun trainingMain(state: GameState) { - interruptExit = true - - initializePicking() - - mySide = GlobalSide.HOST - - val gameState = MutableStateFlow(state) - val playerActions = Channel(Channel.UNLIMITED) - val errorMessages = Channel(Channel.UNLIMITED) - - val gameConnection = GameNetworkInteraction(gameState, playerActions, errorMessages) - val gameRendering = GameRenderInteraction(gameState, playerActions, errorMessages) - - coroutineScope { - val connectionJob = async { gameConnection.execute() } - val renderingJob = launch { gameRendering.execute(this@coroutineScope) } - - val (finalWinner, finalMessage, finalSubplots) = connectionJob.await() - renderingJob.cancel() - - interruptExit = false - Popup.GameOver(finalWinner, finalMessage, finalSubplots, gameState.value).display() - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/game_render.kt b/src/jsMain/kotlin/starshipfights/game/game_render.kt deleted file mode 100644 index 69e6569..0000000 --- a/src/jsMain/kotlin/starshipfights/game/game_render.kt +++ /dev/null @@ -1,43 +0,0 @@ -package starshipfights.game - -import externals.threejs.* - -object GameRender { - fun renderGameState(scene: Scene, state: GameState) { - scene.background = RenderResources.spaceboxes.getValue(state.battleInfo.bg) - scene.getObjectByName("light")?.removeFromParent() - scene.add(AmbientLight(state.battleInfo.bg.color).apply { name = "light" }) - - val shipGroup = scene.getObjectByName("ships") ?: Group().apply { name = "ships" }.also { scene.add(it) } - - shipGroup.clear() - - for (ship in state.ships.values) { - when (state.renderShipAs(ship, mySide)) { - ShipRenderMode.NONE -> {} - ShipRenderMode.SIGNAL -> shipGroup.add(RenderResources.enemySignal.generate(ship.position.location)) - ShipRenderMode.FULL -> shipGroup.add(RenderResources.shipMesh.generate(ship)) - } - } - } -} - -object RenderScaling { - const val METERS_PER_THREEJS_UNIT = 100.0 - const val METERS_PER_3D_MESH_UNIT = 6.9 - - fun toWorldRotation(facing: Double, obj: Object3D) { - obj.rotateY(-facing) - } - - fun toBattleLength(length3js: Double) = length3js * METERS_PER_THREEJS_UNIT - fun toWorldLength(lengthSf: Double) = lengthSf / METERS_PER_THREEJS_UNIT - - fun toBattlePosition(v3: Vector3) = Position( - Vec2(v3.z.toDouble(), -v3.x.toDouble()) * METERS_PER_THREEJS_UNIT - ) - - fun toWorldPosition(pos: Position) = (pos.vector / METERS_PER_THREEJS_UNIT).let { (x, y) -> - Vector3(-y, 0, x) - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/game_resources.kt b/src/jsMain/kotlin/starshipfights/game/game_resources.kt deleted file mode 100644 index 3ab3641..0000000 --- a/src/jsMain/kotlin/starshipfights/game/game_resources.kt +++ /dev/null @@ -1,350 +0,0 @@ -package starshipfights.game - -import externals.threejs.* -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import org.w3c.dom.Image -import starshipfights.data.Id -import kotlin.math.PI -import kotlin.math.roundToInt - -fun interface RenderFactory { - fun generate(): Object3D -} - -fun interface CustomRenderFactory { - fun generate(parameter: T): Object3D -} - -val ClientMode.isSmallLoad: Boolean - get() = this !is ClientMode.InGame && this !is ClientMode.InTrainingGame - -object RenderResources { - const val LOGO_URL = "/static/images/logo.svg" - - private val spaceboxUrls = BattleBackground.values().associateWith { "spacebox-${it.toUrlSlug()}" } - - lateinit var spaceboxes: Map - private set - - private const val enemySignalUrl = "enemy-signal" - - lateinit var enemySignal: CustomRenderFactory - private set - - private const val gridTileUrl = "grid-tile" - lateinit var battleGrid: CustomRenderFactory> - private set - - private const val friendlyMarkerUrl = "friendly-marker" - private const val hostileMarkerUrl = "hostile-marker" - - lateinit var markerFactory: CustomRenderFactory - private set - - private val shipMeshesRaw = mutableMapOf() - - lateinit var shipMeshes: Map> - private set - - lateinit var shipMesh: CustomRenderFactory - private set - - lateinit var shipHologramFactory: CustomRenderFactory - private set - - suspend fun load(isSmallLoad: Boolean = false) { - coroutineScope { - launch { - val img = Image() - val job = launch { img.awaitEvent("load") } - img.src = LOGO_URL - job.join() - } - - launch { - Faction.values().map { faction -> - val img = Image() - val job = launch { img.awaitEvent("load") } - img.src = faction.flagUrl - job - }.joinAll() - } - - launch { - spaceboxes = spaceboxUrls.mapValues { (_, it) -> - async { loadTexture(it) } - }.mapValues { (_, it) -> - it.await() - }.onEach { (_, it) -> - it.mapping = EquirectangularReflectionMapping - } - } - - if (isSmallLoad) return@coroutineScope - - initResCache() - - launch { - val texture = loadTexture(enemySignalUrl) - val material = SpriteMaterial(configure { - map = texture - blending = CustomBlending - blendEquation = AddEquation - blendSrc = OneFactor - blendDst = OneMinusSrcColorFactor - }) - - val sprite = Sprite(material) - sprite.scale.setScalar(4) - sprite.position.set(0, 4, 0) - - enemySignal = CustomRenderFactory { pos -> - Group().apply { - add(sprite.clone(true)) - position.copy(RenderScaling.toWorldPosition(pos)) - } - } - } - - launch { - val texture = loadTexture(gridTileUrl) - texture.minFilter = LinearFilter - texture.magFilter = LinearFilter - - battleGrid = CustomRenderFactory { (bfW, bfL) -> - val w3d = RenderScaling.toWorldLength(bfW) - val l3d = RenderScaling.toWorldLength(bfL) - - val gridTex = texture.clone().apply { - wrapS = RepeatWrapping - wrapT = RepeatWrapping - repeat.set((w3d / 5).roundToInt(), (l3d / 5).roundToInt()) - needsUpdate = true - } - - val material = MeshBasicMaterial(configure { - map = gridTex - - side = DoubleSide - depthWrite = false - - blending = CustomBlending - blendEquation = AddEquation - blendSrc = OneFactor - blendDst = OneMinusSrcColorFactor - }) - - val plane = PlaneGeometry(w3d, l3d) - val mesh = Mesh(plane, material) - mesh.rotateX(PI / 2) - - Group().also { - it.add(Group().apply { - RenderScaling.toWorldRotation(PI / 2, this) - add(mesh) - }) - - it.position.set(0, -0.02, 0) - - it.name = "plane" - } - } - } - - launch { - val friendlyMarkerPromise = async { loadTexture(friendlyMarkerUrl) } - val hostileMarkerPromise = async { loadTexture(hostileMarkerUrl) } - - val friendlyMarkerTexture = friendlyMarkerPromise.await() - friendlyMarkerTexture.minFilter = LinearFilter - friendlyMarkerTexture.magFilter = LinearFilter - - val hostileMarkerTexture = hostileMarkerPromise.await() - hostileMarkerTexture.minFilter = LinearFilter - hostileMarkerTexture.magFilter = LinearFilter - - val friendlyMarkerMaterial = MeshBasicMaterial(configure { - map = friendlyMarkerTexture - alphaTest = 0.5 - side = DoubleSide - }) - - val hostileMarkerMaterial = MeshBasicMaterial(configure { - map = hostileMarkerTexture - alphaTest = 0.5 - side = DoubleSide - }) - - val plane = PlaneGeometry(4, 4) - - val friendlyMarkerMesh = Mesh(plane, friendlyMarkerMaterial) - val hostileMarkerMesh = Mesh(plane, hostileMarkerMaterial) - - friendlyMarkerMesh.rotateX(PI / 2) - hostileMarkerMesh.rotateX(PI / 2) - - markerFactory = CustomRenderFactory { side -> - when (side) { - LocalSide.GREEN -> friendlyMarkerMesh - LocalSide.RED -> hostileMarkerMesh - }.clone(true) - } - } - - launch { - val outlineMaterial = ShaderMaterial(configure { - vertexShader = """ - |uniform float outlineGrow; - - |void main() { - | vec3 grownPosition = position + (normal * outlineGrow); - | gl_Position = projectionMatrix * modelViewMatrix * vec4(grownPosition, 1.0); - |} - """.trimMargin() - - fragmentShader = """ - |uniform vec3 outlineColor; - - |void main () { - | gl_FragColor = vec4( outlineColor, 1.0 ); - |} - """.trimMargin() - - uniforms = configure { - this["outlineGrow"] = configure { - value = 0.75 - } - this["outlineColor"] = configure { - value = Color("#AAAAAA") - } - } - }).apply { - side = BackSide - } - - shipMeshes = ShipType.values().associateWith { st -> - async { loadModel(st.meshName) } - }.mapValues { (st, loadingMesh) -> - val mesh = loadingMesh.await() - mesh.scale.setScalar(RenderScaling.METERS_PER_3D_MESH_UNIT / RenderScaling.METERS_PER_THREEJS_UNIT) - mesh.position.set(0, 4, 0) - - shipMeshesRaw[st] = RenderFactory { mesh.clone(true) } - - val greenOutlineMaterial = outlineMaterial.clone().unsafeCast().apply { - uniforms["outlineColor"]!!.value = Color(LocalSide.GREEN.htmlColor) - } - - val redOutlineMaterial = outlineMaterial.clone().unsafeCast().apply { - uniforms["outlineColor"]!!.value = Color(LocalSide.RED.htmlColor) - } - - val outlineGreen = mesh.clone(true).unsafeCast() - outlineGreen.material = greenOutlineMaterial - - val outlineRed = mesh.clone(true).unsafeCast() - outlineRed.material = redOutlineMaterial - - CustomRenderFactory { ship -> - val side = ship.owner.relativeTo(mySide) - - ShipRender( - ship.id, - markerFactory.generate(side).unsafeCast(), - mesh.clone(true).unsafeCast().apply { - receiveShadow = true - castShadow = true - }, - when (side) { - LocalSide.GREEN -> outlineGreen - LocalSide.RED -> outlineRed - }.clone(true).unsafeCast() - ).group - } - } - - shipMesh = CustomRenderFactory { shipInstance -> - shipMeshes.getValue(shipInstance.ship.shipType).generate(shipInstance).also { render -> - RenderScaling.toWorldRotation(shipInstance.position.facing, render) - render.position.copy(RenderScaling.toWorldPosition(shipInstance.position.location)) - } - } - } - - launch { - val hologramMaterial = ShaderMaterial(configure { - extensions = configure { - derivatives = true - } - - vertexShader = """ - |varying float vNormalZ; - - |void main() { - | vNormalZ = normalize( normalMatrix * normal ).z; - | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - |} - """.trimMargin() - - fragmentShader = """ - |uniform vec3 glowColor; - |uniform float glowAmount; - |varying float vNormalZ; - - |void main () { - | float colorCoeff = smoothstep( 0.0, 1.0, fwidth( vNormalZ * glowAmount ) ); - | gl_FragColor = vec4( glowColor * colorCoeff, 1.0 ); - |} - """.trimMargin() - - uniforms = configure { - this["glowColor"] = configure { - value = Color("#ffffff") - } - this["glowAmount"] = configure { - value = 3.5 - } - } - }).apply { - userData = "hologram" - - blending = CustomBlending - blendEquation = AddEquation - blendSrc = OneFactor - blendDst = OneMinusSrcColorFactor - } - - shipHologramFactory = CustomRenderFactory { shipHelper -> - val shipMesh = shipMeshesRaw.getValue(shipHelper.type).generate().unsafeCast() - - shipMesh.material = hologramMaterial.clone() - - shipMesh - } - } - } - } -} - -data class ShipRender( - val shipId: Id, - - val bottomMarker: Mesh, - val shipMesh: Mesh, - val shipOutline: Mesh -) { - val group: Group = Group().also { g -> - bottomMarker.userData = this - shipMesh.userData = this - shipOutline.userData = this - - shipOutline.visible = false - - g.add(bottomMarker, shipMesh, shipOutline) - g.name = shipId.toString() - g.userData = this - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/game_time_js.kt b/src/jsMain/kotlin/starshipfights/game/game_time_js.kt deleted file mode 100644 index 4ea6ba0..0000000 --- a/src/jsMain/kotlin/starshipfights/game/game_time_js.kt +++ /dev/null @@ -1,20 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import kotlin.js.Date - -@Serializable(with = MomentSerializer::class) -actual class Moment(val date: Date) : Comparable { - actual constructor(millis: Double) : this(Date(millis)) - - actual fun toMillis(): Double { - return date.getTime() - } - - actual override fun compareTo(other: Moment) = toMillis().compareTo(other.toMillis()) - - actual companion object { - actual val now: Moment - get() = Moment(Date()) - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/game_ui.kt b/src/jsMain/kotlin/starshipfights/game/game_ui.kt deleted file mode 100644 index d18225e..0000000 --- a/src/jsMain/kotlin/starshipfights/game/game_ui.kt +++ /dev/null @@ -1,1139 +0,0 @@ -package starshipfights.game - -import externals.textfit.textFit -import externals.threejs.* -import kotlinx.browser.document -import kotlinx.browser.window -import kotlinx.coroutines.delay -import kotlinx.dom.clear -import kotlinx.html.* -import kotlinx.html.dom.append -import kotlinx.html.dom.create -import kotlinx.html.js.div -import kotlinx.html.js.onClickFunction -import org.w3c.dom.* -import org.w3c.dom.events.KeyboardEvent -import starshipfights.data.Id - -interface GameUIResponder { - fun doAction(action: PlayerAction) - fun useAbility(ability: PlayerAbilityType) -} - -object GameUI { - private lateinit var responder: GameUIResponder - - private val gameUI = document.getElementById("ui").unsafeCast() - private lateinit var chatHistory: HTMLDivElement - private lateinit var chatInput: HTMLInputElement - private lateinit var chatSend: HTMLButtonElement - - private lateinit var topMiddleInfo: HTMLDivElement - private lateinit var topRightBar: HTMLDivElement - - private lateinit var objectives: HTMLDivElement - - private lateinit var errorMessages: HTMLParagraphElement - private lateinit var helpMessages: HTMLParagraphElement - - private lateinit var shipsOverlay: HTMLElement - private lateinit var shipsOverlayRenderer: CSS3DRenderer - private lateinit var shipsOverlayCamera: PerspectiveCamera - private lateinit var shipsOverlayScene: Scene - - fun initGameUI(uiResponder: GameUIResponder) { - responder = uiResponder - - gameUI.clear() - - gameUI.append { - div(classes = "panel") { - id = "chat-box" - - div(classes = "inset") { - id = "chat-history" - } - - div { - id = "chat-entry" - - textInput { - id = "chat-input" - placeholder = "Write a friendly chat message" - } - - button { - id = "chat-send" - +"Send" - } - } - } - - div(classes = "panel") { - id = "top-middle-info" - - p { - +"Battle has not started yet" - } - } - - div(classes = "panel") { - id = "top-right-bar" - } - - div { - id = "objectives" - } - - p { - id = "error-messages" - } - - p { - id = "help-messages" - } - } - - chatHistory = document.getElementById("chat-history").unsafeCast() - chatInput = document.getElementById("chat-input").unsafeCast() - chatSend = document.getElementById("chat-send").unsafeCast() - - chatSend.addEventListener("click", { e -> - e.preventDefault() - - val chatAction = PlayerAction.SendChatMessage(chatInput.value) - responder.doAction(chatAction) - chatInput.value = "" - }) - - chatInput.addEventListener("keydown", { e -> - val ke = e.unsafeCast() - if (ke.key == "Enter") { - val chatAction = PlayerAction.SendChatMessage(chatInput.value) - responder.doAction(chatAction) - chatInput.value = "" - } - if (document.activeElement == chatInput && document.hasFocus()) - ke.stopPropagation() - }) - - topMiddleInfo = document.getElementById("top-middle-info").unsafeCast() - topRightBar = document.getElementById("top-right-bar").unsafeCast() - - objectives = document.getElementById("objectives").unsafeCast() - - errorMessages = document.getElementById("error-messages").unsafeCast() - helpMessages = document.getElementById("help-messages").unsafeCast() - - shipsOverlayRenderer = CSS3DRenderer() - shipsOverlayRenderer.setSize(window.innerWidth, window.innerHeight) - - shipsOverlay = shipsOverlayRenderer.domElement - gameUI.prepend(shipsOverlay) - - shipsOverlayCamera = PerspectiveCamera(69, window.aspectRatio, 0.01, 1_000) - shipsOverlayScene = Scene() - shipsOverlayScene.add(shipsOverlayCamera) - - window.addEventListener("resize", { - shipsOverlayCamera.aspect = window.aspectRatio - shipsOverlayCamera.updateProjectionMatrix() - - shipsOverlayRenderer.setSize(window.innerWidth, window.innerHeight) - }) - } - - suspend fun displayErrorMessage(message: String) { - errorMessages.textContent = message - - delay(5000) - - errorMessages.textContent = "" - } - - var currentHelpMessage: String - get() = helpMessages.textContent ?: "" - set(value) { - helpMessages.textContent = value - } - - fun updateGameUI(controls: CameraControls) { - shipsOverlayCamera.position.copy(controls.camera.getWorldPosition(shipsOverlayCamera.position)) - shipsOverlayCamera.quaternion.copy(controls.camera.getWorldQuaternion(shipsOverlayCamera.quaternion)) - shipsOverlayRenderer.render(shipsOverlayScene, shipsOverlayCamera) - - textFit(document.getElementsByClassName("ship-label")) - } - - fun drawGameUI(state: GameState) { - chatHistory.clear() - chatHistory.append { - for (entry in state.chatBox.sortedBy { it.sentAt }) { - p { - title = "At ${entry.sentAt.date}" - - when (entry) { - is ChatEntry.PlayerMessage -> { - val senderInfo = state.admiralInfo(entry.senderSide) - val senderSide = entry.senderSide.relativeTo(mySide) - strong { - style = "color:${senderSide.htmlColor}" - +senderInfo.user.username - +Entities.nbsp - img(alt = senderInfo.faction.shortName, src = senderInfo.faction.flagUrl) { - style = "height:0.75em;width:1.2em" - } - } - +Entities.nbsp - +entry.message - } - is ChatEntry.ShipIdentified -> { - val ship = state.getShipInfo(entry.ship) - val owner = state.getShipOwner(entry.ship).relativeTo(mySide) - +"The " - if (owner == LocalSide.RED) - +"enemy ship " - strong { - style = "color:${owner.htmlColor}" - +ship.fullName - } - +" has been sighted" - if (owner == LocalSide.GREEN) - +" by the enemy" - +"!" - } - is ChatEntry.ShipEscaped -> { - val ship = state.getShipInfo(entry.ship) - val owner = state.getShipOwner(entry.ship).relativeTo(mySide) - +if (owner == LocalSide.RED) - "The enemy ship " - else - "Our ship, the " - strong { - style = "color:${owner.htmlColor}" - +ship.fullName - } - +" has " - +if (owner == LocalSide.RED) - "fled like a coward" - else - "disengaged" - +" from the battlefield!" - } - is ChatEntry.ShipAttacked -> { - val ship = state.getShipInfo(entry.ship) - val owner = state.getShipOwner(entry.ship).relativeTo(mySide) - +if (owner == LocalSide.RED) - "The enemy ship " - else - "Our ship, the " - strong { - style = "color:${owner.htmlColor}" - +ship.fullName - } - +" has taken " - - +if (entry.weapon is ShipWeapon.EmpAntenna) - "subsystem-draining" - else - entry.damageInflicted.toString() - - if (entry.critical != null) - +" critical" - - +" damage from " - when (entry.attacker) { - is ShipAttacker.EnemyShip -> { - if (entry.weapon != null) { - +"the " - +when (entry.weapon) { - is ShipWeapon.Cannon -> "cannons" - is ShipWeapon.Lance -> "lances" - is ShipWeapon.Hangar -> "bombers" - is ShipWeapon.Torpedo -> "torpedoes" - is ShipWeapon.ParticleClawLauncher -> "particle claws" - is ShipWeapon.LightningYarn -> "lightning yarn" - ShipWeapon.MegaCannon -> "Mega Giga Cannon" - ShipWeapon.RevelationGun -> "Revelation Gun" - ShipWeapon.EmpAntenna -> "EMP antenna" - } - +" of " - } - +"the " - strong { - style = "color:${owner.other.htmlColor}" - +state.getShipInfo(entry.attacker.id).fullName - } - } - ShipAttacker.Fire -> { - +"onboard fires" - } - ShipAttacker.Bombers -> { - if (owner == LocalSide.RED) - +"our " - else - +"enemy " - +"bombers" - } - } - - +when (entry.critical) { - ShipCritical.Fire -> ", starting a fire" - is ShipCritical.TroopsKilled -> ", killing ${entry.critical.number} troops" - is ShipCritical.ModulesHit -> ", disabling ${entry.critical.module.joinToDisplayString { it.getDisplayName(ship) }}" - else -> "" - } - +"." - } - is ChatEntry.ShipAttackFailed -> { - val ship = state.getShipInfo(entry.ship) - val owner = state.getShipOwner(entry.ship).relativeTo(mySide) - +if (owner == LocalSide.RED) - "The enemy ship " - else - "Our ship, the " - strong { - style = "color:${owner.htmlColor}" - +ship.fullName - } - +" has ignored an attack from " - when (entry.attacker) { - is ShipAttacker.EnemyShip -> { - if (entry.weapon != null) { - +"the " - +when (entry.weapon) { - is ShipWeapon.Cannon -> "cannons" - is ShipWeapon.Lance -> "lances" - is ShipWeapon.Hangar -> "bombers" - is ShipWeapon.Torpedo -> "torpedoes" - is ShipWeapon.ParticleClawLauncher -> "particle claws" - is ShipWeapon.LightningYarn -> "lightning yarn" - ShipWeapon.MegaCannon -> "Mega Giga Cannon" - ShipWeapon.RevelationGun -> "Revelation Gun" - ShipWeapon.EmpAntenna -> "EMP antenna" - } - +" of " - } - +"the " - strong { - style = "color:${owner.other.htmlColor}" - +state.getShipInfo(entry.attacker.id).fullName - } - } - ShipAttacker.Fire -> { - +"onboard fires" - } - ShipAttacker.Bombers -> { - if (owner == LocalSide.RED) - +"our " - else - +"enemy " - +"bombers" - } - } - - +when (entry.damageIgnoreType) { - DamageIgnoreType.FELINAE_ARMOR -> " using its relativistic armor" - } - +"." - } - is ChatEntry.ShipBoarded -> { - val ship = state.getShipInfo(entry.ship) - val owner = state.getShipOwner(entry.ship).relativeTo(mySide) - +if (owner == LocalSide.RED) - "The enemy ship " - else - "Our ship, the " - strong { - style = "color:${owner.htmlColor}" - +ship.fullName - } - - +" has been boarded by the " - strong { - style = "color:${owner.other.htmlColor}" - +state.getShipInfo(entry.boarder).fullName - } - - +when (entry.critical) { - ShipCritical.ExtraDamage -> ", dealing ${entry.damageAmount} hull damage" - ShipCritical.Fire -> ", starting a fire" - is ShipCritical.TroopsKilled -> ", killing ${entry.critical.number} troops" - is ShipCritical.ModulesHit -> ", disabling ${entry.critical.module.joinToDisplayString { it.getDisplayName(ship) }}" - else -> ", to no effect" - } - +"." - } - is ChatEntry.ShipDestroyed -> { - val ship = state.getShipInfo(entry.ship) - val owner = state.getShipOwner(entry.ship).relativeTo(mySide) - +if (owner == LocalSide.RED) - "The enemy ship " - else - "Our ship, the " - strong { - style = "color:${owner.htmlColor}" - +ship.fullName - } - +" has been destroyed by " - when (entry.destroyedBy) { - is ShipAttacker.EnemyShip -> { - +"the " - strong { - style = "color:${owner.other.htmlColor}" - +state.getShipInfo(entry.destroyedBy.id).fullName - } - } - ShipAttacker.Fire -> { - +"onboard fires" - } - ShipAttacker.Bombers -> { - +if (owner == LocalSide.RED) - "our " - else - "enemy " - +"bombers" - } - } - +"!" - } - } - } - } - }.lastOrNull()?.scrollIntoView() - - objectives.clear() - objectives.append { - for (objective in state.objectives(mySide)) { - val classes = when (objective.succeeded) { - true -> "item succeeded" - false -> "item failed" - else -> "item" - } - div(classes = classes) { - +objective.displayText - } - } - } - - val abilities = state.getPossibleAbilities(mySide) - - topMiddleInfo.clear() - topMiddleInfo.append { - p { - when (state.phase) { - GamePhase.Deploy -> { - strong(classes = "heading") { - +"Pre-Battle Deployment" - } - } - is GamePhase.Power -> { - strong(classes = "heading") { - +"Turn ${state.phase.turn}" - } - br - +"Phase I - Power Distribution" - } - is GamePhase.Move -> { - strong(classes = "heading") { - +"Turn ${state.phase.turn}" - } - br - +"Phase II - Ship Movement" - } - is GamePhase.Attack -> { - strong(classes = "heading") { - +"Turn ${state.phase.turn}" - } - br - +"Phase III - Weapons Fire" - } - is GamePhase.Repair -> { - strong(classes = "heading") { - +"Turn ${state.phase.turn}" - } - br - +"Phase IV - Onboard Repairs" - } - } - - if (state.phase.usesInitiative) { - br - +if (state.doneWithPhase == mySide) - "You have ended your phase" - else if (state.currentInitiative != mySide.other) - "You have the initiative!" - else "Your opponent has the initiative" - } else if (state.doneWithPhase == mySide) { - br - +"You have ended your phase" - } - } - } - - topRightBar.clear() - topRightBar.append { - when (state.phase) { - GamePhase.Deploy -> { - drawDeployPhase(state, abilities) - } - else -> { - drawShipActions(state, selectedShip.value) - } - } - } - - shipsOverlayScene.clear() - for ((shipId, ship) in state.ships) { - if (state.renderShipAs(ship, mySide) == ShipRenderMode.FULL) - shipsOverlayScene.add(CSS3DSprite(document.create.div { - drawShipLabel(state, abilities, shipId, ship) - }).apply { - scale.setScalar(0.00625) - - element.style.asDynamic().pointerEvents = "none" - - position.copy(RenderScaling.toWorldPosition(ship.position.location)) - position.y = 7.5 - }) - } - } - - private fun DIV.drawShipLabel(state: GameState, abilities: List, shipId: Id, ship: ShipInstance) { - id = "ship-overlay-$shipId" - classes = setOf("ship-overlay") - style = "background-color:#999;width:800px;height:300px;opacity:0.8;font-size:4em;text-align:center;vertical-align:middle" - attributes["data-ship-id"] = shipId.toString() - - p(classes = "ship-label") { - style = "color:#fff;margin:0;white-space:nowrap;width:800px;height:100px" - +ship.ship.fullName - } - p(classes = "ship-label") { - style = "color:#fff;margin:0;white-space:nowrap;width:800px;height:75px" - +ship.ship.shipType.fullDisplayName - } - - p { - style = "margin:0;white-space:nowrap;width:800px;height:125px" - when (state.phase) { - GamePhase.Deploy -> { - button { - style = "pointer-events:auto" - +"Undeploy" - - val undeployAbility = PlayerAbilityType.UndeployShip(shipId) - if (undeployAbility in abilities) - onClickFunction = { e -> - e.preventDefault() - - responder.useAbility(undeployAbility) - } - } - } - else -> { - if (ship.canUseShields) { - val totalShield = ship.powerMode.shields - val activeShield = ship.shieldAmount - val downShield = totalShield - activeShield - - table { - style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px" - - tr { - repeat(activeShield) { - td { - style = "background-color:#69F;height:15px;box-shadow:inset 0 0 0 3px #555" - } - } - repeat(downShield) { - td { - style = "background-color:#46A;height:15px;box-shadow:inset 0 0 0 3px #555" - } - } - } - } - } - - val totalHull = ship.durability.maxHullPoints - val activeHull = ship.hullAmount - val downHull = totalHull - activeHull - - table { - style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px" - - tr { - repeat(activeHull) { - td { - style = "background-color:${if (ship.owner == mySide) "#5F5" else "#F55"};height:15px;box-shadow:inset 0 0 0 3px #555" - } - } - repeat(downHull) { - td { - style = "background-color:${if (ship.owner == mySide) "#262" else "#622"};height:15px;box-shadow:inset 0 0 0 3px #555" - } - } - } - } - - val totalTroops = ship.durability.troopsDefense - val activeTroops = ship.troopsAmount - val downTroops = totalTroops - activeTroops - - table { - style = "width:100%;table-layout:fixed;background-color:#555;margin:0;margin-bottom:10px" - - tr { - repeat(activeTroops) { - td { - style = "background-color:#AAA;height:15px;box-shadow:inset 0 0 0 3px #555" - } - } - repeat(downTroops) { - td { - style = "background-color:#444;height:15px;box-shadow:inset 0 0 0 3px #555" - } - } - } - } - - if (ship.ship.reactor is StandardShipReactor) { - if (ship.owner == mySide) { - val totalWeapons = ship.powerMode.weapons - val activeWeapons = ship.weaponAmount - val downWeapons = totalWeapons - activeWeapons - - table { - style = "width:100%;table-layout:fixed;background-color:#555;margin:0" - - tr { - repeat(activeWeapons) { - td { - style = "background-color:#F63;height:15px;box-shadow:inset 0 0 0 3px #555" - } - } - repeat(downWeapons) { - td { - style = "background-color:#A42;height:15px;box-shadow:inset 0 0 0 3px #555" - } - } - } - } - } - } - } - } - } - - if (state.phase is GamePhase.Attack) { - div { - style = "margin:0;white-space:nowrap;text-align:center" - br - - val fighterSide = ship.owner.relativeTo(mySide) - val bomberSide = ship.owner.other.relativeTo(mySide) - - if (ship.fighterWings.isNotEmpty()) { - span { - val (borderColor, fillColor) = when (fighterSide) { - LocalSide.GREEN -> "#5F5" to "#262" - LocalSide.RED -> "#F55" to "#622" - } - - style = "display:inline-block;border:5px solid $borderColor;border-radius:15px;background-color:$fillColor;color:#fff" - - img(src = StrikeCraftWing.FIGHTERS.iconUrl, alt = StrikeCraftWing.FIGHTERS.displayName) { - style = "width:1.125em" - } - - +Entities.nbsp - - +ship.fighterWings.sumOf { (carrierId, wingId) -> - (state.ships[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 - }.toPercent() - } - } - - +Entities.nbsp - - if (ship.bomberWings.isNotEmpty()) { - span { - val (borderColor, fillColor) = when (bomberSide) { - LocalSide.GREEN -> "#5F5" to "#262" - LocalSide.RED -> "#F55" to "#622" - } - - style = "display:inline-block;border:5px solid $borderColor;border-radius:15px;background-color:$fillColor;color:#fff" - - img(src = StrikeCraftWing.BOMBERS.iconUrl, alt = StrikeCraftWing.BOMBERS.displayName) { - style = "width:1.125em" - } - - +Entities.nbsp - - +ship.bomberWings.sumOf { (carrierId, wingId) -> - (state.ships[carrierId]?.armaments?.get(wingId) as? ShipWeaponInstance.Hangar)?.wingHealth ?: 0.0 - }.toPercent() - } - } - } - } - } - - private fun TagConsumer<*>.drawDeployPhase(state: GameState, abilities: List) { - val deployableShips = state.start.playerStart(mySide).deployableFleet - val remainingPoints = state.battleInfo.size.numPoints - state.ships.values.filter { it.owner == mySide }.sumOf { it.ship.pointCost } - - div { - style = "height:19%;font-size:0.9em" - - p { - style = "text-align:center;margin:1.25em" - +"Deploy your fleet" - br - +"Points remaining: $remainingPoints" - } - } - - div { - style = "height:69%;overflow-y:auto;font-size:0.9em" - - hr { style = "border-color:#555" } - for ((id, ship) in deployableShips.toList().sortedBy { (_, ship) -> ship.pointCost }) { - p { - style = "text-align:center;margin:0" - +ship.name - br - +ship.shipType.fullDisplayName - br - +"${ship.pointCost} points | " - - val deployAbility = PlayerAbilityType.DeployShip(id) - if (deployAbility in abilities) - a(href = "#") { - +"Deploy" - - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(deployAbility) - } - } - else - span { - style = "color:#333;cursor:not-allowed" - +"Deploy" - } - } - - hr { style = "border-color:#555" } - } - } - - div { - style = "height:9%;font-size:0.9em" - - p { - style = "text-align:center;margin:0.75em" - - val donePhase = abilities.filterIsInstance().singleOrNull() - if (donePhase != null) - a(href = "#") { - id = "done-phase" - +"Confirm Deployment" - - onClickFunction = { e -> - e.preventDefault() - - finalizePhase(donePhase, abilities) - } - } - else - span { - style = "color:#333;cursor:not-allowed" - +"Confirm Deployment" - } - } - } - } - - private fun TagConsumer<*>.drawShipActions(gameState: GameState, selectedId: Id?) { - val ship = selectedId?.let { gameState.ships[it] } - - div { - style = "text-align:center" - - val abilities = gameState - .getPossibleAbilities(mySide) - - if (ship == null) { - p { - style = "text-align:center;margin:0" - - +"No ship selected. Click on a ship to select it." - } - } else { - val shipAbilities = abilities - .filterIsInstance() - .filter { it.ship == ship.id } - - val combatAbilities = abilities - .filterIsInstance() - .filter { it.ship == ship.id } - - p { - style = "height:19%;margin:0" - - strong(classes = "heading") { +ship.ship.fullName } - br - - +ship.ship.shipType.fullerDisplayName - br - - if (ship.owner == mySide) { - when (ship.ship.reactor) { - is StandardShipReactor -> table { - style = "width:100%;table-layout:fixed;background-color:#555" - tr { - for (subsystem in ShipSubsystem.values()) { - val amount = ship.powerMode[subsystem] - - repeat(amount) { - td { - style = "background-color:${subsystem.htmlColor};margin:1px;height:0.55em" - } - } - } - } - } - is FelinaeShipReactor -> p { - +"Reactor Priority: ${ship.felinaeShipPowerMode.displayName}" - } - } - } - - for ((module, status) in ship.modulesStatus.statuses) { - when (status) { - ShipModuleStatus.INTACT -> {} - ShipModuleStatus.DAMAGED -> { - span { - style = "color:#fd4" - +"${module.getDisplayName(ship.ship)} Damaged" - } - br - } - ShipModuleStatus.DESTROYED -> { - span { - style = "color:#d22" - +"${module.getDisplayName(ship.ship)} Destroyed" - } - br - } - ShipModuleStatus.ABSENT -> {} - } - } - - if (ship.numFires > 0) - span { - style = "color:#e94" - +"${ship.numFires} Onboard Fire${if (ship.numFires == 1) "" else "s"}" - } - } - - if (ship.owner == mySide) { - hr { style = "border-color:#555" } - - p { - style = "height:69%;margin:0" - - if (gameState.phase is GamePhase.Repair && ship.durability is StandardShipDurability) { - +"${ship.remainingRepairTokens} Repair Tokens" - br - } - - for (ability in shipAbilities) { - when (ability) { - is PlayerAbilityType.DistributePower -> { - val shipReactor = ship.ship.reactor as StandardShipReactor - val shipPowerMode = ClientAbilityData.newShipPowerModes[ship.id] ?: ship.powerMode - - table { - style = "width:100%;table-layout:fixed;background-color:#555" - tr { - for (subsystem in ShipSubsystem.values()) { - val amount = shipPowerMode[subsystem] - - repeat(amount) { - td { - style = "background-color:${subsystem.htmlColor};margin:1px;height:0.55em" - } - } - } - } - } - - p { - style = "text-align:center" - +"Power Output: ${shipReactor.powerOutput}" - br - +"Remaining Transfers: ${ship.remainingGridEfficiency(shipPowerMode)}" - } - - for (transferFrom in ShipSubsystem.values()) { - div(classes = "button-set row") { - for (transferTo in ShipSubsystem.values()) { - if (transferFrom == transferTo) continue - - button { - style = "font-size:0.8em;padding:0 0.25em" - title = "${transferFrom.displayName} to ${transferTo.displayName}" - - img(src = transferFrom.imageUrl, alt = transferFrom.displayName) { - style = "width:0.95em" - } - +Entities.nbsp - img(src = ShipSubsystem.transferImageUrl, alt = " to ") { - style = "width:0.95em" - } - +Entities.nbsp - img(src = transferTo.imageUrl, alt = transferTo.displayName) { - style = "width:0.95em" - } - - val delta = mapOf(transferFrom to -1, transferTo to 1) - val newPowerMode = shipPowerMode + delta - - if (ship.validatePowerMode(newPowerMode)) - onClickFunction = { e -> - e.preventDefault() - ClientAbilityData.newShipPowerModes[ship.id] = newPowerMode - updateAbilityData(gameState) - } - else { - disabled = true - style += ";cursor:not-allowed" - } - } - } - } - } - - button { - +"Confirm" - if (ship.validatePowerMode(shipPowerMode)) - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - else { - disabled = true - style = "cursor:not-allowed" - } - } - - button { - +"Reset" - onClickFunction = { e -> - e.preventDefault() - ClientAbilityData.newShipPowerModes[ship.id] = ship.powerMode - updateAbilityData(gameState) - } - } - } - is PlayerAbilityType.ConfigurePower -> { - a(href = "#") { - +"Set Priority: ${ability.powerMode.displayName}" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - br - } - is PlayerAbilityType.MoveShip -> { - a(href = "#") { - +"Move Ship" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - br - } - is PlayerAbilityType.UseInertialessDrive -> { - a(href = "#") { - +"Activate Inertialess Drive (${ship.remainingInertialessDriveJumps})" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - br - } - is PlayerAbilityType.DisruptionPulse -> { - a(href = "#") { - +"Activate Strike-Craft Disruption Pulse (${ship.remainingDisruptionPulseEmissions})" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - br - } - is PlayerAbilityType.BoardingParty -> { - a(href = "#") { - +"Board Enemy Vessel" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - br - } - is PlayerAbilityType.RepairShipModule -> { - a(href = "#") { - +"Repair ${ability.module.getDisplayName(ship.ship)}" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - br - } - is PlayerAbilityType.ExtinguishFire -> { - a(href = "#") { - +"Extinguish Fire" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - br - } - is PlayerAbilityType.Recoalesce -> { - a(href = "#") { - +"Activate Recoalescence" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - br - } - } - } - - for (ability in combatAbilities) { - br - - val weaponInstance = ship.armaments.getValue(ability.weapon) - - val weaponVerb = if (weaponInstance is ShipWeaponInstance.Hangar) "Release" else "Fire" - val weaponDesc = weaponInstance.displayName - - when (ability) { - is PlayerAbilityType.ChargeLance -> { - a(href = "#") { - +"Charge $weaponDesc" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - } - is PlayerAbilityType.UseWeapon -> { - a(href = "#") { - +"$weaponVerb $weaponDesc" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - } - is PlayerAbilityType.RecallStrikeCraft -> { - a(href = "#") { - +"Recall $weaponDesc" - onClickFunction = { e -> - e.preventDefault() - responder.useAbility(ability) - } - } - } - } - } - } - } - } - - p { - style = "height:9%;margin:0" - - hr { style = "border-color:#555" } - - val finishPhase = abilities.filterIsInstance().singleOrNull() - if (finishPhase != null) - a(href = "#") { - +"End Phase" - id = "done-phase" - - onClickFunction = { e -> - e.preventDefault() - finalizePhase(finishPhase, abilities) - } - } - else - span { - style = "color:#333;cursor:not-allowed" - +"End Phase" - } - } - } - } - - fun changeShipSelection(gameState: GameState, selectedId: Id?) { - topRightBar.clear() - topRightBar.append { - drawShipActions(gameState, selectedId) - } - } - - private fun updateAbilityData(gameState: GameState) { - window.requestAnimationFrame { - changeShipSelection(gameState, selectedShip.value) - } - } - - private fun finalizePhase(finishPhase: PlayerAbilityType.DonePhase, otherAbilities: List) { - val donePhase = document.getElementById("done-phase").unsafeCast() - donePhase.replaceWith(document.create.span { - style = "color:#333;cursor:not-allowed" - +"Waiting..." - }) - - var shouldWait = false - - when (finishPhase.phase) { - is GamePhase.Power -> { - val powerAbilities = otherAbilities.filterIsInstance().associateBy { it.ship } - - for (shipId in ClientAbilityData.newShipPowerModes.keys) { - val powerAbility = powerAbilities[shipId] ?: continue - responder.useAbility(powerAbility) - - shouldWait = true - } - - ClientAbilityData.newShipPowerModes.clear() - } - else -> { - // nothing needs to be done - } - } - - if (shouldWait) - window.setTimeout({ - responder.useAbility(finishPhase) - }, 500) - else - responder.useAbility(finishPhase) - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/loaders.kt b/src/jsMain/kotlin/starshipfights/game/loaders.kt deleted file mode 100644 index f4d25bc..0000000 --- a/src/jsMain/kotlin/starshipfights/game/loaders.kt +++ /dev/null @@ -1,107 +0,0 @@ -package starshipfights.game - -import com.juul.indexeddb.Database -import com.juul.indexeddb.Key -import com.juul.indexeddb.KeyPath -import com.juul.indexeddb.openDatabase -import externals.threejs.* -import kotlinx.browser.window -import kotlinx.coroutines.async -import kotlinx.coroutines.await -import kotlinx.coroutines.coroutineScope -import kotlin.js.Date - -const val MESH_PATH = "/static/game/meshes/" - -private val fileLoader: FileLoader - get() = FileLoader() - .setPath(MESH_PATH) - .setResourcePath(MESH_PATH) - .unsafeCast() - -private val mtlLoader: MTLLoader - get() = MTLLoader() - .setPath(MESH_PATH) - .setResourcePath(MESH_PATH) - .unsafeCast() - -private val meshLoader: OBJLoader - get() = OBJLoader() - .setPath(MESH_PATH) - .setResourcePath(MESH_PATH) - .unsafeCast() - -suspend fun loadModel(name: String): Mesh { - val (mtlText, objText) = coroutineScope { - val mtlAsync = async { loadCacheEntry("mtl", name, ::cacheMissHandler).content!! } - val objAsync = async { loadCacheEntry("obj", name, ::cacheMissHandler).content!! } - - mtlAsync.await() to objAsync.await() - } - val mtl = mtlLoader.parse(mtlText, MESH_PATH) - mtl.preload() - - return meshLoader - .setMaterials(mtl) - .parse(objText) - .children - .single { it.type == "Mesh" } - .unsafeCast() - .apply { removeFromParent() } -} - -private val textureLoader: TextureLoader - get() = TextureLoader() - .setPath("/static/game/textures/") - .setResourcePath("/static/game/textures/") - .unsafeCast() - -suspend fun loadTexture(name: String): Texture = textureLoader.loadAsync("$name.png").await() - -private var database: Database? = null -private var cacheTime: Double = 0.0 - -private external interface CacheEntry { - var name: String? - var content: String? - var updated: Double? -} - -private suspend fun cacheMissHandler(collection: String, name: String) = configure { - this.name = name - this.content = fileLoader.loadAsync("$name.$collection").await().unsafeCast() - this.updated = Date.now() -} - -private suspend fun loadCacheEntry(collection: String, name: String, onMiss: suspend (String, String) -> CacheEntry): CacheEntry { - return database?.let { db -> - db.transaction(collection) { - objectStore(collection).getAll(Key(name)).singleOrNull()?.unsafeCast() - }?.takeIf { entry -> - entry.updated?.let { updated -> updated >= cacheTime } ?: false - } ?: onMiss(collection, name).also { entry -> - db.writeTransaction(collection) { - objectStore(collection).put(entry) - } - } - } ?: onMiss(collection, name) -} - -suspend fun initResCache() { - cacheTime = window.fetch("/cache-time").await().text().await().toDouble() - - database = try { - openDatabase("resource-cache", 1) { database, oldVersion, newVersion -> - if (oldVersion < 1) { - database.createObjectStore("obj", KeyPath("name")) - database.createObjectStore("mtl", KeyPath("name")) - } - } - } catch (ex: Throwable) { - ex.printStackTrace() - null - } catch (ex: dynamic) { - console.error(ex) - null - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/pick_bounds_js.kt b/src/jsMain/kotlin/starshipfights/game/pick_bounds_js.kt deleted file mode 100644 index 619dfc0..0000000 --- a/src/jsMain/kotlin/starshipfights/game/pick_bounds_js.kt +++ /dev/null @@ -1,413 +0,0 @@ -package starshipfights.game - -import externals.threejs.* -import kotlinx.browser.window -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.w3c.dom.events.KeyboardEvent -import org.w3c.dom.events.MouseEvent -import kotlin.coroutines.resume -import kotlin.math.PI - -private val validColor = Color("#3399ff") -private val invalidColor = Color("#ff6666") - -private fun PickHelper.generateHologram(): Group = when (this) { - PickHelper.None -> Group() - is PickHelper.Ship -> { - Group().also { - it.add(RenderResources.shipHologramFactory.generate(this)) - RenderScaling.toWorldRotation(facing, it) - } - } - is PickHelper.Circle -> { - val shape = Shape() - .ellipse( - 0, - 0, - RenderScaling.toWorldLength(radius), - RenderScaling.toWorldLength(radius), - 0, - 2 * PI, - false, - 0 - ) - .unsafeCast() - val geometry = ShapeGeometry(shape) - - val material = MeshBasicMaterial(configure { - side = DoubleSide - - color = "#AAAAAA" - - depthWrite = false - - blending = CustomBlending - blendEquation = AddEquation - blendSrc = OneFactor - blendDst = OneMinusSrcColorFactor - - userData = "screen" - }) - - Group().apply { - add(Mesh(geometry, material).apply { - rotateX(PI / 2) - }) - } - } -} - -private fun Line.drawLocation(drawFrom: Position?, drawTo: Position?) { - if (drawFrom == null || drawTo == null) { - geometry.setFromPoints(emptyArray()) - return - } - - geometry.setFromPoints( - arrayOf( - RenderScaling.toWorldPosition(drawFrom), - RenderScaling.toWorldPosition(drawTo), - ) - ) -} - -private fun Object3D.setLocation(context: PickContext, request: PickRequest, location: Position?) { - if (children.isEmpty()) return - - if (location == null) { - visible = false - return - } - - visible = true - position.copy(RenderScaling.toWorldPosition(location)) - - val response = PickResponse.Location(location) - - val material = children.single().unsafeCast().material.unsafeCast() - val materialColor = if (context.getGameState().isValidPick(request, response)) - validColor - else - invalidColor - - if (material.userData == "hologram") - material.unsafeCast().uniforms["glowColor"]!!.value = materialColor - else - material.unsafeCast().color = materialColor -} - -private fun Raycaster.intersectXZPlane(pickRequest: PickRequest): Position? { - val denominator = -ray.direction.y.toDouble() - if (denominator > EPSILON) { - val t = ray.origin.y.toDouble() / denominator - if (t >= 0) { - val worldPos = Vector3().add(ray.direction).multiplyScalar(t).add(ray.origin) - return pickRequest.boundary.normalize(RenderScaling.toBattlePosition(worldPos)) - } - } - - return null -} - -private var handleCanvasMouseMove: (MouseEvent) -> Unit = { _ -> } -private var handleCanvasMouseDown: (MouseEvent) -> Boolean = { _ -> false } -private var handleWindowEscapeKey: (KeyboardEvent) -> Unit = { _ -> } - -fun Raycaster.initializeFromMouse(camera: Camera) { - setFromCamera(configure { - x = mouseLocation.x - y = mouseLocation.y - }, camera) -} - -private var mouseLocation: Vector2 = Vector2(0, 0) - -fun initializePicking() { - threeCanvas.addEventListener("mousemove", { e -> - val me = e.unsafeCast() - - val normalX = (me.clientX.toDouble() / window.innerWidth) * 2 - 1 - val normalY = 1 - (me.clientY.toDouble() / window.innerHeight) * 2 - mouseLocation = Vector2(normalX, normalY) - - handleCanvasMouseMove(me) - }) - - threeCanvas.addEventListener("mousedown", { e -> - val me = e.unsafeCast() - if (me.button.toInt() == 0) - if (handleCanvasMouseDown(me)) - me.stopImmediatePropagation() - }) - - window.addEventListener("keydown", { e -> - val ke = e.unsafeCast() - if (ke.key == "Escape") - handleWindowEscapeKey(ke) - }) -} - -data class PickContext( - val threeScene: Scene, - val threeCamera: Camera, - val getGameState: () -> GameState, -) - -private fun Position?.toIntersectionArray(): Array = listOfNotNull( - this?.let { pos -> - configure { - point = RenderScaling.toWorldPosition(pos) - } - } -).toTypedArray() - -private fun PickRequest.verify(context: PickContext, intersections: Array): PickResponse? { - return when (type) { - is PickType.Location -> { - val intersection3d = (intersections.firstOrNull() ?: return null).point - val intersection = RenderScaling.toBattlePosition(intersection3d) - val pickResponse = PickResponse.Location(boundary.normalize(intersection)) - - pickResponse.takeIf { context.getGameState().isValidPick(this, it) } - } - is PickType.Ship -> { - val shipId = (intersections.firstOrNull() ?: return null).`object`.userData.unsafeCast().shipId - val pickResponse = PickResponse.Ship(shipId) - - pickResponse.takeIf { context.getGameState().isValidPick(this, it) } - } - } -} - -var isPicking: Boolean = false - private set - -private fun beginPick(context: PickContext, pickRequest: PickRequest, responseHandler: (PickResponse?) -> Unit) { - isPicking = true - - if (pickRequest.boundary is PickBoundary.AlongLine) { - val pointA = RenderScaling.toWorldPosition(pickRequest.boundary.pointA) - val pointB = RenderScaling.toWorldPosition(pickRequest.boundary.pointB) - val bufferGeometry = BufferGeometry().setFromPoints(arrayOf(pointA, pointB)) - val material = LineBasicMaterial(configure { - color = "#4477DD" - }) - - val line = Line(bufferGeometry, material) - line.name = "bound" - context.threeScene.add(line) - } else { - val meshGroup = Group().also { - it.position.set(0, -0.01, 0) - - it.name = "bound" - } - - for (shape in pickRequest.boundary.render()) { - val shapeGeometry = ShapeGeometry(shape) - val material = MeshBasicMaterial(configure { - side = DoubleSide - - color = "#3366CC" - - depthWrite = false - - blending = CustomBlending - blendEquation = AddEquation - blendSrc = OneFactor - blendDst = OneMinusSrcColorFactor - }) - - val mesh = Mesh(shapeGeometry, material) - mesh.rotateX(PI / 2) - - meshGroup.add(Group().apply { - RenderScaling.toWorldRotation(PI / 2, this) - add(mesh) - }) - } - - context.threeScene.add(meshGroup) - } - - val raycaster = Raycaster() - - when (pickRequest.type) { - is PickType.Location -> { - raycaster.initializeFromMouse(context.threeCamera) - val firstLocation = raycaster.intersectXZPlane(pickRequest) - - val pickHelperMesh = pickRequest.type.helper.generateHologram() - pickHelperMesh.name = "pick-helper" - pickHelperMesh.setLocation(context, pickRequest, firstLocation) - - val drawnLine = Line(BufferGeometry(), LineBasicMaterial(configure { - color = "#6699FF" - })).apply { - name = "pick-line" - } - drawnLine.drawLocation(pickRequest.type.drawLineFrom, firstLocation) - - context.threeScene.add(pickHelperMesh, drawnLine) - - handleCanvasMouseMove = { _ -> - raycaster.initializeFromMouse(context.threeCamera) - - val location = raycaster.intersectXZPlane(pickRequest) - - pickHelperMesh.setLocation(context, pickRequest, location) - drawnLine.drawLocation(pickRequest.type.drawLineFrom, location) - - threeCanvas.style.cursor = if (pickRequest.verify(context, location.toIntersectionArray()) != null) - "pointer" - else "not-allowed" - } - - handleCanvasMouseDown = { _ -> - raycaster.initializeFromMouse(context.threeCamera) - - val location = raycaster.intersectXZPlane(pickRequest) - - pickHelperMesh.setLocation(context, pickRequest, location) - drawnLine.drawLocation(pickRequest.type.drawLineFrom, location) - - responseHandler(pickRequest.verify(context, location.toIntersectionArray())) - true - } - } - is PickType.Ship -> { - val ships = context.threeScene.getObjectByName("ships").unsafeCast() - - handleCanvasMouseMove = { _ -> - raycaster.initializeFromMouse(context.threeCamera) - - val intersections = raycaster.intersectObjects(ships.children, true) - - threeCanvas.style.cursor = if (pickRequest.verify(context, intersections) != null) - "pointer" - else "not-allowed" - } - - handleCanvasMouseDown = { _ -> - raycaster.initializeFromMouse(context.threeCamera) - - val intersections = raycaster.intersectObjects(ships.children, true) - - responseHandler(pickRequest.verify(context, intersections)) - true - } - } - } - - handleWindowEscapeKey = { responseHandler(null) } - - GameUI.currentHelpMessage = "Press Escape to cancel current action" -} - -private fun endPick(scene: Scene) { - isPicking = false - - handleCanvasMouseMove = { _ -> } - handleCanvasMouseDown = { _ -> false } - handleWindowEscapeKey = { _ -> } - - threeCanvas.style.cursor = "auto" - - scene.getObjectByName("bound")?.removeFromParent() - scene.getObjectByName("pick-helper")?.removeFromParent() - scene.getObjectByName("pick-line")?.removeFromParent() - - GameUI.currentHelpMessage = "" -} - -private val pickMutex = Mutex() -suspend fun PickRequest.pick(context: PickContext): PickResponse? = pickMutex.withLock { - suspendCancellableCoroutine { cancellableContinuation -> - beginPick(context, this) { - endPick(context.threeScene) - cancellableContinuation.resume(it) - } - - cancellableContinuation.invokeOnCancellation { - endPick(context.threeScene) - } - } -} - -private fun PickBoundary.render(): List { - return when (this) { - is PickBoundary.Angle -> { - val position = center.vector - - val startTheta = (normalVector(midAngle) rotatedBy -maxAngle).angle - val endTheta = (normalVector(midAngle) rotatedBy maxAngle).angle - - val innerDistance = 275.0 - val outerDistance = 350.0 - - val startVec = polarVector(innerDistance, startTheta) + position - - listOf( - Shape() - .moveTo(RenderScaling.toWorldLength(startVec.x), RenderScaling.toWorldLength(startVec.y)) - .absarc(RenderScaling.toWorldLength(position.x), RenderScaling.toWorldLength(position.y), RenderScaling.toWorldLength(innerDistance), startTheta, endTheta, false) - .absarc(RenderScaling.toWorldLength(position.x), RenderScaling.toWorldLength(position.y), RenderScaling.toWorldLength(outerDistance), endTheta, startTheta, true) - .unsafeCast() - ) - } - is PickBoundary.AlongLine -> emptyList() // Handled in a special case - is PickBoundary.Rectangle -> listOf( - Shape() - .moveTo(RenderScaling.toWorldLength(center.vector.x + width2), RenderScaling.toWorldLength(center.vector.y + length2)) - .lineTo(RenderScaling.toWorldLength(center.vector.x - width2), RenderScaling.toWorldLength(center.vector.y + length2)) - .lineTo(RenderScaling.toWorldLength(center.vector.x - width2), RenderScaling.toWorldLength(center.vector.y - length2)) - .lineTo(RenderScaling.toWorldLength(center.vector.x + width2), RenderScaling.toWorldLength(center.vector.y - length2)) - .unsafeCast() - ) - is PickBoundary.Circle -> listOf( - Shape() - .ellipse( - RenderScaling.toWorldLength(center.vector.x), - RenderScaling.toWorldLength(center.vector.y), - RenderScaling.toWorldLength(radius), - RenderScaling.toWorldLength(radius), - 0, - 2 * PI, - false, - 0 - ) - .unsafeCast() - ) - is PickBoundary.WeaponsFire -> firingArcs.map { firingArc -> - val position = center.vector - - val startTheta = firingArc.getStartAngle(facing) - val endTheta = firingArc.getEndAngle(facing) - - val startPosMin = polarVector(minDistance, startTheta) + position - - Shape() - .moveTo(RenderScaling.toWorldLength(startPosMin.x), RenderScaling.toWorldLength(startPosMin.y)) - .absarc(RenderScaling.toWorldLength(position.x), RenderScaling.toWorldLength(position.y), RenderScaling.toWorldLength(minDistance), startTheta, endTheta, false) - .absarc(RenderScaling.toWorldLength(position.x), RenderScaling.toWorldLength(position.y), RenderScaling.toWorldLength(maxDistance), endTheta, startTheta, true) - .unsafeCast() - } + (if (canSelfSelect) - listOf( - Shape() - .ellipse( - RenderScaling.toWorldLength(center.vector.x), - RenderScaling.toWorldLength(center.vector.y), - RenderScaling.toWorldLength(SHIP_BASE_SIZE), - RenderScaling.toWorldLength(SHIP_BASE_SIZE), - 0, - 2 * PI, - false, - 0 - ) - .unsafeCast() - ) - else emptyList()) - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/popup.kt b/src/jsMain/kotlin/starshipfights/game/popup.kt deleted file mode 100644 index 09172fb..0000000 --- a/src/jsMain/kotlin/starshipfights/game/popup.kt +++ /dev/null @@ -1,525 +0,0 @@ -package starshipfights.game - -import kotlinx.browser.document -import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.dom.addClass -import kotlinx.dom.clear -import kotlinx.dom.removeClass -import kotlinx.html.* -import kotlinx.html.dom.append -import kotlinx.html.js.onClickFunction -import org.w3c.dom.HTMLDivElement -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.resume - -sealed class Popup { - protected abstract fun TagConsumer<*>.render(context: CoroutineContext, callback: (T) -> Unit) - private fun renderInto(consumer: TagConsumer<*>, context: CoroutineContext, callback: (T) -> Unit) { - consumer.render(context, callback) - } - - suspend fun display(): T = popupMutex.withLock { - suspendCancellableCoroutine { continuation -> - popupBox.clear() - - popupBox.append { - renderInto(this, continuation.context) { - hide() - continuation.resume(it) - } - } - - continuation.invokeOnCancellation { - hide() - } - - show() - } - } - - companion object { - private val popupMutex = Mutex() - - private val popup by lazy { - document.getElementById("popup").unsafeCast() - } - - private val popupBox by lazy { - document.getElementById("popup-box").unsafeCast() - } - - private fun show() { - popup.removeClass("hide") - } - - private fun hide() { - popup.addClass("hide") - } - } - - class ChooseAdmiralScreen(private val admirals: List) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (InGameAdmiral) -> Unit) { - if (admirals.isEmpty()) { - p { - style = "text-align:center" - +"You do not have any admirals! You can fix that by " - a("/admiral/new") { +"creating one" } - +"." - } - return - } - - p { - style = "text-align:center" - - +"Select one of your admirals to continue:" - } - - div(classes = "button-set col") { - for (admiral in admirals) { - button { - +admiral.fullName - +Entities.nbsp - img(alt = "(${admiral.faction.shortName})", src = admiral.faction.flagUrl) { - style = "width:1.2em;height:0.75em" - } - - onClickFunction = { e -> - e.preventDefault() - - callback(admiral) - } - } - } - } - - p { - style = "text-align:center" - - +"Or return to " - a(href = "/me") { +"your user page" } - +"." - } - } - } - - class MainMenuScreen(private val admiralInfo: InGameAdmiral) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (MainMenuOption?) -> Unit) { - p { - style = "text-align:center" - - img(alt = "Starship Fights", src = RenderResources.LOGO_URL) { - style = "width:70%" - } - } - - p { - style = "text-align:center" - - +"Welcome to Starship Fights! You are " - +admiralInfo.fullName - +", fighting for " - +admiralInfo.faction.getDefiniteShortName() - +". " - a(href = "#") { - +"Not you?" - onClickFunction = { e -> - e.preventDefault() - callback(null) - } - } - } - - div(classes = "button-set col") { - button { - +"Play Singleplayer Battle" - onClickFunction = { e -> - e.preventDefault() - - callback(MainMenuOption.Singleplayer) - } - } - button { - +"Host Multiplayer Battle" - onClickFunction = { e -> - e.preventDefault() - - callback(MainMenuOption.Multiplayer(GlobalSide.HOST)) - } - } - button { - +"Join Multiplayer Battle" - onClickFunction = { e -> - e.preventDefault() - - callback(MainMenuOption.Multiplayer(GlobalSide.GUEST)) - } - } - } - } - } - - class ChooseBattleSizeScreen(private val maxBattleSize: BattleSize) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (BattleSize?) -> Unit) { - p { - style = "text-align:center" - - +"Select a battle size" - } - - div(classes = "button-set col") { - for (battleSize in BattleSize.values()) { - if (battleSize <= maxBattleSize) - button { - +battleSize.displayName - +" (" - +battleSize.numPoints.toString() - +")" - onClickFunction = { e -> - e.preventDefault() - callback(battleSize) - } - } - } - button { - +"Cancel" - onClickFunction = { e -> - e.preventDefault() - callback(null) - } - } - } - } - } - - object ChooseBattleBackgroundScreen : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (BattleBackground?) -> Unit) { - p { - style = "text-align:center" - - +"Select a battle background" - } - - div(classes = "button-set col") { - for (bg in BattleBackground.values()) { - button { - +bg.displayName - onClickFunction = { e -> - e.preventDefault() - callback(bg) - } - } - } - button { - +"Cancel" - onClickFunction = { e -> - e.preventDefault() - callback(null) - } - } - } - } - } - - object ChooseEnemyFactionScreen : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (AIFactionChoice?) -> Unit) { - p { - style = "text-align:center" - - +"Select an enemy faction" - } - - div(classes = "button-set col") { - button { - +"Random" - onClickFunction = { e -> - e.preventDefault() - callback(AIFactionChoice.Random) - } - } - for (faction in Faction.values()) { - button { - +faction.navyName - +Entities.nbsp - img(alt = faction.shortName, src = faction.flagUrl) { - style = "width:1.2em;height:0.75em" - } - - onClickFunction = { e -> - e.preventDefault() - callback(AIFactionChoice.Chosen(faction)) - } - } - } - button { - +"Cancel" - onClickFunction = { e -> - e.preventDefault() - callback(null) - } - } - } - } - } - - class GuestRequestScreen(private val hostInfo: InGameAdmiral, private val guestInfo: InGameAdmiral) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Boolean?) -> Unit) { - p { - style = "text-align:center" - - +guestInfo.fullName - +" (" - +guestInfo.user.username - +") wishes to join your battle." - } - table { - style = "table-layout:fixed;width:100%" - - tr { - th { +"HOST" } - th { +"GUEST" } - } - tr { - td { - style = "text-align:center" - - img(alt = hostInfo.faction.shortName, src = hostInfo.faction.flagUrl) { style = "width:65%;" } - } - td { - style = "text-align:center" - - img(alt = guestInfo.faction.shortName, src = guestInfo.faction.flagUrl) { style = "width:65%;" } - } - } - tr { - td { - style = "text-align:center" - - +hostInfo.fullName - } - td { - style = "text-align:center" - - +guestInfo.fullName - } - } - tr { - td { - style = "text-align:center" - - +"(${hostInfo.user.username})" - } - td { - style = "text-align:center" - - +"(${guestInfo.user.username})" - } - } - } - - div(classes = "button-set row") { - button { - +"Accept" - onClickFunction = { e -> - e.preventDefault() - callback(true) - } - } - button { - +"Reject" - onClickFunction = { e -> - e.preventDefault() - callback(false) - } - } - button { - +"Cancel" - onClickFunction = { e -> - e.preventDefault() - callback(null) - } - } - } - } - } - - class HostSelectScreen(private val hosts: Map) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (String?) -> Unit) { - table { - style = "table-layout:fixed;width:100%" - - tr { - th { +"Host Player" } - th { +"Host Admiral" } - th { +"Host Faction" } - th { +"Battle Size" } - th { +"Battle Background" } - th { +Entities.nbsp } - } - for ((id, joinable) in hosts) { - tr { - td { - style = "text-align:center" - - +joinable.admiral.user.username - } - td { - style = "text-align:center" - - +joinable.admiral.fullName - } - td { - style = "text-align:center" - - img(alt = joinable.admiral.faction.shortName, src = joinable.admiral.faction.flagUrl) { - style = "width:4em;height:2.5em" - } - } - td { - style = "text-align:center" - - +joinable.battleInfo.size.displayName - +" (" - +joinable.battleInfo.size.numPoints.toString() - +")" - } - td { - style = "text-align:center" - - +joinable.battleInfo.bg.displayName - } - td { - style = "text-align:center" - - a(href = "#") { - +"Join" - onClickFunction = { e -> - e.preventDefault() - callback(id) - } - } - } - } - } - tr { - td { +Entities.nbsp } - td { +Entities.nbsp } - td { +Entities.nbsp } - td { +Entities.nbsp } - td { +Entities.nbsp } - td { - style = "text-align:center" - - a(href = "#") { - +"Cancel" - onClickFunction = { e -> - e.preventDefault() - callback(null) - } - } - } - } - } - } - } - - class JoinRejectedScreen(private val hostInfo: InGameAdmiral) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Unit) -> Unit) { - p { - style = "text-align:center" - - +hostInfo.fullName - +" has rejected your request to join." - } - - div(classes = "button-set row") { - button { - +"Continue" - onClickFunction = { e -> - e.preventDefault() - callback(Unit) - } - } - } - } - } - - class LoadingScreen(private val loadingText: String, private val loadAction: suspend () -> T) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (T) -> Unit) { - p { - style = "text-align:center" - - +loadingText - } - - AppScope.launch(context) { - callback(loadAction()) - } - } - } - - class CancellableLoadingScreen(private val loadingText: String, private val loadAction: suspend () -> T) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (T?) -> Unit) { - p { - style = "text-align:center" - - +loadingText - } - - val loading = AppScope.launch(context) { - callback(loadAction()) - } - - div(classes = "button-set row") { - button { - +"Cancel" - onClickFunction = { e -> - e.preventDefault() - - loading.cancel() - callback(null) - } - } - } - } - } - - class Error(private val errorMessage: String) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Nothing) -> Unit) { - p(classes = "error") { - style = "text-align:center" - - +errorMessage - } - } - } - - class GameOver(private val winner: GlobalSide?, private val outcome: String, private val subplotStatuses: Map, private val finalState: GameState) : Popup() { - override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Nothing) -> Unit) { - p { - style = "text-align:center" - - strong(classes = "heading") { - +"${victoryTitle(mySide, winner, subplotStatuses)}!" - } - } - p { - style = "text-align:center" - - +outcome - } - p { - style = "text-align:center" - - val admiralId = finalState.admiralInfo(mySide).id - - a(href = "/admiral/${admiralId}") { - +"Exit Battle" - } - } - } - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/popup_util.kt b/src/jsMain/kotlin/starshipfights/game/popup_util.kt deleted file mode 100644 index ea8bbce..0000000 --- a/src/jsMain/kotlin/starshipfights/game/popup_util.kt +++ /dev/null @@ -1,46 +0,0 @@ -package starshipfights.game - -sealed class MainMenuOption { - object Singleplayer : MainMenuOption() - - data class Multiplayer(val side: GlobalSide) : MainMenuOption() -} - -sealed class AIFactionChoice { - object Random : AIFactionChoice() - - data class Chosen(val faction: Faction) : AIFactionChoice() -} - -private suspend fun Popup.Companion.getPlayerInfo(admirals: List): InGameAdmiral { - return Popup.ChooseAdmiralScreen(admirals).display() -} - -private suspend fun Popup.Companion.getBattleInfo(admiral: InGameAdmiral): BattleInfo? { - val battleSize = Popup.ChooseBattleSizeScreen(admiral.rank.maxBattleSize).display() ?: return null - val battleBackground = Popup.ChooseBattleBackgroundScreen.display() ?: return getBattleInfo(admiral) - return BattleInfo(battleSize, battleBackground) -} - -private suspend fun Popup.Companion.getTrainingInfo(admiral: InGameAdmiral): LoginMode? { - val battleInfo = getBattleInfo(admiral) ?: return getLoginMode(admiral) - val faction = Popup.ChooseEnemyFactionScreen.display() ?: return getLoginMode(admiral) - return LoginMode.Train(battleInfo, (faction as? AIFactionChoice.Chosen)?.faction) -} - -private suspend fun Popup.Companion.getLoginMode(admiral: InGameAdmiral): LoginMode? { - val mainMenuOption = Popup.MainMenuScreen(admiral).display() ?: return null - return when (mainMenuOption) { - MainMenuOption.Singleplayer -> getTrainingInfo(admiral) - is MainMenuOption.Multiplayer -> when (mainMenuOption.side) { - GlobalSide.HOST -> LoginMode.Host(getBattleInfo(admiral) ?: return getLoginMode(admiral)) - GlobalSide.GUEST -> LoginMode.Join - } - } -} - -suspend fun Popup.Companion.getPlayerLogin(admirals: List, cachedAdmiral: InGameAdmiral? = null): PlayerLogin { - val admiral = cachedAdmiral ?: getPlayerInfo(admirals) - val loginMode = getLoginMode(admiral) ?: return getPlayerLogin(admirals, null) - return PlayerLogin(admiral.id, loginMode) -} diff --git a/src/jsMain/kotlin/starshipfights/game/ship_selecting.kt b/src/jsMain/kotlin/starshipfights/game/ship_selecting.kt deleted file mode 100644 index 850334d..0000000 --- a/src/jsMain/kotlin/starshipfights/game/ship_selecting.kt +++ /dev/null @@ -1,55 +0,0 @@ -package starshipfights.game - -import externals.threejs.Group -import externals.threejs.Raycaster -import kotlinx.browser.document -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import org.w3c.dom.HTMLCanvasElement -import org.w3c.dom.events.MouseEvent -import starshipfights.data.Id - -val threeCanvas = document.getElementById("three-canvas").unsafeCast() - -private val selectedShipMutable = MutableStateFlow?>(null) -val selectedShip = selectedShipMutable.asStateFlow() - -var isSelecting: Boolean = false - private set - -fun beginSelecting(context: PickContext) { - if (isSelecting) return - isSelecting = true - - val raycaster = Raycaster() - - val ships = context.threeScene.getObjectByName("ships").unsafeCast() - - threeCanvas.addEventListener("mousedown", { e -> - if (isPicking) return@addEventListener - if (e.unsafeCast().button.toInt() != 0) return@addEventListener - - raycaster.initializeFromMouse(context.threeCamera) - val intersections = raycaster.intersectObjects(ships.children, true) - selectedShipMutable.value = intersections.firstOrNull()?.`object`?.userData.unsafeCast()?.shipId - }) -} - -suspend fun handleSelections(context: PickContext) { - val ships = context.threeScene.getObjectByName("ships").unsafeCast() - - var prevId: Id? = null - selectedShip.collect { shipId -> - prevId?.let { id -> - ships.getObjectByName(id.toString())?.userData.unsafeCast()?.shipOutline?.visible = false - } - - shipId?.let { id -> - ships.getObjectByName(id.toString())?.userData.unsafeCast()?.shipOutline?.visible = true - } - - prevId = shipId - - GameUI.changeShipSelection(context.getGameState(), shipId) - } -} diff --git a/src/jsMain/kotlin/starshipfights/game/util_js.kt b/src/jsMain/kotlin/starshipfights/game/util_js.kt deleted file mode 100644 index fa5eb96..0000000 --- a/src/jsMain/kotlin/starshipfights/game/util_js.kt +++ /dev/null @@ -1,73 +0,0 @@ -package starshipfights.game - -import io.ktor.http.cio.websocket.* -import kotlinx.browser.window -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.flow.* -import kotlinx.coroutines.isActive -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.SerializationStrategy -import org.w3c.dom.Window -import org.w3c.dom.events.Event -import org.w3c.dom.events.EventListener -import org.w3c.dom.events.EventTarget -import kotlin.coroutines.resume - -inline fun configure(builder: T.() -> Unit) = js("{}").unsafeCast().apply(builder) - -val Window.aspectRatio: Double - get() = innerWidth.toDouble() / innerHeight - -suspend fun EventTarget.awaitEvent(eventName: String, shouldPreventDefault: Boolean = false): Event = suspendCancellableCoroutine { continuation -> - val listener = object : EventListener { - override fun handleEvent(event: Event) { - if (shouldPreventDefault) - event.preventDefault() - - removeEventListener(eventName, this) - continuation.resume(event) - } - } - - continuation.invokeOnCancellation { - removeEventListener(eventName, listener) - } - - addEventListener(eventName, listener) -} - -suspend fun awaitAnimationFrame(): Double = suspendCancellableCoroutine { continuation -> - val handle = window.requestAnimationFrame { t -> - continuation.resume(t) - } - - continuation.invokeOnCancellation { - window.cancelAnimationFrame(handle) - } -} - -val deltaTimeFlow: Flow - get() = flow { - var prevTime = awaitAnimationFrame() - while (currentCoroutineContext().isActive) { - val currTime = awaitAnimationFrame() - emit((currTime - prevTime) / 1000.0) - prevTime = currTime - } - } - -suspend inline fun DefaultWebSocketSession.receiveObject(serializer: DeserializationStrategy, exitOnError: () -> Nothing): T { - val text = incoming.receiveAsFlow().filterIsInstance().firstOrNull()?.readText() ?: exitOnError() - return jsonSerializer.decodeFromString(serializer, text) -} - -suspend inline fun DefaultWebSocketSession.sendObject(serializer: SerializationStrategy, value: T) { - outgoing.send(Frame.Text(jsonSerializer.encodeToString(serializer, value))) - flush() -} - -suspend inline fun DefaultWebSocketSession.closeAndReturn(closeMessage: String = "", exitFunction: () -> Nothing): Nothing { - close(CloseReason(CloseReason.Codes.NORMAL, closeMessage)) - exitFunction() -} diff --git a/src/jvmMain/kotlin/net/starshipfights/auth/providers.kt b/src/jvmMain/kotlin/net/starshipfights/auth/providers.kt new file mode 100644 index 0000000..4a7c825 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/auth/providers.kt @@ -0,0 +1,637 @@ +package net.starshipfights.auth + +import io.ktor.application.* +import io.ktor.auth.* +import io.ktor.client.* +import io.ktor.client.engine.apache.* +import io.ktor.client.features.* +import io.ktor.client.request.* +import io.ktor.features.* +import io.ktor.html.* +import io.ktor.http.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.sessions.* +import io.ktor.util.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.html.* +import kotlinx.serialization.Serializable +import net.starshipfights.CurrentConfiguration +import net.starshipfights.DiscordLogin +import net.starshipfights.data.Id +import net.starshipfights.data.admiralty.* +import net.starshipfights.data.auth.PreferredTheme +import net.starshipfights.data.auth.User +import net.starshipfights.data.auth.UserSession +import net.starshipfights.data.createNonce +import net.starshipfights.forbid +import net.starshipfights.game.Faction +import net.starshipfights.game.ShipType +import net.starshipfights.game.toUrlSlug +import net.starshipfights.info.* +import net.starshipfights.redirect +import org.litote.kmongo.* +import java.time.Instant +import java.time.temporal.ChronoUnit + +const val PROFILE_NAME_MAX_LENGTH = 32 +const val PROFILE_BIO_MAX_LENGTH = 240 +const val ADMIRAL_NAME_MAX_LENGTH = 48 +const val SHIP_NAME_MAX_LENGTH = 48 + +interface AuthProvider { + fun installApplication(app: Application) = Unit + fun installAuth(conf: Authentication.Configuration) + fun installRouting(conf: Routing) + + companion object Installer { + private val newCurrentProvider: AuthProvider + get() = CurrentConfiguration.discordClient?.let { ProductionAuthProvider(it) } ?: TestAuthProvider + + private var cachedCurrentProvider: AuthProvider? = null + + val currentProvider: AuthProvider + get() = cachedCurrentProvider ?: newCurrentProvider.also { cachedCurrentProvider = it } + + fun install(into: Application) { + currentProvider.installApplication(into) + + into.install(Sessions) { + cookie>("sf_user_session") { + serializer = UserSessionIdSerializer + transform(SessionTransportTransformerMessageAuthentication(hex(CurrentConfiguration.secretHashingKey))) + + cookie.path = "/" + cookie.extensions["Secure"] = null + cookie.extensions["SameSite"] = "Lax" + } + } + + into.install(Authentication) { + session>("session") { + validate { id -> + val userAgent = request.userAgent() ?: return@validate null + id.resolve(userAgent)?.let { sess -> + User.get(sess.user)?.let { user -> + sess.renewed(request.origin.remoteHost, user) + } + } + } + challenge("/login") + } + + currentProvider.installAuth(this) + } + + into.routing { + get("/me") { + val redirectTo = call.getUserSession()?.let { sess -> + "/user/${sess.user}" + } ?: "/login" + + redirect(redirectTo) + } + + get("/me/manage") { + call.respondHtml(HttpStatusCode.OK, call.manageUserPage()) + } + + get("/me/private-info") { + call.respondHtml(HttpStatusCode.OK, call.privateInfoPage()) + } + + get("/me/private-info/txt") { + call.respondText(ContentType.Text.Plain, HttpStatusCode.OK) { call.privateInfo() } + } + + post("/me/manage") { + val form = call.receiveValidatedParameters() + val currentUser = call.getUser() ?: redirect("/login") + + val newUser = currentUser.copy( + showDiscordName = form["showdiscord"] == "yes", + showUserStatus = form["showstatus"] == "yes", + logIpAddresses = form["logaddress"] == "yes", + profileName = form["name"]?.takeIf { it.isNotBlank() && it.length <= PROFILE_NAME_MAX_LENGTH } ?: redirect("/me/manage" + withErrorMessage("Invalid name - must not be blank, must be at most $PROFILE_NAME_MAX_LENGTH characters")), + profileBio = form["bio"]?.takeIf { it.isNotBlank() && it.length <= PROFILE_BIO_MAX_LENGTH } ?: redirect("/me/manage" + withErrorMessage("Invalid bio - must not be blank, must be at most $PROFILE_BIO_MAX_LENGTH characters")), + preferredTheme = form["theme"]?.uppercase()?.takeIf { it in PreferredTheme.values().map { it.name } }?.let { PreferredTheme.valueOf(it) } ?: currentUser.preferredTheme + ) + User.put(newUser) + + if (!newUser.logIpAddresses) + launch { + UserSession.update( + UserSession::user eq currentUser.id, + setValue(UserSession::clientAddresses, emptyList()) + ) + } + + redirect("/user/${newUser.id}") + } + + get("/user/{id}") { + call.respondHtml(HttpStatusCode.OK, call.userPage()) + } + + get("/admiral/new") { + call.respondHtml(HttpStatusCode.OK, call.createAdmiralPage()) + } + + post("/admiral/new") { + val form = call.receiveValidatedParameters() + val currentUser = call.getUserSession()?.user ?: redirect("/login") + + val faction = Faction.valueOf(form.getOrFail("faction")) + val newAdmiral = Admiral( + owningUser = currentUser, + name = form["name"]?.takeIf { it.isNotBlank() && it.length <= ADMIRAL_NAME_MAX_LENGTH } ?: redirect("/me/manage" + withErrorMessage("That name is not valid - must not be blank, must not be longer than $ADMIRAL_NAME_MAX_LENGTH characters")), + isFemale = form.getOrFail("sex") == "female" || faction == Faction.FELINAE_FELICES, + faction = faction, + acumen = if (CurrentConfiguration.isDevEnv) 20_000 else 0, + money = 500 + ) + val newShips = generateFleet(newAdmiral) + + coroutineScope { + launch { Admiral.put(newAdmiral) } + launch { ShipInDrydock.put(newShips) } + } + + redirect("/admiral/${newAdmiral.id}") + } + + get("/admiral/{id}") { + call.respondHtml(HttpStatusCode.OK, call.admiralPage()) + } + + get("/admiral/{id}/manage") { + call.respondHtml(HttpStatusCode.OK, call.manageAdmiralPage()) + } + + post("/admiral/{id}/manage") { + val form = call.receiveValidatedParameters() + + val currentUser = call.getUserSession()?.user + val admiralId = call.parameters["id"]?.let { Id(it) }!! + val admiral = Admiral.get(admiralId)!! + + if (admiral.owningUser != currentUser) forbid() + + val newAdmiral = admiral.copy( + name = form["name"]?.takeIf { it.isNotBlank() && it.length <= ADMIRAL_NAME_MAX_LENGTH } ?: redirect("/me/manage" + withErrorMessage("That name is not valid - must not be blank, must not be longer than $ADMIRAL_NAME_MAX_LENGTH characters")), + isFemale = form["sex"] == "female" || admiral.faction == Faction.FELINAE_FELICES + ) + + Admiral.put(newAdmiral) + redirect("/admiral/$admiralId") + } + + get("/admiral/{id}/rename/{ship}") { + call.respondHtml(HttpStatusCode.OK, call.renameShipPage()) + } + + post("/admiral/{id}/rename/{ship}") { + val formParams = call.receiveValidatedParameters() + val currentUser = call.getUserSession()?.user + + val admiralId = call.parameters["id"]?.let { Id(it) }!! + val shipId = call.parameters["ship"]?.let { Id(it) }!! + + val (admiral, ship) = coroutineScope { + val admiral = async { Admiral.get(admiralId)!! } + val ship = async { ShipInDrydock.get(shipId)!! } + admiral.await() to ship.await() + } + + if (admiral.owningUser != currentUser) forbid() + if (ship.owningAdmiral != admiralId) forbid() + + val newName = formParams["name"]?.takeIf { it.isNotBlank() && it.length <= SHIP_NAME_MAX_LENGTH } ?: redirect("/admiral/${admiralId}/manage" + withErrorMessage("That name is not valid - must not be blank, must not be longer than $SHIP_NAME_MAX_LENGTH characters")) + ShipInDrydock.set(shipId, setValue(ShipInDrydock::name, newName)) + + redirect("/admiral/${admiralId}/manage") + } + + get("/admiral/{id}/sell/{ship}") { + call.respondHtml(HttpStatusCode.OK, call.sellShipConfirmPage()) + } + + post("/admiral/{id}/sell/{ship}") { + call.receiveValidatedParameters() + + val currentUser = call.getUserSession()?.user + + val admiralId = call.parameters["id"]?.let { Id(it) }!! + val shipId = call.parameters["ship"]?.let { Id(it) }!! + + val (admiral, ship) = coroutineScope { + val admiral = async { Admiral.get(admiralId)!! } + val ship = async { ShipInDrydock.get(shipId)!! } + admiral.await() to ship.await() + } + + if (admiral.owningUser != currentUser) forbid() + if (ship.owningAdmiral != admiralId) forbid() + + if (ship.readyAt > Instant.now()) redirect("/admiral/${admiralId}/manage" + withErrorMessage("Cannot sell ships that are not ready for battle")) + if (ship.shipType.weightClass.isUnique) redirect("/admiral/${admiralId}/manage" + withErrorMessage("Cannot sell a ${ship.shipType.fullDisplayName}")) + + coroutineScope { + launch { ShipInDrydock.del(shipId) } + launch { Admiral.set(admiralId, inc(Admiral::money, ship.shipType.sellPrice)) } + } + + redirect("/admiral/${admiralId}/manage") + } + + get("/admiral/{id}/buy/{ship}") { + call.respondHtml(HttpStatusCode.OK, call.buyShipConfirmPage()) + } + + post("/admiral/{id}/buy/{ship}") { + call.receiveValidatedParameters() + + val currentUser = call.getUserSession()?.user + val admiralId = call.parameters["id"]?.let { Id(it) }!! + val admiral = Admiral.get(admiralId)!! + + if (admiral.owningUser != currentUser) forbid() + val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() + + val shipType = call.parameters["ship"]?.let { param -> ShipType.values().singleOrNull { it.toUrlSlug() == param } }!! + val shipPrice = shipType.buyPrice(admiral, ownedShips) ?: throw NotFoundException() + + if (shipPrice > admiral.money) + redirect("/admiral/${admiralId}/manage" + withErrorMessage("You cannot afford that ship")) + + val shipNames = ownedShips.map { it.name }.toMutableSet() + val newShipName = newShipName(shipType.faction, shipType.weightClass, shipNames) ?: nameShip(shipType.faction, shipType.weightClass) + + val newShip = ShipInDrydock( + name = newShipName, + shipType = shipType, + readyAt = Instant.now().plus(2, ChronoUnit.HOURS), + owningAdmiral = admiralId + ) + + coroutineScope { + launch { ShipInDrydock.put(newShip) } + launch { Admiral.set(admiralId, inc(Admiral::money, -shipPrice)) } + } + + redirect("/admiral/${admiralId}/manage") + } + + get("/admiral/{id}/delete") { + call.respondHtml(HttpStatusCode.OK, call.deleteAdmiralConfirmPage()) + } + + post("/admiral/{id}/delete") { + call.receiveValidatedParameters() + + val currentUser = call.getUserSession()?.user + val admiralId = call.parameters["id"]?.let { Id(it) }!! + val admiral = Admiral.get(admiralId)!! + + if (admiral.owningUser != currentUser) forbid() + + coroutineScope { + launch { Admiral.del(admiralId) } + launch { ShipInDrydock.remove(ShipInDrydock::owningAdmiral eq admiralId) } + } + + redirect("/me") + } + + post("/logout") { + call.receiveValidatedParameters() + + call.getUserSession()?.let { sess -> + launch { + val newTime = Instant.now().minusMillis(100) + UserSession.update(UserSession::id eq sess.id, setValue(UserSession::expiration, newTime)) + } + } + + call.sessions.clear>() + redirect("/") + } + + post("/logout/{id}") { + call.receiveValidatedParameters() + + val id = Id(call.parameters.getOrFail("id")) + call.getUserSession()?.let { sess -> + launch { + val newTime = Instant.now().minusMillis(100) + UserSession.update(and(UserSession::id eq id, UserSession::user eq sess.user), setValue(UserSession::expiration, newTime)) + } + } + + redirect("/me/manage") + } + + post("/logout-all") { + call.receiveValidatedParameters() + + call.getUserSession()?.let { sess -> + launch { + val newTime = Instant.now().minusMillis(100) + UserSession.update(and(UserSession::user eq sess.user, UserSession::id ne sess.id), setValue(UserSession::expiration, newTime)) + } + } + + redirect("/me/manage") + } + + post("/clear-expired/{id}") { + call.receiveValidatedParameters() + + val id = Id(call.parameters.getOrFail("id")) + call.getUserSession()?.let { sess -> + launch { + val now = Instant.now() + UserSession.remove(and(UserSession::id eq id, UserSession::user eq sess.user, UserSession::expiration lte now)) + } + } + + redirect("/me/manage") + } + + post("/clear-all-expired") { + call.receiveValidatedParameters() + + call.getUserSession()?.let { sess -> + launch { + val now = Instant.now() + UserSession.remove(and(UserSession::user eq sess.user, UserSession::expiration lte now)) + } + } + + redirect("/me/manage") + } + + currentProvider.installRouting(this) + } + } + } +} + +object TestAuthProvider : AuthProvider { + private const val USERNAME_KEY = "username" + private const val PASSWORD_KEY = "password" + + private const val PASSWORD_VALUE = "very secure" + + override fun installApplication(app: Application) { + app.install(DoubleReceive) + } + + override fun installAuth(conf: Authentication.Configuration) { + with(conf) { + form("test-auth") { + userParamName = USERNAME_KEY + passwordParamName = PASSWORD_KEY + validate { credentials -> + val originAddress = request.origin.remoteHost + val userAgent = request.userAgent() + if (userAgent != null && credentials.name.isNotBlank() && credentials.password == PASSWORD_VALUE) { + val user = User.locate(User::discordId eq credentials.name) + ?: User( + discordId = credentials.name, + discordName = "", + discordDiscriminator = "", + discordAvatar = null, + showDiscordName = false, + profileName = credentials.name, + profileBio = "BEEP BOOP I EXIST ONLY FOR TESTING BLOP BLARP.", + registeredAt = Instant.now(), + lastActivity = Instant.now(), + showUserStatus = false, + logIpAddresses = false, + ).also { + User.put(it) + } + + UserSession( + user = user.id, + clientAddresses = listOf(originAddress), + userAgent = userAgent, + expiration = newExpiration() + ).also { + UserSession.put(it) + } + } else + null + } + challenge { credentials -> + val errorMsg = if (call.request.userAgent() == null) + "User-Agent must be specified when logging in. Are you using some weird API client?" + else if (credentials == null || credentials.name.isBlank()) + "A username must be provided." + else if (credentials.password != PASSWORD_VALUE) + "Password is incorrect." + else + "An unknown error occurred." + + val redirectUrl = "/login" + withErrorMessage(errorMsg) + call.respondRedirect(redirectUrl) + } + } + } + } + + override fun installRouting(conf: Routing) { + with(conf) { + get("/login") { + if (call.getUserSession() != null) + redirect("/me") + + call.respondHtml(HttpStatusCode.OK, call.page("Authentication Test", call.standardNavBar(), CustomSidebar { + p { + +"This instance does not have Discord OAuth login set up. As a fallback, this authentication mode is used for testing only." + } + }) { + section { + h1 { +"Authentication Test" } + form(action = "/login", method = FormMethod.post) { + h3 { + label { + this.htmlFor = USERNAME_KEY + +"Username" + } + } + textInput { + id = USERNAME_KEY + name = USERNAME_KEY + autoComplete = false + + required = true + } + call.request.queryParameters["error"]?.let { errorMsg -> + p { + style = "color:#d22" + +errorMsg + } + } + submitInput { + value = "Authenticate" + } + hiddenInput { + name = PASSWORD_KEY + value = PASSWORD_VALUE + } + } + } + }) + } + + authenticate("test-auth") { + post("/login") { + call.principal()?.id?.let { sessionId -> + call.sessions.set(sessionId) + } + redirect("/me") + } + } + } + } +} + +class ProductionAuthProvider(private val discordLogin: DiscordLogin) : AuthProvider { + private val httpClient = HttpClient(Apache) { + install(UserAgent) { + agent = discordLogin.userAgent + } + + install(RateLimit) { + jsonCodec = JsonClientCodec + } + } + + override fun installAuth(conf: Authentication.Configuration) { + conf.oauth("auth-oauth-discord") { + urlProvider = { "https://starshipfights.net/login/discord/callback" } + providerLookup = { + OAuthServerSettings.OAuth2ServerSettings( + name = "discord", + authorizeUrl = "https://discord.com/api/oauth2/authorize", + accessTokenUrl = "https://discord.com/api/oauth2/token", + requestMethod = HttpMethod.Post, + clientId = discordLogin.clientId, + clientSecret = discordLogin.clientSecret, + defaultScopes = listOf("identify"), + nonceManager = StateParameterManager + ) + } + client = httpClient + } + } + + override fun installRouting(conf: Routing) { + with(conf) { + get("/login") { + call.respondHtml(HttpStatusCode.OK, call.page("Login with Discord", call.standardNavBar()) { + section { + p { + style = "text-align:center" + +"By logging in, you indicate your agreement to the " + a(href = "/about/tnc") { +"Terms and Conditions" } + +" and the " + a(href = "/about/pp") { +"Privacy Policy" } + +"." + } + call.request.queryParameters["error"]?.let { errorMsg -> + p { + style = "color:#d22" + +errorMsg + } + } + p { + style = "text-align:center" + a(href = "/login/discord") { +"Continue to Discord" } + } + } + }) + } + + authenticate("auth-oauth-discord") { + get("/login/discord") { + // Redirects to 'authorizeUrl' automatically + } + + get("/login/discord/callback") { + val userAgent = call.request.userAgent() ?: forbid() + val principal: OAuthAccessTokenResponse.OAuth2 = call.principal() ?: redirect("/login") + val userInfoJson = httpClient.get("https://discord.com/api/users/@me") { + headers { + append(HttpHeaders.Authorization, "Bearer ${principal.accessToken}") + } + } + + val userInfo = JsonClientCodec.decodeFromString(DiscordUserInfo.serializer(), userInfoJson) + val (discordId, discordUsername, discordDiscriminator, discordAvatar) = userInfo + + var redirectTo = "/me" + + val user = User.locate(User::discordId eq discordId)?.copy( + discordName = discordUsername, + discordDiscriminator = discordDiscriminator, + discordAvatar = discordAvatar + ) ?: User( + discordId = discordId, + discordName = discordUsername, + discordDiscriminator = discordDiscriminator, + discordAvatar = discordAvatar, + showDiscordName = false, + profileName = discordUsername, + profileBio = "Hi, I'm new here!", + registeredAt = Instant.now(), + lastActivity = Instant.now(), + showUserStatus = false, + logIpAddresses = false, + ).also { redirectTo = "/me/manage" } + + val userSession = UserSession( + user = user.id, + clientAddresses = if (user.logIpAddresses) listOf(call.request.origin.remoteHost) else emptyList(), + userAgent = userAgent, + expiration = newExpiration() + ) + + coroutineScope { + launch { User.put(user) } + launch { UserSession.put(userSession) } + } + + call.sessions.set(userSession.id) + redirect(redirectTo) + } + } + } + } +} + +object StateParameterManager : NonceManager { + private val nonces = mutableSetOf() + + override suspend fun newNonce(): String { + return createNonce().also { nonces += it } + } + + override suspend fun verifyNonce(nonce: String): Boolean { + return nonces.remove(nonce) + } +} + +@Serializable +data class DiscordUserInfo( + val id: String, + val username: String, + val discriminator: String, + val avatar: String +) diff --git a/src/jvmMain/kotlin/net/starshipfights/auth/ratelimit.kt b/src/jvmMain/kotlin/net/starshipfights/auth/ratelimit.kt new file mode 100644 index 0000000..ea00971 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/auth/ratelimit.kt @@ -0,0 +1,71 @@ +package net.starshipfights.auth + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.features.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.util.* +import kotlinx.coroutines.delay +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import net.starshipfights.rateLimit +import kotlin.math.roundToLong + +class RateLimit( + val jsonCodec: Json, + val remainingHeader: String, + val resetAfterHeader: String, +) { + class Config { + var jsonCodec: Json = Json.Default + var remainingHeader: String = "X-RateLimit-Remaining" + var resetAfterHeader: String = "X-RateLimit-Reset-After" + } + + private var remainingRequests = -1 + private var resetAfter = 0.0 + + companion object Feature : HttpClientFeature { + override val key: AttributeKey = AttributeKey("RateLimit") + override fun prepare(block: Config.() -> Unit): RateLimit = Config().apply(block).run { + RateLimit(jsonCodec, remainingHeader, resetAfterHeader) + } + + override fun install(feature: RateLimit, scope: HttpClient) { + scope.requestPipeline.intercept(HttpRequestPipeline.Before) { + feature.remainingRequests.takeIf { it >= 0 }?.let { remaining -> + delay((feature.resetAfter * 1000 / (remaining + 1)).roundToLong()) + } + } + + scope.responsePipeline.intercept(HttpResponsePipeline.Receive) { + if (context.response.status == HttpStatusCode.TooManyRequests) { + feature.remainingRequests = 0 + val jsonBody = context.response.receive() + val rateLimitedResponse = feature.jsonCodec.decodeFromString(RateLimitedResponse.serializer(), jsonBody) + feature.resetAfter = rateLimitedResponse.retryAfter + + rateLimit() + } else { + context.response.headers[feature.remainingHeader]?.toIntOrNull()?.let { + feature.remainingRequests = it + } + + context.response.headers[feature.resetAfterHeader]?.toDoubleOrNull()?.let { + feature.resetAfter = it + } + } + } + } + } +} + +@Serializable +data class RateLimitedResponse( + val message: String, + @SerialName("retry_after") + val retryAfter: Double +) diff --git a/src/jvmMain/kotlin/net/starshipfights/auth/utils.kt b/src/jvmMain/kotlin/net/starshipfights/auth/utils.kt new file mode 100644 index 0000000..27f015f --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/auth/utils.kt @@ -0,0 +1,92 @@ +package net.starshipfights.auth + +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.http.* +import io.ktor.request.* +import io.ktor.sessions.* +import io.ktor.util.* +import kotlinx.serialization.json.Json +import net.starshipfights.data.Id +import net.starshipfights.data.auth.User +import net.starshipfights.data.auth.UserSession +import net.starshipfights.data.createNonce +import net.starshipfights.invalidCsrfToken +import net.starshipfights.redirect +import java.time.Instant +import java.time.temporal.ChronoUnit + +suspend fun Id.resolve(userAgent: String) = UserSession.get(this)?.takeIf { session -> + session.userAgent == userAgent && session.expiration > Instant.now() +} + +fun newExpiration(): Instant = Instant.now().plus(2, ChronoUnit.HOURS) + +suspend fun UserSession.renewed(clientAddress: String, userData: User) = copy( + expiration = newExpiration(), + clientAddresses = if (!userData.logIpAddresses) + emptyList() + else if (clientAddresses.lastOrNull() != clientAddress) + clientAddresses + clientAddress + else + clientAddresses +).also { UserSession.put(it) } + +suspend fun User.updated() = copy( + lastActivity = Instant.now() +).also { User.put(it) } + +val UserAndSessionAttribute = AttributeKey>("SfUserAndSession") + +suspend fun ApplicationCall.getUserSession() = getUserAndSession().first + +suspend fun ApplicationCall.getUser() = getUserAndSession().second + +suspend fun ApplicationCall.getUserAndSession() = attributes.getOrNull(UserAndSessionAttribute) + ?: request.userAgent()?.let { sessions.get>()?.resolve(it) } + ?.let { sess -> User.get(sess.user)?.let { user -> sess.renewed(request.origin.remoteHost, user) to user.updated() } } + ?.also { attributes.put(UserAndSessionAttribute, it) } + ?: (null to null) + +object UserSessionIdSerializer : SessionSerializer> { + override fun serialize(session: Id): String { + return session.id + } + + override fun deserialize(text: String): Id { + return Id(text) + } +} + +data class CsrfInput(val cookie: Id, val target: String) + +object CsrfProtector { + private val nonces = mutableMapOf() + + const val csrfInputName = "csrf-token" + + fun newNonce(token: Id, action: String): String { + return createNonce().also { nonces[it] = CsrfInput(token, action) } + } + + fun verifyNonce(nonce: String, token: Id, action: String): Boolean { + return nonces.remove(nonce) == CsrfInput(token, action) + } +} + +suspend fun ApplicationCall.receiveValidatedParameters(): Parameters { + val formInput = receiveParameters() + val sessionId = sessions.get>() ?: redirect("/login") + val csrfToken = formInput.getOrFail(CsrfProtector.csrfInputName) + + if (CsrfProtector.verifyNonce(csrfToken, sessionId, request.uri)) + return formInput + else + invalidCsrfToken() +} + +val JsonClientCodec = Json { + ignoreUnknownKeys = true +} + +fun withErrorMessage(message: String) = "?${parametersOf("error", message).formUrlEncode()}" diff --git a/src/jvmMain/kotlin/net/starshipfights/data/admiralty/admirals.kt b/src/jvmMain/kotlin/net/starshipfights/data/admiralty/admirals.kt new file mode 100644 index 0000000..7c13054 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/data/admiralty/admirals.kt @@ -0,0 +1,163 @@ +package net.starshipfights.data.admiralty + +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.toList +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.starshipfights.data.DataDocument +import net.starshipfights.data.DocumentTable +import net.starshipfights.data.Id +import net.starshipfights.data.auth.User +import net.starshipfights.data.invoke +import net.starshipfights.game.* +import org.bson.conversions.Bson +import org.litote.kmongo.* +import java.time.Instant + +@Serializable +data class Admiral( + @SerialName("_id") + override val id: Id = Id(), + + val owningUser: Id, + + val name: String, + val isFemale: Boolean, + + val faction: Faction, + val acumen: Int, + val money: Int, +) : DataDocument { + val rank: AdmiralRank + get() = AdmiralRank.fromAcumen(acumen) + + val fullName: String + get() = "${rank.getDisplayName(faction)} $name" + + companion object Table : DocumentTable by DocumentTable.create({ + index(Admiral::owningUser) + }) +} + +fun genAI(faction: Faction, forBattleSize: BattleSize) = Admiral( + id = Id("advanced_robotical_admiral"), + owningUser = Id("fake_player_actually_an_AI"), + name = "M-5 Computational Unit", + isFemale = true, + faction = faction, + acumen = AdmiralRank.values().first { + it.maxShipWeightClass.tier >= forBattleSize.maxWeightClass.tier + }.minAcumen + 500, + money = 0 +) + +infix fun AdmiralRank.Companion.eq(rank: AdmiralRank): Bson = when (rank.ordinal) { + 0 -> Admiral::acumen lt AdmiralRank.values()[1].minAcumen + AdmiralRank.values().size - 1 -> Admiral::acumen gte rank.minAcumen + else -> and( + Admiral::acumen gte rank.minAcumen, + Admiral::acumen lt AdmiralRank.values()[rank.ordinal + 1].minAcumen + ) +} + +@Serializable +data class ShipInDrydock( + @SerialName("_id") + override val id: Id = Id(), + val name: String, + val shipType: ShipType, + val readyAt: @Contextual Instant, + val owningAdmiral: Id +) : DataDocument { + val shipData: Ship + get() = Ship(id.reinterpret(), name, shipType) + + val fullName: String + get() = shipData.fullName + + companion object Table : DocumentTable by DocumentTable.create({ + index(ShipInDrydock::owningAdmiral) + }) +} + +@Serializable +data class ShipMemorial( + @SerialName("_id") + override val id: Id = Id(), + val name: String, + val shipType: ShipType, + val destroyedAt: @Contextual Instant, + val owningAdmiral: Id, + val destroyedIn: Id, +) : DataDocument { + val fullName: String + get() = "${shipType.faction.shipPrefix}$name" + + companion object Table : DocumentTable by DocumentTable.create({ + index(ShipMemorial::owningAdmiral) + }) +} + +suspend fun getAllInGameAdmirals(user: User) = Admiral.filter(Admiral::owningUser eq user.id).map { admiral -> + InGameAdmiral( + admiral.id.reinterpret(), + InGameUser(user.id.reinterpret(), user.profileName), + admiral.name, + admiral.isFemale, + admiral.faction, + admiral.rank + ) +}.toList() + +suspend fun getInGameAdmiral(admiralId: Id) = Admiral.get(admiralId.reinterpret())?.let { admiral -> + User.get(admiral.owningUser)?.let { user -> + InGameAdmiral( + admiralId, + InGameUser(user.id.reinterpret(), user.profileName), + admiral.name, + admiral.isFemale, + admiral.faction, + admiral.rank + ) + } +} + +suspend fun getAdmiralsShips(admiralId: Id): Map, Ship> { + val now = Instant.now() + + return ShipInDrydock + .filter(and(ShipInDrydock::owningAdmiral eq admiralId, ShipInDrydock::readyAt lte now)) + .toList() + .associate { it.shipData.id to it.shipData } +} + +fun generateFleet(admiral: Admiral): List = ShipWeightClass.values() + .flatMap { swc -> + val shipTypes = ShipType.values().filter { st -> + st.weightClass == swc && st.faction == admiral.faction + }.shuffled() + + if (shipTypes.isEmpty()) + emptyList() + else + (0 until ((admiral.rank.maxShipWeightClass.tier - swc.tier + 1) * 2).coerceAtLeast(0)).map { i -> + shipTypes[i % shipTypes.size] + } + } + .let { shipTypes -> + val now = Instant.now().minusMillis(100L) + + val shipNames = mutableSetOf() + shipTypes.mapNotNull { st -> + newShipName(st.faction, st.weightClass, shipNames)?.let { name -> + ShipInDrydock( + id = Id(), + name = name, + shipType = st, + readyAt = now, + owningAdmiral = admiral.id + ) + } + } + } diff --git a/src/jvmMain/kotlin/net/starshipfights/data/admiralty/battle_records.kt b/src/jvmMain/kotlin/net/starshipfights/data/admiralty/battle_records.kt new file mode 100644 index 0000000..f65b8c5 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/data/admiralty/battle_records.kt @@ -0,0 +1,44 @@ +package net.starshipfights.data.admiralty + +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.starshipfights.data.DataDocument +import net.starshipfights.data.DocumentTable +import net.starshipfights.data.Id +import net.starshipfights.data.auth.User +import net.starshipfights.data.invoke +import net.starshipfights.game.BattleInfo +import net.starshipfights.game.GlobalSide +import java.time.Instant + +@Serializable +data class BattleRecord( + @SerialName("_id") + override val id: Id = Id(), + + val battleInfo: BattleInfo, + + val whenStarted: @Contextual Instant, + val whenEnded: @Contextual Instant, + + val hostUser: Id, + val guestUser: Id, + + val hostAdmiral: Id, + val guestAdmiral: Id, + + val hostEndingMessage: String, + val guestEndingMessage: String, + + val winner: GlobalSide?, + val winMessage: String, +) : DataDocument { + companion object Table : DocumentTable by DocumentTable.create({ + index(BattleRecord::hostUser) + index(BattleRecord::guestUser) + + index(BattleRecord::hostAdmiral) + index(BattleRecord::guestAdmiral) + }) +} diff --git a/src/jvmMain/kotlin/net/starshipfights/data/admiralty/ship_prices.kt b/src/jvmMain/kotlin/net/starshipfights/data/admiralty/ship_prices.kt new file mode 100644 index 0000000..5acf4fd --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/data/admiralty/ship_prices.kt @@ -0,0 +1,28 @@ +package net.starshipfights.data.admiralty + +import net.starshipfights.game.Faction +import net.starshipfights.game.ShipType +import net.starshipfights.game.pointCost + +val ShipType.buyPrice: Int + get() = pointCost * 6 / 5 + +val ShipType.buyWhileDutchPrice: Int + get() = pointCost * 8 / 5 + +val ShipType.sellPrice: Int + get() = pointCost * 4 / 5 + +fun ShipType.buyPriceChecked(admiral: Admiral, ownedShips: List): Int? { + return buyPrice(admiral, ownedShips)?.takeIf { it <= admiral.money } +} + +fun ShipType.buyPrice(admiral: Admiral, ownedShips: List): Int? { + if (weightClass.tier > admiral.rank.maxShipWeightClass.tier) return null + if (weightClass.isUnique && ownedShips.any { it.shipType.weightClass == weightClass }) return null + return when { + admiral.faction == faction -> buyPrice + admiral.faction == Faction.NDRC && !weightClass.isUnique -> buyWhileDutchPrice + else -> null + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/data/auth/user_sessions.kt b/src/jvmMain/kotlin/net/starshipfights/data/auth/user_sessions.kt new file mode 100644 index 0000000..4f1c991 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/data/auth/user_sessions.kt @@ -0,0 +1,71 @@ +package net.starshipfights.data.auth + +import io.ktor.auth.* +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import net.starshipfights.data.DataDocument +import net.starshipfights.data.DocumentTable +import net.starshipfights.data.Id +import net.starshipfights.data.invoke +import java.time.Instant + +@Serializable +data class User( + @SerialName("_id") + override val id: Id = Id(), + + val discordId: String, + val discordName: String, + val discordDiscriminator: String, + val discordAvatar: String?, + val showDiscordName: Boolean, + + val profileName: String, + val profileBio: String, + + val preferredTheme: PreferredTheme = PreferredTheme.SYSTEM, + + val registeredAt: @Contextual Instant, + val lastActivity: @Contextual Instant, + val showUserStatus: Boolean, + + val logIpAddresses: Boolean, + + val status: UserStatus = UserStatus.AVAILABLE, +) : DataDocument { + val discordAvatarUrl: String + get() = discordAvatar?.takeIf { showDiscordName }?.let { + "https://cdn.discordapp.com/avatars/$discordId/$it." + (if (it.startsWith("a_")) "gif" else "png") + "?size=256" + } ?: anonymousAvatarUrl + + val anonymousAvatarUrl: String + get() = "https://cdn.discordapp.com/embed/avatars/${(discordDiscriminator.lastOrNull()?.digitToInt() ?: 0) % 5}.png" + + companion object Table : DocumentTable by DocumentTable.create({ + unique(User::discordId) + index(User::registeredAt) + }) +} + +enum class PreferredTheme { + SYSTEM, LIGHT, DARK; +} + +enum class UserStatus { + AVAILABLE, IN_MATCHMAKING, READY_FOR_BATTLE, IN_BATTLE +} + +@Serializable +data class UserSession( + @SerialName("_id") + override val id: Id = Id(), + val user: Id, + val clientAddresses: List, + val userAgent: String, + val expiration: @Contextual Instant +) : DataDocument, Principal { + companion object Table : DocumentTable by DocumentTable.create({ + index(UserSession::user) + }) +} diff --git a/src/jvmMain/kotlin/net/starshipfights/data/auth/user_trophies.kt b/src/jvmMain/kotlin/net/starshipfights/data/auth/user_trophies.kt new file mode 100644 index 0000000..a74eb74 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/data/auth/user_trophies.kt @@ -0,0 +1,41 @@ +package net.starshipfights.data.auth + +import kotlinx.html.TagConsumer +import kotlinx.html.p +import kotlinx.html.style +import kotlinx.serialization.Serializable +import net.starshipfights.CurrentConfiguration + +@Serializable +sealed class UserTrophy : Comparable { + protected abstract fun TagConsumer<*>.render() + fun renderInto(consumer: TagConsumer<*>) = consumer.render() + + // Higher rank = lower on page + protected abstract val rank: Int + override fun compareTo(other: UserTrophy): Int { + return rank.compareTo(other.rank) + } +} + +fun TagConsumer<*>.renderTrophy(trophy: UserTrophy) = trophy.renderInto(this) + +@Serializable +object SiteOwnerTrophy : UserTrophy() { + override fun TagConsumer<*>.render() { + p { + style = "text-align:center;border:2px solid #a82;padding:3px;background-color:#fc3;color:#541;font-variant:small-caps;font-family:'JetBrains Mono',monospace" + +"Site Owner" + } + } + + override val rank: Int + get() = 0 +} + +fun User.getTrophiesUnsorted(): Set = + (if (discordId == CurrentConfiguration.discordClient?.ownerId) + setOf(SiteOwnerTrophy) + else emptySet()) + +fun User.getTrophies(): List = getTrophiesUnsorted().sorted() diff --git a/src/jvmMain/kotlin/net/starshipfights/data/data_connection.kt b/src/jvmMain/kotlin/net/starshipfights/data/data_connection.kt new file mode 100644 index 0000000..c59f0cb --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/data/data_connection.kt @@ -0,0 +1,93 @@ +package net.starshipfights.data + +import de.flapdoodle.embed.mongo.MongodProcess +import de.flapdoodle.embed.mongo.MongodStarter +import de.flapdoodle.embed.mongo.config.MongoCmdOptions +import de.flapdoodle.embed.mongo.config.MongodConfig +import de.flapdoodle.embed.mongo.config.Net +import de.flapdoodle.embed.mongo.config.Storage +import de.flapdoodle.embed.mongo.distribution.Version +import de.flapdoodle.embed.process.runtime.Network +import kotlinx.coroutines.CompletableDeferred +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import org.litote.kmongo.coroutine.CoroutineClient +import org.litote.kmongo.coroutine.coroutine +import org.litote.kmongo.reactivestreams.KMongo +import org.litote.kmongo.serialization.changeIdController +import org.litote.kmongo.serialization.registerSerializer +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.File +import kotlin.system.exitProcess + +@Serializable +sealed class ConnectionType { + abstract fun createUrl(): String + + @Serializable + @SerialName("embedded") + data class Embedded(val dataDir: String = "mongodb") : ConnectionType() { + @Transient + val log: Logger = LoggerFactory.getLogger(javaClass) + + override fun createUrl(): String { + val dataDirPath = File(dataDir).apply { mkdirs() }.absolutePath + + val starter = MongodStarter.getDefaultInstance() + + log.info("Running embedded MongoDB on port 27017") + + val config = MongodConfig.builder() + .version(Version.Main.PRODUCTION) + .net(Net(27017, Network.localhostIsIPv6())) + .replication(Storage(dataDirPath, null, 1024)) + .cmdOptions(MongoCmdOptions.builder().useNoJournal(false).build()) + .build() + + var process: MongodProcess? = null + Runtime.getRuntime().addShutdownHook( + Thread( + { process?.stop() }, + "Shutdown Thread" + ) + ) + + try { + process = starter.prepare(config).start() + } catch (ex: Exception) { + log.error("Exception from starting embedded MongoDB!", ex) + log.error("Shutting down") + exitProcess(-1) + } + + return "mongodb://localhost:27017" + } + } + + @Serializable + @SerialName("external") + data class External(val url: String) : ConnectionType() { + override fun createUrl() = url + } +} + +object ConnectionHolder { + private lateinit var databaseName: String + + private val clientDeferred = CompletableDeferred() + + suspend fun getDatabase() = clientDeferred.await().getDatabase(databaseName) + + fun initialize(conn: ConnectionType, db: String) { + if (clientDeferred.isCompleted) + error("Cannot initialize database twice!") + + changeIdController(DocumentIdController) + registerSerializer(IdSerializer) + + databaseName = db + clientDeferred.complete(KMongo.createClient(conn.createUrl()).coroutine) + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/data/data_documents.kt b/src/jvmMain/kotlin/net/starshipfights/data/data_documents.kt new file mode 100644 index 0000000..4328647 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/data/data_documents.kt @@ -0,0 +1,151 @@ +package net.starshipfights.data + +import com.mongodb.client.model.BulkWriteOptions +import com.mongodb.client.model.ReplaceOptions +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.serialization.SerialName +import org.bson.conversions.Bson +import org.litote.kmongo.coroutine.coroutine +import org.litote.kmongo.replaceOne +import org.litote.kmongo.serialization.IdController +import org.litote.kmongo.util.KMongoUtil +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import kotlin.coroutines.CoroutineContext +import kotlin.reflect.KClass +import kotlin.reflect.KProperty1 + +interface DataDocument> { + @SerialName("_id") + val id: Id +} + +object DocumentIdController : IdController { + override fun findIdProperty(type: KClass<*>): KProperty1<*, *> { + return DataDocument<*>::id + } + + @Suppress("UNCHECKED_CAST") + override fun getIdValue(idProperty: KProperty1, instance: T): R? { + return (instance as DataDocument<*>).id as R + } + + override fun setIdValue(idProperty: KProperty1, instance: T) { + throw UnsupportedOperationException("Cannot set `id` property of `DataDocument`!") + } +} + +interface DocumentTable> { + fun initialize() + + suspend fun index(vararg properties: KProperty1) + suspend fun unique(vararg properties: KProperty1) + + suspend fun put(doc: T) + suspend fun put(docs: Iterable) + suspend fun set(id: Id, set: Bson): Boolean + suspend fun get(id: Id): T? + suspend fun del(id: Id) + suspend fun all(): Flow + + suspend fun filter(where: Bson): Flow + suspend fun sorted(order: Bson): Flow + suspend fun select(where: Bson, order: Bson): Flow + suspend fun number(where: Bson): Long + suspend fun locate(where: Bson): T? + suspend fun update(where: Bson, set: Bson) + suspend fun remove(where: Bson) + + companion object : CoroutineScope { + private val logger: Logger by lazy { + LoggerFactory.getLogger(DocumentTable::class.java) + } + + override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { ctx, ex -> + val name = ctx[CoroutineName]?.name?.let { "table $it" } ?: "unnamed table" + logger.error("Caught unhandled exception from initializing $name!", ex) + } + + fun > create(kclass: KClass, initFunc: suspend DocumentTable.() -> Unit = {}): DocumentTable = DocumentTableImpl(kclass) { + runBlocking { + it.initFunc() + } + } + + inline fun > create(noinline initFunc: suspend DocumentTable.() -> Unit = {}) = create(T::class, initFunc) + } +} + +private class DocumentTableImpl>(val kclass: KClass, private val initFunc: (DocumentTable) -> Unit) : DocumentTable { + suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kclass.simpleName, kclass.java).coroutine + + override fun initialize() { + initFunc(this) + } + + override suspend fun index(vararg properties: KProperty1) { + collection().ensureIndex(*properties) + } + + override suspend fun unique(vararg properties: KProperty1) { + collection().ensureUniqueIndex(*properties) + } + + override suspend fun put(doc: T) { + collection().replaceOneById(doc.id, doc, ReplaceOptions().upsert(true)) + } + + override suspend fun put(docs: Iterable) { + collection().bulkWrite( + docs.map { doc -> + replaceOne(KMongoUtil.idFilterQuery(doc.id), doc, ReplaceOptions().upsert(true)) + }, + BulkWriteOptions().ordered(false) + ) + } + + override suspend fun set(id: Id, set: Bson): Boolean { + return collection().updateOneById(id, set).matchedCount != 0L + } + + override suspend fun get(id: Id): T? { + return collection().findOneById(id) + } + + override suspend fun del(id: Id) { + collection().deleteOneById(id) + } + + override suspend fun all(): Flow { + return collection().find().toFlow() + } + + override suspend fun filter(where: Bson): Flow { + return collection().find(where).toFlow() + } + + override suspend fun sorted(order: Bson): Flow { + return collection().find().sort(order).toFlow() + } + + override suspend fun select(where: Bson, order: Bson): Flow { + return collection().find(where).sort(order).toFlow() + } + + override suspend fun number(where: Bson): Long { + return collection().countDocuments(where) + } + + override suspend fun locate(where: Bson): T? { + return collection().findOne(where) + } + + override suspend fun update(where: Bson, set: Bson) { + collection().updateMany(where, set) + } + + override suspend fun remove(where: Bson) { + collection().deleteMany(where) + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/data/data_jvm.kt b/src/jvmMain/kotlin/net/starshipfights/data/data_jvm.kt new file mode 100644 index 0000000..4ea7f8e --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/data/data_jvm.kt @@ -0,0 +1,14 @@ +package net.starshipfights.data + +import com.aventrix.jnanoid.jnanoid.NanoIdUtils + +private val alphabet32 = "BCDFGHLMNPQRSTXZbcdfghlmnpqrstxz".toCharArray() + +private const val tokenLength = 8 +fun createToken(): String = NanoIdUtils.randomNanoId(NanoIdUtils.DEFAULT_NUMBER_GENERATOR, alphabet32, tokenLength) + +private const val nonceLength = 16 +fun createNonce(): String = NanoIdUtils.randomNanoId(NanoIdUtils.DEFAULT_NUMBER_GENERATOR, alphabet32, nonceLength) + +private const val idLength = 24 +operator fun Id.Companion.invoke() = Id(NanoIdUtils.randomNanoId(NanoIdUtils.DEFAULT_NUMBER_GENERATOR, alphabet32, idLength)) diff --git a/src/jvmMain/kotlin/net/starshipfights/data/data_routines.kt b/src/jvmMain/kotlin/net/starshipfights/data/data_routines.kt new file mode 100644 index 0000000..bca4f54 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/data/data_routines.kt @@ -0,0 +1,58 @@ +package net.starshipfights.data + +import kotlinx.coroutines.* +import net.starshipfights.data.admiralty.Admiral +import net.starshipfights.data.admiralty.BattleRecord +import net.starshipfights.data.admiralty.ShipInDrydock +import net.starshipfights.data.admiralty.eq +import net.starshipfights.data.auth.User +import net.starshipfights.data.auth.UserSession +import net.starshipfights.game.AdmiralRank +import org.litote.kmongo.inc +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.time.Instant +import java.time.ZoneId + +object DataRoutines { + private val logger: Logger = LoggerFactory.getLogger(javaClass) + + private val scope: CoroutineScope = CoroutineScope( + SupervisorJob() + CoroutineExceptionHandler { ctx, ex -> + val coroutine = ctx[CoroutineName]?.name?.let { "coroutine $it" } ?: "unnamed coroutine" + logger.error("Caught unhandled exception in $coroutine", ex) + } + ) + + fun initializeRoutines(): Job { + // Initialize tables + Admiral.initialize() + BattleRecord.initialize() + ShipInDrydock.initialize() + User.initialize() + UserSession.initialize() + + return scope.launch { + // Pay admirals + launch { + var prevTime = Instant.now().atZone(ZoneId.systemDefault()) + while (currentCoroutineContext().isActive) { + val currTime = Instant.now().atZone(ZoneId.systemDefault()) + if (currTime.dayOfWeek != prevTime.dayOfWeek) + launch { + logger.info("Paying admirals now") + for (rank in AdmiralRank.values()) + launch { + Admiral.update( + AdmiralRank eq rank, + inc(Admiral::money, rank.dailyWage) + ) + } + } + prevTime = currTime + delay(900_000) + } + } + } + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/ai/util_jvm.kt b/src/jvmMain/kotlin/net/starshipfights/game/ai/util_jvm.kt new file mode 100644 index 0000000..97d1bf4 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/ai/util_jvm.kt @@ -0,0 +1,22 @@ +package net.starshipfights.game.ai + +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +val aiLogger: Logger = LoggerFactory.getLogger("SF_AI") + +actual fun logDebug(message: Any?) { + aiLogger.debug(message.toString()) +} + +actual fun logInfo(message: Any?) { + aiLogger.info(message.toString()) +} + +actual fun logWarning(message: Any?) { + aiLogger.warn(message.toString()) +} + +actual fun logError(message: Any?) { + aiLogger.error(message.toString()) +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/concurrency.kt b/src/jvmMain/kotlin/net/starshipfights/game/concurrency.kt new file mode 100644 index 0000000..a00a9bf --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/concurrency.kt @@ -0,0 +1,10 @@ +package net.starshipfights.game + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class ConcurrentCurator(private val curated: T) { + private val mutex = Mutex() + + suspend fun use(block: suspend (T) -> U) = mutex.withLock { block(curated) } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/endpoints_game.kt b/src/jvmMain/kotlin/net/starshipfights/game/endpoints_game.kt new file mode 100644 index 0000000..1106eb6 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/endpoints_game.kt @@ -0,0 +1,94 @@ +package net.starshipfights.game + +import io.ktor.application.* +import io.ktor.html.* +import io.ktor.http.* +import io.ktor.routing.* +import io.ktor.websocket.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.starshipfights.auth.getUser +import net.starshipfights.data.DocumentTable +import net.starshipfights.data.admiralty.getAllInGameAdmirals +import net.starshipfights.data.auth.User +import net.starshipfights.data.auth.UserStatus +import net.starshipfights.redirect +import org.litote.kmongo.setValue + +fun Routing.installGame() { + get("/lobby") { + val user = call.getUser() ?: redirect("/login") + + val clientMode = if (user.status == UserStatus.AVAILABLE) + ClientMode.MatchmakingMenu(getAllInGameAdmirals(user)) + else + ClientMode.Error("You cannot play in multiple battles at the same time") + + call.respondHtml(HttpStatusCode.OK, clientMode.view()) + } + + post("/play") { + delay(750L) // nasty hack + + val user = call.getUser() ?: redirect("/login") + + val clientMode = when (user.status) { + UserStatus.AVAILABLE -> ClientMode.Error("You must use the matchmaking interface to enter a game") + UserStatus.IN_MATCHMAKING -> ClientMode.Error("You must start a game in the matchmaking interface") + UserStatus.READY_FOR_BATTLE -> call.getGameClientMode() + UserStatus.IN_BATTLE -> ClientMode.Error("You cannot play in multiple battles at the same time") + } + + call.respondHtml(HttpStatusCode.OK, clientMode.view()) + } + + post("/train") { + val clientMode = call.getTrainingClientMode() + + call.respondHtml(HttpStatusCode.OK, clientMode.view()) + } + + webSocket("/matchmaking") { + val oldUser = call.getUser() ?: closeAndReturn("You must be logged in to play") { return@webSocket } + if (oldUser.status != UserStatus.AVAILABLE) + closeAndReturn("You cannot play in multiple battles at the same time") { return@webSocket } + + val user = oldUser.copy(status = UserStatus.IN_MATCHMAKING) + User.put(user) + + closeReason.invokeOnCompletion { + DocumentTable.launch { + delay(150L) + if (User.get(user.id)?.status == UserStatus.IN_MATCHMAKING) + User.set(user.id, setValue(User::status, UserStatus.AVAILABLE)) + } + } + + if (matchmakingEndpoint(user)) + User.set(user.id, setValue(User::status, UserStatus.READY_FOR_BATTLE)) + } + + webSocket("/game/{token}") { + val token = call.parameters["token"] ?: closeAndReturn("Invalid or missing battle token") { return@webSocket } + + val oldUser = call.getUser() ?: closeAndReturn("You must be logged in to play") { return@webSocket } + + if (oldUser.status == UserStatus.IN_BATTLE) + closeAndReturn("You cannot play in multiple battles at the same time") { return@webSocket } + if (oldUser.status == UserStatus.IN_MATCHMAKING) + closeAndReturn("You must start a game in the matchmaking interface") { return@webSocket } + if (oldUser.status == UserStatus.AVAILABLE) + closeAndReturn("You must use the matchmaking interface to enter a game") { return@webSocket } + + val user = oldUser.copy(status = UserStatus.IN_BATTLE) + User.put(user) + + closeReason.invokeOnCompletion { + DocumentTable.launch { + User.set(user.id, setValue(User::status, UserStatus.AVAILABLE)) + } + } + + gameEndpoint(user, token) + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/game_start_jvm.kt b/src/jvmMain/kotlin/net/starshipfights/game/game_start_jvm.kt new file mode 100644 index 0000000..950e732 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/game_start_jvm.kt @@ -0,0 +1,89 @@ +package net.starshipfights.game + +import net.starshipfights.data.admiralty.genAI +import net.starshipfights.data.admiralty.generateFleet +import net.starshipfights.data.admiralty.getAdmiralsShips +import kotlin.math.PI + +suspend fun generateGameStart(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameStart { + val battleWidth = (25..35).random() * 500.0 + val battleLength = (15..45).random() * 500.0 + + val deployWidth2 = battleWidth / 2 + val deployLength2 = 875.0 + + val hostDeployCenter = Position(Vec2(0.0, (-battleLength / 2) + deployLength2)) + val guestDeployCenter = Position(Vec2(0.0, (battleLength / 2) - deployLength2)) + + return GameStart( + battleWidth, battleLength, + + PlayerStart( + hostDeployCenter, + PI / 2, + PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2), + PI / 2, + getAdmiralsShips(hostInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier } + ), + + PlayerStart( + guestDeployCenter, + -PI / 2, + PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2), + -PI / 2, + getAdmiralsShips(guestInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier } + ), + ) +} + +suspend fun generateTrainingInitialState(playerInfo: InGameAdmiral, enemyFaction: Faction, battleInfo: BattleInfo): GameState { + val battleWidth = (25..35).random() * 500.0 + val battleLength = (15..45).random() * 500.0 + + val deployWidth2 = battleWidth / 2 + val deployLength2 = 875.0 + + val hostDeployCenter = Position(Vec2(0.0, (-battleLength / 2) + deployLength2)) + val guestDeployCenter = Position(Vec2(0.0, (battleLength / 2) - deployLength2)) + + val aiAdmiral = genAI(enemyFaction, battleInfo.size) + + return GameState( + start = GameStart( + battleWidth, battleLength, + + PlayerStart( + hostDeployCenter, + PI / 2, + PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2), + PI / 2, + getAdmiralsShips(playerInfo.id.reinterpret()) + .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier } + ), + + PlayerStart( + guestDeployCenter, + -PI / 2, + PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2), + -PI / 2, + generateFleet(aiAdmiral) + .associate { it.shipData.id to it.shipData } + .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier } + ) + ), + hostInfo = playerInfo, + guestInfo = InGameAdmiral( + id = aiAdmiral.id.reinterpret(), + user = InGameUser( + id = aiAdmiral.owningUser.reinterpret(), + username = aiAdmiral.name + ), + name = aiAdmiral.name, + isFemale = aiAdmiral.isFemale, + faction = aiAdmiral.faction, + rank = aiAdmiral.rank + ), + battleInfo = battleInfo, + subplots = generateSubplots(battleInfo.size, GlobalSide.HOST) + ) +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/game_time_jvm.kt b/src/jvmMain/kotlin/net/starshipfights/game/game_time_jvm.kt new file mode 100644 index 0000000..9282007 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/game_time_jvm.kt @@ -0,0 +1,25 @@ +package net.starshipfights.game + +import kotlinx.serialization.Serializable +import java.time.Instant + +@Serializable(with = MomentSerializer::class) +actual class Moment(val instant: Instant) : Comparable { + actual constructor(millis: Double) : this( + Instant.ofEpochSecond( + (millis / 1000.0).toLong(), + ((millis % 1000.0) * 1_000_000.0).toLong() + ) + ) + + actual fun toMillis(): Double { + return (instant.epochSecond * 1000.0) + (instant.nano / 1_000_000.0) + } + + actual override fun compareTo(other: Moment) = toMillis().compareTo(other.toMillis()) + + actual companion object { + actual val now: Moment + get() = Moment(Instant.now()) + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/server_game.kt b/src/jvmMain/kotlin/net/starshipfights/game/server_game.kt new file mode 100644 index 0000000..c14cebe --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/server_game.kt @@ -0,0 +1,297 @@ +package net.starshipfights.game + +import io.ktor.websocket.* +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import net.starshipfights.data.DocumentTable +import net.starshipfights.data.Id +import net.starshipfights.data.admiralty.Admiral +import net.starshipfights.data.admiralty.BattleRecord +import net.starshipfights.data.admiralty.ShipInDrydock +import net.starshipfights.data.admiralty.ShipMemorial +import net.starshipfights.data.auth.User +import net.starshipfights.data.createToken +import org.litote.kmongo.`in` +import org.litote.kmongo.inc +import org.litote.kmongo.setValue +import java.time.Instant +import java.time.temporal.ChronoUnit + +data class GameToken(val hostToken: String, val joinToken: String) + +object GameManager { + private val games = ConcurrentCurator(mutableMapOf()) + + suspend fun initGame(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameToken { + val gameState = GameState( + start = generateGameStart(hostInfo, guestInfo, battleInfo), + hostInfo = hostInfo, + guestInfo = guestInfo, + battleInfo = battleInfo, + subplots = generateSubplots(battleInfo.size, GlobalSide.HOST) + generateSubplots(battleInfo.size, GlobalSide.GUEST) + ) + + val session = GameSession(gameState) + DocumentTable.launch { + session.gameStart.join() + val startedAt = Instant.now() + + val end = session.gameEnd.await() + val endedAt = Instant.now() + + onGameEnd(session.state.value, end, startedAt, endedAt) + } + + val hostId = createToken() + val joinId = createToken() + games.use { + it[hostId] = GameEntry(hostInfo.user.id.reinterpret(), GlobalSide.HOST, session) + it[joinId] = GameEntry(guestInfo.user.id.reinterpret(), GlobalSide.GUEST, session) + } + + return GameToken(hostId, joinId) + } + + suspend fun joinGame(userId: Id, token: String, remove: Boolean): GameEntry? { + return games.use { if (remove) it.remove(token) else it[token] }?.takeIf { it.userId == userId } + } +} + +class GameEntry(val userId: Id, val side: GlobalSide, val session: GameSession) + +class GameSession(gameState: GameState) { + private val hostEnter = Job() + private val guestEnter = Job() + + suspend fun enter(player: GlobalSide) = when (player) { + GlobalSide.HOST -> { + hostEnter.complete() + withTimeoutOrNull(30_000L) { + guestEnter.join() + true + } ?: false + } + GlobalSide.GUEST -> { + guestEnter.complete() + withTimeoutOrNull(30_000L) { + hostEnter.join() + true + } ?: false + } + }.also { + if (it) + gameStartMutable.complete() + else + onPacket(player.other, PlayerAction.TimeOut) + } + + private val gameStartMutable = Job() + val gameStart: Job + get() = gameStartMutable + + private val stateMutable = MutableStateFlow(gameState) + private val stateMutex = Mutex() + + val state = stateMutable.asStateFlow() + + private val hostErrorMessages = Channel(Channel.UNLIMITED) + private val guestErrorMessages = Channel(Channel.UNLIMITED) + + private fun errorMessageChannel(player: GlobalSide) = when (player) { + GlobalSide.HOST -> hostErrorMessages + GlobalSide.GUEST -> guestErrorMessages + } + + fun errorMessages(player: GlobalSide): ReceiveChannel = when (player) { + GlobalSide.HOST -> hostErrorMessages + GlobalSide.GUEST -> guestErrorMessages + } + + private val gameEndMutable = CompletableDeferred() + val gameEnd: Deferred + get() = gameEndMutable + + suspend fun onPacket(player: GlobalSide, packet: PlayerAction) { + stateMutex.withLock { + when (val result = state.value.after(player, packet)) { + is GameEvent.StateChange -> { + stateMutable.value = result.newState + result.newState.checkVictory()?.let { gameEndMutable.complete(it) } + } + is GameEvent.InvalidAction -> { + errorMessageChannel(player).send(result.message) + } + is GameEvent.GameEnd -> { + if (gameStartMutable.isActive) + gameStartMutable.cancel() + gameEndMutable.complete(result) + } + } + } + } + + suspend fun onClose(player: GlobalSide) { + if (gameEnd.isCompleted) return + + onPacket(player, PlayerAction.Disconnect) + } +} + +suspend fun DefaultWebSocketServerSession.gameEndpoint(user: User, token: String) { + val gameEntry = GameManager.joinGame(user.id, token, true) ?: closeAndReturn("That battle is not available") { return } + val playerSide = gameEntry.side + val gameSession = gameEntry.session + + val opponentEntered = gameSession.enter(playerSide) + sendObject(GameBeginning.serializer(), GameBeginning(opponentEntered)) + if (!opponentEntered) return + + val sendEventsJob = launch { + listOf( + // Game state changes + launch { + gameSession.state.collect { state -> + sendObject(GameEvent.serializer(), GameEvent.StateChange(state)) + } + }, + // Invalid action messages + launch { + for (errorMessage in gameSession.errorMessages(playerSide)) { + sendObject(GameEvent.serializer(), GameEvent.InvalidAction(errorMessage)) + } + } + ).joinAll() + } + + val receiveActionsJob = launch { + while (true) { + val packet = receiveObject(PlayerAction.serializer()) { + closeAndReturn { + gameSession.onClose(playerSide) + return@launch + } + } + + if (isInternalPlayerAction(packet)) + sendObject(GameEvent.serializer(), GameEvent.InvalidAction("Invalid packet sent over wire - packet type is for internal use only")) + else + gameSession.onPacket(playerSide, packet) + } + } + + val gameEnd = gameSession.gameEnd.await() + sendObject(GameEvent.serializer(), gameEnd) + + sendEventsJob.cancelAndJoin() + receiveActionsJob.cancelAndJoin() +} + +private val BattleSize.shipPointsPerAcumen: Int + get() = when (this) { + BattleSize.SKIRMISH -> 5 + BattleSize.RAID -> 5 + BattleSize.FIREFIGHT -> 5 + BattleSize.BATTLE -> 5 + BattleSize.GRAND_CLASH -> 10 + BattleSize.APOCALYPSE -> 10 + BattleSize.LEGENDARY_STRUGGLE -> 10 + BattleSize.CRUCIBLE_OF_HISTORY -> 10 + } + +private val BattleSize.acumenPerSubplotWon: Int + get() = numPoints / 100 + +private suspend fun onGameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd, startedAt: Instant, endedAt: Instant) { + val damagedShipReadyAt = endedAt.plus(6, ChronoUnit.HOURS) + val intactShipReadyAt = endedAt.plus(3, ChronoUnit.HOURS) + val escapedShipReadyAt = endedAt.plus(3, ChronoUnit.HOURS) + + val shipWrecks = gameState.destroyedShips + val ships = gameState.ships + + val hostAdmiralId = gameState.hostInfo.id.reinterpret() + val guestAdmiralId = gameState.guestInfo.id.reinterpret() + + val battleRecord = BattleRecord( + battleInfo = gameState.battleInfo, + + whenStarted = startedAt, + whenEnded = endedAt, + + hostUser = gameState.hostInfo.user.id.reinterpret(), + guestUser = gameState.guestInfo.user.id.reinterpret(), + + hostAdmiral = hostAdmiralId, + guestAdmiral = guestAdmiralId, + + hostEndingMessage = victoryTitle(GlobalSide.HOST, gameEnd.winner, gameEnd.subplotOutcomes), + guestEndingMessage = victoryTitle(GlobalSide.GUEST, gameEnd.winner, gameEnd.subplotOutcomes), + + winner = gameEnd.winner, + winMessage = gameEnd.message + ) + + val destructions = shipWrecks.filterValues { !it.isEscape } + val destroyedShips = destructions.keys.map { it.reinterpret() }.toSet() + val rememberedShips = destructions.values.map { wreck -> + ShipMemorial( + id = Id("RIP_${wreck.id.id}"), + name = wreck.ship.name, + shipType = wreck.ship.shipType, + destroyedAt = wreck.wreckedAt.instant, + owningAdmiral = when (wreck.owner) { + GlobalSide.HOST -> hostAdmiralId + GlobalSide.GUEST -> guestAdmiralId + }, + destroyedIn = battleRecord.id + ) + } + + val escapedShips = shipWrecks.filterValues { it.isEscape }.keys.map { it.reinterpret() }.toSet() + val damagedShips = ships.filterValues { it.hullAmount < it.durability.maxHullPoints }.keys.map { it.reinterpret() }.toSet() + val intactShips = ships.keys.map { it.reinterpret() }.toSet() - damagedShips + + val battleSize = gameState.battleInfo.size + + val hostAcumenGainFromShips = shipWrecks.values.filter { it.owner == GlobalSide.GUEST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen } + val hostAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player == GlobalSide.HOST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon + val hostAcumenGain = hostAcumenGainFromShips + hostAcumenGainFromSubplots + + val guestAcumenGainFromShips = shipWrecks.values.filter { it.owner == GlobalSide.HOST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen } + val guestAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player == GlobalSide.GUEST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon + val guestAcumenGain = guestAcumenGainFromShips + guestAcumenGainFromSubplots + + coroutineScope { + launch { + ShipMemorial.put(rememberedShips) + } + launch { + ShipInDrydock.remove(ShipInDrydock::id `in` destroyedShips) + } + launch { + ShipInDrydock.update(ShipInDrydock::id `in` damagedShips, setValue(ShipInDrydock::readyAt, damagedShipReadyAt)) + } + launch { + ShipInDrydock.update(ShipInDrydock::id `in` intactShips, setValue(ShipInDrydock::readyAt, intactShipReadyAt)) + } + launch { + ShipInDrydock.update(ShipInDrydock::id `in` escapedShips, setValue(ShipInDrydock::readyAt, escapedShipReadyAt)) + } + + launch { + Admiral.set(hostAdmiralId, inc(Admiral::acumen, hostAcumenGain)) + } + launch { + Admiral.set(guestAdmiralId, inc(Admiral::acumen, guestAcumenGain)) + } + + launch { + BattleRecord.put(battleRecord) + } + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/server_matchmaking.kt b/src/jvmMain/kotlin/net/starshipfights/game/server_matchmaking.kt new file mode 100644 index 0000000..5157d17 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/server_matchmaking.kt @@ -0,0 +1,126 @@ +package net.starshipfights.game + +import io.ktor.websocket.* +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.launch +import net.starshipfights.data.admiralty.getInGameAdmiral +import net.starshipfights.data.auth.User + +private val openSessions = ConcurrentCurator(mutableListOf()) + +class HostInvitation(admiral: InGameAdmiral, battleInfo: BattleInfo) { + val joinable = Joinable(admiral, battleInfo) + val joinInvitations = Channel() + + val gameIdHandler = CompletableDeferred() +} + +class JoinInvitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred) { + val gameIdHandler = CompletableDeferred() +} + +suspend fun DefaultWebSocketServerSession.matchmakingEndpoint(user: User): Boolean { + val playerLogin = receiveObject(PlayerLogin.serializer()) { closeAndReturn { return false } } + val admiralId = playerLogin.admiral + val inGameAdmiral = getInGameAdmiral(admiralId) ?: closeAndReturn("That admiral does not exist") { return false } + if (inGameAdmiral.user.id != user.id) closeAndReturn("You do not own that admiral") { return false } + + when (val loginMode = playerLogin.login) { + is LoginMode.Train -> { + closeAndReturn("Invalid input: LoginMode.Train should redirect you directly to training endpoint") { return false } + } + is LoginMode.Host -> { + val battleInfo = loginMode.battleInfo + val hostInvitation = HostInvitation(inGameAdmiral, battleInfo) + + openSessions.use { it.add(hostInvitation) } + + closeReason.invokeOnCompletion { + hostInvitation.joinInvitations.close() + + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch { + openSessions.use { + it.remove(hostInvitation) + } + } + } + + for (joinInvitation in hostInvitation.joinInvitations) { + sendObject(JoinRequest.serializer(), joinInvitation.joinRequest) + val joinResponse = receiveObject(JoinResponse.serializer()) { + closeAndReturn { + joinInvitation.responseHandler.complete(JoinResponse(false)) + return false + } + } + + if (joinInvitation.responseHandler.isCancelled) { + if (joinResponse.accepted) + sendObject(JoinResponseResponse.serializer(), JoinResponseResponse(false)) + continue + } + + joinInvitation.responseHandler.complete(joinResponse) + + if (joinResponse.accepted) { + sendObject(JoinResponseResponse.serializer(), JoinResponseResponse(true)) + + val (hostId, joinId) = GameManager.initGame(inGameAdmiral, joinInvitation.joinRequest.joiner, loginMode.battleInfo) + hostInvitation.gameIdHandler.complete(hostId) + joinInvitation.gameIdHandler.complete(joinId) + + break + } + } + + val gameId = hostInvitation.gameIdHandler.await() + sendObject(GameReady.serializer(), GameReady(gameId)) + } + LoginMode.Join -> { + val joinRequest = JoinRequest(inGameAdmiral) + + while (true) { + val openGames = openSessions.use { + it.toList() + }.filter { sess -> + sess.joinable.battleInfo.size <= inGameAdmiral.rank.maxBattleSize + }.mapIndexed { i, host -> "$i" to host }.toMap() + + val joinListing = JoinListing(openGames.mapValues { (_, invitation) -> invitation.joinable }) + sendObject(JoinListing.serializer(), joinListing) + + val joinSelection = receiveObject(JoinSelection.serializer()) { closeAndReturn { return false } } + val hostInvitation = openGames.getValue(joinSelection.selectedId) + + val joinResponseHandler = CompletableDeferred() + val joinInvitation = JoinInvitation(joinRequest, joinResponseHandler) + closeReason.invokeOnCompletion { + joinResponseHandler.cancel() + } + + try { + hostInvitation.joinInvitations.send(joinInvitation) + } catch (ex: ClosedSendChannelException) { + sendObject(JoinResponse.serializer(), JoinResponse(false)) + continue + } + + val joinResponse = joinResponseHandler.await() + sendObject(JoinResponse.serializer(), joinResponse) + + if (joinResponse.accepted) { + val gameId = joinInvitation.gameIdHandler.await() + sendObject(GameReady.serializer(), GameReady(gameId)) + break + } + } + } + } + + return true +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/util_jvm.kt b/src/jvmMain/kotlin/net/starshipfights/game/util_jvm.kt new file mode 100644 index 0000000..3fe4512 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/util_jvm.kt @@ -0,0 +1,23 @@ +package net.starshipfights.game + +import io.ktor.http.cio.websocket.* +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.SerializationStrategy + +suspend inline fun DefaultWebSocketSession.receiveObject(serializer: DeserializationStrategy, exitOnError: () -> Nothing): T { + val text = incoming.receiveAsFlow().filterIsInstance().firstOrNull()?.readText() ?: exitOnError() + return jsonSerializer.decodeFromString(serializer, text) +} + +suspend inline fun DefaultWebSocketSession.sendObject(serializer: SerializationStrategy, value: T) { + outgoing.send(Frame.Text(jsonSerializer.encodeToString(serializer, value))) + flush() +} + +suspend inline fun DefaultWebSocketSession.closeAndReturn(closeMessage: String = "", exitFunction: () -> Nothing): Nothing { + close(CloseReason(CloseReason.Codes.NORMAL, closeMessage)) + exitFunction() +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/views_game.kt b/src/jvmMain/kotlin/net/starshipfights/game/views_game.kt new file mode 100644 index 0000000..96d164d --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/views_game.kt @@ -0,0 +1,69 @@ +package net.starshipfights.game + +import io.ktor.application.* +import io.ktor.request.* +import kotlinx.html.* +import net.starshipfights.auth.getUserSession +import net.starshipfights.redirect + +fun ClientMode.view(): HTML.() -> Unit = { + head { + meta(charset = "UTF-8") + + link(rel = "icon", type = "image/svg+xml", href = "/static/images/icon.svg") + + link(rel = "preconnect", href = "https://fonts.googleapis.com") + link(rel = "preconnect", href = "https://fonts.gstatic.com") { attributes["crossorigin"] = "anonymous" } + link(rel = "stylesheet", href = "https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700;800;900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap") + link(rel = "stylesheet", href = "/static/game/style.css") + + script(src = "/static/game/textfit.min.js") {} + + script(src = "/static/game/three.js") {} + script(src = "/static/game/three-examples.js") {} + script(src = "/static/game/three-extras.js") {} + + when (this@view) { + is ClientMode.MatchmakingMenu -> title("Starship Fights | Lobby") + is ClientMode.InTrainingGame -> title("Starship Fights | Training") + is ClientMode.InGame -> title("Starship Fights | In-Game") + is ClientMode.Error -> title("Starship Fights | Error!") + } + } + body { + canvas { id = "three-canvas" } + + div(classes = "ui-layer") { id = "ui" } + + div(classes = "hide") { + id = "popup" + div(classes = "panel") { + id = "popup-panel" + div { + id = "popup-box" + } + } + } + + script { + attributes["id"] = "sf-client-mode" + type = "application/json" + unsafe { + +jsonSerializer.encodeToString(ClientMode.serializer(), this@view) + } + } + + script(src = "/static/game/starship-fights.js") {} + } +} + +suspend fun ApplicationCall.getGameClientMode(): ClientMode { + val userId = getUserSession()?.user ?: redirect("/login") + val token = receiveParameters()["token"] ?: return ClientMode.Error("Invalid or missing battle token") + val game = GameManager.joinGame(userId, token, false) ?: return ClientMode.Error("That battle is no longer available") + return ClientMode.InGame( + game.side, + token, + game.session.state.value + ) +} diff --git a/src/jvmMain/kotlin/net/starshipfights/game/views_training.kt b/src/jvmMain/kotlin/net/starshipfights/game/views_training.kt new file mode 100644 index 0000000..eeda0c8 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/game/views_training.kt @@ -0,0 +1,29 @@ +package net.starshipfights.game + +import io.ktor.application.* +import io.ktor.request.* +import net.starshipfights.auth.getUserSession +import net.starshipfights.data.Id +import net.starshipfights.data.admiralty.Admiral +import net.starshipfights.data.admiralty.getInGameAdmiral +import net.starshipfights.redirect + +suspend fun ApplicationCall.getTrainingClientMode(): ClientMode { + val userId = getUserSession()?.user ?: redirect("/login") + val parameters = receiveParameters() + + val admiralId = parameters["admiral"]?.let { Id(it) } ?: return ClientMode.Error("An admiral must be specified") + val admiralData = getInGameAdmiral(admiralId.reinterpret()) ?: return ClientMode.Error("That admiral does not exist") + + if (admiralData.user.id != userId.reinterpret()) return ClientMode.Error("You do not own that admiral") + + val battleSize = BattleSize.values().singleOrNull { it.toUrlSlug() == parameters["battle-size"] } ?: return ClientMode.Error("Invalid battle size") + val battleBg = BattleBackground.values().singleOrNull { it.toUrlSlug() == parameters["battle-bg"] } ?: return ClientMode.Error("Invalid battle background") + val battleInfo = BattleInfo(battleSize, battleBg) + + val enemyFaction = Faction.values().singleOrNull { it.toUrlSlug() == parameters["enemy-faction"] } ?: Faction.values().random() + + val initialState = generateTrainingInitialState(admiralData, enemyFaction, battleInfo) + + return ClientMode.InTrainingGame(initialState) +} diff --git a/src/jvmMain/kotlin/net/starshipfights/info/endpoints_info.kt b/src/jvmMain/kotlin/net/starshipfights/info/endpoints_info.kt new file mode 100644 index 0000000..c3809c8 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/endpoints_info.kt @@ -0,0 +1,74 @@ +package net.starshipfights.info + +import io.ktor.application.* +import io.ktor.html.* +import io.ktor.http.* +import io.ktor.response.* +import io.ktor.routing.* +import net.starshipfights.data.admiralty.AdmiralNameFlavor +import net.starshipfights.data.admiralty.AdmiralNames +import net.starshipfights.game.Moment +import net.starshipfights.game.ShipType +import net.starshipfights.game.toUrlSlug + +fun Routing.installPages() { + get("/") { + call.respondHtml(HttpStatusCode.OK, call.mainPage()) + } + + get("/info") { + call.respondHtml(HttpStatusCode.OK, call.shipsPage()) + } + + get("/info/{ship}") { + val ship = call.parameters["ship"]?.let { param -> ShipType.values().singleOrNull { it.toUrlSlug() == param } }!! + call.respondHtml(HttpStatusCode.OK, call.shipPage(ship)) + } + + get("/about") { + call.respondHtml(HttpStatusCode.OK, call.aboutPage()) + } + + get("/about/pp") { + call.respondHtml(HttpStatusCode.OK, call.privacyPolicyPage()) + } + + get("/about/tnc") { + call.respondHtml(HttpStatusCode.OK, call.termsAndConditionsPage()) + } + + get("/users") { + call.respondHtml(HttpStatusCode.OK, call.newUsersPage()) + } + + // Random name generation + get("/generate-name/{flavor}/{gender}") { + val flavor = call.parameters["flavor"]?.let { flavor -> AdmiralNameFlavor.values().singleOrNull { it.toUrlSlug().equals(flavor, ignoreCase = true) } }!! + val isFemale = call.parameters["gender"]?.startsWith('f', ignoreCase = true) ?: false + + call.respondText(AdmiralNames.randomName(flavor, isFemale), ContentType.Text.Plain) + } + + // Cache utils + val cacheTime = String.format("%f", Moment.now.toMillis()) + get("/cache-time") { + call.respondText(cacheTime, ContentType.Text.Plain, HttpStatusCode.OK) + } + + // Sitemap + val sitemapUrls = (listOf( + "/", + "/about", + "/about/pp", + "/about/tnc", + "/info", + ) + ShipType.values().map { + "/info/${it.toUrlSlug()}" + }).map { "https://starshipfights.net$it" } + + val sitemap = sitemapUrls.joinToString(separator = "\n") + + get("/sitemap.txt") { + call.respondText(sitemap, ContentType.Text.Plain, HttpStatusCode.OK) + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/info/html_utils.kt b/src/jvmMain/kotlin/net/starshipfights/info/html_utils.kt new file mode 100644 index 0000000..aaef6b7 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/html_utils.kt @@ -0,0 +1,36 @@ +package net.starshipfights.info + +import kotlinx.html.* +import net.starshipfights.auth.CsrfProtector +import net.starshipfights.data.Id +import net.starshipfights.data.auth.UserSession + +var A.method: String? + get() = attributes["data-method"] + set(value) { + if (value != null) + attributes["data-method"] = value + else + attributes.remove("data-method") + } + +fun A.csrfToken(cookie: Id) { + attributes["data-csrf-token"] = CsrfProtector.newNonce(cookie, this.href) +} + +fun FORM.csrfToken(cookie: Id) = hiddenInput { + name = CsrfProtector.csrfInputName + value = CsrfProtector.newNonce(cookie, this@csrfToken.action) +} + +fun interface SECTIONS { + fun section(body: SECTION.() -> Unit) +} + +fun MAIN.sectioned(): SECTIONS = MainSections(this) + +private class MainSections(private val delegate: MAIN) : SECTIONS { + override fun section(body: SECTION.() -> Unit) { + delegate.section(block = body) + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/info/view_bar.kt b/src/jvmMain/kotlin/net/starshipfights/info/view_bar.kt new file mode 100644 index 0000000..7963688 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/view_bar.kt @@ -0,0 +1,40 @@ +package net.starshipfights.info + +import kotlinx.html.* +import net.starshipfights.game.ShipType +import net.starshipfights.game.getDefiniteShortName + +abstract class Sidebar { + protected abstract fun TagConsumer<*>.display() + fun displayIn(aside: ASIDE) = aside.consumer.display() +} + +class CustomSidebar(private val block: TagConsumer<*>.() -> Unit) : Sidebar() { + override fun TagConsumer<*>.display() = block() +} + +data class ShipViewSidebar(val shipType: ShipType) : Sidebar() { + override fun TagConsumer<*>.display() { + p { + img(alt = "Flag of ${shipType.faction.getDefiniteShortName()}", src = shipType.faction.flagUrl) + } + p { + style = "text-align:center" + +shipType.weightClass.displayName + +" of the " + +shipType.faction.navyName + } + } +} + +data class PageNavSidebar(val contents: List) : Sidebar() { + override fun TagConsumer<*>.display() { + div(classes = "list") { + for (it in contents) { + div(classes = "item") { + it.displayIn(this) + } + } + } + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/info/view_nav.kt b/src/jvmMain/kotlin/net/starshipfights/info/view_nav.kt new file mode 100644 index 0000000..60357d6 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/view_nav.kt @@ -0,0 +1,67 @@ +package net.starshipfights.info + +import io.ktor.application.* +import kotlinx.html.DIV +import kotlinx.html.a +import kotlinx.html.span +import kotlinx.html.style +import net.starshipfights.CurrentConfiguration +import net.starshipfights.auth.getUserAndSession +import net.starshipfights.data.Id +import net.starshipfights.data.auth.UserSession + +sealed class NavItem { + protected abstract fun DIV.display() + fun displayIn(div: DIV) = div.display() +} + +data class NavHead(val label: String) : NavItem() { + override fun DIV.display() { + span { + style = "font-variant:small-caps;text-decoration:underline" + +label + } + } +} + +data class NavLink(val to: String, val text: String, val classes: String? = null, val isPost: Boolean = false, val csrfUserCookie: Id? = null) : NavItem() { + override fun DIV.display() { + a(href = to, classes = classes) { + if (isPost) + method = "post" + csrfUserCookie?.let { csrfToken(it) } + + +text + } + } +} + +suspend fun ApplicationCall.standardNavBar(): List = listOf( + NavLink("/", "Main Page"), + NavLink("/info", "Read Manual"), + NavLink("/about", "About Starship Fights"), + NavLink("/users", "New Users"), + NavHead("Your Account"), +) + getUserAndSession().let { (session, user) -> + if (session == null || user == null) + listOf( + NavLink("/login", "Login with Discord"), + ) + else + listOf( + NavLink("/me", user.profileName), + NavLink("/me/manage", "User Preferences"), + NavLink("/lobby", "Enter Game Lobby", classes = "desktop"), + NavLink("/logout", "Log Out", isPost = true, csrfUserCookie = session.id), + ) +} + listOf( + NavHead("External Information") +) + (CurrentConfiguration.discordClient?.serverInvite?.let { + listOf( + NavLink("https://discord.gg/$it", "Official Discord") + ) +} ?: emptyList()) + listOf( + NavLink("https://mechyrdia.netlify.app/", "Mechyrdia Infobase"), + NavLink("https://nationstates.net/mechyrdia", "Multiverse Access"), + NavLink("https://www.buymeacoffee.com/starshipfights", "Support Starship Fights"), +) diff --git a/src/jvmMain/kotlin/net/starshipfights/info/view_tpl.kt b/src/jvmMain/kotlin/net/starshipfights/info/view_tpl.kt new file mode 100644 index 0000000..5952a15 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/view_tpl.kt @@ -0,0 +1,81 @@ +package net.starshipfights.info + +import io.ktor.application.* +import kotlinx.html.* +import net.starshipfights.auth.getUser +import net.starshipfights.data.auth.PreferredTheme + +suspend fun ApplicationCall.page(pageTitle: String? = null, navBar: List? = null, sidebar: Sidebar? = null, content: SECTIONS.() -> Unit): HTML.() -> Unit { + val theme = getUser()?.preferredTheme + + return { + when (theme) { + PreferredTheme.LIGHT -> "light" + PreferredTheme.DARK -> "dark" + else -> null + }?.let { attributes["data-theme"] = it } + + head { + meta(charset = "utf-8") + meta(name = "viewport", content = "width=device-width, initial-scale=1.0") + + link(rel = "icon", type = "image/svg+xml", href = "/static/images/icon.svg") + link(rel = "preconnect", href = "https://fonts.googleapis.com") + link(rel = "preconnect", href = "https://fonts.gstatic.com") { attributes["crossorigin"] = "anonymous" } + link(rel = "stylesheet", href = "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Jetbrains+Mono:wght@400;600;800&display=swap") + link(rel = "stylesheet", href = "/static/style.css") + + title { + +"Starship Fights" + pageTitle?.let { +" | $it" } + } + } + body { + div { id = "bg" } + + navBar?.let { nb -> + nav(classes = "desktop") { + div(classes = "list") { + for (ni in nb) { + div(classes = "item") { + ni.displayIn(this) + } + } + } + } + } + + sidebar?.let { + aside(classes = "desktop") { + it.displayIn(this) + } + } + + main { + sidebar?.let { + aside(classes = "mobile") { + it.displayIn(this) + } + } + + with(sectioned()) { + content() + } + + navBar?.let { nb -> + nav(classes = "mobile") { + div(classes = "list") { + for (ni in nb) { + div(classes = "item") { + ni.displayIn(this) + } + } + } + } + } + } + + script(src = "/static/init.js") {} + } + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/info/views_error.kt b/src/jvmMain/kotlin/net/starshipfights/info/views_error.kt new file mode 100644 index 0000000..56ebbc9 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/views_error.kt @@ -0,0 +1,68 @@ +package net.starshipfights.info + +import io.ktor.application.* +import io.ktor.features.* +import kotlinx.html.HTML +import kotlinx.html.h1 +import kotlinx.html.p +import kotlinx.html.style +import net.starshipfights.CurrentConfiguration + +private fun SECTIONS.devModeCallId(callId: String?) { + callId?.let { id -> + section { + style = if (CurrentConfiguration.isDevEnv) "" else "display:none" + +"If you think this is a bug, report it with the call ID #" + +id + +"." + } + } +} + +suspend fun ApplicationCall.error400(): HTML.() -> Unit = page("Bad Request", standardNavBar()) { + section { + h1 { +"Bad Request" } + p { +"The request your browser sent was improperly formatted." } + } + devModeCallId(callId) +} + +suspend fun ApplicationCall.error403(): HTML.() -> Unit = page("Not Allowed", standardNavBar()) { + section { + h1 { +"Not Allowed" } + p { +"You are not allowed to do that." } + } + devModeCallId(callId) +} + +suspend fun ApplicationCall.error403InvalidCsrf(): HTML.() -> Unit = page("CSRF Validation Failed", standardNavBar()) { + section { + h1 { +"CSRF Validation Failed" } + p { +"Unfortunately, the received CSRF failed to validate. Please try again." } + } + devModeCallId(callId) +} + +suspend fun ApplicationCall.error404(): HTML.() -> Unit = page("Not Found", standardNavBar()) { + section { + h1 { +"Not Found" } + p { +"Unfortunately, we could not find what you were looking for." } + } + devModeCallId(callId) +} + +suspend fun ApplicationCall.error429(): HTML.() -> Unit = page("Too Many Requests", standardNavBar()) { + section { + h1 { +"Too Many Requests" } + p { +"Our server is being bogged down in a quagmire of HTTP requests. Please try again later." } + } + devModeCallId(callId) +} + +suspend fun ApplicationCall.error503(): HTML.() -> Unit = page("Internal Error", standardNavBar()) { + section { + h1 { +"Internal Error" } + p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." } + } + devModeCallId(callId) +} diff --git a/src/jvmMain/kotlin/net/starshipfights/info/views_gdpr.kt b/src/jvmMain/kotlin/net/starshipfights/info/views_gdpr.kt new file mode 100644 index 0000000..20e59a1 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/views_gdpr.kt @@ -0,0 +1,195 @@ +package net.starshipfights.info + +import io.ktor.application.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.toList +import kotlinx.html.* +import net.starshipfights.auth.getUser +import net.starshipfights.auth.getUserSession +import net.starshipfights.data.admiralty.Admiral +import net.starshipfights.data.admiralty.BattleRecord +import net.starshipfights.data.admiralty.ShipInDrydock +import net.starshipfights.data.admiralty.ShipMemorial +import net.starshipfights.data.auth.User +import net.starshipfights.data.auth.UserSession +import net.starshipfights.game.GlobalSide +import net.starshipfights.redirect +import org.litote.kmongo.eq +import org.litote.kmongo.or +import java.time.Instant + +suspend fun ApplicationCall.privateInfo(): String { + val currentSession = getUserSession() ?: redirect("/login") + + val now = Instant.now() + + val userId = currentSession.user + val (user, userData) = coroutineScope { + val getUser = async { User.get(userId) } + val getAdmirals = async { Admiral.filter(Admiral::owningUser eq userId).toList() } + val getSessions = async { UserSession.filter(UserSession::user eq userId).toList() } + val getBattles = async { + BattleRecord.filter( + or( + BattleRecord::hostUser eq userId, + BattleRecord::guestUser eq userId + ) + ).toList() + } + + getUser.await() to Triple(getAdmirals.await(), getSessions.await(), getBattles.await()) + } + val (userAdmirals, userSessions, userBattles) = userData + user ?: redirect("/login") + + val battleEndings = userBattles.associate { record -> + record.id to when (record.winner) { + GlobalSide.HOST -> record.hostUser == userId + GlobalSide.GUEST -> record.guestUser == userId + null -> null + } + } + + val (admiralShips, battleOpponents, battleAdmirals) = coroutineScope { + val getShips = userAdmirals.associate { admiral -> + admiral.id to (async { + ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiral.id).toList() + } to async { + ShipMemorial.filter(ShipMemorial::owningAdmiral eq admiral.id).toList() + }) + } + val getOpponents = userBattles.associate { record -> + val (opponentId, opponentAdmiralId) = if (record.hostUser == userId) record.guestUser to record.guestAdmiral else record.hostUser to record.hostAdmiral + + record.id to (async { User.get(opponentId) } to async { Admiral.get(opponentAdmiralId) }) + } + val getAdmirals = userBattles.associate { record -> + val admiralId = if (record.hostUser == userId) record.hostAdmiral else record.guestAdmiral + record.id to userAdmirals.singleOrNull { it.id == admiralId } + } + + Triple( + getShips.mapValues { (_, pair) -> + val (ships, graves) = pair + ships.await() to graves.await() + }, + getOpponents.mapValues { (_, deferred) -> deferred.let { (u, a) -> u.await() to a.await() } }, + getAdmirals + ) + } + + return buildString { + appendLine("# Private data of user https://starshipfights.net/user/$userId\n") + appendLine("Profile name: ${user.profileName}") + appendLine("Profile bio: \"\"\"") + appendLine(user.profileBio) + appendLine("\"\"\"") + appendLine("Display theme: ${user.preferredTheme}") + appendLine("") + appendLine("## Activity data") + appendLine("Registered at: ${user.registeredAt}") + appendLine("Last activity: ${user.lastActivity}") + appendLine("Online status: ${if (user.showUserStatus) "shown" else "hidden"}") + appendLine("") + appendLine("## Discord login data") + appendLine("Discord ID: ${user.discordId}") + appendLine("Discord name: ${user.discordName}") + appendLine("Discord discriminator: ${user.discordDiscriminator}") + appendLine(user.discordAvatar?.let { "Discord avatar: $it" } ?: "Discord avatar absent") + appendLine("Discord profile: ${if (user.showDiscordName) "shown" else "hidden"}") + appendLine("") + appendLine("## Session data") + appendLine("IP addresses are ${if (user.logIpAddresses) "stored" else "ignored"}") + for (session in userSessions.sortedByDescending { it.expiration }) { + appendLine("") + appendLine("### Session ${session.id}") + appendLine("Browser User-Agent: ${session.userAgent}") + appendLine("Client addresses${if (session.clientAddresses.isEmpty()) " are not stored" else ":"}") + for (addr in session.clientAddresses) appendLine("* $addr") + appendLine("${if (session.expiration > now) "Will expire" else "Has expired"} at: ${session.expiration}") + } + appendLine("") + appendLine("## Battle-record data") + for (record in userBattles.sortedBy { it.whenEnded }) { + appendLine("") + appendLine("### Battle record ${record.id}") + appendLine("Battle size: ${record.battleInfo.size.displayName} (${record.battleInfo.size.numPoints})") + appendLine("Battle background: ${record.battleInfo.bg.displayName}") + appendLine("Battle started at: ${record.whenStarted}") + appendLine("Battle completed at: ${record.whenEnded}") + appendLine("Battle was fought by ${battleAdmirals[record.id]?.let { "${it.fullName} (https://starshipfights.net/admiral/${it.id})" } ?: "{deleted admiral}"}") + appendLine("Battle was fought against ${battleOpponents[record.id]?.second?.let { "${it.fullName} (https://starshipfights.net/admiral/${it.id})" } ?: "{deleted admiral}"}") + appendLine(" => ${battleOpponents[record.id]?.first?.let { "${it.profileName} (https://starshipfights.net/user/${it.id})" } ?: "{deleted user}"}") + when (battleEndings[record.id]) { + true -> appendLine("Battle ended in victory") + false -> appendLine("Battle ended in defeat") + null -> appendLine("Battle ended in stalemate") + } + appendLine(" => \"${record.winMessage}\"") + } + appendLine("") + appendLine("## Admiral data") + for (admiral in userAdmirals) { + appendLine("") + appendLine("### ${admiral.fullName} (https://starshipfights.net/admiral/${admiral.id})") + appendLine("Admiral is ${if (admiral.isFemale) "female" else "male"}") + appendLine("Admiral serves the ${admiral.faction.navyName}") + appendLine("Admiral's experience is ${admiral.acumen} acumen") + appendLine("Admiral's monetary wealth is ${admiral.money} ${admiral.faction.currencyName}") + appendLine("Admiral can command ships as big as a ${admiral.rank.maxShipWeightClass.displayName}") + val ships = admiralShips[admiral.id]?.first.orEmpty() + appendLine("Admiral has ${ships.size} ships:") + for (ship in ships) { + appendLine("") + appendLine("#### ${ship.fullName} (${ship.id})") + appendLine("Ship is a ${ship.shipType.fullerDisplayName}") + appendLine("Ship ${if (ship.readyAt > now) "will be ready at" else "has been ready since"} ${ship.readyAt}") + } + appendLine("") + val graves = admiralShips[admiral.id]?.second.orEmpty() + appendLine("Admiral has lost ${ships.size} ships in battle:") + for (grave in graves) { + appendLine("") + appendLine("#### ${grave.fullName} (${grave.id})") + appendLine("Ship is a ${grave.shipType.fullerDisplayName}") + appendLine("Ship was destroyed at ${grave.destroyedAt} in battle recorded at ${grave.destroyedIn}") + } + appendLine("") + appendLine("# More information") + appendLine("This document contains the totality of your private data as stored by Starship Fights") + appendLine("This page can be accessed at https://starshipfights.net/me/private-info") + appendLine("All private info can be downloaded at https://starshipfights.net/me/private-info/txt") + appendLine("The privacy policy can be reviewed at https://starshipfights.net/about/pp") + } + } +} + +suspend fun ApplicationCall.privateInfoPage(): HTML.() -> Unit { + if (getUser() == null) redirect("/login") + + return page( + null, standardNavBar(), PageNavSidebar( + listOf( + NavLink("/me/manage", "Back to Preferences"), + NavLink("/about/pp", "Review Privacy Policy"), + ) + ) + ) { + section { + h1 { +"Your Private Info" } + + iframe { + style = "width:100%;height:25em" + src = "/me/private-info/txt" + } + + p { + a(href = "/me/private-info/txt") { + attributes["download"] = "private-info.txt" + +"Download your private info" + } + } + } + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/info/views_main.kt b/src/jvmMain/kotlin/net/starshipfights/info/views_main.kt new file mode 100644 index 0000000..635f794 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/views_main.kt @@ -0,0 +1,236 @@ +package net.starshipfights.info + +import io.ktor.application.* +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.html.* +import net.starshipfights.CurrentConfiguration +import net.starshipfights.data.auth.User +import net.starshipfights.game.foreign +import org.litote.kmongo.descending +import org.litote.kmongo.eq + +suspend fun ApplicationCall.mainPage(): HTML.() -> Unit { + return page(null, standardNavBar(), null) { + section { + img(alt = "Starship Fights Logo", src = "/static/images/logo.svg") { + style = "width:100%" + } + p { + +"Starship Fights is a space fleet battle game. Choose your allegiance, create your admiral, build up your fleet, and destroy your enemies' fleets with it. You might, on occasion, get destroyed by your enemies; that's entirely normal, and all a part of learning." + } + p { + +"Set in the galaxy-wide " + a(href = "https://nationstates.net/mechyrdia") { +"Mechyrdiaverse" } + +", Starship Fights is about the grand struggle between six major political powers. Fight for liberty and justice with the Empire of Mechyrdia or their friends in the Dutch Outer Space Company, conquer for glory and honor with the Diadochus Masra Draetsen, strike from the shadows with the " + foreign("la") { +"Felinae Felices" } + +", preserve your homeland and decide its fate with the Isarnareyksk Federation, or reclaim your people's rightful dominion with the American Vestigium! The choice is yours, admiral." + } + } + } +} + +suspend fun ApplicationCall.aboutPage(): HTML.() -> Unit { + val owner = CurrentConfiguration.discordClient?.ownerId?.let { + User.locate(User::discordId eq it) + } ?: return page( + "About", standardNavBar(), null + ) { + section { + h1 { +"In Development" } + p { + +"This is a test instance of Starship Fights." + } + } + } + + return page( + "About", standardNavBar(), PageNavSidebar( + listOf( + NavHead("Useful Links"), + NavLink("/about/pp", "Privacy Policy"), + NavLink("/about/tnc", "Terms and Conditions"), + ) + ) + ) { + section { + h1 { +"About Starship Fights" } + p { + +"Starship Fights is designed and programmed by the person behind " + a(href = "https://nationstates.net/mechyrdia") { +"Mechyrdia" } + +". He can be reached by telegram on NationStates, or by his " + a(href = "/user/${owner.id}") { +"account on this site" } + +"." + } + } + } +} + +suspend fun ApplicationCall.privacyPolicyPage(): HTML.() -> Unit { + return page( + "Privacy Policy", standardNavBar(), PageNavSidebar( + listOf( + NavHead("Useful Links"), + NavLink("/about", "About Starship Fights"), + NavLink("/about/tnc", "Terms and Conditions"), + ) + ) + ) { + section { + h1 { +"Privacy Policy" } + h2 { +"What Data Do We Collect" } + p { +"Starship Fights does not collect very much personal data; the only data it collects is relevant to either user authentication or user authorization. The following data is collected by the game:" } + dl { + dt { +"Discord ID" } + dd { +"This is needed to keep your Starship Fights user account associated with your Discord login, so that you can keep your admirals and ships when you log in." } + dt { +"Discord Profile Data (Name, Discriminator, Avatar)" } + dd { + +"This is kept so that you have the option of showing what your Discord account is on your profile page. It's optional to display to other users, with the choice being in the " + a(href = "/me/manage") { +"User Preferences" } + +" page. Note that we do " + strong { +"not" } + +" request or track email addresses." + } + dt { +"Your browser's User-Agent" } + dd { + +"This is associated with your session data as a layer of security, so that if someone were to (somehow) steal your session token and put it into their browser, that person wouldn't be logged in as you, since the User-Agent would probably be different." + } + dt { +"Your public-facing IP address (opt-in)" } + dd { + +"This is associated with your sessions, so that it may be displayed to you when you look at your currently logged-in sessions on your " + a(href = "/me/manage") { +"User Preferences" } + +" page, so that you can log out of a session if you don't recognize its IP address. You may opt in to the site's collection and storage of your IP address on that same page." + } + dt { +"The date and time of your last activity" } + dd { + +"This is associated with your user account as a whole, so that your Online/Offline status can be displayed. It's optional to display your current status, and the choice is in your " + a(href = "/me/manage") { +"User Preferences" } + +" page." + } + } + h2 { +"How Do We Collect It" } + p { + +"Your Discord information is collected using the Discord API whenever you log in via Discord's OAuth2. Your User-Agent and IP address are collected using the HTTP requests that your browser sends to the website, and the date and time of your last activity is tracked using the server's system clock." + } + h2 { +"Who Can See It" } + p { + +"The only people who can see the data we collect are you and the system administrator. We do not sell data to advertisers. The site is hosted on " + a(href = "https://hetzner.com/") { +"Hetzner Cloud" } + +", who can " + em { +"in theory" } + +" access it." + } + p { + +"Privacy policies are nice and all, but they're only as strong as the staff that implements them. I have no interest in abusing others, just as I have no interest in doxing or otherwise revealing what locations people log in from. Nor have I any interest in being worshipped as some kind of programmer-god messiah. I am impervious to such corrupting ambitions." + } + p { + +"All of the private data we collect can be viewed at your " + a(href = "/me/private-info") { +"Private Info" } + +" page." + } + h2 { +"Who Can't See It" } + p { + +"We protect your data by a combination of requiring TLS-secured HTTP connections, and keeping the database's port only open on 127.0.0.1, i.e. no one outside of the server's local machine can even connect to the database, much less access the data stored inside of it." + } + h2 { +"When Was This Written" } + dl { + dt { +"February 13, 2022" } + dd { +"Initial writing" } + dt { +"February 15, 2022" } + dd { +"Indicate that IP storage is an opt-in-only feature" } + dt { +"April 08, 2022" } + dd { +"Add link to Private Info page" } + } + } + } +} + +suspend fun ApplicationCall.termsAndConditionsPage(): HTML.() -> Unit { + val ownerDiscordUsername = CurrentConfiguration.discordClient?.ownerId?.let { + User.locate(User::discordId eq it) + }?.let { "${it.discordName}#${it.discordDiscriminator}" } + + return page( + "Terms and Conditions", standardNavBar(), PageNavSidebar( + listOf( + NavHead("Useful Links"), + NavLink("/about", "About Starship Fights"), + NavLink("/about/pp", "Privacy Policy"), + ) + ) + ) { + section { + h1 { +"Terms And Conditions" } + h2 { +"Section I - Privacy Policy" } + p { + +"By agreeing to these Terms and Conditions, you confirm that you have read and acknowledged the Privacy Policy of Starship Fights, accessible at " + a(href = "https://starshipfights.net/about/pp") { +"https://starshipfights.net/about/pp" } + +"." + } + h2 { +"Section II - Limitation of Liability" } + p { + +"UNDER NO CIRCUMSTANCES will Starship Fights be liable or responsible to either its users or any third party for any damages or injuries sustained as a result of using this website." + } + h2 { +"Section III - Termination" } + p { + +"Starship Fights may terminate your usage if:" + } + ol { + li { +"You are in breach of these Terms and Conditions." } + li { +"You, at any point, inflict abuse upon the website, including but not limited to: DDoS attacks, vulnerability scanning, vulnerability exploitation, etc." } + li { +"For any reason, at our sole discretion." } + } + h2 { +"Section IV - Amendment Process" } + p { + +"Starship Fights will notify users when amendments to the Terms and Conditions will impact their usage of their site." + CurrentConfiguration.discordClient?.serverInvite?.let { invite -> + +" Users will be notified via the " + a(href = "https://discord.gg/$invite") { +"Starship Fights Discord server" } + +"." + } + } + h2 { +"Section V - Amendments" } + dl { + dt { +"March 11, 2022" } + dd { +"Initial writing" } + } + ownerDiscordUsername?.let { + h2 { +"Section VI - Contact" } + p { + +"The operator of Starship Fights may be contacted via Discord at $it, or via telegram to " + a(href = "https://nationstates.net/mechyrdia") { +"his NationStates account" } + +"." + } + } + } + } +} + +suspend fun ApplicationCall.newUsersPage(): HTML.() -> Unit { + val newUsers = User.sorted(descending(User::registeredAt)).take(20).toList() + + return page("New Users", standardNavBar()) { + section { + h1 { +"New Users" } + div { + style = "text-align:center" + for (newUser in newUsers) { + div { + style = "display:inline-block;width:20%;padding:2%" + a(href = "/user/${newUser.id}") { + img(src = newUser.discordAvatarUrl) { + style = "width:100%;border-radius:50%" + } + } + p { + style = "text-align:center" + a(href = "/user/${newUser.id}") { + +newUser.profileName + } + } + } + } + } + } + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/info/views_ships.kt b/src/jvmMain/kotlin/net/starshipfights/info/views_ships.kt new file mode 100644 index 0000000..648dda0 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/views_ships.kt @@ -0,0 +1,228 @@ +package net.starshipfights.info + +import io.ktor.application.* +import kotlinx.html.* +import net.starshipfights.game.* +import kotlin.math.PI +import kotlin.math.roundToInt + +private val shipsPageSidebar: PageNavSidebar + get() = PageNavSidebar( + listOf(NavHead("Jump to Faction")) + Faction.values().map { faction -> + NavLink("#${faction.toUrlSlug()}", faction.polityName) + } + ) + +suspend fun ApplicationCall.shipsPage(): HTML.() -> Unit = page("Strategema Nauticum", standardNavBar(), shipsPageSidebar) { + section { + h1 { + foreign("la") { +"Strategema Nauticum" } + } + p { + +"Here you will find an index of all ship classes in Starship Fights, with links to pages that show ship stats and appearances." + } + } + for ((faction, factionShipTypes) in ShipType.values().groupBy { it.faction }.toSortedMap()) { + section { + id = faction.toUrlSlug() + + h2 { +faction.polityName } + + p { + style = "text-align:center" + img(src = faction.flagUrl, alt = "Flag of ${faction.getDefiniteShortName()}") { + style = "width:40%" + } + } + + faction.blurbDesc(consumer) + + for ((weightClass, weightedShipTypes) in factionShipTypes.groupBy { it.weightClass }.toSortedMap(Comparator.comparingInt(ShipWeightClass::tier))) { + h3 { +weightClass.displayName } + ul { + for (shipType in weightedShipTypes) { + li { + a(href = "/info/${shipType.toUrlSlug()}") { + +shipType.fullDisplayName + +" (${shipType.pointCost} points)" + } + } + } + } + } + } + } +} + +suspend fun ApplicationCall.shipPage(shipType: ShipType): HTML.() -> Unit = page( + shipType.fullerDisplayName, + standardNavBar(), + ShipViewSidebar(shipType) +) { + section { + h1 { +shipType.fullDisplayName } + + p { +Entities.nbsp } + + table { + tr { + th { +"Weight Class" } + th { +"Hull Integrity" } + th { +"Defense Turrets" } + } + tr { + td { + +shipType.weightClass.displayName + br + +"(${shipType.pointCost} points to deploy)" + } + td { + +"${shipType.weightClass.durability.maxHullPoints} impacts" + br + +"${shipType.weightClass.durability.troopsDefense} troops" + } + td { + when (val durability = shipType.weightClass.durability) { + is StandardShipDurability -> +"${durability.turretDefense.toPercent()} fighter-wing equivalent" + is FelinaeShipDurability -> { + span { + style = "font-style:italic" + +"Felinae Felices ships do not use turrets" + } + br + br + +"Disruption Pulse can wipe out strike craft up to ${durability.disruptionPulseRange} meters away up to ${durability.disruptionPulseShots} times" + } + } + } + } + tr { + th { +"Max Movement" } + th { +"Reactor Power" } + th { +"Energy Flow" } + } + tr { + when (val movement = shipType.weightClass.movement) { + is StandardShipMovement -> td { + +"Accelerate ${movement.moveSpeed.roundToInt()} meters/turn" + br + +"Rotate ${(movement.turnAngle * 180.0 / PI).roundToInt()} degrees/turn" + } + is FelinaeShipMovement -> td { + +"Accelerate ${movement.moveSpeed.roundToInt()} meters/turn" + br + +"Rotate ${(movement.turnAngle * 180.0 / PI).roundToInt()} degrees/turn" + br + br + +"Inertialess Drive can jump up to ${movement.inertialessDriveRange} meters up to ${movement.inertialessDriveShots} times" + } + } + + when (val reactor = shipType.weightClass.reactor) { + is StandardShipReactor -> { + td { + +reactor.powerOutput.toString() + br + +"(${reactor.subsystemAmount} per subsystem)" + } + td { + +reactor.gridEfficiency.toString() + } + } + FelinaeShipReactor -> { + td { + colSpan = "2" + style = "font-style:italic" + +"Felinae Felices ships use hyper-technologically-advanced super-reactors that need not concern themselves with \"power output\" or \"grid efficiency\"." + } + } + } + } + tr { + th { +"Base Crit Chance" } + th { +"Cannon Targeting" } + th { +"Lance Efficiency" } + } + tr { + td { + +shipType.weightClass.firepower.criticalChance.toPercent() + } + td { + +shipType.weightClass.firepower.cannonAccuracy.toPercent() + } + td { + if (shipType.weightClass.firepower.lanceCharging < 0.0) + +"N/A" + else + +shipType.weightClass.firepower.lanceCharging.toPercent() + } + } + } + table { + tr { + th { +"Armament" } + th { +"Firing Arcs" } + th { +"Range" } + th { +"Firepower" } + } + + for ((label, weapons) in shipType.armaments.values.groupBy { it.groupLabel }) { + val weapon = weapons.distinct().single() + val numShots = weapons.sumOf { it.numShots } + + tr { + td { +label } + td { + +if (weapon is AreaWeapon && weapon.isLine) { + "Linear (Fore-firing)" + } else if (weapon is ShipWeapon.Hangar) { + "(Omnidirectional)" + } else { + weapon.firingArcs.joinToString { arc -> arc.displayName } + } + } + td { + val weaponRangeMult = when (weapon) { + is ShipWeapon.Cannon -> shipType.weightClass.firepower.rangeMultiplier + is ShipWeapon.Lance -> shipType.weightClass.firepower.rangeMultiplier + is ShipWeapon.ParticleClawLauncher -> shipType.weightClass.firepower.rangeMultiplier + is ShipWeapon.LightningYarn -> shipType.weightClass.firepower.rangeMultiplier + else -> 1.0 + } + + weapon.minRange.takeIf { it != SHIP_BASE_SIZE }?.let { +"${it.roundToInt()}-" } + +"${(weapon.maxRange * weaponRangeMult).roundToInt()} meters" + if (weapon is AreaWeapon) { + br + +"${weapon.areaRadius.roundToInt()} meter impact radius" + } + } + td { + +when (weapon) { + is ShipWeapon.Cannon -> "$numShots cannon" + (if (numShots == 1) "" else "s") + is ShipWeapon.Lance -> "$numShots lance" + (if (numShots == 1) "" else "s") + is ShipWeapon.Torpedo -> "$numShots launcher" + (if (numShots == 1) "" else "s") + is ShipWeapon.Hangar -> "$numShots strike wing" + (if (numShots == 1) "" else "s") + is ShipWeapon.ParticleClawLauncher -> "$numShots particle claw launcher" + (if (numShots == 1) "" else "s") + is ShipWeapon.LightningYarn -> "$numShots lightning yarn launcher" + (if (numShots == 1) "" else "s") + ShipWeapon.MegaCannon -> "Severely damages targets" + ShipWeapon.RevelationGun -> "Vaporizes target" + ShipWeapon.EmpAntenna -> "Randomly depletes targets' subsystems" + } + } + } + } + } + + p { +Entities.nbsp } + + canvas { + style = "width:100%;height:25em" + attributes["data-model"] = shipType.meshName + } + + script { + unsafe { +"window.sfShipMeshViewer = true;" } + } + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/info/views_user.kt b/src/jvmMain/kotlin/net/starshipfights/info/views_user.kt new file mode 100644 index 0000000..e9f5eb2 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/info/views_user.kt @@ -0,0 +1,1043 @@ +package net.starshipfights.info + +import io.ktor.application.* +import io.ktor.features.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.toList +import kotlinx.html.* +import net.starshipfights.auth.* +import net.starshipfights.data.Id +import net.starshipfights.data.admiralty.* +import net.starshipfights.data.auth.* +import net.starshipfights.forbid +import net.starshipfights.game.* +import net.starshipfights.redirect +import org.litote.kmongo.and +import org.litote.kmongo.eq +import org.litote.kmongo.gt +import org.litote.kmongo.or +import java.time.Instant + +suspend fun ApplicationCall.userPage(): HTML.() -> Unit { + val userId = Id(parameters["id"]!!) + val user = User.get(userId)!! + val currentUser = getUserSession() + + val isCurrentUser = user.id == currentUser?.user + val hasOpenSessions = UserSession.locate( + and(UserSession::user eq userId, UserSession::expiration gt Instant.now()) + ) != null + + val admirals = Admiral.filter(Admiral::owningUser eq user.id).toList() + + return page( + user.profileName, standardNavBar(), CustomSidebar { + if (user.showDiscordName) { + img(src = user.discordAvatarUrl) { + style = "border-radius:50%" + } + p { + style = "text-align:center" + +user.discordName + +"#" + +user.discordDiscriminator + } + } else { + img(src = user.anonymousAvatarUrl) { + style = "border-radius:50%" + } + } + for (trophy in user.getTrophies()) + renderTrophy(trophy) + + if (user.showUserStatus) { + p { + style = "text-align:center" + +when (user.status) { + UserStatus.IN_BATTLE -> "In Battle" + UserStatus.READY_FOR_BATTLE -> "In Battle" + UserStatus.IN_MATCHMAKING -> "In Matchmaking" + UserStatus.AVAILABLE -> if (hasOpenSessions) "Online" else "Offline" + } + } + p { + style = "text-align:center" + +"Registered at " + span(classes = "moment") { + style = "display:none" + +user.registeredAt.toEpochMilli().toString() + } + br + +"Last active at " + span(classes = "moment") { + style = "display:none" + +user.lastActivity.toEpochMilli().toString() + } + } + } + if (isCurrentUser) { + hr { style = "border-color:#036" } + div(classes = "list") { + div(classes = "item") { + a(href = "/admiral/new") { +"Create New Admiral" } + } + div(classes = "item") { + a(href = "/me/manage") { +"Edit Profile" } + } + } + } /*else if (currentUser != null) { + hr { style = "border-color:#036" } + div(classes = "list") { + div(classes = "item") { + a(href = "/user/${userId}/send") { +"Send Message" } + } + } + }*/ + } + ) { + section { + h1 { +user.profileName } + + for (paragraph in user.profileBio.split('\n')) + p { +paragraph } + } + section { + h2 { +"Admirals" } + + if (admirals.isNotEmpty()) { + p { + +"This user has the following admirals:" + } + ul { + for (admiral in admirals.sortedBy { it.name }.sortedBy { it.rank }.sortedBy { it.faction }) { + li { + a("/admiral/${admiral.id}") { +admiral.fullName } + } + } + } + } else + p { + +"This user has no admirals." + } + } + } +} + +suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit { + val currentSession = getUserSession() ?: redirect("/login") + val currentUser = User.get(currentSession.user) ?: redirect("/login") + val allUserSessions = UserSession.filter(and(UserSession::user eq currentUser.id)).toList() + + return page( + "User Preferences", standardNavBar(), PageNavSidebar( + listOf( + NavLink("/me", "Back to User Page") + ) + ) + ) { + section { + h1 { +"User Preferences" } + form(method = FormMethod.post, action = "/me/manage") { + csrfToken(currentSession.id) + h2 { + +"Profile" + } + h3 { + label { + htmlFor = "name" + +"Display Name" + } + } + textInput(name = "name") { + required = true + maxLength = "$PROFILE_NAME_MAX_LENGTH" + + value = currentUser.profileName + autoComplete = false + } + p { + style = "font-style:italic;font-size:0.8em;color:#555" + +"Max length $PROFILE_NAME_MAX_LENGTH characters" + } + h3 { + label { + htmlFor = "bio" + +"Public Bio" + } + } + textArea { + name = "bio" + style = "width: 100%;height:5em" + + required = true + maxLength = "$PROFILE_BIO_MAX_LENGTH" + + +currentUser.profileBio + } + h3 { + +"Display Theme" + } + p { + +"Clicking one of the options here will preview the selected theme. It is still necessary to click Accept Changes to keep your choice of theme." + } + label { + radioInput(name = "theme") { + id = "system-theme" + value = "system" + required = true + checked = currentUser.preferredTheme == PreferredTheme.SYSTEM + } + +Entities.nbsp + +"System Choice" + } + br + label { + radioInput(name = "theme") { + id = "light-theme" + value = "light" + required = true + checked = currentUser.preferredTheme == PreferredTheme.LIGHT + } + +Entities.nbsp + +"Light Theme" + } + br + label { + radioInput(name = "theme") { + id = "dark-theme" + value = "dark" + required = true + checked = currentUser.preferredTheme == PreferredTheme.DARK + } + +Entities.nbsp + +"Dark Theme" + } + h3 { + +"Privacy Settings" + } + label { + checkBoxInput { + name = "showdiscord" + checked = currentUser.showDiscordName + value = "yes" + } + +Entities.nbsp + +"Show Discord name" + } + br + label { + checkBoxInput { + name = "showstatus" + checked = currentUser.showUserStatus + value = "yes" + } + +Entities.nbsp + +"Show Online Status" + } + br + label { + checkBoxInput { + name = "logaddress" + checked = currentUser.logIpAddresses + value = "yes" + } + +Entities.nbsp + +"Log Session IP Addresses" + } + p { + +"Your private info can be viewed at the " + a(href = "/me/private-info") { +"Private Info" } + +" page." + } + request.queryParameters["error"]?.let { errorMsg -> + p { + style = "color:#d22" + +errorMsg + } + } + submitInput { + value = "Accept Changes" + } + } + script { + unsafe { +"window.sfThemeChoice = true;" } + } + } + section { + h2 { +"Logged-In Sessions" } + table { + tr { + th { +"User-Agent" } + if (currentUser.logIpAddresses) + th { +"Client IPs" } + th { +Entities.nbsp } + } + val now = Instant.now() + val expiredSessions = mutableListOf() + for (session in allUserSessions) { + if (session.expiration < now) { + expiredSessions += session + continue + } + + tr { + td { +session.userAgent } + if (currentUser.logIpAddresses) + td { + for ((i, clientAddress) in session.clientAddresses.withIndex()) { + if (i != 0) br + +clientAddress + } + } + td { + if (session.id == currentSession.id) { + +"Current Session" + br + } + a(href = "/logout/${session.id}") { + method = "post" + csrfToken(currentSession.id) + +"Logout" + } + } + } + } + tr { + td { + colSpan = if (currentUser.logIpAddresses) "3" else "2" + a(href = "/logout-all") { + method = "post" + csrfToken(currentSession.id) + +"Logout All Other Sessions" + } + } + } + for (session in expiredSessions) { + tr { + td { +session.userAgent } + if (currentUser.logIpAddresses) + td { + for ((i, clientAddress) in session.clientAddresses.withIndex()) { + if (i != 0) br + +clientAddress + } + } + td { + +"Expired at " + span(classes = "moment") { + style = "display:none" + +session.expiration.toEpochMilli().toString() + } + br + a(href = "/clear-expired/${session.id}") { + method = "post" + csrfToken(currentSession.id) + +"Clear" + } + } + } + } + if (expiredSessions.isNotEmpty()) + tr { + td { + colSpan = if (currentUser.logIpAddresses) "3" else "2" + a(href = "/clear-all-expired") { + method = "post" + csrfToken(currentSession.id) + +"Clear All Expired Sessions" + } + } + } + } + } + } +} + +suspend fun ApplicationCall.createAdmiralPage(): HTML.() -> Unit { + val sessionId = getUserSession()?.id ?: redirect("/login") + + return page( + "Creating Admiral", standardNavBar(), null + ) { + section { + h1 { +"Creating Admiral" } + form(method = FormMethod.post, action = "/admiral/new") { + csrfToken(sessionId) + h3 { + label { + htmlFor = "faction" + +"Faction" + } + } + p { + for (faction in Faction.values()) { + val factionId = "faction-${faction.toUrlSlug()}" + label { + htmlFor = factionId + radioInput(name = "faction") { + id = factionId + value = faction.name + required = true + if (faction == Faction.FELINAE_FELICES) + attributes["data-force-gender"] = "female" + } + img(src = faction.flagUrl) { + style = "height:0.75em;width:1.2em" + } + +Entities.nbsp + +faction.shortName + } + br + } + } + h3 { + label { + htmlFor = "name" + +"Name" + } + } + textInput(name = "name") { + id = "name" + + autoComplete = false + required = true + maxLength = "$ADMIRAL_NAME_MAX_LENGTH" + } + p { + label { + htmlFor = "sex-male" + radioInput(name = "sex") { + id = "sex-male" + value = "male" + required = true + } + +"Male" + } + label { + htmlFor = "sex-female" + radioInput(name = "sex") { + id = "sex-female" + value = "female" + required = true + } + +"Female" + } + } + h3 { +"Generate Random Name" } + p { + for ((i, flavor) in AdmiralNameFlavor.values().withIndex()) { + if (i != 0) + br + a(href = "#", classes = "generate-admiral-name") { + attributes["data-flavor"] = flavor.toUrlSlug() + +flavor.displayName + } + } + } + submitInput { + value = "Create Admiral" + } + } + script { + unsafe { +"window.sfAdmiralNameGen = true; window.sfFactionSelect = true;" } + } + } + } +} + +suspend fun ApplicationCall.admiralPage(): HTML.() -> Unit { + val currentUser = getUserSession()?.user + val admiralId = parameters["id"]?.let { Id(it) }!! + val admiral = Admiral.get(admiralId)!! + val (ships, graveyard, records) = coroutineScope { + val ships = async { ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() } + val graveyard = async { ShipMemorial.filter(ShipMemorial::owningAdmiral eq admiralId).toList() } + val records = async { BattleRecord.filter(or(BattleRecord::hostAdmiral eq admiralId, BattleRecord::guestAdmiral eq admiralId)).toList() } + + Triple(ships.await(), graveyard.await(), records.await()) + } + + val recordRoles = records.mapNotNull { + when (admiralId) { + it.hostAdmiral -> GlobalSide.HOST + it.guestAdmiral -> GlobalSide.GUEST + else -> null + }?.let { role -> it.id to role } + }.toMap() + + val recordOpponents = coroutineScope { + records.mapNotNull { + recordRoles[it.id]?.let { role -> + val aId = when (role) { + GlobalSide.HOST -> it.guestAdmiral + GlobalSide.GUEST -> it.hostAdmiral + } + it.id to async { Admiral.get(aId) } + } + }.mapNotNull { (id, deferred) -> + deferred.await()?.let { id to it } + }.toMap() + } + + return page( + admiral.fullName, standardNavBar(), PageNavSidebar( + listOf( + NavLink("/user/${admiral.owningUser}", "Back to User") + ) + if (currentUser == admiral.owningUser) + listOf( + NavLink("/admiral/${admiral.id}/manage", "Manage Admiral") + ) + else emptyList() + ) + ) { + section { + h1 { +admiral.name } + p { + b { +admiral.fullName } + +" is a flag officer of the " + +admiral.faction.navyName + +". " + +(if (admiral.isFemale) "She" else "He") + +" controls the following ships:" + } + + table { + tr { + th { +"Ship Name" } + th { +"Ship Class" } + th { +"Ship Status" } + } + + val now = Instant.now() + for (ship in ships.sortedBy { it.name }.sortedBy { it.shipType.weightClass.tier }) { + tr { + td { +ship.shipData.fullName } + td { + a(href = "/info/${ship.shipData.shipType.toUrlSlug()}") { + +ship.shipData.shipType.fullDisplayName + } + } + td { + val shipReadyAt = ship.readyAt + if (shipReadyAt <= now) { + +"Ready" + br + +"(since " + span(classes = "moment") { + style = "display:none" + +shipReadyAt.toEpochMilli().toString() + } + +")" + } else { + +"Will be ready at " + span(classes = "moment") { + style = "display:none" + +shipReadyAt.toEpochMilli().toString() + } + } + } + } + } + } + h2 { +"Lost Ships' Memorial" } + p { + +"The following ships were lost under " + +(if (admiral.isFemale) "her" else "his") + +" command:" + } + table { + tr { + th { +"Ship Name" } + th { +"Ship Class" } + th { +Entities.nbsp } + } + + for (ship in graveyard.sortedBy { it.name }.sortedBy { it.shipType.weightClass.tier }) { + tr { + td { +ship.fullName } + td { + a(href = "/info/${ship.shipType.toUrlSlug()}") { + +ship.shipType.fullDisplayName + } + } + td { + +"Destroyed by " + val opponent = recordOpponents[ship.destroyedIn] + if (opponent == null) + i { +"(Deleted Admiral)" } + else + a(href = "/admiral/${opponent.id}") { + +opponent.fullName + } + br + br + +"Destroyed at " + span(classes = "moment") { + style = "display:none" + +ship.destroyedAt.toEpochMilli().toString() + } + } + } + } + } + } + section { + h2 { +"Valor" } + p { + +"This admiral has fought in the following battles:" + } + table { + tr { + th { +"When" } + th { +"Size" } + th { +"Role" } + th { +"Against" } + th { +"Result" } + } + for (record in records.sortedBy { it.whenEnded }) { + tr { + td { + +"Started at " + span(classes = "moment") { + style = "display:none" + +record.whenStarted.toEpochMilli().toString() + } + br + +"Ended at " + span(classes = "moment") { + style = "display:none" + +record.whenEnded.toEpochMilli().toString() + } + } + td { + +record.battleInfo.size.displayName + +" (" + +record.battleInfo.size.numPoints.toString() + +")" + } + td { + +when (recordRoles[record.id]) { + GlobalSide.HOST -> "Host" + GlobalSide.GUEST -> "Guest" + else -> "N/A" + } + } + td { + val opponent = recordOpponents[record.id] + if (opponent == null) + i { +"(Deleted Admiral)" } + else + a(href = "/admiral/${opponent.id}") { + +opponent.fullName + } + } + td { + +when (recordRoles[record.id]) { + GlobalSide.HOST -> record.hostEndingMessage + GlobalSide.GUEST -> record.guestEndingMessage + else -> "N/A" + } + } + } + } + } + } + } +} + +suspend fun ApplicationCall.manageAdmiralPage(): HTML.() -> Unit { + val currentSession = getUserSession() ?: redirect("/login") + val currentUser = currentSession.user + + val admiralId = parameters["id"]?.let { Id(it) }!! + val admiral = Admiral.get(admiralId)!! + + if (admiral.owningUser != currentUser) forbid() + + val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() + val buyableShips = ShipType.values() + .mapNotNull { type -> type.buyPriceChecked(admiral, ownedShips)?.let { price -> type to price } } + .sortedBy { (_, price) -> price } + .sortedBy { (type, _) -> type.name } + .sortedBy { (type, _) -> type.weightClass.tier } + .sortedBy { (type, _) -> if (type.faction == admiral.faction) -1 else type.faction.ordinal } + .toMap() + + return page( + "Managing ${admiral.name}", standardNavBar(), PageNavSidebar( + listOf( + NavLink("/admiral/${admiral.id}", "Back to Admiral") + ) + ) + ) { + section { + h1 { +"Managing ${admiral.name}" } + request.queryParameters["error"]?.let { errorMsg -> + p { + style = "color:#d22" + +errorMsg + } + } + form(method = FormMethod.post, action = "/admiral/${admiral.id}/manage") { + csrfToken(currentSession.id) + h3 { + label { + htmlFor = "name" + +"Name" + } + } + textInput(name = "name") { + id = "name" + autoComplete = false + + required = true + value = admiral.name + maxLength = "$ADMIRAL_NAME_MAX_LENGTH" + } + if (admiral.faction == Faction.FELINAE_FELICES) + p { + style = "font-size:0.8em;font-style:italic;color:#555" + checkBoxInput { + style = "display:none" + id = "sex-female" + checked = true + } + +"The Felinae Felices are a female-only faction." + } + else + p { + label { + htmlFor = "sex-male" + radioInput(name = "sex") { + id = "sex-male" + value = "male" + required = true + checked = !admiral.isFemale + } + +"Male" + } + label { + htmlFor = "sex-female" + radioInput(name = "sex") { + id = "sex-female" + value = "female" + required = true + checked = admiral.isFemale + } + +"Female" + } + } + h3 { +"Generate Random Name" } + p { + for ((i, flavor) in AdmiralNameFlavor.values().withIndex()) { + if (i != 0) + br + a(href = "#", classes = "generate-admiral-name") { + attributes["data-flavor"] = flavor.toUrlSlug() + +flavor.displayName + } + } + } + script { + unsafe { +"window.sfAdmiralNameGen = true;" } + } + submitInput { + value = "Submit Changes" + } + } + form(method = FormMethod.get, action = "/admiral/${admiral.id}/delete") { + submitInput(classes = "evil") { + value = "Delete this Admiral" + } + } + } + section { + val currRank = admiral.rank + if (currRank.ordinal < AdmiralRank.values().size - 1) { + val nextRank = AdmiralRank.values()[currRank.ordinal + 1] + val reqAcumen = nextRank.minAcumen - currRank.minAcumen + val hasAcumen = admiral.acumen - currRank.minAcumen + + label { + h2 { +"Progress to Promotion" } + progress { + style = "width:100%;box-sizing:border-box" + max = "$reqAcumen" + value = "$hasAcumen" + +"$hasAcumen/$reqAcumen" + } + } + p { + +"${admiral.fullName} is $hasAcumen/$reqAcumen Acumen away from being promoted to ${nextRank.getDisplayName(admiral.faction)}." + } + } else { + h2 { +"Progress to Promotion" } + p { + +"${admiral.fullName} is at the maximum rank possible for the ${admiral.faction.navyName}." + } + } + } + section { + h2 { +"Manage Fleet" } + p { + +"${admiral.fullName} currently owns ${admiral.money} ${admiral.faction.currencyName}, and earns ${admiral.rank.dailyWage} ${admiral.faction.currencyName} every day." + } + table { + tr { + th { +"Ship Name" } + th { +"Ship Class" } + th { +"Ship Status" } + th { +"Ship Value" } + } + + val now = Instant.now() + for (ship in ownedShips.sortedBy { it.name }.sortedBy { it.shipType.weightClass.tier }) { + tr { + td { + +ship.shipData.fullName + br + a(href = "/admiral/${admiralId}/rename/${ship.id}") { +"Rename" } + } + td { + a(href = "/info/${ship.shipData.shipType.toUrlSlug()}") { + +ship.shipData.shipType.fullDisplayName + } + } + td { + val shipReadyAt = ship.readyAt + if (shipReadyAt <= now) { + +"Ready" + br + +"(since " + span(classes = "moment") { + style = "display:none" + +shipReadyAt.toEpochMilli().toString() + } + +")" + } else { + +"Will be ready at " + span(classes = "moment") { + style = "display:none" + +shipReadyAt.toEpochMilli().toString() + } + } + } + td { + +ship.shipType.sellPrice.toString() + +" " + +admiral.faction.currencyName + if (ship.readyAt <= now && !ship.shipType.weightClass.isUnique) { + br + a(href = "/admiral/${admiralId}/sell/${ship.id}") { +"Sell" } + } + } + } + } + } + h3 { +"Buy New Ship" } + table { + tr { + th { +"Ship Class" } + th { +"Ship Cost" } + } + for ((st, price) in buyableShips) { + tr { + td { + a(href = "/info/${st.toUrlSlug()}") { +st.fullDisplayName } + } + td { + +price.toString() + +" " + +admiral.faction.currencyName + br + a(href = "/admiral/${admiralId}/buy/${st.toUrlSlug()}") { +"Buy" } + } + } + } + } + } + } +} + +suspend fun ApplicationCall.renameShipPage(): HTML.() -> Unit { + val currentSession = getUserSession() ?: redirect("/login") + val currentUser = currentSession.user + + val admiralId = parameters["id"]?.let { Id(it) }!! + val shipId = parameters["ship"]?.let { Id(it) }!! + + val (admiral, ship) = coroutineScope { + val admiral = async { Admiral.get(admiralId)!! } + val ship = async { ShipInDrydock.get(shipId)!! } + admiral.await() to ship.await() + } + + if (admiral.owningUser != currentUser) forbid() + if (ship.owningAdmiral != admiralId) forbid() + + return page("Renaming Ship", null, null) { + section { + h1 { +"Renaming Ship" } + p { + +"${admiral.fullName} is about to rename the ${ship.shipType.fullDisplayName} ${ship.shipData.fullName}. Choose a name here:" + } + form(method = FormMethod.post, action = "/admiral/${admiral.id}/rename/${ship.id}") { + csrfToken(currentSession.id) + textInput(name = "name") { + id = "name" + value = ship.name + + autoComplete = false + required = true + + maxLength = "$SHIP_NAME_MAX_LENGTH" + } + p { + style = "font-style:italic;font-size:0.8em;color:#555" + +"Max length $SHIP_NAME_MAX_LENGTH characters" + } + submitInput { + value = "Rename" + } + } + form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { + submitInput { + value = "Cancel" + } + } + } + } +} + +suspend fun ApplicationCall.sellShipConfirmPage(): HTML.() -> Unit { + val currentSession = getUserSession() ?: redirect("/login") + val currentUser = currentSession.user + + val admiralId = parameters["id"]?.let { Id(it) }!! + val shipId = parameters["ship"]?.let { Id(it) }!! + + val (admiral, ship) = coroutineScope { + val admiral = async { Admiral.get(admiralId)!! } + val ship = async { ShipInDrydock.get(shipId)!! } + admiral.await() to ship.await() + } + + if (admiral.owningUser != currentUser) forbid() + if (ship.owningAdmiral != admiralId) forbid() + + if (ship.readyAt > Instant.now()) redirect("/admiral/${admiralId}/manage") + if (ship.shipType.weightClass.isUnique) redirect("/admiral/${admiralId}/manage") + + return page( + "Are You Sure?", null, null + ) { + section { + h1 { +"Are You Sure?" } + p { + +"${admiral.fullName} is about to sell the ${ship.shipType.fullDisplayName} ${ship.shipData.fullName} for ${ship.shipType.sellPrice} ${admiral.faction.currencyName}." + } + form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { + submitInput { + value = "Cancel" + } + } + form(method = FormMethod.post, action = "/admiral/${admiral.id}/sell/${ship.id}") { + csrfToken(currentSession.id) + submitInput { + value = "Sell" + } + } + } + } +} + +suspend fun ApplicationCall.buyShipConfirmPage(): HTML.() -> Unit { + val currentSession = getUserSession() ?: redirect("/login") + val currentUser = currentSession.user + + val admiralId = parameters["id"]?.let { Id(it) }!! + val admiral = Admiral.get(admiralId)!! + + if (admiral.owningUser != currentUser) forbid() + + val shipType = parameters["ship"]?.let { param -> ShipType.values().singleOrNull { it.toUrlSlug() == param } }!! + + if (shipType.buyPrice > admiral.money) { + return page( + "Too Expensive", null, null + ) { + section { + h1 { +"Too Expensive" } + p { + +"Unfortunately, the ${shipType.fullDisplayName} is out of ${admiral.fullName}'s budget. It costs ${shipType.buyPrice} ${admiral.faction.currencyName}, and ${admiral.name} only has ${admiral.money} ${admiral.faction.currencyName}." + } + form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { + submitInput { + value = "Back" + } + } + } + } + } + + val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() + if (shipType.buyPriceChecked(admiral, ownedShips) == null) + throw NotFoundException() + + return page( + "Are You Sure?", null, null + ) { + section { + h1 { +"Are You Sure?" } + p { + +"${admiral.fullName} is about to buy a ${shipType.fullDisplayName} for ${shipType.buyPrice} ${admiral.faction.currencyName}." + } + form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { + submitInput { + value = "Cancel" + } + } + form(method = FormMethod.post, action = "/admiral/${admiral.id}/buy/${shipType.toUrlSlug()}") { + csrfToken(currentSession.id) + submitInput { + value = "Checkout" + } + } + } + } +} + +suspend fun ApplicationCall.deleteAdmiralConfirmPage(): HTML.() -> Unit { + val currentSession = getUserSession() ?: redirect("/login") + val currentUser = currentSession.user + + val admiralId = parameters["id"]?.let { Id(it) }!! + val admiral = Admiral.get(admiralId)!! + + if (admiral.owningUser != currentUser) forbid() + + return page( + "Are You Sure?", null, null + ) { + section { + h1 { +"Are You Sure?" } + p { + +"Are you sure you want to delete " + +admiral.fullName + +"? Deletion cannot be undone!" + } + form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { + submitInput { + value = "No" + } + } + form(method = FormMethod.post, action = "/admiral/${admiral.id}/delete") { + csrfToken(currentSession.id) + submitInput(classes = "evil") { + value = "Yes" + } + } + } + } +} diff --git a/src/jvmMain/kotlin/net/starshipfights/server.kt b/src/jvmMain/kotlin/net/starshipfights/server.kt new file mode 100644 index 0000000..0980ad3 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/server.kt @@ -0,0 +1,170 @@ +@file:JvmName("Server") + +package net.starshipfights + +import io.ktor.application.* +import io.ktor.features.* +import io.ktor.html.* +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.request.* +import io.ktor.response.* +import io.ktor.routing.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.util.* +import io.ktor.websocket.* +import net.starshipfights.auth.AuthProvider +import net.starshipfights.data.ConnectionHolder +import net.starshipfights.data.DataRoutines +import net.starshipfights.game.installGame +import net.starshipfights.info.* +import org.slf4j.event.Level +import java.io.InputStream +import java.util.concurrent.atomic.AtomicLong + +object ResourceLoader { + fun getResource(resource: String): InputStream? = javaClass.getResourceAsStream(resource) + + val SHA256AttributeKey = AttributeKey("SHA256Hash") +} + +fun main() { + System.setProperty("logback.statusListenerClass", "ch.qos.logback.core.status.NopStatusListener") + + System.setProperty("io.ktor.development", if (CurrentConfiguration.isDevEnv) "true" else "false") + + ConnectionHolder.initialize(CurrentConfiguration.dbConn, CurrentConfiguration.dbName) + + val dataRoutines = DataRoutines.initializeRoutines() + + embeddedServer(Netty, port = CurrentConfiguration.port, host = CurrentConfiguration.host) { + install(IgnoreTrailingSlash) + install(XForwardedHeaderSupport) + + install(CallId) { + val counter = AtomicLong(0) + generate { + "call-${counter.incrementAndGet().toULong()}-${System.currentTimeMillis()}" + } + } + + install(CallLogging) { + level = Level.INFO + + callIdMdc("ktor-call-id") + + format { call -> + "Call #${call.callId} Client ${call.request.origin.remoteHost} `${call.request.userAgent()}` Request ${call.request.httpMethod.value} ${call.request.uri} Response ${call.response.status()}" + } + } + + install(ConditionalHeaders) { + version { outgoingContent -> + outgoingContent.getProperty(ResourceLoader.SHA256AttributeKey)?.let { hash -> + listOf(EntityTagVersion(hash)) + }.orEmpty() + } + } + + install(StatusPages) { + status(HttpStatusCode.NotFound) { + call.respondHtml(HttpStatusCode.NotFound, call.error404()) + } + + exception { (url, permanent) -> + call.respondRedirect(url, permanent) + } + exception { + call.respondHtml(HttpStatusCode.BadRequest, call.error400()) + } + exception { + call.respondHtml(HttpStatusCode.Forbidden, call.error403()) + } + exception { + call.respondHtml(HttpStatusCode.Forbidden, call.error403InvalidCsrf()) + } + exception { + call.respondHtml(HttpStatusCode.NotFound, call.error404()) + } + exception { + call.respondHtml(HttpStatusCode.TooManyRequests, call.error429()) + } + + exception { + call.respondHtml(HttpStatusCode.InternalServerError, call.error503()) + throw it + } + } + + install(WebSockets) { + pingPeriodMillis = 500L + } + + if (CurrentConfiguration.isDevEnv) { + install(ShutDownUrl.ApplicationCallFeature) { + shutDownUrl = "/dev/shutdown" + exitCodeSupplier = { 0 } + } + } + + AuthProvider.install(this) + + routing { + installPages() + installGame() + + static("/static") { + // I HAVE TO DO THIS MANUALLY + // BECAUSE KTOR DOESN'T SUPPORT + // PRE-COMPRESSED STATIC JAR RESOURCES + // FOR SOME UNGODLY REASON + get("{static-content...}") { + val staticContentPath = call.parameters.getAll("static-content")?.joinToString("/") ?: return@get + val contentPath = "/static/$staticContentPath" + + val hashContentPath = "$contentPath.sha256" + val sha256Hash = ResourceLoader.getResource(hashContentPath)?.reader()?.readText() + val configureContent: OutgoingContent.() -> Unit = { setProperty(ResourceLoader.SHA256AttributeKey, sha256Hash) } + + val brContentPath = "$contentPath.br" + val gzContentPath = "$contentPath.gz" + + val contentType = ContentType.fromFileExtension(contentPath.substringAfterLast('.')).firstOrNull() + + val acceptedEncodings = call.request.acceptEncodingItems().map { it.value }.toSet() + + if (CompressedFileType.BROTLI.encoding in acceptedEncodings) { + val brContent = ResourceLoader.getResource(brContentPath) + if (brContent != null) { + call.attributes.put(Compression.SuppressionAttribute, true) + + call.response.header(HttpHeaders.ContentEncoding, CompressedFileType.BROTLI.encoding) + + call.respondBytes(brContent.readBytes(), contentType, configure = configureContent) + + return@get + } + } + + if (CompressedFileType.GZIP.encoding in acceptedEncodings) { + val gzContent = ResourceLoader.getResource(gzContentPath) + if (gzContent != null) { + call.attributes.put(Compression.SuppressionAttribute, true) + + call.response.header(HttpHeaders.ContentEncoding, CompressedFileType.GZIP.encoding) + + call.respondBytes(gzContent.readBytes(), contentType, configure = configureContent) + + return@get + } + } + + ResourceLoader.getResource(contentPath)?.let { call.respondBytes(it.readBytes(), contentType, configure = configureContent) } + } + } + } + }.start(wait = true) + + dataRoutines.cancel() +} diff --git a/src/jvmMain/kotlin/net/starshipfights/server_conf.kt b/src/jvmMain/kotlin/net/starshipfights/server_conf.kt new file mode 100644 index 0000000..64166e2 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/server_conf.kt @@ -0,0 +1,66 @@ +package net.starshipfights + +import io.ktor.util.* +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import net.starshipfights.data.ConnectionType +import java.io.File +import java.security.SecureRandom + +@Serializable +data class Configuration( + val isDevEnv: Boolean = true, + + val host: String = "127.0.0.1", + val port: Int = 8080, + + val dbConn: ConnectionType = ConnectionType.Embedded(), + val dbName: String = "sf", + + val secretHashingKey: String = hex( + ByteArray(16).also { SecureRandom.getInstanceStrong().nextBytes(it) } + ), + val discordClient: DiscordLogin? = null +) + +@Serializable +data class DiscordLogin( + val userAgent: String, + + val clientId: String, + val clientSecret: String, + + val ownerId: String, + val serverInvite: String, +) + +private val DEFAULT_CONFIG = Configuration() + +private var currentConfig: Configuration? = null + +val CurrentConfiguration: Configuration + get() { + currentConfig?.let { return it } + + val file = File(System.getProperty("starshipfights.configpath", "./config.json")) + if (!file.isFile) { + if (file.exists()) + file.deleteRecursively() + + val json = JsonConfigCodec.encodeToString(Configuration.serializer(), DEFAULT_CONFIG) + file.writeText(json, Charsets.UTF_8) + return DEFAULT_CONFIG + } + + val json = file.readText() + return JsonConfigCodec.decodeFromString(Configuration.serializer(), json).also { currentConfig = it } + } + +@OptIn(ExperimentalSerializationApi::class) +val JsonConfigCodec = Json { + prettyPrint = true + prettyPrintIndent = "\t" + + useAlternativeNames = false +} diff --git a/src/jvmMain/kotlin/net/starshipfights/server_utils.kt b/src/jvmMain/kotlin/net/starshipfights/server_utils.kt new file mode 100644 index 0000000..dc039c0 --- /dev/null +++ b/src/jvmMain/kotlin/net/starshipfights/server_utils.kt @@ -0,0 +1,17 @@ +package net.starshipfights + +open class ForbiddenException : IllegalArgumentException() + +fun forbid(): Nothing = throw ForbiddenException() + +class InvalidCsrfTokenException : ForbiddenException() + +fun invalidCsrfToken(): Nothing = throw InvalidCsrfTokenException() + +data class HttpRedirectException(val url: String, val permanent: Boolean) : RuntimeException() + +fun redirect(url: String, permanent: Boolean = false): Nothing = throw HttpRedirectException(url, permanent) + +class RateLimitException : RuntimeException() + +fun rateLimit(): Nothing = throw RateLimitException() diff --git a/src/jvmMain/kotlin/starshipfights/auth/providers.kt b/src/jvmMain/kotlin/starshipfights/auth/providers.kt deleted file mode 100644 index f776aaf..0000000 --- a/src/jvmMain/kotlin/starshipfights/auth/providers.kt +++ /dev/null @@ -1,637 +0,0 @@ -package starshipfights.auth - -import io.ktor.application.* -import io.ktor.auth.* -import io.ktor.client.* -import io.ktor.client.engine.apache.* -import io.ktor.client.features.* -import io.ktor.client.request.* -import io.ktor.features.* -import io.ktor.html.* -import io.ktor.http.* -import io.ktor.request.* -import io.ktor.response.* -import io.ktor.routing.* -import io.ktor.sessions.* -import io.ktor.util.* -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.html.* -import kotlinx.serialization.Serializable -import org.litote.kmongo.* -import starshipfights.CurrentConfiguration -import starshipfights.DiscordLogin -import starshipfights.data.Id -import starshipfights.data.admiralty.* -import starshipfights.data.auth.PreferredTheme -import starshipfights.data.auth.User -import starshipfights.data.auth.UserSession -import starshipfights.data.createNonce -import starshipfights.forbid -import starshipfights.game.Faction -import starshipfights.game.ShipType -import starshipfights.game.toUrlSlug -import starshipfights.info.* -import starshipfights.redirect -import java.time.Instant -import java.time.temporal.ChronoUnit - -const val PROFILE_NAME_MAX_LENGTH = 32 -const val PROFILE_BIO_MAX_LENGTH = 240 -const val ADMIRAL_NAME_MAX_LENGTH = 48 -const val SHIP_NAME_MAX_LENGTH = 48 - -interface AuthProvider { - fun installApplication(app: Application) = Unit - fun installAuth(conf: Authentication.Configuration) - fun installRouting(conf: Routing) - - companion object Installer { - private val newCurrentProvider: AuthProvider - get() = CurrentConfiguration.discordClient?.let { ProductionAuthProvider(it) } ?: TestAuthProvider - - private var cachedCurrentProvider: AuthProvider? = null - - val currentProvider: AuthProvider - get() = cachedCurrentProvider ?: newCurrentProvider.also { cachedCurrentProvider = it } - - fun install(into: Application) { - currentProvider.installApplication(into) - - into.install(Sessions) { - cookie>("sf_user_session") { - serializer = UserSessionIdSerializer - transform(SessionTransportTransformerMessageAuthentication(hex(CurrentConfiguration.secretHashingKey))) - - cookie.path = "/" - cookie.extensions["Secure"] = null - cookie.extensions["SameSite"] = "Lax" - } - } - - into.install(Authentication) { - session>("session") { - validate { id -> - val userAgent = request.userAgent() ?: return@validate null - id.resolve(userAgent)?.let { sess -> - User.get(sess.user)?.let { user -> - sess.renewed(request.origin.remoteHost, user) - } - } - } - challenge("/login") - } - - currentProvider.installAuth(this) - } - - into.routing { - get("/me") { - val redirectTo = call.getUserSession()?.let { sess -> - "/user/${sess.user}" - } ?: "/login" - - redirect(redirectTo) - } - - get("/me/manage") { - call.respondHtml(HttpStatusCode.OK, call.manageUserPage()) - } - - get("/me/private-info") { - call.respondHtml(HttpStatusCode.OK, call.privateInfoPage()) - } - - get("/me/private-info/txt") { - call.respondText(ContentType.Text.Plain, HttpStatusCode.OK) { call.privateInfo() } - } - - post("/me/manage") { - val form = call.receiveValidatedParameters() - val currentUser = call.getUser() ?: redirect("/login") - - val newUser = currentUser.copy( - showDiscordName = form["showdiscord"] == "yes", - showUserStatus = form["showstatus"] == "yes", - logIpAddresses = form["logaddress"] == "yes", - profileName = form["name"]?.takeIf { it.isNotBlank() && it.length <= PROFILE_NAME_MAX_LENGTH } ?: redirect("/me/manage" + withErrorMessage("Invalid name - must not be blank, must be at most $PROFILE_NAME_MAX_LENGTH characters")), - profileBio = form["bio"]?.takeIf { it.isNotBlank() && it.length <= PROFILE_BIO_MAX_LENGTH } ?: redirect("/me/manage" + withErrorMessage("Invalid bio - must not be blank, must be at most $PROFILE_BIO_MAX_LENGTH characters")), - preferredTheme = form["theme"]?.uppercase()?.takeIf { it in PreferredTheme.values().map { it.name } }?.let { PreferredTheme.valueOf(it) } ?: currentUser.preferredTheme - ) - User.put(newUser) - - if (!newUser.logIpAddresses) - launch { - UserSession.update( - UserSession::user eq currentUser.id, - setValue(UserSession::clientAddresses, emptyList()) - ) - } - - redirect("/user/${newUser.id}") - } - - get("/user/{id}") { - call.respondHtml(HttpStatusCode.OK, call.userPage()) - } - - get("/admiral/new") { - call.respondHtml(HttpStatusCode.OK, call.createAdmiralPage()) - } - - post("/admiral/new") { - val form = call.receiveValidatedParameters() - val currentUser = call.getUserSession()?.user ?: redirect("/login") - - val faction = Faction.valueOf(form.getOrFail("faction")) - val newAdmiral = Admiral( - owningUser = currentUser, - name = form["name"]?.takeIf { it.isNotBlank() && it.length <= ADMIRAL_NAME_MAX_LENGTH } ?: redirect("/me/manage" + withErrorMessage("That name is not valid - must not be blank, must not be longer than $ADMIRAL_NAME_MAX_LENGTH characters")), - isFemale = form.getOrFail("sex") == "female" || faction == Faction.FELINAE_FELICES, - faction = faction, - acumen = if (CurrentConfiguration.isDevEnv) 20_000 else 0, - money = 500 - ) - val newShips = generateFleet(newAdmiral) - - coroutineScope { - launch { Admiral.put(newAdmiral) } - launch { ShipInDrydock.put(newShips) } - } - - redirect("/admiral/${newAdmiral.id}") - } - - get("/admiral/{id}") { - call.respondHtml(HttpStatusCode.OK, call.admiralPage()) - } - - get("/admiral/{id}/manage") { - call.respondHtml(HttpStatusCode.OK, call.manageAdmiralPage()) - } - - post("/admiral/{id}/manage") { - val form = call.receiveValidatedParameters() - - val currentUser = call.getUserSession()?.user - val admiralId = call.parameters["id"]?.let { Id(it) }!! - val admiral = Admiral.get(admiralId)!! - - if (admiral.owningUser != currentUser) forbid() - - val newAdmiral = admiral.copy( - name = form["name"]?.takeIf { it.isNotBlank() && it.length <= ADMIRAL_NAME_MAX_LENGTH } ?: redirect("/me/manage" + withErrorMessage("That name is not valid - must not be blank, must not be longer than $ADMIRAL_NAME_MAX_LENGTH characters")), - isFemale = form["sex"] == "female" || admiral.faction == Faction.FELINAE_FELICES - ) - - Admiral.put(newAdmiral) - redirect("/admiral/$admiralId") - } - - get("/admiral/{id}/rename/{ship}") { - call.respondHtml(HttpStatusCode.OK, call.renameShipPage()) - } - - post("/admiral/{id}/rename/{ship}") { - val formParams = call.receiveValidatedParameters() - val currentUser = call.getUserSession()?.user - - val admiralId = call.parameters["id"]?.let { Id(it) }!! - val shipId = call.parameters["ship"]?.let { Id(it) }!! - - val (admiral, ship) = coroutineScope { - val admiral = async { Admiral.get(admiralId)!! } - val ship = async { ShipInDrydock.get(shipId)!! } - admiral.await() to ship.await() - } - - if (admiral.owningUser != currentUser) forbid() - if (ship.owningAdmiral != admiralId) forbid() - - val newName = formParams["name"]?.takeIf { it.isNotBlank() && it.length <= SHIP_NAME_MAX_LENGTH } ?: redirect("/admiral/${admiralId}/manage" + withErrorMessage("That name is not valid - must not be blank, must not be longer than $SHIP_NAME_MAX_LENGTH characters")) - ShipInDrydock.set(shipId, setValue(ShipInDrydock::name, newName)) - - redirect("/admiral/${admiralId}/manage") - } - - get("/admiral/{id}/sell/{ship}") { - call.respondHtml(HttpStatusCode.OK, call.sellShipConfirmPage()) - } - - post("/admiral/{id}/sell/{ship}") { - call.receiveValidatedParameters() - - val currentUser = call.getUserSession()?.user - - val admiralId = call.parameters["id"]?.let { Id(it) }!! - val shipId = call.parameters["ship"]?.let { Id(it) }!! - - val (admiral, ship) = coroutineScope { - val admiral = async { Admiral.get(admiralId)!! } - val ship = async { ShipInDrydock.get(shipId)!! } - admiral.await() to ship.await() - } - - if (admiral.owningUser != currentUser) forbid() - if (ship.owningAdmiral != admiralId) forbid() - - if (ship.readyAt > Instant.now()) redirect("/admiral/${admiralId}/manage" + withErrorMessage("Cannot sell ships that are not ready for battle")) - if (ship.shipType.weightClass.isUnique) redirect("/admiral/${admiralId}/manage" + withErrorMessage("Cannot sell a ${ship.shipType.fullDisplayName}")) - - coroutineScope { - launch { ShipInDrydock.del(shipId) } - launch { Admiral.set(admiralId, inc(Admiral::money, ship.shipType.sellPrice)) } - } - - redirect("/admiral/${admiralId}/manage") - } - - get("/admiral/{id}/buy/{ship}") { - call.respondHtml(HttpStatusCode.OK, call.buyShipConfirmPage()) - } - - post("/admiral/{id}/buy/{ship}") { - call.receiveValidatedParameters() - - val currentUser = call.getUserSession()?.user - val admiralId = call.parameters["id"]?.let { Id(it) }!! - val admiral = Admiral.get(admiralId)!! - - if (admiral.owningUser != currentUser) forbid() - val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() - - val shipType = call.parameters["ship"]?.let { param -> ShipType.values().singleOrNull { it.toUrlSlug() == param } }!! - val shipPrice = shipType.buyPrice(admiral, ownedShips) ?: throw NotFoundException() - - if (shipPrice > admiral.money) - redirect("/admiral/${admiralId}/manage" + withErrorMessage("You cannot afford that ship")) - - val shipNames = ownedShips.map { it.name }.toMutableSet() - val newShipName = newShipName(shipType.faction, shipType.weightClass, shipNames) ?: nameShip(shipType.faction, shipType.weightClass) - - val newShip = ShipInDrydock( - name = newShipName, - shipType = shipType, - readyAt = Instant.now().plus(2, ChronoUnit.HOURS), - owningAdmiral = admiralId - ) - - coroutineScope { - launch { ShipInDrydock.put(newShip) } - launch { Admiral.set(admiralId, inc(Admiral::money, -shipPrice)) } - } - - redirect("/admiral/${admiralId}/manage") - } - - get("/admiral/{id}/delete") { - call.respondHtml(HttpStatusCode.OK, call.deleteAdmiralConfirmPage()) - } - - post("/admiral/{id}/delete") { - call.receiveValidatedParameters() - - val currentUser = call.getUserSession()?.user - val admiralId = call.parameters["id"]?.let { Id(it) }!! - val admiral = Admiral.get(admiralId)!! - - if (admiral.owningUser != currentUser) forbid() - - coroutineScope { - launch { Admiral.del(admiralId) } - launch { ShipInDrydock.remove(ShipInDrydock::owningAdmiral eq admiralId) } - } - - redirect("/me") - } - - post("/logout") { - call.receiveValidatedParameters() - - call.getUserSession()?.let { sess -> - launch { - val newTime = Instant.now().minusMillis(100) - UserSession.update(UserSession::id eq sess.id, setValue(UserSession::expiration, newTime)) - } - } - - call.sessions.clear>() - redirect("/") - } - - post("/logout/{id}") { - call.receiveValidatedParameters() - - val id = Id(call.parameters.getOrFail("id")) - call.getUserSession()?.let { sess -> - launch { - val newTime = Instant.now().minusMillis(100) - UserSession.update(and(UserSession::id eq id, UserSession::user eq sess.user), setValue(UserSession::expiration, newTime)) - } - } - - redirect("/me/manage") - } - - post("/logout-all") { - call.receiveValidatedParameters() - - call.getUserSession()?.let { sess -> - launch { - val newTime = Instant.now().minusMillis(100) - UserSession.update(and(UserSession::user eq sess.user, UserSession::id ne sess.id), setValue(UserSession::expiration, newTime)) - } - } - - redirect("/me/manage") - } - - post("/clear-expired/{id}") { - call.receiveValidatedParameters() - - val id = Id(call.parameters.getOrFail("id")) - call.getUserSession()?.let { sess -> - launch { - val now = Instant.now() - UserSession.remove(and(UserSession::id eq id, UserSession::user eq sess.user, UserSession::expiration lte now)) - } - } - - redirect("/me/manage") - } - - post("/clear-all-expired") { - call.receiveValidatedParameters() - - call.getUserSession()?.let { sess -> - launch { - val now = Instant.now() - UserSession.remove(and(UserSession::user eq sess.user, UserSession::expiration lte now)) - } - } - - redirect("/me/manage") - } - - currentProvider.installRouting(this) - } - } - } -} - -object TestAuthProvider : AuthProvider { - private const val USERNAME_KEY = "username" - private const val PASSWORD_KEY = "password" - - private const val PASSWORD_VALUE = "very secure" - - override fun installApplication(app: Application) { - app.install(DoubleReceive) - } - - override fun installAuth(conf: Authentication.Configuration) { - with(conf) { - form("test-auth") { - userParamName = USERNAME_KEY - passwordParamName = PASSWORD_KEY - validate { credentials -> - val originAddress = request.origin.remoteHost - val userAgent = request.userAgent() - if (userAgent != null && credentials.name.isNotBlank() && credentials.password == PASSWORD_VALUE) { - val user = User.locate(User::discordId eq credentials.name) - ?: User( - discordId = credentials.name, - discordName = "", - discordDiscriminator = "", - discordAvatar = null, - showDiscordName = false, - profileName = credentials.name, - profileBio = "BEEP BOOP I EXIST ONLY FOR TESTING BLOP BLARP.", - registeredAt = Instant.now(), - lastActivity = Instant.now(), - showUserStatus = false, - logIpAddresses = false, - ).also { - User.put(it) - } - - UserSession( - user = user.id, - clientAddresses = listOf(originAddress), - userAgent = userAgent, - expiration = newExpiration() - ).also { - UserSession.put(it) - } - } else - null - } - challenge { credentials -> - val errorMsg = if (call.request.userAgent() == null) - "User-Agent must be specified when logging in. Are you using some weird API client?" - else if (credentials == null || credentials.name.isBlank()) - "A username must be provided." - else if (credentials.password != PASSWORD_VALUE) - "Password is incorrect." - else - "An unknown error occurred." - - val redirectUrl = "/login" + withErrorMessage(errorMsg) - call.respondRedirect(redirectUrl) - } - } - } - } - - override fun installRouting(conf: Routing) { - with(conf) { - get("/login") { - if (call.getUserSession() != null) - redirect("/me") - - call.respondHtml(HttpStatusCode.OK, call.page("Authentication Test", call.standardNavBar(), CustomSidebar { - p { - +"This instance does not have Discord OAuth login set up. As a fallback, this authentication mode is used for testing only." - } - }) { - section { - h1 { +"Authentication Test" } - form(action = "/login", method = FormMethod.post) { - h3 { - label { - this.htmlFor = USERNAME_KEY - +"Username" - } - } - textInput { - id = USERNAME_KEY - name = USERNAME_KEY - autoComplete = false - - required = true - } - call.request.queryParameters["error"]?.let { errorMsg -> - p { - style = "color:#d22" - +errorMsg - } - } - submitInput { - value = "Authenticate" - } - hiddenInput { - name = PASSWORD_KEY - value = PASSWORD_VALUE - } - } - } - }) - } - - authenticate("test-auth") { - post("/login") { - call.principal()?.id?.let { sessionId -> - call.sessions.set(sessionId) - } - redirect("/me") - } - } - } - } -} - -class ProductionAuthProvider(private val discordLogin: DiscordLogin) : AuthProvider { - private val httpClient = HttpClient(Apache) { - install(UserAgent) { - agent = discordLogin.userAgent - } - - install(RateLimit) { - jsonCodec = JsonClientCodec - } - } - - override fun installAuth(conf: Authentication.Configuration) { - conf.oauth("auth-oauth-discord") { - urlProvider = { "https://starshipfights.net/login/discord/callback" } - providerLookup = { - OAuthServerSettings.OAuth2ServerSettings( - name = "discord", - authorizeUrl = "https://discord.com/api/oauth2/authorize", - accessTokenUrl = "https://discord.com/api/oauth2/token", - requestMethod = HttpMethod.Post, - clientId = discordLogin.clientId, - clientSecret = discordLogin.clientSecret, - defaultScopes = listOf("identify"), - nonceManager = StateParameterManager - ) - } - client = httpClient - } - } - - override fun installRouting(conf: Routing) { - with(conf) { - get("/login") { - call.respondHtml(HttpStatusCode.OK, call.page("Login with Discord", call.standardNavBar()) { - section { - p { - style = "text-align:center" - +"By logging in, you indicate your agreement to the " - a(href = "/about/tnc") { +"Terms and Conditions" } - +" and the " - a(href = "/about/pp") { +"Privacy Policy" } - +"." - } - call.request.queryParameters["error"]?.let { errorMsg -> - p { - style = "color:#d22" - +errorMsg - } - } - p { - style = "text-align:center" - a(href = "/login/discord") { +"Continue to Discord" } - } - } - }) - } - - authenticate("auth-oauth-discord") { - get("/login/discord") { - // Redirects to 'authorizeUrl' automatically - } - - get("/login/discord/callback") { - val userAgent = call.request.userAgent() ?: forbid() - val principal: OAuthAccessTokenResponse.OAuth2 = call.principal() ?: redirect("/login") - val userInfoJson = httpClient.get("https://discord.com/api/users/@me") { - headers { - append(HttpHeaders.Authorization, "Bearer ${principal.accessToken}") - } - } - - val userInfo = JsonClientCodec.decodeFromString(DiscordUserInfo.serializer(), userInfoJson) - val (discordId, discordUsername, discordDiscriminator, discordAvatar) = userInfo - - var redirectTo = "/me" - - val user = User.locate(User::discordId eq discordId)?.copy( - discordName = discordUsername, - discordDiscriminator = discordDiscriminator, - discordAvatar = discordAvatar - ) ?: User( - discordId = discordId, - discordName = discordUsername, - discordDiscriminator = discordDiscriminator, - discordAvatar = discordAvatar, - showDiscordName = false, - profileName = discordUsername, - profileBio = "Hi, I'm new here!", - registeredAt = Instant.now(), - lastActivity = Instant.now(), - showUserStatus = false, - logIpAddresses = false, - ).also { redirectTo = "/me/manage" } - - val userSession = UserSession( - user = user.id, - clientAddresses = if (user.logIpAddresses) listOf(call.request.origin.remoteHost) else emptyList(), - userAgent = userAgent, - expiration = newExpiration() - ) - - coroutineScope { - launch { User.put(user) } - launch { UserSession.put(userSession) } - } - - call.sessions.set(userSession.id) - redirect(redirectTo) - } - } - } - } -} - -object StateParameterManager : NonceManager { - private val nonces = mutableSetOf() - - override suspend fun newNonce(): String { - return createNonce().also { nonces += it } - } - - override suspend fun verifyNonce(nonce: String): Boolean { - return nonces.remove(nonce) - } -} - -@Serializable -data class DiscordUserInfo( - val id: String, - val username: String, - val discriminator: String, - val avatar: String -) diff --git a/src/jvmMain/kotlin/starshipfights/auth/ratelimit.kt b/src/jvmMain/kotlin/starshipfights/auth/ratelimit.kt deleted file mode 100644 index e339598..0000000 --- a/src/jvmMain/kotlin/starshipfights/auth/ratelimit.kt +++ /dev/null @@ -1,71 +0,0 @@ -package starshipfights.auth - -import io.ktor.client.* -import io.ktor.client.call.* -import io.ktor.client.features.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import io.ktor.http.* -import io.ktor.util.* -import kotlinx.coroutines.delay -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import starshipfights.rateLimit -import kotlin.math.roundToLong - -class RateLimit( - val jsonCodec: Json, - val remainingHeader: String, - val resetAfterHeader: String, -) { - class Config { - var jsonCodec: Json = Json.Default - var remainingHeader: String = "X-RateLimit-Remaining" - var resetAfterHeader: String = "X-RateLimit-Reset-After" - } - - private var remainingRequests = -1 - private var resetAfter = 0.0 - - companion object Feature : HttpClientFeature { - override val key: AttributeKey = AttributeKey("RateLimit") - override fun prepare(block: Config.() -> Unit): RateLimit = Config().apply(block).run { - RateLimit(jsonCodec, remainingHeader, resetAfterHeader) - } - - override fun install(feature: RateLimit, scope: HttpClient) { - scope.requestPipeline.intercept(HttpRequestPipeline.Before) { - feature.remainingRequests.takeIf { it >= 0 }?.let { remaining -> - delay((feature.resetAfter * 1000 / (remaining + 1)).roundToLong()) - } - } - - scope.responsePipeline.intercept(HttpResponsePipeline.Receive) { - if (context.response.status == HttpStatusCode.TooManyRequests) { - feature.remainingRequests = 0 - val jsonBody = context.response.receive() - val rateLimitedResponse = feature.jsonCodec.decodeFromString(RateLimitedResponse.serializer(), jsonBody) - feature.resetAfter = rateLimitedResponse.retryAfter - - rateLimit() - } else { - context.response.headers[feature.remainingHeader]?.toIntOrNull()?.let { - feature.remainingRequests = it - } - - context.response.headers[feature.resetAfterHeader]?.toDoubleOrNull()?.let { - feature.resetAfter = it - } - } - } - } - } -} - -@Serializable -data class RateLimitedResponse( - val message: String, - @SerialName("retry_after") - val retryAfter: Double -) diff --git a/src/jvmMain/kotlin/starshipfights/auth/utils.kt b/src/jvmMain/kotlin/starshipfights/auth/utils.kt deleted file mode 100644 index 68c4883..0000000 --- a/src/jvmMain/kotlin/starshipfights/auth/utils.kt +++ /dev/null @@ -1,92 +0,0 @@ -package starshipfights.auth - -import io.ktor.application.* -import io.ktor.features.* -import io.ktor.http.* -import io.ktor.request.* -import io.ktor.sessions.* -import io.ktor.util.* -import kotlinx.serialization.json.Json -import starshipfights.data.Id -import starshipfights.data.auth.User -import starshipfights.data.auth.UserSession -import starshipfights.data.createNonce -import starshipfights.invalidCsrfToken -import starshipfights.redirect -import java.time.Instant -import java.time.temporal.ChronoUnit - -suspend fun Id.resolve(userAgent: String) = UserSession.get(this)?.takeIf { session -> - session.userAgent == userAgent && session.expiration > Instant.now() -} - -fun newExpiration(): Instant = Instant.now().plus(2, ChronoUnit.HOURS) - -suspend fun UserSession.renewed(clientAddress: String, userData: User) = copy( - expiration = newExpiration(), - clientAddresses = if (!userData.logIpAddresses) - emptyList() - else if (clientAddresses.lastOrNull() != clientAddress) - clientAddresses + clientAddress - else - clientAddresses -).also { UserSession.put(it) } - -suspend fun User.updated() = copy( - lastActivity = Instant.now() -).also { User.put(it) } - -val UserAndSessionAttribute = AttributeKey>("SfUserAndSession") - -suspend fun ApplicationCall.getUserSession() = getUserAndSession().first - -suspend fun ApplicationCall.getUser() = getUserAndSession().second - -suspend fun ApplicationCall.getUserAndSession() = attributes.getOrNull(UserAndSessionAttribute) - ?: request.userAgent()?.let { sessions.get>()?.resolve(it) } - ?.let { sess -> User.get(sess.user)?.let { user -> sess.renewed(request.origin.remoteHost, user) to user.updated() } } - ?.also { attributes.put(UserAndSessionAttribute, it) } - ?: (null to null) - -object UserSessionIdSerializer : SessionSerializer> { - override fun serialize(session: Id): String { - return session.id - } - - override fun deserialize(text: String): Id { - return Id(text) - } -} - -data class CsrfInput(val cookie: Id, val target: String) - -object CsrfProtector { - private val nonces = mutableMapOf() - - const val csrfInputName = "csrf-token" - - fun newNonce(token: Id, action: String): String { - return createNonce().also { nonces[it] = CsrfInput(token, action) } - } - - fun verifyNonce(nonce: String, token: Id, action: String): Boolean { - return nonces.remove(nonce) == CsrfInput(token, action) - } -} - -suspend fun ApplicationCall.receiveValidatedParameters(): Parameters { - val formInput = receiveParameters() - val sessionId = sessions.get>() ?: redirect("/login") - val csrfToken = formInput.getOrFail(CsrfProtector.csrfInputName) - - if (CsrfProtector.verifyNonce(csrfToken, sessionId, request.uri)) - return formInput - else - invalidCsrfToken() -} - -val JsonClientCodec = Json { - ignoreUnknownKeys = true -} - -fun withErrorMessage(message: String) = "?${parametersOf("error", message).formUrlEncode()}" diff --git a/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt b/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt deleted file mode 100644 index 88f1180..0000000 --- a/src/jvmMain/kotlin/starshipfights/data/admiralty/admirals.kt +++ /dev/null @@ -1,163 +0,0 @@ -package starshipfights.data.admiralty - -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList -import kotlinx.serialization.Contextual -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import org.bson.conversions.Bson -import org.litote.kmongo.* -import starshipfights.data.DataDocument -import starshipfights.data.DocumentTable -import starshipfights.data.Id -import starshipfights.data.auth.User -import starshipfights.data.invoke -import starshipfights.game.* -import java.time.Instant - -@Serializable -data class Admiral( - @SerialName("_id") - override val id: Id = Id(), - - val owningUser: Id, - - val name: String, - val isFemale: Boolean, - - val faction: Faction, - val acumen: Int, - val money: Int, -) : DataDocument { - val rank: AdmiralRank - get() = AdmiralRank.fromAcumen(acumen) - - val fullName: String - get() = "${rank.getDisplayName(faction)} $name" - - companion object Table : DocumentTable by DocumentTable.create({ - index(Admiral::owningUser) - }) -} - -fun genAI(faction: Faction, forBattleSize: BattleSize) = Admiral( - id = Id("advanced_robotical_admiral"), - owningUser = Id("fake_player_actually_an_AI"), - name = "M-5 Computational Unit", - isFemale = true, - faction = faction, - acumen = AdmiralRank.values().first { - it.maxShipWeightClass.tier >= forBattleSize.maxWeightClass.tier - }.minAcumen + 500, - money = 0 -) - -infix fun AdmiralRank.Companion.eq(rank: AdmiralRank): Bson = when (rank.ordinal) { - 0 -> Admiral::acumen lt AdmiralRank.values()[1].minAcumen - AdmiralRank.values().size - 1 -> Admiral::acumen gte rank.minAcumen - else -> and( - Admiral::acumen gte rank.minAcumen, - Admiral::acumen lt AdmiralRank.values()[rank.ordinal + 1].minAcumen - ) -} - -@Serializable -data class ShipInDrydock( - @SerialName("_id") - override val id: Id = Id(), - val name: String, - val shipType: ShipType, - val readyAt: @Contextual Instant, - val owningAdmiral: Id -) : DataDocument { - val shipData: Ship - get() = Ship(id.reinterpret(), name, shipType) - - val fullName: String - get() = shipData.fullName - - companion object Table : DocumentTable by DocumentTable.create({ - index(ShipInDrydock::owningAdmiral) - }) -} - -@Serializable -data class ShipMemorial( - @SerialName("_id") - override val id: Id = Id(), - val name: String, - val shipType: ShipType, - val destroyedAt: @Contextual Instant, - val owningAdmiral: Id, - val destroyedIn: Id, -) : DataDocument { - val fullName: String - get() = "${shipType.faction.shipPrefix}$name" - - companion object Table : DocumentTable by DocumentTable.create({ - index(ShipMemorial::owningAdmiral) - }) -} - -suspend fun getAllInGameAdmirals(user: User) = Admiral.filter(Admiral::owningUser eq user.id).map { admiral -> - InGameAdmiral( - admiral.id.reinterpret(), - InGameUser(user.id.reinterpret(), user.profileName), - admiral.name, - admiral.isFemale, - admiral.faction, - admiral.rank - ) -}.toList() - -suspend fun getInGameAdmiral(admiralId: Id) = Admiral.get(admiralId.reinterpret())?.let { admiral -> - User.get(admiral.owningUser)?.let { user -> - InGameAdmiral( - admiralId, - InGameUser(user.id.reinterpret(), user.profileName), - admiral.name, - admiral.isFemale, - admiral.faction, - admiral.rank - ) - } -} - -suspend fun getAdmiralsShips(admiralId: Id): Map, Ship> { - val now = Instant.now() - - return ShipInDrydock - .filter(and(ShipInDrydock::owningAdmiral eq admiralId, ShipInDrydock::readyAt lte now)) - .toList() - .associate { it.shipData.id to it.shipData } -} - -fun generateFleet(admiral: Admiral): List = ShipWeightClass.values() - .flatMap { swc -> - val shipTypes = ShipType.values().filter { st -> - st.weightClass == swc && st.faction == admiral.faction - }.shuffled() - - if (shipTypes.isEmpty()) - emptyList() - else - (0 until ((admiral.rank.maxShipWeightClass.tier - swc.tier + 1) * 2).coerceAtLeast(0)).map { i -> - shipTypes[i % shipTypes.size] - } - } - .let { shipTypes -> - val now = Instant.now().minusMillis(100L) - - val shipNames = mutableSetOf() - shipTypes.mapNotNull { st -> - newShipName(st.faction, st.weightClass, shipNames)?.let { name -> - ShipInDrydock( - id = Id(), - name = name, - shipType = st, - readyAt = now, - owningAdmiral = admiral.id - ) - } - } - } diff --git a/src/jvmMain/kotlin/starshipfights/data/admiralty/battle_records.kt b/src/jvmMain/kotlin/starshipfights/data/admiralty/battle_records.kt deleted file mode 100644 index e90c09d..0000000 --- a/src/jvmMain/kotlin/starshipfights/data/admiralty/battle_records.kt +++ /dev/null @@ -1,44 +0,0 @@ -package starshipfights.data.admiralty - -import kotlinx.serialization.Contextual -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import starshipfights.data.DataDocument -import starshipfights.data.DocumentTable -import starshipfights.data.Id -import starshipfights.data.auth.User -import starshipfights.data.invoke -import starshipfights.game.BattleInfo -import starshipfights.game.GlobalSide -import java.time.Instant - -@Serializable -data class BattleRecord( - @SerialName("_id") - override val id: Id = Id(), - - val battleInfo: BattleInfo, - - val whenStarted: @Contextual Instant, - val whenEnded: @Contextual Instant, - - val hostUser: Id, - val guestUser: Id, - - val hostAdmiral: Id, - val guestAdmiral: Id, - - val hostEndingMessage: String, - val guestEndingMessage: String, - - val winner: GlobalSide?, - val winMessage: String, -) : DataDocument { - companion object Table : DocumentTable by DocumentTable.create({ - index(BattleRecord::hostUser) - index(BattleRecord::guestUser) - - index(BattleRecord::hostAdmiral) - index(BattleRecord::guestAdmiral) - }) -} diff --git a/src/jvmMain/kotlin/starshipfights/data/admiralty/ship_prices.kt b/src/jvmMain/kotlin/starshipfights/data/admiralty/ship_prices.kt deleted file mode 100644 index d2acbb8..0000000 --- a/src/jvmMain/kotlin/starshipfights/data/admiralty/ship_prices.kt +++ /dev/null @@ -1,28 +0,0 @@ -package starshipfights.data.admiralty - -import starshipfights.game.Faction -import starshipfights.game.ShipType -import starshipfights.game.pointCost - -val ShipType.buyPrice: Int - get() = pointCost * 6 / 5 - -val ShipType.buyWhileDutchPrice: Int - get() = pointCost * 8 / 5 - -val ShipType.sellPrice: Int - get() = pointCost * 4 / 5 - -fun ShipType.buyPriceChecked(admiral: Admiral, ownedShips: List): Int? { - return buyPrice(admiral, ownedShips)?.takeIf { it <= admiral.money } -} - -fun ShipType.buyPrice(admiral: Admiral, ownedShips: List): Int? { - if (weightClass.tier > admiral.rank.maxShipWeightClass.tier) return null - if (weightClass.isUnique && ownedShips.any { it.shipType.weightClass == weightClass }) return null - return when { - admiral.faction == faction -> buyPrice - admiral.faction == Faction.NDRC && !weightClass.isUnique -> buyWhileDutchPrice - else -> null - } -} diff --git a/src/jvmMain/kotlin/starshipfights/data/auth/user_sessions.kt b/src/jvmMain/kotlin/starshipfights/data/auth/user_sessions.kt deleted file mode 100644 index c602df3..0000000 --- a/src/jvmMain/kotlin/starshipfights/data/auth/user_sessions.kt +++ /dev/null @@ -1,71 +0,0 @@ -package starshipfights.data.auth - -import io.ktor.auth.* -import kotlinx.serialization.Contextual -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import starshipfights.data.DataDocument -import starshipfights.data.DocumentTable -import starshipfights.data.Id -import starshipfights.data.invoke -import java.time.Instant - -@Serializable -data class User( - @SerialName("_id") - override val id: Id = Id(), - - val discordId: String, - val discordName: String, - val discordDiscriminator: String, - val discordAvatar: String?, - val showDiscordName: Boolean, - - val profileName: String, - val profileBio: String, - - val preferredTheme: PreferredTheme = PreferredTheme.SYSTEM, - - val registeredAt: @Contextual Instant, - val lastActivity: @Contextual Instant, - val showUserStatus: Boolean, - - val logIpAddresses: Boolean, - - val status: UserStatus = UserStatus.AVAILABLE, -) : DataDocument { - val discordAvatarUrl: String - get() = discordAvatar?.takeIf { showDiscordName }?.let { - "https://cdn.discordapp.com/avatars/$discordId/$it." + (if (it.startsWith("a_")) "gif" else "png") + "?size=256" - } ?: anonymousAvatarUrl - - val anonymousAvatarUrl: String - get() = "https://cdn.discordapp.com/embed/avatars/${(discordDiscriminator.lastOrNull()?.digitToInt() ?: 0) % 5}.png" - - companion object Table : DocumentTable by DocumentTable.create({ - unique(User::discordId) - index(User::registeredAt) - }) -} - -enum class PreferredTheme { - SYSTEM, LIGHT, DARK; -} - -enum class UserStatus { - AVAILABLE, IN_MATCHMAKING, READY_FOR_BATTLE, IN_BATTLE -} - -@Serializable -data class UserSession( - @SerialName("_id") - override val id: Id = Id(), - val user: Id, - val clientAddresses: List, - val userAgent: String, - val expiration: @Contextual Instant -) : DataDocument, Principal { - companion object Table : DocumentTable by DocumentTable.create({ - index(UserSession::user) - }) -} diff --git a/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt b/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt deleted file mode 100644 index 0c8a446..0000000 --- a/src/jvmMain/kotlin/starshipfights/data/auth/user_trophies.kt +++ /dev/null @@ -1,39 +0,0 @@ -package starshipfights.data.auth - -import kotlinx.html.* -import kotlinx.serialization.Serializable -import starshipfights.CurrentConfiguration - -@Serializable -sealed class UserTrophy : Comparable { - protected abstract fun TagConsumer<*>.render() - fun renderInto(consumer: TagConsumer<*>) = consumer.render() - - // Higher rank = lower on page - protected abstract val rank: Int - override fun compareTo(other: UserTrophy): Int { - return rank.compareTo(other.rank) - } -} - -fun TagConsumer<*>.renderTrophy(trophy: UserTrophy) = trophy.renderInto(this) - -@Serializable -object SiteOwnerTrophy : UserTrophy() { - override fun TagConsumer<*>.render() { - p { - style = "text-align:center;border:2px solid #a82;padding:3px;background-color:#fc3;color:#541;font-variant:small-caps;font-family:'JetBrains Mono',monospace" - +"Site Owner" - } - } - - override val rank: Int - get() = 0 -} - -fun User.getTrophiesUnsorted(): Set = - (if (discordId == CurrentConfiguration.discordClient?.ownerId) - setOf(SiteOwnerTrophy) - else emptySet()) - -fun User.getTrophies(): List = getTrophiesUnsorted().sorted() diff --git a/src/jvmMain/kotlin/starshipfights/data/data_connection.kt b/src/jvmMain/kotlin/starshipfights/data/data_connection.kt deleted file mode 100644 index 5bfa88a..0000000 --- a/src/jvmMain/kotlin/starshipfights/data/data_connection.kt +++ /dev/null @@ -1,93 +0,0 @@ -package starshipfights.data - -import de.flapdoodle.embed.mongo.MongodProcess -import de.flapdoodle.embed.mongo.MongodStarter -import de.flapdoodle.embed.mongo.config.MongoCmdOptions -import de.flapdoodle.embed.mongo.config.MongodConfig -import de.flapdoodle.embed.mongo.config.Net -import de.flapdoodle.embed.mongo.config.Storage -import de.flapdoodle.embed.mongo.distribution.Version -import de.flapdoodle.embed.process.runtime.Network -import kotlinx.coroutines.CompletableDeferred -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import org.litote.kmongo.coroutine.CoroutineClient -import org.litote.kmongo.coroutine.coroutine -import org.litote.kmongo.reactivestreams.KMongo -import org.litote.kmongo.serialization.changeIdController -import org.litote.kmongo.serialization.registerSerializer -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import java.io.File -import kotlin.system.exitProcess - -@Serializable -sealed class ConnectionType { - abstract fun createUrl(): String - - @Serializable - @SerialName("embedded") - data class Embedded(val dataDir: String = "mongodb") : ConnectionType() { - @Transient - val log: Logger = LoggerFactory.getLogger(javaClass) - - override fun createUrl(): String { - val dataDirPath = File(dataDir).apply { mkdirs() }.absolutePath - - val starter = MongodStarter.getDefaultInstance() - - log.info("Running embedded MongoDB on port 27017") - - val config = MongodConfig.builder() - .version(Version.Main.PRODUCTION) - .net(Net(27017, Network.localhostIsIPv6())) - .replication(Storage(dataDirPath, null, 1024)) - .cmdOptions(MongoCmdOptions.builder().useNoJournal(false).build()) - .build() - - var process: MongodProcess? = null - Runtime.getRuntime().addShutdownHook( - Thread( - { process?.stop() }, - "Shutdown Thread" - ) - ) - - try { - process = starter.prepare(config).start() - } catch (ex: Exception) { - log.error("Exception from starting embedded MongoDB!", ex) - log.error("Shutting down") - exitProcess(-1) - } - - return "mongodb://localhost:27017" - } - } - - @Serializable - @SerialName("external") - data class External(val url: String) : ConnectionType() { - override fun createUrl() = url - } -} - -object ConnectionHolder { - private lateinit var databaseName: String - - private val clientDeferred = CompletableDeferred() - - suspend fun getDatabase() = clientDeferred.await().getDatabase(databaseName) - - fun initialize(conn: ConnectionType, db: String) { - if (clientDeferred.isCompleted) - error("Cannot initialize database twice!") - - changeIdController(DocumentIdController) - registerSerializer(IdSerializer) - - databaseName = db - clientDeferred.complete(KMongo.createClient(conn.createUrl()).coroutine) - } -} diff --git a/src/jvmMain/kotlin/starshipfights/data/data_documents.kt b/src/jvmMain/kotlin/starshipfights/data/data_documents.kt deleted file mode 100644 index 64a0e64..0000000 --- a/src/jvmMain/kotlin/starshipfights/data/data_documents.kt +++ /dev/null @@ -1,151 +0,0 @@ -package starshipfights.data - -import com.mongodb.client.model.BulkWriteOptions -import com.mongodb.client.model.ReplaceOptions -import kotlinx.coroutines.* -import kotlinx.coroutines.flow.Flow -import kotlinx.serialization.SerialName -import org.bson.conversions.Bson -import org.litote.kmongo.coroutine.coroutine -import org.litote.kmongo.replaceOne -import org.litote.kmongo.serialization.IdController -import org.litote.kmongo.util.KMongoUtil -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import kotlin.coroutines.CoroutineContext -import kotlin.reflect.KClass -import kotlin.reflect.KProperty1 - -interface DataDocument> { - @SerialName("_id") - val id: Id -} - -object DocumentIdController : IdController { - override fun findIdProperty(type: KClass<*>): KProperty1<*, *> { - return DataDocument<*>::id - } - - @Suppress("UNCHECKED_CAST") - override fun getIdValue(idProperty: KProperty1, instance: T): R? { - return (instance as DataDocument<*>).id as R - } - - override fun setIdValue(idProperty: KProperty1, instance: T) { - throw UnsupportedOperationException("Cannot set `id` property of `DataDocument`!") - } -} - -interface DocumentTable> { - fun initialize() - - suspend fun index(vararg properties: KProperty1) - suspend fun unique(vararg properties: KProperty1) - - suspend fun put(doc: T) - suspend fun put(docs: Iterable) - suspend fun set(id: Id, set: Bson): Boolean - suspend fun get(id: Id): T? - suspend fun del(id: Id) - suspend fun all(): Flow - - suspend fun filter(where: Bson): Flow - suspend fun sorted(order: Bson): Flow - suspend fun select(where: Bson, order: Bson): Flow - suspend fun number(where: Bson): Long - suspend fun locate(where: Bson): T? - suspend fun update(where: Bson, set: Bson) - suspend fun remove(where: Bson) - - companion object : CoroutineScope { - private val logger: Logger by lazy { - LoggerFactory.getLogger(DocumentTable::class.java) - } - - override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.IO + CoroutineExceptionHandler { ctx, ex -> - val name = ctx[CoroutineName]?.name?.let { "table $it" } ?: "unnamed table" - logger.error("Caught unhandled exception from initializing $name!", ex) - } - - fun > create(kclass: KClass, initFunc: suspend DocumentTable.() -> Unit = {}): DocumentTable = DocumentTableImpl(kclass) { - runBlocking { - it.initFunc() - } - } - - inline fun > create(noinline initFunc: suspend DocumentTable.() -> Unit = {}) = create(T::class, initFunc) - } -} - -private class DocumentTableImpl>(val kclass: KClass, private val initFunc: (DocumentTable) -> Unit) : DocumentTable { - suspend fun collection() = ConnectionHolder.getDatabase().database.getCollection(kclass.simpleName, kclass.java).coroutine - - override fun initialize() { - initFunc(this) - } - - override suspend fun index(vararg properties: KProperty1) { - collection().ensureIndex(*properties) - } - - override suspend fun unique(vararg properties: KProperty1) { - collection().ensureUniqueIndex(*properties) - } - - override suspend fun put(doc: T) { - collection().replaceOneById(doc.id, doc, ReplaceOptions().upsert(true)) - } - - override suspend fun put(docs: Iterable) { - collection().bulkWrite( - docs.map { doc -> - replaceOne(KMongoUtil.idFilterQuery(doc.id), doc, ReplaceOptions().upsert(true)) - }, - BulkWriteOptions().ordered(false) - ) - } - - override suspend fun set(id: Id, set: Bson): Boolean { - return collection().updateOneById(id, set).matchedCount != 0L - } - - override suspend fun get(id: Id): T? { - return collection().findOneById(id) - } - - override suspend fun del(id: Id) { - collection().deleteOneById(id) - } - - override suspend fun all(): Flow { - return collection().find().toFlow() - } - - override suspend fun filter(where: Bson): Flow { - return collection().find(where).toFlow() - } - - override suspend fun sorted(order: Bson): Flow { - return collection().find().sort(order).toFlow() - } - - override suspend fun select(where: Bson, order: Bson): Flow { - return collection().find(where).sort(order).toFlow() - } - - override suspend fun number(where: Bson): Long { - return collection().countDocuments(where) - } - - override suspend fun locate(where: Bson): T? { - return collection().findOne(where) - } - - override suspend fun update(where: Bson, set: Bson) { - collection().updateMany(where, set) - } - - override suspend fun remove(where: Bson) { - collection().deleteMany(where) - } -} diff --git a/src/jvmMain/kotlin/starshipfights/data/data_jvm.kt b/src/jvmMain/kotlin/starshipfights/data/data_jvm.kt deleted file mode 100644 index 9253fe0..0000000 --- a/src/jvmMain/kotlin/starshipfights/data/data_jvm.kt +++ /dev/null @@ -1,14 +0,0 @@ -package starshipfights.data - -import com.aventrix.jnanoid.jnanoid.NanoIdUtils - -private val alphabet32 = "BCDFGHLMNPQRSTXZbcdfghlmnpqrstxz".toCharArray() - -private const val tokenLength = 8 -fun createToken(): String = NanoIdUtils.randomNanoId(NanoIdUtils.DEFAULT_NUMBER_GENERATOR, alphabet32, tokenLength) - -private const val nonceLength = 16 -fun createNonce(): String = NanoIdUtils.randomNanoId(NanoIdUtils.DEFAULT_NUMBER_GENERATOR, alphabet32, nonceLength) - -private const val idLength = 24 -operator fun Id.Companion.invoke() = Id(NanoIdUtils.randomNanoId(NanoIdUtils.DEFAULT_NUMBER_GENERATOR, alphabet32, idLength)) diff --git a/src/jvmMain/kotlin/starshipfights/data/data_routines.kt b/src/jvmMain/kotlin/starshipfights/data/data_routines.kt deleted file mode 100644 index 9b4cca1..0000000 --- a/src/jvmMain/kotlin/starshipfights/data/data_routines.kt +++ /dev/null @@ -1,58 +0,0 @@ -package starshipfights.data - -import kotlinx.coroutines.* -import org.litote.kmongo.inc -import org.slf4j.Logger -import org.slf4j.LoggerFactory -import starshipfights.data.admiralty.Admiral -import starshipfights.data.admiralty.BattleRecord -import starshipfights.data.admiralty.ShipInDrydock -import starshipfights.data.admiralty.eq -import starshipfights.data.auth.User -import starshipfights.data.auth.UserSession -import starshipfights.game.AdmiralRank -import java.time.Instant -import java.time.ZoneId - -object DataRoutines { - private val logger: Logger = LoggerFactory.getLogger(javaClass) - - private val scope: CoroutineScope = CoroutineScope( - SupervisorJob() + CoroutineExceptionHandler { ctx, ex -> - val coroutine = ctx[CoroutineName]?.name?.let { "coroutine $it" } ?: "unnamed coroutine" - logger.error("Caught unhandled exception in $coroutine", ex) - } - ) - - fun initializeRoutines(): Job { - // Initialize tables - Admiral.initialize() - BattleRecord.initialize() - ShipInDrydock.initialize() - User.initialize() - UserSession.initialize() - - return scope.launch { - // Pay admirals - launch { - var prevTime = Instant.now().atZone(ZoneId.systemDefault()) - while (currentCoroutineContext().isActive) { - val currTime = Instant.now().atZone(ZoneId.systemDefault()) - if (currTime.dayOfWeek != prevTime.dayOfWeek) - launch { - logger.info("Paying admirals now") - for (rank in AdmiralRank.values()) - launch { - Admiral.update( - AdmiralRank eq rank, - inc(Admiral::money, rank.dailyWage) - ) - } - } - prevTime = currTime - delay(900_000) - } - } - } - } -} diff --git a/src/jvmMain/kotlin/starshipfights/game/ai/util_jvm.kt b/src/jvmMain/kotlin/starshipfights/game/ai/util_jvm.kt deleted file mode 100644 index 300acc1..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/ai/util_jvm.kt +++ /dev/null @@ -1,22 +0,0 @@ -package starshipfights.game.ai - -import org.slf4j.Logger -import org.slf4j.LoggerFactory - -val aiLogger: Logger = LoggerFactory.getLogger("SF_AI") - -actual fun logDebug(message: Any?) { - aiLogger.debug(message.toString()) -} - -actual fun logInfo(message: Any?) { - aiLogger.info(message.toString()) -} - -actual fun logWarning(message: Any?) { - aiLogger.warn(message.toString()) -} - -actual fun logError(message: Any?) { - aiLogger.error(message.toString()) -} diff --git a/src/jvmMain/kotlin/starshipfights/game/concurrency.kt b/src/jvmMain/kotlin/starshipfights/game/concurrency.kt deleted file mode 100644 index 2c1bc79..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/concurrency.kt +++ /dev/null @@ -1,10 +0,0 @@ -package starshipfights.game - -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class ConcurrentCurator(private val curated: T) { - private val mutex = Mutex() - - suspend fun use(block: suspend (T) -> U) = mutex.withLock { block(curated) } -} diff --git a/src/jvmMain/kotlin/starshipfights/game/endpoints_game.kt b/src/jvmMain/kotlin/starshipfights/game/endpoints_game.kt deleted file mode 100644 index b6b00e1..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/endpoints_game.kt +++ /dev/null @@ -1,94 +0,0 @@ -package starshipfights.game - -import io.ktor.application.* -import io.ktor.html.* -import io.ktor.http.* -import io.ktor.routing.* -import io.ktor.websocket.* -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import org.litote.kmongo.setValue -import starshipfights.auth.getUser -import starshipfights.data.DocumentTable -import starshipfights.data.admiralty.getAllInGameAdmirals -import starshipfights.data.auth.User -import starshipfights.data.auth.UserStatus -import starshipfights.redirect - -fun Routing.installGame() { - get("/lobby") { - val user = call.getUser() ?: redirect("/login") - - val clientMode = if (user.status == UserStatus.AVAILABLE) - ClientMode.MatchmakingMenu(getAllInGameAdmirals(user)) - else - ClientMode.Error("You cannot play in multiple battles at the same time") - - call.respondHtml(HttpStatusCode.OK, clientMode.view()) - } - - post("/play") { - delay(750L) // nasty hack - - val user = call.getUser() ?: redirect("/login") - - val clientMode = when (user.status) { - UserStatus.AVAILABLE -> ClientMode.Error("You must use the matchmaking interface to enter a game") - UserStatus.IN_MATCHMAKING -> ClientMode.Error("You must start a game in the matchmaking interface") - UserStatus.READY_FOR_BATTLE -> call.getGameClientMode() - UserStatus.IN_BATTLE -> ClientMode.Error("You cannot play in multiple battles at the same time") - } - - call.respondHtml(HttpStatusCode.OK, clientMode.view()) - } - - post("/train") { - val clientMode = call.getTrainingClientMode() - - call.respondHtml(HttpStatusCode.OK, clientMode.view()) - } - - webSocket("/matchmaking") { - val oldUser = call.getUser() ?: closeAndReturn("You must be logged in to play") { return@webSocket } - if (oldUser.status != UserStatus.AVAILABLE) - closeAndReturn("You cannot play in multiple battles at the same time") { return@webSocket } - - val user = oldUser.copy(status = UserStatus.IN_MATCHMAKING) - User.put(user) - - closeReason.invokeOnCompletion { - DocumentTable.launch { - delay(150L) - if (User.get(user.id)?.status == UserStatus.IN_MATCHMAKING) - User.set(user.id, setValue(User::status, UserStatus.AVAILABLE)) - } - } - - if (matchmakingEndpoint(user)) - User.set(user.id, setValue(User::status, UserStatus.READY_FOR_BATTLE)) - } - - webSocket("/game/{token}") { - val token = call.parameters["token"] ?: closeAndReturn("Invalid or missing battle token") { return@webSocket } - - val oldUser = call.getUser() ?: closeAndReturn("You must be logged in to play") { return@webSocket } - - if (oldUser.status == UserStatus.IN_BATTLE) - closeAndReturn("You cannot play in multiple battles at the same time") { return@webSocket } - if (oldUser.status == UserStatus.IN_MATCHMAKING) - closeAndReturn("You must start a game in the matchmaking interface") { return@webSocket } - if (oldUser.status == UserStatus.AVAILABLE) - closeAndReturn("You must use the matchmaking interface to enter a game") { return@webSocket } - - val user = oldUser.copy(status = UserStatus.IN_BATTLE) - User.put(user) - - closeReason.invokeOnCompletion { - DocumentTable.launch { - User.set(user.id, setValue(User::status, UserStatus.AVAILABLE)) - } - } - - gameEndpoint(user, token) - } -} diff --git a/src/jvmMain/kotlin/starshipfights/game/game_start_jvm.kt b/src/jvmMain/kotlin/starshipfights/game/game_start_jvm.kt deleted file mode 100644 index 40bcce5..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/game_start_jvm.kt +++ /dev/null @@ -1,89 +0,0 @@ -package starshipfights.game - -import starshipfights.data.admiralty.genAI -import starshipfights.data.admiralty.generateFleet -import starshipfights.data.admiralty.getAdmiralsShips -import kotlin.math.PI - -suspend fun generateGameStart(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameStart { - val battleWidth = (25..35).random() * 500.0 - val battleLength = (15..45).random() * 500.0 - - val deployWidth2 = battleWidth / 2 - val deployLength2 = 875.0 - - val hostDeployCenter = Position(Vec2(0.0, (-battleLength / 2) + deployLength2)) - val guestDeployCenter = Position(Vec2(0.0, (battleLength / 2) - deployLength2)) - - return GameStart( - battleWidth, battleLength, - - PlayerStart( - hostDeployCenter, - PI / 2, - PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2), - PI / 2, - getAdmiralsShips(hostInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier } - ), - - PlayerStart( - guestDeployCenter, - -PI / 2, - PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2), - -PI / 2, - getAdmiralsShips(guestInfo.id.reinterpret()).filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier } - ), - ) -} - -suspend fun generateTrainingInitialState(playerInfo: InGameAdmiral, enemyFaction: Faction, battleInfo: BattleInfo): GameState { - val battleWidth = (25..35).random() * 500.0 - val battleLength = (15..45).random() * 500.0 - - val deployWidth2 = battleWidth / 2 - val deployLength2 = 875.0 - - val hostDeployCenter = Position(Vec2(0.0, (-battleLength / 2) + deployLength2)) - val guestDeployCenter = Position(Vec2(0.0, (battleLength / 2) - deployLength2)) - - val aiAdmiral = genAI(enemyFaction, battleInfo.size) - - return GameState( - start = GameStart( - battleWidth, battleLength, - - PlayerStart( - hostDeployCenter, - PI / 2, - PickBoundary.Rectangle(hostDeployCenter, deployWidth2, deployLength2), - PI / 2, - getAdmiralsShips(playerInfo.id.reinterpret()) - .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier } - ), - - PlayerStart( - guestDeployCenter, - -PI / 2, - PickBoundary.Rectangle(guestDeployCenter, deployWidth2, deployLength2), - -PI / 2, - generateFleet(aiAdmiral) - .associate { it.shipData.id to it.shipData } - .filterValues { it.shipType.weightClass.tier <= battleInfo.size.maxWeightClass.tier } - ) - ), - hostInfo = playerInfo, - guestInfo = InGameAdmiral( - id = aiAdmiral.id.reinterpret(), - user = InGameUser( - id = aiAdmiral.owningUser.reinterpret(), - username = aiAdmiral.name - ), - name = aiAdmiral.name, - isFemale = aiAdmiral.isFemale, - faction = aiAdmiral.faction, - rank = aiAdmiral.rank - ), - battleInfo = battleInfo, - subplots = generateSubplots(battleInfo.size, GlobalSide.HOST) - ) -} diff --git a/src/jvmMain/kotlin/starshipfights/game/game_time_jvm.kt b/src/jvmMain/kotlin/starshipfights/game/game_time_jvm.kt deleted file mode 100644 index 8361dda..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/game_time_jvm.kt +++ /dev/null @@ -1,25 +0,0 @@ -package starshipfights.game - -import kotlinx.serialization.Serializable -import java.time.Instant - -@Serializable(with = MomentSerializer::class) -actual class Moment(val instant: Instant) : Comparable { - actual constructor(millis: Double) : this( - Instant.ofEpochSecond( - (millis / 1000.0).toLong(), - ((millis % 1000.0) * 1_000_000.0).toLong() - ) - ) - - actual fun toMillis(): Double { - return (instant.epochSecond * 1000.0) + (instant.nano / 1_000_000.0) - } - - actual override fun compareTo(other: Moment) = toMillis().compareTo(other.toMillis()) - - actual companion object { - actual val now: Moment - get() = Moment(Instant.now()) - } -} diff --git a/src/jvmMain/kotlin/starshipfights/game/server_game.kt b/src/jvmMain/kotlin/starshipfights/game/server_game.kt deleted file mode 100644 index 4be90be..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/server_game.kt +++ /dev/null @@ -1,297 +0,0 @@ -package starshipfights.game - -import io.ktor.websocket.* -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import org.litote.kmongo.`in` -import org.litote.kmongo.inc -import org.litote.kmongo.setValue -import starshipfights.data.DocumentTable -import starshipfights.data.Id -import starshipfights.data.admiralty.Admiral -import starshipfights.data.admiralty.BattleRecord -import starshipfights.data.admiralty.ShipInDrydock -import starshipfights.data.admiralty.ShipMemorial -import starshipfights.data.auth.User -import starshipfights.data.createToken -import java.time.Instant -import java.time.temporal.ChronoUnit - -data class GameToken(val hostToken: String, val joinToken: String) - -object GameManager { - private val games = ConcurrentCurator(mutableMapOf()) - - suspend fun initGame(hostInfo: InGameAdmiral, guestInfo: InGameAdmiral, battleInfo: BattleInfo): GameToken { - val gameState = GameState( - start = generateGameStart(hostInfo, guestInfo, battleInfo), - hostInfo = hostInfo, - guestInfo = guestInfo, - battleInfo = battleInfo, - subplots = generateSubplots(battleInfo.size, GlobalSide.HOST) + generateSubplots(battleInfo.size, GlobalSide.GUEST) - ) - - val session = GameSession(gameState) - DocumentTable.launch { - session.gameStart.join() - val startedAt = Instant.now() - - val end = session.gameEnd.await() - val endedAt = Instant.now() - - onGameEnd(session.state.value, end, startedAt, endedAt) - } - - val hostId = createToken() - val joinId = createToken() - games.use { - it[hostId] = GameEntry(hostInfo.user.id.reinterpret(), GlobalSide.HOST, session) - it[joinId] = GameEntry(guestInfo.user.id.reinterpret(), GlobalSide.GUEST, session) - } - - return GameToken(hostId, joinId) - } - - suspend fun joinGame(userId: Id, token: String, remove: Boolean): GameEntry? { - return games.use { if (remove) it.remove(token) else it[token] }?.takeIf { it.userId == userId } - } -} - -class GameEntry(val userId: Id, val side: GlobalSide, val session: GameSession) - -class GameSession(gameState: GameState) { - private val hostEnter = Job() - private val guestEnter = Job() - - suspend fun enter(player: GlobalSide) = when (player) { - GlobalSide.HOST -> { - hostEnter.complete() - withTimeoutOrNull(30_000L) { - guestEnter.join() - true - } ?: false - } - GlobalSide.GUEST -> { - guestEnter.complete() - withTimeoutOrNull(30_000L) { - hostEnter.join() - true - } ?: false - } - }.also { - if (it) - gameStartMutable.complete() - else - onPacket(player.other, PlayerAction.TimeOut) - } - - private val gameStartMutable = Job() - val gameStart: Job - get() = gameStartMutable - - private val stateMutable = MutableStateFlow(gameState) - private val stateMutex = Mutex() - - val state = stateMutable.asStateFlow() - - private val hostErrorMessages = Channel(Channel.UNLIMITED) - private val guestErrorMessages = Channel(Channel.UNLIMITED) - - private fun errorMessageChannel(player: GlobalSide) = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages - } - - fun errorMessages(player: GlobalSide): ReceiveChannel = when (player) { - GlobalSide.HOST -> hostErrorMessages - GlobalSide.GUEST -> guestErrorMessages - } - - private val gameEndMutable = CompletableDeferred() - val gameEnd: Deferred - get() = gameEndMutable - - suspend fun onPacket(player: GlobalSide, packet: PlayerAction) { - stateMutex.withLock { - when (val result = state.value.after(player, packet)) { - is GameEvent.StateChange -> { - stateMutable.value = result.newState - result.newState.checkVictory()?.let { gameEndMutable.complete(it) } - } - is GameEvent.InvalidAction -> { - errorMessageChannel(player).send(result.message) - } - is GameEvent.GameEnd -> { - if (gameStartMutable.isActive) - gameStartMutable.cancel() - gameEndMutable.complete(result) - } - } - } - } - - suspend fun onClose(player: GlobalSide) { - if (gameEnd.isCompleted) return - - onPacket(player, PlayerAction.Disconnect) - } -} - -suspend fun DefaultWebSocketServerSession.gameEndpoint(user: User, token: String) { - val gameEntry = GameManager.joinGame(user.id, token, true) ?: closeAndReturn("That battle is not available") { return } - val playerSide = gameEntry.side - val gameSession = gameEntry.session - - val opponentEntered = gameSession.enter(playerSide) - sendObject(GameBeginning.serializer(), GameBeginning(opponentEntered)) - if (!opponentEntered) return - - val sendEventsJob = launch { - listOf( - // Game state changes - launch { - gameSession.state.collect { state -> - sendObject(GameEvent.serializer(), GameEvent.StateChange(state)) - } - }, - // Invalid action messages - launch { - for (errorMessage in gameSession.errorMessages(playerSide)) { - sendObject(GameEvent.serializer(), GameEvent.InvalidAction(errorMessage)) - } - } - ).joinAll() - } - - val receiveActionsJob = launch { - while (true) { - val packet = receiveObject(PlayerAction.serializer()) { - closeAndReturn { - gameSession.onClose(playerSide) - return@launch - } - } - - if (isInternalPlayerAction(packet)) - sendObject(GameEvent.serializer(), GameEvent.InvalidAction("Invalid packet sent over wire - packet type is for internal use only")) - else - gameSession.onPacket(playerSide, packet) - } - } - - val gameEnd = gameSession.gameEnd.await() - sendObject(GameEvent.serializer(), gameEnd) - - sendEventsJob.cancelAndJoin() - receiveActionsJob.cancelAndJoin() -} - -private val BattleSize.shipPointsPerAcumen: Int - get() = when (this) { - BattleSize.SKIRMISH -> 5 - BattleSize.RAID -> 5 - BattleSize.FIREFIGHT -> 5 - BattleSize.BATTLE -> 5 - BattleSize.GRAND_CLASH -> 10 - BattleSize.APOCALYPSE -> 10 - BattleSize.LEGENDARY_STRUGGLE -> 10 - BattleSize.CRUCIBLE_OF_HISTORY -> 10 - } - -private val BattleSize.acumenPerSubplotWon: Int - get() = numPoints / 100 - -private suspend fun onGameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd, startedAt: Instant, endedAt: Instant) { - val damagedShipReadyAt = endedAt.plus(6, ChronoUnit.HOURS) - val intactShipReadyAt = endedAt.plus(3, ChronoUnit.HOURS) - val escapedShipReadyAt = endedAt.plus(3, ChronoUnit.HOURS) - - val shipWrecks = gameState.destroyedShips - val ships = gameState.ships - - val hostAdmiralId = gameState.hostInfo.id.reinterpret() - val guestAdmiralId = gameState.guestInfo.id.reinterpret() - - val battleRecord = BattleRecord( - battleInfo = gameState.battleInfo, - - whenStarted = startedAt, - whenEnded = endedAt, - - hostUser = gameState.hostInfo.user.id.reinterpret(), - guestUser = gameState.guestInfo.user.id.reinterpret(), - - hostAdmiral = hostAdmiralId, - guestAdmiral = guestAdmiralId, - - hostEndingMessage = victoryTitle(GlobalSide.HOST, gameEnd.winner, gameEnd.subplotOutcomes), - guestEndingMessage = victoryTitle(GlobalSide.GUEST, gameEnd.winner, gameEnd.subplotOutcomes), - - winner = gameEnd.winner, - winMessage = gameEnd.message - ) - - val destructions = shipWrecks.filterValues { !it.isEscape } - val destroyedShips = destructions.keys.map { it.reinterpret() }.toSet() - val rememberedShips = destructions.values.map { wreck -> - ShipMemorial( - id = Id("RIP_${wreck.id.id}"), - name = wreck.ship.name, - shipType = wreck.ship.shipType, - destroyedAt = wreck.wreckedAt.instant, - owningAdmiral = when (wreck.owner) { - GlobalSide.HOST -> hostAdmiralId - GlobalSide.GUEST -> guestAdmiralId - }, - destroyedIn = battleRecord.id - ) - } - - val escapedShips = shipWrecks.filterValues { it.isEscape }.keys.map { it.reinterpret() }.toSet() - val damagedShips = ships.filterValues { it.hullAmount < it.durability.maxHullPoints }.keys.map { it.reinterpret() }.toSet() - val intactShips = ships.keys.map { it.reinterpret() }.toSet() - damagedShips - - val battleSize = gameState.battleInfo.size - - val hostAcumenGainFromShips = shipWrecks.values.filter { it.owner == GlobalSide.GUEST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen } - val hostAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player == GlobalSide.HOST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon - val hostAcumenGain = hostAcumenGainFromShips + hostAcumenGainFromSubplots - - val guestAcumenGainFromShips = shipWrecks.values.filter { it.owner == GlobalSide.HOST && !it.isEscape }.sumOf { it.ship.pointCost / battleSize.shipPointsPerAcumen } - val guestAcumenGainFromSubplots = gameEnd.subplotOutcomes.filterKeys { it.player == GlobalSide.GUEST }.count { (_, outcome) -> outcome == SubplotOutcome.WON } * battleSize.acumenPerSubplotWon - val guestAcumenGain = guestAcumenGainFromShips + guestAcumenGainFromSubplots - - coroutineScope { - launch { - ShipMemorial.put(rememberedShips) - } - launch { - ShipInDrydock.remove(ShipInDrydock::id `in` destroyedShips) - } - launch { - ShipInDrydock.update(ShipInDrydock::id `in` damagedShips, setValue(ShipInDrydock::readyAt, damagedShipReadyAt)) - } - launch { - ShipInDrydock.update(ShipInDrydock::id `in` intactShips, setValue(ShipInDrydock::readyAt, intactShipReadyAt)) - } - launch { - ShipInDrydock.update(ShipInDrydock::id `in` escapedShips, setValue(ShipInDrydock::readyAt, escapedShipReadyAt)) - } - - launch { - Admiral.set(hostAdmiralId, inc(Admiral::acumen, hostAcumenGain)) - } - launch { - Admiral.set(guestAdmiralId, inc(Admiral::acumen, guestAcumenGain)) - } - - launch { - BattleRecord.put(battleRecord) - } - } -} diff --git a/src/jvmMain/kotlin/starshipfights/game/server_matchmaking.kt b/src/jvmMain/kotlin/starshipfights/game/server_matchmaking.kt deleted file mode 100644 index 1454a74..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/server_matchmaking.kt +++ /dev/null @@ -1,126 +0,0 @@ -package starshipfights.game - -import io.ktor.websocket.* -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.ClosedSendChannelException -import kotlinx.coroutines.launch -import starshipfights.data.admiralty.getInGameAdmiral -import starshipfights.data.auth.User - -private val openSessions = ConcurrentCurator(mutableListOf()) - -class HostInvitation(admiral: InGameAdmiral, battleInfo: BattleInfo) { - val joinable = Joinable(admiral, battleInfo) - val joinInvitations = Channel() - - val gameIdHandler = CompletableDeferred() -} - -class JoinInvitation(val joinRequest: JoinRequest, val responseHandler: CompletableDeferred) { - val gameIdHandler = CompletableDeferred() -} - -suspend fun DefaultWebSocketServerSession.matchmakingEndpoint(user: User): Boolean { - val playerLogin = receiveObject(PlayerLogin.serializer()) { closeAndReturn { return false } } - val admiralId = playerLogin.admiral - val inGameAdmiral = getInGameAdmiral(admiralId) ?: closeAndReturn("That admiral does not exist") { return false } - if (inGameAdmiral.user.id != user.id) closeAndReturn("You do not own that admiral") { return false } - - when (val loginMode = playerLogin.login) { - is LoginMode.Train -> { - closeAndReturn("Invalid input: LoginMode.Train should redirect you directly to training endpoint") { return false } - } - is LoginMode.Host -> { - val battleInfo = loginMode.battleInfo - val hostInvitation = HostInvitation(inGameAdmiral, battleInfo) - - openSessions.use { it.add(hostInvitation) } - - closeReason.invokeOnCompletion { - hostInvitation.joinInvitations.close() - - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch { - openSessions.use { - it.remove(hostInvitation) - } - } - } - - for (joinInvitation in hostInvitation.joinInvitations) { - sendObject(JoinRequest.serializer(), joinInvitation.joinRequest) - val joinResponse = receiveObject(JoinResponse.serializer()) { - closeAndReturn { - joinInvitation.responseHandler.complete(JoinResponse(false)) - return false - } - } - - if (joinInvitation.responseHandler.isCancelled) { - if (joinResponse.accepted) - sendObject(JoinResponseResponse.serializer(), JoinResponseResponse(false)) - continue - } - - joinInvitation.responseHandler.complete(joinResponse) - - if (joinResponse.accepted) { - sendObject(JoinResponseResponse.serializer(), JoinResponseResponse(true)) - - val (hostId, joinId) = GameManager.initGame(inGameAdmiral, joinInvitation.joinRequest.joiner, loginMode.battleInfo) - hostInvitation.gameIdHandler.complete(hostId) - joinInvitation.gameIdHandler.complete(joinId) - - break - } - } - - val gameId = hostInvitation.gameIdHandler.await() - sendObject(GameReady.serializer(), GameReady(gameId)) - } - LoginMode.Join -> { - val joinRequest = JoinRequest(inGameAdmiral) - - while (true) { - val openGames = openSessions.use { - it.toList() - }.filter { sess -> - sess.joinable.battleInfo.size <= inGameAdmiral.rank.maxBattleSize - }.mapIndexed { i, host -> "$i" to host }.toMap() - - val joinListing = JoinListing(openGames.mapValues { (_, invitation) -> invitation.joinable }) - sendObject(JoinListing.serializer(), joinListing) - - val joinSelection = receiveObject(JoinSelection.serializer()) { closeAndReturn { return false } } - val hostInvitation = openGames.getValue(joinSelection.selectedId) - - val joinResponseHandler = CompletableDeferred() - val joinInvitation = JoinInvitation(joinRequest, joinResponseHandler) - closeReason.invokeOnCompletion { - joinResponseHandler.cancel() - } - - try { - hostInvitation.joinInvitations.send(joinInvitation) - } catch (ex: ClosedSendChannelException) { - sendObject(JoinResponse.serializer(), JoinResponse(false)) - continue - } - - val joinResponse = joinResponseHandler.await() - sendObject(JoinResponse.serializer(), joinResponse) - - if (joinResponse.accepted) { - val gameId = joinInvitation.gameIdHandler.await() - sendObject(GameReady.serializer(), GameReady(gameId)) - break - } - } - } - } - - return true -} diff --git a/src/jvmMain/kotlin/starshipfights/game/util_jvm.kt b/src/jvmMain/kotlin/starshipfights/game/util_jvm.kt deleted file mode 100644 index 31ac3ac..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/util_jvm.kt +++ /dev/null @@ -1,23 +0,0 @@ -package starshipfights.game - -import io.ktor.http.cio.websocket.* -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.serialization.DeserializationStrategy -import kotlinx.serialization.SerializationStrategy - -suspend inline fun DefaultWebSocketSession.receiveObject(serializer: DeserializationStrategy, exitOnError: () -> Nothing): T { - val text = incoming.receiveAsFlow().filterIsInstance().firstOrNull()?.readText() ?: exitOnError() - return jsonSerializer.decodeFromString(serializer, text) -} - -suspend inline fun DefaultWebSocketSession.sendObject(serializer: SerializationStrategy, value: T) { - outgoing.send(Frame.Text(jsonSerializer.encodeToString(serializer, value))) - flush() -} - -suspend inline fun DefaultWebSocketSession.closeAndReturn(closeMessage: String = "", exitFunction: () -> Nothing): Nothing { - close(CloseReason(CloseReason.Codes.NORMAL, closeMessage)) - exitFunction() -} diff --git a/src/jvmMain/kotlin/starshipfights/game/views_game.kt b/src/jvmMain/kotlin/starshipfights/game/views_game.kt deleted file mode 100644 index 4db90d2..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/views_game.kt +++ /dev/null @@ -1,69 +0,0 @@ -package starshipfights.game - -import io.ktor.application.* -import io.ktor.request.* -import kotlinx.html.* -import starshipfights.auth.getUserSession -import starshipfights.redirect - -fun ClientMode.view(): HTML.() -> Unit = { - head { - meta(charset = "UTF-8") - - link(rel = "icon", type = "image/svg+xml", href = "/static/images/icon.svg") - - link(rel = "preconnect", href = "https://fonts.googleapis.com") - link(rel = "preconnect", href = "https://fonts.gstatic.com") { attributes["crossorigin"] = "anonymous" } - link(rel = "stylesheet", href = "https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700;800;900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap") - link(rel = "stylesheet", href = "/static/game/style.css") - - script(src = "/static/game/textfit.min.js") {} - - script(src = "/static/game/three.js") {} - script(src = "/static/game/three-examples.js") {} - script(src = "/static/game/three-extras.js") {} - - when (this@view) { - is ClientMode.MatchmakingMenu -> title("Starship Fights | Lobby") - is ClientMode.InTrainingGame -> title("Starship Fights | Training") - is ClientMode.InGame -> title("Starship Fights | In-Game") - is ClientMode.Error -> title("Starship Fights | Error!") - } - } - body { - canvas { id = "three-canvas" } - - div(classes = "ui-layer") { id = "ui" } - - div(classes = "hide") { - id = "popup" - div(classes = "panel") { - id = "popup-panel" - div { - id = "popup-box" - } - } - } - - script { - attributes["id"] = "sf-client-mode" - type = "application/json" - unsafe { - +jsonSerializer.encodeToString(ClientMode.serializer(), this@view) - } - } - - script(src = "/static/game/starship-fights.js") {} - } -} - -suspend fun ApplicationCall.getGameClientMode(): ClientMode { - val userId = getUserSession()?.user ?: redirect("/login") - val token = receiveParameters()["token"] ?: return ClientMode.Error("Invalid or missing battle token") - val game = GameManager.joinGame(userId, token, false) ?: return ClientMode.Error("That battle is no longer available") - return ClientMode.InGame( - game.side, - token, - game.session.state.value - ) -} diff --git a/src/jvmMain/kotlin/starshipfights/game/views_training.kt b/src/jvmMain/kotlin/starshipfights/game/views_training.kt deleted file mode 100644 index 23f8f37..0000000 --- a/src/jvmMain/kotlin/starshipfights/game/views_training.kt +++ /dev/null @@ -1,29 +0,0 @@ -package starshipfights.game - -import io.ktor.application.* -import io.ktor.request.* -import starshipfights.auth.getUserSession -import starshipfights.data.Id -import starshipfights.data.admiralty.Admiral -import starshipfights.data.admiralty.getInGameAdmiral -import starshipfights.redirect - -suspend fun ApplicationCall.getTrainingClientMode(): ClientMode { - val userId = getUserSession()?.user ?: redirect("/login") - val parameters = receiveParameters() - - val admiralId = parameters["admiral"]?.let { Id(it) } ?: return ClientMode.Error("An admiral must be specified") - val admiralData = getInGameAdmiral(admiralId.reinterpret()) ?: return ClientMode.Error("That admiral does not exist") - - if (admiralData.user.id != userId.reinterpret()) return ClientMode.Error("You do not own that admiral") - - val battleSize = BattleSize.values().singleOrNull { it.toUrlSlug() == parameters["battle-size"] } ?: return ClientMode.Error("Invalid battle size") - val battleBg = BattleBackground.values().singleOrNull { it.toUrlSlug() == parameters["battle-bg"] } ?: return ClientMode.Error("Invalid battle background") - val battleInfo = BattleInfo(battleSize, battleBg) - - val enemyFaction = Faction.values().singleOrNull { it.toUrlSlug() == parameters["enemy-faction"] } ?: Faction.values().random() - - val initialState = generateTrainingInitialState(admiralData, enemyFaction, battleInfo) - - return ClientMode.InTrainingGame(initialState) -} diff --git a/src/jvmMain/kotlin/starshipfights/info/endpoints_info.kt b/src/jvmMain/kotlin/starshipfights/info/endpoints_info.kt deleted file mode 100644 index dd07d2a..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/endpoints_info.kt +++ /dev/null @@ -1,74 +0,0 @@ -package starshipfights.info - -import io.ktor.application.* -import io.ktor.html.* -import io.ktor.http.* -import io.ktor.response.* -import io.ktor.routing.* -import starshipfights.data.admiralty.AdmiralNameFlavor -import starshipfights.data.admiralty.AdmiralNames -import starshipfights.game.Moment -import starshipfights.game.ShipType -import starshipfights.game.toUrlSlug - -fun Routing.installPages() { - get("/") { - call.respondHtml(HttpStatusCode.OK, call.mainPage()) - } - - get("/info") { - call.respondHtml(HttpStatusCode.OK, call.shipsPage()) - } - - get("/info/{ship}") { - val ship = call.parameters["ship"]?.let { param -> ShipType.values().singleOrNull { it.toUrlSlug() == param } }!! - call.respondHtml(HttpStatusCode.OK, call.shipPage(ship)) - } - - get("/about") { - call.respondHtml(HttpStatusCode.OK, call.aboutPage()) - } - - get("/about/pp") { - call.respondHtml(HttpStatusCode.OK, call.privacyPolicyPage()) - } - - get("/about/tnc") { - call.respondHtml(HttpStatusCode.OK, call.termsAndConditionsPage()) - } - - get("/users") { - call.respondHtml(HttpStatusCode.OK, call.newUsersPage()) - } - - // Random name generation - get("/generate-name/{flavor}/{gender}") { - val flavor = call.parameters["flavor"]?.let { flavor -> AdmiralNameFlavor.values().singleOrNull { it.toUrlSlug().equals(flavor, ignoreCase = true) } }!! - val isFemale = call.parameters["gender"]?.startsWith('f', ignoreCase = true) ?: false - - call.respondText(AdmiralNames.randomName(flavor, isFemale), ContentType.Text.Plain) - } - - // Cache utils - val cacheTime = String.format("%f", Moment.now.toMillis()) - get("/cache-time") { - call.respondText(cacheTime, ContentType.Text.Plain, HttpStatusCode.OK) - } - - // Sitemap - val sitemapUrls = (listOf( - "/", - "/about", - "/about/pp", - "/about/tnc", - "/info", - ) + ShipType.values().map { - "/info/${it.toUrlSlug()}" - }).map { "https://starshipfights.net$it" } - - val sitemap = sitemapUrls.joinToString(separator = "\n") - - get("/sitemap.txt") { - call.respondText(sitemap, ContentType.Text.Plain, HttpStatusCode.OK) - } -} diff --git a/src/jvmMain/kotlin/starshipfights/info/html_utils.kt b/src/jvmMain/kotlin/starshipfights/info/html_utils.kt deleted file mode 100644 index eee9510..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/html_utils.kt +++ /dev/null @@ -1,36 +0,0 @@ -package starshipfights.info - -import kotlinx.html.* -import starshipfights.auth.CsrfProtector -import starshipfights.data.Id -import starshipfights.data.auth.UserSession - -var A.method: String? - get() = attributes["data-method"] - set(value) { - if (value != null) - attributes["data-method"] = value - else - attributes.remove("data-method") - } - -fun A.csrfToken(cookie: Id) { - attributes["data-csrf-token"] = CsrfProtector.newNonce(cookie, this.href) -} - -fun FORM.csrfToken(cookie: Id) = hiddenInput { - name = CsrfProtector.csrfInputName - value = CsrfProtector.newNonce(cookie, this@csrfToken.action) -} - -fun interface SECTIONS { - fun section(body: SECTION.() -> Unit) -} - -fun MAIN.sectioned(): SECTIONS = MainSections(this) - -private class MainSections(private val delegate: MAIN) : SECTIONS { - override fun section(body: SECTION.() -> Unit) { - delegate.section(block = body) - } -} diff --git a/src/jvmMain/kotlin/starshipfights/info/view_bar.kt b/src/jvmMain/kotlin/starshipfights/info/view_bar.kt deleted file mode 100644 index 67de0df..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/view_bar.kt +++ /dev/null @@ -1,40 +0,0 @@ -package starshipfights.info - -import kotlinx.html.* -import starshipfights.game.ShipType -import starshipfights.game.getDefiniteShortName - -abstract class Sidebar { - protected abstract fun TagConsumer<*>.display() - fun displayIn(aside: ASIDE) = aside.consumer.display() -} - -class CustomSidebar(private val block: TagConsumer<*>.() -> Unit) : Sidebar() { - override fun TagConsumer<*>.display() = block() -} - -data class ShipViewSidebar(val shipType: ShipType) : Sidebar() { - override fun TagConsumer<*>.display() { - p { - img(alt = "Flag of ${shipType.faction.getDefiniteShortName()}", src = shipType.faction.flagUrl) - } - p { - style = "text-align:center" - +shipType.weightClass.displayName - +" of the " - +shipType.faction.navyName - } - } -} - -data class PageNavSidebar(val contents: List) : Sidebar() { - override fun TagConsumer<*>.display() { - div(classes = "list") { - for (it in contents) { - div(classes = "item") { - it.displayIn(this) - } - } - } - } -} diff --git a/src/jvmMain/kotlin/starshipfights/info/view_nav.kt b/src/jvmMain/kotlin/starshipfights/info/view_nav.kt deleted file mode 100644 index c073434..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/view_nav.kt +++ /dev/null @@ -1,67 +0,0 @@ -package starshipfights.info - -import io.ktor.application.* -import kotlinx.html.DIV -import kotlinx.html.a -import kotlinx.html.span -import kotlinx.html.style -import starshipfights.CurrentConfiguration -import starshipfights.auth.getUserAndSession -import starshipfights.data.Id -import starshipfights.data.auth.UserSession - -sealed class NavItem { - protected abstract fun DIV.display() - fun displayIn(div: DIV) = div.display() -} - -data class NavHead(val label: String) : NavItem() { - override fun DIV.display() { - span { - style = "font-variant:small-caps;text-decoration:underline" - +label - } - } -} - -data class NavLink(val to: String, val text: String, val classes: String? = null, val isPost: Boolean = false, val csrfUserCookie: Id? = null) : NavItem() { - override fun DIV.display() { - a(href = to, classes = classes) { - if (isPost) - method = "post" - csrfUserCookie?.let { csrfToken(it) } - - +text - } - } -} - -suspend fun ApplicationCall.standardNavBar(): List = listOf( - NavLink("/", "Main Page"), - NavLink("/info", "Read Manual"), - NavLink("/about", "About Starship Fights"), - NavLink("/users", "New Users"), - NavHead("Your Account"), -) + getUserAndSession().let { (session, user) -> - if (session == null || user == null) - listOf( - NavLink("/login", "Login with Discord"), - ) - else - listOf( - NavLink("/me", user.profileName), - NavLink("/me/manage", "User Preferences"), - NavLink("/lobby", "Enter Game Lobby", classes = "desktop"), - NavLink("/logout", "Log Out", isPost = true, csrfUserCookie = session.id), - ) -} + listOf( - NavHead("External Information") -) + (CurrentConfiguration.discordClient?.serverInvite?.let { - listOf( - NavLink("https://discord.gg/$it", "Official Discord") - ) -} ?: emptyList()) + listOf( - NavLink("https://mechyrdia.netlify.app/", "Mechyrdia Infobase"), - NavLink("https://nationstates.net/mechyrdia", "Multiverse Access"), - NavLink("https://www.buymeacoffee.com/starshipfights", "Support Starship Fights"), -) diff --git a/src/jvmMain/kotlin/starshipfights/info/view_tpl.kt b/src/jvmMain/kotlin/starshipfights/info/view_tpl.kt deleted file mode 100644 index d46317d..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/view_tpl.kt +++ /dev/null @@ -1,81 +0,0 @@ -package starshipfights.info - -import io.ktor.application.* -import kotlinx.html.* -import starshipfights.auth.getUser -import starshipfights.data.auth.PreferredTheme - -suspend fun ApplicationCall.page(pageTitle: String? = null, navBar: List? = null, sidebar: Sidebar? = null, content: SECTIONS.() -> Unit): HTML.() -> Unit { - val theme = getUser()?.preferredTheme - - return { - when (theme) { - PreferredTheme.LIGHT -> "light" - PreferredTheme.DARK -> "dark" - else -> null - }?.let { attributes["data-theme"] = it } - - head { - meta(charset = "utf-8") - meta(name = "viewport", content = "width=device-width, initial-scale=1.0") - - link(rel = "icon", type = "image/svg+xml", href = "/static/images/icon.svg") - link(rel = "preconnect", href = "https://fonts.googleapis.com") - link(rel = "preconnect", href = "https://fonts.gstatic.com") { attributes["crossorigin"] = "anonymous" } - link(rel = "stylesheet", href = "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Jetbrains+Mono:wght@400;600;800&display=swap") - link(rel = "stylesheet", href = "/static/style.css") - - title { - +"Starship Fights" - pageTitle?.let { +" | $it" } - } - } - body { - div { id = "bg" } - - navBar?.let { nb -> - nav(classes = "desktop") { - div(classes = "list") { - for (ni in nb) { - div(classes = "item") { - ni.displayIn(this) - } - } - } - } - } - - sidebar?.let { - aside(classes = "desktop") { - it.displayIn(this) - } - } - - main { - sidebar?.let { - aside(classes = "mobile") { - it.displayIn(this) - } - } - - with(sectioned()) { - content() - } - - navBar?.let { nb -> - nav(classes = "mobile") { - div(classes = "list") { - for (ni in nb) { - div(classes = "item") { - ni.displayIn(this) - } - } - } - } - } - } - - script(src = "/static/init.js") {} - } - } -} diff --git a/src/jvmMain/kotlin/starshipfights/info/views_error.kt b/src/jvmMain/kotlin/starshipfights/info/views_error.kt deleted file mode 100644 index 96fb44c..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/views_error.kt +++ /dev/null @@ -1,65 +0,0 @@ -package starshipfights.info - -import io.ktor.application.* -import io.ktor.features.* -import kotlinx.html.* -import starshipfights.CurrentConfiguration - -private fun SECTIONS.devModeCallId(callId: String?) { - callId?.let { id -> - section { - style = if (CurrentConfiguration.isDevEnv) "" else "display:none" - +"If you think this is a bug, report it with the call ID #" - +id - +"." - } - } -} - -suspend fun ApplicationCall.error400(): HTML.() -> Unit = page("Bad Request", standardNavBar()) { - section { - h1 { +"Bad Request" } - p { +"The request your browser sent was improperly formatted." } - } - devModeCallId(callId) -} - -suspend fun ApplicationCall.error403(): HTML.() -> Unit = page("Not Allowed", standardNavBar()) { - section { - h1 { +"Not Allowed" } - p { +"You are not allowed to do that." } - } - devModeCallId(callId) -} - -suspend fun ApplicationCall.error403InvalidCsrf(): HTML.() -> Unit = page("CSRF Validation Failed", standardNavBar()) { - section { - h1 { +"CSRF Validation Failed" } - p { +"Unfortunately, the received CSRF failed to validate. Please try again." } - } - devModeCallId(callId) -} - -suspend fun ApplicationCall.error404(): HTML.() -> Unit = page("Not Found", standardNavBar()) { - section { - h1 { +"Not Found" } - p { +"Unfortunately, we could not find what you were looking for." } - } - devModeCallId(callId) -} - -suspend fun ApplicationCall.error429(): HTML.() -> Unit = page("Too Many Requests", standardNavBar()) { - section { - h1 { +"Too Many Requests" } - p { +"Our server is being bogged down in a quagmire of HTTP requests. Please try again later." } - } - devModeCallId(callId) -} - -suspend fun ApplicationCall.error503(): HTML.() -> Unit = page("Internal Error", standardNavBar()) { - section { - h1 { +"Internal Error" } - p { +"The servers made a bit of a mistake. Please be patient while we clean up our mess." } - } - devModeCallId(callId) -} diff --git a/src/jvmMain/kotlin/starshipfights/info/views_gdpr.kt b/src/jvmMain/kotlin/starshipfights/info/views_gdpr.kt deleted file mode 100644 index d80ce00..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/views_gdpr.kt +++ /dev/null @@ -1,195 +0,0 @@ -package starshipfights.info - -import io.ktor.application.* -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.toList -import kotlinx.html.* -import org.litote.kmongo.eq -import org.litote.kmongo.or -import starshipfights.auth.getUser -import starshipfights.auth.getUserSession -import starshipfights.data.admiralty.Admiral -import starshipfights.data.admiralty.BattleRecord -import starshipfights.data.admiralty.ShipInDrydock -import starshipfights.data.admiralty.ShipMemorial -import starshipfights.data.auth.User -import starshipfights.data.auth.UserSession -import starshipfights.game.GlobalSide -import starshipfights.redirect -import java.time.Instant - -suspend fun ApplicationCall.privateInfo(): String { - val currentSession = getUserSession() ?: redirect("/login") - - val now = Instant.now() - - val userId = currentSession.user - val (user, userData) = coroutineScope { - val getUser = async { User.get(userId) } - val getAdmirals = async { Admiral.filter(Admiral::owningUser eq userId).toList() } - val getSessions = async { UserSession.filter(UserSession::user eq userId).toList() } - val getBattles = async { - BattleRecord.filter( - or( - BattleRecord::hostUser eq userId, - BattleRecord::guestUser eq userId - ) - ).toList() - } - - getUser.await() to Triple(getAdmirals.await(), getSessions.await(), getBattles.await()) - } - val (userAdmirals, userSessions, userBattles) = userData - user ?: redirect("/login") - - val battleEndings = userBattles.associate { record -> - record.id to when (record.winner) { - GlobalSide.HOST -> record.hostUser == userId - GlobalSide.GUEST -> record.guestUser == userId - null -> null - } - } - - val (admiralShips, battleOpponents, battleAdmirals) = coroutineScope { - val getShips = userAdmirals.associate { admiral -> - admiral.id to (async { - ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiral.id).toList() - } to async { - ShipMemorial.filter(ShipMemorial::owningAdmiral eq admiral.id).toList() - }) - } - val getOpponents = userBattles.associate { record -> - val (opponentId, opponentAdmiralId) = if (record.hostUser == userId) record.guestUser to record.guestAdmiral else record.hostUser to record.hostAdmiral - - record.id to (async { User.get(opponentId) } to async { Admiral.get(opponentAdmiralId) }) - } - val getAdmirals = userBattles.associate { record -> - val admiralId = if (record.hostUser == userId) record.hostAdmiral else record.guestAdmiral - record.id to userAdmirals.singleOrNull { it.id == admiralId } - } - - Triple( - getShips.mapValues { (_, pair) -> - val (ships, graves) = pair - ships.await() to graves.await() - }, - getOpponents.mapValues { (_, deferred) -> deferred.let { (u, a) -> u.await() to a.await() } }, - getAdmirals - ) - } - - return buildString { - appendLine("# Private data of user https://starshipfights.net/user/$userId\n") - appendLine("Profile name: ${user.profileName}") - appendLine("Profile bio: \"\"\"") - appendLine(user.profileBio) - appendLine("\"\"\"") - appendLine("Display theme: ${user.preferredTheme}") - appendLine("") - appendLine("## Activity data") - appendLine("Registered at: ${user.registeredAt}") - appendLine("Last activity: ${user.lastActivity}") - appendLine("Online status: ${if (user.showUserStatus) "shown" else "hidden"}") - appendLine("") - appendLine("## Discord login data") - appendLine("Discord ID: ${user.discordId}") - appendLine("Discord name: ${user.discordName}") - appendLine("Discord discriminator: ${user.discordDiscriminator}") - appendLine(user.discordAvatar?.let { "Discord avatar: $it" } ?: "Discord avatar absent") - appendLine("Discord profile: ${if (user.showDiscordName) "shown" else "hidden"}") - appendLine("") - appendLine("## Session data") - appendLine("IP addresses are ${if (user.logIpAddresses) "stored" else "ignored"}") - for (session in userSessions.sortedByDescending { it.expiration }) { - appendLine("") - appendLine("### Session ${session.id}") - appendLine("Browser User-Agent: ${session.userAgent}") - appendLine("Client addresses${if (session.clientAddresses.isEmpty()) " are not stored" else ":"}") - for (addr in session.clientAddresses) appendLine("* $addr") - appendLine("${if (session.expiration > now) "Will expire" else "Has expired"} at: ${session.expiration}") - } - appendLine("") - appendLine("## Battle-record data") - for (record in userBattles.sortedBy { it.whenEnded }) { - appendLine("") - appendLine("### Battle record ${record.id}") - appendLine("Battle size: ${record.battleInfo.size.displayName} (${record.battleInfo.size.numPoints})") - appendLine("Battle background: ${record.battleInfo.bg.displayName}") - appendLine("Battle started at: ${record.whenStarted}") - appendLine("Battle completed at: ${record.whenEnded}") - appendLine("Battle was fought by ${battleAdmirals[record.id]?.let { "${it.fullName} (https://starshipfights.net/admiral/${it.id})" } ?: "{deleted admiral}"}") - appendLine("Battle was fought against ${battleOpponents[record.id]?.second?.let { "${it.fullName} (https://starshipfights.net/admiral/${it.id})" } ?: "{deleted admiral}"}") - appendLine(" => ${battleOpponents[record.id]?.first?.let { "${it.profileName} (https://starshipfights.net/user/${it.id})" } ?: "{deleted user}"}") - when (battleEndings[record.id]) { - true -> appendLine("Battle ended in victory") - false -> appendLine("Battle ended in defeat") - null -> appendLine("Battle ended in stalemate") - } - appendLine(" => \"${record.winMessage}\"") - } - appendLine("") - appendLine("## Admiral data") - for (admiral in userAdmirals) { - appendLine("") - appendLine("### ${admiral.fullName} (https://starshipfights.net/admiral/${admiral.id})") - appendLine("Admiral is ${if (admiral.isFemale) "female" else "male"}") - appendLine("Admiral serves the ${admiral.faction.navyName}") - appendLine("Admiral's experience is ${admiral.acumen} acumen") - appendLine("Admiral's monetary wealth is ${admiral.money} ${admiral.faction.currencyName}") - appendLine("Admiral can command ships as big as a ${admiral.rank.maxShipWeightClass.displayName}") - val ships = admiralShips[admiral.id]?.first.orEmpty() - appendLine("Admiral has ${ships.size} ships:") - for (ship in ships) { - appendLine("") - appendLine("#### ${ship.fullName} (${ship.id})") - appendLine("Ship is a ${ship.shipType.fullerDisplayName}") - appendLine("Ship ${if (ship.readyAt > now) "will be ready at" else "has been ready since"} ${ship.readyAt}") - } - appendLine("") - val graves = admiralShips[admiral.id]?.second.orEmpty() - appendLine("Admiral has lost ${ships.size} ships in battle:") - for (grave in graves) { - appendLine("") - appendLine("#### ${grave.fullName} (${grave.id})") - appendLine("Ship is a ${grave.shipType.fullerDisplayName}") - appendLine("Ship was destroyed at ${grave.destroyedAt} in battle recorded at ${grave.destroyedIn}") - } - appendLine("") - appendLine("# More information") - appendLine("This document contains the totality of your private data as stored by Starship Fights") - appendLine("This page can be accessed at https://starshipfights.net/me/private-info") - appendLine("All private info can be downloaded at https://starshipfights.net/me/private-info/txt") - appendLine("The privacy policy can be reviewed at https://starshipfights.net/about/pp") - } - } -} - -suspend fun ApplicationCall.privateInfoPage(): HTML.() -> Unit { - if (getUser() == null) redirect("/login") - - return page( - null, standardNavBar(), PageNavSidebar( - listOf( - NavLink("/me/manage", "Back to Preferences"), - NavLink("/about/pp", "Review Privacy Policy"), - ) - ) - ) { - section { - h1 { +"Your Private Info" } - - iframe { - style = "width:100%;height:25em" - src = "/me/private-info/txt" - } - - p { - a(href = "/me/private-info/txt") { - attributes["download"] = "private-info.txt" - +"Download your private info" - } - } - } - } -} diff --git a/src/jvmMain/kotlin/starshipfights/info/views_main.kt b/src/jvmMain/kotlin/starshipfights/info/views_main.kt deleted file mode 100644 index 82d29a7..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/views_main.kt +++ /dev/null @@ -1,236 +0,0 @@ -package starshipfights.info - -import io.ktor.application.* -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.flow.toList -import kotlinx.html.* -import org.litote.kmongo.descending -import org.litote.kmongo.eq -import starshipfights.CurrentConfiguration -import starshipfights.data.auth.User -import starshipfights.game.foreign - -suspend fun ApplicationCall.mainPage(): HTML.() -> Unit { - return page(null, standardNavBar(), null) { - section { - img(alt = "Starship Fights Logo", src = "/static/images/logo.svg") { - style = "width:100%" - } - p { - +"Starship Fights is a space fleet battle game. Choose your allegiance, create your admiral, build up your fleet, and destroy your enemies' fleets with it. You might, on occasion, get destroyed by your enemies; that's entirely normal, and all a part of learning." - } - p { - +"Set in the galaxy-wide " - a(href = "https://nationstates.net/mechyrdia") { +"Mechyrdiaverse" } - +", Starship Fights is about the grand struggle between six major political powers. Fight for liberty and justice with the Empire of Mechyrdia or their friends in the Dutch Outer Space Company, conquer for glory and honor with the Diadochus Masra Draetsen, strike from the shadows with the " - foreign("la") { +"Felinae Felices" } - +", preserve your homeland and decide its fate with the Isarnareyksk Federation, or reclaim your people's rightful dominion with the American Vestigium! The choice is yours, admiral." - } - } - } -} - -suspend fun ApplicationCall.aboutPage(): HTML.() -> Unit { - val owner = CurrentConfiguration.discordClient?.ownerId?.let { - User.locate(User::discordId eq it) - } ?: return page( - "About", standardNavBar(), null - ) { - section { - h1 { +"In Development" } - p { - +"This is a test instance of Starship Fights." - } - } - } - - return page( - "About", standardNavBar(), PageNavSidebar( - listOf( - NavHead("Useful Links"), - NavLink("/about/pp", "Privacy Policy"), - NavLink("/about/tnc", "Terms and Conditions"), - ) - ) - ) { - section { - h1 { +"About Starship Fights" } - p { - +"Starship Fights is designed and programmed by the person behind " - a(href = "https://nationstates.net/mechyrdia") { +"Mechyrdia" } - +". He can be reached by telegram on NationStates, or by his " - a(href = "/user/${owner.id}") { +"account on this site" } - +"." - } - } - } -} - -suspend fun ApplicationCall.privacyPolicyPage(): HTML.() -> Unit { - return page( - "Privacy Policy", standardNavBar(), PageNavSidebar( - listOf( - NavHead("Useful Links"), - NavLink("/about", "About Starship Fights"), - NavLink("/about/tnc", "Terms and Conditions"), - ) - ) - ) { - section { - h1 { +"Privacy Policy" } - h2 { +"What Data Do We Collect" } - p { +"Starship Fights does not collect very much personal data; the only data it collects is relevant to either user authentication or user authorization. The following data is collected by the game:" } - dl { - dt { +"Discord ID" } - dd { +"This is needed to keep your Starship Fights user account associated with your Discord login, so that you can keep your admirals and ships when you log in." } - dt { +"Discord Profile Data (Name, Discriminator, Avatar)" } - dd { - +"This is kept so that you have the option of showing what your Discord account is on your profile page. It's optional to display to other users, with the choice being in the " - a(href = "/me/manage") { +"User Preferences" } - +" page. Note that we do " - strong { +"not" } - +" request or track email addresses." - } - dt { +"Your browser's User-Agent" } - dd { - +"This is associated with your session data as a layer of security, so that if someone were to (somehow) steal your session token and put it into their browser, that person wouldn't be logged in as you, since the User-Agent would probably be different." - } - dt { +"Your public-facing IP address (opt-in)" } - dd { - +"This is associated with your sessions, so that it may be displayed to you when you look at your currently logged-in sessions on your " - a(href = "/me/manage") { +"User Preferences" } - +" page, so that you can log out of a session if you don't recognize its IP address. You may opt in to the site's collection and storage of your IP address on that same page." - } - dt { +"The date and time of your last activity" } - dd { - +"This is associated with your user account as a whole, so that your Online/Offline status can be displayed. It's optional to display your current status, and the choice is in your " - a(href = "/me/manage") { +"User Preferences" } - +" page." - } - } - h2 { +"How Do We Collect It" } - p { - +"Your Discord information is collected using the Discord API whenever you log in via Discord's OAuth2. Your User-Agent and IP address are collected using the HTTP requests that your browser sends to the website, and the date and time of your last activity is tracked using the server's system clock." - } - h2 { +"Who Can See It" } - p { - +"The only people who can see the data we collect are you and the system administrator. We do not sell data to advertisers. The site is hosted on " - a(href = "https://hetzner.com/") { +"Hetzner Cloud" } - +", who can " - em { +"in theory" } - +" access it." - } - p { - +"Privacy policies are nice and all, but they're only as strong as the staff that implements them. I have no interest in abusing others, just as I have no interest in doxing or otherwise revealing what locations people log in from. Nor have I any interest in being worshipped as some kind of programmer-god messiah. I am impervious to such corrupting ambitions." - } - p { - +"All of the private data we collect can be viewed at your " - a(href = "/me/private-info") { +"Private Info" } - +" page." - } - h2 { +"Who Can't See It" } - p { - +"We protect your data by a combination of requiring TLS-secured HTTP connections, and keeping the database's port only open on 127.0.0.1, i.e. no one outside of the server's local machine can even connect to the database, much less access the data stored inside of it." - } - h2 { +"When Was This Written" } - dl { - dt { +"February 13, 2022" } - dd { +"Initial writing" } - dt { +"February 15, 2022" } - dd { +"Indicate that IP storage is an opt-in-only feature" } - dt { +"April 08, 2022" } - dd { +"Add link to Private Info page" } - } - } - } -} - -suspend fun ApplicationCall.termsAndConditionsPage(): HTML.() -> Unit { - val ownerDiscordUsername = CurrentConfiguration.discordClient?.ownerId?.let { - User.locate(User::discordId eq it) - }?.let { "${it.discordName}#${it.discordDiscriminator}" } - - return page( - "Terms and Conditions", standardNavBar(), PageNavSidebar( - listOf( - NavHead("Useful Links"), - NavLink("/about", "About Starship Fights"), - NavLink("/about/pp", "Privacy Policy"), - ) - ) - ) { - section { - h1 { +"Terms And Conditions" } - h2 { +"Section I - Privacy Policy" } - p { - +"By agreeing to these Terms and Conditions, you confirm that you have read and acknowledged the Privacy Policy of Starship Fights, accessible at " - a(href = "https://starshipfights.net/about/pp") { +"https://starshipfights.net/about/pp" } - +"." - } - h2 { +"Section II - Limitation of Liability" } - p { - +"UNDER NO CIRCUMSTANCES will Starship Fights be liable or responsible to either its users or any third party for any damages or injuries sustained as a result of using this website." - } - h2 { +"Section III - Termination" } - p { - +"Starship Fights may terminate your usage if:" - } - ol { - li { +"You are in breach of these Terms and Conditions." } - li { +"You, at any point, inflict abuse upon the website, including but not limited to: DDoS attacks, vulnerability scanning, vulnerability exploitation, etc." } - li { +"For any reason, at our sole discretion." } - } - h2 { +"Section IV - Amendment Process" } - p { - +"Starship Fights will notify users when amendments to the Terms and Conditions will impact their usage of their site." - CurrentConfiguration.discordClient?.serverInvite?.let { invite -> - +" Users will be notified via the " - a(href = "https://discord.gg/$invite") { +"Starship Fights Discord server" } - +"." - } - } - h2 { +"Section V - Amendments" } - dl { - dt { +"March 11, 2022" } - dd { +"Initial writing" } - } - ownerDiscordUsername?.let { - h2 { +"Section VI - Contact" } - p { - +"The operator of Starship Fights may be contacted via Discord at $it, or via telegram to " - a(href = "https://nationstates.net/mechyrdia") { +"his NationStates account" } - +"." - } - } - } - } -} - -suspend fun ApplicationCall.newUsersPage(): HTML.() -> Unit { - val newUsers = User.sorted(descending(User::registeredAt)).take(20).toList() - - return page("New Users", standardNavBar()) { - section { - h1 { +"New Users" } - div { - style = "text-align:center" - for (newUser in newUsers) { - div { - style = "display:inline-block;width:20%;padding:2%" - a(href = "/user/${newUser.id}") { - img(src = newUser.discordAvatarUrl) { - style = "width:100%;border-radius:50%" - } - } - p { - style = "text-align:center" - a(href = "/user/${newUser.id}") { - +newUser.profileName - } - } - } - } - } - } - } -} diff --git a/src/jvmMain/kotlin/starshipfights/info/views_ships.kt b/src/jvmMain/kotlin/starshipfights/info/views_ships.kt deleted file mode 100644 index b162d9a..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/views_ships.kt +++ /dev/null @@ -1,228 +0,0 @@ -package starshipfights.info - -import io.ktor.application.* -import kotlinx.html.* -import starshipfights.game.* -import kotlin.math.PI -import kotlin.math.roundToInt - -private val shipsPageSidebar: PageNavSidebar - get() = PageNavSidebar( - listOf(NavHead("Jump to Faction")) + Faction.values().map { faction -> - NavLink("#${faction.toUrlSlug()}", faction.polityName) - } - ) - -suspend fun ApplicationCall.shipsPage(): HTML.() -> Unit = page("Strategema Nauticum", standardNavBar(), shipsPageSidebar) { - section { - h1 { - foreign("la") { +"Strategema Nauticum" } - } - p { - +"Here you will find an index of all ship classes in Starship Fights, with links to pages that show ship stats and appearances." - } - } - for ((faction, factionShipTypes) in ShipType.values().groupBy { it.faction }.toSortedMap()) { - section { - id = faction.toUrlSlug() - - h2 { +faction.polityName } - - p { - style = "text-align:center" - img(src = faction.flagUrl, alt = "Flag of ${faction.getDefiniteShortName()}") { - style = "width:40%" - } - } - - faction.blurbDesc(consumer) - - for ((weightClass, weightedShipTypes) in factionShipTypes.groupBy { it.weightClass }.toSortedMap(Comparator.comparingInt(ShipWeightClass::tier))) { - h3 { +weightClass.displayName } - ul { - for (shipType in weightedShipTypes) { - li { - a(href = "/info/${shipType.toUrlSlug()}") { - +shipType.fullDisplayName - +" (${shipType.pointCost} points)" - } - } - } - } - } - } - } -} - -suspend fun ApplicationCall.shipPage(shipType: ShipType): HTML.() -> Unit = page( - shipType.fullerDisplayName, - standardNavBar(), - ShipViewSidebar(shipType) -) { - section { - h1 { +shipType.fullDisplayName } - - p { +Entities.nbsp } - - table { - tr { - th { +"Weight Class" } - th { +"Hull Integrity" } - th { +"Defense Turrets" } - } - tr { - td { - +shipType.weightClass.displayName - br - +"(${shipType.pointCost} points to deploy)" - } - td { - +"${shipType.weightClass.durability.maxHullPoints} impacts" - br - +"${shipType.weightClass.durability.troopsDefense} troops" - } - td { - when (val durability = shipType.weightClass.durability) { - is StandardShipDurability -> +"${durability.turretDefense.toPercent()} fighter-wing equivalent" - is FelinaeShipDurability -> { - span { - style = "font-style:italic" - +"Felinae Felices ships do not use turrets" - } - br - br - +"Disruption Pulse can wipe out strike craft up to ${durability.disruptionPulseRange} meters away up to ${durability.disruptionPulseShots} times" - } - } - } - } - tr { - th { +"Max Movement" } - th { +"Reactor Power" } - th { +"Energy Flow" } - } - tr { - when (val movement = shipType.weightClass.movement) { - is StandardShipMovement -> td { - +"Accelerate ${movement.moveSpeed.roundToInt()} meters/turn" - br - +"Rotate ${(movement.turnAngle * 180.0 / PI).roundToInt()} degrees/turn" - } - is FelinaeShipMovement -> td { - +"Accelerate ${movement.moveSpeed.roundToInt()} meters/turn" - br - +"Rotate ${(movement.turnAngle * 180.0 / PI).roundToInt()} degrees/turn" - br - br - +"Inertialess Drive can jump up to ${movement.inertialessDriveRange} meters up to ${movement.inertialessDriveShots} times" - } - } - - when (val reactor = shipType.weightClass.reactor) { - is StandardShipReactor -> { - td { - +reactor.powerOutput.toString() - br - +"(${reactor.subsystemAmount} per subsystem)" - } - td { - +reactor.gridEfficiency.toString() - } - } - FelinaeShipReactor -> { - td { - colSpan = "2" - style = "font-style:italic" - +"Felinae Felices ships use hyper-technologically-advanced super-reactors that need not concern themselves with \"power output\" or \"grid efficiency\"." - } - } - } - } - tr { - th { +"Base Crit Chance" } - th { +"Cannon Targeting" } - th { +"Lance Efficiency" } - } - tr { - td { - +shipType.weightClass.firepower.criticalChance.toPercent() - } - td { - +shipType.weightClass.firepower.cannonAccuracy.toPercent() - } - td { - if (shipType.weightClass.firepower.lanceCharging < 0.0) - +"N/A" - else - +shipType.weightClass.firepower.lanceCharging.toPercent() - } - } - } - table { - tr { - th { +"Armament" } - th { +"Firing Arcs" } - th { +"Range" } - th { +"Firepower" } - } - - for ((label, weapons) in shipType.armaments.values.groupBy { it.groupLabel }) { - val weapon = weapons.distinct().single() - val numShots = weapons.sumOf { it.numShots } - - tr { - td { +label } - td { - +if (weapon is AreaWeapon && weapon.isLine) { - "Linear (Fore-firing)" - } else if (weapon is ShipWeapon.Hangar) { - "(Omnidirectional)" - } else { - weapon.firingArcs.joinToString { arc -> arc.displayName } - } - } - td { - val weaponRangeMult = when (weapon) { - is ShipWeapon.Cannon -> shipType.weightClass.firepower.rangeMultiplier - is ShipWeapon.Lance -> shipType.weightClass.firepower.rangeMultiplier - is ShipWeapon.ParticleClawLauncher -> shipType.weightClass.firepower.rangeMultiplier - is ShipWeapon.LightningYarn -> shipType.weightClass.firepower.rangeMultiplier - else -> 1.0 - } - - weapon.minRange.takeIf { it != SHIP_BASE_SIZE }?.let { +"${it.roundToInt()}-" } - +"${(weapon.maxRange * weaponRangeMult).roundToInt()} meters" - if (weapon is AreaWeapon) { - br - +"${weapon.areaRadius.roundToInt()} meter impact radius" - } - } - td { - +when (weapon) { - is ShipWeapon.Cannon -> "$numShots cannon" + (if (numShots == 1) "" else "s") - is ShipWeapon.Lance -> "$numShots lance" + (if (numShots == 1) "" else "s") - is ShipWeapon.Torpedo -> "$numShots launcher" + (if (numShots == 1) "" else "s") - is ShipWeapon.Hangar -> "$numShots strike wing" + (if (numShots == 1) "" else "s") - is ShipWeapon.ParticleClawLauncher -> "$numShots particle claw launcher" + (if (numShots == 1) "" else "s") - is ShipWeapon.LightningYarn -> "$numShots lightning yarn launcher" + (if (numShots == 1) "" else "s") - ShipWeapon.MegaCannon -> "Severely damages targets" - ShipWeapon.RevelationGun -> "Vaporizes target" - ShipWeapon.EmpAntenna -> "Randomly depletes targets' subsystems" - } - } - } - } - } - - p { +Entities.nbsp } - - canvas { - style = "width:100%;height:25em" - attributes["data-model"] = shipType.meshName - } - - script { - unsafe { +"window.sfShipMeshViewer = true;" } - } - } -} diff --git a/src/jvmMain/kotlin/starshipfights/info/views_user.kt b/src/jvmMain/kotlin/starshipfights/info/views_user.kt deleted file mode 100644 index b032b60..0000000 --- a/src/jvmMain/kotlin/starshipfights/info/views_user.kt +++ /dev/null @@ -1,1043 +0,0 @@ -package starshipfights.info - -import io.ktor.application.* -import io.ktor.features.* -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.toList -import kotlinx.html.* -import org.litote.kmongo.and -import org.litote.kmongo.eq -import org.litote.kmongo.gt -import org.litote.kmongo.or -import starshipfights.auth.* -import starshipfights.data.Id -import starshipfights.data.admiralty.* -import starshipfights.data.auth.* -import starshipfights.forbid -import starshipfights.game.* -import starshipfights.redirect -import java.time.Instant - -suspend fun ApplicationCall.userPage(): HTML.() -> Unit { - val userId = Id(parameters["id"]!!) - val user = User.get(userId)!! - val currentUser = getUserSession() - - val isCurrentUser = user.id == currentUser?.user - val hasOpenSessions = UserSession.locate( - and(UserSession::user eq userId, UserSession::expiration gt Instant.now()) - ) != null - - val admirals = Admiral.filter(Admiral::owningUser eq user.id).toList() - - return page( - user.profileName, standardNavBar(), CustomSidebar { - if (user.showDiscordName) { - img(src = user.discordAvatarUrl) { - style = "border-radius:50%" - } - p { - style = "text-align:center" - +user.discordName - +"#" - +user.discordDiscriminator - } - } else { - img(src = user.anonymousAvatarUrl) { - style = "border-radius:50%" - } - } - for (trophy in user.getTrophies()) - renderTrophy(trophy) - - if (user.showUserStatus) { - p { - style = "text-align:center" - +when (user.status) { - UserStatus.IN_BATTLE -> "In Battle" - UserStatus.READY_FOR_BATTLE -> "In Battle" - UserStatus.IN_MATCHMAKING -> "In Matchmaking" - UserStatus.AVAILABLE -> if (hasOpenSessions) "Online" else "Offline" - } - } - p { - style = "text-align:center" - +"Registered at " - span(classes = "moment") { - style = "display:none" - +user.registeredAt.toEpochMilli().toString() - } - br - +"Last active at " - span(classes = "moment") { - style = "display:none" - +user.lastActivity.toEpochMilli().toString() - } - } - } - if (isCurrentUser) { - hr { style = "border-color:#036" } - div(classes = "list") { - div(classes = "item") { - a(href = "/admiral/new") { +"Create New Admiral" } - } - div(classes = "item") { - a(href = "/me/manage") { +"Edit Profile" } - } - } - } /*else if (currentUser != null) { - hr { style = "border-color:#036" } - div(classes = "list") { - div(classes = "item") { - a(href = "/user/${userId}/send") { +"Send Message" } - } - } - }*/ - } - ) { - section { - h1 { +user.profileName } - - for (paragraph in user.profileBio.split('\n')) - p { +paragraph } - } - section { - h2 { +"Admirals" } - - if (admirals.isNotEmpty()) { - p { - +"This user has the following admirals:" - } - ul { - for (admiral in admirals.sortedBy { it.name }.sortedBy { it.rank }.sortedBy { it.faction }) { - li { - a("/admiral/${admiral.id}") { +admiral.fullName } - } - } - } - } else - p { - +"This user has no admirals." - } - } - } -} - -suspend fun ApplicationCall.manageUserPage(): HTML.() -> Unit { - val currentSession = getUserSession() ?: redirect("/login") - val currentUser = User.get(currentSession.user) ?: redirect("/login") - val allUserSessions = UserSession.filter(and(UserSession::user eq currentUser.id)).toList() - - return page( - "User Preferences", standardNavBar(), PageNavSidebar( - listOf( - NavLink("/me", "Back to User Page") - ) - ) - ) { - section { - h1 { +"User Preferences" } - form(method = FormMethod.post, action = "/me/manage") { - csrfToken(currentSession.id) - h2 { - +"Profile" - } - h3 { - label { - htmlFor = "name" - +"Display Name" - } - } - textInput(name = "name") { - required = true - maxLength = "$PROFILE_NAME_MAX_LENGTH" - - value = currentUser.profileName - autoComplete = false - } - p { - style = "font-style:italic;font-size:0.8em;color:#555" - +"Max length $PROFILE_NAME_MAX_LENGTH characters" - } - h3 { - label { - htmlFor = "bio" - +"Public Bio" - } - } - textArea { - name = "bio" - style = "width: 100%;height:5em" - - required = true - maxLength = "$PROFILE_BIO_MAX_LENGTH" - - +currentUser.profileBio - } - h3 { - +"Display Theme" - } - p { - +"Clicking one of the options here will preview the selected theme. It is still necessary to click Accept Changes to keep your choice of theme." - } - label { - radioInput(name = "theme") { - id = "system-theme" - value = "system" - required = true - checked = currentUser.preferredTheme == PreferredTheme.SYSTEM - } - +Entities.nbsp - +"System Choice" - } - br - label { - radioInput(name = "theme") { - id = "light-theme" - value = "light" - required = true - checked = currentUser.preferredTheme == PreferredTheme.LIGHT - } - +Entities.nbsp - +"Light Theme" - } - br - label { - radioInput(name = "theme") { - id = "dark-theme" - value = "dark" - required = true - checked = currentUser.preferredTheme == PreferredTheme.DARK - } - +Entities.nbsp - +"Dark Theme" - } - h3 { - +"Privacy Settings" - } - label { - checkBoxInput { - name = "showdiscord" - checked = currentUser.showDiscordName - value = "yes" - } - +Entities.nbsp - +"Show Discord name" - } - br - label { - checkBoxInput { - name = "showstatus" - checked = currentUser.showUserStatus - value = "yes" - } - +Entities.nbsp - +"Show Online Status" - } - br - label { - checkBoxInput { - name = "logaddress" - checked = currentUser.logIpAddresses - value = "yes" - } - +Entities.nbsp - +"Log Session IP Addresses" - } - p { - +"Your private info can be viewed at the " - a(href = "/me/private-info") { +"Private Info" } - +" page." - } - request.queryParameters["error"]?.let { errorMsg -> - p { - style = "color:#d22" - +errorMsg - } - } - submitInput { - value = "Accept Changes" - } - } - script { - unsafe { +"window.sfThemeChoice = true;" } - } - } - section { - h2 { +"Logged-In Sessions" } - table { - tr { - th { +"User-Agent" } - if (currentUser.logIpAddresses) - th { +"Client IPs" } - th { +Entities.nbsp } - } - val now = Instant.now() - val expiredSessions = mutableListOf() - for (session in allUserSessions) { - if (session.expiration < now) { - expiredSessions += session - continue - } - - tr { - td { +session.userAgent } - if (currentUser.logIpAddresses) - td { - for ((i, clientAddress) in session.clientAddresses.withIndex()) { - if (i != 0) br - +clientAddress - } - } - td { - if (session.id == currentSession.id) { - +"Current Session" - br - } - a(href = "/logout/${session.id}") { - method = "post" - csrfToken(currentSession.id) - +"Logout" - } - } - } - } - tr { - td { - colSpan = if (currentUser.logIpAddresses) "3" else "2" - a(href = "/logout-all") { - method = "post" - csrfToken(currentSession.id) - +"Logout All Other Sessions" - } - } - } - for (session in expiredSessions) { - tr { - td { +session.userAgent } - if (currentUser.logIpAddresses) - td { - for ((i, clientAddress) in session.clientAddresses.withIndex()) { - if (i != 0) br - +clientAddress - } - } - td { - +"Expired at " - span(classes = "moment") { - style = "display:none" - +session.expiration.toEpochMilli().toString() - } - br - a(href = "/clear-expired/${session.id}") { - method = "post" - csrfToken(currentSession.id) - +"Clear" - } - } - } - } - if (expiredSessions.isNotEmpty()) - tr { - td { - colSpan = if (currentUser.logIpAddresses) "3" else "2" - a(href = "/clear-all-expired") { - method = "post" - csrfToken(currentSession.id) - +"Clear All Expired Sessions" - } - } - } - } - } - } -} - -suspend fun ApplicationCall.createAdmiralPage(): HTML.() -> Unit { - val sessionId = getUserSession()?.id ?: redirect("/login") - - return page( - "Creating Admiral", standardNavBar(), null - ) { - section { - h1 { +"Creating Admiral" } - form(method = FormMethod.post, action = "/admiral/new") { - csrfToken(sessionId) - h3 { - label { - htmlFor = "faction" - +"Faction" - } - } - p { - for (faction in Faction.values()) { - val factionId = "faction-${faction.toUrlSlug()}" - label { - htmlFor = factionId - radioInput(name = "faction") { - id = factionId - value = faction.name - required = true - if (faction == Faction.FELINAE_FELICES) - attributes["data-force-gender"] = "female" - } - img(src = faction.flagUrl) { - style = "height:0.75em;width:1.2em" - } - +Entities.nbsp - +faction.shortName - } - br - } - } - h3 { - label { - htmlFor = "name" - +"Name" - } - } - textInput(name = "name") { - id = "name" - - autoComplete = false - required = true - maxLength = "$ADMIRAL_NAME_MAX_LENGTH" - } - p { - label { - htmlFor = "sex-male" - radioInput(name = "sex") { - id = "sex-male" - value = "male" - required = true - } - +"Male" - } - label { - htmlFor = "sex-female" - radioInput(name = "sex") { - id = "sex-female" - value = "female" - required = true - } - +"Female" - } - } - h3 { +"Generate Random Name" } - p { - for ((i, flavor) in AdmiralNameFlavor.values().withIndex()) { - if (i != 0) - br - a(href = "#", classes = "generate-admiral-name") { - attributes["data-flavor"] = flavor.toUrlSlug() - +flavor.displayName - } - } - } - submitInput { - value = "Create Admiral" - } - } - script { - unsafe { +"window.sfAdmiralNameGen = true; window.sfFactionSelect = true;" } - } - } - } -} - -suspend fun ApplicationCall.admiralPage(): HTML.() -> Unit { - val currentUser = getUserSession()?.user - val admiralId = parameters["id"]?.let { Id(it) }!! - val admiral = Admiral.get(admiralId)!! - val (ships, graveyard, records) = coroutineScope { - val ships = async { ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() } - val graveyard = async { ShipMemorial.filter(ShipMemorial::owningAdmiral eq admiralId).toList() } - val records = async { BattleRecord.filter(or(BattleRecord::hostAdmiral eq admiralId, BattleRecord::guestAdmiral eq admiralId)).toList() } - - Triple(ships.await(), graveyard.await(), records.await()) - } - - val recordRoles = records.mapNotNull { - when (admiralId) { - it.hostAdmiral -> GlobalSide.HOST - it.guestAdmiral -> GlobalSide.GUEST - else -> null - }?.let { role -> it.id to role } - }.toMap() - - val recordOpponents = coroutineScope { - records.mapNotNull { - recordRoles[it.id]?.let { role -> - val aId = when (role) { - GlobalSide.HOST -> it.guestAdmiral - GlobalSide.GUEST -> it.hostAdmiral - } - it.id to async { Admiral.get(aId) } - } - }.mapNotNull { (id, deferred) -> - deferred.await()?.let { id to it } - }.toMap() - } - - return page( - admiral.fullName, standardNavBar(), PageNavSidebar( - listOf( - NavLink("/user/${admiral.owningUser}", "Back to User") - ) + if (currentUser == admiral.owningUser) - listOf( - NavLink("/admiral/${admiral.id}/manage", "Manage Admiral") - ) - else emptyList() - ) - ) { - section { - h1 { +admiral.name } - p { - b { +admiral.fullName } - +" is a flag officer of the " - +admiral.faction.navyName - +". " - +(if (admiral.isFemale) "She" else "He") - +" controls the following ships:" - } - - table { - tr { - th { +"Ship Name" } - th { +"Ship Class" } - th { +"Ship Status" } - } - - val now = Instant.now() - for (ship in ships.sortedBy { it.name }.sortedBy { it.shipType.weightClass.tier }) { - tr { - td { +ship.shipData.fullName } - td { - a(href = "/info/${ship.shipData.shipType.toUrlSlug()}") { - +ship.shipData.shipType.fullDisplayName - } - } - td { - val shipReadyAt = ship.readyAt - if (shipReadyAt <= now) { - +"Ready" - br - +"(since " - span(classes = "moment") { - style = "display:none" - +shipReadyAt.toEpochMilli().toString() - } - +")" - } else { - +"Will be ready at " - span(classes = "moment") { - style = "display:none" - +shipReadyAt.toEpochMilli().toString() - } - } - } - } - } - } - h2 { +"Lost Ships' Memorial" } - p { - +"The following ships were lost under " - +(if (admiral.isFemale) "her" else "his") - +" command:" - } - table { - tr { - th { +"Ship Name" } - th { +"Ship Class" } - th { +Entities.nbsp } - } - - for (ship in graveyard.sortedBy { it.name }.sortedBy { it.shipType.weightClass.tier }) { - tr { - td { +ship.fullName } - td { - a(href = "/info/${ship.shipType.toUrlSlug()}") { - +ship.shipType.fullDisplayName - } - } - td { - +"Destroyed by " - val opponent = recordOpponents[ship.destroyedIn] - if (opponent == null) - i { +"(Deleted Admiral)" } - else - a(href = "/admiral/${opponent.id}") { - +opponent.fullName - } - br - br - +"Destroyed at " - span(classes = "moment") { - style = "display:none" - +ship.destroyedAt.toEpochMilli().toString() - } - } - } - } - } - } - section { - h2 { +"Valor" } - p { - +"This admiral has fought in the following battles:" - } - table { - tr { - th { +"When" } - th { +"Size" } - th { +"Role" } - th { +"Against" } - th { +"Result" } - } - for (record in records.sortedBy { it.whenEnded }) { - tr { - td { - +"Started at " - span(classes = "moment") { - style = "display:none" - +record.whenStarted.toEpochMilli().toString() - } - br - +"Ended at " - span(classes = "moment") { - style = "display:none" - +record.whenEnded.toEpochMilli().toString() - } - } - td { - +record.battleInfo.size.displayName - +" (" - +record.battleInfo.size.numPoints.toString() - +")" - } - td { - +when (recordRoles[record.id]) { - GlobalSide.HOST -> "Host" - GlobalSide.GUEST -> "Guest" - else -> "N/A" - } - } - td { - val opponent = recordOpponents[record.id] - if (opponent == null) - i { +"(Deleted Admiral)" } - else - a(href = "/admiral/${opponent.id}") { - +opponent.fullName - } - } - td { - +when (recordRoles[record.id]) { - GlobalSide.HOST -> record.hostEndingMessage - GlobalSide.GUEST -> record.guestEndingMessage - else -> "N/A" - } - } - } - } - } - } - } -} - -suspend fun ApplicationCall.manageAdmiralPage(): HTML.() -> Unit { - val currentSession = getUserSession() ?: redirect("/login") - val currentUser = currentSession.user - - val admiralId = parameters["id"]?.let { Id(it) }!! - val admiral = Admiral.get(admiralId)!! - - if (admiral.owningUser != currentUser) forbid() - - val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() - val buyableShips = ShipType.values() - .mapNotNull { type -> type.buyPriceChecked(admiral, ownedShips)?.let { price -> type to price } } - .sortedBy { (_, price) -> price } - .sortedBy { (type, _) -> type.name } - .sortedBy { (type, _) -> type.weightClass.tier } - .sortedBy { (type, _) -> if (type.faction == admiral.faction) -1 else type.faction.ordinal } - .toMap() - - return page( - "Managing ${admiral.name}", standardNavBar(), PageNavSidebar( - listOf( - NavLink("/admiral/${admiral.id}", "Back to Admiral") - ) - ) - ) { - section { - h1 { +"Managing ${admiral.name}" } - request.queryParameters["error"]?.let { errorMsg -> - p { - style = "color:#d22" - +errorMsg - } - } - form(method = FormMethod.post, action = "/admiral/${admiral.id}/manage") { - csrfToken(currentSession.id) - h3 { - label { - htmlFor = "name" - +"Name" - } - } - textInput(name = "name") { - id = "name" - autoComplete = false - - required = true - value = admiral.name - maxLength = "$ADMIRAL_NAME_MAX_LENGTH" - } - if (admiral.faction == Faction.FELINAE_FELICES) - p { - style = "font-size:0.8em;font-style:italic;color:#555" - checkBoxInput { - style = "display:none" - id = "sex-female" - checked = true - } - +"The Felinae Felices are a female-only faction." - } - else - p { - label { - htmlFor = "sex-male" - radioInput(name = "sex") { - id = "sex-male" - value = "male" - required = true - checked = !admiral.isFemale - } - +"Male" - } - label { - htmlFor = "sex-female" - radioInput(name = "sex") { - id = "sex-female" - value = "female" - required = true - checked = admiral.isFemale - } - +"Female" - } - } - h3 { +"Generate Random Name" } - p { - for ((i, flavor) in AdmiralNameFlavor.values().withIndex()) { - if (i != 0) - br - a(href = "#", classes = "generate-admiral-name") { - attributes["data-flavor"] = flavor.toUrlSlug() - +flavor.displayName - } - } - } - script { - unsafe { +"window.sfAdmiralNameGen = true;" } - } - submitInput { - value = "Submit Changes" - } - } - form(method = FormMethod.get, action = "/admiral/${admiral.id}/delete") { - submitInput(classes = "evil") { - value = "Delete this Admiral" - } - } - } - section { - val currRank = admiral.rank - if (currRank.ordinal < AdmiralRank.values().size - 1) { - val nextRank = AdmiralRank.values()[currRank.ordinal + 1] - val reqAcumen = nextRank.minAcumen - currRank.minAcumen - val hasAcumen = admiral.acumen - currRank.minAcumen - - label { - h2 { +"Progress to Promotion" } - progress { - style = "width:100%;box-sizing:border-box" - max = "$reqAcumen" - value = "$hasAcumen" - +"$hasAcumen/$reqAcumen" - } - } - p { - +"${admiral.fullName} is $hasAcumen/$reqAcumen Acumen away from being promoted to ${nextRank.getDisplayName(admiral.faction)}." - } - } else { - h2 { +"Progress to Promotion" } - p { - +"${admiral.fullName} is at the maximum rank possible for the ${admiral.faction.navyName}." - } - } - } - section { - h2 { +"Manage Fleet" } - p { - +"${admiral.fullName} currently owns ${admiral.money} ${admiral.faction.currencyName}, and earns ${admiral.rank.dailyWage} ${admiral.faction.currencyName} every day." - } - table { - tr { - th { +"Ship Name" } - th { +"Ship Class" } - th { +"Ship Status" } - th { +"Ship Value" } - } - - val now = Instant.now() - for (ship in ownedShips.sortedBy { it.name }.sortedBy { it.shipType.weightClass.tier }) { - tr { - td { - +ship.shipData.fullName - br - a(href = "/admiral/${admiralId}/rename/${ship.id}") { +"Rename" } - } - td { - a(href = "/info/${ship.shipData.shipType.toUrlSlug()}") { - +ship.shipData.shipType.fullDisplayName - } - } - td { - val shipReadyAt = ship.readyAt - if (shipReadyAt <= now) { - +"Ready" - br - +"(since " - span(classes = "moment") { - style = "display:none" - +shipReadyAt.toEpochMilli().toString() - } - +")" - } else { - +"Will be ready at " - span(classes = "moment") { - style = "display:none" - +shipReadyAt.toEpochMilli().toString() - } - } - } - td { - +ship.shipType.sellPrice.toString() - +" " - +admiral.faction.currencyName - if (ship.readyAt <= now && !ship.shipType.weightClass.isUnique) { - br - a(href = "/admiral/${admiralId}/sell/${ship.id}") { +"Sell" } - } - } - } - } - } - h3 { +"Buy New Ship" } - table { - tr { - th { +"Ship Class" } - th { +"Ship Cost" } - } - for ((st, price) in buyableShips) { - tr { - td { - a(href = "/info/${st.toUrlSlug()}") { +st.fullDisplayName } - } - td { - +price.toString() - +" " - +admiral.faction.currencyName - br - a(href = "/admiral/${admiralId}/buy/${st.toUrlSlug()}") { +"Buy" } - } - } - } - } - } - } -} - -suspend fun ApplicationCall.renameShipPage(): HTML.() -> Unit { - val currentSession = getUserSession() ?: redirect("/login") - val currentUser = currentSession.user - - val admiralId = parameters["id"]?.let { Id(it) }!! - val shipId = parameters["ship"]?.let { Id(it) }!! - - val (admiral, ship) = coroutineScope { - val admiral = async { Admiral.get(admiralId)!! } - val ship = async { ShipInDrydock.get(shipId)!! } - admiral.await() to ship.await() - } - - if (admiral.owningUser != currentUser) forbid() - if (ship.owningAdmiral != admiralId) forbid() - - return page("Renaming Ship", null, null) { - section { - h1 { +"Renaming Ship" } - p { - +"${admiral.fullName} is about to rename the ${ship.shipType.fullDisplayName} ${ship.shipData.fullName}. Choose a name here:" - } - form(method = FormMethod.post, action = "/admiral/${admiral.id}/rename/${ship.id}") { - csrfToken(currentSession.id) - textInput(name = "name") { - id = "name" - value = ship.name - - autoComplete = false - required = true - - maxLength = "$SHIP_NAME_MAX_LENGTH" - } - p { - style = "font-style:italic;font-size:0.8em;color:#555" - +"Max length $SHIP_NAME_MAX_LENGTH characters" - } - submitInput { - value = "Rename" - } - } - form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { - submitInput { - value = "Cancel" - } - } - } - } -} - -suspend fun ApplicationCall.sellShipConfirmPage(): HTML.() -> Unit { - val currentSession = getUserSession() ?: redirect("/login") - val currentUser = currentSession.user - - val admiralId = parameters["id"]?.let { Id(it) }!! - val shipId = parameters["ship"]?.let { Id(it) }!! - - val (admiral, ship) = coroutineScope { - val admiral = async { Admiral.get(admiralId)!! } - val ship = async { ShipInDrydock.get(shipId)!! } - admiral.await() to ship.await() - } - - if (admiral.owningUser != currentUser) forbid() - if (ship.owningAdmiral != admiralId) forbid() - - if (ship.readyAt > Instant.now()) redirect("/admiral/${admiralId}/manage") - if (ship.shipType.weightClass.isUnique) redirect("/admiral/${admiralId}/manage") - - return page( - "Are You Sure?", null, null - ) { - section { - h1 { +"Are You Sure?" } - p { - +"${admiral.fullName} is about to sell the ${ship.shipType.fullDisplayName} ${ship.shipData.fullName} for ${ship.shipType.sellPrice} ${admiral.faction.currencyName}." - } - form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { - submitInput { - value = "Cancel" - } - } - form(method = FormMethod.post, action = "/admiral/${admiral.id}/sell/${ship.id}") { - csrfToken(currentSession.id) - submitInput { - value = "Sell" - } - } - } - } -} - -suspend fun ApplicationCall.buyShipConfirmPage(): HTML.() -> Unit { - val currentSession = getUserSession() ?: redirect("/login") - val currentUser = currentSession.user - - val admiralId = parameters["id"]?.let { Id(it) }!! - val admiral = Admiral.get(admiralId)!! - - if (admiral.owningUser != currentUser) forbid() - - val shipType = parameters["ship"]?.let { param -> ShipType.values().singleOrNull { it.toUrlSlug() == param } }!! - - if (shipType.buyPrice > admiral.money) { - return page( - "Too Expensive", null, null - ) { - section { - h1 { +"Too Expensive" } - p { - +"Unfortunately, the ${shipType.fullDisplayName} is out of ${admiral.fullName}'s budget. It costs ${shipType.buyPrice} ${admiral.faction.currencyName}, and ${admiral.name} only has ${admiral.money} ${admiral.faction.currencyName}." - } - form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { - submitInput { - value = "Back" - } - } - } - } - } - - val ownedShips = ShipInDrydock.filter(ShipInDrydock::owningAdmiral eq admiralId).toList() - if (shipType.buyPriceChecked(admiral, ownedShips) == null) - throw NotFoundException() - - return page( - "Are You Sure?", null, null - ) { - section { - h1 { +"Are You Sure?" } - p { - +"${admiral.fullName} is about to buy a ${shipType.fullDisplayName} for ${shipType.buyPrice} ${admiral.faction.currencyName}." - } - form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { - submitInput { - value = "Cancel" - } - } - form(method = FormMethod.post, action = "/admiral/${admiral.id}/buy/${shipType.toUrlSlug()}") { - csrfToken(currentSession.id) - submitInput { - value = "Checkout" - } - } - } - } -} - -suspend fun ApplicationCall.deleteAdmiralConfirmPage(): HTML.() -> Unit { - val currentSession = getUserSession() ?: redirect("/login") - val currentUser = currentSession.user - - val admiralId = parameters["id"]?.let { Id(it) }!! - val admiral = Admiral.get(admiralId)!! - - if (admiral.owningUser != currentUser) forbid() - - return page( - "Are You Sure?", null, null - ) { - section { - h1 { +"Are You Sure?" } - p { - +"Are you sure you want to delete " - +admiral.fullName - +"? Deletion cannot be undone!" - } - form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") { - submitInput { - value = "No" - } - } - form(method = FormMethod.post, action = "/admiral/${admiral.id}/delete") { - csrfToken(currentSession.id) - submitInput(classes = "evil") { - value = "Yes" - } - } - } - } -} diff --git a/src/jvmMain/kotlin/starshipfights/server.kt b/src/jvmMain/kotlin/starshipfights/server.kt deleted file mode 100644 index 370cd38..0000000 --- a/src/jvmMain/kotlin/starshipfights/server.kt +++ /dev/null @@ -1,170 +0,0 @@ -@file:JvmName("Server") - -package starshipfights - -import io.ktor.application.* -import io.ktor.features.* -import io.ktor.html.* -import io.ktor.http.* -import io.ktor.http.content.* -import io.ktor.request.* -import io.ktor.response.* -import io.ktor.routing.* -import io.ktor.server.engine.* -import io.ktor.server.netty.* -import io.ktor.util.* -import io.ktor.websocket.* -import org.slf4j.event.Level -import starshipfights.auth.AuthProvider -import starshipfights.data.ConnectionHolder -import starshipfights.data.DataRoutines -import starshipfights.game.installGame -import starshipfights.info.* -import java.io.InputStream -import java.util.concurrent.atomic.AtomicLong - -object ResourceLoader { - fun getResource(resource: String): InputStream? = javaClass.getResourceAsStream(resource) - - val SHA256AttributeKey = AttributeKey("SHA256Hash") -} - -fun main() { - System.setProperty("logback.statusListenerClass", "ch.qos.logback.core.status.NopStatusListener") - - System.setProperty("io.ktor.development", if (CurrentConfiguration.isDevEnv) "true" else "false") - - ConnectionHolder.initialize(CurrentConfiguration.dbConn, CurrentConfiguration.dbName) - - val dataRoutines = DataRoutines.initializeRoutines() - - embeddedServer(Netty, port = CurrentConfiguration.port, host = CurrentConfiguration.host) { - install(IgnoreTrailingSlash) - install(XForwardedHeaderSupport) - - install(CallId) { - val counter = AtomicLong(0) - generate { - "call-${counter.incrementAndGet().toULong()}-${System.currentTimeMillis()}" - } - } - - install(CallLogging) { - level = Level.INFO - - callIdMdc("ktor-call-id") - - format { call -> - "Call #${call.callId} Client ${call.request.origin.remoteHost} `${call.request.userAgent()}` Request ${call.request.httpMethod.value} ${call.request.uri} Response ${call.response.status()}" - } - } - - install(ConditionalHeaders) { - version { outgoingContent -> - outgoingContent.getProperty(ResourceLoader.SHA256AttributeKey)?.let { hash -> - listOf(EntityTagVersion(hash)) - }.orEmpty() - } - } - - install(StatusPages) { - status(HttpStatusCode.NotFound) { - call.respondHtml(HttpStatusCode.NotFound, call.error404()) - } - - exception { (url, permanent) -> - call.respondRedirect(url, permanent) - } - exception { - call.respondHtml(HttpStatusCode.BadRequest, call.error400()) - } - exception { - call.respondHtml(HttpStatusCode.Forbidden, call.error403()) - } - exception { - call.respondHtml(HttpStatusCode.Forbidden, call.error403InvalidCsrf()) - } - exception { - call.respondHtml(HttpStatusCode.NotFound, call.error404()) - } - exception { - call.respondHtml(HttpStatusCode.TooManyRequests, call.error429()) - } - - exception { - call.respondHtml(HttpStatusCode.InternalServerError, call.error503()) - throw it - } - } - - install(WebSockets) { - pingPeriodMillis = 500L - } - - if (CurrentConfiguration.isDevEnv) { - install(ShutDownUrl.ApplicationCallFeature) { - shutDownUrl = "/dev/shutdown" - exitCodeSupplier = { 0 } - } - } - - AuthProvider.install(this) - - routing { - installPages() - installGame() - - static("/static") { - // I HAVE TO DO THIS MANUALLY - // BECAUSE KTOR DOESN'T SUPPORT - // PRE-COMPRESSED STATIC JAR RESOURCES - // FOR SOME UNGODLY REASON - get("{static-content...}") { - val staticContentPath = call.parameters.getAll("static-content")?.joinToString("/") ?: return@get - val contentPath = "/static/$staticContentPath" - - val hashContentPath = "$contentPath.sha256" - val sha256Hash = ResourceLoader.getResource(hashContentPath)?.reader()?.readText() - val configureContent: OutgoingContent.() -> Unit = { setProperty(ResourceLoader.SHA256AttributeKey, sha256Hash) } - - val brContentPath = "$contentPath.br" - val gzContentPath = "$contentPath.gz" - - val contentType = ContentType.fromFileExtension(contentPath.substringAfterLast('.')).firstOrNull() - - val acceptedEncodings = call.request.acceptEncodingItems().map { it.value }.toSet() - - if (CompressedFileType.BROTLI.encoding in acceptedEncodings) { - val brContent = ResourceLoader.getResource(brContentPath) - if (brContent != null) { - call.attributes.put(Compression.SuppressionAttribute, true) - - call.response.header(HttpHeaders.ContentEncoding, CompressedFileType.BROTLI.encoding) - - call.respondBytes(brContent.readBytes(), contentType, configure = configureContent) - - return@get - } - } - - if (CompressedFileType.GZIP.encoding in acceptedEncodings) { - val gzContent = ResourceLoader.getResource(gzContentPath) - if (gzContent != null) { - call.attributes.put(Compression.SuppressionAttribute, true) - - call.response.header(HttpHeaders.ContentEncoding, CompressedFileType.GZIP.encoding) - - call.respondBytes(gzContent.readBytes(), contentType, configure = configureContent) - - return@get - } - } - - ResourceLoader.getResource(contentPath)?.let { call.respondBytes(it.readBytes(), contentType, configure = configureContent) } - } - } - } - }.start(wait = true) - - dataRoutines.cancel() -} diff --git a/src/jvmMain/kotlin/starshipfights/server_conf.kt b/src/jvmMain/kotlin/starshipfights/server_conf.kt deleted file mode 100644 index d96abf4..0000000 --- a/src/jvmMain/kotlin/starshipfights/server_conf.kt +++ /dev/null @@ -1,66 +0,0 @@ -package starshipfights - -import io.ktor.util.* -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import starshipfights.data.ConnectionType -import java.io.File -import java.security.SecureRandom - -@Serializable -data class Configuration( - val isDevEnv: Boolean = true, - - val host: String = "127.0.0.1", - val port: Int = 8080, - - val dbConn: ConnectionType = ConnectionType.Embedded(), - val dbName: String = "sf", - - val secretHashingKey: String = hex( - ByteArray(16).also { SecureRandom.getInstanceStrong().nextBytes(it) } - ), - val discordClient: DiscordLogin? = null -) - -@Serializable -data class DiscordLogin( - val userAgent: String, - - val clientId: String, - val clientSecret: String, - - val ownerId: String, - val serverInvite: String, -) - -private val DEFAULT_CONFIG = Configuration() - -private var currentConfig: Configuration? = null - -val CurrentConfiguration: Configuration - get() { - currentConfig?.let { return it } - - val file = File(System.getProperty("starshipfights.configpath", "./config.json")) - if (!file.isFile) { - if (file.exists()) - file.deleteRecursively() - - val json = JsonConfigCodec.encodeToString(Configuration.serializer(), DEFAULT_CONFIG) - file.writeText(json, Charsets.UTF_8) - return DEFAULT_CONFIG - } - - val json = file.readText() - return JsonConfigCodec.decodeFromString(Configuration.serializer(), json).also { currentConfig = it } - } - -@OptIn(ExperimentalSerializationApi::class) -val JsonConfigCodec = Json { - prettyPrint = true - prettyPrintIndent = "\t" - - useAlternativeNames = false -} diff --git a/src/jvmMain/kotlin/starshipfights/server_utils.kt b/src/jvmMain/kotlin/starshipfights/server_utils.kt deleted file mode 100644 index 87574c2..0000000 --- a/src/jvmMain/kotlin/starshipfights/server_utils.kt +++ /dev/null @@ -1,17 +0,0 @@ -package starshipfights - -open class ForbiddenException : IllegalArgumentException() - -fun forbid(): Nothing = throw ForbiddenException() - -class InvalidCsrfTokenException : ForbiddenException() - -fun invalidCsrfToken(): Nothing = throw InvalidCsrfTokenException() - -data class HttpRedirectException(val url: String, val permanent: Boolean) : RuntimeException() - -fun redirect(url: String, permanent: Boolean = false): Nothing = throw HttpRedirectException(url, permanent) - -class RateLimitException : RuntimeException() - -fun rateLimit(): Nothing = throw RateLimitException() diff --git a/src/jvmTest/kotlin/net/starshipfights/game/ai/AITesting.kt b/src/jvmTest/kotlin/net/starshipfights/game/ai/AITesting.kt new file mode 100644 index 0000000..61612a0 --- /dev/null +++ b/src/jvmTest/kotlin/net/starshipfights/game/ai/AITesting.kt @@ -0,0 +1,253 @@ +package net.starshipfights.game.ai + +import kotlinx.coroutines.Job +import kotlinx.coroutines.runBlocking +import kotlinx.html.* +import kotlinx.html.stream.createHTML +import net.starshipfights.game.BattleSize +import net.starshipfights.game.Faction +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import javax.swing.JOptionPane +import javax.swing.UIManager +import kotlin.concurrent.thread + +object AITesting { + @JvmStatic + fun main(args: Array) { + UIManager.setLookAndFeel("javax.swing.plaf.nimbus.NimbusLookAndFeel") + + val instinctVectorCounts = listOf(5, 11, 17) + val instinctVectorOptions = instinctVectorCounts.map { it.toString() }.toTypedArray() + + val instinctVectorIndex = JOptionPane.showOptionDialog( + null, "Please select the number of Instinct vectors to generate", + "Generate Instinct Vectors", JOptionPane.DEFAULT_OPTION, + JOptionPane.QUESTION_MESSAGE, null, + instinctVectorOptions, instinctVectorOptions[0] + ) + + if (instinctVectorIndex == JOptionPane.CLOSED_OPTION) return + + val instinctVectorCount = instinctVectorCounts[instinctVectorIndex] + val instinctVectors = genInstinctCandidates(instinctVectorCount) + + val numTrialCounts = listOf(3, 5, 7, 10, 25) + val numTrialOptions = numTrialCounts.map { it.toString() }.toTypedArray() + + val numTrialIndex = JOptionPane.showOptionDialog( + null, "Please select the number of trials to execute per instinct pairing", + "Number of Trials", JOptionPane.DEFAULT_OPTION, + JOptionPane.QUESTION_MESSAGE, null, + numTrialOptions, numTrialOptions[0] + ) + + if (numTrialIndex == JOptionPane.CLOSED_OPTION) return + + val numTrials = numTrialCounts[numTrialIndex] + + val allowedBattleSizeChoices = BattleSize.values().map { setOf(it) } + listOf(BattleSize.values().toSet()) + val allowedBattleSizeOptions = allowedBattleSizeChoices.map { it.singleOrNull()?.displayName ?: "Allow Any" }.toTypedArray() + + val allowedBattleSizeIndex = JOptionPane.showOptionDialog( + null, "Please select the allowed sizes of battle", + "Allowed Battle Sizes", JOptionPane.DEFAULT_OPTION, + JOptionPane.QUESTION_MESSAGE, null, + allowedBattleSizeOptions, allowedBattleSizeOptions[0] + ) + + if (allowedBattleSizeIndex == JOptionPane.CLOSED_OPTION) return + + val allowedBattleSizes = allowedBattleSizeChoices[allowedBattleSizeIndex] + + val allowedFactionChoices = Faction.values().map { setOf(it) } + listOf(Faction.values().toSet()) + val allowedFactionOptions = allowedFactionChoices.map { it.singleOrNull()?.shortName ?: "Allow Any" }.toTypedArray() + + val allowedFactionIndex = JOptionPane.showOptionDialog( + null, "Please select the allowed factions in battle", + "Allowed Factions", JOptionPane.DEFAULT_OPTION, + JOptionPane.QUESTION_MESSAGE, null, + allowedFactionOptions, allowedFactionOptions[0] + ) + + if (allowedFactionIndex == JOptionPane.CLOSED_OPTION) return + + val allowedFactions = allowedFactionChoices[allowedFactionIndex] + + val allTrials = numTrials * instinctVectorCount * instinctVectorCount + val doneTrials = AtomicInteger(0) + val cancelJob = Job() + + thread { + while (true) { + val options = arrayOf("Update", "Cancel") + + val option = JOptionPane.showOptionDialog( + null, "Please select an action. ${doneTrials.get()}/$allTrials trials are done.", + "Trials in Progress", JOptionPane.DEFAULT_OPTION, + JOptionPane.INFORMATION_MESSAGE, null, + options, options[0] + ) + + if (option == 1) { + cancelJob.cancel() + break + } + } + } + + val instinctPairingSuccessRate = runBlocking { + performTrials(numTrials, instinctVectors, allowedBattleSizes, allowedFactions, cancelJob) { + doneTrials.getAndIncrement() + } + } + + val instinctVictories = instinctPairingSuccessRate.toVictoryPairingMap() + + val instinctSuccessRate = instinctPairingSuccessRate.toVictoryMap() + + val instinctHistograms = instinctSuccessRate.successHistograms(instinctVectorCount) + + val indexedInstincts = instinctSuccessRate + .toList() + .sortedBy { (_, v) -> v } + .mapIndexed { i, p -> (i + 1) to p } + + val results = createHTML(prettyPrint = false, xhtmlCompatible = true).html { + head { + title { +"Test Results" } + } + body { + style = "font-family:sans-serif" + + h1 { +"Test Results" } + p { +"These are the results of testing AI instinct parameters in Starship Fights" } + h2 { +"Test Parameters" } + p { +"Number of Instincts Generated: ${instinctVectors.size}" } + p { +"Number of Trials Per Instinct Pairing: $numTrials" } + p { +"Battle Sizes Allowed: ${allowedBattleSizes.singleOrNull()?.displayName ?: "All"}" } + p { +"Factions Allowed: ${allowedFactions.singleOrNull()?.polityName ?: "All"}" } + h2 { +"Instincts Vectors and Battle Results" } + val cellStyle = "border:1px solid rgba(0, 0, 0, 0.6)" + table { + thead { + tr { + th(scope = ThScope.row) { + style = cellStyle + +"Vector Values" + } + th(scope = ThScope.col) { + style = cellStyle + +"Battles Won" + } + for (it in allInstincts) + th(scope = ThScope.col) { + style = cellStyle + +it.key + } + } + } + tbody { + for ((i, pair) in indexedInstincts) { + val (instincts, successRate) = pair + tr { + th(scope = ThScope.row) { + style = cellStyle + +"Instincts $i" + } + td { + style = cellStyle + +"$successRate" + } + for (key in allInstincts) + td { + style = cellStyle + +"${instincts[key]}" + } + } + } + } + } + h2 { +"Instincts Pairing Battle Results" } + table { + tr { + th { + style = cellStyle + +"Winner \\ Loser" + } + for ((i, _) in indexedInstincts) + th(scope = ThScope.col) { + style = cellStyle + +"Instincts $i" + } + } + for ((i, v) in indexedInstincts) + tr { + th(scope = ThScope.row) { + style = cellStyle + +"Instincts $i" + } + for ((_, w) in indexedInstincts) + td { + val pairing = InstinctVictoryPairing(v.first, w.first) + val victories = instinctVictories[pairing] ?: 0 + + style = cellStyle + +"$victories" + } + } + } + h2 { +"Instinct Victory Histograms" } + for ((instinct, histogram) in instinctHistograms) { + val sortedHistogram = histogram.toList().sortedBy { (range, _) -> range.start } + val lowestNumber = sortedHistogram.minOf { (_, score) -> score } + val highestNumber = sortedHistogram.maxOf { (_, score) -> score } + + h3 { +"Instinct ${instinct.key}" } + table { + thead { + tr { + th(scope = ThScope.col) { + style = cellStyle + +"Value Range" + } + for (num in lowestNumber..highestNumber) + th(scope = ThScope.col) { + style = cellStyle + +"$num" + } + } + } + tbody { + for ((range, successRate) in sortedHistogram) + tr { + th { + style = cellStyle + +"${range.start}" + br + +"${range.endInclusive}" + } + for (i in lowestNumber until successRate) + td { + style = "$cellStyle;background-color:#AAA;color:#AAA" + +"##" + } + td { + style = "$cellStyle;background-color:#555;color:#555" + +"##" + } + for (i in successRate until highestNumber) + td { + style = "$cellStyle;background-color:#FFF;color:#FFF" + +"##" + } + } + } + } + } + } + } + + File("test_results.html").writeText(results) + } +} diff --git a/src/jvmTest/kotlin/starshipfights/game/ai/AITesting.kt b/src/jvmTest/kotlin/starshipfights/game/ai/AITesting.kt deleted file mode 100644 index 8c5a141..0000000 --- a/src/jvmTest/kotlin/starshipfights/game/ai/AITesting.kt +++ /dev/null @@ -1,253 +0,0 @@ -package starshipfights.game.ai - -import kotlinx.coroutines.Job -import kotlinx.coroutines.runBlocking -import kotlinx.html.* -import kotlinx.html.stream.createHTML -import starshipfights.game.BattleSize -import starshipfights.game.Faction -import java.io.File -import java.util.concurrent.atomic.AtomicInteger -import javax.swing.JOptionPane -import javax.swing.UIManager -import kotlin.concurrent.thread - -object AITesting { - @JvmStatic - fun main(args: Array) { - UIManager.setLookAndFeel("javax.swing.plaf.nimbus.NimbusLookAndFeel") - - val instinctVectorCounts = listOf(5, 11, 17) - val instinctVectorOptions = instinctVectorCounts.map { it.toString() }.toTypedArray() - - val instinctVectorIndex = JOptionPane.showOptionDialog( - null, "Please select the number of Instinct vectors to generate", - "Generate Instinct Vectors", JOptionPane.DEFAULT_OPTION, - JOptionPane.QUESTION_MESSAGE, null, - instinctVectorOptions, instinctVectorOptions[0] - ) - - if (instinctVectorIndex == JOptionPane.CLOSED_OPTION) return - - val instinctVectorCount = instinctVectorCounts[instinctVectorIndex] - val instinctVectors = genInstinctCandidates(instinctVectorCount) - - val numTrialCounts = listOf(3, 5, 7, 10, 25) - val numTrialOptions = numTrialCounts.map { it.toString() }.toTypedArray() - - val numTrialIndex = JOptionPane.showOptionDialog( - null, "Please select the number of trials to execute per instinct pairing", - "Number of Trials", JOptionPane.DEFAULT_OPTION, - JOptionPane.QUESTION_MESSAGE, null, - numTrialOptions, numTrialOptions[0] - ) - - if (numTrialIndex == JOptionPane.CLOSED_OPTION) return - - val numTrials = numTrialCounts[numTrialIndex] - - val allowedBattleSizeChoices = BattleSize.values().map { setOf(it) } + listOf(BattleSize.values().toSet()) - val allowedBattleSizeOptions = allowedBattleSizeChoices.map { it.singleOrNull()?.displayName ?: "Allow Any" }.toTypedArray() - - val allowedBattleSizeIndex = JOptionPane.showOptionDialog( - null, "Please select the allowed sizes of battle", - "Allowed Battle Sizes", JOptionPane.DEFAULT_OPTION, - JOptionPane.QUESTION_MESSAGE, null, - allowedBattleSizeOptions, allowedBattleSizeOptions[0] - ) - - if (allowedBattleSizeIndex == JOptionPane.CLOSED_OPTION) return - - val allowedBattleSizes = allowedBattleSizeChoices[allowedBattleSizeIndex] - - val allowedFactionChoices = Faction.values().map { setOf(it) } + listOf(Faction.values().toSet()) - val allowedFactionOptions = allowedFactionChoices.map { it.singleOrNull()?.shortName ?: "Allow Any" }.toTypedArray() - - val allowedFactionIndex = JOptionPane.showOptionDialog( - null, "Please select the allowed factions in battle", - "Allowed Factions", JOptionPane.DEFAULT_OPTION, - JOptionPane.QUESTION_MESSAGE, null, - allowedFactionOptions, allowedFactionOptions[0] - ) - - if (allowedFactionIndex == JOptionPane.CLOSED_OPTION) return - - val allowedFactions = allowedFactionChoices[allowedFactionIndex] - - val allTrials = numTrials * instinctVectorCount * instinctVectorCount - val doneTrials = AtomicInteger(0) - val cancelJob = Job() - - thread { - while (true) { - val options = arrayOf("Update", "Cancel") - - val option = JOptionPane.showOptionDialog( - null, "Please select an action. ${doneTrials.get()}/$allTrials trials are done.", - "Trials in Progress", JOptionPane.DEFAULT_OPTION, - JOptionPane.INFORMATION_MESSAGE, null, - options, options[0] - ) - - if (option == 1) { - cancelJob.cancel() - break - } - } - } - - val instinctPairingSuccessRate = runBlocking { - performTrials(numTrials, instinctVectors, allowedBattleSizes, allowedFactions, cancelJob) { - doneTrials.getAndIncrement() - } - } - - val instinctVictories = instinctPairingSuccessRate.toVictoryPairingMap() - - val instinctSuccessRate = instinctPairingSuccessRate.toVictoryMap() - - val instinctHistograms = instinctSuccessRate.successHistograms(instinctVectorCount) - - val indexedInstincts = instinctSuccessRate - .toList() - .sortedBy { (_, v) -> v } - .mapIndexed { i, p -> (i + 1) to p } - - val results = createHTML(prettyPrint = false, xhtmlCompatible = true).html { - head { - title { +"Test Results" } - } - body { - style = "font-family:sans-serif" - - h1 { +"Test Results" } - p { +"These are the results of testing AI instinct parameters in Starship Fights" } - h2 { +"Test Parameters" } - p { +"Number of Instincts Generated: ${instinctVectors.size}" } - p { +"Number of Trials Per Instinct Pairing: $numTrials" } - p { +"Battle Sizes Allowed: ${allowedBattleSizes.singleOrNull()?.displayName ?: "All"}" } - p { +"Factions Allowed: ${allowedFactions.singleOrNull()?.polityName ?: "All"}" } - h2 { +"Instincts Vectors and Battle Results" } - val cellStyle = "border:1px solid rgba(0, 0, 0, 0.6)" - table { - thead { - tr { - th(scope = ThScope.row) { - style = cellStyle - +"Vector Values" - } - th(scope = ThScope.col) { - style = cellStyle - +"Battles Won" - } - for (it in allInstincts) - th(scope = ThScope.col) { - style = cellStyle - +it.key - } - } - } - tbody { - for ((i, pair) in indexedInstincts) { - val (instincts, successRate) = pair - tr { - th(scope = ThScope.row) { - style = cellStyle - +"Instincts $i" - } - td { - style = cellStyle - +"$successRate" - } - for (key in allInstincts) - td { - style = cellStyle - +"${instincts[key]}" - } - } - } - } - } - h2 { +"Instincts Pairing Battle Results" } - table { - tr { - th { - style = cellStyle - +"Winner \\ Loser" - } - for ((i, _) in indexedInstincts) - th(scope = ThScope.col) { - style = cellStyle - +"Instincts $i" - } - } - for ((i, v) in indexedInstincts) - tr { - th(scope = ThScope.row) { - style = cellStyle - +"Instincts $i" - } - for ((_, w) in indexedInstincts) - td { - val pairing = InstinctVictoryPairing(v.first, w.first) - val victories = instinctVictories[pairing] ?: 0 - - style = cellStyle - +"$victories" - } - } - } - h2 { +"Instinct Victory Histograms" } - for ((instinct, histogram) in instinctHistograms) { - val sortedHistogram = histogram.toList().sortedBy { (range, _) -> range.start } - val lowestNumber = sortedHistogram.minOf { (_, score) -> score } - val highestNumber = sortedHistogram.maxOf { (_, score) -> score } - - h3 { +"Instinct ${instinct.key}" } - table { - thead { - tr { - th(scope = ThScope.col) { - style = cellStyle - +"Value Range" - } - for (num in lowestNumber..highestNumber) - th(scope = ThScope.col) { - style = cellStyle - +"$num" - } - } - } - tbody { - for ((range, successRate) in sortedHistogram) - tr { - th { - style = cellStyle - +"${range.start}" - br - +"${range.endInclusive}" - } - for (i in lowestNumber until successRate) - td { - style = "$cellStyle;background-color:#AAA;color:#AAA" - +"##" - } - td { - style = "$cellStyle;background-color:#555;color:#555" - +"##" - } - for (i in successRate until highestNumber) - td { - style = "$cellStyle;background-color:#FFF;color:#FFF" - +"##" - } - } - } - } - } - } - } - - File("test_results.html").writeText(results) - } -}