From 845907e8255ef17e9edb0c8f0168b3e0bba8808f Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 29 Jul 2026 00:24:46 -0500 Subject: [PATCH] Show rank tier and ELO alongside RZ points in the coins badge Adds a server-computed tier/division (Bronze..Diamond, III-I) to the ratings profile endpoint and surfaces it next to the numeric ELO in the shared header badge, matching the coin balance display already there. --- api/QuestionsApi/Models/RatingModels.cs | 2 + api/QuestionsApi/Services/RatingStore.cs | 31 ++++++++++++- assets/icons/lucide/shield.svg | 13 ++++++ assets/icons/lucide/shield.svg.import | 43 +++++++++++++++++ scripts/autoload/question_bank_service.gd | 6 +++ scripts/ui/credits_badge.gd | 56 +++++++++++++++++++++-- 6 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 assets/icons/lucide/shield.svg create mode 100644 assets/icons/lucide/shield.svg.import diff --git a/api/QuestionsApi/Models/RatingModels.cs b/api/QuestionsApi/Models/RatingModels.cs index fc6ff9d..7384330 100644 --- a/api/QuestionsApi/Models/RatingModels.cs +++ b/api/QuestionsApi/Models/RatingModels.cs @@ -47,4 +47,6 @@ public class RatingProfileResponse public int OverallElo { get; set; } public int RatedQuestionCount { get; set; } public Dictionary CategoryElos { get; set; } = []; + public string Tier { get; set; } = ""; + public int TierDivision { get; set; } } diff --git a/api/QuestionsApi/Services/RatingStore.cs b/api/QuestionsApi/Services/RatingStore.cs index 0d3d790..521d336 100644 --- a/api/QuestionsApi/Services/RatingStore.cs +++ b/api/QuestionsApi/Services/RatingStore.cs @@ -27,14 +27,43 @@ public class RatingStore public async Task GetProfileAsync(string userId) { var rating = await GetOrCreateUserRatingAsync(userId); + var (tierName, tierDivision) = GetTier(rating.OverallElo); return new RatingProfileResponse { OverallElo = rating.OverallElo, RatedQuestionCount = rating.RatedQuestionCount, - CategoryElos = rating.CategoryElos + CategoryElos = rating.CategoryElos, + Tier = tierName, + TierDivision = tierDivision }; } + private static readonly (string Name, int FloorElo)[] TierBands = + [ + ("Bronze", MinElo), + ("Silver", 1000), + ("Gold", 1300), + ("Platinum", 1900), + ("Diamond", 2200) + ]; + + private static (string Name, int Division) GetTier(int elo) + { + var bandIndex = 0; + for (var i = 0; i < TierBands.Length; i++) + { + if (elo >= TierBands[i].FloorElo) + bandIndex = i; + } + + var band = TierBands[bandIndex]; + var nextFloor = bandIndex + 1 < TierBands.Length ? TierBands[bandIndex + 1].FloorElo : band.FloorElo + 300; + var bandWidth = nextFloor - band.FloorElo; + var progress = bandWidth > 0 ? (elo - band.FloorElo) / (double)bandWidth : 0; + var division = 3 - Math.Clamp((int)(progress * 3), 0, 2); + return (band.Name, division); + } + public async Task GetOrInitializeHistoryAsync(string userId, DateTime nowUtc) { var rating = await GetOrCreateUserRatingAsync(userId); diff --git a/assets/icons/lucide/shield.svg b/assets/icons/lucide/shield.svg new file mode 100644 index 0000000..779a29c --- /dev/null +++ b/assets/icons/lucide/shield.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/lucide/shield.svg.import b/assets/icons/lucide/shield.svg.import new file mode 100644 index 0000000..33c657a --- /dev/null +++ b/assets/icons/lucide/shield.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b6wxo6lr3s8ll" +path="res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/lucide/shield.svg" +dest_files=["res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index ee15038..d1ec58e 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -38,6 +38,8 @@ var load_state := "idle" var retry_attempt := 0 var next_retry_seconds := 0 var overall_elo := 1000 +var rank_tier := "Bronze" +var rank_division := 3 var rated_question_count := 0 var category_elos := {} var last_rating_change := {} @@ -246,6 +248,8 @@ func clear_questions() -> void: retry_attempt = 0 next_retry_seconds = 0 overall_elo = 1000 + rank_tier = "Bronze" + rank_division = 3 rated_question_count = 0 category_elos = {} last_rating_change = {} @@ -617,6 +621,8 @@ func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringA return overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000))) + rank_tier = str(parsed.get("tier", rank_tier)) + rank_division = int(parsed.get("tierDivision", parsed.get("tier_division", rank_division))) rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0))) category_elos = parsed.get("categoryElos", parsed.get("category_elos", {})) ratings_changed.emit() diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd index 633c3c5..b56b3f4 100644 --- a/scripts/ui/credits_badge.gd +++ b/scripts/ui/credits_badge.gd @@ -1,9 +1,16 @@ extends PanelContainer const Brand := preload("res://scripts/ui/medmind_style.gd") +const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg") const STAR_ICON := preload("res://assets/icons/lucide/star.svg") const CHEVRON_ICON := preload("res://assets/icons/lucide/chevron-down.svg") +var rank_icon_panel: PanelContainer +var rank_icon_texture: TextureRect +var rank_tier_label: Label +var rank_elo_label: Label +var divider: ColorRect + var icon_panel: PanelContainer var icon_texture: TextureRect var value_label: Label @@ -13,12 +20,14 @@ var _initialized := false func _ready() -> void: - custom_minimum_size = Vector2(150, 44) + custom_minimum_size = Vector2(230, 44) size_flags_vertical = Control.SIZE_SHRINK_CENTER _build_ui() refresh_theme_controls() _on_credits_changed() + _on_ratings_changed() CreditsService.credits_changed.connect(_on_credits_changed) + QuestionBankService.ratings_changed.connect(_on_ratings_changed) resized.connect(func(): pivot_offset = size * 0.5) @@ -34,6 +43,35 @@ func _build_ui() -> void: row.add_theme_constant_override("separation", 8) margin.add_child(row) + rank_icon_panel = PanelContainer.new() + rank_icon_panel.custom_minimum_size = Vector2(28, 28) + rank_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER + rank_icon_texture = TextureRect.new() + rank_icon_texture.texture = SHIELD_ICON + rank_icon_texture.custom_minimum_size = Vector2(16, 16) + rank_icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + rank_icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + rank_icon_panel.add_child(rank_icon_texture) + row.add_child(rank_icon_panel) + + var rank_copy := VBoxContainer.new() + rank_copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER + rank_copy.add_theme_constant_override("separation", 0) + rank_tier_label = Label.new() + rank_tier_label.add_theme_font_override("font", Brand.font) + rank_tier_label.add_theme_font_size_override("font_size", 15) + rank_copy.add_child(rank_tier_label) + rank_elo_label = Label.new() + rank_elo_label.add_theme_font_override("font", Brand.font) + rank_elo_label.add_theme_font_size_override("font_size", 10) + rank_copy.add_child(rank_elo_label) + row.add_child(rank_copy) + + divider = ColorRect.new() + divider.custom_minimum_size = Vector2(1, 26) + divider.size_flags_vertical = Control.SIZE_SHRINK_CENTER + row.add_child(divider) + icon_panel = PanelContainer.new() icon_panel.custom_minimum_size = Vector2(28, 28) icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER @@ -53,7 +91,7 @@ func _build_ui() -> void: value_label.add_theme_font_size_override("font_size", 15) copy.add_child(value_label) caption_label = Label.new() - caption_label.text = "Rz Coins" + caption_label.text = "RZ Points" caption_label.add_theme_font_override("font", Brand.font) caption_label.add_theme_font_size_override("font_size", 10) copy.add_child(caption_label) @@ -71,7 +109,12 @@ func _build_ui() -> void: func refresh_theme_controls() -> void: var dark := UIThemeService.is_dark_mode() - add_theme_stylebox_override("panel", Brand.content_card_box(dark, false, Brand.AMBER)) + add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE)) + rank_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 14)) + rank_icon_texture.modulate = Brand.ROYAL_BLUE + rank_tier_label.add_theme_color_override("font_color", Brand.text_color(dark)) + rank_elo_label.add_theme_color_override("font_color", Brand.ROYAL_BLUE) + divider.color = Color("2a365a") if dark else Brand.LIGHT_BORDER icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 14)) icon_texture.modulate = Brand.AMBER value_label.add_theme_color_override("font_color", Brand.text_color(dark)) @@ -84,6 +127,13 @@ func bump() -> void: tween.tween_property(self, "scale", Vector2.ONE, 0.18).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN) +func _on_ratings_changed() -> void: + var roman := {1: "I", 2: "II", 3: "III"} + var division_label: String = roman.get(QuestionBankService.rank_division, "") + rank_tier_label.text = "%s %s" % [QuestionBankService.rank_tier, division_label] + rank_elo_label.text = "%d ELO" % QuestionBankService.overall_elo + + func _on_credits_changed() -> void: if not CreditsService.loaded: _initialized = false