diff --git a/build.gradle.kts b/build.gradle.kts index e766108..d82b086 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -10,6 +10,7 @@ plugins { id("com.gradleup.shadow") version "9.2.2" } repositories { + mavenLocal() mavenCentral() maven("https://repo.papermc.io/repository/maven-public/") } @@ -19,6 +20,7 @@ val exposedVersion = "1.0.0-rc-4" dependencies { compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") compileOnly("org.jetbrains.kotlin:kotlin-stdlib") + compileOnly("com.michael-bull.kotlin-result:kotlin-result:2.1.0") compileOnly("net.hareworks.hcu:hcu-core") compileOnly("net.hareworks:kommand-lib") @@ -34,7 +36,6 @@ dependencies { compileOnly("org.jetbrains.exposed:exposed-dao:$exposedVersion") compileOnly("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") compileOnly("org.jetbrains.exposed:exposed-kotlin-datetime:$exposedVersion") - compileOnly("com.michael-bull.kotlin-result:kotlin-result:2.1.0") } tasks { withType { diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ba9ecbe --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.daemon=false + +org.gradle.configuration-cache=true + diff --git a/hcu-core b/hcu-core index 8d47e35..388d0df 160000 --- a/hcu-core +++ b/hcu-core @@ -1 +1 @@ -Subproject commit 8d47e35af6002d863199c1b37fbd5f2d3cf1d826 +Subproject commit 388d0df943f2b4a0232c1f4f55f1cecb2d372fca diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..548fb23 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,6 @@ +rootProject.name = "faction" +includeBuild("hcu-core") +includeBuild("hcu-core/kommand-lib") +includeBuild("hcu-core/kommand-lib/permits-lib") + + diff --git a/src/main/kotlin/net/hareworks/hcu/faction/App.kt b/src/main/kotlin/net/hareworks/hcu/faction/App.kt index 8320f35..fdc0904 100644 --- a/src/main/kotlin/net/hareworks/hcu/faction/App.kt +++ b/src/main/kotlin/net/hareworks/hcu/faction/App.kt @@ -1,18 +1,66 @@ -package net.hareworks.hcu.economy +package net.hareworks.hcu.faction -import net.hareworks.kommand_lib.KommandLib -import net.hareworks.permits_lib.PermitsLib +import net.hareworks.hcu.core.database.DatabaseSessionManager +import net.hareworks.hcu.faction.command.FactionCommand +import net.hareworks.hcu.faction.database.schema.FactionMembersTable +import net.hareworks.hcu.faction.database.schema.FactionRequestsTable +import net.hareworks.hcu.faction.database.schema.FactionsTable +import net.hareworks.hcu.faction.service.FactionService import org.bukkit.plugin.java.JavaPlugin +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import java.util.logging.Level class App : JavaPlugin() { - private var commands: KommandLib? = null - private val permits = PermitsLib.session(this) + private lateinit var service: FactionService override fun onEnable() { instance = this + + // Initialize Database Listener + val dbListener = net.hareworks.hcu.faction.listener.DatabaseListener(logger) + server.pluginManager.registerEvents(dbListener, this) + + // Trigger schema check immediately if DB is already online (hcu-core might have loaded before us) + if (server.servicesManager.isProvidedFor(org.jetbrains.exposed.v1.jdbc.Database::class.java)) { + // We can trigger the listener logic or just rely on the service being present. + // But since ServiceRegisterEvent fires when registered, if it's ALREADY registered, we missed the event. + // Note: isConnected check is a proxy for "Service is available and working". + // We'll trust the simpler check we had, but just delegate logic to avoid duplication if possible, + // or just keep the inline block for simplicity as "Initial Attempt". + } + + // Initialize Schema (Best effort at startup) + if (DatabaseSessionManager.isConnected()) { + try { + DatabaseSessionManager.transaction { + SchemaUtils.createMissingTablesAndColumns( + FactionsTable, + FactionMembersTable, + FactionRequestsTable + ) + } + } catch (e: Exception) { + logger.log(Level.SEVERE, "Failed to initialize Faction schema", e) + } + } else { + logger.warning("hcu-core database not connected. Waiting for service registration to initialize schema.") + } + + // Initialize Service + service = FactionService() + + // Register Commands + FactionCommand(this, service).register() + + // Register Listeners + server.pluginManager.registerEvents(net.hareworks.hcu.faction.listener.ChatListener(service), this) + + logger.info("Faction plugin enabled") + } override fun onDisable() { + logger.info("Faction plugin disabled") } companion object { diff --git a/src/main/kotlin/net/hareworks/hcu/faction/command/FactionCommand.kt b/src/main/kotlin/net/hareworks/hcu/faction/command/FactionCommand.kt new file mode 100644 index 0000000..9cb7814 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/faction/command/FactionCommand.kt @@ -0,0 +1,378 @@ +package net.hareworks.hcu.faction.command + +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.Ok +import com.mojang.brigadier.arguments.StringArgumentType +import com.mojang.brigadier.arguments.IntegerArgumentType +import net.hareworks.hcu.faction.service.FactionService +import com.mojang.brigadier.arguments.BoolArgumentType +import net.hareworks.kommand_lib.kommand +import net.hareworks.kommand_lib.KommandLib +import net.hareworks.hcu.faction.database.schema.FactionRole +import org.bukkit.plugin.java.JavaPlugin +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.entity.Player + +class FactionCommand(private val plugin: JavaPlugin, private val service: FactionService) { + + fun register() { + kommand(plugin) { + command("faction") { + description = "Manage your faction" + + literal("create") { + string("name") { + executes { + val sender = sender + if (sender !is Player) { + sender.sendMessage(Component.text("Only players can create factions", NamedTextColor.RED)) + return@executes + } + val name = argument("name") + + service.createFaction(name, null, null, sender.uniqueId) + .onSuccess { id -> + sender.sendMessage(Component.text("Faction '$name' created with ID $id", NamedTextColor.GREEN)) + } + .onFailure { err -> + sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) + } + } + } + } + + literal("invite") { + player("player") { + executes { + val sender = sender + if (sender !is Player) return@executes + + val target = argument("player") + + val factionId = service.getFactionOfPlayer(sender.uniqueId) + if (factionId == null) { + sender.sendMessage(Component.text("You are not in a faction", NamedTextColor.RED)) + return@executes + } + + val role = service.getRole(factionId, sender.uniqueId) + if (role == FactionRole.MEMBER) { + sender.sendMessage(Component.text("Members cannot invite players", NamedTextColor.RED)) + return@executes + } + + service.invitePlayer(factionId, target.uniqueId) + .onSuccess { + sender.sendMessage(Component.text("Invited ${target.name}", NamedTextColor.GREEN)) + if (target.isOnline) { + val factionName = service.getFactionName(factionId) ?: "Unknown" + val senderName = sender.name + + val acceptMsg = Component.text("[Accept]", NamedTextColor.GREEN) + .clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand("/faction accept $factionName")) + .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(Component.text("Accept invitation"))) + + val rejectMsg = Component.text("[Reject]", NamedTextColor.RED) + .clickEvent(net.kyori.adventure.text.event.ClickEvent.runCommand("/faction reject $factionName")) + .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(Component.text("Reject invitation"))) + + (target as? Player)?.sendMessage( + Component.text("You have been invited to join ", NamedTextColor.YELLOW) + .append(Component.text(factionName, NamedTextColor.GOLD)) + .append(Component.text(" by ", NamedTextColor.YELLOW)) + .append(Component.text(senderName, NamedTextColor.AQUA)) + .append(Component.text(". ", NamedTextColor.YELLOW)) + .append(acceptMsg) + .append(Component.text(" ", NamedTextColor.WHITE)) + .append(rejectMsg) + ) + } + } + .onFailure { err -> + sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) + } + } + } + } + + // Remove join command + + literal("accept") { + executes { + val sender = sender + if (sender !is Player) return@executes + + val latestInviteFactionId = service.getLatestInvite(sender.uniqueId) + if (latestInviteFactionId == null) { + sender.sendMessage(Component.text("No pending factory invitations found.", NamedTextColor.RED)) + return@executes + } + + service.acceptInvite(latestInviteFactionId, sender.uniqueId) + .onSuccess { + val facName = service.getFactionName(latestInviteFactionId) + sender.sendMessage(Component.text("You joined ${facName ?: "the faction"}", NamedTextColor.GREEN)) + } + .onFailure { err -> sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) } + } + + string("target") { + executes { + val sender = sender + if (sender !is Player) return@executes + val targetName = argument("target") + + // Check if target is a Faction Name (Accepting Invite) + val factionIdByName = service.getFactionIdByName(targetName) + + // Check if target is a Player Name (Accepting Join Request - Leader) + val factionIdOfSender = service.getFactionOfPlayer(sender.uniqueId) + var processedAsRequest = false + + if (factionIdOfSender != null) { + val role = service.getRole(factionIdOfSender, sender.uniqueId) + if (role != FactionRole.MEMBER && role != null) { + val targetPlayer = plugin.server.getOfflinePlayer(targetName) + // Try request accept + // Use a simplified check or just try calling the service. + // Service returns error if no request found. + // But if we have ambiguity (Faction name == Player name), what to do? + // We prioritize Request if Sender is Leader? Or Invite? + // Let's try Request first if Leader. + + service.acceptRequest(factionIdOfSender, targetPlayer.uniqueId) + .onSuccess { + sender.sendMessage(Component.text("Accepted join request from $targetName", NamedTextColor.GREEN)) + return@executes + } + // If failed (e.g. no request), proceed to check if it's a Faction Invite for ME. + } + } + + if (factionIdByName != null) { + service.acceptInvite(factionIdByName, sender.uniqueId) + .onSuccess { + sender.sendMessage(Component.text("You joined $targetName", NamedTextColor.GREEN)) + } + .onFailure { err -> + // If we also failed request above, user might be confused. + sender.sendMessage(Component.text("Error: $err (or no request found from player)", NamedTextColor.RED)) + } + return@executes + } + + sender.sendMessage(Component.text("Target '$targetName' not found as Faction or Requesting Player", NamedTextColor.RED)) + } + } + } + + literal("reject") { + executes { + val sender = sender + if (sender !is Player) return@executes + + val latestInviteFactionId = service.getLatestInvite(sender.uniqueId) + if (latestInviteFactionId == null) { + sender.sendMessage(Component.text("No pending factory invitations found.", NamedTextColor.RED)) + return@executes + } + + service.rejectInvite(latestInviteFactionId, sender.uniqueId) + .onSuccess { + val facName = service.getFactionName(latestInviteFactionId) + sender.sendMessage(Component.text("Rejected invitation from ${facName ?: "the faction"}", NamedTextColor.GREEN)) + } + .onFailure { err -> sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) } + } + + string("factionName") { + executes { + val sender = sender + if (sender !is Player) return@executes + val factionName = argument("factionName") + + val factionId = service.getFactionIdByName(factionName) + if (factionId == null) { + sender.sendMessage(Component.text("Faction '$factionName' not found", NamedTextColor.RED)) + return@executes + } + + service.rejectInvite(factionId, sender.uniqueId) + .onSuccess { sender.sendMessage(Component.text("Rejected invitation from $factionName", NamedTextColor.GREEN)) } + .onFailure { err -> sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) } + } + } + } + literal("promote") { + player("player") { + executes { + val sender = sender + if (sender !is Player) return@executes + + val factionId = service.getFactionOfPlayer(sender.uniqueId) + if (factionId == null) { + sender.sendMessage(Component.text("You are not in a faction", NamedTextColor.RED)) + return@executes + } + + if (service.getRole(factionId, sender.uniqueId) != FactionRole.OWNER) { + sender.sendMessage(Component.text("Only OWNER can promote", NamedTextColor.RED)) + return@executes + } + + val target = argument("player") + + service.promote(factionId, target.uniqueId) + .onSuccess { sender.sendMessage(Component.text("Promoted ${target.name}", NamedTextColor.GREEN)) } + .onFailure { err -> sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) } + } + } + } + + literal("demote") { + player("player") { + executes { + val sender = sender + if (sender !is Player) return@executes + + val factionId = service.getFactionOfPlayer(sender.uniqueId) + if (factionId == null) { + sender.sendMessage(Component.text("You are not in a faction", NamedTextColor.RED)) + return@executes + } + + if (service.getRole(factionId, sender.uniqueId) != FactionRole.OWNER) { + sender.sendMessage(Component.text("Only OWNER can demote", NamedTextColor.RED)) + return@executes + } + + val target = argument("player") + + service.demote(factionId, target.uniqueId) + .onSuccess { sender.sendMessage(Component.text("Demoted ${target.name}", NamedTextColor.GREEN)) } + .onFailure { err -> sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) } + } + } + } + + literal("settings") { + literal("tag") { + string("tag") { + executes { + val sender = sender + if (sender !is Player) return@executes + val factionId = service.getFactionOfPlayer(sender.uniqueId) ?: return@executes + if (service.getRole(factionId, sender.uniqueId) == FactionRole.MEMBER) return@executes + + val tag = argument("tag") + service.setTag(factionId, tag) + .onSuccess { sender.sendMessage(Component.text("Tag updated to $tag", NamedTextColor.GREEN)) } + .onFailure { err -> sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) } + } + } + } + literal("color") { + string("color") { + executes { + val sender = sender + if (sender !is Player) return@executes + val factionId = service.getFactionOfPlayer(sender.uniqueId) ?: return@executes + if (service.getRole(factionId, sender.uniqueId) == FactionRole.MEMBER) return@executes + + val color = argument("color") + service.setColor(factionId, color) + .onSuccess { sender.sendMessage(Component.text("Color updated to $color", NamedTextColor.GREEN)) } + .onFailure { err -> sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) } + } + } + } + literal("open") { + bool("open") { + executes { + val sender = sender + if (sender !is Player) return@executes + val factionId = service.getFactionOfPlayer(sender.uniqueId) ?: return@executes + if (service.getRole(factionId, sender.uniqueId) == FactionRole.MEMBER) return@executes + + val open = argument("open") + service.setOpen(factionId, open) + .onSuccess { sender.sendMessage(Component.text("Open status updated to $open", NamedTextColor.GREEN)) } + .onFailure { err -> sender.sendMessage(Component.text("Error: $err", NamedTextColor.RED)) } + } + } + } + } + + literal("leave") { + executes { + val sender = sender + if (sender !is Player) return@executes + service.leaveFaction(sender.uniqueId) + .onSuccess { sender.sendMessage(Component.text("Left faction", NamedTextColor.GREEN)) } + .onFailure { sender.sendMessage(Component.text("Error: $it", NamedTextColor.RED)) } + } + } + + literal("disband") { + executes { + val sender = sender + if (sender !is Player) return@executes + service.disbandFaction(sender.uniqueId) + .onSuccess { sender.sendMessage(Component.text("Faction disbanded", NamedTextColor.GREEN)) } + .onFailure { sender.sendMessage(Component.text("Error: $it", NamedTextColor.RED)) } + } + } + + literal("kick") { + player("player") { + executes { + val sender = sender + if (sender !is Player) return@executes + val target = argument("player") + + service.kickPlayer(sender.uniqueId, target.uniqueId) + .onSuccess { sender.sendMessage(Component.text("Kicked ${target.name}", NamedTextColor.GREEN)) } + .onFailure { sender.sendMessage(Component.text("Error: $it", NamedTextColor.RED)) } + } + } + } + + literal("list") { + executes { + val factions = service.getAllFactions() + if (factions.isEmpty()) { + sender.sendMessage(Component.text("No factions found", NamedTextColor.YELLOW)) + } else { + sender.sendMessage(Component.text("Factions:", NamedTextColor.GOLD)) + factions.forEach { (name, tag) -> + val tagText = tag?.let { " [$it]" } ?: "" + sender.sendMessage(Component.text("- $name$tagText", NamedTextColor.WHITE)) + } + } + } + } + + literal("info") { + string("name") { + executes { + val name = argument("name") + val info = service.getFactionInfo(name) + if (info == null) { + sender.sendMessage(Component.text("Faction not found", NamedTextColor.RED)) + } else { + sender.sendMessage(Component.text("Faction: ${info.name}", NamedTextColor.GOLD)) + sender.sendMessage(Component.text("Tag: ${info.tag ?: "None"}", NamedTextColor.WHITE)) + sender.sendMessage(Component.text("Members: ${info.memberCount}", NamedTextColor.WHITE)) + sender.sendMessage(Component.text("Open: ${info.open}", NamedTextColor.WHITE)) + } + } + } + } + } + } + } +} + + diff --git a/src/main/kotlin/net/hareworks/hcu/faction/database/schema/FactionMembersTable.kt b/src/main/kotlin/net/hareworks/hcu/faction/database/schema/FactionMembersTable.kt new file mode 100644 index 0000000..5a863c0 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/faction/database/schema/FactionMembersTable.kt @@ -0,0 +1,24 @@ +package net.hareworks.hcu.faction.database.schema + +import net.hareworks.hcu.core.database.schema.PlayersTable +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.datetime.CurrentDateTime +import org.jetbrains.exposed.v1.datetime.datetime + +enum class FactionRole { + OWNER, + EXEC, + MEMBER +} + +object FactionMembersTable : Table("faction_members") { + val factionId = integer("faction_id") + .references(FactionsTable.id, onDelete = ReferenceOption.CASCADE) + val playerUuid = uuid("player_uuid") + .references(PlayersTable.uuid, onDelete = ReferenceOption.CASCADE) + val role = enumerationByName("role", 16, FactionRole::class) + val joinedAt = datetime("joined_at").defaultExpression(CurrentDateTime) + + override val primaryKey = PrimaryKey(factionId, playerUuid) +} diff --git a/src/main/kotlin/net/hareworks/hcu/faction/database/schema/FactionRequestsTable.kt b/src/main/kotlin/net/hareworks/hcu/faction/database/schema/FactionRequestsTable.kt new file mode 100644 index 0000000..26760ef --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/faction/database/schema/FactionRequestsTable.kt @@ -0,0 +1,24 @@ +package net.hareworks.hcu.faction.database.schema + +import net.hareworks.hcu.core.database.schema.PlayersTable +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.datetime.CurrentDateTime +import org.jetbrains.exposed.v1.datetime.datetime + +enum class FactionRequestType { + INVITE, + REQUEST +} + +object FactionRequestsTable : Table("faction_requests") { + val id = long("id").autoIncrement() + val factionId = integer("faction_id") + .references(FactionsTable.id, onDelete = ReferenceOption.CASCADE) + val playerUuid = uuid("player_uuid") + .references(PlayersTable.uuid, onDelete = ReferenceOption.CASCADE) + val type = enumerationByName("type", 16, FactionRequestType::class) + val createdAt = datetime("created_at").defaultExpression(CurrentDateTime) + + override val primaryKey = PrimaryKey(id) +} diff --git a/src/main/kotlin/net/hareworks/hcu/faction/database/schema/FactionsTable.kt b/src/main/kotlin/net/hareworks/hcu/faction/database/schema/FactionsTable.kt new file mode 100644 index 0000000..308fc97 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/faction/database/schema/FactionsTable.kt @@ -0,0 +1,17 @@ +package net.hareworks.hcu.faction.database.schema + +import net.hareworks.hcu.core.database.schema.ActorsTable +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.ReferenceOption + +object FactionsTable : Table("factions") { + val id = integer("id") + .references(ActorsTable.actorId, onDelete = ReferenceOption.CASCADE) + val name = varchar("name", 64).uniqueIndex() + val tag = varchar("tag", 16).nullable() + val color = varchar("color", 16).nullable() + val description = text("description").nullable() + val open = bool("open").default(false) + + override val primaryKey = PrimaryKey(id) +} diff --git a/src/main/kotlin/net/hareworks/hcu/faction/listener/ChatListener.kt b/src/main/kotlin/net/hareworks/hcu/faction/listener/ChatListener.kt new file mode 100644 index 0000000..20e0c60 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/faction/listener/ChatListener.kt @@ -0,0 +1,25 @@ +package net.hareworks.hcu.faction.listener + +import io.papermc.paper.event.player.AsyncChatEvent +import net.hareworks.hcu.faction.service.FactionService +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener + +class ChatListener(private val service: FactionService) : Listener { + @EventHandler + fun onChat(event: AsyncChatEvent) { + val tag = service.getFactionTag(event.player.uniqueId) ?: return + + val originalRenderer = event.renderer() + event.renderer { source, sourceDisplayName, message, viewer -> + val rendered = originalRenderer.render(source, sourceDisplayName, message, viewer) + + Component.text() + .append(Component.text("[$tag] ", NamedTextColor.GOLD)) + .append(rendered) + .build() + } + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/faction/listener/DatabaseListener.kt b/src/main/kotlin/net/hareworks/hcu/faction/listener/DatabaseListener.kt new file mode 100644 index 0000000..8871ad2 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/faction/listener/DatabaseListener.kt @@ -0,0 +1,41 @@ +package net.hareworks.hcu.faction.listener + +import net.hareworks.hcu.core.database.DatabaseSessionManager +import net.hareworks.hcu.faction.database.schema.FactionMembersTable +import net.hareworks.hcu.faction.database.schema.FactionRequestsTable +import net.hareworks.hcu.faction.database.schema.FactionsTable +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.server.ServiceRegisterEvent +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import java.util.logging.Level +import java.util.logging.Logger + +class DatabaseListener(private val logger: Logger) : Listener { + + @EventHandler + fun onServiceRegister(event: ServiceRegisterEvent) { + if (event.provider.service == Database::class.java) { + logger.info("Database service registered. Ensuring Faction schema...") + ensureSchema() + } + } + + private fun ensureSchema() { + if (!DatabaseSessionManager.isConnected()) return + + try { + DatabaseSessionManager.transaction { + SchemaUtils.createMissingTablesAndColumns( + FactionsTable, + FactionMembersTable, + FactionRequestsTable + ) + } + logger.info("Faction schema initialized successfully") + } catch (e: Exception) { + logger.log(Level.SEVERE, "Failed to initialize Faction schema", e) + } + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/faction/service/FactionService.kt b/src/main/kotlin/net/hareworks/hcu/faction/service/FactionService.kt new file mode 100644 index 0000000..2c613bd --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/faction/service/FactionService.kt @@ -0,0 +1,412 @@ +package net.hareworks.hcu.faction.service + +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess + + +import net.hareworks.hcu.core.database.DatabaseSessionManager +import net.hareworks.hcu.core.database.schema.ActorIdSequence +import net.hareworks.hcu.core.database.schema.ActorsTable +import net.hareworks.hcu.core.database.schema.PlayersTable +import net.hareworks.hcu.faction.database.schema.* +import org.jetbrains.exposed.v1.core.eq +import net.hareworks.hcu.faction.database.schema.* +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.update +import org.jetbrains.exposed.v1.jdbc.deleteWhere +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.andWhere +import org.jetbrains.exposed.v1.datetime.CurrentDateTime +import org.jetbrains.exposed.v1.core.and +import kotlinx.datetime.Clock +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import java.util.UUID + +class FactionService { + + fun createFaction(name: String, tag: String?, color: String?, ownerUuid: UUID): Result { + return DatabaseSessionManager.transaction { + // Check uniqueness + if (FactionsTable.selectAll().andWhere { FactionsTable.name eq name }.count() > 0L) { + return@transaction Err("Faction name already taken") + } + + // Create Actor + val today = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val actorIdVal = ActorsTable.insert { + it[type] = "faction" + it[registeredAt] = today + it[removed] = false + } get ActorsTable.actorId + + // Create Faction + FactionsTable.insert { + it[id] = actorIdVal + it[this.name] = name + it[this.tag] = tag + it[this.color] = color + it[open] = false + } + + // Add Owner + FactionMembersTable.insert { + it[factionId] = actorIdVal + it[playerUuid] = ownerUuid + it[role] = FactionRole.OWNER + } + + Ok(actorIdVal) + } + } + + fun getFactionOfPlayer(playerUuid: UUID): Int? { + return DatabaseSessionManager.transaction { + FactionMembersTable + .selectAll() + .andWhere { FactionMembersTable.playerUuid eq playerUuid } + .singleOrNull() + ?.get(FactionMembersTable.factionId) + } + } + + fun getRole(factionId: Int, playerUuid: UUID): FactionRole? { + return DatabaseSessionManager.transaction { + FactionMembersTable + .selectAll() + .andWhere { FactionMembersTable.factionId eq factionId } + .andWhere { FactionMembersTable.playerUuid eq playerUuid } + .singleOrNull() + ?.get(FactionMembersTable.role) + } + } + + fun getFactionTag(playerUuid: UUID): String? { + return DatabaseSessionManager.transaction { + (FactionMembersTable innerJoin FactionsTable) + .selectAll() + .andWhere { FactionMembersTable.playerUuid eq playerUuid } + .singleOrNull() + ?.get(FactionsTable.tag) + } + } + + fun getFactionIdByName(name: String): Int? { + return DatabaseSessionManager.transaction { + FactionsTable + .selectAll() + .andWhere { FactionsTable.name eq name } + .singleOrNull() + ?.get(FactionsTable.id) + } + } + + fun getFactionName(factionId: Int): String? { + return DatabaseSessionManager.transaction { + FactionsTable + .selectAll() + .andWhere { FactionsTable.id eq factionId } + .singleOrNull() + ?.get(FactionsTable.name) + } + } + + fun invitePlayer(factionId: Int, targetUuid: UUID): Result { + return DatabaseSessionManager.transaction { + if (isMember(factionId, targetUuid)) { + return@transaction Err("Player is already a member") + } + if (hasPendingRequest(factionId, targetUuid, FactionRequestType.INVITE)) { + return@transaction Err("Player already invited") + } + + FactionRequestsTable.insert { + it[this.factionId] = factionId + it[this.playerUuid] = targetUuid + it[type] = FactionRequestType.INVITE + } + Ok(Unit) + } + } + + fun acceptInvite(factionId: Int, playerUuid: UUID): Result { + return DatabaseSessionManager.transaction { + val request = FactionRequestsTable.selectAll() + .andWhere { FactionRequestsTable.factionId eq factionId } + .andWhere { FactionRequestsTable.playerUuid eq playerUuid } + .andWhere { FactionRequestsTable.type eq FactionRequestType.INVITE } + .singleOrNull() ?: return@transaction Err("No invite found") + + addMember(factionId, playerUuid, FactionRole.MEMBER) + + val reqId = request[FactionRequestsTable.id] + FactionRequestsTable.deleteWhere { FactionRequestsTable.id eq reqId } + Ok(Unit) + } + } + + fun rejectInvite(factionId: Int, playerUuid: UUID): Result { + return DatabaseSessionManager.transaction { + val request = FactionRequestsTable.selectAll() + .andWhere { FactionRequestsTable.factionId eq factionId } + .andWhere { FactionRequestsTable.playerUuid eq playerUuid } + .andWhere { FactionRequestsTable.type eq FactionRequestType.INVITE } + .singleOrNull() ?: return@transaction Err("No invite found") + + val reqId = request[FactionRequestsTable.id] + FactionRequestsTable.deleteWhere { FactionRequestsTable.id eq reqId } + Ok(Unit) + } + } + + fun getLatestInvite(playerUuid: UUID): Int? { + return DatabaseSessionManager.transaction { + FactionRequestsTable.selectAll() + .andWhere { FactionRequestsTable.playerUuid eq playerUuid } + .andWhere { FactionRequestsTable.type eq FactionRequestType.INVITE } + .sortedByDescending { it[FactionRequestsTable.createdAt] } + .firstOrNull() + ?.get(FactionRequestsTable.factionId) + } + } + + private fun addMember(factionId: Int, playerUuid: UUID, role: FactionRole) { + FactionMembersTable.insert { + it[this.factionId] = factionId + it[this.playerUuid] = playerUuid + it[this.role] = role + } + } + + private fun isMember(factionId: Int, playerUuid: UUID): Boolean { + return FactionMembersTable.selectAll() + .andWhere { FactionMembersTable.factionId eq factionId } + .andWhere { FactionMembersTable.playerUuid eq playerUuid } + .count() > 0L + } + + fun requestJoin(factionId: Int, playerUuid: UUID): Result { + return DatabaseSessionManager.transaction { + if (isMember(factionId, playerUuid)) return@transaction Err("Already a member") + + // If invited, join immediately + if (hasPendingRequest(factionId, playerUuid, FactionRequestType.INVITE)) { + addMember(factionId, playerUuid, FactionRole.MEMBER) + // Delete invite. Find it first to delete by ID to avoid 'and'. + val inviteId = FactionRequestsTable.selectAll() + .andWhere { FactionRequestsTable.factionId eq factionId } + .andWhere { FactionRequestsTable.playerUuid eq playerUuid } + .andWhere { FactionRequestsTable.type eq FactionRequestType.INVITE } + .singleOrNull()?.get(FactionRequestsTable.id) + + if (inviteId != null) { + FactionRequestsTable.deleteWhere { FactionRequestsTable.id eq inviteId } + } + return@transaction Ok(Unit) + } + + if (hasPendingRequest(factionId, playerUuid, FactionRequestType.REQUEST)) return@transaction Err("Request already pending") + + val isOpen = FactionsTable + .selectAll() + .andWhere { FactionsTable.id eq factionId } + .singleOrNull()?.get(FactionsTable.open) ?: return@transaction Err("Faction not found") + + if (isOpen) { + addMember(factionId, playerUuid, FactionRole.MEMBER) + Ok(Unit) + } else { + FactionRequestsTable.insert { + it[this.factionId] = factionId + it[this.playerUuid] = playerUuid + it[type] = FactionRequestType.REQUEST + } + Ok(Unit) // Request sent + } + } + } + + fun acceptRequest(factionId: Int, targetUuid: UUID): Result { + return DatabaseSessionManager.transaction { + if (!hasPendingRequest(factionId, targetUuid, FactionRequestType.REQUEST)) { + return@transaction Err("No pending request from this player") + } + + addMember(factionId, targetUuid, FactionRole.MEMBER) + + val reqId = FactionRequestsTable.selectAll() + .andWhere { FactionRequestsTable.factionId eq factionId } + .andWhere { FactionRequestsTable.playerUuid eq targetUuid } + .andWhere { FactionRequestsTable.type eq FactionRequestType.REQUEST } + .singleOrNull()?.get(FactionRequestsTable.id) + + if (reqId != null) { + FactionRequestsTable.deleteWhere { FactionRequestsTable.id eq reqId } + } + Ok(Unit) + } + } + + fun setTag(factionId: Int, tag: String): Result { + return DatabaseSessionManager.transaction { + FactionsTable.update({ FactionsTable.id eq factionId }) { + it[this.tag] = tag + } + Ok(Unit) + } + } + + fun setColor(factionId: Int, color: String): Result { + return DatabaseSessionManager.transaction { + FactionsTable.update({ FactionsTable.id eq factionId }) { + it[this.color] = color + } + Ok(Unit) + } + } + + fun setOpen(factionId: Int, open: Boolean): Result { + return DatabaseSessionManager.transaction { + FactionsTable.update({ FactionsTable.id eq factionId }) { + it[this.open] = open + } + Ok(Unit) + } + } + + fun promote(factionId: Int, targetUuid: UUID): Result { + return DatabaseSessionManager.transaction { + val currentRole = getRole(factionId, targetUuid) ?: return@transaction Err("Player not in faction") + + val newRole = when (currentRole) { + FactionRole.MEMBER -> FactionRole.EXEC + FactionRole.EXEC -> return@transaction Err("Cannot promote EXEC, transfer ownership instead") + FactionRole.OWNER -> return@transaction Err("Already OWNER") + } + + FactionMembersTable.update({ (FactionMembersTable.factionId eq factionId) and (FactionMembersTable.playerUuid eq targetUuid) }) { + it[role] = newRole + } + Ok(Unit) + } + } + + fun demote(factionId: Int, targetUuid: UUID): Result { + return DatabaseSessionManager.transaction { + val currentRole = getRole(factionId, targetUuid) ?: return@transaction Err("Player not in faction") + + val newRole = when (currentRole) { + FactionRole.EXEC -> FactionRole.MEMBER + FactionRole.MEMBER -> return@transaction Err("Cannot demote MEMBER") + FactionRole.OWNER -> return@transaction Err("Cannot demote OWNER") + } + + FactionMembersTable.update({ (FactionMembersTable.factionId eq factionId) and (FactionMembersTable.playerUuid eq targetUuid) }) { + it[role] = newRole + } + Ok(Unit) + } + } + + private fun hasPendingRequest(factionId: Int, playerUuid: UUID, type: FactionRequestType): Boolean { + return FactionRequestsTable.selectAll() + .andWhere { FactionRequestsTable.factionId eq factionId } + .andWhere { FactionRequestsTable.playerUuid eq playerUuid } + .andWhere { FactionRequestsTable.type eq type } + .count() > 0L + } + + fun leaveFaction(playerUuid: UUID): Result { + return DatabaseSessionManager.transaction { + val factionId = getFactionOfPlayer(playerUuid) ?: return@transaction Err("Not in a faction") + val role = getRole(factionId, playerUuid) + if (role == FactionRole.OWNER) return@transaction Err("Owner cannot leave. Disband or transfer ownership first.") + + FactionMembersTable.deleteWhere { + (FactionMembersTable.factionId eq factionId) and (FactionMembersTable.playerUuid eq playerUuid) + } + Ok(Unit) + } + } + + fun kickPlayer(actorUuid: UUID, targetUuid: UUID): Result { + return DatabaseSessionManager.transaction { + val factionId = getFactionOfPlayer(actorUuid) ?: return@transaction Err("You are not in a faction") + val targetFactionId = getFactionOfPlayer(targetUuid) + + if (factionId != targetFactionId) return@transaction Err("Player is not in your faction") + + val actorRole = getRole(factionId, actorUuid) ?: return@transaction Err("Error fetching role") + val targetRole = getRole(factionId, targetUuid) ?: return@transaction Err("Error fetching target role") + + if (actorRole == FactionRole.MEMBER) return@transaction Err("Members cannot kick") + if (actorRole == FactionRole.EXEC && targetRole != FactionRole.MEMBER) return@transaction Err("Execs can only kick members") + if (targetRole == FactionRole.OWNER) return@transaction Err("Cannot kick owner") + + FactionMembersTable.deleteWhere { + (FactionMembersTable.factionId eq factionId) and (FactionMembersTable.playerUuid eq targetUuid) + } + Ok(Unit) + } + } + + fun disbandFaction(actorUuid: UUID): Result { + return DatabaseSessionManager.transaction { + val factionId = getFactionOfPlayer(actorUuid) ?: return@transaction Err("Not in a faction") + val role = getRole(factionId, actorUuid) + if (role != FactionRole.OWNER) return@transaction Err("Only owner can disband") + + FactionMembersTable.deleteWhere { FactionMembersTable.factionId eq factionId } + FactionRequestsTable.deleteWhere { FactionRequestsTable.factionId eq factionId } + FactionsTable.deleteWhere { FactionsTable.id eq factionId } + + ActorsTable.update({ ActorsTable.actorId eq factionId }) { + it[removed] = true + } + Ok(Unit) + } + } + + fun getAllFactions(): List> { + return DatabaseSessionManager.transaction { + FactionsTable.selectAll().map { + it[FactionsTable.name] to it[FactionsTable.tag] + } + } + } + + fun getFactionInfo(name: String): FactionInfo? { + return DatabaseSessionManager.transaction { + val row = FactionsTable.selectAll().andWhere { FactionsTable.name eq name }.singleOrNull() ?: return@transaction null + val id = row[FactionsTable.id] + val count = FactionMembersTable.selectAll().andWhere { FactionMembersTable.factionId eq id }.count() + val ownerUuid = FactionMembersTable.selectAll() + .andWhere { FactionMembersTable.factionId eq id } + .andWhere { FactionMembersTable.role eq FactionRole.OWNER } + .singleOrNull()?.get(FactionMembersTable.playerUuid) + + FactionInfo( + row[FactionsTable.name], + row[FactionsTable.tag], + row[FactionsTable.color], + row[FactionsTable.open], + count, + ownerUuid + ) + } + } +} + +data class FactionInfo( + val name: String, + val tag: String?, + val color: String?, + val open: Boolean, + val memberCount: Long, + val owner: UUID? +) +