Skip to content

Commit

Permalink
✨ Add leaderboard system
Browse files Browse the repository at this point in the history
  • Loading branch information
w305jack committed Nov 15, 2023
1 parent 0643327 commit 439d739
Show file tree
Hide file tree
Showing 16 changed files with 680 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ package tw.waterballsa.utopia.jda.extensions

import net.dv8tion.jda.api.events.interaction.command.GenericCommandInteractionEvent
import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback
import net.dv8tion.jda.api.interactions.commands.Command
import net.dv8tion.jda.api.interactions.commands.Command.*
import net.dv8tion.jda.api.interactions.commands.Command.Choice
import net.dv8tion.jda.api.interactions.commands.OptionMapping
import net.dv8tion.jda.api.interactions.commands.OptionType
import net.dv8tion.jda.api.interactions.commands.build.OptionData
Expand Down Expand Up @@ -75,16 +74,22 @@ fun <T> GenericCommandInteractionEvent.getOptionWithValidation(name: String,
return null
}

fun SlashCommandData.addOptionalOption(type: OptionType, name: String, description: String, vararg choices: Choice) =
addOption(type, name, description, false, *choices)

fun SlashCommandData.addRequiredOption(type: OptionType, name: String, description: String, vararg choices: Choice) =
addOptions(
OptionData(type, name, description, true)
.addChoices(*choices)
)
addOption(type, name, description, true, *choices)

fun SlashCommandData.addOption(type: OptionType, name: String, description: String, isRequired: Boolean, vararg choices: Choice) =
addOptions(OptionData(type, name, description, isRequired).addChoices(*choices))

fun SubcommandData.addOptionalOption(type: OptionType, name: String, description: String, vararg choices: Choice) =
addOption(type, name, description, false, *choices)

fun SubcommandData.addRequiredOption(type: OptionType, name: String, description: String, vararg choices: Choice) =
addOptions(
OptionData(type, name, description, true)
.addChoices(*choices)
)
addOptions(OptionData(type, name, description, true).addChoices(*choices))

fun SubcommandData.addOption(type: OptionType, name: String, description: String, isRequired: Boolean, vararg choices: Choice) =
addOptions(OptionData(type, name, description, isRequired).addChoices(*choices))

fun IReplyCallback.replyEphemerally(message: String) = reply(message).setEphemeral(true).queue()
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package tw.waterballsa.utopia.utopiagamification

import net.dv8tion.jda.api.entities.Guild
import net.dv8tion.jda.api.interactions.commands.Command
import net.dv8tion.jda.api.interactions.commands.OptionType.STRING
import net.dv8tion.jda.api.interactions.commands.build.CommandData
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData
import org.springframework.stereotype.Component
import tw.waterballsa.utopia.jda.extensions.addOptionalOption
import tw.waterballsa.utopia.utopiagamification.quest.listeners.UtopiaGamificationListener
import tw.waterballsa.utopia.utopiagamification.repositories.PlayerRepository

const val UTOPIA_COMMAND_NAME = "utopia"
const val FIRST_QUEST_COMMAND_NAME = "first-quest"
const val REVIEW_COMMAND_NAME = "re-render"
const val OPTION_COMMAND_NAME = "options"

private const val LEADERBOARD_COMMAND_NAME = "leaderboard"
private const val LEADERBOARD_OPTION_MY_RANK = "my-rank"

@Component
class RegisterGamificationCommand(
guild : Guild,
playerRepository: PlayerRepository
) : UtopiaGamificationListener(guild, playerRepository){
override fun commands(): List<CommandData> = listOf(
Commands.slash(UTOPIA_COMMAND_NAME, "utopia command")
.addSubcommands(
// 任務系統
SubcommandData(FIRST_QUEST_COMMAND_NAME, "get first quest"),
// 查詢並重發任務
SubcommandData(REVIEW_COMMAND_NAME, "re-render in_progress/completed quest"),
// 排行榜
SubcommandData(LEADERBOARD_COMMAND_NAME, "leaderboard")
.addOptionalOption(
STRING,
OPTION_COMMAND_NAME,
LEADERBOARD_OPTION_MY_RANK,
Command.Choice(LEADERBOARD_OPTION_MY_RANK, LEADERBOARD_OPTION_MY_RANK)
)
)
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package tw.waterballsa.utopia.utopiagamification.leaderboard

import dev.minn.jda.ktx.messages.Embed
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent
import net.dv8tion.jda.api.interactions.components.buttons.Button
import org.springframework.stereotype.Component
import tw.waterballsa.utopia.gamification.leaderboard.domain.LeaderBoardItem
import tw.waterballsa.utopia.jda.UtopiaListener
import tw.waterballsa.utopia.utopiagamification.leaderboard.repository.LeaderBoardRepository
import tw.waterballsa.utopia.utopiagamification.repositories.query.Page
import tw.waterballsa.utopia.utopiagamification.repositories.query.PageRequest
import tw.waterballsa.utopia.utopiagamification.repositories.query.Pageable

private const val UTOPIA_COMMAND_NAME = "utopia"
private const val LEADERBOARD_PREVIOUS_BUTTON = "utopia-leaderboard-previous"
private const val LEADERBOARD_NEXT_BUTTON = "utopia-leaderboard-next"
private const val LEADERBOARD_OPTION = "options"
private const val LEADERBOARD_SUBCOMMAND_NAME = "leaderboard"
private const val LEADERBOARD_MY_RANK = "my-rank"
private const val PREVIOUS_PAGE = "上一頁"
private const val NEXT_PAGE = "下一頁"

@Component
class LeaderBoardListener(
private val leaderBoardRepository: LeaderBoardRepository,
) : UtopiaListener() {

override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) {

with(event) {
if (name != UTOPIA_COMMAND_NAME || subcommandName != LEADERBOARD_SUBCOMMAND_NAME) {
return
}

val isLeaderboardQuery = options.isEmpty()
if (isLeaderboardQuery) {
queryLeaderboard()
}

val leaderboardOption = getOption(LEADERBOARD_OPTION)?.asString ?: return

val isSelfRankQuery = leaderboardOption == LEADERBOARD_MY_RANK
if (isSelfRankQuery) {
querySelfRank()
}
}
}

/**
* 使用 Discord Embedded Message:
* - 印出多列:`<rank> <@userId> Lv.<等級> Exp: <經驗值> $<賞金>` ,每一列代表一個排名。
* Discord Embedded Message 下方有兩個按鈕,”Previous Page” 和 “Next Page”。
*/
private fun SlashCommandInteractionEvent.queryLeaderboard() {
val pageable = PageRequest.of(0, 10)
val page = leaderBoardRepository.findAll(pageable)

reply("").addEmbeds(
Embed { description = page.createLeaderBoardRankDescription() }
).addActionRow(
createLeaderBoardButtons(page)
).queue()
}

/**
* 假設是 leaderboard my-rank 指令的話,會去 query 指定的 player
* 然後 output「妳的排名為第 N 名」到 discord channel
*/
private fun SlashCommandInteractionEvent.querySelfRank() {
val rank = leaderBoardRepository.queryPlayerRank(user.id)?.let {
"你的排名為第 ${it.rank}"
} ?: "找不到你的排名"
reply("").addEmbeds(
Embed { description = rank }
).queue()
}

override fun onButtonInteraction(event: ButtonInteractionEvent) {
with(event) {
if (!button.isLeaderBoardButton()) {
return
}

deferEdit().queue()

val pageable: Pageable = button.toPageable()
val page = leaderBoardRepository.findAll(pageable)

hook.editMessageEmbedsById(
messageId,
Embed {
description = page.createLeaderBoardRankDescription()
}
).setActionRow(
createLeaderBoardButtons(page)
).queue()
}
}

private fun Button.isLeaderBoardButton(): Boolean {
return listOf(
LEADERBOARD_PREVIOUS_BUTTON,
LEADERBOARD_NEXT_BUTTON
).any { id.toString().contains(it) }
}

private fun Button.toPageable(): Pageable {
val (_, page, size) = id.toString().split("_")
return PageRequest.of(page.toInt(), size.toInt())
}

private fun Page<LeaderBoardItem>.createLeaderBoardRankDescription(): String {
return getContent().joinToString(separator = "\n") {
"[${it.rank}] ${it.name}, ${it.level}, EXP=${it.exp}, Bounty=${it.bounty}"
}
}

private fun createLeaderBoardButtons(page: Page<LeaderBoardItem>): List<Button> =
mutableListOf(createPreviousPageButton(page), createNextPageButton(page))


private fun createButtonId(prefix: String, pageable: Pageable): String {
return "${prefix}_${pageable.getPageNumber()}_${pageable.getPageSize()}"
}

private fun createPreviousPageButton(page: Page<LeaderBoardItem>): Button {
return if (page.hasPrevious()) {
Button.primary(
createButtonId(LEADERBOARD_PREVIOUS_BUTTON, page.previousPageable()),
PREVIOUS_PAGE
).asEnabled()
} else {
Button.primary(
createButtonId(LEADERBOARD_PREVIOUS_BUTTON, page.getPageable()),
PREVIOUS_PAGE
).asDisabled()
}
}

private fun createNextPageButton(page: Page<LeaderBoardItem>): Button {
return if (page.hasNext()) {
Button.primary(
createButtonId(LEADERBOARD_NEXT_BUTTON, page.nextPageable()),
NEXT_PAGE
).asEnabled()
} else {
Button.primary(
createButtonId(LEADERBOARD_NEXT_BUTTON, page.getPageable()),
NEXT_PAGE
).asDisabled()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package tw.waterballsa.utopia.gamification.leaderboard.domain

data class LeaderBoardItem(
val playerId: String,
val name: String,
val exp: ULong,
val level: UInt,
val bounty: UInt,
var rank: Int = 0,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package tw.waterballsa.utopia.utopiagamification.leaderboard.repository

import tw.waterballsa.utopia.gamification.leaderboard.domain.LeaderBoardItem
import tw.waterballsa.utopia.utopiagamification.repositories.PageableRepository

interface LeaderBoardRepository: PageableRepository<LeaderBoardItem> {

fun queryPlayerRank(playerId: String): LeaderBoardItem?

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package tw.waterballsa.utopia.gamification.repositories.mongodb.repositoryimpl

import org.springframework.stereotype.Component
import tw.waterballsa.utopia.gamification.leaderboard.domain.LeaderBoardItem
import tw.waterballsa.utopia.utopiagamification.leaderboard.repository.LeaderBoardRepository
import tw.waterballsa.utopia.utopiagamification.quest.domain.Player
import tw.waterballsa.utopia.utopiagamification.repositories.PlayerRepository
import tw.waterballsa.utopia.utopiagamification.repositories.page
import tw.waterballsa.utopia.utopiagamification.repositories.query.Page
import tw.waterballsa.utopia.utopiagamification.repositories.query.Pageable

@Component
class MongodbLeaderBoardRepository(
private val playerRepository: PlayerRepository
) : LeaderBoardRepository {

override fun findAll(pageable: Pageable): Page<LeaderBoardItem> = playerRepository.findAll()
.rank()
.page(pageable)

override fun queryPlayerRank(playerId: String): LeaderBoardItem? = playerRepository.findAll()
.rank()
.find { it.playerId == playerId }

}


private fun Collection<Player>.rank(): List<LeaderBoardItem> =
sortedWith(rankOrder)
.mapIndexed { index, it -> LeaderBoardItem(it.id, it.name, it.exp, it.level, it.bounty, index + 1) }

private val rankOrder : Comparator<Player> =
compareByDescending<Player> { it.level }
.thenByDescending { it.exp }
.thenByDescending { it.bounty }
.thenBy { it.levelUpgradeDate }
.thenBy { it.joinDate }
.thenBy { it.id }
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package tw.waterballsa.utopia.utopiagamification.quest.domain


import tw.waterballsa.utopia.utopiagamification.quest.extensions.LevelSheet.Companion.toLevel
import java.time.OffsetDateTime
import java.time.OffsetDateTime.now
Expand All @@ -9,6 +10,7 @@ class Player(
var name: String,
exp: ULong = 0uL,
level: UInt = 1u,
var bounty: UInt = 0u,
val joinDate: OffsetDateTime = now(),
latestActivateDate: OffsetDateTime = now(),
levelUpgradeDate: OffsetDateTime? = null,
Expand All @@ -27,6 +29,10 @@ class Player(
var latestActivateDate = latestActivateDate
private set

init {
this.level = exp.toLevel()
}

fun gainExp(rewardExp: ULong) {
exp += rewardExp
val newLevel = exp.toLevel()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ package tw.waterballsa.utopia.utopiagamification.quest.listeners

import net.dv8tion.jda.api.entities.Guild
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent
import net.dv8tion.jda.api.interactions.commands.build.CommandData
import net.dv8tion.jda.api.interactions.commands.build.Commands
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData
import org.springframework.stereotype.Component
import tw.waterballsa.utopia.utopiagamification.quest.domain.State.*
import tw.waterballsa.utopia.utopiagamification.quest.domain.exception.AssignedQuestException
Expand All @@ -28,14 +25,6 @@ class SlashCommandListener(
private val missionRepository: MissionRepository
) : UtopiaGamificationListener(guild, playerRepository) {

override fun commands(): List<CommandData> = listOf(
Commands.slash(UTOPIA_COMMAND_NAME, "utopia command")
.addSubcommands(
SubcommandData(FIRST_QUEST_COMMAND_NAME, "get first quest"),
SubcommandData(REVIEW_COMMAND_NAME, "re-render in_progress/completed quest"),
)
)

override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) {
with(event) {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package tw.waterballsa.utopia.utopiagamification.repositories

import tw.waterballsa.utopia.utopiagamification.repositories.query.Pageable
import tw.waterballsa.utopia.utopiagamification.repositories.query.Page
import tw.waterballsa.utopia.utopiagamification.repositories.query.PageImpl

interface PageableRepository<T> {
fun findAll(pageable: Pageable): Page<T>
}

fun <T> Collection<T>.page(pageable: Pageable): Page<T> =
drop(pageable.getOffset().toInt())
.take(pageable.getPageSize())
.let { PageImpl(it.toList(), pageable, size.toLong()) }
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ package tw.waterballsa.utopia.utopiagamification.repositories
import tw.waterballsa.utopia.utopiagamification.quest.domain.Player

interface PlayerRepository {

fun findPlayerById(id: String): Player?
fun savePlayer(player: Player): Player

fun findAll(): List<Player>
}
Loading

0 comments on commit 439d739

Please sign in to comment.