Add ranking system and /ranking command
Introduce a ranking system: add Rank enum and RankingManager enhancements (isRankingEnabled toggle, getRank(), RR change handling, rank-up/down detection and player notifications with titles/sounds). Add a new /ranking admin command (toggle, status, rank) with tab completion and register it in plugin startup. Surface player rank on the scoreboard (ScoreboardManager) and add related language entries and plugin.yml permission/command metadata. Logging updated to include ranked state.
This commit is contained in:
@@ -2,6 +2,7 @@ package club.mcscrims.speedhg
|
||||
|
||||
import club.mcscrims.speedhg.command.KitCommand
|
||||
import club.mcscrims.speedhg.command.LeaderboardCommand
|
||||
import club.mcscrims.speedhg.command.RankingCommand
|
||||
import club.mcscrims.speedhg.command.TimerCommand
|
||||
import club.mcscrims.speedhg.config.CustomGameManager
|
||||
import club.mcscrims.speedhg.config.CustomGameSettings
|
||||
@@ -149,6 +150,12 @@ class SpeedHG : JavaPlugin() {
|
||||
tabCompleter = timerCommand
|
||||
}
|
||||
|
||||
val rankingCommand = RankingCommand( this )
|
||||
getCommand( "ranking" )?.apply {
|
||||
setExecutor( rankingCommand )
|
||||
tabCompleter = rankingCommand
|
||||
}
|
||||
|
||||
getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() )
|
||||
}
|
||||
|
||||
|
||||
109
src/main/kotlin/club/mcscrims/speedhg/command/RankingCommand.kt
Normal file
109
src/main/kotlin/club/mcscrims/speedhg/command/RankingCommand.kt
Normal file
@@ -0,0 +1,109 @@
|
||||
package club.mcscrims.speedhg.command
|
||||
|
||||
import club.mcscrims.speedhg.SpeedHG
|
||||
import club.mcscrims.speedhg.util.sendMsg
|
||||
import org.bukkit.command.Command
|
||||
import org.bukkit.command.CommandExecutor
|
||||
import org.bukkit.command.CommandSender
|
||||
import org.bukkit.command.TabCompleter
|
||||
|
||||
class RankingCommand(
|
||||
private val plugin: SpeedHG
|
||||
) : CommandExecutor, TabCompleter {
|
||||
|
||||
override fun onCommand(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
label: String,
|
||||
args: Array<out String>
|
||||
): Boolean
|
||||
{
|
||||
if (!sender.hasPermission( "speedhg.admin.ranking" ))
|
||||
{
|
||||
sender.sendMsg( "default.no_permission" )
|
||||
return true
|
||||
}
|
||||
|
||||
when( args.firstOrNull()?.lowercase() )
|
||||
{
|
||||
"toggle" -> handleToggle( sender )
|
||||
"status" -> handleStatus( sender )
|
||||
"rank" -> handleRank( sender, args )
|
||||
else -> sender.sendMsg( "commands.ranking.usage" )
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Sub-Commands ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun handleToggle(
|
||||
sender: CommandSender
|
||||
) {
|
||||
val rm = plugin.rankingManager
|
||||
rm.isRankingEnabled = !rm.isRankingEnabled
|
||||
|
||||
val key = if ( rm.isRankingEnabled ) "commands.ranking.enabled"
|
||||
else "commands.ranking.disabled"
|
||||
sender.sendMsg( key )
|
||||
}
|
||||
|
||||
private fun handleStatus(
|
||||
sender: CommandSender
|
||||
) {
|
||||
val enabled = plugin.rankingManager.isRankingEnabled
|
||||
val key = if ( enabled ) "commands.ranking.status_enabled"
|
||||
else "commands.ranking.status_disabled"
|
||||
sender.sendMsg( key )
|
||||
}
|
||||
|
||||
private fun handleRank(
|
||||
sender: CommandSender,
|
||||
args: Array<out String>
|
||||
) {
|
||||
val targetName = args.getOrNull( 1 ) ?: run {
|
||||
sender.sendMsg( "commands.ranking.usage" )
|
||||
return
|
||||
}
|
||||
|
||||
val target = plugin.server.getPlayer( targetName ) ?: run {
|
||||
sender.sendMsg( "commands.ranking.player_not_found", "name" to targetName )
|
||||
return
|
||||
}
|
||||
|
||||
val stats = plugin.statsManager.getCachedStats( target.uniqueId )
|
||||
val rank = plugin.rankingManager.getRank( target )
|
||||
val score = stats?.scrimScore ?: 0
|
||||
val games = ( stats?.wins ?: 0 ) + ( stats?.losses ?: 0 )
|
||||
|
||||
sender.sendMsg(
|
||||
"commands.ranking.rank_info",
|
||||
"name" to target.name,
|
||||
"rank" to rank.tag,
|
||||
"score" to score.toString(),
|
||||
"games" to games.toString()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onTabComplete(
|
||||
sender: CommandSender,
|
||||
command: Command,
|
||||
label: String,
|
||||
args: Array<out String>
|
||||
): List<String?>
|
||||
{
|
||||
if (!sender.hasPermission( "speedhg.admin.ranking" ))
|
||||
return emptyList()
|
||||
|
||||
return when( args.size )
|
||||
{
|
||||
1 -> listOf( "toggle", "status", "rank" )
|
||||
.filter { it.startsWith( args[0], true ) }
|
||||
2 -> if (args[ 0 ].equals( "rank", true ))
|
||||
plugin.server.onlinePlayers.map { it.name }
|
||||
.filter { it.startsWith( args[1], true ) }
|
||||
else emptyList()
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
78
src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.kt
Normal file
78
src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.kt
Normal file
@@ -0,0 +1,78 @@
|
||||
package club.mcscrims.speedhg.ranking
|
||||
|
||||
import net.kyori.adventure.text.Component
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage
|
||||
|
||||
/**
|
||||
* Tier-System für SpeedHG.
|
||||
*
|
||||
* Reihenfolge der Enum-Einträge ist bewusst aufsteigend (niedrig → hoch),
|
||||
* da [ordinal] für den Rank-Up/Down-Vergleich in [RankingManager] genutzt wird.
|
||||
*
|
||||
* @param displayName Angezeigter Name (z. B. "Gold").
|
||||
* @param colorTag MiniMessage-Farbtag ohne Inhalt (z. B. "<gold>").
|
||||
* @param minScore Mindest-scrimScore für diesen Rang (inklusive). UNRANKED
|
||||
* bekommt [Int.MIN_VALUE] — er wird nie per Score gewählt,
|
||||
* sondern nur wenn der Spieler in der Placement-Phase ist.
|
||||
*/
|
||||
enum class Rank(
|
||||
val displayName: String,
|
||||
val colorTag: String,
|
||||
val minScore: Int
|
||||
) {
|
||||
UNRANKED ("Unranked", "<dark_gray>", Int.MIN_VALUE),
|
||||
BRONZE ("Bronze", "<#CD7F32>", 0 ),
|
||||
SILVER ("Silver", "<silver>", 500 ),
|
||||
GOLD ("Gold", "<gold>", 1000 ),
|
||||
PLATINUM ("Platinum", "<aqua>", 1500 ),
|
||||
DIAMOND ("Diamond", "<#B9F2FF>", 2000 ),
|
||||
SCRIM_MASTER("Scrim-Master", "<gradient:red:gold>", 2500 );
|
||||
|
||||
// ── Vorgefertigte Strings (String-Konkatenation einmalig, nicht pro Frame) ──
|
||||
|
||||
/** MiniMessage-String: "<gold>Gold<reset>". Ideal für Platzhalter in Nachrichten. */
|
||||
val tag: String = "$colorTag$displayName<reset>"
|
||||
|
||||
/** Klammer-Prefix für Chat/Scoreboard: "<gold>[Gold]<reset>". */
|
||||
val prefix: String = "$colorTag[$displayName]<reset>"
|
||||
|
||||
/**
|
||||
* Deserialisiertes Adventure-[Component] des [tag].
|
||||
* [lazy] → wird nur beim ersten Zugriff gebaut und dann gecacht.
|
||||
* Da Enum-Instanzen Singletons sind, passiert das genau einmal pro Rang.
|
||||
*/
|
||||
val component: Component by lazy { MiniMessage.miniMessage().deserialize(tag) }
|
||||
|
||||
/** Deserialisiertes Adventure-[Component] des [prefix]. */
|
||||
val prefixComponent: Component by lazy { MiniMessage.miniMessage().deserialize(prefix) }
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* Gibt den **sichtbaren** Rang zurück.
|
||||
*
|
||||
* Spieler mit < [RankingManager.PLACEMENT_GAMES] abgeschlossenen Spielen
|
||||
* bekommen immer [UNRANKED], egal wie hoch ihr interner Score ist.
|
||||
*
|
||||
* @param scrimScore Aktueller Scrim-Score.
|
||||
* @param gamesPlayed Abgeschlossene Spiele (wins + losses zum Zeitpunkt der Abfrage).
|
||||
*/
|
||||
fun fromPlayer(scrimScore: Int, gamesPlayed: Int): Rank =
|
||||
if (gamesPlayed < RankingManager.PLACEMENT_GAMES) UNRANKED
|
||||
else fromScore(scrimScore)
|
||||
|
||||
/**
|
||||
* Reines Score → Tier Mapping, ignoriert die Placement-Phase.
|
||||
* Wird intern für Rank-Change-Erkennung (Pre/Post Adjustment) genutzt.
|
||||
*
|
||||
* Iteriert 6 Einträge in absteigender Score-Reihenfolge → O(1) für die Praxis.
|
||||
*/
|
||||
fun fromScore(scrimScore: Int): Rank =
|
||||
entries
|
||||
.asSequence()
|
||||
.filter { it != UNRANKED }
|
||||
.sortedByDescending { it.minScore }
|
||||
.firstOrNull { scrimScore >= it.minScore }
|
||||
?: BRONZE
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
package club.mcscrims.speedhg.ranking
|
||||
|
||||
import club.mcscrims.speedhg.SpeedHG
|
||||
import club.mcscrims.speedhg.ranking.RankingManager.Companion.KILL_CAP
|
||||
import club.mcscrims.speedhg.ranking.RankingManager.Companion.MAX_RR_GAIN
|
||||
import club.mcscrims.speedhg.ranking.RankingManager.Companion.MAX_RR_LOSS
|
||||
import club.mcscrims.speedhg.ranking.RankingManager.Companion.MIN_RR_GAIN
|
||||
import club.mcscrims.speedhg.ranking.RankingManager.Companion.MIN_RR_LOSS
|
||||
import club.mcscrims.speedhg.ranking.RankingManager.Companion.PLACEMENT_GAMES
|
||||
import club.mcscrims.speedhg.util.sendMsg
|
||||
import net.kyori.adventure.text.minimessage.MiniMessage
|
||||
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder
|
||||
import club.mcscrims.speedhg.util.trans
|
||||
import net.kyori.adventure.title.Title
|
||||
import org.bukkit.Sound
|
||||
import org.bukkit.entity.Player
|
||||
import java.util.UUID
|
||||
import java.time.Duration
|
||||
import java.util.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.roundToInt
|
||||
@@ -68,7 +76,15 @@ class RankingManager(
|
||||
const val KILL_CAP = 8
|
||||
}
|
||||
|
||||
private val mm = MiniMessage.miniMessage()
|
||||
// ── Ranking-Modus ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wenn `false`: Keine RR-Änderungen am Ende der Runde.
|
||||
* Kills/Deaths werden weiterhin geloggt.
|
||||
* Per `/ranking toggle` umschaltbar.
|
||||
*/
|
||||
@Volatile
|
||||
var isRankingEnabled: Boolean = true
|
||||
|
||||
// ── Round State (wird in startRound() zurückgesetzt) ─────────────────────
|
||||
|
||||
@@ -102,7 +118,7 @@ class RankingManager(
|
||||
eliminationIndex.set( 0 )
|
||||
totalPlayersThisRound = players.size
|
||||
players.forEach { roundKills[ it.uniqueId ] = 0 }
|
||||
plugin.logger.info("[RankingManager] Runde gestartet mit ${players.size} Spielern.")
|
||||
plugin.logger.info("[RankingManager] Runde gestartet mit ${players.size} Spielern, Ranked: $isRankingEnabled.")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,7 +168,7 @@ class RankingManager(
|
||||
if ( gamesPlayed < PLACEMENT_GAMES ) {
|
||||
handlePlacementMatch( player, gamesPlayed, kills, placement )
|
||||
} else {
|
||||
handleRankedMatch( player, kills, placement )
|
||||
handleRankedMatch( player, kills, placement, gamesPlayed )
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +184,26 @@ class RankingManager(
|
||||
totalPlayersThisRound = 0
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Rank-Abfrage (für Scoreboard, Chat-Prefix etc.)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Gibt den aktuell sichtbaren Rang des Spielers zurück.
|
||||
* Berücksichtigt die Placement-Phase — Spieler in Placement bekommen [Rank.UNRANKED].
|
||||
*
|
||||
* **Performant**: Greift nur auf den In-Memory-Cache zu, kein DB-Call.
|
||||
* Sicher für häufige Aufrufe (Scoreboard-Updates alle 10 Ticks).
|
||||
*/
|
||||
fun getRank(
|
||||
player: Player
|
||||
): Rank
|
||||
{
|
||||
val stats = plugin.statsManager.getCachedStats( player.uniqueId )
|
||||
?: return Rank.UNRANKED
|
||||
return Rank.fromPlayer( stats.scrimScore, stats.wins + stats.losses )
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Core RR Algorithm
|
||||
// =========================================================================
|
||||
@@ -239,9 +275,12 @@ class RankingManager(
|
||||
kills: Int,
|
||||
placement: Int
|
||||
) {
|
||||
if ( isRankingEnabled )
|
||||
{
|
||||
// Internen Score still anpassen (für die spätere Einrankung relevant)
|
||||
val internalChange = calculateRRChange( placement, totalPlayersThisRound, kills )
|
||||
plugin.statsManager.adjustScrimScore( player.uniqueId, internalChange )
|
||||
}
|
||||
|
||||
// Spielnummer im Display: min mit PLACEMENT_GAMES, damit kein "6/5" erscheint
|
||||
val currentGameNumber = ( completedGames + 1 ).coerceAtMost( PLACEMENT_GAMES )
|
||||
@@ -258,16 +297,29 @@ class RankingManager(
|
||||
private fun handleRankedMatch(
|
||||
player: Player,
|
||||
kills: Int,
|
||||
placement: Int
|
||||
placement: Int,
|
||||
gamesPlayed: Int
|
||||
) {
|
||||
val rrChange = calculateRRChange( placement, totalPlayersThisRound, kills )
|
||||
val rrTag: String
|
||||
|
||||
if ( isRankingEnabled )
|
||||
{
|
||||
// ── Rank-Change-Erkennung: Score VOR dem Anpassen lesen ───────────
|
||||
val stats = plugin.statsManager.getCachedStats( player.uniqueId )
|
||||
val scoreBefore = stats?.scrimScore ?: 0
|
||||
val rankBefore = Rank.fromScore( scoreBefore )
|
||||
|
||||
// scrimScore clamp auf >= 0 ist bereits in adjustScrimScore() via coerceAtLest(0) gesichert
|
||||
plugin.statsManager.adjustScrimScore( player.uniqueId, rrChange )
|
||||
val rankAfter = Rank.fromScore( stats?.scrimScore ?: 0 )
|
||||
|
||||
// RR-Tag vorformatieren
|
||||
val rrTag = if ( rrChange >= 0 ) "<green>+${rrChange} RR</green>"
|
||||
if ( rankBefore != rankAfter && gamesPlayed >= PLACEMENT_GAMES )
|
||||
notifyRankChange( player, rankBefore, rankAfter )
|
||||
|
||||
rrTag = if ( rrChange >= 0 ) "<green>+${rrChange} RR</green>"
|
||||
else "<red>${rrChange} RR</red>"
|
||||
}
|
||||
else rrTag = "<gray><italic>(Unranked - no RR)</italic></gray>"
|
||||
|
||||
val msgKey = if ( placement == 1 ) "ranking.result_win"
|
||||
else "ranking.result_loss"
|
||||
@@ -280,6 +332,54 @@ class RankingManager(
|
||||
)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: Rank-Change Benachrichtigung
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Benachrichtigt den Spieler über einen Rang-Aufstieg oder -Abstieg.
|
||||
* Promotion: Title + Fanfare-Sound.
|
||||
* Demotion: Subtile Nachricht + leiser Sound.
|
||||
*/
|
||||
private fun notifyRankChange(
|
||||
player: Player,
|
||||
from: Rank,
|
||||
to: Rank
|
||||
) {
|
||||
val isPromotion = to.ordinal > from.ordinal
|
||||
|
||||
if ( isPromotion )
|
||||
{
|
||||
player.showTitle(Title.title(
|
||||
player.trans( "ranking.promote-main" ),
|
||||
player.trans( "ranking.promote-sub", "color" to to.colorTag, "name" to to.displayName ),
|
||||
Title.Times.times(
|
||||
Duration.ofMillis( 300 ),
|
||||
Duration.ofSeconds( 3 ),
|
||||
Duration.ofMillis( 700 )
|
||||
)
|
||||
))
|
||||
|
||||
player.playSound( player.location, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f )
|
||||
player.playSound( player.location, Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.5f )
|
||||
|
||||
player.sendMsg(
|
||||
"ranking.rank_up",
|
||||
"from" to from.tag,
|
||||
"to" to to.tag
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
player.playSound( player.location, Sound.ENTITY_WITHER_HURT, 0.4f, 1.6f )
|
||||
player.sendMsg(
|
||||
"ranking.rank_down",
|
||||
"from" to from.tag,
|
||||
"to" to to.tag
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private: Helpers
|
||||
// =========================================================================
|
||||
|
||||
@@ -66,6 +66,9 @@ class ScoreboardManager(
|
||||
val max = Bukkit.getMaxPlayers().toString()
|
||||
val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" )
|
||||
|
||||
val rank = plugin.rankingManager.getRank( player )
|
||||
val rankComponent = rank.component
|
||||
|
||||
val lines: List<Component>
|
||||
|
||||
if ( state == GameState.LOBBY || state == GameState.STARTING )
|
||||
@@ -75,7 +78,7 @@ class ScoreboardManager(
|
||||
lines = plugin.languageManager.getMessageList(
|
||||
player, "scoreboard.lobby",
|
||||
mapOf( "online" to online, "max" to max, "time" to timeString ),
|
||||
mapOf( "kit" to kitName )
|
||||
mapOf( "kit" to kitName, "rank" to rankComponent )
|
||||
)
|
||||
}
|
||||
else
|
||||
@@ -88,7 +91,7 @@ class ScoreboardManager(
|
||||
lines = plugin.languageManager.getMessageList(
|
||||
player, "scoreboard.ingame",
|
||||
mapOf( "timer" to timeString, "alive" to alive, "kills" to kills, "border" to border ),
|
||||
mapOf( "kit" to kitName )
|
||||
mapOf( "kit" to kitName, "rank" to rankComponent )
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ ranking:
|
||||
result_win: '<prefix><gold>🏆 Victory!</gold> <gray>Kills: <aqua><kills></aqua></gray> · <rr>'
|
||||
result_loss: '<prefix><gray>You placed <aqua><placement></aqua> · Kills: <aqua><kills></aqua></gray> · <rr>'
|
||||
|
||||
promote-main: '<gradient:gold:yellow><bold>RANK UP!</bold></gradient>'
|
||||
promote-sub: '<color><name><reset>'
|
||||
|
||||
rank_up: '<prefix><green>⬆ Rank Up!</green> <gray><from> → <to>'
|
||||
rank_down: '<prefix><red>⬇ Rank Down.</red> <gray><from> → <to>'
|
||||
|
||||
title:
|
||||
fight-main: '<red>The battle has begun!</red>'
|
||||
fight-sub: '<red>Try not to die!</red>'
|
||||
@@ -79,6 +85,15 @@ commands:
|
||||
positiveNumber: '<red>Invalid time format! Use, for example, 10m, 30s, or 600.</red>'
|
||||
onlyIngame: '<red>Timer can only be set in game.</red>'
|
||||
set: '<green>The game timer has been set to <time>!</green>'
|
||||
ranking:
|
||||
usage: '<red>Usage: /ranking <toggle|status|rank [player]></red>'
|
||||
enabled: '<prefix><green>✔ Ranked mode is now <bold>ACTIVE</bold>. RR will be awarded next round.</green>'
|
||||
disabled: '<prefix><yellow>⚠ Unranked mode is now <bold>ACTIVE</bold>. No RR changes next round.</yellow>'
|
||||
status_enabled: '<prefix><gray>Ranking: <green><bold>ACTIVE</bold></green></gray>'
|
||||
status_disabled: '<prefix><gray>Ranking: <yellow><bold>DISABLED (Unranked)</bold></yellow></gray>'
|
||||
rank_usage: '<red>Usage: /ranking rank <player></red>'
|
||||
player_not_found: '<red>Player <name> is not online.</red>'
|
||||
rank_info: '<prefix><gray>Player <white><name></white> — <rank> <gray>(<score> RR · <games> games)</gray>'
|
||||
|
||||
scoreboard:
|
||||
title: '<gradient:red:gold><bold>SpeedHG</bold></gradient>'
|
||||
@@ -86,6 +101,7 @@ scoreboard:
|
||||
- "<gray><st> "
|
||||
- "Players: <green><online>/<max>"
|
||||
- "Kit: <yellow><kit>"
|
||||
- "Rank: <rank>"
|
||||
- ""
|
||||
- "<gray>Waiting for start..."
|
||||
- ""
|
||||
@@ -95,6 +111,7 @@ scoreboard:
|
||||
- "Time: <green><timer>"
|
||||
- "Players: <red><alive>"
|
||||
- "Kills: <green><kills>"
|
||||
- "Rank: <rank>"
|
||||
- ""
|
||||
- "Border: <red><border>"
|
||||
- ""
|
||||
|
||||
@@ -13,6 +13,9 @@ permissions:
|
||||
speedhg.admin.timer:
|
||||
description: 'Change the current game time'
|
||||
default: false
|
||||
speedhg.admin.ranking:
|
||||
description: 'Manage the ranking system (toggle unranked mode, inspect ranks)'
|
||||
default: false
|
||||
|
||||
commands:
|
||||
kit:
|
||||
@@ -25,3 +28,7 @@ commands:
|
||||
description: 'Change the current game time (Admin Command)'
|
||||
usage: '/timer <seconds>'
|
||||
permission: speedhg.admin.timer
|
||||
ranking:
|
||||
description: 'Manage the SpeedHG ranking system'
|
||||
usage: '/ranking <toggle|status|rank [player]>'
|
||||
permission: speedhg.admin.ranking
|
||||
Reference in New Issue
Block a user