Skip to content

Commit c970a84

Browse files
finreinhardclaude
andcommitted
Add player status display and filter toggle to challenge menu
- Add player-specific status labels (Not joined, Active, Failed, Completed) to challenge menu items - Implement efficient database JOIN queries to eliminate N+1 query performance issues - Add filter toggle to switch between "Your Challenges" and "All Challenges" views - Include completed/failed challenges in menu display with proper status tracking - Fix challenge completion database persistence to update status correctly - Add internationalization support for new status labels in English and German 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 7ea2ec8 commit c970a84

File tree

5 files changed

+226
-37
lines changed

5 files changed

+226
-37
lines changed

src/main/kotlin/li/angu/challengeplugin/managers/ChallengeManager.kt

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import org.bukkit.Bukkit
88
import org.bukkit.World
99
import org.bukkit.WorldCreator
1010
import org.bukkit.GameRule
11+
import org.bukkit.GameMode
1112
import org.bukkit.entity.Player
1213
import java.util.UUID
1314
import java.util.concurrent.ConcurrentHashMap
@@ -16,6 +17,36 @@ import java.time.Duration
1617
import java.time.Instant
1718
import java.io.File
1819

20+
data class ChallengeMenuData(
21+
val id: UUID,
22+
val name: String,
23+
val status: ChallengeStatus,
24+
val playerCount: Int,
25+
val startedAt: Instant?,
26+
val pausedAt: Instant?,
27+
val totalPausedDuration: Duration,
28+
val playerStatus: String
29+
) {
30+
fun getFormattedDuration(): String {
31+
if (startedAt == null) {
32+
return "Not started"
33+
}
34+
35+
val end = if (status != ChallengeStatus.ACTIVE) {
36+
Instant.now() // For completed/failed challenges, use current time as end
37+
} else if (pausedAt != null) {
38+
pausedAt
39+
} else {
40+
Instant.now()
41+
}
42+
43+
val rawDuration = Duration.between(startedAt, end)
44+
val effectiveDuration = rawDuration.minus(totalPausedDuration)
45+
46+
return li.angu.challengeplugin.utils.TimeFormatter.formatDuration(effectiveDuration)
47+
}
48+
}
49+
1950
class ChallengeManager(private val plugin: ChallengePluginPlugin) {
2051

2152
private val activeChallenges = ConcurrentHashMap<UUID, Challenge>()
@@ -98,7 +129,7 @@ class ChallengeManager(private val plugin: ChallengePluginPlugin) {
98129
}
99130
}
100131
}
101-
132+
102133
plugin.logger.info("Loaded ${activeChallenges.size} challenges from database")
103134

104135
// Load players for each challenge
@@ -537,6 +568,9 @@ class ChallengeManager(private val plugin: ChallengePluginPlugin) {
537568

538569
challenge.complete()
539570

571+
// Save updated status to database
572+
saveChallengeToDatabase(challenge)
573+
540574
// Handle rewards or other completion logic
541575
Bukkit.getOnlinePlayers().forEach { player ->
542576
if (challenge.isPlayerInChallenge(player)) {
@@ -649,4 +683,97 @@ class ChallengeManager(private val plugin: ChallengePluginPlugin) {
649683
null
650684
}
651685
}
686+
687+
/**
688+
* Get challenges for menu with minimal data and player status using efficient JOIN query
689+
*/
690+
fun getChallengesForMenu(playerId: UUID, showAll: Boolean, limit: Int = 36): List<ChallengeMenuData> {
691+
val query = if (showAll) {
692+
"""
693+
SELECT c.id, c.name, c.status, c.started_at, c.paused_at, c.total_paused_duration,
694+
COUNT(cp.player_uuid) as player_count,
695+
pcd.game_mode, pcd.saved_at
696+
FROM challenges c
697+
LEFT JOIN challenge_participants cp ON c.id = cp.challenge_id
698+
LEFT JOIN player_challenge_data pcd ON c.id = pcd.challenge_id AND pcd.player_uuid = ?
699+
GROUP BY c.id, c.name, c.status, c.started_at, c.paused_at, c.total_paused_duration, pcd.game_mode, pcd.saved_at
700+
ORDER BY (SELECT MAX(pcd2.saved_at) FROM player_challenge_data pcd2 WHERE pcd2.challenge_id = c.id) DESC NULLS LAST
701+
LIMIT ?
702+
""".trimIndent()
703+
} else {
704+
"""
705+
SELECT c.id, c.name, c.status, c.started_at, c.paused_at, c.total_paused_duration,
706+
COUNT(cp.player_uuid) as player_count,
707+
pcd.game_mode, pcd.saved_at
708+
FROM challenges c
709+
LEFT JOIN challenge_participants cp ON c.id = cp.challenge_id
710+
LEFT JOIN player_challenge_data pcd ON c.id = pcd.challenge_id AND pcd.player_uuid = ?
711+
LEFT JOIN challenge_participants cp2 ON c.id = cp2.challenge_id AND cp2.player_uuid = ?
712+
GROUP BY c.id, c.name, c.status, c.started_at, c.paused_at, c.total_paused_duration, pcd.game_mode, pcd.saved_at
713+
ORDER BY COALESCE(pcd.saved_at, cp.left_at, cp.joined_at) DESC
714+
LIMIT ?
715+
""".trimIndent()
716+
}
717+
718+
val resultCallback = { rs: java.sql.ResultSet ->
719+
val results = mutableListOf<ChallengeMenuData>()
720+
721+
while (rs.next()) {
722+
try {
723+
val challengeId = UUID.fromString(rs.getString("id"))
724+
val status = ChallengeStatus.valueOf(rs.getString("status"))
725+
726+
val startedAt = rs.getTimestamp("started_at")?.toInstant()
727+
val pausedAt = rs.getTimestamp("paused_at")?.toInstant()
728+
val totalPausedDuration = Duration.ofSeconds(rs.getLong("total_paused_duration"))
729+
730+
val playerStatus = determinePlayerStatusFromDB(playerId, challengeId, status, rs.getString("game_mode"))
731+
732+
results.add(ChallengeMenuData(
733+
id = challengeId,
734+
name = rs.getString("name"),
735+
status = status,
736+
playerCount = rs.getInt("player_count"),
737+
startedAt = startedAt,
738+
pausedAt = pausedAt,
739+
totalPausedDuration = totalPausedDuration,
740+
playerStatus = playerStatus
741+
))
742+
743+
} catch (e: Exception) {
744+
plugin.logger.warning("Failed to load challenge menu data: ${e.message}")
745+
}
746+
}
747+
results
748+
}
749+
750+
return if (showAll) {
751+
plugin.databaseDriver.executeQuery(query, playerId.toString(), limit, processor = resultCallback)
752+
} else {
753+
plugin.databaseDriver.executeQuery(query, playerId.toString(), playerId.toString(), limit, processor = resultCallback)
754+
} ?: emptyList()
755+
}
756+
757+
private fun determinePlayerStatusFromDB(playerId: UUID, challengeId: UUID, challengeStatus: ChallengeStatus, savedGameMode: String?): String {
758+
// Check if player is currently active (in memory)
759+
val challenge = activeChallenges[challengeId]
760+
val player = plugin.server.getPlayer(playerId)
761+
if (challenge != null && player != null && challenge.isPlayerInChallenge(player)) {
762+
return "active"
763+
}
764+
765+
// Check if player has saved data
766+
if (savedGameMode != null) {
767+
return when (challengeStatus) {
768+
ChallengeStatus.COMPLETED -> "completed"
769+
ChallengeStatus.FAILED -> "failed"
770+
ChallengeStatus.ACTIVE -> {
771+
// Challenge is still active, check if player died (spectator mode)
772+
if (savedGameMode == "SPECTATOR") "failed" else "active"
773+
}
774+
}
775+
}
776+
777+
return "not_joined"
778+
}
652779
}

src/main/kotlin/li/angu/challengeplugin/managers/ChallengeMenuManager.kt

Lines changed: 76 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import li.angu.challengeplugin.models.Challenge
55
import li.angu.challengeplugin.models.ChallengeStatus
66
import org.bukkit.Bukkit
77
import org.bukkit.Material
8+
import org.bukkit.NamespacedKey
89
import org.bukkit.entity.Player
910
import org.bukkit.event.EventHandler
1011
import org.bukkit.event.HandlerList
@@ -14,13 +15,15 @@ import org.bukkit.event.inventory.InventoryCloseEvent
1415
import org.bukkit.inventory.Inventory
1516
import org.bukkit.inventory.ItemStack
1617
import org.bukkit.inventory.meta.ItemMeta
18+
import org.bukkit.persistence.PersistentDataType
1719
import java.util.UUID
1820
import java.util.concurrent.ConcurrentHashMap
1921

2022
class ChallengeMenuManager(private val plugin: ChallengePluginPlugin) : Listener {
2123

2224
private val playerMenus = ConcurrentHashMap<UUID, Inventory>()
23-
private val challengesPerInventory = ConcurrentHashMap<Inventory, List<Challenge>>()
25+
private val challengesPerInventory = ConcurrentHashMap<Inventory, List<ChallengeMenuData>>()
26+
private val filterToggleKey = NamespacedKey(plugin, "filter_show_all")
2427

2528
init {
2629
plugin.server.pluginManager.registerEvents(this, plugin)
@@ -32,7 +35,7 @@ class ChallengeMenuManager(private val plugin: ChallengePluginPlugin) : Listener
3235
challengesPerInventory.clear()
3336
}
3437

35-
fun openMainMenu(player: Player) {
38+
fun openMainMenu(player: Player, showAll: Boolean = false) {
3639
val inventory = Bukkit.createInventory(
3740
player,
3841
54, // 6 rows
@@ -56,7 +59,11 @@ class ChallengeMenuManager(private val plugin: ChallengePluginPlugin) : Listener
5659
createMeta.setDisplayName(plugin.languageManager.getMessage("challenge.menu.create", player))
5760
createMeta.lore = listOf(plugin.languageManager.getMessage("challenge.menu.create_lore", player))
5861
createItem.itemMeta = createMeta
59-
inventory.setItem(4, createItem)
62+
inventory.setItem(0, createItem)
63+
64+
// Filter toggle button (slot 4)
65+
val filterItem = createFilterToggleItem(player, showAll)
66+
inventory.setItem(4, filterItem)
6067

6168
// Create Leave Challenge button
6269
val leaveItem = ItemStack(Material.BARRIER)
@@ -66,24 +73,24 @@ class ChallengeMenuManager(private val plugin: ChallengePluginPlugin) : Listener
6673
leaveItem.itemMeta = leaveMeta
6774
inventory.setItem(8, leaveItem)
6875

69-
// Get all active challenges
70-
val challenges = plugin.challengeManager.getActiveChallenges()
76+
// Get challenges using efficient database query
77+
val challenges = plugin.challengeManager.getChallengesForMenu(player.uniqueId, showAll)
7178
val playerCurrentChallenge = plugin.challengeManager.getPlayerChallenge(player)
7279

7380
// Store the challenges for this inventory so we can retrieve them in the click handler
7481
challengesPerInventory[inventory] = challenges
7582

7683
// Add challenges to inventory
7784
if (challenges.isNotEmpty()) {
78-
challenges.forEachIndexed { index, challenge ->
85+
challenges.forEachIndexed { index, challengeData ->
7986
// Start at slot 18 (third row) and fill rows from there
8087
val slot = 18 + index
8188

8289
// Skip if we've reached the end of the inventory
8390
if (slot >= inventory.size) return@forEachIndexed
8491

85-
val isCurrentChallenge = playerCurrentChallenge?.id == challenge.id
86-
val challengeItem = createChallengeItem(challenge, player, isCurrentChallenge)
92+
val isCurrentChallenge = playerCurrentChallenge?.id == challengeData.id
93+
val challengeItem = createChallengeItemFromData(challengeData, player, isCurrentChallenge)
8794
inventory.setItem(slot, challengeItem)
8895
}
8996
} else {
@@ -102,43 +109,63 @@ class ChallengeMenuManager(private val plugin: ChallengePluginPlugin) : Listener
102109
player.openInventory(inventory)
103110
}
104111

105-
private fun createChallengeItem(challenge: Challenge, player: Player, isCurrentChallenge: Boolean): ItemStack {
106-
// Choose material based on whether this is the player's current challenge
107-
val material = when {
108-
isCurrentChallenge -> Material.ENCHANTED_BOOK
109-
else -> Material.BOOK
112+
private fun createFilterToggleItem(player: Player, showAll: Boolean): ItemStack {
113+
val item = ItemStack(Material.PLAYER_HEAD)
114+
val meta = item.itemMeta ?: Bukkit.getItemFactory().getItemMeta(Material.PLAYER_HEAD)
115+
116+
// Store filter state in persistent data
117+
meta.persistentDataContainer.set(filterToggleKey, PersistentDataType.BYTE, if (showAll) 1 else 0)
118+
119+
if (showAll) {
120+
meta.setDisplayName(plugin.languageManager.getMessage("challenge.menu.filter_all", player))
121+
meta.lore = listOf(plugin.languageManager.getMessage("challenge.menu.filter_all_lore", player))
122+
} else {
123+
meta.setDisplayName(plugin.languageManager.getMessage("challenge.menu.filter_your", player))
124+
meta.lore = listOf(plugin.languageManager.getMessage("challenge.menu.filter_your_lore", player))
125+
126+
// Set to player's head
127+
if (meta is org.bukkit.inventory.meta.SkullMeta) {
128+
meta.owningPlayer = player
129+
}
110130
}
111131

132+
item.itemMeta = meta
133+
return item
134+
}
135+
136+
private fun createChallengeItemFromData(challengeData: ChallengeMenuData, player: Player, isCurrentChallenge: Boolean): ItemStack {
137+
val material = if (isCurrentChallenge) Material.ENCHANTED_BOOK else Material.BOOK
138+
112139
val item = ItemStack(material)
113140
val meta = item.itemMeta ?: Bukkit.getItemFactory().getItemMeta(material)
114141

115142
meta.setDisplayName(
116143
if (isCurrentChallenge)
117-
plugin.languageManager.getMessage("challenge.menu.current_challenge", player, "name" to challenge.name)
144+
plugin.languageManager.getMessage("challenge.menu.current_challenge", player, "name" to challengeData.name)
118145
else
119-
plugin.languageManager.getMessage("challenge.menu.challenge", player, "name" to challenge.name)
146+
plugin.languageManager.getMessage("challenge.menu.challenge", player, "name" to challengeData.name)
120147
)
121148

122149
val loreList = mutableListOf<String>()
123150

124151
// Challenge ID
125-
loreList.add(plugin.languageManager.getMessage("challenge.menu.id", player, "id" to challenge.id.toString()))
126-
127-
// Challenge status
128-
val statusKey = when(challenge.status) {
129-
ChallengeStatus.ACTIVE -> "status.active"
130-
ChallengeStatus.COMPLETED -> "status.completed"
131-
ChallengeStatus.FAILED -> "status.failed"
132-
}
133-
loreList.add(plugin.languageManager.getMessage("challenge.menu.status", player,
134-
"status" to plugin.languageManager.getMessage(statusKey, player)))
152+
loreList.add(plugin.languageManager.getMessage("challenge.menu.id", player, "id" to challengeData.id.toString()))
135153

136154
// Players
137-
loreList.add(plugin.languageManager.getMessage("challenge.menu.players", player, "count" to challenge.players.size.toString()))
155+
loreList.add(plugin.languageManager.getMessage("challenge.menu.players", player, "count" to challengeData.playerCount.toString()))
156+
157+
// Player status for this challenge
158+
val playerStatusMessage = when(challengeData.playerStatus) {
159+
"active" -> plugin.languageManager.getMessage("player_status.active", player)
160+
"failed" -> plugin.languageManager.getMessage("player_status.failed", player)
161+
"completed" -> plugin.languageManager.getMessage("player_status.completed", player)
162+
else -> plugin.languageManager.getMessage("player_status.not_joined", player)
163+
}
164+
loreList.add(plugin.languageManager.getMessage("challenge.menu.your_status", player, "status" to playerStatusMessage))
138165

139166
// Duration (if started)
140-
if (challenge.startedAt != null) {
141-
loreList.add(plugin.languageManager.getMessage("challenge.menu.duration", player, "time" to challenge.getFormattedDuration()))
167+
if (challengeData.startedAt != null) {
168+
loreList.add(plugin.languageManager.getMessage("challenge.menu.duration", player, "time" to challengeData.getFormattedDuration()))
142169
}
143170

144171
// Action text
@@ -155,6 +182,7 @@ class ChallengeMenuManager(private val plugin: ChallengePluginPlugin) : Listener
155182
return item
156183
}
157184

185+
158186
@EventHandler
159187
fun onInventoryClick(event: InventoryClickEvent) {
160188
val player = event.whoClicked as? Player ?: return
@@ -171,11 +199,21 @@ class ChallengeMenuManager(private val plugin: ChallengePluginPlugin) : Listener
171199

172200
when (event.slot) {
173201
// Create Challenge button
174-
4 -> {
202+
0 -> {
175203
player.closeInventory()
176204
player.performCommand("create ") // Open create name prompt
177205
}
178206

207+
// Filter Toggle button
208+
4 -> {
209+
val filterItem = event.currentItem ?: return
210+
val currentShowAll = filterItem.itemMeta?.persistentDataContainer?.get(filterToggleKey, PersistentDataType.BYTE) == 1.toByte()
211+
212+
// Toggle filter and refresh menu
213+
player.closeInventory()
214+
openMainMenu(player, !currentShowAll)
215+
}
216+
179217
// Leave Challenge button
180218
8 -> {
181219
val currentChallenge = plugin.challengeManager.getPlayerChallenge(player)
@@ -202,15 +240,15 @@ class ChallengeMenuManager(private val plugin: ChallengePluginPlugin) : Listener
202240
// Calculate index in the challenges list
203241
val index = event.slot - 18
204242
if (index >= 0 && index < challenges.size) {
205-
val challenge = challenges[index]
243+
val challengeData = challenges[index]
206244
val currentChallenge = plugin.challengeManager.getPlayerChallenge(player)
207245

208-
if (currentChallenge?.id == challenge.id) {
246+
if (currentChallenge?.id == challengeData.id) {
209247
// Player clicked their current challenge, leave it
210-
plugin.playerDataManager.savePlayerData(player, challenge.id)
248+
plugin.playerDataManager.savePlayerData(player, challengeData.id)
211249

212250
if (plugin.challengeManager.leaveChallenge(player)) {
213-
player.sendMessage(plugin.languageManager.getMessage("challenge.left", player, "name" to challenge.name))
251+
player.sendMessage(plugin.languageManager.getMessage("challenge.left", player, "name" to challengeData.name))
214252

215253
// Refresh the menu
216254
player.closeInventory()
@@ -220,13 +258,15 @@ class ChallengeMenuManager(private val plugin: ChallengePluginPlugin) : Listener
220258
}
221259
} else {
222260
// Player clicked a different challenge, join it
223-
if (challenge.status != ChallengeStatus.ACTIVE) {
261+
if (challengeData.status != ChallengeStatus.ACTIVE) {
224262
player.sendMessage(plugin.languageManager.getMessage("challenge.already_completed", player))
225263
return
226264
}
227265

228-
if (plugin.challengeManager.joinChallenge(player, challenge)) {
229-
player.sendMessage(plugin.languageManager.getMessage("challenge.joined", player, "name" to challenge.name))
266+
// Get full challenge object from memory to join
267+
val fullChallenge = plugin.challengeManager.getChallenge(challengeData.id)
268+
if (fullChallenge != null && plugin.challengeManager.joinChallenge(player, fullChallenge)) {
269+
player.sendMessage(plugin.languageManager.getMessage("challenge.joined", player, "name" to challengeData.name))
230270

231271
// Close the menu
232272
player.closeInventory()

0 commit comments

Comments
 (0)