import kotlinx.serialization.Serializable
import starshipfights.data.Id
-enum class AdmiralRank(val maxShipWeightClass: ShipWeightClass) {
- REAR_ADMIRAL(ShipWeightClass.CRUISER),
- VICE_ADMIRAL(ShipWeightClass.CRUISER),
- ADMIRAL(ShipWeightClass.BATTLECRUISER),
- HIGH_ADMIRAL(ShipWeightClass.BATTLESHIP),
- LORD_ADMIRAL(ShipWeightClass.COLOSSUS);
+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.CRUISER
+ ADMIRAL -> ShipWeightClass.BATTLECRUISER
+ HIGH_ADMIRAL -> ShipWeightClass.BATTLESHIP
+ LORD_ADMIRAL -> ShipWeightClass.COLOSSUS
+ }
val maxBattleSize: BattleSize
get() = BattleSize.values().last { it.maxWeightClass <= maxShipWeightClass }
+
+ 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().firstOrNull { it.minAcumen <= acumen } ?: REAR_ADMIRAL
+ }
}
fun AdmiralRank.getDisplayName(faction: Faction) = when (faction) {
LINE_SHIP -> 275
DREADNOUGHT -> 400
}
+
+ val isUnique: Boolean
+ get() = this == COLOSSUS
}
+val ShipWeightClass.buyPrice: Int
+ get() = basePointCost + 25
+
+val ShipWeightClass.sellPrice: Int
+ get() = basePointCost - 25
+
enum class ShipType(
val faction: Faction,
val weightClass: ShipWeightClass,
}.display()
if (!opponentJoined)
- Popup.GameOver("Unfortunately, your opponent never entered the battle.").display()
+ Popup.GameOver("Unfortunately, your opponent never entered the battle.", gameState.value).display()
val sendActionsJob = launch {
while (true) {
val finalMessage = connectionJob.await()
renderingJob.cancel()
- Popup.GameOver(finalMessage).display()
+ Popup.GameOver(finalMessage, gameState.value).display()
}
}
}
}
- class GameOver(private val outcome: String) : Popup<Nothing>() {
+ class GameOver(private val outcome: String, private val finalState: GameState) : Popup<Nothing>() {
override fun TagConsumer<*>.render(context: CoroutineContext, callback: (Nothing) -> Unit) {
p {
style = "text-align:center"
p {
style = "text-align:center"
- a(href = "/me") {
- +"Return to Home Page"
+ val admiralId = finalState.admiralInfo(mySide).id
+
+ a(href = "/admiral/${admiralId}") {
+ +"Exit Battle"
}
}
}
import io.ktor.sessions.*
import io.ktor.util.*
import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.html.*
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
-import org.litote.kmongo.and
-import org.litote.kmongo.eq
-import org.litote.kmongo.ne
-import org.litote.kmongo.setValue
+import org.litote.kmongo.*
import starshipfights.*
import starshipfights.data.Id
-import starshipfights.data.admiralty.Admiral
-import starshipfights.data.admiralty.ShipInDrydock
-import starshipfights.data.admiralty.generateFleet
+import starshipfights.data.admiralty.*
import starshipfights.data.auth.User
import starshipfights.data.auth.UserSession
import starshipfights.data.createNonce
-import starshipfights.game.AdmiralRank
import starshipfights.game.Faction
+import starshipfights.game.ShipType
+import starshipfights.game.buyPrice
+import starshipfights.game.toUrlSlug
import starshipfights.info.*
import java.time.Instant
import java.time.temporal.ChronoUnit
name = form["name"]?.takeIf { it.isNotBlank() } ?: throw MissingRequestParameterException("name"),
isFemale = form.getOrFail("sex") == "female",
faction = Faction.valueOf(form.getOrFail("faction")),
- // TODO change to Rear Admiral
- rank = AdmiralRank.LORD_ADMIRAL
+ acumen = 0,
+ money = 500
)
val newShips = generateFleet(newAdmiral)
redirect("/admiral/$admiralId")
}
+ get("/admiral/{id}/buy/{ship}") {
+ call.respondHtml(HttpStatusCode.OK, call.buyShipConfirmPage())
+ }
+
+ post("/admiral/{id}/buy/{ship}") {
+ val currentUser = call.getUserSession()?.user
+ val admiralId = call.parameters["id"]?.let { Id<Admiral>(it) }!!
+ val admiral = Admiral.get(admiralId)!!
+
+ if (admiral.owningUser != currentUser) throw ForbiddenException()
+
+ val shipType = call.parameters["ship"]?.let { param -> ShipType.values().singleOrNull { it.toUrlSlug() == param } }!!
+
+ if (shipType.faction != admiral.faction || shipType.weightClass.rank > admiral.rank.maxShipWeightClass.rank)
+ throw NotFoundException()
+
+ if (shipType.weightClass.buyPrice > admiral.money)
+ redirect("/admiral/${admiralId}/manage")
+
+ val ownedShips = ShipInDrydock.select(ShipInDrydock::owningAdmiral eq admiralId).toList()
+
+ if (shipType.weightClass.isUnique) {
+ val hasSameWeightClass = ownedShips.any { it.shipType.weightClass == shipType.weightClass }
+ if (hasSameWeightClass)
+ redirect("/admiral/${admiralId}/manage")
+ }
+
+ val shipNames = ownedShips.map { it.name }.toMutableSet()
+ val newShipName = newShipName(shipType.faction, shipType.weightClass, shipNames) ?: ShipNames.nameShip(shipType.faction, shipType.weightClass)
+
+ val newShip = ShipInDrydock(
+ name = newShipName,
+ shipType = shipType,
+ status = DrydockStatus.Ready,
+ owningAdmiral = admiralId
+ )
+
+ launch { ShipInDrydock.put(newShip) }
+ launch {
+ Admiral.set(admiralId, inc(Admiral::money, -shipType.weightClass.buyPrice))
+ }
+
+ redirect("/admiral/${admiralId}/manage")
+ }
+
get("/admiral/{id}/delete") {
call.respondHtml(HttpStatusCode.OK, call.deleteAdmiralConfirmPage())
}
private val diadochiFemaleNames = listOf(
"Lursha",
"Jamoqena",
- "Hikari",
+ "Lokoria",
"Iekuna",
"Shara",
"Etugen",
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import org.bson.conversions.Bson
+import org.litote.kmongo.and
import org.litote.kmongo.eq
-import starshipfights.data.*
+import org.litote.kmongo.gte
+import org.litote.kmongo.lt
+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
val isFemale: Boolean,
val faction: Faction,
- val rank: AdmiralRank,
+ val acumen: Int,
+ val money: Int,
) : DataDocument<Admiral> {
+ val rank: AdmiralRank
+ get() = AdmiralRank.fromAcumen(acumen)
+
val fullName: String
get() = "${rank.getDisplayName(faction)} $name"
})
}
+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
sealed class DrydockStatus {
@Serializable
.associate { it.shipData.id to it.shipData }
fun generateFleet(admiral: Admiral): List<ShipInDrydock> = ShipWeightClass.values()
- .flatMap {
+ .flatMap { swc ->
val shipTypes = ShipType.values().filter { st ->
- st.weightClass == it && st.faction == admiral.faction
+ st.weightClass == swc && st.faction == admiral.faction
}.shuffled()
if (shipTypes.isEmpty())
emptyList()
else
- (0..((admiral.rank.maxShipWeightClass.rank - it.rank) * 2 + 1).coerceAtLeast(0)).map { i ->
+ (0..((admiral.rank.maxShipWeightClass.rank - swc.rank) * 2 + 1).coerceAtLeast(0)).map { i ->
shipTypes[i % shipTypes.size]
}
}
suspend fun unique(vararg properties: KProperty1<T, *>)
suspend fun put(doc: T)
+ suspend fun set(id: Id<T>, set: Bson): Boolean
suspend fun get(id: Id<T>): T?
suspend fun del(id: Id<T>)
suspend fun all(): Flow<T>
collection().replaceOneById(doc.id, doc, ReplaceOptions().upsert(true))
}
+ override suspend fun set(id: Id<T>, set: Bson): Boolean {
+ return collection().updateOneById(id, set).matchedCount != 0L
+ }
+
override suspend fun get(id: Id<T>): T? {
return collection().findOneById(id)
}
import kotlinx.coroutines.*
import org.litote.kmongo.div
+import org.litote.kmongo.inc
import org.litote.kmongo.lt
import org.litote.kmongo.setValue
-import starshipfights.data.admiralty.Admiral
-import starshipfights.data.admiralty.BattleRecord
-import starshipfights.data.admiralty.DrydockStatus
-import starshipfights.data.admiralty.ShipInDrydock
+import starshipfights.data.admiralty.*
import starshipfights.data.auth.User
import starshipfights.data.auth.UserSession
+import starshipfights.game.AdmiralRank
import starshipfights.sfLogger
import java.time.Instant
+import java.time.ZoneId
import kotlin.coroutines.CoroutineContext
object DataRoutines : CoroutineScope {
// Repair ships
launch {
while (currentCoroutineContext().isActive) {
- val now = Instant.now()
launch {
+ val now = Instant.now()
ShipInDrydock.update(ShipInDrydock::status / DrydockStatus.InRepair::until lt now, setValue(ShipInDrydock::status, DrydockStatus.Ready))
}
delay(300_000)
}
}
+
+ // 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 {
+ AdmiralRank.values().forEach { rank ->
+ launch {
+ Admiral.update(
+ AdmiralRank eq rank,
+ inc(Admiral::money, rank.dailyWage)
+ )
+ }
+ }
+ }
+ prevTime = currTime
+ delay(900_000)
+ }
+ }
}
}
}
import io.ktor.routing.*
import io.ktor.websocket.*
import kotlinx.coroutines.launch
+import org.litote.kmongo.setValue
import starshipfights.auth.getUser
import starshipfights.data.admiralty.getAllInGameAdmirals
import starshipfights.data.auth.User
matchmakingEndpoint(user)
launch {
- User.put(user.copy(status = UserStatus.READY_FOR_BATTLE))
+ User.set(user.id, setValue(User::status, UserStatus.READY_FOR_BATTLE))
}
}
gameEndpoint(user, token)
launch {
- User.put(user.copy(status = UserStatus.AVAILABLE))
+ User.set(user.id, setValue(User::status, UserStatus.AVAILABLE))
}
}
}
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.DrydockStatus
import starshipfights.data.admiralty.ShipInDrydock
val end = session.gameEnd.await()
val endedAt = Instant.now()
- val destroyedShipStatus = DrydockStatus.InRepair(endedAt.plus(12, ChronoUnit.HOURS))
- val damagedShipStatus = DrydockStatus.InRepair(endedAt.plus(9, ChronoUnit.HOURS))
- val intactShipStatus = DrydockStatus.InRepair(endedAt.plus(6, ChronoUnit.HOURS))
- val escapedShipStatus = DrydockStatus.InRepair(endedAt.plus(3, ChronoUnit.HOURS))
-
- val shipWrecks = session.state.value.destroyedShips
- val destroyedShips = shipWrecks.filterValues { !it.isEscape }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
- val escapedShips = shipWrecks.filterValues { it.isEscape }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
- val damagedShips = session.state.value.ships.filterValues { it.hullAmount < it.ship.durability.maxHullPoints }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
- val intactShips = session.state.value.ships.keys.map { it.reinterpret<ShipInDrydock>() }.toSet() - damagedShips
-
- launch {
- ShipInDrydock.update(ShipInDrydock::id `in` destroyedShips, setValue(ShipInDrydock::status, destroyedShipStatus))
- }
- launch {
- ShipInDrydock.update(ShipInDrydock::id `in` damagedShips, setValue(ShipInDrydock::status, damagedShipStatus))
- }
- launch {
- ShipInDrydock.update(ShipInDrydock::id `in` intactShips, setValue(ShipInDrydock::status, intactShipStatus))
- }
- launch {
- ShipInDrydock.update(ShipInDrydock::id `in` escapedShips, setValue(ShipInDrydock::status, escapedShipStatus))
- }
-
- val battleRecord = BattleRecord(
- battleInfo = session.state.value.battleInfo,
-
- whenStarted = startedAt,
- whenEnded = endedAt,
-
- hostUser = hostInfo.user.id.reinterpret(),
- guestUser = guestInfo.user.id.reinterpret(),
-
- hostAdmiral = hostInfo.id.reinterpret(),
- guestAdmiral = guestInfo.id.reinterpret(),
-
- winner = end.winner,
- winMessage = end.message
- )
-
- launch {
- BattleRecord.put(battleRecord)
- }
+ onGameEnd(session.state.value, end, startedAt, endedAt)
}
val hostId = createToken()
sendEventsJob.cancelAndJoin()
receiveActionsJob.cancelAndJoin()
}
+
+private suspend fun onGameEnd(gameState: GameState, gameEnd: GameEvent.GameEnd, startedAt: Instant, endedAt: Instant) {
+ val destroyedShipStatus = DrydockStatus.InRepair(endedAt.plus(12, ChronoUnit.HOURS))
+ val damagedShipStatus = DrydockStatus.InRepair(endedAt.plus(8, ChronoUnit.HOURS))
+ val intactShipStatus = DrydockStatus.InRepair(endedAt.plus(4, ChronoUnit.HOURS))
+ val escapedShipStatus = DrydockStatus.InRepair(endedAt.plus(4, ChronoUnit.HOURS))
+
+ val shipWrecks = gameState.destroyedShips
+ val ships = gameState.ships
+
+ val destroyedShips = shipWrecks.filterValues { !it.isEscape }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
+ val escapedShips = shipWrecks.filterValues { it.isEscape }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
+ val damagedShips = ships.filterValues { it.hullAmount < it.ship.durability.maxHullPoints }.keys.map { it.reinterpret<ShipInDrydock>() }.toSet()
+ val intactShips = ships.keys.map { it.reinterpret<ShipInDrydock>() }.toSet() - damagedShips
+
+ val hostAdmiralId = gameState.hostInfo.id.reinterpret<Admiral>()
+ val guestAdmiralId = gameState.guestInfo.id.reinterpret<Admiral>()
+
+ val hostAcumenGain = shipWrecks.values.filter { it.owner == GlobalSide.GUEST && !it.isEscape }.sumOf { it.ship.pointCost }
+ val guestAcumenGain = shipWrecks.values.filter { it.owner == GlobalSide.HOST && !it.isEscape }.sumOf { it.ship.pointCost }
+
+ 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,
+
+ winner = gameEnd.winner,
+ winMessage = gameEnd.message
+ )
+
+ coroutineScope {
+ launch {
+ ShipInDrydock.update(ShipInDrydock::id `in` destroyedShips, setValue(ShipInDrydock::status, destroyedShipStatus))
+ }
+ launch {
+ ShipInDrydock.update(ShipInDrydock::id `in` damagedShips, setValue(ShipInDrydock::status, damagedShipStatus))
+ }
+ launch {
+ ShipInDrydock.update(ShipInDrydock::id `in` intactShips, setValue(ShipInDrydock::status, intactShipStatus))
+ }
+ launch {
+ ShipInDrydock.update(ShipInDrydock::id `in` escapedShips, setValue(ShipInDrydock::status, escapedShipStatus))
+ }
+
+ launch {
+ Admiral.set(hostAdmiralId, inc(Admiral::acumen, hostAcumenGain))
+ }
+ launch {
+ Admiral.set(guestAdmiralId, inc(Admiral::acumen, guestAcumenGain))
+ }
+
+ launch {
+ BattleRecord.put(battleRecord)
+ }
+ }
+}
package starshipfights.info
import io.ktor.application.*
+import io.ktor.features.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.firstOrNull
import starshipfights.data.auth.User
import starshipfights.data.auth.UserSession
import starshipfights.data.auth.UserStatus
-import starshipfights.game.Faction
-import starshipfights.game.GlobalSide
-import starshipfights.game.toUrlSlug
+import starshipfights.game.*
import starshipfights.redirect
import java.time.Instant
}
textInput(name = "name") {
id = "name"
+
autoComplete = false
required = true
- minLength = "2"
- maxLength = "32"
}
p {
label {
Triple(admiral.await(), ships.await(), records.await())
}
+
val recordRoles = records.mapNotNull {
when (admiralId) {
it.hostAdmiral -> GlobalSide.HOST
section {
h1 { +admiral.name }
p {
- +admiral.fullName
+ b { +admiral.fullName }
+" is a flag officer of the "
+admiral.faction.navyName
+". "
}
}
td {
- +when (recordRoles[record.id]) {
- record.winner -> "Victory"
+ +when (record.winner) {
+ null -> "Stalemate"
+ recordRoles[record.id] -> "Victory"
else -> "Defeat"
}
}
if (admiral.owningUser != currentUser) throw ForbiddenException()
+ val ownedShips = ShipInDrydock.select(ShipInDrydock::owningAdmiral eq admiralId).toList()
+
+ //val sellableShips = ownedShips.filter { it.status == DrydockStatus.Ready }.sortedBy { it.name }.sortedBy { it.shipType.weightClass.rank }
+
+ val buyableShips = ShipType.values().filter { type ->
+ type.faction == admiral.faction && type.weightClass.rank <= admiral.rank.maxShipWeightClass.rank && type.weightClass.buyPrice <= admiral.money && (if (type.weightClass.isUnique) ownedShips.none { it.shipType.weightClass == type.weightClass } else true)
+ }.sortedBy { it.name }.sortedBy { it.weightClass.rank }
+
return page(
"Managing ${admiral.name}", standardNavBar(), PageNavSidebar(
listOf(
}
}
textInput(name = "name") {
+ id = "name"
+ autoComplete = false
+
required = true
value = admiral.name
- minLength = "4"
- maxLength = "24"
}
p {
label {
+"Female"
}
}
+ h3 { +"Generate Random Name" }
+ p {
+ AdmiralNameFlavor.values().forEachIndexed { i, flavor ->
+ 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"
}
}
}
}
+ section {
+ h2 { +"Manage Fleet" }
+ h3 { +"Buy New Ship" }
+ table {
+ tr {
+ th { +"Ship Class" }
+ th { +"Ship Cost" }
+ th { +Entities.nbsp }
+ }
+ buyableShips.forEach { st ->
+ tr {
+ td { +st.fullDisplayName }
+ td {
+ +st.weightClass.buyPrice.toString()
+ +" Electro-Ducats"
+ }
+ td {
+ form(action = "/admiral/${admiralId}/buy/${st.toUrlSlug()}", method = FormMethod.get) {
+ submitInput {
+ value = "Buy"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+suspend fun ApplicationCall.buyShipConfirmPage(): HTML.() -> Unit {
+ val currentUser = getUserSession()?.user
+ val admiralId = parameters["id"]?.let { Id<Admiral>(it) }!!
+ val admiral = Admiral.get(admiralId)!!
+
+ if (admiral.owningUser != currentUser) throw ForbiddenException()
+
+ val shipType = parameters["ship"]?.let { param -> ShipType.values().singleOrNull { it.toUrlSlug() == param } }!!
+
+ if (shipType.faction != admiral.faction || shipType.weightClass.rank > admiral.rank.maxShipWeightClass.rank)
+ throw NotFoundException()
+
+ if (shipType.weightClass.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.weightClass.buyPrice} Electro-Ducats, and ${admiral.name} only has ${admiral.money} Electro-Ducats."
+ }
+ form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") {
+ submitInput {
+ value = "Back"
+ }
+ }
+ }
+ }
+ }
+
+ return page(
+ "Are You Sure?", null, null
+ ) {
+ section {
+ h1 { +"Are You Sure?" }
+ p {
+ +"${admiral.fullName} is about to buy a ${shipType.fullDisplayName} for ${shipType.weightClass.buyPrice} Electro-Ducats."
+ }
+ form(method = FormMethod.get, action = "/admiral/${admiral.id}/manage") {
+ submitInput {
+ value = "Cancel"
+ }
+ }
+ form(method = FormMethod.post, action = "/admiral/${admiral.id}/buy/${shipType.toUrlSlug()}") {
+ submitInput {
+ value = "Checkout"
+ }
+ }
+ }
}
}