Add an RZ Coin slot machine

Adds a spend-coins-for-a-random-reward mini-game reachable from the
shop. Backend mirrors the store's existing atomic/idempotent purchase
pattern (SpinAsync in CreditStore.cs) with a server-authoritative,
weighted reward roll and a rolling per-user spin-rate guard; odds are
served from GET /api/credits/spin-config so the client never hardcodes
numbers that could drift from the real payout table. The mockup's
"Streak Protector" reward was swapped for a real Exam Simulation Token
since streaks aren't currently a persisted, protectable concept.

Also fixes two latent bugs surfaced while building this:
- credits_service.gd's operation-id builder referenced a nonexistent
  AuthState.user_id (should be .username), throwing on every credit
  action.
- _post() silently no-op'd with no signal when signed out, which
  combined with a GDScript closure gotcha (lambdas capture outer
  locals by value, so reassigning inside one never reached the
  caller) meant a spin attempted while unauthenticated could hang
  forever instead of surfacing an error.
This commit is contained in:
hz
2026-07-30 00:29:49 -05:00
parent 4909cf0b71
commit 1860e6ede6
8 changed files with 816 additions and 3 deletions
+66
View File
@@ -28,6 +28,10 @@ var balance_value_label: Label
var coin_caption_label: Label
var inventory_label: Label
var tab_controls: Array = []
var banner_card: PanelContainer
var banner_icon_panel: PanelContainer
var banner_heading: Label
var banner_description: Label
var section_heading: Label
var section_subtitle: Label
var products_grid: HFlowContainer
@@ -49,6 +53,7 @@ func _ready() -> void:
_build_header()
_build_summary_card()
_build_tabs()
_build_slot_machine_banner()
section_heading = Label.new()
section_heading.text = "Featured"
@@ -217,6 +222,63 @@ func _apply_tab_theme(dark: bool) -> void:
(entry["underline"] as ColorRect).color = Brand.ROYAL_BLUE if active else Color(0, 0, 0, 0)
func _build_slot_machine_banner() -> void:
var dark := UIThemeService.is_dark_mode()
banner_card = PanelContainer.new()
banner_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
add_child(banner_card)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_bottom", 12)
banner_card.add_child(margin)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 14)
margin.add_child(row)
banner_icon_panel = PanelContainer.new()
banner_icon_panel.custom_minimum_size = Vector2(40, 40)
banner_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
banner_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 20))
var icon := TextureRect.new()
icon.texture = load("res://assets/icons/lucide/star.svg")
icon.custom_minimum_size = Vector2(20, 20)
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon.modulate = Brand.ROYAL_BLUE
banner_icon_panel.add_child(icon)
row.add_child(banner_icon_panel)
var copy := VBoxContainer.new()
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
copy.add_theme_constant_override("separation", 2)
banner_heading = Label.new()
banner_heading.text = "RZ Coin Slot Machine"
banner_heading.add_theme_font_override("font", Brand.font)
banner_heading.add_theme_font_size_override("font_size", 15)
banner_heading.add_theme_color_override("font_color", Brand.text_color(dark))
copy.add_child(banner_heading)
banner_description = Label.new()
banner_description.text = "Spend RZ Coins for a chance at bigger rewards."
banner_description.add_theme_font_override("font", Brand.font)
banner_description.add_theme_font_size_override("font_size", 12)
banner_description.add_theme_color_override("font_color", Brand.muted_color(dark))
copy.add_child(banner_description)
row.add_child(copy)
var play_button := Button.new()
play_button.text = "Play Now"
play_button.custom_minimum_size = Vector2(96, 36)
play_button.add_theme_font_override("font", Brand.font)
_style_buy_button(play_button)
play_button.pressed.connect(func(): main.show_screen("slot_machine"))
row.add_child(play_button)
func _build_footer() -> void:
var dark := UIThemeService.is_dark_mode()
footer_card = PanelContainer.new()
@@ -276,6 +338,10 @@ func _refresh_theme_controls() -> void:
coin_caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
inventory_label.add_theme_color_override("font_color", Brand.muted_color(dark))
_apply_tab_theme(dark)
banner_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
banner_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 20))
banner_heading.add_theme_color_override("font_color", Brand.text_color(dark))
banner_description.add_theme_color_override("font_color", Brand.muted_color(dark))
section_subtitle.add_theme_color_override("font_color", Brand.muted_color(dark))
footer_card.add_theme_stylebox_override("panel", Brand.content_card_box(dark, false))
footer_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 18))