diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7e5e69d..c803b21 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,5 +1,5 @@
{
- "godotTools.editorPath.godot4": "C:\\Users\\Yogi\\Godot\\Editors\\.editor_config\\tekton-local\\Godot_v4.6-stable_win64.exe",
+ "godotTools.editorPath.godot4": "c:\\Users\\beng\\Godot\\Editors\\4.6.2-stable\\Godot_v4.6.2-stable_win64.exe",
"editor.tabSize": 4,
"editor.insertSpaces": false,
"files.eol": "\n",
diff --git a/CHANGELOG_DRAFT.md b/CHANGELOG_DRAFT.md
index e6ca632..3113370 100644
--- a/CHANGELOG_DRAFT.md
+++ b/CHANGELOG_DRAFT.md
@@ -1,3 +1,28 @@
+## [2.3.4] — 2026-05-15
+- Modernized `GachaPanel` and `FragmentCraftPanel` UI to align with the Tekton dark theme and Lobby aesthetics.
+- Updated panel borders to use standard 4px content margins, matching the Login and Lobby interfaces.
+- Redesigned banner selection tabs (Star/Gold) into compact navigation elements with state-driven styling (Inactive: Cyan, Active: Dark Blue).
+- Replaced single-line currency balances with individual themed panels containing icons and right-aligned values.
+- Updated `GachaPanel` to display both Star and Gold balances simultaneously for better resource management.
+- Introduced separate UI panels in `FragmentCraftPanel` to display Common, Uncommon, and Rare fragment quantities individually.
+- Refactored `FragmentCraftPanel` action buttons to use the Tekton `BtnDark` style with consistent padding and corner radii.
+- Fixed nearly invisible "Rates" text in Gacha by changing font color from dark brown to light grey.
+- Updated `ProfilePanel` category tabs to use descriptive text labels ("Head", "Costume", "Gloves", "Accessory") instead of emojis.
+- Standardized currency and fragment balance panels to be fixed-width and compact, removing excessive horizontal stretching.
+- Dynamic `StyleBoxFlat` instantiation for tabs in `GachaPanel` and `ProfilePanel` to ensure visual consistency across all modal screens.
+
+## [2.3.3] — 2026-05-13
+- Modernized Shop UI to match Lobby's dark theme; removed orange/brown accents in favor of a neutral dark-grey aesthetic.
+- Replaced Label-based currency indicators with pill-shaped containers and high-quality gold/star textures.
+- Integrated `RichTextLabel` with BBCode support for inline currency icons across all shop cards (Gold, Star, and Cosmetic).
+- Implemented active-state highlighting for shop tabs with distinct visual feedback (white bg/dark text for active).
+- Added featured banner system with sidebar support for event-based selling and special item spotlights.
+- Added "Shop" tab to Admin Panel to manage 3 featured banner slots and their associated event labels.
+- Standardized UI margins, button padding, and minimum sizes to ensure layout consistency and prevent text overlap.
+- Added server-side RPCs (`admin_set_featured_banners`, `admin_get_featured_banners`) for persistent shop configuration.
+- Fixed missing dependency errors (sky_sea_01.png and tiles_slot.png) by clearing stale .godot cache and UID references.
+- Implemented `_populate_banners()` function in `shop_panel.gd` to fix "Function not found" runtime error.
+
## [2.3.2] — 2026-05-12
- Integrated Mailbox UI into lobby with CanvasLayer overlay (renders above 3D viewport).
- Redesigned `mailbox_panel.tscn` to 3-column layout: scrollable mail list | content area | reward slots.
diff --git a/addons/godotsteam/win64/~libgodotsteam.windows.template_debug.x86_64.dll~RF17ff0bdf.TMP b/addons/godotsteam/win64/~libgodotsteam.windows.template_debug.x86_64.dll~RF17ff0bdf.TMP
new file mode 100644
index 0000000..1e8bc23
Binary files /dev/null and b/addons/godotsteam/win64/~libgodotsteam.windows.template_debug.x86_64.dll~RF17ff0bdf.TMP differ
diff --git a/addons/godotsteam/win64/~libgodotsteam.windows.template_debug.x86_64.dll~RF306d3c62.TMP b/addons/godotsteam/win64/~libgodotsteam.windows.template_debug.x86_64.dll~RF306d3c62.TMP
new file mode 100644
index 0000000..1e8bc23
Binary files /dev/null and b/addons/godotsteam/win64/~libgodotsteam.windows.template_debug.x86_64.dll~RF306d3c62.TMP differ
diff --git a/assets/data/version.json b/assets/data/version.json
index 817fd3a..fec0237 100644
--- a/assets/data/version.json
+++ b/assets/data/version.json
@@ -1,7 +1,40 @@
{
- "latest_version": "2.3.2",
+ "latest_version": "2.3.4",
"minimum_app_version": "2.1.0",
"releases": [
+ {
+ "version": "2.3.4",
+ "date": "2026-05-15",
+ "pck_url": "https://raw.githubusercontent.com/adtpdn/tekton-updates/main/latest/patch.pck",
+ "pck_size": 0,
+ "changelog": [
+ "Modernized Gacha and Fragment Craft UI with Tekton dark theme and 4px bordered panels",
+ "Redesigned Star/Gold banner tabs with Cyan (inactive) and Dark Blue (active) styling",
+ "Replaced text balances with icon-based currency panels matching the Lobby style",
+ "Added individual fragment quantity displays (Common, Uncommon, Rare) in Craft Panel",
+ "Fixed RatesLabel text visibility in Gacha Panel (changed brown to light grey)",
+ "Updated ProfilePanel category tabs to use text labels instead of emojis",
+ "Standardized compact, fixed-width currency display logic across all sub-menus"
+ ]
+ },
+ {
+ "version": "2.3.3",
+ "date": "2026-05-13",
+ "pck_url": "https://raw.githubusercontent.com/adtpdn/tekton-updates/main/latest/patch.pck",
+ "pck_size": 0,
+ "changelog": [
+ "Modernized Shop UI to match Lobby's dark theme and neutral dark-grey aesthetic",
+ "Replaced Label-based currency indicators with pill-shaped containers and high-quality gold/star textures",
+ "Integrated RichTextLabel with BBCode support for inline currency icons across all shop cards",
+ "Implemented active-state highlighting for shop tabs (white bg/dark text for selected)",
+ "Added featured banner system with sidebar support for event-based selling",
+ "Added 'Shop' tab to Admin Panel to manage featured banner slots and event labels",
+ "Standardized UI margins, button padding, and minimum sizes across shop panels",
+ "Added server-side RPCs for persistent featured banner management",
+ "Fixed missing dependency errors by clearing stale .godot cache and UID references",
+ "Implemented _populate_banners() function in shop_panel.gd"
+ ]
+ },
{
"version": "2.3.2",
"date": "2026-05-12",
diff --git a/assets/graphics/gui/inventory/item_placeholder.png b/assets/graphics/gui/inventory/item_placeholder.png
new file mode 100644
index 0000000..988facd
Binary files /dev/null and b/assets/graphics/gui/inventory/item_placeholder.png differ
diff --git a/assets/graphics/gui/inventory/item_placeholder.png.import b/assets/graphics/gui/inventory/item_placeholder.png.import
new file mode 100644
index 0000000..be47504
--- /dev/null
+++ b/assets/graphics/gui/inventory/item_placeholder.png.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://dhha66txpcgus"
+path="res://.godot/imported/item_placeholder.png-1bce3068ad9d08af10973c1bc3b84751.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/graphics/gui/inventory/item_placeholder.png"
+dest_files=["res://.godot/imported/item_placeholder.png-1bce3068ad9d08af10973c1bc3b84751.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
diff --git a/assets/models/meshes/block.res b/assets/models/meshes/block.res
index 353d5a5..25ec462 100644
Binary files a/assets/models/meshes/block.res and b/assets/models/meshes/block.res differ
diff --git a/docs/rpc_migration_audit.md b/docs/rpc_migration_audit.md
new file mode 100644
index 0000000..928da7b
--- /dev/null
+++ b/docs/rpc_migration_audit.md
@@ -0,0 +1,89 @@
+# Nakama JS → Lua Migration Audit
+
+## JS RPC Registration (core.js.bak lines 12-72)
+
+| # | JS RPC Name | JS Function | Lua Module | Lua RPC Name | Status |
+|---|---|---|---|---|---|
+| 1 | `admin_kick_player` | `rpcAdminKickPlayer` | admin.lua | `lua_admin_kick_player` | ✅ |
+| 2 | `admin_ban_player` | `rpcAdminBanPlayer` | admin.lua | `lua_admin_ban_player` | ✅ |
+| 3 | `admin_unban_player` | `rpcAdminUnbanPlayer` | admin.lua | `lua_admin_unban_player` | ✅ |
+| 4 | `admin_get_ban_list` | `rpcAdminGetBanList` | admin.lua | `lua_admin_get_ban_list` | ✅ |
+| 5 | `admin_get_server_stats` | `rpcAdminGetServerStats` | admin.lua | `lua_admin_get_server_stats` | ✅ |
+| 6 | `admin_get_player_list` | `rpcAdminGetPlayerList` | admin.lua | `lua_admin_get_player_list` | ✅ Fixed |
+| 7 | `admin_end_match` | `rpcAdminEndMatch` | admin.lua | `lua_admin_end_match` | ✅ |
+| 8 | `admin_set_user_role` | `rpcAdminSetUserRole` | admin.lua | `lua_admin_set_user_role` | ✅ |
+| 9 | `admin_list_users` | `rpcAdminListUsers` | admin.lua | `lua_admin_list_users` | ✅ |
+| 10 | `admin_delete_users` | `rpcAdminDeleteUsers` | admin.lua | `lua_admin_delete_users` | ✅ Bug fixed |
+| 11 | `admin_topup_gold` | `rpcAdminTopupGold` | admin.lua | `lua_admin_topup_gold` | ✅ |
+| 12 | `admin_clear_global_chat` | `rpcAdminClearGlobalChat` | admin.lua | `lua_admin_clear_global_chat` | ✅ |
+| 13 | `get_user_profile` | `rpcGetUserProfile` | user.lua | `lua_get_user_profile` | ✅ |
+| 14 | `update_user_profile` | `rpcUpdateUserProfile` | user.lua | `lua_update_user_profile` | ✅ |
+| 15 | `search_users` | `rpcSearchUsers` | user.lua | `lua_search_users` | ✅ |
+| 16 | `get_leaderboard_stats` | `rpcGetLeaderboardStats` | leaderboard.lua | `lua_get_leaderboard_stats` | ✅ |
+| 17 | `admin_update_stats` | `rpcAdminUpdateStats` | leaderboard.lua | `lua_admin_update_stats` | ✅ |
+| 18 | `admin_delete_stats` | `rpcAdminDeleteStats` | leaderboard.lua | `lua_admin_delete_stats` | ✅ |
+| 19 | `admin_sync_leaderboard` | `rpcAdminSyncLeaderboard` | leaderboard.lua | `lua_admin_sync_leaderboard` | ✅ |
+| 20 | `submit_score` | `rpcSubmitScore` | leaderboard.lua | `lua_submit_score` | ✅ |
+| 21 | `sync_leaderboard` | `rpcSyncLeaderboard` | leaderboard.lua | `lua_sync_leaderboard` | ✅ |
+| 22 | `change_credentials` | `rpcChangeCredentials` | user.lua | `lua_change_credentials` | ✅ |
+| 23 | `reset_stats` | `rpcResetStats` | leaderboard.lua | `lua_reset_stats` | ✅ |
+| 24 | `send_lobby_invite` | `rpcSendLobbyInvite` | user.lua | `lua_send_lobby_invite` | ✅ |
+| 25 | `send_friend_request` | `rpcSendFriendRequest` | user.lua | `lua_send_friend_request` | ✅ |
+| 26 | `claim_daily_reward` | `rpcClaimDailyReward` | daily_rewards.lua | `lua_claim_daily_reward` | ✅ |
+| 27 | `get_daily_reward_state` | `rpcGetDailyRewardState` | daily_rewards.lua | `lua_get_daily_reward_state` | ✅ |
+| 28 | `set_daily_reward_config` | `rpcSetDailyRewardConfig` | daily_rewards.lua | `lua_set_daily_reward_config` | ✅ |
+| 29 | `get_daily_reward_config_admin` | `rpcGetDailyRewardConfigAdmin` | daily_rewards.lua | `lua_get_daily_reward_config_admin` | ✅ |
+| 30 | `admin_send_mail` | `rpcAdminSendMail` | inbox.lua | `lua_admin_send_mail` | ✅ |
+| 31 | `admin_list_mail` | `rpcAdminListMail` | inbox.lua | `lua_admin_list_mail` | ✅ |
+| 32 | `admin_update_mail` | `rpcAdminUpdateMail` | inbox.lua | `lua_admin_update_mail` | ✅ |
+| 33 | `admin_delete_mail_server` | `rpcAdminDeleteMailServer` | inbox.lua | `lua_admin_delete_mail_server` | ✅ |
+| 34 | `get_mail` | `rpcGetMail` | inbox.lua | `lua_get_mail` | ✅ |
+| 35 | `claim_mail_reward` | `rpcClaimMailReward` | inbox.lua | `lua_claim_mail_reward` | ✅ |
+| 36 | `delete_mail` | `rpcDeleteMail` | inbox.lua | `lua_delete_mail` | ✅ |
+| 37 | `save_mail_state` | `rpcSaveMailState` | inbox.lua | `lua_save_mail_state` | ✅ |
+| 38 | `purchase_item` | `rpcPurchaseItem` | economy.lua | `lua_purchase_item` | ✅ |
+| 39 | `get_shop_catalog` | `rpcGetShopCatalog` | economy.lua | `lua_get_shop_catalog` | ✅ |
+| 40 | `buy_currency` | `rpcBuyCurrency` | economy.lua | `lua_buy_currency` | ✅ |
+| 41 | `admin_set_featured_banners` | `rpcAdminSetFeaturedBanners` | economy.lua | `lua_admin_set_featured_banners` | ✅ |
+| 42 | `admin_get_featured_banners` | `rpcAdminGetFeaturedBanners` | economy.lua | `lua_admin_get_featured_banners` | ✅ |
+
+## Hooks
+
+| JS Hook | JS Function | Lua Module | Status |
+|---|---|---|---|
+| `registerAfterAuthenticateSteam` | `afterAuthenticateSteam` | core.lua | ✅ Fixed |
+
+## Non-RPC Items
+
+| Item | Lua Module | Status |
+|---|---|---|
+| `leaderboardCreate("global_high_score")` | core.lua | ✅ |
+| `ADMIN_ROLES` definition | utils.lua | ✅ |
+| `isAdmin` / `isMatchHost` helpers | utils.lua | ✅ |
+| `requireAdmin` | utils.lua | ✅ |
+| `requireAdminOrHost` | utils.lua | ✅ |
+
+## Bugs Fixed This Session
+
+1. **`admin_get_player_list` RPC was missing** — Added to admin.lua
+2. **`afterAuthenticateSteam` hook was missing** — Added to core.lua via `nk.register_req_after`
+3. **`admin_delete_users` metadata bug** — `pcall(nk.json_decode, ...)` result was discarded, so the admin-role guard never worked. Fixed to assign decoded result.
+4. **`core.lua` had stale duplicate stubs** — Duplicate `rpc_get_user_profile` and `rpc_admin_kick_player` registrations conflicted with user.lua and admin.lua. Removed.
+
+## Final Module Map
+
+```
+server/nakama/
+├── main.lua # Entrypoint - requires all modules
+├── lua/
+│ ├── utils.lua # Shared helpers (is_admin, require_admin, etc.)
+│ ├── core.lua # Steam auth hook + leaderboard creation
+│ ├── admin.lua # 12 admin RPCs
+│ ├── user.lua # 6 user RPCs
+│ ├── daily_rewards.lua # 4 daily reward RPCs
+│ ├── leaderboard.lua # 7 leaderboard RPCs
+│ ├── inbox.lua # 8 inbox/mail RPCs
+│ └── economy.lua # 5 economy/shop RPCs
+```
+
+**Total: 42 RPCs + 1 auth hook = full 1:1 parity with core.js.bak** ✅
diff --git a/docs/tekton-dash-knowledge-base.html b/docs/tekton-dash-knowledge-base.html
new file mode 100644
index 0000000..979f6a4
--- /dev/null
+++ b/docs/tekton-dash-knowledge-base.html
@@ -0,0 +1,2191 @@
+
+
+
+
+
+
+ Knowledge Base: Multi-Platform & Regional Production Deployment Blueprint
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ A highly descriptive, production-centric strategy for engineering global, multi-store authentication,
+ transaction validation, and regulatory compliance networks utilizing Godot 4 and Heroic Labs Nakama.
+
+
+
+
+
+
+
+
Core Jurisdictions
+
+
+
+
+
+
+
+ Europe, Mainland China Transit & APAC
+ Networks
+
+
+
+
+
+
+
IAP Validation
+
Asynchronous, Server-to-Server, Ledger-Signed
+ Receipt Verification
+
+
+
+
+
+
Store Integration
+
Steamworks, Google Play, Apple App Store, TapSDK
+ (Zero-Commission)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Blueprint released under MIT authorization. Secure server implementations are responsibility of the
+ deployment architecture team.
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/export_presets.cfg b/export_presets.cfg
index c8761db..bb28c88 100644
--- a/export_presets.cfg
+++ b/export_presets.cfg
@@ -8,7 +8,7 @@ custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
-export_path="build/tekton_armageddon_v2.3.1.exe"
+export_path="build/tekton_armageddon_v2.3.4.exe"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
@@ -42,8 +42,8 @@ application/modify_resources=false
application/icon=""
application/console_wrapper_icon=""
application/icon_interpolation=4
-application/file_version="2.3.1"
-application/product_version="2.3.1"
+application/file_version="2.3.4"
+application/product_version="2.3.4"
application/company_name="DanchieGo"
application/product_name="Tekton Armageddon"
application/file_description=""
@@ -80,7 +80,7 @@ custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
-export_path="build/tekton-dash-armageddon-v.2.3.1.apk"
+export_path="build/tekton-dash-armageddon-v.2.3.4.apk"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
@@ -111,7 +111,7 @@ architectures/arm64-v8a=true
architectures/x86=false
architectures/x86_64=false
version/code=3
-version/name="2.3.1"
+version/name="2.3.4"
package/unique_name="com.danchiego.$genname"
package/name="Tekton Dash Armageddon"
package/signed=true
@@ -306,7 +306,7 @@ custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
-export_path="build/tekton_armageddon_v2.3.1.zip"
+export_path="build/tekton_armageddon_v2.3.4.zip"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
@@ -565,8 +565,8 @@ codesign/digest_algorithm=1
codesign/identity_type=0
application/modify_resources=false
application/console_wrapper_icon=""
-application/file_version="2.3.1"
-application/product_version="2.3.1"
+application/file_version="2.3.4"
+application/product_version="2.3.4"
application/company_name="DanchieGo"
application/product_name="Tekton Armageddon"
application/file_description=""
@@ -582,7 +582,7 @@ custom_features=""
export_filter="all_resources"
include_filter=""
exclude_filter=""
-export_path="build/tekton_armageddon_v2.3.1.x86_64"
+export_path="build/tekton_armageddon_v2.3.4.x86_64"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
diff --git a/project.godot b/project.godot
index df5f358..c0af5f7 100644
--- a/project.godot
+++ b/project.godot
@@ -15,7 +15,7 @@ compatibility/default_parent_skeleton_in_mesh_instance_3d=true
[application]
config/name="Tekton Dash Armageddon"
-config/version="2.3.2"
+config/version="2.3.4"
run/main_scene="res://scenes/ui/boot_screen.tscn"
config/features=PackedStringArray("4.6", "Forward Plus")
boot_splash/bg_color=Color(0.16470589, 0.6745098, 0.9372549, 1)
diff --git a/scenes/ui/admin_panel.tscn b/scenes/ui/admin_panel.tscn
index e7e73b5..d613000 100644
--- a/scenes/ui/admin_panel.tscn
+++ b/scenes/ui/admin_panel.tscn
@@ -112,6 +112,13 @@ unique_name_in_owner = true
layout_mode = 2
text = "0 selected"
+[node name="HistoryBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(80, 36)
+layout_mode = 2
+text = "HISTORY"
+
+
[node name="BanBtn" type="Button" parent="Margin/VBox/Tabs/Users/UserActionBar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(80, 36)
@@ -440,3 +447,123 @@ text = "Delete"
unique_name_in_owner = true
layout_mode = 2
horizontal_alignment = 1
+
+[node name="Shop" type="VBoxContainer" parent="Margin/VBox/Tabs"]
+visible = false
+layout_mode = 2
+theme_override_constants/separation = 16
+metadata/_tab_index = 5
+
+[node name="HeaderLbl" type="Label" parent="Margin/VBox/Tabs/Shop"]
+layout_mode = 2
+text = "Featured Banner Slots (Event / Special)"
+theme_override_font_sizes/font_size = 15
+
+[node name="InfoLbl" type="Label" parent="Margin/VBox/Tabs/Shop"]
+layout_mode = 2
+autowrap_mode = 3
+text = "Each slot shows a cosmetic item as a special event banner in the Shop sidebar. Leave Item ID blank to hide the slot."
+
+[node name="SlotsVBox" type="VBoxContainer" parent="Margin/VBox/Tabs/Shop"]
+unique_name_in_owner = true
+layout_mode = 2
+theme_override_constants/separation = 12
+
+[node name="Slot1" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox"]
+layout_mode = 2
+theme_override_constants/separation = 10
+
+[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1"]
+custom_minimum_size = Vector2(60, 0)
+layout_mode = 2
+text = "Slot 1:"
+vertical_alignment = 1
+
+[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1"]
+layout_mode = 2
+size_flags_horizontal = 3
+placeholder_text = "Item ID (e.g. oldpop-blue-hat)"
+
+[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot1"]
+custom_minimum_size = Vector2(160, 0)
+layout_mode = 2
+placeholder_text = "Event label (e.g. LIMITED!)"
+
+[node name="Slot2" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox"]
+layout_mode = 2
+theme_override_constants/separation = 10
+
+[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2"]
+custom_minimum_size = Vector2(60, 0)
+layout_mode = 2
+text = "Slot 2:"
+vertical_alignment = 1
+
+[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2"]
+layout_mode = 2
+size_flags_horizontal = 3
+placeholder_text = "Item ID (e.g. oldpop-red-hat)"
+
+[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot2"]
+custom_minimum_size = Vector2(160, 0)
+layout_mode = 2
+placeholder_text = "Event label"
+
+[node name="Slot3" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop/SlotsVBox"]
+layout_mode = 2
+theme_override_constants/separation = 10
+
+[node name="SlotLbl" type="Label" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3"]
+custom_minimum_size = Vector2(60, 0)
+layout_mode = 2
+text = "Slot 3:"
+vertical_alignment = 1
+
+[node name="ItemIdEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3"]
+layout_mode = 2
+size_flags_horizontal = 3
+placeholder_text = "Item ID (e.g. oldpop-yellow-hat)"
+
+[node name="LabelEdit" type="LineEdit" parent="Margin/VBox/Tabs/Shop/SlotsVBox/Slot3"]
+custom_minimum_size = Vector2(160, 0)
+layout_mode = 2
+placeholder_text = "Event label"
+
+[node name="ShopActionBar" type="HBoxContainer" parent="Margin/VBox/Tabs/Shop"]
+layout_mode = 2
+theme_override_constants/separation = 8
+alignment = 2
+
+[node name="LoadBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(140, 36)
+layout_mode = 2
+text = "Load Current"
+
+[node name="SaveBannersBtn" type="Button" parent="Margin/VBox/Tabs/Shop/ShopActionBar"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(160, 36)
+layout_mode = 2
+[node name="HistoryDialog" type="AcceptDialog" parent="."]
+unique_name_in_owner = true
+title = "User History"
+size = Vector2i(700, 500)
+
+[node name="ScrollContainer" type="ScrollContainer" parent="HistoryDialog"]
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = 8.0
+offset_top = 8.0
+offset_right = -8.0
+offset_bottom = -49.0
+grow_horizontal = 2
+grow_vertical = 2
+
+[node name="HistoryText" type="RichTextLabel" parent="HistoryDialog/ScrollContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_vertical = 3
+focus_mode = 2
+selection_enabled = true
diff --git a/scenes/ui/fragment_craft_panel.tscn b/scenes/ui/fragment_craft_panel.tscn
index 3e131b3..d875ad0 100644
--- a/scenes/ui/fragment_craft_panel.tscn
+++ b/scenes/ui/fragment_craft_panel.tscn
@@ -1,8 +1,63 @@
[gd_scene format=3 uid="uid://frag_craft_panel_001"]
[ext_resource type="Script" path="res://scripts/ui/fragment_craft_panel.gd" id="1"]
-[ext_resource type="Theme" uid="uid://da337sh5qxi0s" path="res://assets/themes/ui_theme.tres" id="2"]
+[ext_resource type="Theme" uid="uid://cxab3xxy00" path="res://assets/themes/GUI_Tekton.tres" id="2"]
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="3_font"]
+[ext_resource type="Texture2D" uid="uid://jqvv6s55mlsk" path="res://assets/graphics/gui/BG.png" id="4_bg"]
+[ext_resource type="Texture2D" uid="uid://b5pp08fke7ptd" path="res://assets/graphics/gui/lobby/gold.png" id="5_common"]
+[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="6_uncommon"]
+[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="7_rare"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_OuterPanel"]
+content_margin_left = 4.0
+content_margin_top = 4.0
+content_margin_right = 4.0
+content_margin_bottom = 4.0
+bg_color = Color(0.14117648, 0.16862746, 0.19215687, 1)
+corner_radius_top_left = 12
+corner_radius_top_right = 12
+corner_radius_bottom_right = 12
+corner_radius_bottom_left = 12
+shadow_color = Color(0, 0, 0, 0.3529412)
+shadow_size = 4
+shadow_offset = Vector2(-2, 2)
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_InnerDark"]
+bg_color = Color(0.1, 0.1, 0.1, 1)
+corner_radius_top_left = 6
+corner_radius_top_right = 6
+corner_radius_bottom_right = 6
+corner_radius_bottom_left = 6
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gd4oi"]
+bg_color = Color(0, 0, 0, 0.48235294)
+border_color = Color(0.92941177, 0.91764706, 0.8862745, 1)
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tab_inactive"]
+content_margin_left = 16.0
+content_margin_top = 14.0
+content_margin_right = 16.0
+content_margin_bottom = 14.0
+bg_color = Color(0.33, 0.62, 0.78, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tab_active"]
+content_margin_left = 16.0
+content_margin_top = 14.0
+content_margin_right = 16.0
+content_margin_bottom = 14.0
+bg_color = Color(0.1, 0.19, 0.27, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
[node name="FragmentCraftPanel" type="Control"]
layout_mode = 3
@@ -15,13 +70,24 @@ theme = ExtResource("2")
script = ExtResource("1")
[node name="Background" type="ColorRect" parent="."]
+visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
-color = Color(0.04, 0.04, 0.08, 0.96)
+color = Color(0.0627451, 0.0745098, 0.101961, 1)
+
+[node name="BackgroundTexture" type="TextureRect" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+texture = ExtResource("4_bg")
+expand_mode = 2
[node name="MainMargin" type="MarginContainer" parent="."]
layout_mode = 1
@@ -49,6 +115,9 @@ custom_minimum_size = Vector2(44, 44)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_font")
theme_override_font_sizes/font_size = 22
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
text = "←"
[node name="TitleLabel" type="Label" parent="MainMargin/MainVBox/TopBar"]
@@ -57,19 +126,225 @@ size_flags_horizontal = 3
theme_override_colors/font_color = Color(0.4, 1.0, 0.7, 1)
theme_override_fonts/font = ExtResource("3_font")
theme_override_font_sizes/font_size = 24
-text = "🧩 Fragment Craft"
+text = "Fragment Craft"
-[node name="FragBalance" type="Label" parent="MainMargin/MainVBox/TopBar"]
+[node name="FragRow" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar"]
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_constants/separation = 8
+alignment = 2
+
+[node name="CommonPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/FragRow"]
+custom_minimum_size = Vector2(80, 0)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
+
+[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/FragRow/CommonPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 6
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 4
+
+[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/FragRow/CommonPanel/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 4
+
+[node name="Icon" type="Label" parent="MainMargin/MainVBox/TopBar/FragRow/CommonPanel/Margin/HBox"]
+layout_mode = 2
+text = "⬜"
+
+[node name="CommonLabel" type="Label" parent="MainMargin/MainVBox/TopBar/FragRow/CommonPanel/Margin/HBox"]
unique_name_in_owner = true
layout_mode = 2
-theme_override_colors/font_color = Color(0.85, 0.85, 0.85, 1)
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 13
+text = "0"
+horizontal_alignment = 2
+
+[node name="UncommonPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/FragRow"]
+custom_minimum_size = Vector2(80, 0)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
+
+[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/FragRow/UncommonPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 6
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 4
+
+[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/FragRow/UncommonPanel/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 4
+
+[node name="Icon" type="Label" parent="MainMargin/MainVBox/TopBar/FragRow/UncommonPanel/Margin/HBox"]
+layout_mode = 2
+text = "🟩"
+
+[node name="UncommonLabel" type="Label" parent="MainMargin/MainVBox/TopBar/FragRow/UncommonPanel/Margin/HBox"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 13
+text = "0"
+horizontal_alignment = 2
+
+[node name="RarePanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/FragRow"]
+custom_minimum_size = Vector2(80, 0)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
+
+[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/FragRow/RarePanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 6
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 4
+
+[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/FragRow/RarePanel/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 4
+
+[node name="Icon" type="Label" parent="MainMargin/MainVBox/TopBar/FragRow/RarePanel/Margin/HBox"]
+layout_mode = 2
+text = "🟦"
+
+[node name="RareLabel" type="Label" parent="MainMargin/MainVBox/TopBar/FragRow/RarePanel/Margin/HBox"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 13
+text = "0"
+horizontal_alignment = 2
+
+[node name="FragBalanceRow" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar"]
+visible = false
+layout_mode = 2
+theme_override_constants/separation = 6
+
+custom_minimum_size = Vector2(70, 30)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi")
+
+[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/CommonPanel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/margin_left = 3
+theme_override_constants/margin_top = 3
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/CommonPanel/MarginContainer"]
+layout_mode = 2
+
+[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/CommonPanel/MarginContainer/HBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(20, 20)
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+texture = ExtResource("5_common")
+expand_mode = 1
+stretch_mode = 5
+
+[node name="CommonLabel" type="Label" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/CommonPanel/MarginContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
theme_override_fonts/font = ExtResource("3_font")
theme_override_font_sizes/font_size = 14
-text = "⬜ ×0 🟩 ×0 🟦 ×0"
+text = "0"
+horizontal_alignment = 2
+
+[node name="UncommonPanel" type="Panel" parent="MainMargin/MainVBox/TopBar/FragBalanceRow"]
+custom_minimum_size = Vector2(70, 30)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi")
+
+[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/UncommonPanel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/margin_left = 3
+theme_override_constants/margin_top = 3
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/UncommonPanel/MarginContainer"]
+layout_mode = 2
+
+[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/UncommonPanel/MarginContainer/HBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(20, 20)
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+texture = ExtResource("6_uncommon")
+expand_mode = 1
+stretch_mode = 5
+
+[node name="UncommonLabel" type="Label" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/UncommonPanel/MarginContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 14
+text = "0"
+horizontal_alignment = 2
+
+[node name="RarePanel" type="Panel" parent="MainMargin/MainVBox/TopBar/FragBalanceRow"]
+custom_minimum_size = Vector2(70, 30)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi")
+
+[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/RarePanel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/margin_left = 3
+theme_override_constants/margin_top = 3
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/RarePanel/MarginContainer"]
+layout_mode = 2
+
+[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/RarePanel/MarginContainer/HBoxContainer"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(20, 20)
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+texture = ExtResource("7_rare")
+expand_mode = 1
+stretch_mode = 5
+
+[node name="RareLabel" type="Label" parent="MainMargin/MainVBox/TopBar/FragBalanceRow/RarePanel/MarginContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 14
+text = "0"
+horizontal_alignment = 2
[node name="SubTitle" type="Label" parent="MainMargin/MainVBox"]
layout_mode = 2
-theme_override_colors/font_color = Color(0.65, 0.65, 0.65, 1)
+theme_override_colors/font_color = Color(1.0, 1.0, 1.0, 1)
theme_override_font_sizes/font_size = 13
text = "Collect Common, Uncommon, and Rare fragments from gacha pulls to craft exclusive skins."
diff --git a/scenes/ui/gacha_panel.tscn b/scenes/ui/gacha_panel.tscn
index f43fda7..cf8bfe8 100644
--- a/scenes/ui/gacha_panel.tscn
+++ b/scenes/ui/gacha_panel.tscn
@@ -1,8 +1,85 @@
-[gd_scene format=3 uid="uid://gacha_panel_001"]
+ [gd_scene format=3 uid="uid://gacha_panel_001"]
[ext_resource type="Script" path="res://scripts/ui/gacha_panel.gd" id="1"]
-[ext_resource type="Theme" uid="uid://da337sh5qxi0s" path="res://assets/themes/ui_theme.tres" id="2"]
+[ext_resource type="Theme" uid="uid://cxab3xxy00" path="res://assets/themes/GUI_Tekton.tres" id="2"]
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="3_font"]
+[ext_resource type="Texture2D" uid="uid://jqvv6s55mlsk" path="res://assets/graphics/gui/BG.png" id="4_bg"]
+[ext_resource type="Texture2D" uid="uid://b5pp08fke7ptd" path="res://assets/graphics/gui/lobby/gold.png" id="5_gold"]
+[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="6_star"]
+[ext_resource type="Texture2D" uid="uid://b6is65v4h87u8" path="res://assets/graphics/gui/lobby/star.png" id="tex_star"]
+[ext_resource type="Texture2D" uid="uid://be5i65v4h87u7" path="res://assets/graphics/gui/lobby/gold.png" id="tex_gold"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_OuterPanel"]
+content_margin_left = 12.0
+content_margin_top = 12.0
+content_margin_right = 12.0
+content_margin_bottom = 12.0
+bg_color = Color(0.14117648, 0.16862746, 0.19215687, 1)
+corner_radius_top_left = 12
+corner_radius_top_right = 12
+corner_radius_bottom_right = 12
+corner_radius_bottom_left = 12
+shadow_color = Color(0, 0, 0, 0.3529412)
+shadow_size = 4
+shadow_offset = Vector2(-2, 2)
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_InnerDark"]
+bg_color = Color(0.1, 0.1, 0.1, 1)
+corner_radius_top_left = 6
+corner_radius_top_right = 6
+corner_radius_bottom_right = 6
+corner_radius_bottom_left = 6
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gd4oi"]
+bg_color = Color(0, 0, 0, 0.48235294)
+border_color = Color(0.92941177, 0.91764706, 0.8862745, 1)
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tab_inactive"]
+content_margin_left = 16.0
+content_margin_top = 14.0
+content_margin_right = 16.0
+content_margin_bottom = 14.0
+bg_color = Color(0.33, 0.62, 0.78, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tab_active"]
+content_margin_left = 16.0
+content_margin_top = 14.0
+content_margin_right = 16.0
+content_margin_bottom = 14.0
+bg_color = Color(0.1, 0.19, 0.27, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_banner_tab_inactive"]
+bg_color = Color(0.33, 0.62, 0.78, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_banner_tab_active"]
+bg_color = Color(0.1, 0.19, 0.27, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_BtnDark"]
+bg_color = Color(0.15, 0.15, 0.15, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
[node name="GachaPanel" type="Control"]
layout_mode = 3
@@ -15,13 +92,24 @@ theme = ExtResource("2")
script = ExtResource("1")
[node name="Background" type="ColorRect" parent="."]
+visible = false
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
-color = Color(0.04, 0.04, 0.08, 0.95)
+color = Color(0.0627451, 0.0745098, 0.101961, 1)
+
+[node name="BackgroundTexture" type="TextureRect" parent="."]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+texture = ExtResource("4_bg")
+expand_mode = 2
[node name="MainMargin" type="MarginContainer" parent="."]
layout_mode = 1
@@ -47,18 +135,87 @@ theme_override_constants/separation = 10
unique_name_in_owner = true
custom_minimum_size = Vector2(44, 44)
layout_mode = 2
-theme_override_fonts/font = ExtResource("3_font")
-theme_override_font_sizes/font_size = 22
text = "←"
[node name="TitleLabel" type="Label" parent="MainMargin/MainVBox/TopBar"]
layout_mode = 2
-size_flags_horizontal = 3
theme_override_colors/font_color = Color(1.0, 0.85, 0.25, 1)
theme_override_fonts/font = ExtResource("3_font")
theme_override_font_sizes/font_size = 26
text = "✨ Gacha"
+[node name="CurrencyRow" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar"]
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_constants/separation = 8
+alignment = 2
+
+[node name="StarPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
+
+[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 6
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 4
+
+[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 4
+
+[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox"]
+custom_minimum_size = Vector2(20, 20)
+layout_mode = 2
+size_flags_vertical = 4
+texture = ExtResource("tex_star")
+expand_mode = 1
+stretch_mode = 5
+
+[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/StarPanel/Margin/HBox"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 14
+text = "0"
+horizontal_alignment = 2
+
+[node name="GoldPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
+
+[node name="Margin" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 6
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 4
+
+[node name="HBox" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 4
+
+[node name="Icon" type="TextureRect" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox"]
+custom_minimum_size = Vector2(20, 20)
+layout_mode = 2
+size_flags_vertical = 4
+texture = ExtResource("tex_gold")
+expand_mode = 1
+stretch_mode = 5
+
+[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/TopBar/CurrencyRow/GoldPanel/Margin/HBox"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 14
+text = "0"
+horizontal_alignment = 2
+
[node name="CraftBtn" type="Button" parent="MainMargin/MainVBox/TopBar"]
unique_name_in_owner = true
custom_minimum_size = Vector2(130, 40)
@@ -66,6 +223,9 @@ layout_mode = 2
theme_override_colors/font_color = Color(0.4, 1.0, 0.7, 1)
theme_override_fonts/font = ExtResource("3_font")
theme_override_font_sizes/font_size = 14
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
text = "🧩 Fragment Craft"
[node name="BannerTabs" type="HBoxContainer" parent="MainMargin/MainVBox"]
@@ -75,18 +235,24 @@ alignment = 1
[node name="StarTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(160, 48)
+custom_minimum_size = Vector2(130, 38)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_font")
-theme_override_font_sizes/font_size = 18
+theme_override_font_sizes/font_size = 14
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
text = "✦ Star Banner"
[node name="GoldTabBtn" type="Button" parent="MainMargin/MainVBox/BannerTabs"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(160, 48)
+custom_minimum_size = Vector2(130, 38)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_font")
-theme_override_font_sizes/font_size = 18
+theme_override_font_sizes/font_size = 14
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
text = "▤ Gold Banner"
[node name="ContentHBox" type="HBoxContainer" parent="MainMargin/MainVBox"]
@@ -97,6 +263,7 @@ theme_override_constants/separation = 20
[node name="LeftPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox"]
custom_minimum_size = Vector2(320, 0)
layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
[node name="LeftMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel"]
layout_mode = 2
@@ -120,22 +287,96 @@ horizontal_alignment = 1
[node name="BalanceRow" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
layout_mode = 2
-theme_override_constants/separation = 8
+theme_override_constants/separation = 6
alignment = 1
+[node name="GoldPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"]
+custom_minimum_size = Vector2(100, 30)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi")
+
+[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/margin_left = 3
+theme_override_constants/margin_top = 3
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer"]
+layout_mode = 2
+
+[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+texture = ExtResource("5_gold")
+
+[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/GoldPanel/MarginContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 18
+text = "0"
+horizontal_alignment = 2
+
+[node name="StarPanel" type="Panel" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"]
+custom_minimum_size = Vector2(100, 30)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi")
+
+[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/margin_left = 3
+theme_override_constants/margin_top = 3
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer"]
+layout_mode = 2
+
+[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer"]
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+texture = ExtResource("6_star")
+
+[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow/StarPanel/MarginContainer/HBoxContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 18
+text = "0"
+horizontal_alignment = 2
+
[node name="BalanceLbl" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"]
+visible = false
layout_mode = 2
theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1)
theme_override_font_sizes/font_size = 13
text = "Balance:"
+horizontal_alignment = 1
[node name="BalanceLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/BalanceRow"]
unique_name_in_owner = true
+visible = false
layout_mode = 2
theme_override_colors/font_color = Color(0.9, 0.75, 0.2, 1)
theme_override_fonts/font = ExtResource("3_font")
theme_override_font_sizes/font_size = 18
text = "✦ 0"
+horizontal_alignment = 2
[node name="PityLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
unique_name_in_owner = true
@@ -159,6 +400,9 @@ layout_mode = 2
size_flags_horizontal = 3
theme_override_fonts/font = ExtResource("3_font")
theme_override_font_sizes/font_size = 16
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
text = "1× Pull"
[node name="Cost1Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull1Row"]
@@ -180,6 +424,9 @@ layout_mode = 2
size_flags_horizontal = 3
theme_override_fonts/font = ExtResource("3_font")
theme_override_font_sizes/font_size = 16
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
text = "10× Pull"
[node name="Cost10Label" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox/Pull10Row"]
@@ -193,7 +440,7 @@ text = "✦ 1440"
[node name="StatusLabel" type="Label" parent="MainMargin/MainVBox/ContentHBox/LeftPanel/LeftMargin/LeftVBox"]
unique_name_in_owner = true
layout_mode = 2
-theme_override_colors/font_color = Color(0.9, 0.4, 0.4, 1)
+theme_override_colors/font_color = Color(1.0, 1.0, 1.0, 1)
theme_override_font_sizes/font_size = 13
horizontal_alignment = 1
text = ""
@@ -201,6 +448,7 @@ text = ""
[node name="RightPanel" type="PanelContainer" parent="MainMargin/MainVBox/ContentHBox"]
layout_mode = 2
size_flags_horizontal = 3
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
[node name="RightMargin" type="MarginContainer" parent="MainMargin/MainVBox/ContentHBox/RightPanel"]
layout_mode = 2
@@ -214,7 +462,7 @@ unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
-theme_override_colors/default_color = Color(0.35, 0.2, 0.1, 1)
+theme_override_colors/default_color = Color(0.75, 0.78, 0.82, 1)
theme_override_font_sizes/font_size = 13
text = ""
fit_content = true
@@ -223,6 +471,7 @@ fit_content = true
unique_name_in_owner = true
visible = false
layout_mode = 1
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
@@ -265,4 +514,7 @@ unique_name_in_owner = true
custom_minimum_size = Vector2(0, 38)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_font")
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_inactive")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_active")
text = "Close"
diff --git a/scenes/ui/leaderboard_panel.tscn b/scenes/ui/leaderboard_panel.tscn
index b322e3b..5f5e96e 100644
--- a/scenes/ui/leaderboard_panel.tscn
+++ b/scenes/ui/leaderboard_panel.tscn
@@ -10,9 +10,38 @@
[ext_resource type="PackedScene" uid="uid://bmln7v6v5kvxg" path="res://assets/characters/Oldpop.glb" id="4_oldpop"]
[ext_resource type="AnimationLibrary" uid="uid://c3pyopnwibckj" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"]
-[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_amgyn"]
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_OuterPanel"]
+bg_color = Color(0.42, 0.47, 0.52, 1)
+border_width_left = 4
+border_width_top = 4
+border_width_right = 4
+border_width_bottom = 4
+border_color = Color(0.25, 0.3, 0.35, 1)
+corner_radius_top_left = 12
+corner_radius_top_right = 12
+corner_radius_bottom_right = 12
+corner_radius_bottom_left = 12
-[sub_resource type="StyleBoxEmpty" id="StyleBoxEmpty_q8r81"]
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_InnerDark"]
+bg_color = Color(0.2, 0.25, 0.3, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_BtnDark"]
+bg_color = Color(0.15, 0.15, 0.15, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_BtnBrown"]
+bg_color = Color(0.35, 0.25, 0.2, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
[sub_resource type="Environment" id="Environment_lb"]
background_mode = 1
@@ -21,7 +50,7 @@ ambient_light_source = 2
ambient_light_color = Color(0.5, 0.55, 0.75, 1)
ambient_light_energy = 0.7
-[node name="LeaderboardPanel" type="Control" unique_id=2123026133]
+[node name="LeaderboardPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
@@ -31,7 +60,7 @@ grow_vertical = 2
theme = ExtResource("1_q8r81")
script = ExtResource("1")
-[node name="Background" type="TextureRect" parent="." unique_id=1166091442]
+[node name="Background" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -41,198 +70,350 @@ grow_vertical = 2
texture = ExtResource("3_amgyn")
expand_mode = 1
-[node name="MarginContainer" type="MarginContainer" parent="." unique_id=1028567223]
+[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
-theme_override_constants/margin_left = 30
+theme_override_constants/margin_left = 40
theme_override_constants/margin_top = 30
-theme_override_constants/margin_right = 30
+theme_override_constants/margin_right = 40
theme_override_constants/margin_bottom = 30
-[node name="MainLayout" type="HBoxContainer" parent="MarginContainer" unique_id=53100533]
+[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
layout_mode = 2
-theme_override_constants/separation = 0
+theme_override_constants/separation = 16
-[node name="LeftPanel" type="PanelContainer" parent="MarginContainer/MainLayout" unique_id=125724276]
-custom_minimum_size = Vector2(500, 0)
+[node name="TopHBox" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
-size_flags_horizontal = 3
-theme_override_styles/panel = SubResource("StyleBoxEmpty_amgyn")
-[node name="LeftVBox" type="VBoxContainer" parent="MarginContainer/MainLayout/LeftPanel" unique_id=1333726314]
+[node name="Title" type="Label" parent="MarginContainer/VBoxContainer/TopHBox"]
+layout_mode = 2
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 48
+text = "LEADERBOARD"
+
+[node name="MainLayout" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
+layout_mode = 2
+size_flags_vertical = 3
+theme_override_constants/separation = 16
+
+[node name="LeftCol" type="VBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout"]
+custom_minimum_size = Vector2(300, 0)
+layout_mode = 2
+theme_override_constants/separation = 16
+
+[node name="SelectedPlayerInfo" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol"]
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
+
+[node name="Margin" type="MarginContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo"]
+layout_mode = 2
+theme_override_constants/margin_left = 12
+theme_override_constants/margin_top = 12
+theme_override_constants/margin_right = 12
+theme_override_constants/margin_bottom = 12
+
+[node name="HBox" type="HBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin"]
layout_mode = 2
theme_override_constants/separation = 12
-[node name="Header" type="HBoxContainer" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox" unique_id=2015043023]
+[node name="AvatarBorder" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin/HBox"]
layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_BtnDark")
+custom_minimum_size = Vector2(72, 72)
-[node name="BackBtn" type="Button" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox/Header" unique_id=1330539196]
+[node name="SelectedAvatarRect" type="TextureRect" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin/HBox/AvatarBorder"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(100, 44)
layout_mode = 2
-theme_override_fonts/font = ExtResource("3_font")
-text = "← BACK"
+expand_mode = 1
+stretch_mode = 5
-[node name="RefreshBtn" type="Button" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox/Header" unique_id=993543919]
-unique_name_in_owner = true
-visible = false
-custom_minimum_size = Vector2(44, 44)
-layout_mode = 2
-tooltip_text = "Refresh Data"
-text = "⟳"
-
-[node name="SyncBtn" type="Button" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox/Header" unique_id=1452457095]
-unique_name_in_owner = true
-custom_minimum_size = Vector2(160, 44)
-layout_mode = 2
-tooltip_text = "Sync your score to the global leaderboard"
-text = "⟳ Refresh
-"
-
-[node name="Title" type="Label" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox/Header" unique_id=1037998429]
+[node name="VBox" type="VBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin/HBox"]
layout_mode = 2
size_flags_horizontal = 3
-theme_override_colors/font_color = Color(1, 1, 1, 1)
-theme_override_fonts/font = ExtResource("3_font")
-theme_override_font_sizes/font_size = 26
-text = "LEADERBOARD"
-horizontal_alignment = 1
-
-[node name="SortTabs" type="HBoxContainer" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox" unique_id=718289870]
-layout_mode = 2
theme_override_constants/separation = 8
alignment = 1
-[node name="SortScoreBtn" type="Button" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox/SortTabs" unique_id=130573026]
+[node name="SelectedNameLabel" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin/HBox/VBox"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(120, 36)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_font")
-text = "High Score"
+text = "USERNAME"
-[node name="SortWinRateBtn" type="Button" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox/SortTabs" unique_id=1440940092]
+[node name="InnerPanel" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin/HBox/VBox"]
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
+
+[node name="Margin" type="MarginContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin/HBox/VBox/InnerPanel"]
+layout_mode = 2
+theme_override_constants/margin_left = 8
+theme_override_constants/margin_right = 8
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_bottom = 4
+
+[node name="InnerHBox" type="HBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin/HBox/VBox/InnerPanel/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 8
+
+[node name="SelectedScoreLabel" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin/HBox/VBox/InnerPanel/Margin/InnerHBox"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+text = "000000"
+
+[node name="SelectedRankLabel" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SelectedPlayerInfo/Margin/HBox/VBox/InnerPanel/Margin/InnerHBox"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(120, 36)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_font")
-text = "Win Rate"
+theme_override_colors/font_color = Color(0.4, 0.45, 0.5, 1)
+text = "Rank #00"
-[node name="SortGamesBtn" type="Button" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox/SortTabs" unique_id=1859306138]
+[node name="SpacerPanel" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol"]
+layout_mode = 2
+size_flags_vertical = 3
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
+
+[node name="StatusLabel" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/SpacerPanel"]
unique_name_in_owner = true
-custom_minimum_size = Vector2(120, 36)
+layout_mode = 2
+horizontal_alignment = 1
+
+[node name="BottomLeftButtons" type="HBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol"]
+layout_mode = 2
+theme_override_constants/separation = 12
+
+[node name="BackBtn" type="Button" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/BottomLeftButtons"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(100, 48)
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_styles/normal = SubResource("StyleBoxFlat_BtnDark")
+text = "◀ BACK"
+
+[node name="RefreshBtn" type="Button" parent="MarginContainer/VBoxContainer/MainLayout/LeftCol/BottomLeftButtons"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(100, 48)
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_styles/normal = SubResource("StyleBoxFlat_BtnBrown")
+text = "REFRESH"
+
+[node name="MidCol" type="VBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout"]
+layout_mode = 2
+size_flags_horizontal = 3
+size_flags_stretch_ratio = 1.3
+theme_override_constants/separation = 16
+
+[node name="LeaderboardContainer" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol"]
+layout_mode = 2
+size_flags_vertical = 3
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
+
+[node name="Margin" type="MarginContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer"]
+layout_mode = 2
+theme_override_constants/margin_left = 12
+theme_override_constants/margin_top = 12
+theme_override_constants/margin_right = 12
+theme_override_constants/margin_bottom = 12
+
+[node name="VBox" type="VBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 8
+
+[node name="HeaderRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox"]
+layout_mode = 2
+theme_override_constants/separation = 8
+
+[node name="RankPanel" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/HeaderRow"]
+custom_minimum_size = Vector2(80, 36)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
+
+[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/HeaderRow/RankPanel"]
layout_mode = 2
theme_override_fonts/font = ExtResource("3_font")
-text = "Games Played"
+theme_override_colors/font_color = Color(0.4, 0.45, 0.5, 1)
+text = "Rank"
+horizontal_alignment = 1
+vertical_alignment = 1
-[node name="HSeparator" type="HSeparator" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox" unique_id=1424980566]
+[node name="UserPanel" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/HeaderRow"]
layout_mode = 2
-theme_override_styles/separator = SubResource("StyleBoxEmpty_q8r81")
+size_flags_horizontal = 3
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
-[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox" unique_id=1067011185]
+[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/HeaderRow/UserPanel"]
+layout_mode = 2
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_colors/font_color = Color(0.4, 0.45, 0.5, 1)
+text = "Username"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="ScorePanel" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/HeaderRow"]
+custom_minimum_size = Vector2(100, 36)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
+
+[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/HeaderRow/ScorePanel"]
+layout_mode = 2
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_colors/font_color = Color(0.4, 0.45, 0.5, 1)
+text = "Score"
+horizontal_alignment = 1
+vertical_alignment = 1
+
+[node name="ScrollContainer" type="ScrollContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox"]
layout_mode = 2
size_flags_vertical = 3
-[node name="LeaderboardList" type="VBoxContainer" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox/ScrollContainer" unique_id=1303389084]
+[node name="LeaderboardList" type="VBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/ScrollContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 8
-[node name="StatusLabel" type="Label" parent="MarginContainer/MainLayout/LeftPanel/LeftVBox" unique_id=218097909]
+[node name="ItemTemplate" type="PanelContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/ScrollContainer/LeaderboardList"]
unique_name_in_owner = true
+custom_minimum_size = Vector2(0, 48)
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/ScrollContainer/LeaderboardList/ItemTemplate"]
+layout_mode = 2
+theme_override_constants/separation = 8
+
+[node name="RankLabel" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/ScrollContainer/LeaderboardList/ItemTemplate/HBoxContainer"]
+custom_minimum_size = Vector2(80, 0)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_font")
-text = "Loading data..."
+text = "1st"
horizontal_alignment = 1
-[node name="RightPanel" type="SubViewportContainer" parent="MarginContainer/MainLayout" unique_id=733341277]
-custom_minimum_size = Vector2(340, 0)
+[node name="Margin" type="MarginContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/ScrollContainer/LeaderboardList/ItemTemplate/HBoxContainer"]
layout_mode = 2
size_flags_horizontal = 3
-size_flags_stretch_ratio = 0.8
+theme_override_constants/margin_left = 12
+theme_override_constants/margin_right = 12
+
+[node name="InnerHBox" type="HBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/ScrollContainer/LeaderboardList/ItemTemplate/HBoxContainer/Margin"]
+layout_mode = 2
+theme_override_constants/separation = 16
+
+[node name="AvatarRect" type="TextureRect" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/ScrollContainer/LeaderboardList/ItemTemplate/HBoxContainer/Margin/InnerHBox"]
+custom_minimum_size = Vector2(32, 32)
+layout_mode = 2
+expand_mode = 1
+stretch_mode = 5
+
+[node name="NameLabel" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/ScrollContainer/LeaderboardList/ItemTemplate/HBoxContainer/Margin/InnerHBox"]
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+text = "Player_Username"
+
+[node name="ValueLabel" type="Label" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/LeaderboardContainer/Margin/VBox/ScrollContainer/LeaderboardList/ItemTemplate/HBoxContainer"]
+custom_minimum_size = Vector2(100, 0)
+layout_mode = 2
+theme_override_fonts/font = ExtResource("3_font")
+text = "00000000"
+horizontal_alignment = 1
+
+[node name="BottomMidButtons" type="HBoxContainer" parent="MarginContainer/VBoxContainer/MainLayout/MidCol"]
+layout_mode = 2
+theme_override_constants/separation = 12
+
+[node name="SortScoreBtn" type="Button" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/BottomMidButtons"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(120, 48)
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_styles/normal = SubResource("StyleBoxFlat_BtnBrown")
+text = "HighScore"
+
+[node name="SortWinRateBtn" type="Button" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/BottomMidButtons"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(120, 48)
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_styles/normal = SubResource("StyleBoxFlat_BtnBrown")
+text = "WinRate"
+
+[node name="SortGamesBtn" type="Button" parent="MarginContainer/VBoxContainer/MainLayout/MidCol/BottomMidButtons"]
+unique_name_in_owner = true
+custom_minimum_size = Vector2(120, 48)
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_styles/normal = SubResource("StyleBoxFlat_BtnBrown")
+text = "GamePlay"
+
+[node name="RightCol" type="Control" parent="MarginContainer/VBoxContainer/MainLayout"]
+layout_mode = 2
+size_flags_horizontal = 3
+
+[node name="SubViewportContainer" type="SubViewportContainer" parent="MarginContainer/VBoxContainer/MainLayout/RightCol"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
stretch = true
-[node name="PreviewViewport" type="SubViewport" parent="MarginContainer/MainLayout/RightPanel" unique_id=434116765]
+[node name="PreviewViewport" type="SubViewport" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer"]
unique_name_in_owner = true
own_world_3d = true
transparent_bg = true
handle_input_locally = false
-size = Vector2i(581, 708)
+size = Vector2i(300, 500)
render_target_update_mode = 4
-[node name="WorldEnvironment" type="WorldEnvironment" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport" unique_id=176076489]
+[node name="WorldEnvironment" type="WorldEnvironment" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport"]
environment = SubResource("Environment_lb")
-[node name="DirectionalLight3D" type="DirectionalLight3D" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport" unique_id=1487133814]
+[node name="DirectionalLight3D" type="DirectionalLight3D" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport"]
transform = Transform3D(0.866025, -0.25, 0.433013, 0, 0.866025, 0.5, -0.5, -0.433013, 0.75, 0, 4, 0)
light_energy = 1.4
-[node name="FillLight" type="OmniLight3D" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport" unique_id=1492921006]
+[node name="FillLight" type="OmniLight3D" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -2, 2, 2)
light_color = Color(0.4, 0.5, 1, 1)
light_energy = 0.5
omni_range = 8.0
-[node name="Camera3D" type="Camera3D" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport" unique_id=28500697]
+[node name="Camera3D" type="Camera3D" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport"]
transform = Transform3D(1, 0, 0, 0, 0.9659259, 0.25881898, 0, -0.25881898, 0.9659259, 0, 0.8380414, 3.2)
current = true
fov = 40.0
-[node name="CharacterRoot" type="Node3D" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport" unique_id=1908332248]
+[node name="CharacterRoot" type="Node3D" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport"]
unique_name_in_owner = true
-[node name="Masbro" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport/CharacterRoot" unique_id=1123428413 instance=ExtResource("4_masbro")]
+[node name="Masbro" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport/CharacterRoot" instance=ExtResource("4_masbro")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.485, 0)
visible = false
-[node name="Bob" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport/CharacterRoot" unique_id=1443851131 instance=ExtResource("4_bob")]
+[node name="Bob" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport/CharacterRoot" instance=ExtResource("4_bob")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.484627, 0)
visible = false
-[node name="Gatot" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport/CharacterRoot" unique_id=1688463565 instance=ExtResource("4_gatot")]
+[node name="Gatot" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport/CharacterRoot" instance=ExtResource("4_gatot")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.485, 0)
visible = false
-[node name="Oldpop" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport/CharacterRoot" unique_id=2101387155 instance=ExtResource("4_oldpop")]
+[node name="Oldpop" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport/CharacterRoot" instance=ExtResource("4_oldpop")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.485, 0)
-[node name="AnimationPlayer" type="AnimationPlayer" parent="MarginContainer/MainLayout/RightPanel/PreviewViewport/CharacterRoot" unique_id=264395095]
+[node name="AnimationPlayer" type="AnimationPlayer" parent="MarginContainer/VBoxContainer/MainLayout/RightCol/SubViewportContainer/PreviewViewport/CharacterRoot"]
root_node = NodePath("../Oldpop")
libraries/animation-pack = ExtResource("5_animlib")
-autoplay = &"animation-pack/idle"
-
-[node name="SelectedPlayerInfo" type="PanelContainer" parent="MarginContainer/MainLayout/RightPanel" unique_id=1592883771]
-layout_mode = 2
-size_flags_vertical = 8
-
-[node name="InfoMargin" type="MarginContainer" parent="MarginContainer/MainLayout/RightPanel/SelectedPlayerInfo" unique_id=882298034]
-layout_mode = 2
-theme_override_constants/margin_left = 20
-theme_override_constants/margin_top = 15
-theme_override_constants/margin_right = 20
-theme_override_constants/margin_bottom = 15
-
-[node name="InfoVBox" type="VBoxContainer" parent="MarginContainer/MainLayout/RightPanel/SelectedPlayerInfo/InfoMargin" unique_id=567154378]
-layout_mode = 2
-theme_override_constants/separation = 4
-
-[node name="SelectedNameLabel" type="Label" parent="MarginContainer/MainLayout/RightPanel/SelectedPlayerInfo/InfoMargin/InfoVBox" unique_id=1940372038]
-unique_name_in_owner = true
-layout_mode = 2
-theme_override_fonts/font = ExtResource("3_font")
-theme_override_font_sizes/font_size = 24
-text = "PLAYER NAME"
-horizontal_alignment = 1
-
-[node name="SelectedRankLabel" type="Label" parent="MarginContainer/MainLayout/RightPanel/SelectedPlayerInfo/InfoMargin/InfoVBox" unique_id=994755781]
-unique_name_in_owner = true
-layout_mode = 2
-theme_override_colors/font_color = Color(0.647, 0.996, 0.224, 1)
-theme_override_fonts/font = ExtResource("3_font")
-theme_override_font_sizes/font_size = 16
-text = "RANK #1"
-horizontal_alignment = 1
+autoplay = "animation-pack/idle"
diff --git a/scenes/ui/mailbox_panel.tscn b/scenes/ui/mailbox_panel.tscn
index 0fb0b75..7913419 100644
--- a/scenes/ui/mailbox_panel.tscn
+++ b/scenes/ui/mailbox_panel.tscn
@@ -2,7 +2,7 @@
[ext_resource type="Script" uid="uid://b5fema68m6b2s" path="res://scripts/ui/mailbox_panel.gd" id="1_a"]
[ext_resource type="Theme" uid="uid://cxab3xxy00" path="res://assets/themes/GUI_Tekton.tres" id="1_wi8mn"]
-[ext_resource type="Texture2D" uid="uid://dfmailbox" path="res://assets/graphics/gui/mainmenu/mailbox.png" id="tex_mailbox"]
+[ext_resource type="Texture2D" uid="uid://bm0d40n1rwvvs" path="res://assets/graphics/gui/mainmenu/mailbox.png" id="tex_mailbox"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_middle"]
bg_color = Color(0.02, 0.04, 0.08, 1)
@@ -16,13 +16,6 @@ corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
-[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_slot"]
-bg_color = Color(0.1, 0.3, 0.6, 0.6)
-corner_radius_top_left = 8
-corner_radius_top_right = 8
-corner_radius_bottom_right = 8
-corner_radius_bottom_left = 8
-
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_mailbtn"]
bg_color = Color(0.2, 0.4, 0.6, 1)
border_width_left = 2
@@ -35,7 +28,14 @@ corner_radius_top_right = 8
corner_radius_bottom_right = 8
corner_radius_bottom_left = 8
-[node name="MailboxPanel" type="Panel"]
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_slot"]
+bg_color = Color(0.1, 0.3, 0.6, 0.6)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
+[node name="MailboxPanel" type="Panel" unique_id=1279323201]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
@@ -44,7 +44,7 @@ grow_vertical = 2
theme = ExtResource("1_wi8mn")
script = ExtResource("1_a")
-[node name="BG" type="ColorRect" parent="."]
+[node name="BG" type="ColorRect" parent="." unique_id=2137631459]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -53,7 +53,7 @@ grow_horizontal = 2
grow_vertical = 2
color = Color(0.12, 0.4, 0.9, 1)
-[node name="Margin" type="MarginContainer" parent="."]
+[node name="Margin" type="MarginContainer" parent="." unique_id=1361806928]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -65,46 +65,46 @@ theme_override_constants/margin_top = 40
theme_override_constants/margin_right = 40
theme_override_constants/margin_bottom = 40
-[node name="VBox" type="VBoxContainer" parent="Margin"]
+[node name="VBox" type="VBoxContainer" parent="Margin" unique_id=1690554709]
layout_mode = 2
theme_override_constants/separation = 20
-[node name="HeaderHBox" type="HBoxContainer" parent="Margin/VBox"]
+[node name="HeaderHBox" type="HBoxContainer" parent="Margin/VBox" unique_id=2059547048]
layout_mode = 2
-[node name="Icon" type="TextureRect" parent="Margin/VBox/HeaderHBox"]
+[node name="Icon" type="TextureRect" parent="Margin/VBox/HeaderHBox" unique_id=306927751]
custom_minimum_size = Vector2(48, 48)
layout_mode = 2
texture = ExtResource("tex_mailbox")
expand_mode = 1
stretch_mode = 5
-[node name="Label" type="Label" parent="Margin/VBox/HeaderHBox"]
+[node name="Label" type="Label" parent="Margin/VBox/HeaderHBox" unique_id=968911698]
layout_mode = 2
theme_override_font_sizes/font_size = 42
text = "MAILBOX"
-[node name="HBox" type="HBoxContainer" parent="Margin/VBox"]
+[node name="HBox" type="HBoxContainer" parent="Margin/VBox" unique_id=916057446]
layout_mode = 2
size_flags_vertical = 3
theme_override_constants/separation = 20
-[node name="LeftCol" type="VBoxContainer" parent="Margin/VBox/HBox"]
+[node name="LeftCol" type="VBoxContainer" parent="Margin/VBox/HBox" unique_id=1487784728]
custom_minimum_size = Vector2(350, 0)
layout_mode = 2
theme_override_constants/separation = 12
-[node name="Scroll" type="ScrollContainer" parent="Margin/VBox/HBox/LeftCol"]
+[node name="Scroll" type="ScrollContainer" parent="Margin/VBox/HBox/LeftCol" unique_id=621356191]
layout_mode = 2
size_flags_vertical = 3
-[node name="MailListVBox" type="VBoxContainer" parent="Margin/VBox/HBox/LeftCol/Scroll"]
+[node name="MailListVBox" type="VBoxContainer" parent="Margin/VBox/HBox/LeftCol/Scroll" unique_id=426637634]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 12
-[node name="EmptyStateLbl" type="Label" parent="Margin/VBox/HBox/LeftCol/Scroll"]
+[node name="EmptyStateLbl" type="Label" parent="Margin/VBox/HBox/LeftCol/Scroll" unique_id=834561715]
unique_name_in_owner = true
visible = false
layout_mode = 2
@@ -113,88 +113,88 @@ size_flags_vertical = 6
text = "No mails found."
horizontal_alignment = 1
-[node name="MiddleCol" type="PanelContainer" parent="Margin/VBox/HBox"]
+[node name="MiddleCol" type="PanelContainer" parent="Margin/VBox/HBox" unique_id=1987681114]
layout_mode = 2
size_flags_horizontal = 3
theme_override_styles/panel = SubResource("StyleBoxFlat_middle")
-[node name="Margin" type="MarginContainer" parent="Margin/VBox/HBox/MiddleCol"]
+[node name="Margin" type="MarginContainer" parent="Margin/VBox/HBox/MiddleCol" unique_id=1071270502]
layout_mode = 2
theme_override_constants/margin_left = 24
theme_override_constants/margin_top = 24
theme_override_constants/margin_right = 24
theme_override_constants/margin_bottom = 24
-[node name="VBox" type="VBoxContainer" parent="Margin/VBox/HBox/MiddleCol/Margin"]
+[node name="VBox" type="VBoxContainer" parent="Margin/VBox/HBox/MiddleCol/Margin" unique_id=685231427]
layout_mode = 2
theme_override_constants/separation = 16
-[node name="MailTitleLbl" type="Label" parent="Margin/VBox/HBox/MiddleCol/Margin/VBox"]
+[node name="MailTitleLbl" type="Label" parent="Margin/VBox/HBox/MiddleCol/Margin/VBox" unique_id=382775133]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 32
text = "Message Title"
-[node name="Sep" type="HSeparator" parent="Margin/VBox/HBox/MiddleCol/Margin/VBox"]
+[node name="Sep" type="HSeparator" parent="Margin/VBox/HBox/MiddleCol/Margin/VBox" unique_id=1340637749]
layout_mode = 2
-[node name="MailContentText" type="RichTextLabel" parent="Margin/VBox/HBox/MiddleCol/Margin/VBox"]
+[node name="MailContentText" type="RichTextLabel" parent="Margin/VBox/HBox/MiddleCol/Margin/VBox" unique_id=2049891721]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
theme_override_font_sizes/normal_font_size = 18
text = "Lorem ipsum..."
-[node name="RightCol" type="VBoxContainer" parent="Margin/VBox/HBox"]
+[node name="RightCol" type="VBoxContainer" parent="Margin/VBox/HBox" unique_id=2015908253]
custom_minimum_size = Vector2(250, 0)
layout_mode = 2
theme_override_constants/separation = 16
-[node name="DynamicRewardsContainer" type="VBoxContainer" parent="Margin/VBox/HBox/RightCol"]
+[node name="DynamicRewardsContainer" type="VBoxContainer" parent="Margin/VBox/HBox/RightCol" unique_id=1329577984]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 16
-[node name="FooterHBox" type="HBoxContainer" parent="Margin/VBox"]
+[node name="FooterHBox" type="HBoxContainer" parent="Margin/VBox" unique_id=320817593]
layout_mode = 2
-[node name="CloseBtn" type="Button" parent="Margin/VBox/FooterHBox"]
+[node name="CloseBtn" type="Button" parent="Margin/VBox/FooterHBox" unique_id=1750785772]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 50)
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
text = "< BACK"
-[node name="ReadAllBtn" type="Button" parent="Margin/VBox/FooterHBox"]
+[node name="ReadAllBtn" type="Button" parent="Margin/VBox/FooterHBox" unique_id=896945464]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 50)
layout_mode = 2
text = "READ ALL"
-[node name="Spacer" type="Control" parent="Margin/VBox/FooterHBox"]
+[node name="Spacer" type="Control" parent="Margin/VBox/FooterHBox" unique_id=281696850]
layout_mode = 2
size_flags_horizontal = 3
-[node name="ActionBtn" type="Button" parent="Margin/VBox/FooterHBox"]
+[node name="ActionBtn" type="Button" parent="Margin/VBox/FooterHBox" unique_id=1319041955]
unique_name_in_owner = true
custom_minimum_size = Vector2(180, 50)
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
text = "DELETE"
-[node name="Templates" type="Control" parent="."]
+[node name="Templates" type="Control" parent="." unique_id=1436568788]
visible = false
layout_mode = 1
anchors_preset = 0
-[node name="MailBtnTemplate" type="Button" parent="Templates"]
+[node name="MailBtnTemplate" type="Button" parent="Templates" unique_id=1697770233]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 100)
layout_mode = 0
-toggle_mode = true
theme_override_styles/normal = SubResource("StyleBoxFlat_mailbtn")
+toggle_mode = true
-[node name="Margin" type="MarginContainer" parent="Templates/MailBtnTemplate"]
+[node name="Margin" type="MarginContainer" parent="Templates/MailBtnTemplate" unique_id=1252674391]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -206,58 +206,58 @@ theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 12
-[node name="VBox" type="VBoxContainer" parent="Templates/MailBtnTemplate/Margin"]
+[node name="VBox" type="VBoxContainer" parent="Templates/MailBtnTemplate/Margin" unique_id=63790172]
layout_mode = 2
-[node name="Title" type="Label" parent="Templates/MailBtnTemplate/Margin/VBox"]
+[node name="Title" type="Label" parent="Templates/MailBtnTemplate/Margin/VBox" unique_id=1593462170]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "Message Title"
text_overrun_behavior = 3
-[node name="Spacer" type="Control" parent="Templates/MailBtnTemplate/Margin/VBox"]
+[node name="Spacer" type="Control" parent="Templates/MailBtnTemplate/Margin/VBox" unique_id=389846246]
layout_mode = 2
size_flags_vertical = 3
-[node name="HBox" type="HBoxContainer" parent="Templates/MailBtnTemplate/Margin/VBox"]
+[node name="HBox" type="HBoxContainer" parent="Templates/MailBtnTemplate/Margin/VBox" unique_id=1962257584]
layout_mode = 2
-[node name="DateLbl" type="Label" parent="Templates/MailBtnTemplate/Margin/VBox/HBox"]
+[node name="DateLbl" type="Label" parent="Templates/MailBtnTemplate/Margin/VBox/HBox" unique_id=1751599412]
layout_mode = 2
theme_override_colors/font_color = Color(0.9, 0.9, 0.9, 1)
theme_override_font_sizes/font_size = 14
-[node name="Spacer" type="Control" parent="Templates/MailBtnTemplate/Margin/VBox/HBox"]
+[node name="Spacer" type="Control" parent="Templates/MailBtnTemplate/Margin/VBox/HBox" unique_id=925210925]
layout_mode = 2
size_flags_horizontal = 3
-[node name="StatusLbl" type="Label" parent="Templates/MailBtnTemplate/Margin/VBox/HBox"]
+[node name="StatusLbl" type="Label" parent="Templates/MailBtnTemplate/Margin/VBox/HBox" unique_id=555418621]
layout_mode = 2
theme_override_font_sizes/font_size = 14
-[node name="RewardHBoxTemplate" type="PanelContainer" parent="Templates"]
+[node name="RewardHBoxTemplate" type="PanelContainer" parent="Templates" unique_id=794877169]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 80)
layout_mode = 0
theme_override_styles/panel = SubResource("StyleBoxFlat_slot")
-[node name="Margin" type="MarginContainer" parent="Templates/RewardHBoxTemplate"]
+[node name="Margin" type="MarginContainer" parent="Templates/RewardHBoxTemplate" unique_id=1534603023]
layout_mode = 2
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 12
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 12
-[node name="HBox" type="HBoxContainer" parent="Templates/RewardHBoxTemplate/Margin"]
+[node name="HBox" type="HBoxContainer" parent="Templates/RewardHBoxTemplate/Margin" unique_id=1130738443]
layout_mode = 2
theme_override_constants/separation = 12
-[node name="IconBg" type="ColorRect" parent="Templates/RewardHBoxTemplate/Margin/HBox"]
+[node name="IconBg" type="ColorRect" parent="Templates/RewardHBoxTemplate/Margin/HBox" unique_id=861876172]
custom_minimum_size = Vector2(56, 56)
layout_mode = 2
color = Color(0, 0, 0, 1)
-[node name="Icon" type="TextureRect" parent="Templates/RewardHBoxTemplate/Margin/HBox/IconBg"]
+[node name="Icon" type="TextureRect" parent="Templates/RewardHBoxTemplate/Margin/HBox/IconBg" unique_id=1578987001]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -267,16 +267,16 @@ grow_vertical = 2
expand_mode = 1
stretch_mode = 5
-[node name="VBox" type="VBoxContainer" parent="Templates/RewardHBoxTemplate/Margin/HBox"]
+[node name="VBox" type="VBoxContainer" parent="Templates/RewardHBoxTemplate/Margin/HBox" unique_id=2024184299]
layout_mode = 2
alignment = 1
-[node name="TypeLbl" type="Label" parent="Templates/RewardHBoxTemplate/Margin/HBox/VBox"]
+[node name="TypeLbl" type="Label" parent="Templates/RewardHBoxTemplate/Margin/HBox/VBox" unique_id=648909275]
layout_mode = 2
theme_override_font_sizes/font_size = 12
text = "ITEM NAME"
-[node name="AmountLbl" type="Label" parent="Templates/RewardHBoxTemplate/Margin/HBox/VBox"]
+[node name="AmountLbl" type="Label" parent="Templates/RewardHBoxTemplate/Margin/HBox/VBox" unique_id=1659268049]
layout_mode = 2
theme_override_font_sizes/font_size = 16
text = "x00000"
diff --git a/scenes/ui/profile_panel.tscn b/scenes/ui/profile_panel.tscn
index 7223f70..e0f1c82 100644
--- a/scenes/ui/profile_panel.tscn
+++ b/scenes/ui/profile_panel.tscn
@@ -10,6 +10,27 @@
[ext_resource type="PackedScene" uid="uid://bmln7v6v5kvxg" path="res://assets/characters/Oldpop.glb" id="4_oldpop"]
[ext_resource type="Texture2D" uid="uid://brhn1dhp1gm13" path="res://assets/graphics/character_selection/sc_characters/sc_copper.png" id="4_pti1t"]
[ext_resource type="AnimationLibrary" uid="uid://c3pyopnwibckj" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"]
+[ext_resource type="Texture2D" uid="uid://b5pp08fke7ptd" path="res://assets/graphics/gui/lobby/gold.png" id="tex_gold"]
+[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="tex_star"]
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_OuterPanel"]
+bg_color = Color(0.42, 0.47, 0.52, 1)
+border_width_left = 4
+border_width_top = 4
+border_width_right = 4
+border_width_bottom = 4
+border_color = Color(0.25, 0.3, 0.35, 1)
+corner_radius_top_left = 12
+corner_radius_top_right = 12
+corner_radius_bottom_right = 12
+corner_radius_bottom_left = 12
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_InnerDark"]
+bg_color = Color(0.2, 0.25, 0.3, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
[sub_resource type="Environment" id="Env_preview"]
background_mode = 1
@@ -18,6 +39,13 @@ ambient_light_source = 2
ambient_light_color = Color(0.55, 0.65, 0.9, 1)
ambient_light_energy = 0.7
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_BtnDark"]
+bg_color = Color(0.15, 0.15, 0.15, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
[node name="ProfilePanel" type="Control" unique_id=229091481]
layout_mode = 3
anchors_preset = 15
@@ -71,9 +99,14 @@ theme_override_constants/separation = 8
[node name="ProfileCard" type="PanelContainer" parent="MainMargin/MainHBox/LeftCol" unique_id=1335998802]
layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
[node name="Margin" type="MarginContainer" parent="MainMargin/MainHBox/LeftCol/ProfileCard" unique_id=854167006]
layout_mode = 2
+theme_override_constants/margin_left = 12
+theme_override_constants/margin_top = 12
+theme_override_constants/margin_right = 12
+theme_override_constants/margin_bottom = 12
[node name="HBox" type="HBoxContainer" parent="MainMargin/MainHBox/LeftCol/ProfileCard/Margin" unique_id=2103985860]
layout_mode = 2
@@ -125,20 +158,26 @@ theme_override_constants/separation = 12
[node name="StarPanel" type="PanelContainer" parent="MainMargin/MainHBox/LeftCol/CurrencyRow" unique_id=1253486930]
layout_mode = 2
-size_flags_horizontal = 3
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
[node name="Margin" type="MarginContainer" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/StarPanel" unique_id=1653822675]
layout_mode = 2
+theme_override_constants/margin_left = 8
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 8
+theme_override_constants/margin_bottom = 4
[node name="HBox" type="HBoxContainer" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/StarPanel/Margin" unique_id=304844268]
layout_mode = 2
theme_override_constants/separation = 8
-[node name="Icon" type="Label" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/StarPanel/Margin/HBox" unique_id=1733501]
+[node name="Icon" type="TextureRect" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/StarPanel/Margin/HBox" unique_id=1733501]
+custom_minimum_size = Vector2(24, 24)
layout_mode = 2
-theme_override_colors/font_color = Color(0.9, 0.7, 0.3, 1)
-theme_override_font_sizes/font_size = 18
-text = "✦"
+size_flags_vertical = 4
+texture = ExtResource("tex_star")
+expand_mode = 1
+stretch_mode = 5
[node name="StarLabel" type="Label" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/StarPanel/Margin/HBox" unique_id=1536697694]
unique_name_in_owner = true
@@ -151,21 +190,27 @@ horizontal_alignment = 2
[node name="GoldPanel" type="PanelContainer" parent="MainMargin/MainHBox/LeftCol/CurrencyRow" unique_id=289720528]
layout_mode = 2
-size_flags_horizontal = 3
+theme_override_styles/panel = SubResource("StyleBoxFlat_InnerDark")
[node name="Margin" type="MarginContainer" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/GoldPanel" unique_id=1374150600]
layout_mode = 2
+theme_override_constants/margin_left = 8
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 8
+theme_override_constants/margin_bottom = 4
[node name="HBox" type="HBoxContainer" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/GoldPanel/Margin" unique_id=1558907810]
layout_mode = 2
theme_override_constants/separation = 8
alignment = 1
-[node name="Icon" type="Label" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/GoldPanel/Margin/HBox" unique_id=351462654]
+[node name="Icon" type="TextureRect" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/GoldPanel/Margin/HBox" unique_id=351462654]
+custom_minimum_size = Vector2(24, 24)
layout_mode = 2
-theme_override_colors/font_color = Color(0.8, 0.6, 0.2, 1)
-theme_override_font_sizes/font_size = 18
-text = "▤"
+size_flags_vertical = 4
+texture = ExtResource("tex_gold")
+expand_mode = 1
+stretch_mode = 5
[node name="GoldLabel" type="Label" parent="MainMargin/MainHBox/LeftCol/CurrencyRow/GoldPanel/Margin/HBox" unique_id=1517793393]
unique_name_in_owner = true
@@ -178,6 +223,7 @@ horizontal_alignment = 2
[node name="StatsPanel" type="PanelContainer" parent="MainMargin/MainHBox/LeftCol" unique_id=1663081199]
layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
[node name="Margin" type="MarginContainer" parent="MainMargin/MainHBox/LeftCol/StatsPanel" unique_id=1581044063]
layout_mode = 2
@@ -284,39 +330,44 @@ alignment = 1
[node name="HeadTabBtn" type="Button" parent="MainMargin/MainHBox/CenterCol/CategoryTabs" unique_id=767008262]
unique_name_in_owner = true
-custom_minimum_size = Vector2(56, 56)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
-theme_override_font_sizes/font_size = 28
-text = "🎩"
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 16
+text = "Head"
[node name="CostumeTabBtn" type="Button" parent="MainMargin/MainHBox/CenterCol/CategoryTabs" unique_id=73958290]
unique_name_in_owner = true
-custom_minimum_size = Vector2(56, 56)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
-theme_override_font_sizes/font_size = 28
-text = "👔"
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 16
+text = "Costume"
[node name="GloveTabBtn" type="Button" parent="MainMargin/MainHBox/CenterCol/CategoryTabs" unique_id=204746295]
unique_name_in_owner = true
-custom_minimum_size = Vector2(56, 56)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
-theme_override_font_sizes/font_size = 28
-text = "🧤"
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 16
+text = "Gloves"
[node name="AccTabBtn" type="Button" parent="MainMargin/MainHBox/CenterCol/CategoryTabs" unique_id=1885872464]
unique_name_in_owner = true
-custom_minimum_size = Vector2(56, 56)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
-theme_override_font_sizes/font_size = 28
-text = "💎"
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 16
+text = "Accessory"
[node name="FragTabBtn" type="Button" parent="MainMargin/MainHBox/CenterCol/CategoryTabs" unique_id=1985872465]
unique_name_in_owner = true
visible = false
-custom_minimum_size = Vector2(56, 56)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
-theme_override_font_sizes/font_size = 28
-text = "🧩"
+theme_override_fonts/font = ExtResource("3_font")
+theme_override_font_sizes/font_size = 16
+text = "Fragments"
[node name="ViewportWrapper" type="Control" parent="MainMargin/MainHBox/CenterCol" unique_id=1095002692]
layout_mode = 2
@@ -337,7 +388,7 @@ unique_name_in_owner = true
own_world_3d = true
transparent_bg = true
handle_input_locally = false
-size = Vector2i(627, 541)
+size = Vector2i(651, 554)
render_target_update_mode = 4
[node name="WorldEnvironment" type="WorldEnvironment" parent="MainMargin/MainHBox/CenterCol/ViewportWrapper/ViewportContainer/PreviewViewport" unique_id=1660814495]
@@ -458,6 +509,7 @@ theme_override_constants/separation = 12
[node name="ItemInfoCard" type="PanelContainer" parent="MainMargin/MainHBox/RightCol" unique_id=1009501358]
layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
[node name="Margin" type="MarginContainer" parent="MainMargin/MainHBox/RightCol/ItemInfoCard" unique_id=663473404]
layout_mode = 2
@@ -472,6 +524,8 @@ theme_override_constants/separation = 12
[node name="PreviewBorder" type="PanelContainer" parent="MainMargin/MainHBox/RightCol/ItemInfoCard/Margin/HBox" unique_id=501514249]
layout_mode = 2
+size_flags_vertical = 4
+theme_override_styles/panel = SubResource("StyleBoxFlat_BtnDark")
[node name="ItemPreview" type="TextureRect" parent="MainMargin/MainHBox/RightCol/ItemInfoCard/Margin/HBox/PreviewBorder" unique_id=1738821483]
unique_name_in_owner = true
@@ -542,6 +596,7 @@ text = "Dismantle"
[node name="ItemGridCard" type="PanelContainer" parent="MainMargin/MainHBox/RightCol" unique_id=748947828]
layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_OuterPanel")
[node name="Margin" type="MarginContainer" parent="MainMargin/MainHBox/RightCol/ItemGridCard" unique_id=2047178434]
layout_mode = 2
@@ -700,10 +755,10 @@ anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
-offset_left = -156.0
-offset_top = -106.0
-offset_right = 156.0
-offset_bottom = 106.0
+offset_left = 4.0
+offset_top = 4.0
+offset_right = 316.0
+offset_bottom = 216.0
grow_horizontal = 2
grow_vertical = 2
columns = 3
diff --git a/scenes/ui/shop_panel.tscn b/scenes/ui/shop_panel.tscn
index 3f57796..167efea 100644
--- a/scenes/ui/shop_panel.tscn
+++ b/scenes/ui/shop_panel.tscn
@@ -1,33 +1,82 @@
[gd_scene format=3 uid="uid://c018oue81jm44"]
[ext_resource type="Script" uid="uid://w0ddjofws4ib" path="res://scripts/ui/shop_panel.gd" id="1"]
-[ext_resource type="Theme" uid="uid://da337sh5qxi0s" path="res://assets/themes/ui_theme.tres" id="1_jr3vq"]
-[ext_resource type="Texture2D" uid="uid://2d1ks5pmblc7" path="res://assets/graphics/main_menu/bg_back.png" id="3_qjhny"]
+[ext_resource type="Theme" uid="uid://cxab3xxy00" path="res://assets/themes/GUI_Tekton.tres" id="1_jr3vq"]
+[ext_resource type="Texture2D" uid="uid://jqvv6s55mlsk" path="res://assets/graphics/gui/BG.png" id="3_qjhny"]
[ext_resource type="FontFile" uid="uid://xnjx058n4tsw" path="res://assets/fonts/Nougat-ExtraBlack.ttf" id="3_udh10"]
[ext_resource type="PackedScene" uid="uid://ejeamn0pyey4" path="res://assets/characters/Bob.glb" id="4_bob"]
[ext_resource type="PackedScene" uid="uid://d4cul3w3wem5w" path="res://assets/characters/Gatot.glb" id="4_gatot"]
[ext_resource type="PackedScene" uid="uid://1vk0mjnwkngi" path="res://assets/characters/Masbro.glb" id="4_masbro"]
[ext_resource type="PackedScene" uid="uid://bmln7v6v5kvxg" path="res://assets/characters/Oldpop.glb" id="4_oldpop"]
[ext_resource type="AnimationLibrary" uid="uid://c3pyopnwibckj" path="res://assets/characters/animations/animation-pack.res" id="5_animlib"]
+[ext_resource type="Texture2D" uid="uid://b5pp08fke7ptd" path="res://assets/graphics/gui/lobby/gold.png" id="12_gufxi"]
+[ext_resource type="Texture2D" uid="uid://d0ouvm3x8h42c" path="res://assets/graphics/gui/lobby/star.png" id="13_arjad"]
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_bg"]
bg_color = Color(0.08, 0.09, 0.12, 1)
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_gd4oi"]
+bg_color = Color(0, 0, 0, 0.48235294)
+border_color = Color(0.92941177, 0.91764706, 0.8862745, 1)
+corner_radius_top_left = 3
+corner_radius_top_right = 3
+corner_radius_bottom_right = 3
+corner_radius_bottom_left = 3
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tab_normal"]
+content_margin_left = 16.0
+content_margin_top = 10.0
+content_margin_right = 16.0
+content_margin_bottom = 10.0
+bg_color = Color(0.15, 0.18, 0.22, 1)
+corner_radius_top_left = 6
+corner_radius_top_right = 6
+corner_radius_bottom_right = 6
+corner_radius_bottom_left = 6
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_tab_hover"]
+content_margin_left = 16.0
+content_margin_top = 10.0
+content_margin_right = 16.0
+content_margin_bottom = 10.0
+bg_color = Color(0.22, 0.26, 0.3, 1)
+corner_radius_top_left = 6
+corner_radius_top_right = 6
+corner_radius_bottom_right = 6
+corner_radius_bottom_left = 6
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_banner"]
+content_margin_left = 12.0
+content_margin_top = 12.0
+content_margin_right = 12.0
+content_margin_bottom = 12.0
+bg_color = Color(0.18, 0.22, 0.26, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
+[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_btn_back"]
+content_margin_left = 18.0
+content_margin_top = 12.0
+content_margin_right = 18.0
+content_margin_bottom = 12.0
+bg_color = Color(0.15, 0.18, 0.22, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
+
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_card"]
content_margin_left = 12.0
content_margin_top = 12.0
content_margin_right = 12.0
content_margin_bottom = 12.0
-bg_color = Color(0.91, 0.86, 0.61, 1)
-border_width_left = 4
-border_width_top = 4
-border_width_right = 4
-border_width_bottom = 4
-border_color = Color(0.72, 0.52, 0.1, 1)
-corner_radius_top_left = 10
-corner_radius_top_right = 10
-corner_radius_bottom_right = 10
-corner_radius_bottom_left = 10
+bg_color = Color(0.25, 0.3, 0.35, 1)
+corner_radius_top_left = 8
+corner_radius_top_right = 8
+corner_radius_bottom_right = 8
+corner_radius_bottom_left = 8
[node name="ShopPanel" type="Control" unique_id=1967851868]
layout_mode = 3
@@ -49,7 +98,6 @@ grow_vertical = 2
theme_override_styles/panel = SubResource("StyleBoxFlat_bg")
[node name="Background2" type="TextureRect" parent="." unique_id=1682487151]
-modulate = Color(1, 1, 1, 0.28235295)
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -57,7 +105,7 @@ anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
texture = ExtResource("3_qjhny")
-expand_mode = 2
+expand_mode = 1
[node name="MainMargin" type="MarginContainer" parent="." unique_id=1416392345]
layout_mode = 1
@@ -78,37 +126,81 @@ theme_override_constants/separation = 20
[node name="TopBar" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=1421665563]
layout_mode = 2
-[node name="Wallet" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar" unique_id=382704727]
+[node name="Wallet" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar" unique_id=333280079]
layout_mode = 2
-theme_override_constants/separation = 15
+theme_override_constants/separation = 6
-[node name="StarPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/Wallet" unique_id=1049443997]
-custom_minimum_size = Vector2(150, 50)
+[node name="Panel" type="Panel" parent="MainMargin/MainVBox/TopBar/Wallet" unique_id=885749275]
+custom_minimum_size = Vector2(160, 46)
layout_mode = 2
-theme = ExtResource("1_jr3vq")
+size_flags_horizontal = 3
+theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi")
-[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/TopBar/Wallet/StarPanel" unique_id=593135182]
+[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/Wallet/Panel" unique_id=471921005]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/margin_left = 3
+theme_override_constants/margin_top = 3
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/Wallet/Panel/MarginContainer" unique_id=1931322340]
+layout_mode = 2
+
+[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/TopBar/Wallet/Panel/MarginContainer/HBoxContainer" unique_id=918981910]
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+texture = ExtResource("12_gufxi")
+
+[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/TopBar/Wallet/Panel/MarginContainer/HBoxContainer" unique_id=1446969934]
unique_name_in_owner = true
layout_mode = 2
-theme_override_colors/font_color = Color(0, 0, 0, 1)
+size_flags_horizontal = 3
theme_override_fonts/font = ExtResource("3_udh10")
theme_override_font_sizes/font_size = 18
-text = "✦ 0"
-horizontal_alignment = 1
+text = "0"
+horizontal_alignment = 2
-[node name="GoldPanel" type="PanelContainer" parent="MainMargin/MainVBox/TopBar/Wallet" unique_id=132289]
-custom_minimum_size = Vector2(150, 50)
+[node name="Panel2" type="Panel" parent="MainMargin/MainVBox/TopBar/Wallet" unique_id=63717008]
+custom_minimum_size = Vector2(160, 46)
layout_mode = 2
-theme = ExtResource("1_jr3vq")
+size_flags_horizontal = 3
+theme_override_styles/panel = SubResource("StyleBoxFlat_gd4oi")
-[node name="GoldLabel" type="Label" parent="MainMargin/MainVBox/TopBar/Wallet/GoldPanel" unique_id=1638818024]
+[node name="MarginContainer" type="MarginContainer" parent="MainMargin/MainVBox/TopBar/Wallet/Panel2" unique_id=26646029]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+theme_override_constants/margin_left = 3
+theme_override_constants/margin_top = 3
+theme_override_constants/margin_right = 6
+theme_override_constants/margin_bottom = 3
+
+[node name="HBoxContainer" type="HBoxContainer" parent="MainMargin/MainVBox/TopBar/Wallet/Panel2/MarginContainer" unique_id=1913617815]
+layout_mode = 2
+
+[node name="TextureRect" type="TextureRect" parent="MainMargin/MainVBox/TopBar/Wallet/Panel2/MarginContainer/HBoxContainer" unique_id=1840630206]
+layout_mode = 2
+size_flags_horizontal = 0
+size_flags_vertical = 4
+texture = ExtResource("13_arjad")
+
+[node name="StarLabel" type="Label" parent="MainMargin/MainVBox/TopBar/Wallet/Panel2/MarginContainer/HBoxContainer" unique_id=1963734128]
unique_name_in_owner = true
layout_mode = 2
-theme_override_colors/font_color = Color(0, 0, 0, 1)
+size_flags_horizontal = 3
theme_override_fonts/font = ExtResource("3_udh10")
theme_override_font_sizes/font_size = 18
-text = "⭐ 0"
-horizontal_alignment = 1
+text = "0"
+horizontal_alignment = 2
[node name="Spacer" type="Control" parent="MainMargin/MainVBox/TopBar" unique_id=1578035202]
layout_mode = 2
@@ -120,45 +212,69 @@ theme_override_constants/separation = 10
[node name="TabHead" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=212253926]
unique_name_in_owner = true
-custom_minimum_size = Vector2(60, 50)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_font_sizes/font_size = 14
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_hover")
text = "HAT"
[node name="TabCostume" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=1953478397]
unique_name_in_owner = true
-custom_minimum_size = Vector2(60, 50)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_font_sizes/font_size = 14
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_hover")
text = "CLOTHING"
[node name="TabGlove" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=1900195629]
unique_name_in_owner = true
-custom_minimum_size = Vector2(60, 50)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_font_sizes/font_size = 14
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_hover")
text = "GLOVES"
[node name="TabAccessory" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=228390814]
unique_name_in_owner = true
visible = false
-custom_minimum_size = Vector2(60, 50)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_font_sizes/font_size = 14
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_hover")
text = "COSTUME"
[node name="TabGold" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=1246468211]
unique_name_in_owner = true
-custom_minimum_size = Vector2(60, 50)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_font_sizes/font_size = 14
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_hover")
text = "GET GOLD"
[node name="TabStar" type="Button" parent="MainMargin/MainVBox/TopBar/TabsHBox" unique_id=378819506]
unique_name_in_owner = true
-custom_minimum_size = Vector2(60, 50)
+custom_minimum_size = Vector2(80, 44)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_font_sizes/font_size = 14
+theme_override_styles/normal = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_tab_normal")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_hover")
text = "GET STAR"
[node name="ContentHBox" type="HBoxContainer" parent="MainMargin/MainVBox" unique_id=908636948]
@@ -172,23 +288,35 @@ layout_mode = 2
theme_override_constants/separation = 20
[node name="Banner1" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftSidebar" unique_id=785866623]
+unique_name_in_owner = true
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_styles/normal = SubResource("StyleBoxFlat_banner")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_banner")
+theme_override_styles/hover = SubResource("StyleBoxFlat_banner")
disabled = true
text = "Banner Slot"
[node name="Banner2" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftSidebar" unique_id=1108522673]
+unique_name_in_owner = true
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_styles/normal = SubResource("StyleBoxFlat_banner")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_banner")
+theme_override_styles/hover = SubResource("StyleBoxFlat_banner")
disabled = true
text = "Banner Slot"
[node name="Banner3" type="Button" parent="MainMargin/MainVBox/ContentHBox/LeftSidebar" unique_id=1479545458]
+unique_name_in_owner = true
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_styles/normal = SubResource("StyleBoxFlat_banner")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_banner")
+theme_override_styles/hover = SubResource("StyleBoxFlat_banner")
disabled = true
text = "Banner Slot"
@@ -201,6 +329,10 @@ unique_name_in_owner = true
custom_minimum_size = Vector2(80, 60)
layout_mode = 2
size_flags_horizontal = 0
+theme_override_fonts/font = ExtResource("3_udh10")
+theme_override_styles/normal = SubResource("StyleBoxFlat_btn_back")
+theme_override_styles/pressed = SubResource("StyleBoxFlat_btn_back")
+theme_override_styles/hover = SubResource("StyleBoxFlat_tab_hover")
text = "←"
[node name="CenterScroll" type="ScrollContainer" parent="MainMargin/MainVBox/ContentHBox" unique_id=953911053]
@@ -217,10 +349,11 @@ theme_override_constants/v_separation = 20
columns = 2
[node name="Divider" type="ColorRect" parent="MainMargin/MainVBox/ContentHBox" unique_id=221156668]
+visible = false
custom_minimum_size = Vector2(8, 0)
layout_direction = 2
layout_mode = 2
-color = Color(0.85, 0.72, 0.35, 1)
+color = Color(0.25, 0.3, 0.35, 1)
[node name="RightPanel" type="VBoxContainer" parent="MainMargin/MainVBox/ContentHBox" unique_id=472452762]
custom_minimum_size = Vector2(350, 0)
@@ -235,7 +368,7 @@ stretch = true
[node name="SubViewport" type="SubViewport" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer" unique_id=1223822678]
transparent_bg = true
handle_input_locally = false
-size = Vector2i(350, 450)
+size = Vector2i(350, 516)
render_target_update_mode = 4
[node name="Camera3D" type="Camera3D" parent="MainMargin/MainVBox/ContentHBox/RightPanel/SubViewportContainer/SubViewport" unique_id=24602145]
@@ -329,23 +462,26 @@ theme_override_constants/separation = 14
[node name="Icon" type="ColorRect" parent="Templates/GoldCard/HBox" unique_id=1987319256]
custom_minimum_size = Vector2(125, 125)
layout_mode = 2
-color = Color(0.8, 0.7, 0.4, 1)
+color = Color(0.15, 0.18, 0.22, 1)
[node name="VBox" type="VBoxContainer" parent="Templates/GoldCard/HBox" unique_id=1510531971]
layout_mode = 2
size_flags_horizontal = 3
alignment = 1
-[node name="AmountLabel" type="Label" parent="Templates/GoldCard/HBox/VBox" unique_id=581128094]
+[node name="AmountLabel" type="RichTextLabel" parent="Templates/GoldCard/HBox/VBox" unique_id=581128094]
layout_mode = 2
-theme_override_colors/font_color = Color(0.15, 0.1, 0.05, 1)
-theme_override_font_sizes/font_size = 18
-text = "⭐ 100"
-horizontal_alignment = 2
+theme_override_colors/default_color = Color(1, 1, 1, 1)
+theme_override_fonts/normal_font = ExtResource("3_udh10")
+theme_override_font_sizes/normal_font_size = 18
+bbcode_enabled = true
+text = "[right][img=24x24]res://assets/graphics/gui/lobby/gold.png[/img] 100[/right]"
+fit_content = true
+scroll_active = false
[node name="BonusLabel" type="Label" parent="Templates/GoldCard/HBox/VBox" unique_id=31159053]
layout_mode = 2
-theme_override_colors/font_color = Color(0.9, 0.45, 0.1, 1)
+theme_override_colors/font_color = Color(1, 1, 1, 1)
text = "+50"
horizontal_alignment = 2
@@ -381,23 +517,29 @@ layout_mode = 2
size_flags_horizontal = 3
alignment = 1
-[node name="AmountLabel" type="Label" parent="Templates/StarCard/HBox/VBox" unique_id=1276779551]
+[node name="AmountLabel" type="RichTextLabel" parent="Templates/StarCard/HBox/VBox" unique_id=1276779551]
layout_mode = 2
-theme_override_colors/font_color = Color(0.15, 0.1, 0.05, 1)
-theme_override_font_sizes/font_size = 18
-text = "✦ 100"
-horizontal_alignment = 2
+theme_override_colors/default_color = Color(1, 1, 1, 1)
+theme_override_fonts/normal_font = ExtResource("3_udh10")
+theme_override_font_sizes/normal_font_size = 18
+bbcode_enabled = true
+text = "[right][img=24x24]res://assets/graphics/gui/lobby/star.png[/img] 100[/right]"
+fit_content = true
+scroll_active = false
[node name="HSeparator" type="Control" parent="Templates/StarCard/HBox/VBox" unique_id=1627096153]
clip_contents = true
layout_mode = 2
size_flags_vertical = 3
-[node name="CostLabel" type="Label" parent="Templates/StarCard/HBox/VBox" unique_id=1937011106]
+[node name="CostLabel" type="RichTextLabel" parent="Templates/StarCard/HBox/VBox" unique_id=1937011106]
layout_mode = 2
-theme_override_colors/font_color = Color(0.55, 0.35, 0.05, 1)
-text = "⭐ 500 Gold"
-horizontal_alignment = 1
+theme_override_colors/default_color = Color(0.8, 0.7, 0.4, 1)
+theme_override_fonts/normal_font = ExtResource("3_udh10")
+bbcode_enabled = true
+text = "[center]Cost: [img=20x20]res://assets/graphics/gui/lobby/gold.png[/img] 500[/center]"
+fit_content = true
+scroll_active = false
[node name="BuyBtn" type="Button" parent="Templates/StarCard/HBox/VBox" unique_id=1523983544]
layout_mode = 2
@@ -418,7 +560,7 @@ theme_override_constants/separation = 14
[node name="Icon" type="ColorRect" parent="Templates/CosmeticCard/HBox" unique_id=1136185378]
custom_minimum_size = Vector2(125, 125)
layout_mode = 2
-color = Color(0.8, 0.7, 0.4, 1)
+color = Color(0.15, 0.18, 0.22, 1)
[node name="VBox" type="VBoxContainer" parent="Templates/CosmeticCard/HBox" unique_id=1048501562]
layout_mode = 2
@@ -427,7 +569,7 @@ alignment = 1
[node name="NameLabel" type="Label" parent="Templates/CosmeticCard/HBox/VBox" unique_id=958903842]
layout_mode = 2
-theme_override_colors/font_color = Color(0.1, 0.08, 0.04, 1)
+theme_override_colors/font_color = Color(1, 1, 1, 1)
text = "Item Name"
[node name="RarityLabel" type="Label" parent="Templates/CosmeticCard/HBox/VBox" unique_id=882234879]
@@ -435,12 +577,15 @@ layout_mode = 2
theme_override_colors/font_color = Color(0.5, 0.5, 0.5, 1)
text = "Common"
-[node name="PriceLabel" type="Label" parent="Templates/CosmeticCard/HBox/VBox" unique_id=489381936]
+[node name="PriceLabel" type="RichTextLabel" parent="Templates/CosmeticCard/HBox/VBox" unique_id=489381936]
layout_mode = 2
size_flags_vertical = 6
-theme_override_colors/font_color = Color(0.15, 0.1, 0.05, 1)
-text = "⭐ 0 ✦ 0"
-horizontal_alignment = 1
+theme_override_colors/default_color = Color(1, 1, 1, 1)
+theme_override_fonts/normal_font = ExtResource("3_udh10")
+bbcode_enabled = true
+text = "[center][img=20x20]res://assets/graphics/gui/lobby/gold.png[/img] 0 [img=20x20]res://assets/graphics/gui/lobby/star.png[/img] 0[/center]"
+fit_content = true
+scroll_active = false
[node name="BtnRow" type="HBoxContainer" parent="Templates/CosmeticCard/HBox/VBox" unique_id=1035177281]
layout_mode = 2
diff --git a/scripts/managers/user_profile_manager.gd b/scripts/managers/user_profile_manager.gd
index 51409a4..ad6d93b 100644
--- a/scripts/managers/user_profile_manager.gd
+++ b/scripts/managers/user_profile_manager.gd
@@ -25,6 +25,7 @@ var fragments: Dictionary = {} # frag_common, frag_uncommon, frag_rare
var inventory: Array = []
var loadout: Dictionary = {"head": "", "costume": "", "glove": "", "accessory": ""}
var shop_catalog: Dictionary = {}
+var featured_banners: Array = []
var is_profile_loaded: bool = false
# Nakama storage collection names
@@ -311,14 +312,13 @@ func update_loadout(category: String, item_id: String) -> bool:
loadout[category] = item_id
return await _save_profile_data()
-func purchase_item(item_id: String, price_gold: int, price_star: int, category: String) -> bool:
- if not NakamaManager.session: return false
+func purchase_item(item_id: String) -> String:
+ if not NakamaManager.session: return "Not authenticated"
var payload = JSON.stringify({
"item_id": item_id,
- "price_gold": price_gold,
- "price_star": price_star,
- "category": category
+ "quantity": 1,
+ "idempotency_key": str(randi()) + "_" + str(Time.get_ticks_usec())
})
var result = await NakamaManager.client.rpc_async(
@@ -328,17 +328,20 @@ func purchase_item(item_id: String, price_gold: int, price_star: int, category:
)
if result.is_exception():
- push_error("[UserProfileManager] Purchase failed: ", result.get_exception().message)
- return false
+ var msg = result.get_exception().message
+ push_error("[UserProfileManager] Purchase failed: ", msg)
+ return msg
- # Update local cache
- if price_gold > 0: wallet["gold"] -= price_gold
- if price_star > 0: wallet["star"] -= price_star
- if not inventory.has(item_id):
- inventory.append(item_id)
-
- emit_signal("profile_updated")
- return true
+ var response = JSON.parse_string(result.payload)
+ if typeof(response) == TYPE_DICTIONARY and response.has("success") and response.success == true:
+ await _reload_wallet()
+ if not inventory.has(item_id):
+ inventory.append(item_id)
+
+ emit_signal("profile_updated")
+ return ""
+
+ return "Unknown error"
func fetch_shop_catalog() -> void:
if not NakamaManager.session: return
@@ -356,6 +359,8 @@ func fetch_shop_catalog() -> void:
var payload: Dictionary = JSON.parse_string(result.payload)
if payload and payload.has("catalog"):
shop_catalog = payload.catalog
+ if payload.has("featured_banners"):
+ featured_banners = payload.get("featured_banners", [])
emit_signal("profile_updated")
## Admin-only: grants a large amount of gold via a server-authoritative RPC.
@@ -377,7 +382,9 @@ func buy_currency(package_id: String) -> bool:
if not NakamaManager.session: return false
var payload = JSON.stringify({
- "package_id": package_id
+ "package_id": package_id,
+ "idempotency_key": str(randi()) + "_" + str(Time.get_ticks_usec()),
+ "receipt": "mock_receipt_for_now"
})
var result = await NakamaManager.client.rpc_async(
@@ -387,9 +394,17 @@ func buy_currency(package_id: String) -> bool:
)
if result.is_exception():
- push_error("[UserProfileManager] Failed to buy currency: ", result.get_exception().message)
+ var msg = result.get_exception().message
+ if "NotEnoughFunds" in msg:
+ push_error("[UserProfileManager] Failed to buy currency: Not enough funds.")
+ else:
+ push_error("[UserProfileManager] Failed to buy currency: ", msg)
return false
+ var response = JSON.parse_string(result.payload)
+ if typeof(response) == TYPE_DICTIONARY and response.has("status") and response.status == "pending":
+ print("[UserProfileManager] Currency purchase pending verification.")
+
await _reload_wallet()
return true
diff --git a/scripts/tools/skin_catalog_editor.gd b/scripts/tools/skin_catalog_editor.gd
index 0cc88be..beee2c1 100644
--- a/scripts/tools/skin_catalog_editor.gd
+++ b/scripts/tools/skin_catalog_editor.gd
@@ -6,43 +6,43 @@ extends Control
## Open scenes/tools/skin_catalog_editor.tscn in the editor, then press F6 (Run Current Scene).
## Edit skins in the form, then click "💾 Save & Generate" to rewrite:
## • scripts/managers/skin_manager.gd (SKIN_CATALOG block)
-## • server/nakama/tekton_admin.js (SHOP_CATALOG_DEFS block)
+## • server/nakama/economy.js (SHOP_CATALOG_DEFS block)
-const DATA_PATH := "res://assets/data/skin_catalog_data.json"
+const DATA_PATH := "res://assets/data/skin_catalog_data.json"
const SKIN_MANAGER_PATH := "res://scripts/managers/skin_manager.gd"
-const ADMIN_JS_PATH := "res://server/nakama/tekton_admin.js"
+const ADMIN_JS_PATH := "res://server/nakama/economy.js"
const CATEGORIES := ["head", "costume", "glove", "accessory"]
-const RARITIES := ["Common", "Rare", "Epic", "Legendary"]
-const MODES := ["override", "overlay"]
+const RARITIES := ["Common", "Rare", "Epic", "Legendary"]
+const MODES := ["override", "overlay"]
# Sentinel markers — must match what's in the target files
const BEGIN_SKIN := "## [BEGIN_SKIN_CATALOG]"
-const END_SKIN := "## [END_SKIN_CATALOG]"
+const END_SKIN := "## [END_SKIN_CATALOG]"
const BEGIN_SHOP := "// [BEGIN_SHOP_CATALOG_DEFS]"
-const END_SHOP := "// [END_SHOP_CATALOG_DEFS]"
+const END_SHOP := "// [END_SHOP_CATALOG_DEFS]"
# ─── State ───────────────────────────────────────────────────────────────────
-var _data: Array = []
-var _selected_idx: int = -1
-var _dirty: bool = false
+var _data: Array = []
+var _selected_idx: int = -1
+var _dirty: bool = false
# ─── UI refs (built in code) ─────────────────────────────────────────────────
-var _skin_list_vbox: VBoxContainer
-var _status_label: Label
-var _form_panel: PanelContainer
-var _no_sel_label: Label
-var _form_item_id: LineEdit
-var _form_name: LineEdit
-var _form_character: LineEdit
-var _form_gold: SpinBox
-var _form_star: SpinBox
-var _form_category: OptionButton
-var _form_rarity: OptionButton
-var _slots_vbox: VBoxContainer
-var _duplicate_btn: Button
-var _delete_btn: Button
-var _save_btn: Button
+var _skin_list_vbox: VBoxContainer
+var _status_label: Label
+var _form_panel: PanelContainer
+var _no_sel_label: Label
+var _form_item_id: LineEdit
+var _form_name: LineEdit
+var _form_character: LineEdit
+var _form_gold: SpinBox
+var _form_star: SpinBox
+var _form_category: OptionButton
+var _form_rarity: OptionButton
+var _slots_vbox: VBoxContainer
+var _duplicate_btn: Button
+var _delete_btn: Button
+var _save_btn: Button
# ─────────────────────────────────────────────────────────────────────────────
func _ready() -> void:
@@ -54,7 +54,7 @@ func _ready() -> void:
# UI Construction
# ─────────────────────────────────────────────────────────────────────────────
func _build_ui() -> void:
- anchor_right = 1.0
+ anchor_right = 1.0
anchor_bottom = 1.0
# Root VBox
@@ -137,13 +137,13 @@ func _build_ui() -> void:
# Form fields
form_vbox.add_child(_section_label("── Item Info ───────────────────────────"))
- _form_item_id = _field(form_vbox, "Item ID", "e.g. oldpop_hat1")
- _form_name = _field(form_vbox, "Display Name", "e.g. Oldpop Hat I")
- _form_character = _field(form_vbox, "Character (node)", "e.g. Oldpop, Masbro, Bob")
- _form_gold = _spinbox(form_vbox, "Gold Price", 0, 99999)
- _form_star = _spinbox(form_vbox, "Star Price", 0, 99999)
- _form_category = _option(form_vbox, "Category", CATEGORIES)
- _form_rarity = _option(form_vbox, "Rarity", RARITIES)
+ _form_item_id = _field(form_vbox, "Item ID", "e.g. oldpop_hat1")
+ _form_name = _field(form_vbox, "Display Name", "e.g. Oldpop Hat I")
+ _form_character = _field(form_vbox, "Character (node)", "e.g. Oldpop, Masbro, Bob")
+ _form_gold = _spinbox(form_vbox, "Gold Price", 0, 99999)
+ _form_star = _spinbox(form_vbox, "Star Price", 0, 99999)
+ _form_category = _option(form_vbox, "Category", CATEGORIES)
+ _form_rarity = _option(form_vbox, "Rarity", RARITIES)
# ── Slots section ─────────────────────────────────────────────────────────
var slots_hdr := HBoxContainer.new()
@@ -280,23 +280,23 @@ func _on_list_item_pressed(idx: int) -> void:
# ─────────────────────────────────────────────────────────────────────────────
func _populate_form() -> void:
if _selected_idx < 0 or _selected_idx >= _data.size():
- _form_panel.visible = false
+ _form_panel.visible = false
_no_sel_label.visible = true
_duplicate_btn.disabled = true
- _delete_btn.disabled = true
+ _delete_btn.disabled = true
return
- _form_panel.visible = true
+ _form_panel.visible = true
_no_sel_label.visible = false
_duplicate_btn.disabled = false
- _delete_btn.disabled = false
+ _delete_btn.disabled = false
var e: Dictionary = _data[_selected_idx]
- _form_item_id.text = e.get("item_id", "")
- _form_name.text = e.get("name", "")
- _form_character.text = e.get("character", "")
- _form_gold.value = e.get("gold", 0)
- _form_star.value = e.get("star", 0)
+ _form_item_id.text = e.get("item_id", "")
+ _form_name.text = e.get("name", "")
+ _form_character.text = e.get("character", "")
+ _form_gold.value = e.get("gold", 0)
+ _form_star.value = e.get("star", 0)
var cat_idx := CATEGORIES.find(e.get("category", "head"))
_form_category.selected = max(0, cat_idx)
@@ -355,13 +355,13 @@ func _commit_form() -> void:
if _selected_idx < 0 or _selected_idx >= _data.size():
return
var e: Dictionary = _data[_selected_idx]
- e["item_id"] = _form_item_id.text.strip_edges()
- e["name"] = _form_name.text.strip_edges()
+ e["item_id"] = _form_item_id.text.strip_edges()
+ e["name"] = _form_name.text.strip_edges()
e["character"] = _form_character.text.strip_edges()
- e["gold"] = int(_form_gold.value)
- e["star"] = int(_form_star.value)
- e["category"] = CATEGORIES[_form_category.selected]
- e["rarity"] = RARITIES[_form_rarity.selected]
+ e["gold"] = int(_form_gold.value)
+ e["star"] = int(_form_star.value)
+ e["category"] = CATEGORIES[_form_category.selected]
+ e["rarity"] = RARITIES[_form_rarity.selected]
# Read slots
var slots: Array = []
for row in _slots_vbox.get_children():
@@ -371,8 +371,8 @@ func _commit_form() -> void:
if ch.size() < 3:
continue
slots.append({
- "mesh": (ch[0] as LineEdit).text.strip_edges(),
- "mode": MODES[(ch[1] as OptionButton).selected],
+ "mesh": (ch[0] as LineEdit).text.strip_edges(),
+ "mode": MODES[(ch[1] as OptionButton).selected],
"material": (ch[2] as LineEdit).text.strip_edges(),
})
e["slots"] = slots
@@ -384,14 +384,14 @@ func _on_add_pressed() -> void:
if _selected_idx >= 0:
_commit_form()
_data.append({
- "item_id": "new_skin_%d" % _data.size(),
- "name": "New Skin",
- "category": "head",
+ "item_id": "new_skin_%d" % _data.size(),
+ "name": "New Skin",
+ "category": "head",
"character": "",
- "gold": 0,
- "star": 0,
- "rarity": "Common",
- "slots": [],
+ "gold": 0,
+ "star": 0,
+ "rarity": "Common",
+ "slots": [],
})
_selected_idx = _data.size() - 1
_refresh_list()
@@ -454,7 +454,7 @@ func _on_save_pressed() -> void:
var err_gd := _generate_skin_manager()
var err_js := _generate_admin_js()
if err_gd == OK and err_js == OK:
- _set_status("✓ skin_manager.gd and tekton_admin.js updated successfully!", Color(0.4, 1.0, 0.4))
+ _set_status("✓ skin_manager.gd and economy.js updated successfully!", Color(0.4, 1.0, 0.4))
else:
_set_status("⚠ Some files could not be updated — check the Output log.", Color.YELLOW)
@@ -465,7 +465,7 @@ func _generate_skin_manager() -> int:
if not FileAccess.file_exists(SKIN_MANAGER_PATH):
push_error("[SkinCatalogEditor] File not found: " + SKIN_MANAGER_PATH)
return ERR_FILE_NOT_FOUND
- var f := FileAccess.open(SKIN_MANAGER_PATH, FileAccess.READ)
+ var f := FileAccess.open(SKIN_MANAGER_PATH, FileAccess.READ)
var src: String = f.get_as_text()
f.close()
@@ -516,20 +516,20 @@ func _generate_skin_manager() -> int:
return OK
# ─────────────────────────────────────────────────────────────────────────────
-# Code Generation — tekton_admin.js
+# Code Generation — economy.js
# ─────────────────────────────────────────────────────────────────────────────
func _generate_admin_js() -> int:
if not FileAccess.file_exists(ADMIN_JS_PATH):
push_error("[SkinCatalogEditor] File not found: " + ADMIN_JS_PATH)
return ERR_FILE_NOT_FOUND
- var f := FileAccess.open(ADMIN_JS_PATH, FileAccess.READ)
+ var f := FileAccess.open(ADMIN_JS_PATH, FileAccess.READ)
var src: String = f.get_as_text()
f.close()
var b := src.find(BEGIN_SHOP)
var e := src.find(END_SHOP)
if b == -1 or e == -1:
- push_error("[SkinCatalogEditor] Sentinel markers not found in tekton_admin.js")
+ push_error("[SkinCatalogEditor] Sentinel markers not found in economy.js")
return ERR_INVALID_DATA
var lines: PackedStringArray = []
@@ -545,12 +545,12 @@ func _generate_admin_js() -> int:
var char_val: String = entry.get("character", "")
var char_part: String = (", character: \"%s\"" % char_val) if not char_val.is_empty() else ""
lines.append(" { id: \"%s\", name: \"%s\", category: \"%s\", gold: %d, star: %d, rarity: \"%s\"%s }," % [
- entry.get("item_id", ""),
- entry.get("name", ""),
+ entry.get("item_id", ""),
+ entry.get("name", ""),
cat,
- entry.get("gold", 0),
- entry.get("star", 0),
- entry.get("rarity", "Common"),
+ entry.get("gold", 0),
+ entry.get("star", 0),
+ entry.get("rarity", "Common"),
char_part,
])
diff --git a/scripts/ui/admin_panel.gd b/scripts/ui/admin_panel.gd
index a21668c..838c288 100644
--- a/scripts/ui/admin_panel.gd
+++ b/scripts/ui/admin_panel.gd
@@ -20,6 +20,9 @@ signal closed
@onready var ban_btn := %BanBtn as Button
@onready var unban_btn := %UnbanBtn as Button
@onready var delete_btn := %DeleteBtn as Button
+@onready var history_btn := %HistoryBtn as Button
+@onready var history_dialog := %HistoryDialog as AcceptDialog
+@onready var history_text := %HistoryText as RichTextLabel
# Tab: Leaderboards
@onready var lb_tree := %LeaderboardTree as Tree
@@ -61,6 +64,11 @@ var _resolved_user_id: String = ""
var _mail_root: TreeItem
var _all_server_mails: Array = []
+# Tab: Shop (Featured Banners)
+@onready var slots_vbox := %SlotsVBox as VBoxContainer
+@onready var load_banners_btn := %LoadBannersBtn as Button
+@onready var save_banners_btn := %SaveBannersBtn as Button
+
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
# -- Data --
@@ -178,6 +186,7 @@ func _connect_signals() -> void:
ban_btn.pressed.connect(_on_ban)
unban_btn.pressed.connect(_on_unban)
delete_btn.pressed.connect(_on_delete)
+ history_btn.pressed.connect(_on_history_pressed)
user_tree.item_edited.connect(_on_user_tree_item_edited)
user_tree.button_clicked.connect(_on_user_tree_button_clicked)
@@ -204,6 +213,10 @@ func _connect_signals() -> void:
delete_mail_server_btn.pressed.connect(_on_delete_mail_server_pressed)
_update_mail_action_btns(null)
+ # Shop actions
+ load_banners_btn.pressed.connect(func(): await _load_featured_banners())
+ save_banners_btn.pressed.connect(func(): await _save_featured_banners())
+
# =============================================================================
# Core Panel Logic
# =============================================================================
@@ -228,6 +241,8 @@ func _on_tab_changed(tab_index: int) -> void:
await _load_daily_rewards_config()
elif tab_index == 4:
await _load_mail()
+ elif tab_index == 5:
+ await _load_featured_banners()
# =============================================================================
# RPC Helper
@@ -262,7 +277,8 @@ func _load_users() -> void:
_set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
return
- all_users = res.get("users", [])
+ var raw_users = res.get("users", [])
+ all_users = raw_users if typeof(raw_users) == TYPE_ARRAY else []
count_label.text = "%d users" % all_users.size()
for user in all_users:
@@ -464,6 +480,63 @@ func _on_unban() -> void:
_set_status("Unbanned %d/%d" % [ok, to_unban.size()], CLR_STATUS_OK)
await _load_users()
+func _on_history_pressed() -> void:
+ var selected_data = _get_checked_user_data()
+ if selected_data.size() != 1:
+ _set_status("Please select exactly ONE user to view history.", CLR_STATUS_ERR)
+ return
+
+ var uid = selected_data[0].get("user_id", "")
+ _set_status("Fetching history for user...", CLR_STATUS_OK)
+ var res = await _rpc("admin_get_user_history", {"user_id": uid})
+
+ if res.has("error"):
+ _set_status("Failed to get history: " + str(res.error), CLR_STATUS_ERR)
+ return
+
+ _set_status("History loaded.", CLR_STATUS_OK)
+
+ var h = res.get("history", {})
+ var text = "[b]=== USER HISTORY ===[/b]\n"
+ text += "User ID: " + uid + "\n\n"
+
+ # Logins
+ text += "[b]-- Recent Logins --[/b]\n"
+ var logins = h.get("logins", [])
+ if logins.is_empty():
+ text += "No recent logins found.\n"
+ else:
+ for l in logins:
+ var time_str = Time.get_datetime_string_from_unix_time(int(l.get("time", 0)))
+ text += "- %s (IP: %s)\n" % [time_str, l.get("ip", "unknown")]
+
+ text += "\n"
+
+ # Wallet Ledger
+ text += "[b]-- Economy / Wallet Ledger --[/b]\n"
+ var ledger = h.get("wallet_ledger", [])
+ if ledger.is_empty():
+ text += "No transactions found.\n"
+ else:
+ for item in ledger:
+ var changeset = str(item.get("changeset", {}))
+ var c_time = item.get("create_time", "")
+ text += "- [%s] %s\n" % [c_time.left(19).replace("T", " "), changeset]
+
+ text += "\n"
+
+ # Matches
+ text += "[b]-- Matches --[/b]\n"
+ var matches = h.get("matches", [])
+ if matches.is_empty():
+ text += "No match history found.\n"
+ else:
+ for m in matches:
+ text += "- " + str(m) + "\n"
+
+ history_text.text = text
+ history_dialog.popup_centered()
+
func _on_delete() -> void:
var users := _get_checked_user_data()
if users.is_empty(): return
@@ -498,7 +571,8 @@ func _load_leaderboard() -> void:
_set_status("Failed to load scores", CLR_STATUS_ERR)
return
- lb_data = res.get("leaderboard", [])
+ var raw_lb = res.get("leaderboard", [])
+ lb_data = raw_lb if typeof(raw_lb) == TYPE_ARRAY else []
count_label.text = "%d records" % lb_data.size()
lb_data.sort_custom(func(a, b): return a.get("high_score", 0) > b.get("high_score", 0))
@@ -824,7 +898,8 @@ func _load_mail() -> void:
_set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
return
- _all_server_mails = res.get("mails", [])
+ var raw_mails = res.get("mails", [])
+ _all_server_mails = raw_mails if typeof(raw_mails) == TYPE_ARRAY else []
count_label.text = "%d mails" % _all_server_mails.size()
var now_str = Time.get_datetime_string_from_system(true)
@@ -1065,3 +1140,60 @@ func _on_delete_mail_server_pressed() -> void:
await _load_mail()
confirm.queue_free()
)
+
+# =============================================================================
+# TAB 6: SHOP — FEATURED BANNERS
+# =============================================================================
+var _slot_nodes: Array = [] # cached references to the 3 slot HBoxContainers
+
+func _get_slot_nodes() -> Array:
+ if _slot_nodes.is_empty():
+ for child in slots_vbox.get_children():
+ if child is HBoxContainer:
+ _slot_nodes.append(child)
+ return _slot_nodes
+
+func _load_featured_banners() -> void:
+ _set_status("Loading banners...")
+ var res := await _rpc("admin_get_featured_banners", {})
+ if res.has("error"):
+ _set_status("Failed: " + str(res.error), CLR_STATUS_ERR)
+ return
+
+ var raw_banners = res.get("banners", [])
+ var banners: Array = raw_banners if typeof(raw_banners) == TYPE_ARRAY else []
+ var slots := _get_slot_nodes()
+
+ for i in range(slots.size()):
+ var slot: HBoxContainer = slots[i]
+ var id_edit: LineEdit = slot.get_node("ItemIdEdit") as LineEdit
+ var lbl_edit: LineEdit = slot.get_node("LabelEdit") as LineEdit
+ if i < banners.size():
+ var b: Dictionary = banners[i] if banners[i] is Dictionary else {}
+ id_edit.text = b.get("item_id", "")
+ lbl_edit.text = b.get("label", "")
+ else:
+ id_edit.text = ""
+ lbl_edit.text = ""
+
+ count_label.text = "%d banners configured" % banners.size()
+ _set_status("Banners loaded", CLR_STATUS_OK)
+
+func _save_featured_banners() -> void:
+ var banners: Array = []
+ var slots := _get_slot_nodes()
+
+ for slot in slots:
+ var id_edit: LineEdit = slot.get_node("ItemIdEdit") as LineEdit
+ var lbl_edit: LineEdit = slot.get_node("LabelEdit") as LineEdit
+ var item_id: String = id_edit.text.strip_edges()
+ var label: String = lbl_edit.text.strip_edges()
+ if not item_id.is_empty():
+ banners.append({"item_id": item_id, "label": label})
+
+ _set_status("Saving banners...")
+ var res := await _rpc("admin_set_featured_banners", {"banners": banners})
+ if res.has("error"):
+ _set_status("Save failed: " + str(res.error), CLR_STATUS_ERR)
+ elif res.has("success"):
+ _set_status("Banners saved! (%d slots)" % banners.size(), CLR_STATUS_OK)
diff --git a/scripts/ui/boot_screen.gd b/scripts/ui/boot_screen.gd
index 9f4f591..b9cf74a 100644
--- a/scripts/ui/boot_screen.gd
+++ b/scripts/ui/boot_screen.gd
@@ -75,7 +75,7 @@ func _on_update_check_completed(has_update: bool, info: Dictionary) -> void:
button_container.visible = true
update_button.visible = true
skip_button.visible = true
- skip_button_label.text = "Play without updating"
+ skip_button_label.text = "Force Play"
else:
status_label.text = "Game up to date."
button_container.visible = true
diff --git a/scripts/ui/fragment_craft_panel.gd b/scripts/ui/fragment_craft_panel.gd
index d4a88e6..3d3962c 100644
--- a/scripts/ui/fragment_craft_panel.gd
+++ b/scripts/ui/fragment_craft_panel.gd
@@ -5,10 +5,12 @@ extends Control
signal closed
# ─── Node refs ───────────────────────────────────────────────────────────────
-@onready var back_btn := %BackBtn as Button
-@onready var recipe_list := %RecipeList as VBoxContainer
-@onready var status_label := %StatusLabel as Label
-@onready var frag_balance := %FragBalance as Label
+@onready var back_btn := %BackBtn as Button
+@onready var recipe_list := %RecipeList as VBoxContainer
+@onready var status_label := %StatusLabel as Label
+@onready var common_label := %CommonLabel as Label
+@onready var uncommon_label := %UncommonLabel as Label
+@onready var rare_label := %RareLabel as Label
const FRAG_ICONS := {
"frag_common": "⬜",
@@ -31,12 +33,9 @@ func _refresh() -> void:
# ─── Fragment balance header ──────────────────────────────────────────────────
func _update_frag_balance() -> void:
var frags: Dictionary = UserProfileManager.fragments
- var parts: Array = []
- for fid in ["frag_common", "frag_uncommon", "frag_rare"]:
- var icon: String = FRAG_ICONS.get(fid, "?")
- var count: int = frags.get(fid, 0)
- parts.append("%s ×%d" % [icon, count])
- frag_balance.text = " ".join(parts)
+ common_label.text = str(frags.get("frag_common", 0))
+ uncommon_label.text = str(frags.get("frag_uncommon", 0))
+ rare_label.text = str(frags.get("frag_rare", 0))
# ─── Recipe cards ─────────────────────────────────────────────────────────────
func _rebuild_recipe_list() -> void:
@@ -55,6 +54,18 @@ func _make_recipe_card(recipe_id: String, recipe: Dictionary) -> PanelContainer:
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
+
+ # Apply Tekton panel style
+ var panel_style := StyleBoxFlat.new()
+ panel_style.bg_color = Color(0.14117648, 0.16862746, 0.19215687, 1)
+ panel_style.corner_radius_top_left = 12
+ panel_style.corner_radius_top_right = 12
+ panel_style.corner_radius_bottom_right = 12
+ panel_style.corner_radius_bottom_left = 12
+ panel_style.shadow_color = Color(0, 0, 0, 0.3529412)
+ panel_style.shadow_size = 4
+ panel_style.shadow_offset = Vector2(-2, 2)
+ panel.add_theme_stylebox_override("panel", panel_style)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 14)
@@ -103,11 +114,20 @@ func _make_recipe_card(recipe_id: String, recipe: Dictionary) -> PanelContainer:
Color(0.4, 1.0, 0.5) if have >= needed else Color(1.0, 0.4, 0.4))
cost_hbox.add_child(cost_lbl)
- # Craft button
+ # Craft button with Tekton dark style
var craft_btn := Button.new()
craft_btn.text = "🔨 Craft"
craft_btn.custom_minimum_size = Vector2(100, 40)
craft_btn.disabled = not can_craft
+
+ var btn_style := StyleBoxFlat.new()
+ btn_style.bg_color = Color(0.15, 0.15, 0.15, 1)
+ btn_style.corner_radius_top_left = 8
+ btn_style.corner_radius_top_right = 8
+ btn_style.corner_radius_bottom_right = 8
+ btn_style.corner_radius_bottom_left = 8
+ craft_btn.add_theme_stylebox_override("normal", btn_style)
+
if not can_craft:
craft_btn.modulate = Color(0.5, 0.5, 0.5, 0.7)
craft_btn.pressed.connect(_on_craft_pressed.bind(recipe_id, panel))
diff --git a/scripts/ui/gacha_panel.gd b/scripts/ui/gacha_panel.gd
index bb474de..c3dec21 100644
--- a/scripts/ui/gacha_panel.gd
+++ b/scripts/ui/gacha_panel.gd
@@ -10,7 +10,8 @@ signal closed
@onready var star_tab_btn := %StarTabBtn as Button
@onready var gold_tab_btn := %GoldTabBtn as Button
@onready var banner_label := %BannerLabel as Label
-@onready var balance_label := %BalanceLabel as Label
+@onready var gold_label := %GoldLabel as Label
+@onready var star_label := %StarLabel as Label
@onready var pity_label := %PityLabel as Label
@onready var pull_1_btn := %Pull1Btn as Button
@onready var pull_10_btn := %Pull10Btn as Button
@@ -88,8 +89,40 @@ func _ensure_dummy_wallet() -> void:
# ─── Banner switching ─────────────────────────────────────────────────────────
func _switch_banner(id: String) -> void:
_current_banner = id
- star_tab_btn.modulate = Color(1.3, 1.1, 0.3) if id == "star" else Color.WHITE
- gold_tab_btn.modulate = Color(1.3, 1.1, 0.3) if id == "gold" else Color.WHITE
+
+ # Create active tab style (dark blue)
+ var active_style := StyleBoxFlat.new()
+ active_style.bg_color = Color(0.1, 0.19, 0.27, 1)
+ active_style.content_margin_left = 16.0
+ active_style.content_margin_top = 14.0
+ active_style.content_margin_right = 16.0
+ active_style.content_margin_bottom = 14.0
+ active_style.corner_radius_top_left = 8
+ active_style.corner_radius_top_right = 8
+ active_style.corner_radius_bottom_right = 8
+ active_style.corner_radius_bottom_left = 8
+
+ # Create inactive tab style (cyan)
+ var inactive_style := StyleBoxFlat.new()
+ inactive_style.bg_color = Color(0.33, 0.62, 0.78, 1)
+ inactive_style.content_margin_left = 16.0
+ inactive_style.content_margin_top = 14.0
+ inactive_style.content_margin_right = 16.0
+ inactive_style.content_margin_bottom = 14.0
+ inactive_style.corner_radius_top_left = 8
+ inactive_style.corner_radius_top_right = 8
+ inactive_style.corner_radius_bottom_right = 8
+ inactive_style.corner_radius_bottom_left = 8
+
+ # Apply styles
+ star_tab_btn.add_theme_stylebox_override("normal", active_style if id == "star" else inactive_style)
+ star_tab_btn.add_theme_stylebox_override("hover", active_style if id == "star" else inactive_style)
+ star_tab_btn.add_theme_stylebox_override("pressed", active_style if id == "star" else inactive_style)
+
+ gold_tab_btn.add_theme_stylebox_override("normal", active_style if id == "gold" else inactive_style)
+ gold_tab_btn.add_theme_stylebox_override("hover", active_style if id == "gold" else inactive_style)
+ gold_tab_btn.add_theme_stylebox_override("pressed", active_style if id == "gold" else inactive_style)
+
_refresh_ui()
func _refresh_ui() -> void:
@@ -107,7 +140,11 @@ func _refresh_ui() -> void:
var rates: Dictionary = banner.get("rates", {})
banner_label.text = banner.get("name", "Banner")
- balance_label.text = "%s %d" % [icon, bal]
+
+ # Update both gold and star labels
+ star_label.text = str(UserProfileManager.wallet.get("star", 0))
+ gold_label.text = str(UserProfileManager.wallet.get("gold", 0))
+
pity_label.text = "Pity: %d / %d" % [pity, pity_at]
cost_1_label.text = "%s %d" % [icon, c1]
cost_10_label.text = "%s %d" % [icon, c10]
diff --git a/scripts/ui/leaderboard_panel.gd b/scripts/ui/leaderboard_panel.gd
index 876eee3..311a254 100644
--- a/scripts/ui/leaderboard_panel.gd
+++ b/scripts/ui/leaderboard_panel.gd
@@ -10,17 +10,19 @@ signal closed
# -------------------------------------------------------------------------
@onready var back_btn := %BackBtn as Button
@onready var refresh_btn := %RefreshBtn as Button
-@onready var sync_btn := %SyncBtn as Button
@onready var sort_score_btn := %SortScoreBtn as Button
@onready var sort_win_rate_btn := %SortWinRateBtn as Button
@onready var sort_games_btn := %SortGamesBtn as Button
@onready var leaderboard_list := %LeaderboardList as VBoxContainer
@onready var status_label := %StatusLabel as Label
+@onready var item_template := %ItemTemplate as PanelContainer
# 3D Preview
@onready var character_root := %CharacterRoot as Node3D
@onready var selected_name_label := %SelectedNameLabel as Label
@onready var selected_rank_label := %SelectedRankLabel as Label
+@onready var selected_score_label := %SelectedScoreLabel as Label
+@onready var selected_avatar_rect := %SelectedAvatarRect as TextureRect
# -------------------------------------------------------------------------
# State
@@ -43,13 +45,15 @@ const AVATAR_TO_CHAR: Array[String] = ["Pip", "Gatot", "Dabro", "Copper"]
func _ready() -> void:
back_btn.pressed.connect(_on_close_pressed)
refresh_btn.pressed.connect(_fetch_leaderboard_data)
- sync_btn.pressed.connect(_on_sync_pressed)
sort_score_btn.pressed.connect(func(): _sort_by("high_score"))
sort_win_rate_btn.pressed.connect(func(): _sort_by("win_rate"))
sort_games_btn.pressed.connect(func(): _sort_by("games_played"))
_update_tab_visuals()
_setup_3d_preview()
+
+ if item_template:
+ item_template.hide()
# Listen to profile and stats changes to keep the panel updated
UserProfileManager.profile_updated.connect(_on_profile_or_stats_changed)
@@ -79,17 +83,6 @@ func _on_close_pressed() -> void:
hide()
emit_signal("closed")
-func _on_sync_pressed() -> void:
- """Push the current player's stored stats up to the native Nakama leaderboard."""
- if not NakamaManager.session or AuthManager.is_guest:
- status_label.text = "Must be logged in to sync"
- return
- status_label.text = "Syncing your score..."
- await UserProfileManager.submit_to_leaderboard()
- status_label.text = "Synced! Refreshing..."
- await get_tree().create_timer(0.5).timeout
- _fetch_leaderboard_data()
-
# -------------------------------------------------------------------------
# Data
# -------------------------------------------------------------------------
@@ -99,8 +92,10 @@ func _fetch_leaderboard_data() -> void:
return
status_label.text = "Fetching Leaderboard..."
+ status_label.show()
for child in leaderboard_list.get_children():
- child.queue_free()
+ if child != item_template:
+ child.queue_free()
# Try native Nakama leaderboard first (fastest, ranked already)
var native_data = await _fetch_native_leaderboard()
@@ -231,90 +226,76 @@ func _update_tab_visuals() -> void:
func _populate_list() -> void:
for child in leaderboard_list.get_children():
- child.queue_free()
+ if child != item_template:
+ child.queue_free()
if leaderboard_data.size() == 0:
status_label.text = "No players found.\nPlay a match to appear here!"
+ status_label.show()
return
+ status_label.hide()
for i in range(leaderboard_data.size()):
var entry = leaderboard_data[i]
_create_leaderboard_item(i + 1, entry, i)
func _create_leaderboard_item(rank: int, entry: Dictionary, index: int) -> void:
- var item = PanelContainer.new()
- var style = StyleBoxFlat.new()
+ var item = item_template.duplicate()
+ item.show()
+
+ var style: StyleBoxFlat
+ if item.has_theme_stylebox_override("panel"):
+ style = item.get_theme_stylebox("panel").duplicate()
+ else:
+ style = StyleBoxFlat.new()
+ style.bg_color = Color(0.25, 0.3, 0.35, 1.0)
+ style.set_corner_radius_all(8)
if index == current_selected_index:
- # Highlight color for the currently selected player row
- style.bg_color = Color(0.25, 0.35, 0.20, 1.0)
- elif rank <= 3:
- style.bg_color = Color(0.2, 0.2, 0.15, 1.0)
+ style.bg_color = Color(0.35, 0.45, 0.30, 1.0)
else:
- style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
+ style.bg_color = Color(0.25, 0.3, 0.35, 1.0)
- style.set_corner_radius_all(4)
- style.content_margin_left = 10
- style.content_margin_right = 10
- style.content_margin_top = 8
- style.content_margin_bottom = 8
item.add_theme_stylebox_override("panel", style)
- var hbox = HBoxContainer.new()
- hbox.add_theme_constant_override("separation", 16)
- item.add_child(hbox)
+ var rank_label = item.get_node("HBoxContainer/RankLabel") as Label
+ if rank_label:
+ var rank_suffix = "th"
+ if rank % 10 == 1 and rank % 100 != 11: rank_suffix = "st"
+ elif rank % 10 == 2 and rank % 100 != 12: rank_suffix = "nd"
+ elif rank % 10 == 3 and rank % 100 != 13: rank_suffix = "rd"
+ rank_label.text = str(rank) + rank_suffix
- # Rank
- var rank_label = Label.new()
- rank_label.text = "#" + str(rank)
- rank_label.custom_minimum_size = Vector2(40, 0)
- match rank:
- 1: rank_label.add_theme_color_override("font_color", Color.GOLD)
- 2: rank_label.add_theme_color_override("font_color", Color.SILVER)
- 3: rank_label.add_theme_color_override("font_color", Color.DARK_ORANGE)
- _: rank_label.add_theme_color_override("font_color", Color.LIGHT_GRAY)
- hbox.add_child(rank_label)
+ match rank:
+ 1: rank_label.add_theme_color_override("font_color", Color.GOLD)
+ 2: rank_label.add_theme_color_override("font_color", Color.SILVER)
+ 3: rank_label.add_theme_color_override("font_color", Color.DARK_ORANGE)
+ _: rank_label.add_theme_color_override("font_color", Color.WHITE)
- # Avatar
- var avatar_rect = TextureRect.new()
- avatar_rect.custom_minimum_size = Vector2(32, 32)
- avatar_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
-
- var avatar_url = entry.get("avatar_url", "")
- if avatar_url.is_empty() or not ResourceLoader.exists(avatar_url):
- if not avatar_url.is_empty():
- print("[Leaderboard] Avatar URL not found or invalid: ", avatar_url)
- avatar_url = UserProfileManager.AVATARS[0]
-
- avatar_rect.texture = load(avatar_url)
- hbox.add_child(avatar_rect)
+ var avatar_rect = item.get_node("HBoxContainer/Margin/InnerHBox/AvatarRect") as TextureRect
+ if avatar_rect:
+ var avatar_url = entry.get("avatar_url", "")
+ if avatar_url.is_empty() or not ResourceLoader.exists(avatar_url):
+ avatar_url = UserProfileManager.AVATARS[0]
+ avatar_rect.texture = load(avatar_url)
- # Name
- var name_label = Label.new()
- name_label.text = entry.get("display_name", "Unknown")
- name_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
- name_label.add_theme_color_override("font_color", Color.WHITE)
- hbox.add_child(name_label)
+ var name_label = item.get_node("HBoxContainer/Margin/InnerHBox/NameLabel") as Label
+ if name_label:
+ name_label.text = entry.get("display_name", "Unknown")
- # Value
- var value_label = Label.new()
- var color = Color(0.647, 0.996, 0.224, 1)
- match current_sort_key:
- "high_score": value_label.text = str(entry.get("high_score", 0))
- "win_rate": value_label.text = "%.1f%%" % entry.get("win_rate", 0.0)
- "games_played": value_label.text = str(entry.get("games_played", 0))
- value_label.add_theme_color_override("font_color", color)
- value_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
- value_label.custom_minimum_size = Vector2(80, 0)
- hbox.add_child(value_label)
+ var value_label = item.get_node("HBoxContainer/ValueLabel") as Label
+ if value_label:
+ match current_sort_key:
+ "high_score": value_label.text = str(entry.get("high_score", 0))
+ "win_rate": value_label.text = "%.1f%%" % entry.get("win_rate", 0.0)
+ "games_played": value_label.text = str(entry.get("games_played", 0))
leaderboard_list.add_child(item)
- # Make row clickable to update 3D preview
item.gui_input.connect(func(event: InputEvent):
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
current_selected_index = index
- _populate_list() # Re-draw list to apply the new active highlight colors visually
+ _populate_list()
_show_entry_preview(index)
)
item.mouse_filter = Control.MOUSE_FILTER_STOP
@@ -347,8 +328,18 @@ func _show_entry_preview(index: int) -> void:
var display_name: String = entry.get("display_name", "Unknown")
var rank := index + 1
- selected_name_label.text = display_name
- selected_rank_label.text = "#%d" % rank
+ if selected_name_label: selected_name_label.text = display_name
+ if selected_rank_label: selected_rank_label.text = "Rank #%d" % rank
+ if selected_score_label:
+ match current_sort_key:
+ "high_score": selected_score_label.text = str(entry.get("high_score", 0))
+ "win_rate": selected_score_label.text = "%.1f%%" % entry.get("win_rate", 0.0)
+ "games_played": selected_score_label.text = str(entry.get("games_played", 0))
+ if selected_avatar_rect:
+ var avatar_url2 = entry.get("avatar_url", "")
+ if avatar_url2.is_empty() or not ResourceLoader.exists(avatar_url2):
+ avatar_url2 = UserProfileManager.AVATARS[0]
+ selected_avatar_rect.texture = load(avatar_url2)
func _update_3d_preview(character_name: String) -> void:
if not character_root:
diff --git a/scripts/ui/profile_panel.gd b/scripts/ui/profile_panel.gd
index 0f6d9ce..17c12e0 100644
--- a/scripts/ui/profile_panel.gd
+++ b/scripts/ui/profile_panel.gd
@@ -247,6 +247,30 @@ func _on_category_tab_pressed(category: String) -> void:
_highlight_active_tab()
func _highlight_active_tab() -> void:
+ # Create active tab style (dark blue)
+ var active_style := StyleBoxFlat.new()
+ active_style.bg_color = Color(0.1, 0.19, 0.27, 1)
+ active_style.content_margin_left = 12.0
+ active_style.content_margin_top = 8.0
+ active_style.content_margin_right = 12.0
+ active_style.content_margin_bottom = 8.0
+ active_style.corner_radius_top_left = 8
+ active_style.corner_radius_top_right = 8
+ active_style.corner_radius_bottom_right = 8
+ active_style.corner_radius_bottom_left = 8
+
+ # Create inactive tab style (cyan)
+ var inactive_style := StyleBoxFlat.new()
+ inactive_style.bg_color = Color(0.33, 0.62, 0.78, 1)
+ inactive_style.content_margin_left = 12.0
+ inactive_style.content_margin_top = 8.0
+ inactive_style.content_margin_right = 12.0
+ inactive_style.content_margin_bottom = 8.0
+ inactive_style.corner_radius_top_left = 8
+ inactive_style.corner_radius_top_right = 8
+ inactive_style.corner_radius_bottom_right = 8
+ inactive_style.corner_radius_bottom_left = 8
+
var map := {
"head": head_tab_btn,
"costume": costume_tab_btn,
@@ -255,7 +279,13 @@ func _highlight_active_tab() -> void:
"fragment": frag_tab_btn
}
for cat: String in map:
- (map[cat] as Button).modulate = Color(1.3, 1.3, 0.4, 1) if cat == _current_category else Color.WHITE
+ var btn: Button = map[cat]
+ var is_active := (cat == _current_category)
+ var style := active_style if is_active else inactive_style
+ btn.add_theme_stylebox_override("normal", style)
+ btn.add_theme_stylebox_override("hover", style)
+ btn.add_theme_stylebox_override("pressed", style)
+ btn.add_theme_color_override("font_color", Color.WHITE)
func _rebuild_category_items() -> void:
_category_items.clear()
@@ -306,6 +336,8 @@ func _populate_item_grid() -> void:
prev_page_btn.disabled = (_current_page == 0)
next_page_btn.disabled = ((_current_page + 1) >= total_pages)
+ var placeholder_tex = preload("res://assets/graphics/gui/inventory/item_placeholder.png")
+
var equipped: String = UserProfileManager.loadout.get(_current_category, "")
for i in _item_slots.size():
var slot: Button = _item_slots[i]
@@ -313,11 +345,22 @@ func _populate_item_grid() -> void:
if idx < total:
var item_id: String = _category_items[idx]
var info: Dictionary = ITEM_CATALOG.get(item_id, {})
- slot.text = info.get("name", item_id)
- slot.tooltip_text = item_id
+
+ var tex_path = "res://assets/graphics/gui/inventory/%s.png" % item_id
+ if ResourceLoader.exists(tex_path):
+ slot.icon = load(tex_path)
+ else:
+ slot.icon = placeholder_tex
+
+ slot.text = ""
+ slot.tooltip_text = info.get("name", item_id)
+ slot.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
+ slot.expand_icon = true
+
slot.modulate = Color(0.4, 1.0, 0.4, 1) if item_id == equipped else Color.WHITE
else:
slot.text = ""
+ slot.icon = null
slot.tooltip_text = ""
slot.modulate = Color.WHITE
@@ -331,6 +374,12 @@ func _on_slot_pressed(slot_index: int) -> void:
func _show_item_info(item_id: String) -> void:
var info: Dictionary = ITEM_CATALOG.get(item_id, {})
+ var tex_path = "res://assets/graphics/gui/inventory/%s.png" % item_id
+ if ResourceLoader.exists(tex_path):
+ item_preview.texture = load(tex_path)
+ else:
+ item_preview.texture = preload("res://assets/graphics/gui/inventory/item_placeholder.png")
+
item_name_label.text = info.get("name", item_id)
var rarity: String = info.get("rarity", "Common")
@@ -353,6 +402,7 @@ func _show_item_info(item_id: String) -> void:
func _clear_item_info() -> void:
item_name_label.text = "Select an item"
+ item_preview.texture = null
item_rarity_label.text = ""
item_price_label.text = ""
equip_btn.text = "Equip"
diff --git a/scripts/ui/shop_panel.gd b/scripts/ui/shop_panel.gd
index b153f8f..ce4a21d 100644
--- a/scripts/ui/shop_panel.gd
+++ b/scripts/ui/shop_panel.gd
@@ -8,6 +8,9 @@ signal closed
@onready var item_grid: GridContainer = %ItemGrid
@onready var back_btn: Button = %BackBtn
@onready var status_label: Label = %StatusLabel
+@onready var banner1: Button = %Banner1
+@onready var banner2: Button = %Banner2
+@onready var banner3: Button = %Banner3
# Tabs
@onready var tab_head: Button = %TabHead
@@ -17,6 +20,9 @@ signal closed
@onready var tab_gold: Button = %TabGold
@onready var tab_star: Button = %TabStar
+# Maps category -> tab button (populated in _ready)
+var _tab_map: Dictionary = {}
+
# 3D Preview
@onready var character_root: Node3D = %CharacterRoot
@onready var anim_player: AnimationPlayer = %AnimationPlayer
@@ -72,6 +78,14 @@ const STAR_PACKS: Array = [
# _ready
# -----------------------------------------------------------------------
func _ready() -> void:
+ _tab_map = {
+ "head": tab_head,
+ "costume": tab_costume,
+ "glove": tab_glove,
+ "accessory": tab_acc,
+ "gold_packs": tab_gold,
+ "star_packs": tab_star,
+ }
back_btn.pressed.connect(_on_close)
tab_head.pressed.connect(_on_tab_selected.bind("head"))
tab_costume.pressed.connect(_on_tab_selected.bind("costume"))
@@ -85,6 +99,7 @@ func _ready() -> void:
if UserProfileManager.profile_updated.connect(_refresh_wallet) != OK:
pass
+ _set_active_tab(current_category)
_setup_3d_preview()
if UserProfileManager.shop_catalog.is_empty():
@@ -175,12 +190,78 @@ func _fetch_and_build() -> void:
func _build_shop() -> void:
_refresh_wallet()
+ _populate_banners()
_populate_current_tab()
func _on_tab_selected(category: String) -> void:
current_category = category
+ _set_active_tab(category)
_populate_current_tab()
+func _set_active_tab(active_category: String) -> void:
+ var style_active := StyleBoxFlat.new()
+ style_active.bg_color = Color(1, 1, 1, 1)
+ style_active.content_margin_left = 16.0
+ style_active.content_margin_top = 10.0
+ style_active.content_margin_right = 16.0
+ style_active.content_margin_bottom = 10.0
+ style_active.corner_radius_top_left = 6
+ style_active.corner_radius_top_right = 6
+ style_active.corner_radius_bottom_right = 6
+ style_active.corner_radius_bottom_left = 6
+
+ var style_inactive := StyleBoxFlat.new()
+ style_inactive.bg_color = Color(0.15, 0.18, 0.22, 1)
+ style_inactive.content_margin_left = 16.0
+ style_inactive.content_margin_top = 10.0
+ style_inactive.content_margin_right = 16.0
+ style_inactive.content_margin_bottom = 10.0
+ style_inactive.corner_radius_top_left = 6
+ style_inactive.corner_radius_top_right = 6
+ style_inactive.corner_radius_bottom_right = 6
+ style_inactive.corner_radius_bottom_left = 6
+
+ var style_hover := StyleBoxFlat.new()
+ style_hover.bg_color = Color(0.22, 0.26, 0.30, 1)
+ style_hover.content_margin_left = 16.0
+ style_hover.content_margin_top = 10.0
+ style_hover.content_margin_right = 16.0
+ style_hover.content_margin_bottom = 10.0
+ style_hover.corner_radius_top_left = 6
+ style_hover.corner_radius_top_right = 6
+ style_hover.corner_radius_bottom_right = 6
+ style_hover.corner_radius_bottom_left = 6
+
+ for cat in _tab_map:
+ var btn: Button = _tab_map[cat]
+ if cat == active_category:
+ btn.add_theme_stylebox_override("normal", style_active)
+ btn.add_theme_stylebox_override("hover", style_active)
+ btn.add_theme_stylebox_override("pressed", style_active)
+ btn.add_theme_color_override("font_color", Color(0.08, 0.09, 0.12))
+ btn.add_theme_color_override("font_hover_color", Color(0.08, 0.09, 0.12))
+ else:
+ btn.add_theme_stylebox_override("normal", style_inactive)
+ btn.add_theme_stylebox_override("hover", style_hover)
+ btn.add_theme_stylebox_override("pressed", style_inactive)
+ btn.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
+ btn.add_theme_color_override("font_hover_color", Color(1, 1, 1))
+
+# -----------------------------------------------------------------------
+# Banner population — promotional / featured items
+# -----------------------------------------------------------------------
+func _populate_banners() -> void:
+ var banners: Array[Button] = [banner1, banner2, banner3]
+ var promos: Array = UserProfileManager.shop_catalog.get("banners", [])
+ for i in banners.size():
+ var btn: Button = banners[i]
+ if i < promos.size():
+ btn.text = promos[i].get("label", "")
+ btn.tooltip_text = promos[i].get("id", "")
+ btn.show()
+ else:
+ btn.hide()
+
# -----------------------------------------------------------------------
# Grid population — builds cards dynamically from localized templates
# -----------------------------------------------------------------------
@@ -214,8 +295,8 @@ func _make_gold_card(pack: Dictionary) -> Control:
var card: Control = template_gold_card.duplicate()
card.visible = true
- var amount_lbl: Label = card.find_child("AmountLabel", true, false) as Label
- if amount_lbl: amount_lbl.text = "⭐ %d" % pack.amount
+ var amount_lbl: RichTextLabel = card.find_child("AmountLabel", true, false) as RichTextLabel
+ if amount_lbl: amount_lbl.text = "[right][img=24x24]res://assets/graphics/gui/lobby/gold.png[/img] %d[/right]" % pack.amount
var bonus_lbl: Label = card.find_child("BonusLabel", true, false) as Label
if bonus_lbl:
@@ -237,11 +318,11 @@ func _make_star_card(pack: Dictionary) -> Control:
var card: Control = template_star_card.duplicate()
card.visible = true
- var amount_lbl: Label = card.find_child("AmountLabel", true, false) as Label
- if amount_lbl: amount_lbl.text = "✦ %d" % pack.amount
+ var amount_lbl: RichTextLabel = card.find_child("AmountLabel", true, false) as RichTextLabel
+ if amount_lbl: amount_lbl.text = "[right][img=24x24]res://assets/graphics/gui/lobby/star.png[/img] %d[/right]" % pack.amount
- var cost_lbl: Label = card.find_child("CostLabel", true, false) as Label
- if cost_lbl: cost_lbl.text = "Cost: ⭐ %d Gold" % pack.gold_cost
+ var cost_lbl: RichTextLabel = card.find_child("CostLabel", true, false) as RichTextLabel
+ if cost_lbl: cost_lbl.text = "[center]Cost: [img=20x20]res://assets/graphics/gui/lobby/gold.png[/img] %d[/center]" % pack.gold_cost
var buy_btn: Button = card.find_child("BuyBtn", true, false) as Button
if buy_btn: buy_btn.pressed.connect(_on_buy_star_pressed.bind(pack))
@@ -270,13 +351,16 @@ func _make_cosmetic_card(item: Dictionary) -> Control:
}.get(rarity, Color(0.50, 0.50, 0.50))
rarity_lbl.add_theme_color_override("font_color", rarity_col)
- var price_lbl: Label = card.find_child("PriceLabel", true, false) as Label
+ var price_lbl: RichTextLabel = card.find_child("PriceLabel", true, false) as RichTextLabel
if price_lbl:
var g: int = int(item.get("gold", 0))
var s: int = int(item.get("star", 0))
- if g > 0 and s > 0: price_lbl.text = "⭐ %d ✦ %d" % [g, s]
- elif g > 0: price_lbl.text = "⭐ %d" % g
- else: price_lbl.text = "✦ %d" % s
+ if g > 0 and s > 0:
+ price_lbl.text = "[center][img=20x20]res://assets/graphics/gui/lobby/gold.png[/img] %d [img=20x20]res://assets/graphics/gui/lobby/star.png[/img] %d[/center]" % [g, s]
+ elif g > 0:
+ price_lbl.text = "[center][img=20x20]res://assets/graphics/gui/lobby/gold.png[/img] %d[/center]" % g
+ else:
+ price_lbl.text = "[center][img=20x20]res://assets/graphics/gui/lobby/star.png[/img] %d[/center]" % s
var try_btn: Button = card.find_child("TryBtn", true, false) as Button
if try_btn: try_btn.pressed.connect(_on_try_pressed.bind(item))
@@ -300,8 +384,8 @@ func _make_cosmetic_card(item: Dictionary) -> Control:
func _refresh_wallet() -> void:
var g: int = UserProfileManager.wallet.get("gold", 0)
var s: int = UserProfileManager.wallet.get("star", 0)
- gold_label.text = "⭐ %d" % g
- star_label.text = "✦ %d" % s
+ gold_label.text = str(g)
+ star_label.text = str(s)
status_label.text = ""
# -----------------------------------------------------------------------
@@ -351,23 +435,23 @@ func _on_buy_cosmetic_pressed(item: Dictionary) -> void:
if UserProfileManager.inventory.has(item.id):
status_label.text = "Already owned: " + item.get("name", item.id)
return
- var price_gold: int = item.get("gold", 0)
- var price_star: int = item.get("star", 0)
- if UserProfileManager.wallet.get("gold", 0) < price_gold \
- or UserProfileManager.wallet.get("star", 0) < price_star:
- status_label.text = "Not enough currency."
- return
+
status_label.text = "Purchasing..."
- var success: bool = await UserProfileManager.purchase_item(
- item.id, price_gold, price_star, current_category)
- status_label.text = ("Purchased: " + item.get("name", item.id)) if success else "Purchase failed."
- if success:
+ var err: String = await UserProfileManager.purchase_item(item.id)
+
+ if err == "":
+ status_label.text = "Purchased: " + item.get("name", item.id)
_refresh_wallet()
# Refresh preview to show newly purchased skin's materials
if _preview_revert.is_valid():
_preview_revert.call()
_preview_revert = Callable()
SkinManager.apply_loadout(character_root, UserProfileManager.loadout)
+ else:
+ if "NotEnoughFunds" in err or "funds" in err.to_lower() or "wallet" in err.to_lower():
+ status_label.text = "Not enough currency."
+ else:
+ status_label.text = "Purchase failed."
func _on_close() -> void:
# Clean up any open preview when closing the shop
diff --git a/server/nakama/README.md b/server/nakama/README.md
index b5f2be7..2983512 100644
--- a/server/nakama/README.md
+++ b/server/nakama/README.md
@@ -4,7 +4,7 @@ This guide explains how to deploy the admin module to your Nakama server.
## Files
-- `tekton_admin.ts` - TypeScript server runtime module
+- `core.js` — Admin, user management, leaderboard, daily rewards, inbox, auth, shop catalog, purchases, and economy RPCs
## Prerequisites
@@ -23,12 +23,7 @@ This guide explains how to deploy the admin module to your Nakama server.
2. **Compile TypeScript to JavaScript:**
```bash
cd server/nakama
- tsc tekton_admin.ts --outDir dist --lib ES2020 --types nakama-runtime
- ```
-
-3. **Copy to Nakama modules directory:**
- ```bash
- cp dist/tekton_admin.js /path/to/nakama/data/modules/
+ cp core.js /path/to/nakama/data/modules/
```
4. **Restart Nakama server**
@@ -43,15 +38,11 @@ In your `nakama.yml` or `nakama-docker.yml`:
```yaml
runtime:
- js_entrypoint: "tekton_admin.js"
+ path: /nakama/data/modules
```
-Or for multiple modules:
-
-```yaml
-runtime:
- js_entrypoint: "index.js"
-```
+`core.js` has an `InitModule` function.
+Nakama auto-discovers and calls it — no `js_entrypoint` needed.
## Role System
@@ -109,8 +100,7 @@ curl -X PUT "http://localhost:7350/v2/console/account/{user_id}" \
## Troubleshooting
### RPC Not Found
-- Check module is loaded: `nakama --help` or check logs
-- Verify js_entrypoint in config
+- Check modules are loaded: look for "Tekton core module loaded" in logs
### Permission Denied
- Check user has correct role in metadata
diff --git a/server/nakama/tekton_admin.js b/server/nakama/core.js.bak
similarity index 88%
rename from server/nakama/tekton_admin.js
rename to server/nakama/core.js.bak
index 27ce563..32aa56c 100644
--- a/server/nakama/tekton_admin.js
+++ b/server/nakama/core.js.bak
@@ -3,6 +3,9 @@
*
* This module provides secure admin operations via RPC calls.
* Deploy this to your Nakama server's runtime directory.
+ *
+ * NOTE: Economy RPCs (shop, currency, purchase, featured banners)
+ * are registered by economy.js — each file has its own InitModule.
*/
// Initialize RPC endpoints
@@ -25,12 +28,8 @@ function InitModule(ctx, logger, nk, initializer) {
initializer.registerRpc("get_user_profile", rpcGetUserProfile);
initializer.registerRpc("update_user_profile", rpcUpdateUserProfile);
initializer.registerRpc("search_users", rpcSearchUsers);
-
- // Store RPCs
- initializer.registerRpc("purchase_item", rpcPurchaseItem);
- initializer.registerRpc("get_shop_catalog", rpcGetShopCatalog);
- initializer.registerRpc("buy_currency", rpcBuyCurrency);
-
+
+
// Leaderboard RPCs
initializer.registerRpc("get_leaderboard_stats", rpcGetLeaderboardStats);
initializer.registerRpc("admin_update_stats", rpcAdminUpdateStats);
@@ -60,75 +59,32 @@ function InitModule(ctx, logger, nk, initializer) {
initializer.registerRpc("delete_mail", rpcDeleteMail);
initializer.registerRpc("save_mail_state", rpcSaveMailState);
+
+
// Steam auth hooks
initializer.registerAfterAuthenticateSteam(afterAuthenticateSteam);
+ // Shop and Economy RPCs
+ initializer.registerRpc("purchase_item", rpcPurchaseItem);
+ initializer.registerRpc("get_shop_catalog", rpcGetShopCatalog);
+ initializer.registerRpc("buy_currency", rpcBuyCurrency);
+ initializer.registerRpc("admin_set_featured_banners", rpcAdminSetFeaturedBanners);
+ initializer.registerRpc("admin_get_featured_banners", rpcAdminGetFeaturedBanners);
+
// Create default native leaderboard
// id: "global_high_score", authoritative: true, sort: "desc", operator: "best", reset: None
- try { nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {}); } catch(e) {}
+ try { nk.leaderboardCreate("global_high_score", true, "desc", "best", null, {}); } catch (e) { }
- logger.info("Tekton admin module loaded");
+ logger.info("Tekton core module loaded");
}
// =============================================================================
// Authorization Helpers
// =============================================================================
+
var ADMIN_ROLES = ["admin", "moderator", "owner"];
-// =============================================================================
-// Shop Catalog Definitions
-// =============================================================================
-// To add a new item: append ONE entry to SHOP_CATALOG_DEFS.
-// Fields:
-// id (String) — must match item_id in game inventory + SkinManager
-// name (String) — display name shown in shop
-// category (String) — "head" | "costume" | "glove" | "accessory"
-// gold (Number) — gold price (0 = not sold for gold)
-// star (Number) — star price (0 = not sold for star)
-// rarity (String) — "Common" | "Rare" | "Epic" | "Legendary"
-// character (String) — (optional) which character the skin targets, e.g. "Oldpop"
-
-// [BEGIN_SHOP_CATALOG_DEFS]
-var SHOP_CATALOG_DEFS = [
- // ── HEAD ────────────────────────────────────────────────────────────
- { id: "oldpop-blue-hat", name: "Oldpop Blue Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
- { id: "oldpop-green-hat", name: "Oldpop Green Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
- { id: "oldpop-red-hat", name: "Oldpop Red Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
- { id: "oldpop-yellow-hat", name: "Oldpop Yellow Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
- // ── COSTUME ────────────────────────────────────────────────────────────
- { id: "oldpop-og-pant", name: "Copper OG Pant", category: "costume", gold: 0, star: 0, rarity: "Common", character: "Oldpop" },
- { id: "oldpop-grey-pant", name: "Copper Grey Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
- { id: "oldpop-red-pant", name: "Copper Red Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
- { id: "oldpop-yellow-pant", name: "Copper Yellow Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
- // ── GLOVE ────────────────────────────────────────────────────────────
- { id: "oldpop-blue-gloves", name: "Oldpop Blue Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
- { id: "oldpop-green-gloves", name: "Oldpop Green Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
- { id: "oldpop-red-gloves", name: "Oldpop Red Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
- { id: "oldpop-yellow-gloves", name: "Oldpop Yellow Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
-];
-// [END_SHOP_CATALOG_DEFS]
-
-/** Groups SHOP_CATALOG_DEFS by category for the shop RPC response. */
-function buildShopCatalog() {
- var catalog = {};
- for (var i = 0; i < SHOP_CATALOG_DEFS.length; i++) {
- var def = SHOP_CATALOG_DEFS[i];
- var cat = def.category;
- if (!catalog[cat]) catalog[cat] = [];
- var entry = {
- id: def.id,
- name: def.name,
- gold: def.gold || 0,
- star: def.star || 0,
- rarity: def.rarity || "Common"
- };
- if (def.character) entry.character = def.character;
- catalog[cat].push(entry);
- }
- return catalog;
-}
-
function isAdmin(ctx, nk) {
if (!ctx.userId) return false;
@@ -185,7 +141,7 @@ function rpcSendLobbyInvite(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var req = JSON.parse(payload || "{}");
var toUserId = req.to_user_id;
- var matchId = req.match_id;
+ var matchId = req.match_id;
if (!toUserId || !matchId) throw new Error("Missing to_user_id or match_id");
var sender = nk.accountGetId(ctx.userId);
@@ -227,7 +183,7 @@ function afterAuthenticateSteam(ctx, logger, nk, out, request) {
metadata = typeof account.user.metadata === "string"
? JSON.parse(account.user.metadata || "{}")
: (account.user.metadata || {});
- } catch (e) {}
+ } catch (e) { }
if (!metadata.role) {
metadata.role = "player";
@@ -383,7 +339,7 @@ function rpcAdminGetBanList(ctx, logger, nk, payload) {
""
);
- var bans = result.objects ? result.objects.map(function(obj) { return obj.value; }) : [];
+ var bans = result.objects ? result.objects.map(function (obj) { return obj.value; }) : [];
return JSON.stringify({ bans: bans });
} catch (e) {
@@ -522,14 +478,9 @@ function rpcAdminSetUserRole(ctx, logger, nk, payload) {
}
// =============================================================================
-// Store / Economy RPCs
+// Admin Wallet RPCs
// =============================================================================
-function rpcGetShopCatalog(ctx, logger, nk, payload) {
- if (!ctx.userId) throw new Error("Not authenticated");
- return JSON.stringify({ catalog: buildShopCatalog() });
-}
-
function rpcAdminTopupGold(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
try {
@@ -542,6 +493,7 @@ function rpcAdminTopupGold(ctx, logger, nk, payload) {
}
}
+
// =============================================================================
// Admin Clear Global Chat RPC
// =============================================================================
@@ -589,73 +541,7 @@ function rpcAdminClearGlobalChat(ctx, logger, nk, payload) {
}
-function rpcBuyCurrency(ctx, logger, nk, payload) {
- if (!ctx.userId) throw new Error("Not authenticated");
-
- var request = JSON.parse(payload);
- var packageId = request.package_id;
-
- var changeset = { "gold": 0, "star": 0 };
-
- if (packageId === "gold_100") changeset["gold"] = 100;
- else if (packageId === "gold_500") changeset["gold"] = 550;
- else if (packageId === "gold_1000") changeset["gold"] = 1150;
- else if (packageId === "gold_2000") changeset["gold"] = 2400;
- else if (packageId === "gold_5000") changeset["gold"] = 6250;
- else if (packageId === "gold_10000") changeset["gold"] = 13000;
- else if (packageId === "star_100") { changeset["star"] = 100; changeset["gold"] = -500; }
- else if (packageId === "star_250") { changeset["star"] = 250; changeset["gold"] = -1100; }
- else if (packageId === "star_600") { changeset["star"] = 600; changeset["gold"] = -2500; }
- else throw new Error("Invalid package ID");
-
- try {
- nk.walletUpdate(ctx.userId, changeset, {}, true);
- logger.info("User " + ctx.userId + " bought currency package " + packageId);
- return JSON.stringify({ success: true, package_id: packageId });
- } catch (e) {
- logger.error("Currency purchase failed: " + e.message);
- throw new Error("Currency purchase failed: " + e.message);
- }
-}
-function rpcPurchaseItem(ctx, logger, nk, payload) {
- if (!ctx.userId) throw new Error("Not authenticated");
-
- var request = JSON.parse(payload);
- var itemId = request.item_id;
- var priceGold = request.price_gold || 0;
- var priceStar = request.price_star || 0;
- var category = request.category || "accessory"; // head, costume, glove, accessory
-
- if (!itemId) throw new Error("Item ID required");
-
- try {
- var changeset = {};
- if (priceGold > 0) changeset["gold"] = -priceGold;
- if (priceStar > 0) changeset["star"] = -priceStar;
-
- // Check wallet and deduct
- // nk.walletUpdate throws an error if insufficient funds
- nk.walletUpdate(ctx.userId, changeset, {}, true);
-
- // Record purchase in inventory
- var inventoryObj = {
- collection: "inventory",
- key: itemId,
- userId: ctx.userId,
- value: { category: category, purchased_at: new Date().toISOString() },
- permissionRead: 1,
- permissionWrite: 0
- };
- nk.storageWrite([inventoryObj]);
-
- logger.info("User " + ctx.userId + " purchased " + itemId);
- return JSON.stringify({ success: true, item: itemId });
- } catch (e) {
- logger.error("Purchase failed: " + e.message);
- throw new Error("Purchase failed: " + e.message);
- }
-}
// =============================================================================
// Daily Rewards RPCs
@@ -667,7 +553,7 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
var currentMonth = now.toISOString().substring(5, 7); // e.g. "05"
var todayStr = now.toISOString().substring(0, 10);
var todayIndex = now.getUTCDate() - 1; // 0 to 30
-
+
var stateObjs = nk.storageRead([{ collection: "daily_rewards", key: "state", userId: ctx.userId }]);
var state = { claimed_days: [], last_claim_date: "", month: "" };
if (stateObjs && stateObjs.length > 0) {
@@ -676,7 +562,7 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
state.month = val.month || "";
if (typeof val.claimed_days === 'number') {
var arr = [];
- for (var i=0; i 0) {
config = configObjs[0].value;
}
-
+
var monthRewards = config[currentMonth];
if (!monthRewards || monthRewards.length === 0) {
monthRewards = [];
for (var i = 0; i < 31; i++) {
- monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
+ monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
}
}
-
- var dayIndex = todayIndex;
+
+ var dayIndex = todayIndex;
if (dayIndex >= monthRewards.length) {
throw new Error("Already claimed all rewards for this month");
}
if (state.claimed_days.indexOf(dayIndex) !== -1) {
throw new Error("Already claimed today's reward");
}
-
+
var rewardData = monthRewards[dayIndex];
if (typeof rewardData === "number") {
rewardData = { type: "star", amount: rewardData };
}
-
+
var rewardType = rewardData.type || "star";
var rewardAmount = rewardData.amount || 0;
-
+
if (rewardType === "star" || rewardType === "gold") {
var changes = {};
changes[rewardType] = rewardAmount;
@@ -744,10 +630,10 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
permissionWrite: 0
}]);
}
-
+
state.claimed_days.push(dayIndex);
state.last_claim_date = todayStr;
-
+
nk.storageWrite([{
collection: "daily_rewards",
key: "state",
@@ -756,7 +642,7 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
permissionRead: 1,
permissionWrite: 0
}]);
-
+
return JSON.stringify({ success: true, reward_type: rewardType, reward_amount: rewardAmount, day: dayIndex + 1 });
}
@@ -766,7 +652,7 @@ function rpcGetDailyRewardState(ctx, logger, nk, payload) {
var currentMonth = now.toISOString().substring(5, 7); // e.g. "05"
var todayStr = now.toISOString().substring(0, 10);
var todayIndex = now.getUTCDate() - 1;
-
+
var stateObjs = nk.storageRead([{ collection: "daily_rewards", key: "state", userId: ctx.userId }]);
var state = { claimed_days: [], last_claim_date: "", month: "" };
if (stateObjs && stateObjs.length > 0) {
@@ -775,7 +661,7 @@ function rpcGetDailyRewardState(ctx, logger, nk, payload) {
state.month = val.month || "";
if (typeof val.claimed_days === 'number') {
var arr = [];
- for (var i=0; i 0) {
@@ -797,10 +683,10 @@ function rpcGetDailyRewardState(ctx, logger, nk, payload) {
if (!monthRewards || monthRewards.length === 0) {
monthRewards = [];
for (var i = 0; i < 31; i++) {
- monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
+ monthRewards.push({ type: "star", amount: Math.min(10 + i * 5, 100) });
}
}
-
+
return JSON.stringify({
state: state,
month_rewards: monthRewards,
@@ -913,7 +799,7 @@ function rpcSearchUsers(ctx, logger, nk, payload) {
var request = {};
try {
request = JSON.parse(payload || "{}");
- } catch (e) {}
+ } catch (e) { }
var query = request.query || "";
@@ -921,21 +807,21 @@ function rpcSearchUsers(ctx, logger, nk, payload) {
var users = [];
var sql = "";
var params = [];
-
+
if (query === "") {
sql = "SELECT id, username, display_name, metadata FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100";
} else {
sql = "SELECT id, username, display_name, metadata FROM users WHERE (username ILIKE $1 OR display_name ILIKE $1) AND id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100";
params = ["%" + query + "%"];
}
-
+
var rows = nk.sqlQuery(sql, params);
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var metadata = {};
- try { metadata = JSON.parse(row.metadata || "{}"); } catch(e) {}
-
+ try { metadata = JSON.parse(row.metadata || "{}"); } catch (e) { }
+
users.push({
user_id: row.id,
username: row.username || "",
@@ -960,13 +846,13 @@ function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
var limit = 50;
var records = nk.leaderboardRecordsList("global_high_score", null, limit, "");
var leaderboardData = [];
-
+
var ownerRecords = records.records || [];
for (var i = 0; i < ownerRecords.length; i++) {
var record = ownerRecords[i];
var metadata = {};
- try { metadata = JSON.parse(record.metadata || "{}"); } catch (e) {}
-
+ try { metadata = JSON.parse(record.metadata || "{}"); } catch (e) { }
+
leaderboardData.push({
user_id: record.ownerId,
username: record.username,
@@ -978,7 +864,7 @@ function rpcGetLeaderboardStats(ctx, logger, nk, payload) {
games_won: metadata.games_won || 0
});
}
-
+
return JSON.stringify({ leaderboard: leaderboardData });
} catch (e) {
logger.error("Failed to get native leaderboard stats: " + e);
@@ -1023,7 +909,7 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
var result = nk.storageList(null, "stats", 100, "");
var statsObjects = result.objects || [];
var userGroup = {};
-
+
for (var i = 0; i < statsObjects.length; i++) {
var obj = statsObjects[i];
var userId = obj.userId;
@@ -1032,7 +918,7 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
value = (typeof obj.value === "string") ? JSON.parse(obj.value) : obj.value;
} catch (e) { continue; }
if (!value) continue;
-
+
if (!userGroup[userId]) {
userGroup[userId] = {
high_score: value.high_score || 0,
@@ -1046,14 +932,14 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
userGroup[userId].games_played = Math.max(userGroup[userId].games_played, value.games_played || 0);
userGroup[userId].games_won = Math.max(userGroup[userId].games_won, value.games_won || 0);
}
-
+
// Prioritize avatar and character from game_stats or if current is empty
if (obj.key === "game_stats" || !userGroup[userId].avatar_url) {
if (value.avatar_url) userGroup[userId].avatar_url = value.avatar_url;
if (value.loadout_character) userGroup[userId].loadout_character = value.loadout_character;
}
}
-
+
// Phase 2: Read profiles collection to get loadout and avatars!
var profileResult = nk.storageList(null, "profiles", 100, "");
var profileObjects = profileResult.objects || [];
@@ -1065,7 +951,7 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
try {
value = (typeof obj.value === "string") ? JSON.parse(obj.value) : obj.value;
} catch (e) { continue; }
-
+
if (!userGroup[userId]) {
userGroup[userId] = {
high_score: 0, games_played: 0, games_won: 0,
@@ -1076,16 +962,16 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
if (value.avatar_url && !userGroup[userId].avatar_url) userGroup[userId].avatar_url = value.avatar_url;
if (value.loadout_character && !userGroup[userId].loadout_character) userGroup[userId].loadout_character = value.loadout_character;
}
-
+
var count = 0;
var debugLogs = [];
for (var uid in userGroup) {
try {
var stats = userGroup[uid];
var account = nk.accountGetId(uid);
- var meta = {
- games_played: stats.games_played || 0,
- games_won: stats.games_won || 0,
+ var meta = {
+ games_played: stats.games_played || 0,
+ games_won: stats.games_won || 0,
avatar_url: stats.avatar_url || account.user.avatarUrl || "res://assets/graphics/character_selection/sc_characters/sc_copper.png",
loadout_character: stats.loadout_character || "Copper"
};
@@ -1108,10 +994,10 @@ function rpcSyncLeaderboard(ctx, logger, nk, payload) {
function rpcChangeCredentials(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var req = {};
- try { req = JSON.parse(payload || "{}"); } catch (e) {}
-
+ try { req = JSON.parse(payload || "{}"); } catch (e) { }
+
var account = nk.accountGetId(ctx.userId);
-
+
// If not a guest (has email), verify current password and unlink
if (account.email) {
if (!req.current_password) throw new Error("Current password required");
@@ -1122,7 +1008,7 @@ function rpcChangeCredentials(ctx, logger, nk, payload) {
}
nk.unlinkEmail(ctx.userId, account.email, req.current_password);
}
-
+
try {
nk.linkEmail(ctx.userId, req.new_email, req.new_password);
} catch (e) {
@@ -1130,7 +1016,7 @@ function rpcChangeCredentials(ctx, logger, nk, payload) {
if (account.email) nk.linkEmail(ctx.userId, account.email, req.current_password);
throw new Error("Failed to set new credentials: " + e.message);
}
-
+
return JSON.stringify({ success: true });
}
@@ -1138,10 +1024,10 @@ function rpcChangeCredentials(ctx, logger, nk, payload) {
function rpcResetStats(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var account = nk.accountGetId(ctx.userId);
-
+
// Delete native leaderboard rank
- try { nk.leaderboardRecordDelete("global_high_score", ctx.userId); } catch (e) {}
-
+ try { nk.leaderboardRecordDelete("global_high_score", ctx.userId); } catch (e) { }
+
// Wipe storage stats
var zeros = { games_played: 0, games_won: 0, high_score: 0, total_kills: 0, total_deaths: 0 };
nk.storageWrite([{
@@ -1152,7 +1038,7 @@ function rpcResetStats(ctx, logger, nk, payload) {
permissionRead: 2,
permissionWrite: 1
}]);
-
+
return JSON.stringify({ success: true });
}
@@ -1166,11 +1052,11 @@ function rpcAdminListUsers(ctx, logger, nk, payload) {
try {
var users = [];
var rows = nk.sqlQuery("SELECT id, username, display_name, metadata, create_time FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 500", []);
-
+
for (var i = 0; i < rows.length; i++) {
var row = rows[i];
var metadata = {};
- try { metadata = JSON.parse(row.metadata || "{}"); } catch(e) {}
+ try { metadata = JSON.parse(row.metadata || "{}"); } catch (e) { }
users.push({
user_id: row.id,
username: row.username || "",
@@ -1215,7 +1101,7 @@ function rpcAdminDeleteUsers(ctx, logger, nk, payload) {
// Check if target is admin — don't allow deleting admins
var account = nk.accountGetId(uid);
var meta = {};
- try { meta = JSON.parse(account.user.metadata || "{}"); } catch(e) {}
+ try { meta = JSON.parse(account.user.metadata || "{}"); } catch (e) { }
if (ADMIN_ROLES.indexOf(meta.role || "") !== -1) {
failed.push({ user_id: uid, reason: "Cannot delete admin account" });
continue;
@@ -1304,26 +1190,26 @@ function rpcAdminDeleteStats(ctx, logger, nk, payload) {
function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
-
+
try {
var result = nk.storageList(null, "stats", 100, "");
var statsObjects = result.objects || [];
var userGroup = {}; // [userId] = { highScore, gamesPlayed, gamesWon, avatar }
-
+
// Phase 1: Group and merge
for (var i = 0; i < statsObjects.length; i++) {
var obj = statsObjects[i];
var userId = obj.userId;
var key = obj.key; // "stats" or "game_stats"
var value;
-
+
try {
value = JSON.parse(obj.value || "{}");
} catch (jsonErr) {
logger.error("Skipping key " + key + " for user " + userId + " due to corrupt JSON");
continue;
}
-
+
if (!userGroup[userId]) {
userGroup[userId] = {
high_score: value.high_score || 0,
@@ -1338,37 +1224,37 @@ function rpcAdminSyncLeaderboard(ctx, logger, nk, payload) {
userGroup[userId].games_played += (value.games_played || 0);
userGroup[userId].games_won += (value.games_won || 0);
}
-
+
// Prioritize avatar and character from game_stats
if (key === "game_stats" || !userGroup[userId].avatar_url) {
try {
if (value.avatar_url) userGroup[userId].avatar_url = value.avatar_url;
if (value.loadout_character) userGroup[userId].loadout_character = value.loadout_character;
- } catch (e) {}
+ } catch (e) { }
}
}
-
+
// Phase 2: Write to native leaderboard
var count = 0;
for (var userId in userGroup) {
try {
var stats = userGroup[userId];
var account = nk.accountGetId(userId);
-
+
var metadata = {
games_played: stats.games_played,
games_won: stats.games_won,
avatar_url: stats.avatar_url || account.user.avatarUrl || "",
loadout_character: stats.loadout_character || "Copper"
};
-
+
nk.leaderboardRecordWrite("global_high_score", userId, account.user.username, stats.high_score, 0, metadata);
count++;
} catch (inner) {
logger.error("Failed to sync merged record for " + userId + ": " + inner);
}
}
-
+
return JSON.stringify({ success: true, synced: count });
} catch (e) {
logger.error("Leaderboard sync failed: " + e);
@@ -1393,7 +1279,7 @@ function rpcSendFriendRequest(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var request = {};
- try { request = JSON.parse(payload || "{}"); } catch (e) {}
+ try { request = JSON.parse(payload || "{}"); } catch (e) { }
var targetUserId = request.user_id || "";
if (!targetUserId) throw new Error("user_id is required");
@@ -1420,12 +1306,12 @@ function rpcSendLobbyInvite(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
var request = {};
- try { request = JSON.parse(payload || "{}"); } catch (e) {}
+ try { request = JSON.parse(payload || "{}"); } catch (e) { }
var toUserId = request.to_user_id || "";
- var matchId = request.match_id || "";
+ var matchId = request.match_id || "";
if (!toUserId) throw new Error("to_user_id is required");
- if (!matchId) throw new Error("match_id is required");
+ if (!matchId) throw new Error("match_id is required");
var senderAccount = nk.accountGetId(ctx.userId);
var senderName = senderAccount.user.displayName || senderAccount.user.username || "Someone";
@@ -1450,16 +1336,16 @@ function rpcSendLobbyInvite(ctx, logger, nk, payload) {
function rpcAdminSendMail(ctx, logger, nk, payload) {
requireAdmin(ctx, nk);
var request = JSON.parse(payload || "{}");
-
+
var nowStr = new Date().toISOString();
var startDate = request.start_date || nowStr;
var endDate = request.end_date || "";
-
+
// Auto-delete / expire after 30 days from start
var startObj = new Date(startDate);
startObj.setDate(startObj.getDate() + 30);
var expiryDate = startObj.toISOString();
-
+
var mailObj = {
id: nk.uuidv4(),
title: request.title || "Announcement",
@@ -1471,7 +1357,7 @@ function rpcAdminSendMail(ctx, logger, nk, payload) {
expiry_date: expiryDate,
rewards: request.rewards || []
};
-
+
if (request.target_user_id) {
mailObj.type = "personal";
var invObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: request.target_user_id }]);
@@ -1507,20 +1393,20 @@ function rpcAdminSendMail(ctx, logger, nk, payload) {
}]);
logger.info("Global mail sent");
}
-
+
return JSON.stringify({ success: true, mail: mailObj });
}
function rpcGetMail(ctx, logger, nk, payload) {
if (!ctx.userId) throw new Error("Not authenticated");
-
+
var personalObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: ctx.userId }]);
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
-
+
var personalMails = (personalObjs && personalObjs.length > 0) ? (personalObjs[0].value.mails || []) : [];
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
-
+
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
if (stateObjs && stateObjs.length > 0) {
var val = stateObjs[0].value;
@@ -1528,33 +1414,33 @@ function rpcGetMail(ctx, logger, nk, payload) {
state.deleted_ids = val.deleted_ids || [];
state.read_ids = val.read_ids || [];
}
-
+
var allMails = personalMails.concat(globalMails);
var filteredMails = [];
var nowStr = new Date().toISOString();
-
+
for (var i = 0; i < allMails.length; i++) {
var mail = allMails[i];
if (state.deleted_ids.indexOf(mail.id) !== -1) continue;
-
+
// Expiry check
if (mail.expiry_date && nowStr > mail.expiry_date) {
continue;
}
-
+
// Scheduled start
if (mail.start_date && nowStr < mail.start_date) {
continue;
}
-
+
// Scheduled end
if (mail.type === "global" && mail.end_date && nowStr > mail.end_date) {
continue;
}
-
+
filteredMails.push(mail);
}
-
+
return JSON.stringify({ mails: filteredMails, state: state });
}
@@ -1563,12 +1449,12 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
var request = JSON.parse(payload || "{}");
var mailId = request.mail_id;
if (!mailId) throw new Error("mail_id required");
-
+
// fetch all mails to find it
var personalObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: ctx.userId }]);
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
-
+
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
if (stateObjs && stateObjs.length > 0) {
var val = stateObjs[0].value;
@@ -1576,15 +1462,15 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
state.deleted_ids = val.deleted_ids || [];
state.read_ids = val.read_ids || [];
}
-
+
if (state.claimed_ids.indexOf(mailId) !== -1) {
throw new Error("Reward already claimed");
}
-
+
var personalMails = (personalObjs && personalObjs.length > 0) ? (personalObjs[0].value.mails || []) : [];
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
var allMails = personalMails.concat(globalMails);
-
+
var targetMail = null;
for (var i = 0; i < allMails.length; i++) {
if (allMails[i].id === mailId) {
@@ -1592,28 +1478,28 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
break;
}
}
-
+
if (!targetMail) throw new Error("Mail not found");
-
+
var rewards = targetMail.rewards || [];
var starTotal = 0;
var goldTotal = 0;
-
+
// Support legacy dictionary if it exists
if (!Array.isArray(rewards)) {
starTotal = rewards.star || 0;
goldTotal = rewards.gold || 0;
rewards = []; // prevent array loop
}
-
+
var fragsToUpdate = {};
var skinsToAdd = [];
-
+
for (var j = 0; j < rewards.length; j++) {
var r = rewards[j];
var type = r.type || "star";
var amount = r.amount || 0;
-
+
if (type === "star") starTotal += amount;
else if (type === "gold") goldTotal += amount;
else if (type.startsWith("frag_") || type === "item") {
@@ -1624,14 +1510,14 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
if (r.id) skinsToAdd.push(r.id);
}
}
-
+
if (starTotal > 0 || goldTotal > 0) {
var changes = {};
if (starTotal > 0) changes["star"] = starTotal;
if (goldTotal > 0) changes["gold"] = goldTotal;
nk.walletUpdate(ctx.userId, changes, {}, true);
}
-
+
if (Object.keys(fragsToUpdate).length > 0) {
var invObjs = nk.storageRead([{ collection: "inventory", key: "fragments", userId: ctx.userId }]);
var frags = {};
@@ -1650,7 +1536,7 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
permissionWrite: 0
}]);
}
-
+
if (skinsToAdd.length > 0) {
var skinWrites = [];
for (var s = 0; s < skinsToAdd.length; s++) {
@@ -1665,12 +1551,12 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
}
nk.storageWrite(skinWrites);
}
-
+
state.claimed_ids.push(mailId);
if (state.read_ids.indexOf(mailId) === -1) {
state.read_ids.push(mailId);
}
-
+
nk.storageWrite([{
collection: "inbox",
key: "state",
@@ -1679,7 +1565,7 @@ function rpcClaimMailReward(ctx, logger, nk, payload) {
permissionRead: 1,
permissionWrite: 0
}]);
-
+
return JSON.stringify({ success: true, claimed_ids: state.claimed_ids });
}
@@ -1688,7 +1574,7 @@ function rpcDeleteMail(ctx, logger, nk, payload) {
var request = JSON.parse(payload || "{}");
var mailId = request.mail_id;
if (!mailId) throw new Error("mail_id required");
-
+
var stateObjs = nk.storageRead([{ collection: "inbox", key: "state", userId: ctx.userId }]);
var state = { claimed_ids: [], deleted_ids: [], read_ids: [] };
if (stateObjs && stateObjs.length > 0) {
@@ -1697,14 +1583,14 @@ function rpcDeleteMail(ctx, logger, nk, payload) {
state.deleted_ids = val.deleted_ids || [];
state.read_ids = val.read_ids || [];
}
-
+
if (state.deleted_ids.indexOf(mailId) === -1) {
state.deleted_ids.push(mailId);
}
if (state.read_ids.indexOf(mailId) === -1) {
state.read_ids.push(mailId);
}
-
+
nk.storageWrite([{
collection: "inbox",
key: "state",
@@ -1713,7 +1599,7 @@ function rpcDeleteMail(ctx, logger, nk, payload) {
permissionRead: 1,
permissionWrite: 0
}]);
-
+
return JSON.stringify({ success: true, deleted_ids: state.deleted_ids });
}
@@ -1794,7 +1680,7 @@ function rpcAdminListMail(ctx, logger, nk, payload) {
var allMails = globalMails.concat(personalMails);
// Sort newest first
- allMails.sort(function(a, b) {
+ allMails.sort(function (a, b) {
return (b.date || "").localeCompare(a.date || "");
});
@@ -1912,7 +1798,7 @@ function rpcAdminDeleteMailServer(ctx, logger, nk, payload) {
var globalObjs = nk.storageRead([{ collection: "config", key: "global_mail", userId: "00000000-0000-0000-0000-000000000000" }]);
var globalMails = (globalObjs && globalObjs.length > 0) ? (globalObjs[0].value.mails || []) : [];
var before = globalMails.length;
- globalMails = globalMails.filter(function(m) { return m.id !== mailId; });
+ globalMails = globalMails.filter(function (m) { return m.id !== mailId; });
if (globalMails.length === before) throw new Error("Mail not found");
nk.storageWrite([{
collection: "config",
@@ -1927,7 +1813,7 @@ function rpcAdminDeleteMailServer(ctx, logger, nk, payload) {
var pObjs = nk.storageRead([{ collection: "inbox", key: "personal", userId: targetUserId }]);
var personalMails = (pObjs && pObjs.length > 0) ? (pObjs[0].value.mails || []) : [];
var pBefore = personalMails.length;
- personalMails = personalMails.filter(function(m) { return m.id !== mailId; });
+ personalMails = personalMails.filter(function (m) { return m.id !== mailId; });
if (personalMails.length === pBefore) throw new Error("Mail not found");
nk.storageWrite([{
collection: "inbox",
@@ -1942,3 +1828,273 @@ function rpcAdminDeleteMailServer(ctx, logger, nk, payload) {
logger.info("Admin deleted mail " + mailId + " from server by " + ctx.userId);
return JSON.stringify({ success: true });
}
+
+// =============================================================================
+// Shop Catalog Definitions
+// =============================================================================
+
+// [BEGIN_SHOP_CATALOG_DEFS]
+var SHOP_CATALOG_DEFS = [
+ // ── HEAD ────────────────────────────────────────────────────────────
+ { id: "oldpop-blue-hat", name: "Oldpop Blue Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
+ { id: "oldpop-green-hat", name: "Oldpop Green Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
+ { id: "oldpop-red-hat", name: "Oldpop Red Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
+ { id: "oldpop-yellow-hat", name: "Oldpop Yellow Hat", category: "head", gold: 100, star: 0, rarity: "Common", character: "Oldpop" },
+ // ── COSTUME ────────────────────────────────────────────────────────────
+ { id: "oldpop-og-pant", name: "Copper OG Pant", category: "costume", gold: 0, star: 0, rarity: "Common", character: "Oldpop" },
+ { id: "oldpop-grey-pant", name: "Copper Grey Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
+ { id: "oldpop-red-pant", name: "Copper Red Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
+ { id: "oldpop-yellow-pant", name: "Copper Yellow Pant", category: "costume", gold: 150, star: 0, rarity: "Common", character: "Oldpop" },
+ // ── GLOVE ────────────────────────────────────────────────────────────
+ { id: "oldpop-blue-gloves", name: "Oldpop Blue Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
+ { id: "oldpop-green-gloves", name: "Oldpop Green Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
+ { id: "oldpop-red-gloves", name: "Oldpop Red Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
+ { id: "oldpop-yellow-gloves", name: "Oldpop Yellow Gloves", category: "glove", gold: 75, star: 0, rarity: "Common", character: "Oldpop" },
+];
+// [END_SHOP_CATALOG_DEFS]
+
+// =============================================================================
+// Shop RPCs
+// =============================================================================
+
+function buildShopCatalog() {
+ var catalog = {};
+ for (var i = 0; i < SHOP_CATALOG_DEFS.length; i++) {
+ var def = SHOP_CATALOG_DEFS[i];
+ var cat = def.category;
+ if (!catalog[cat]) catalog[cat] = [];
+ var entry = {
+ id: def.id,
+ name: def.name,
+ gold: def.gold || 0,
+ star: def.star || 0,
+ rarity: def.rarity || "Common"
+ };
+ if (def.character) entry.character = def.character;
+ catalog[cat].push(entry);
+ }
+ return catalog;
+}
+
+function rpcGetShopCatalog(ctx, logger, nk, payload) {
+ if (!ctx.userId) throw new Error("Not authenticated");
+ var result = { catalog: buildShopCatalog(), featured_banners: [] };
+ try {
+ var objs = nk.storageRead([{ collection: "shop_config", key: "featured_banners", userId: "00000000-0000-0000-0000-000000000000" }]);
+ if (objs && objs.length > 0) {
+ var data = JSON.parse(objs[0].value);
+ if (data && data.banners) result.featured_banners = data.banners;
+ }
+ } catch (e) {
+ logger.warn("No featured banners configured: " + e);
+ }
+ return JSON.stringify(result);
+}
+
+// =============================================================================
+// Currency Purchase RPC
+// =============================================================================
+
+function rpcBuyCurrency(ctx, logger, nk, payload) {
+ if (!ctx.userId) throw new Error("Not authenticated");
+
+ var request = JSON.parse(payload);
+ var packageId = request.package_id;
+ var receipt = request.receipt;
+ var idempotencyKey = request.idempotency_key;
+
+ if (!packageId) throw new Error("Package ID required");
+ if (!idempotencyKey) throw new Error("Idempotency key required");
+
+ try {
+ var existing = nk.storageRead([{ collection: "receipts", key: idempotencyKey, userId: ctx.userId }]);
+ if (existing && existing.length > 0) {
+ return JSON.stringify({ success: true, package_id: packageId, duplicate: true, status: existing[0].value.status });
+ }
+ } catch (e) { }
+
+ var changeset = { "gold": 0, "star": 0 };
+ var requiresVerification = false;
+
+ if (packageId === "gold_100") { changeset["gold"] = 100; requiresVerification = true; }
+ else if (packageId === "gold_500") { changeset["gold"] = 550; requiresVerification = true; }
+ else if (packageId === "gold_1000") { changeset["gold"] = 1150; requiresVerification = true; }
+ else if (packageId === "gold_2000") { changeset["gold"] = 2400; requiresVerification = true; }
+ else if (packageId === "gold_5000") { changeset["gold"] = 6250; requiresVerification = true; }
+ else if (packageId === "gold_10000") { changeset["gold"] = 13000; requiresVerification = true; }
+ else if (packageId === "star_100") { changeset["star"] = 100; changeset["gold"] = -500; }
+ else if (packageId === "star_250") { changeset["star"] = 250; changeset["gold"] = -1100; }
+ else if (packageId === "star_600") { changeset["star"] = 600; changeset["gold"] = -2500; }
+ else throw new Error("Invalid package ID");
+
+ if (requiresVerification && !receipt) {
+ var pendingObj = {
+ collection: "receipts",
+ key: idempotencyKey,
+ userId: ctx.userId,
+ value: { type: "currency", package_id: packageId, status: "pending", created_at: new Date().toISOString() },
+ permissionRead: 1,
+ permissionWrite: 0
+ };
+ nk.storageWrite([pendingObj]);
+ return JSON.stringify({ success: true, status: "pending", package_id: packageId });
+ }
+
+ try {
+ if (changeset["gold"] !== 0 || changeset["star"] !== 0) {
+ nk.walletUpdate(ctx.userId, changeset, {}, true);
+ }
+
+ var receiptObj = {
+ collection: "receipts",
+ key: idempotencyKey,
+ userId: ctx.userId,
+ value: { type: "currency", package_id: packageId, changeset: changeset, receipt: receipt || null, status: "verified", processed_at: new Date().toISOString() },
+ permissionRead: 1,
+ permissionWrite: 0
+ };
+ nk.storageWrite([receiptObj]);
+
+ logger.info("User " + ctx.userId + " bought currency package " + packageId);
+ return JSON.stringify({ success: true, status: "verified", package_id: packageId });
+ } catch (e) {
+ logger.error("Currency purchase failed: " + e.message);
+ throw new Error("NotEnoughFunds");
+ }
+}
+
+// =============================================================================
+// Item Purchase RPC
+// =============================================================================
+
+function rpcPurchaseItem(ctx, logger, nk, payload) {
+ if (!ctx.userId) throw new Error("Not authenticated");
+
+ var request = JSON.parse(payload);
+ var itemId = request.item_id;
+ var quantity = request.quantity || 1;
+ var idempotencyKey = request.idempotency_key;
+
+ if (!itemId) throw new Error("Item ID required");
+ if (quantity < 1) throw new Error("Invalid quantity");
+ if (!idempotencyKey) throw new Error("Idempotency key required");
+
+ try {
+ var existing = nk.storageRead([{ collection: "receipts", key: idempotencyKey, userId: ctx.userId }]);
+ if (existing && existing.length > 0) {
+ return JSON.stringify({ success: true, item: itemId, duplicate: true });
+ }
+ } catch (e) { }
+
+ var itemDef = null;
+ for (var i = 0; i < SHOP_CATALOG_DEFS.length; i++) {
+ if (SHOP_CATALOG_DEFS[i].id === itemId) {
+ itemDef = SHOP_CATALOG_DEFS[i];
+ break;
+ }
+ }
+
+ if (!itemDef) throw new Error("ItemNotFound");
+
+ var priceGold = (itemDef.gold || 0) * quantity;
+ var priceStar = (itemDef.star || 0) * quantity;
+ var category = itemDef.category || "accessory";
+
+ try {
+ var changeset = {};
+ if (priceGold > 0) changeset["gold"] = -priceGold;
+ if (priceStar > 0) changeset["star"] = -priceStar;
+
+ if (priceGold > 0 || priceStar > 0) {
+ nk.walletUpdate(ctx.userId, changeset, {}, true);
+ }
+ } catch (e) {
+ logger.error("Wallet update failed: " + e.message);
+ throw new Error("NotEnoughFunds");
+ }
+
+ try {
+ var writes = [];
+ writes.push({
+ collection: "inventory",
+ key: itemId,
+ userId: ctx.userId,
+ value: { category: category, purchased_at: new Date().toISOString(), quantity: quantity },
+ permissionRead: 1,
+ permissionWrite: 0
+ });
+
+ writes.push({
+ collection: "receipts",
+ key: idempotencyKey,
+ userId: ctx.userId,
+ value: { type: "item", item_id: itemId, quantity: quantity, cost: { gold: priceGold, star: priceStar }, processed_at: new Date().toISOString() },
+ permissionRead: 1,
+ permissionWrite: 0
+ });
+
+ nk.storageWrite(writes);
+
+ logger.info("User " + ctx.userId + " purchased " + itemId);
+ return JSON.stringify({ success: true, item: itemId });
+ } catch (e) {
+ logger.error("Purchase failed: " + e.message);
+ throw new Error("PurchaseFailed");
+ }
+}
+
+// =============================================================================
+// Featured Banners (Shop) RPCs
+// =============================================================================
+
+/**
+ * Admin sets featured banner slots.
+ * Payload: { banners: [ { item_id, label }, ... ] } (max 3 slots)
+ * Stored in system-owned storage: shop_config / featured_banners
+ */
+function rpcAdminSetFeaturedBanners(ctx, logger, nk, payload) {
+ requireAdmin(ctx, nk);
+ var req = JSON.parse(payload || "{}");
+ var banners = req.banners || [];
+ if (banners.length > 3) banners = banners.slice(0, 3);
+
+ // Validate each banner references a real catalog item
+ for (var i = 0; i < banners.length; i++) {
+ var itemId = banners[i].item_id || "";
+ if (itemId === "") continue; // empty slot
+ var found = false;
+ for (var j = 0; j < SHOP_CATALOG_DEFS.length; j++) {
+ if (SHOP_CATALOG_DEFS[j].id === itemId) { found = true; break; }
+ }
+ if (!found) throw new Error("Item not found in catalog: " + itemId);
+ }
+
+ nk.storageWrite([{
+ collection: "shop_config",
+ key: "featured_banners",
+ userId: "00000000-0000-0000-0000-000000000000",
+ value: JSON.stringify({ banners: banners }),
+ permissionRead: 2,
+ permissionWrite: 0
+ }]);
+
+ logger.info("Featured banners updated by admin " + ctx.userId);
+ return JSON.stringify({ success: true, banners: banners });
+}
+
+/**
+ * Admin reads current featured banner config.
+ */
+function rpcAdminGetFeaturedBanners(ctx, logger, nk, payload) {
+ requireAdmin(ctx, nk);
+ try {
+ var objs = nk.storageRead([{ collection: "shop_config", key: "featured_banners", userId: "00000000-0000-0000-0000-000000000000" }]);
+ if (objs && objs.length > 0) {
+ var data = JSON.parse(objs[0].value);
+ return JSON.stringify({ banners: data.banners || [] });
+ }
+ } catch (e) {
+ logger.warn("Error reading featured banners: " + e);
+ }
+ return JSON.stringify({ banners: [] });
+}
diff --git a/server/nakama/lua/admin.lua b/server/nakama/lua/admin.lua
new file mode 100644
index 0000000..aa0463c
--- /dev/null
+++ b/server/nakama/lua/admin.lua
@@ -0,0 +1,355 @@
+local nk = require("nakama")
+local utils = require("lua.utils")
+
+local admin = {}
+
+function admin.rpc_admin_kick_player(context, payload)
+ local request = nk.json_decode(payload)
+ utils.require_admin_or_host(context, request.match_id)
+
+ if request.user_id == context.user_id then
+ error("Cannot kick yourself")
+ end
+
+ local status, err = pcall(nk.match_signal, request.match_id, nk.json_encode({
+ action = "kick",
+ user_id = request.user_id,
+ reason = request.reason or "Kicked by admin"
+ }))
+
+ if not status then
+ nk.logger_error("Failed to kick player: " .. tostring(err))
+ error("Failed to kick player")
+ end
+
+ nk.logger_info("Player " .. request.user_id .. " kicked from match " .. request.match_id .. " by " .. context.user_id)
+ return nk.json_encode({ success = true })
+end
+
+function admin.rpc_admin_ban_player(context, payload)
+ local request = nk.json_decode(payload)
+ utils.require_admin(context)
+
+ if request.user_id == context.user_id then
+ error("Cannot ban yourself")
+ end
+
+ local status, targetAccount = pcall(nk.account_get_id, request.user_id)
+ if not status then error("Target account not found") end
+
+ local metadata = {}
+ if targetAccount.user.metadata then
+ status, metadata = pcall(nk.json_decode, targetAccount.user.metadata)
+ if not status then metadata = {} end
+ end
+
+ if utils.ADMIN_ROLES[metadata.role or ""] then
+ error("Cannot ban an admin")
+ end
+
+ local banExpires = nil
+ if request.duration_hours and request.duration_hours > 0 then
+ -- Unix time in seconds for lua, Nakama might expect ISO string or unix
+ banExpires = os.time() + (request.duration_hours * 60 * 60)
+ end
+
+ metadata.banned = true
+ metadata.ban_reason = request.reason or "Banned by admin"
+ if banExpires then metadata.ban_expires = banExpires end
+
+ nk.account_update_id(request.user_id, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
+
+ if request.match_id then
+ pcall(nk.match_signal, request.match_id, nk.json_encode({
+ action = "kick",
+ user_id = request.user_id,
+ reason = "Banned: " .. (request.reason or "")
+ }))
+ end
+
+ local banRecord = {
+ user_id = request.user_id,
+ username = targetAccount.user.username,
+ banned_by = context.user_id,
+ banned_at = os.time(),
+ reason = request.reason,
+ expires = banExpires
+ }
+
+ nk.storage_write({{
+ collection = "bans",
+ key = request.user_id,
+ user_id = "00000000-0000-0000-0000-000000000000",
+ value = banRecord,
+ permission_read = 2,
+ permission_write = 0
+ }})
+
+ nk.logger_warn("Player " .. request.user_id .. " banned by " .. context.user_id)
+ return nk.json_encode({ success = true, ban = banRecord })
+end
+
+function admin.rpc_admin_unban_player(context, payload)
+ local request = nk.json_decode(payload)
+ utils.require_admin(context)
+
+ local status, targetAccount = pcall(nk.account_get_id, request.user_id)
+ if not status then error("Target account not found") end
+
+ local metadata = {}
+ if targetAccount.user.metadata then
+ status, metadata = pcall(nk.json_decode, targetAccount.user.metadata)
+ if not status then metadata = {} end
+ end
+
+ metadata.banned = nil
+ metadata.ban_reason = nil
+ metadata.ban_expires = nil
+
+ nk.account_update_id(request.user_id, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
+
+ nk.storage_delete({{
+ collection = "bans",
+ key = request.user_id,
+ user_id = "00000000-0000-0000-0000-000000000000"
+ }})
+
+ nk.logger_info("Player " .. request.user_id .. " unbanned by " .. context.user_id)
+ return nk.json_encode({ success = true })
+end
+
+function admin.rpc_admin_get_ban_list(context, payload)
+ utils.require_admin(context)
+
+ local status, result = pcall(nk.storage_list, "00000000-0000-0000-0000-000000000000", "bans", 100)
+ local bans = {}
+
+ if status and result and result.objects then
+ for _, obj in ipairs(result.objects) do
+ table.insert(bans, obj.value)
+ end
+ end
+
+ return nk.json_encode({ bans = bans })
+end
+
+function admin.rpc_admin_get_server_stats(context, payload)
+ local request = nk.json_decode(payload or "{}")
+
+ if request.match_id then
+ utils.require_admin_or_host(context, request.match_id)
+ else
+ utils.require_admin(context)
+ end
+
+ local matches = nk.match_list(100, true)
+ local activeMatchCount = matches and #matches or 0
+
+ local totalPlayers = 0
+ if matches then
+ for _, match in ipairs(matches) do
+ totalPlayers = totalPlayers + (match.size or 0)
+ end
+ end
+
+ local stats = {
+ active_matches = activeMatchCount,
+ total_players = totalPlayers,
+ server_time = os.time()
+ }
+
+ if request.match_id then
+ local status, match = pcall(nk.match_get, request.match_id)
+ if status and match then
+ stats.match = {
+ id = match.match_id,
+ size = match.size,
+ tick_rate = match.tick_rate,
+ authoritative = match.authoritative
+ }
+ end
+ end
+
+ return nk.json_encode(stats)
+end
+
+function admin.rpc_admin_end_match(context, payload)
+ local request = nk.json_decode(payload)
+ utils.require_admin_or_host(context, request.match_id)
+
+ nk.match_signal(request.match_id, nk.json_encode({
+ action = "end_match",
+ reason = request.reason or "Ended by admin"
+ }))
+
+ nk.logger_info("Match " .. request.match_id .. " ended by " .. context.user_id)
+ return nk.json_encode({ success = true })
+end
+
+function admin.rpc_admin_set_user_role(context, payload)
+ local request = nk.json_decode(payload)
+
+ local callerAccount = nk.account_get_id(context.user_id)
+ local callerMetadata = nk.json_decode(callerAccount.user.metadata or "{}")
+
+ if callerMetadata.role ~= "owner" then
+ error("Only owners can modify user roles")
+ end
+
+ local validRoles = { player = true, moderator = true, admin = true }
+ if not validRoles[request.role] then
+ error("Invalid role")
+ end
+
+ local targetAccount = nk.account_get_id(request.user_id)
+ local metadata = nk.json_decode(targetAccount.user.metadata or "{}")
+ metadata.role = request.role
+
+ nk.account_update_id(request.user_id, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
+
+ nk.logger_info("User " .. request.user_id .. " role set to " .. request.role .. " by " .. context.user_id)
+ return nk.json_encode({ success = true, role = request.role })
+end
+
+function admin.rpc_admin_topup_gold(context, payload)
+ utils.require_admin(context)
+ nk.wallet_update(context.user_id, { gold = 999999 })
+ nk.logger_info("Admin gold top-up applied for user " .. context.user_id)
+ return nk.json_encode({ success = true, gold_added = 999999 })
+end
+
+function admin.rpc_admin_clear_global_chat(context, payload)
+ utils.require_admin(context)
+
+ local request = nk.json_decode(payload or "{}")
+ local channelId = request.channel_id or ""
+
+ if channelId == "" then
+ error("channel_id is required. Pass the channel ID from the client.")
+ end
+
+ local deleted = 0
+ local cursor = ""
+
+ repeat
+ local status, result = pcall(nk.channel_messages_list, channelId, 100, false, cursor)
+ if not status then break end
+
+ local messages = result.messages or {}
+ for _, msg in ipairs(messages) do
+ pcall(nk.channel_message_remove, channelId, msg.message_id)
+ deleted = deleted + 1
+ end
+
+ cursor = result.next_cursor or ""
+ until cursor == ""
+
+ nk.logger_info("[AdminClearGlobalChat] Deleted " .. deleted .. " messages by " .. context.user_id)
+ return nk.json_encode({ success = true, deleted = deleted })
+end
+
+function admin.rpc_admin_list_users(context, payload)
+ utils.require_admin(context)
+
+ local users = {}
+ local sql = "SELECT id, username, display_name, metadata, create_time FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 500"
+
+ local status, rows = pcall(nk.sql_query, sql, {})
+ if status and rows then
+ for _, row in ipairs(rows) do
+ local metadata = {}
+ if row.metadata then
+ local s, m = pcall(nk.json_decode, row.metadata)
+ if s then metadata = m end
+ end
+
+ table.insert(users, {
+ user_id = row.id,
+ username = row.username or "",
+ display_name = row.display_name or row.username or "",
+ create_time = row.create_time,
+ role = metadata.role or "player",
+ banned = metadata.banned or false,
+ ban_reason = metadata.ban_reason or ""
+ })
+ end
+ end
+
+ return nk.json_encode({ users = users, count = #users })
+end
+
+function admin.rpc_admin_delete_users(context, payload)
+ utils.require_admin(context)
+
+ local request = nk.json_decode(payload)
+ local userIds = request.user_ids or {}
+
+ if #userIds == 0 then error("No user IDs provided") end
+
+ for _, uid in ipairs(userIds) do
+ if uid == context.user_id then
+ error("Cannot delete your own account")
+ end
+ end
+
+ local deleted = {}
+ local failed = {}
+
+ for _, uid in ipairs(userIds) do
+ local status, err = pcall(function()
+ local account = nk.account_get_id(uid)
+ local meta = {}
+ if account.user.metadata then
+ local s, m = pcall(nk.json_decode, account.user.metadata)
+ if s then meta = m end
+ end
+ if meta.role == "admin" or meta.role == "moderator" or meta.role == "owner" then
+ error("Cannot delete admin account")
+ end
+ nk.account_delete_id(uid, false)
+ table.insert(deleted, uid)
+ nk.logger_warn("User " .. uid .. " deleted by " .. context.user_id)
+ end)
+
+ if not status then
+ table.insert(failed, { user_id = uid, reason = tostring(err) })
+ end
+ end
+
+ return nk.json_encode({ success = true, deleted = deleted, failed = failed })
+end
+
+function admin.rpc_admin_get_player_list(context, payload)
+ local request = nk.json_decode(payload)
+ utils.require_admin_or_host(context, request.match_id)
+
+ local status, match = pcall(nk.match_get, request.match_id)
+ if not status or not match then
+ error("Match not found")
+ end
+
+ -- Get player details
+ -- Note: In actual implementation, you'd need to track presences
+ -- This is a simplified version - adjust based on your match handler
+ local players = {}
+
+ return nk.json_encode({ players = players })
+end
+
+-- Register RPCs
+nk.register_rpc(admin.rpc_admin_kick_player, "admin_kick_player")
+nk.register_rpc(admin.rpc_admin_ban_player, "admin_ban_player")
+nk.register_rpc(admin.rpc_admin_unban_player, "admin_unban_player")
+nk.register_rpc(admin.rpc_admin_get_ban_list, "admin_get_ban_list")
+nk.register_rpc(admin.rpc_admin_get_server_stats, "admin_get_server_stats")
+nk.register_rpc(admin.rpc_admin_get_player_list, "admin_get_player_list")
+nk.register_rpc(admin.rpc_admin_end_match, "admin_end_match")
+nk.register_rpc(admin.rpc_admin_set_user_role, "admin_set_user_role")
+nk.register_rpc(admin.rpc_admin_topup_gold, "admin_topup_gold")
+nk.register_rpc(admin.rpc_admin_clear_global_chat, "admin_clear_global_chat")
+nk.register_rpc(admin.rpc_admin_list_users, "admin_list_users")
+nk.register_rpc(admin.rpc_admin_delete_users, "admin_delete_users")
+
+nk.logger_info("LUA TEST: admin module loaded successfully")
+
+return admin
diff --git a/server/nakama/lua/core.lua b/server/nakama/lua/core.lua
new file mode 100644
index 0000000..1abb82a
--- /dev/null
+++ b/server/nakama/lua/core.lua
@@ -0,0 +1,42 @@
+local nk = require("nakama")
+
+-- =============================================================================
+-- Steam Auth Hook
+-- =============================================================================
+-- On first Steam login: set display_name from Steam username, default role to "player"
+
+local function after_authenticate_steam(context, output, input)
+ if not context.user_id then return end
+
+ local status, account = pcall(nk.account_get_id, context.user_id)
+ if not status or not account then return end
+
+ -- On first login (no display name set), use Steam username
+ if not account.user.display_name or account.user.display_name == "" then
+ local steamName = input.username or "SteamPlayer"
+ pcall(nk.account_update_id, context.user_id, nil, steamName, nil, nil, nil, nil, nil)
+ nk.logger_info("Steam user " .. context.user_id .. " display name set to: " .. steamName)
+ end
+
+ -- Set default role if not set
+ local metadata = {}
+ if type(account.user.metadata) == "string" then
+ local s, m = pcall(nk.json_decode, account.user.metadata or "{}")
+ if s then metadata = m end
+ else
+ metadata = account.user.metadata or {}
+ end
+
+ if not metadata.role or metadata.role == "" then
+ metadata.role = "player"
+ pcall(nk.account_update_id, context.user_id, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
+ end
+end
+
+-- Register the Steam auth after-hook
+nk.register_req_after(after_authenticate_steam, "AuthenticateSteam")
+
+-- Create default native leaderboard on startup
+pcall(nk.leaderboard_create, "global_high_score", true, "desc", "best", nil, {})
+
+nk.logger_info("LUA TEST: core module loaded successfully")
diff --git a/server/nakama/lua/daily_rewards.lua b/server/nakama/lua/daily_rewards.lua
new file mode 100644
index 0000000..a688bf3
--- /dev/null
+++ b/server/nakama/lua/daily_rewards.lua
@@ -0,0 +1,205 @@
+local nk = require("nakama")
+local utils = require("lua.utils")
+
+local daily_rewards = {}
+
+function daily_rewards.rpc_claim_daily_reward(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ local now = os.date("!*t")
+ local currentMonth = string.format("%02d", now.month)
+ local todayStr = string.format("%04d-%02d-%02d", now.year, now.month, now.day)
+ local todayIndex = now.day - 1 -- 0 to 30
+
+ local stateObjs = nk.storage_read({{ collection = "daily_rewards", key = "state", user_id = context.user_id }})
+ local state = { claimed_days = {}, last_claim_date = "", month = "" }
+
+ if stateObjs and #stateObjs > 0 then
+ local val = stateObjs[1].value
+ state.last_claim_date = val.last_claim_date or ""
+ state.month = val.month or ""
+
+ if type(val.claimed_days) == "number" then
+ for i = 0, val.claimed_days - 1 do
+ table.insert(state.claimed_days, i)
+ end
+ elseif type(val.claimed_days) == "table" then
+ state.claimed_days = val.claimed_days
+ end
+ end
+
+ if state.month ~= currentMonth then
+ state.claimed_days = {}
+ state.month = currentMonth
+ end
+
+ if state.last_claim_date == todayStr then
+ error("Already claimed today")
+ end
+
+ local configObjs = nk.storage_read({{ collection = "config", key = "daily_rewards", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local config = {}
+ if configObjs and #configObjs > 0 then
+ config = configObjs[1].value
+ end
+
+ local monthRewards = config[currentMonth]
+ if not monthRewards or #monthRewards == 0 then
+ monthRewards = {}
+ for i = 0, 30 do
+ table.insert(monthRewards, { type = "star", amount = math.min(10 + i * 5, 100) })
+ end
+ end
+
+ local dayIndex = todayIndex
+ -- In lua array size is #monthRewards
+ if dayIndex >= #monthRewards then
+ error("Already claimed all rewards for this month")
+ end
+
+ local hasClaimed = false
+ for _, claimed_day in ipairs(state.claimed_days) do
+ if claimed_day == dayIndex then
+ hasClaimed = true
+ break
+ end
+ end
+
+ if hasClaimed then
+ error("Already claimed today's reward")
+ end
+
+ -- Lua arrays are 1-indexed!
+ local rewardData = monthRewards[dayIndex + 1]
+ if type(rewardData) == "number" then
+ rewardData = { type = "star", amount = rewardData }
+ end
+
+ local rewardType = rewardData.type or "star"
+ local rewardAmount = rewardData.amount or 0
+
+ if rewardType == "star" or rewardType == "gold" then
+ local changes = {}
+ changes[rewardType] = rewardAmount
+ nk.wallet_update(context.user_id, changes, {}, true)
+ elseif string.sub(rewardType, 1, 5) == "frag_" then
+ local invObjs = nk.storage_read({{ collection = "inventory", key = "fragments", user_id = context.user_id }})
+ local frags = {}
+ if invObjs and #invObjs > 0 then
+ frags = invObjs[1].value
+ end
+ frags[rewardType] = (frags[rewardType] or 0) + rewardAmount
+ nk.storage_write({{
+ collection = "inventory",
+ key = "fragments",
+ user_id = context.user_id,
+ value = frags,
+ permission_read = 1,
+ permission_write = 0
+ }})
+ end
+
+ table.insert(state.claimed_days, dayIndex)
+ state.last_claim_date = todayStr
+
+ nk.storage_write({{
+ collection = "daily_rewards",
+ key = "state",
+ user_id = context.user_id,
+ value = state,
+ permission_read = 1,
+ permission_write = 0
+ }})
+
+ return nk.json_encode({ success = true, reward_type = rewardType, reward_amount = rewardAmount, day = dayIndex + 1 })
+end
+
+function daily_rewards.rpc_get_daily_reward_state(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ local now = os.date("!*t")
+ local currentMonth = string.format("%02d", now.month)
+ local todayStr = string.format("%04d-%02d-%02d", now.year, now.month, now.day)
+ local todayIndex = now.day - 1
+
+ local stateObjs = nk.storage_read({{ collection = "daily_rewards", key = "state", user_id = context.user_id }})
+ local state = { claimed_days = {}, last_claim_date = "", month = "" }
+ if stateObjs and #stateObjs > 0 then
+ local val = stateObjs[1].value
+ state.last_claim_date = val.last_claim_date or ""
+ state.month = val.month or ""
+ if type(val.claimed_days) == "number" then
+ for i = 0, val.claimed_days - 1 do
+ table.insert(state.claimed_days, i)
+ end
+ elseif type(val.claimed_days) == "table" then
+ state.claimed_days = val.claimed_days
+ end
+ end
+ if state.month ~= currentMonth then
+ state.claimed_days = {}
+ state.month = currentMonth
+ end
+
+ local configObjs = nk.storage_read({{ collection = "config", key = "daily_rewards", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local config = {}
+ if configObjs and #configObjs > 0 then
+ config = configObjs[1].value
+ end
+ local monthRewards = config[currentMonth]
+ if not monthRewards or #monthRewards == 0 then
+ monthRewards = {}
+ for i = 0, 30 do
+ table.insert(monthRewards, { type = "star", amount = math.min(10 + i * 5, 100) })
+ end
+ end
+
+ local hasClaimedToday = false
+ for _, claimed_day in ipairs(state.claimed_days) do
+ if claimed_day == todayIndex then hasClaimedToday = true break end
+ end
+
+ local canClaimToday = (state.last_claim_date ~= todayStr) and (not hasClaimedToday) and (todayIndex < #monthRewards)
+
+ return nk.json_encode({
+ state = state,
+ month_rewards = monthRewards,
+ can_claim_today = canClaimToday,
+ today_date = todayStr,
+ today_index = todayIndex,
+ server_month = now.month
+ })
+end
+
+function daily_rewards.rpc_set_daily_reward_config(context, payload)
+ utils.require_admin(context)
+ local request = nk.json_decode(payload or "{}")
+ nk.storage_write({{
+ collection = "config",
+ key = "daily_rewards",
+ user_id = "00000000-0000-0000-0000-000000000000",
+ value = request.config,
+ permission_read = 2,
+ permission_write = 0
+ }})
+ return nk.json_encode({ success = true })
+end
+
+function daily_rewards.rpc_get_daily_reward_config_admin(context, payload)
+ utils.require_admin(context)
+ local configObjs = nk.storage_read({{ collection = "config", key = "daily_rewards", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local config = {}
+ if configObjs and #configObjs > 0 then
+ config = configObjs[1].value
+ end
+ return nk.json_encode({ config = config })
+end
+
+nk.register_rpc(daily_rewards.rpc_claim_daily_reward, "claim_daily_reward")
+nk.register_rpc(daily_rewards.rpc_get_daily_reward_state, "get_daily_reward_state")
+nk.register_rpc(daily_rewards.rpc_set_daily_reward_config, "set_daily_reward_config")
+nk.register_rpc(daily_rewards.rpc_get_daily_reward_config_admin, "get_daily_reward_config_admin")
+
+nk.logger_info("LUA TEST: daily rewards module loaded")
+
+return daily_rewards
diff --git a/server/nakama/lua/economy.lua b/server/nakama/lua/economy.lua
new file mode 100644
index 0000000..4a8be46
--- /dev/null
+++ b/server/nakama/lua/economy.lua
@@ -0,0 +1,244 @@
+local nk = require("nakama")
+local utils = require("lua.utils")
+
+local economy = {}
+
+local SHOP_CATALOG_DEFS = {
+ { id = "oldpop-blue-hat", name = "Oldpop Blue Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-green-hat", name = "Oldpop Green Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-red-hat", name = "Oldpop Red Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-yellow-hat", name = "Oldpop Yellow Hat", category = "head", gold = 100, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-og-pant", name = "Copper OG Pant", category = "costume", gold = 0, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-grey-pant", name = "Copper Grey Pant", category = "costume", gold = 150, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-red-pant", name = "Copper Red Pant", category = "costume", gold = 150, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-yellow-pant", name = "Copper Yellow Pant", category = "costume", gold = 150, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-blue-gloves", name = "Oldpop Blue Gloves", category = "glove", gold = 75, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-green-gloves", name = "Oldpop Green Gloves", category = "glove", gold = 75, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-red-gloves", name = "Oldpop Red Gloves", category = "glove", gold = 75, star = 0, rarity = "Common", character = "Oldpop" },
+ { id = "oldpop-yellow-gloves", name = "Oldpop Yellow Gloves", category = "glove", gold = 75, star = 0, rarity = "Common", character = "Oldpop" }
+}
+
+local function build_shop_catalog()
+ local catalog = {}
+ for _, def in ipairs(SHOP_CATALOG_DEFS) do
+ local cat = def.category
+ if not catalog[cat] then catalog[cat] = {} end
+ local entry = {
+ id = def.id,
+ name = def.name,
+ gold = def.gold or 0,
+ star = def.star or 0,
+ rarity = def.rarity or "Common",
+ character = def.character
+ }
+ table.insert(catalog[cat], entry)
+ end
+ return catalog
+end
+
+function economy.rpc_get_shop_catalog(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ local result = { catalog = build_shop_catalog(), featured_banners = {} }
+
+ local status, objs = pcall(nk.storage_read, {{ collection = "shop_config", key = "featured_banners", user_id = "00000000-0000-0000-0000-000000000000" }})
+ if status and objs and #objs > 0 then
+ local val = objs[1].value
+ if val.banners then result.featured_banners = val.banners end
+ end
+
+ return nk.json_encode(result)
+end
+
+function economy.rpc_buy_currency(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ local request = nk.json_decode(payload)
+ local packageId = request.package_id
+ local receipt = request.receipt
+ local idempotencyKey = request.idempotency_key
+
+ if not packageId or packageId == "" then error("Package ID required") end
+ if not idempotencyKey or idempotencyKey == "" then error("Idempotency key required") end
+
+ local status, existing = pcall(nk.storage_read, {{ collection = "receipts", key = idempotencyKey, user_id = context.user_id }})
+ if status and existing and #existing > 0 then
+ return nk.json_encode({ success = true, package_id = packageId, duplicate = true, status = existing[1].value.status })
+ end
+
+ local changeset = { gold = 0, star = 0 }
+ local requiresVerification = false
+
+ if packageId == "gold_100" then changeset.gold = 100; requiresVerification = true
+ elseif packageId == "gold_500" then changeset.gold = 550; requiresVerification = true
+ elseif packageId == "gold_1000" then changeset.gold = 1150; requiresVerification = true
+ elseif packageId == "gold_2000" then changeset.gold = 2400; requiresVerification = true
+ elseif packageId == "gold_5000" then changeset.gold = 6250; requiresVerification = true
+ elseif packageId == "gold_10000" then changeset.gold = 13000; requiresVerification = true
+ elseif packageId == "star_100" then changeset.star = 100; changeset.gold = -500
+ elseif packageId == "star_250" then changeset.star = 250; changeset.gold = -1100
+ elseif packageId == "star_600" then changeset.star = 600; changeset.gold = -2500
+ else error("Invalid package ID") end
+
+ if requiresVerification and not receipt then
+ nk.storage_write({{
+ collection = "receipts",
+ key = idempotencyKey,
+ user_id = context.user_id,
+ value = { type = "currency", package_id = packageId, status = "pending", created_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z") },
+ permission_read = 1,
+ permission_write = 0
+ }})
+ return nk.json_encode({ success = true, status = "pending", package_id = packageId })
+ end
+
+ local s, err = pcall(function()
+ if changeset.gold ~= 0 or changeset.star ~= 0 then
+ nk.wallet_update(context.user_id, changeset, {}, true)
+ end
+ nk.storage_write({{
+ collection = "receipts",
+ key = idempotencyKey,
+ user_id = context.user_id,
+ value = { type = "currency", package_id = packageId, changeset = changeset, receipt = receipt or nk.json_null(), status = "verified", processed_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z") },
+ permission_read = 1,
+ permission_write = 0
+ }})
+ end)
+
+ if not s then
+ nk.logger_error("Currency purchase failed: " .. tostring(err))
+ error("NotEnoughFunds")
+ end
+
+ nk.logger_info("User " .. context.user_id .. " bought currency package " .. packageId)
+ return nk.json_encode({ success = true, status = "verified", package_id = packageId })
+end
+
+function economy.rpc_purchase_item(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ local request = nk.json_decode(payload)
+ local itemId = request.item_id
+ local quantity = request.quantity or 1
+ local idempotencyKey = request.idempotency_key
+
+ if not itemId or itemId == "" then error("Item ID required") end
+ if quantity < 1 then error("Invalid quantity") end
+ if not idempotencyKey or idempotencyKey == "" then error("Idempotency key required") end
+
+ local status, existing = pcall(nk.storage_read, {{ collection = "receipts", key = idempotencyKey, user_id = context.user_id }})
+ if status and existing and #existing > 0 then
+ return nk.json_encode({ success = true, item = itemId, duplicate = true })
+ end
+
+ local itemDef = nil
+ for _, def in ipairs(SHOP_CATALOG_DEFS) do
+ if def.id == itemId then
+ itemDef = def
+ break
+ end
+ end
+
+ if not itemDef then error("ItemNotFound") end
+
+ local priceGold = (itemDef.gold or 0) * quantity
+ local priceStar = (itemDef.star or 0) * quantity
+ local category = itemDef.category or "accessory"
+
+ local s, err = pcall(function()
+ local changeset = {}
+ if priceGold > 0 then changeset.gold = -priceGold end
+ if priceStar > 0 then changeset.star = -priceStar end
+
+ if priceGold > 0 or priceStar > 0 then
+ nk.wallet_update(context.user_id, changeset, {}, true)
+ end
+ end)
+ if not s then
+ nk.logger_error("Wallet update failed: " .. tostring(err))
+ error("NotEnoughFunds")
+ end
+
+ local s2, err2 = pcall(function()
+ local writes = {
+ {
+ collection = "inventory",
+ key = itemId,
+ user_id = context.user_id,
+ value = { category = category, purchased_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z"), quantity = quantity },
+ permission_read = 1,
+ permission_write = 0
+ },
+ {
+ collection = "receipts",
+ key = idempotencyKey,
+ user_id = context.user_id,
+ value = { type = "item", item_id = itemId, quantity = quantity, cost = { gold = priceGold, star = priceStar }, processed_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z") },
+ permission_read = 1,
+ permission_write = 0
+ }
+ }
+ nk.storage_write(writes)
+ end)
+ if not s2 then
+ nk.logger_error("Purchase failed: " .. tostring(err2))
+ error("PurchaseFailed")
+ end
+
+ nk.logger_info("User " .. context.user_id .. " purchased " .. itemId)
+ return nk.json_encode({ success = true, item = itemId })
+end
+
+function economy.rpc_admin_set_featured_banners(context, payload)
+ utils.require_admin(context)
+ local req = nk.json_decode(payload or "{}")
+ local banners = req.banners or {}
+
+ local finalBanners = {}
+ for i = 1, math.min(#banners, 3) do
+ table.insert(finalBanners, banners[i])
+ end
+
+ for _, b in ipairs(finalBanners) do
+ local itemId = b.item_id or ""
+ if itemId ~= "" then
+ local found = false
+ for _, def in ipairs(SHOP_CATALOG_DEFS) do
+ if def.id == itemId then found = true; break end
+ end
+ if not found then error("Item not found in catalog: " .. itemId) end
+ end
+ end
+
+ nk.storage_write({{
+ collection = "shop_config",
+ key = "featured_banners",
+ user_id = "00000000-0000-0000-0000-000000000000",
+ value = { banners = finalBanners },
+ permission_read = 2,
+ permission_write = 0
+ }})
+
+ nk.logger_info("Featured banners updated by admin " .. context.user_id)
+ return nk.json_encode({ success = true, banners = finalBanners })
+end
+
+function economy.rpc_admin_get_featured_banners(context, payload)
+ utils.require_admin(context)
+ local status, objs = pcall(nk.storage_read, {{ collection = "shop_config", key = "featured_banners", user_id = "00000000-0000-0000-0000-000000000000" }})
+ if status and objs and #objs > 0 then
+ return nk.json_encode({ banners = objs[1].value.banners or {} })
+ end
+ return nk.json_encode({ banners = {} })
+end
+
+nk.register_rpc(economy.rpc_get_shop_catalog, "get_shop_catalog")
+nk.register_rpc(economy.rpc_buy_currency, "buy_currency")
+nk.register_rpc(economy.rpc_purchase_item, "purchase_item")
+nk.register_rpc(economy.rpc_admin_set_featured_banners, "admin_set_featured_banners")
+nk.register_rpc(economy.rpc_admin_get_featured_banners, "admin_get_featured_banners")
+
+nk.logger_info("LUA TEST: economy module loaded successfully")
+
+return economy
diff --git a/server/nakama/lua/inbox.lua b/server/nakama/lua/inbox.lua
new file mode 100644
index 0000000..41663d5
--- /dev/null
+++ b/server/nakama/lua/inbox.lua
@@ -0,0 +1,535 @@
+local nk = require("nakama")
+local utils = require("lua.utils")
+
+local inbox = {}
+
+function inbox.rpc_admin_send_mail(context, payload)
+ utils.require_admin(context)
+ local request = nk.json_decode(payload or "{}")
+
+ local nowStr = os.date("!%Y-%m-%dT%H:%M:%S.000Z") -- approximate ISO8601
+ local startDate = request.start_date or nowStr
+ local endDate = request.end_date or ""
+
+ -- 30 days from now in seconds for expiry_date fallback if not specified
+ local expiryDate = os.date("!%Y-%m-%dT%H:%M:%S.000Z", os.time() + 30 * 24 * 60 * 60)
+
+ local mailObj = {
+ id = nk.uuid_v4(),
+ title = request.title or "Announcement",
+ content = request.content or "",
+ sender = "TEKTON DEV TEAM",
+ date = startDate,
+ start_date = startDate,
+ end_date = endDate,
+ expiry_date = expiryDate,
+ rewards = request.rewards or {}
+ }
+
+ if request.target_user_id and request.target_user_id ~= "" then
+ mailObj.type = "personal"
+ local invObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = request.target_user_id }})
+ local personalMails = {}
+ if #invObjs > 0 then
+ personalMails = invObjs[1].value.mails or {}
+ end
+ table.insert(personalMails, mailObj)
+
+ nk.storage_write({{
+ collection = "inbox",
+ key = "personal",
+ user_id = request.target_user_id,
+ value = { mails = personalMails },
+ permission_read = 1,
+ permission_write = 0
+ }})
+ nk.logger_info("Personal mail sent to " .. request.target_user_id)
+ else
+ mailObj.type = "global"
+ local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local globalMails = {}
+ if #globalObjs > 0 then
+ globalMails = globalObjs[1].value.mails or {}
+ end
+ table.insert(globalMails, mailObj)
+
+ nk.storage_write({{
+ collection = "config",
+ key = "global_mail",
+ user_id = "00000000-0000-0000-0000-000000000000",
+ value = { mails = globalMails },
+ permission_read = 2,
+ permission_write = 0
+ }})
+ nk.logger_info("Global mail sent")
+ end
+
+ return nk.json_encode({ success = true, mail = mailObj })
+end
+
+function inbox.rpc_get_mail(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ local personalObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = context.user_id }})
+ local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local stateObjs = nk.storage_read({{ collection = "inbox", key = "state", user_id = context.user_id }})
+
+ local personalMails = (#personalObjs > 0) and (personalObjs[1].value.mails or {}) or {}
+ local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
+
+ local state = { claimed_ids = {}, deleted_ids = {}, read_ids = {} }
+ if #stateObjs > 0 then
+ local val = stateObjs[1].value
+ state.claimed_ids = val.claimed_ids or {}
+ state.deleted_ids = val.deleted_ids or {}
+ state.read_ids = val.read_ids or {}
+ end
+
+ local function array_contains(arr, val)
+ for _, v in ipairs(arr) do
+ if v == val then return true end
+ end
+ return false
+ end
+
+ local allMails = {}
+ for _, m in ipairs(personalMails) do table.insert(allMails, m) end
+ for _, m in ipairs(globalMails) do table.insert(allMails, m) end
+
+ local filteredMails = {}
+ local nowStr = os.date("!%Y-%m-%dT%H:%M:%S.000Z")
+
+ for _, mail in ipairs(allMails) do
+ if not array_contains(state.deleted_ids, mail.id) then
+ local skip = false
+ if mail.expiry_date and mail.expiry_date ~= "" and nowStr > mail.expiry_date then
+ skip = true
+ end
+ if not skip and mail.start_date and mail.start_date ~= "" and nowStr < mail.start_date then
+ skip = true
+ end
+ if not skip and mail.type == "global" and mail.end_date and mail.end_date ~= "" and nowStr > mail.end_date then
+ skip = true
+ end
+
+ if not skip then
+ table.insert(filteredMails, mail)
+ end
+ end
+ end
+
+ return nk.json_encode({ mails = filteredMails, state = state })
+end
+
+function inbox.rpc_claim_mail_reward(context, payload)
+ if not context.user_id then error("Not authenticated") end
+ local request = nk.json_decode(payload or "{}")
+ local mailId = request.mail_id
+ if not mailId then error("mail_id required") end
+
+ local personalObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = context.user_id }})
+ local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local stateObjs = nk.storage_read({{ collection = "inbox", key = "state", user_id = context.user_id }})
+
+ local state = { claimed_ids = {}, deleted_ids = {}, read_ids = {} }
+ if #stateObjs > 0 then
+ local val = stateObjs[1].value
+ state.claimed_ids = val.claimed_ids or {}
+ state.deleted_ids = val.deleted_ids or {}
+ state.read_ids = val.read_ids or {}
+ end
+
+ local function array_contains(arr, val)
+ for _, v in ipairs(arr) do
+ if v == val then return true end
+ end
+ return false
+ end
+
+ if array_contains(state.claimed_ids, mailId) then
+ error("Reward already claimed")
+ end
+
+ local personalMails = (#personalObjs > 0) and (personalObjs[1].value.mails or {}) or {}
+ local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
+ local allMails = {}
+ for _, m in ipairs(personalMails) do table.insert(allMails, m) end
+ for _, m in ipairs(globalMails) do table.insert(allMails, m) end
+
+ local targetMail = nil
+ for _, mail in ipairs(allMails) do
+ if mail.id == mailId then
+ targetMail = mail
+ break
+ end
+ end
+
+ if not targetMail then error("Mail not found") end
+
+ local rewards = targetMail.rewards or {}
+ local starTotal = 0
+ local goldTotal = 0
+ local fragsToUpdate = {}
+ local skinsToAdd = {}
+
+ if type(rewards) == "table" and not rewards[1] and (rewards.star or rewards.gold) then
+ -- Handle legacy dictionary format
+ starTotal = rewards.star or 0
+ goldTotal = rewards.gold or 0
+ rewards = {}
+ end
+
+ for _, r in ipairs(rewards) do
+ local rType = r.type or "star"
+ local amount = r.amount or 0
+
+ if rType == "star" then
+ starTotal = starTotal + amount
+ elseif rType == "gold" then
+ goldTotal = goldTotal + amount
+ elseif string.sub(rType, 1, 5) == "frag_" or rType == "item" then
+ local fragId = r.id or rType
+ fragsToUpdate[fragId] = (fragsToUpdate[fragId] or 0) + amount
+ elseif rType == "skin" then
+ if r.id then table.insert(skinsToAdd, r.id) end
+ end
+ end
+
+ if starTotal > 0 or goldTotal > 0 then
+ local changes = {}
+ if starTotal > 0 then changes.star = starTotal end
+ if goldTotal > 0 then changes.gold = goldTotal end
+ nk.wallet_update(context.user_id, changes, {}, true)
+ end
+
+ local fragKeysCount = 0
+ for _ in pairs(fragsToUpdate) do fragKeysCount = fragKeysCount + 1 end
+
+ if fragKeysCount > 0 then
+ local invObjs = nk.storage_read({{ collection = "inventory", key = "fragments", user_id = context.user_id }})
+ local frags = (#invObjs > 0) and invObjs[1].value or {}
+ for fId, count in pairs(fragsToUpdate) do
+ frags[fId] = (frags[fId] or 0) + count
+ end
+ nk.storage_write({{
+ collection = "inventory",
+ key = "fragments",
+ user_id = context.user_id,
+ value = frags,
+ permission_read = 1,
+ permission_write = 0
+ }})
+ end
+
+ if #skinsToAdd > 0 then
+ local skinWrites = {}
+ for _, sId in ipairs(skinsToAdd) do
+ table.insert(skinWrites, {
+ collection = "inventory",
+ key = sId,
+ user_id = context.user_id,
+ value = { acquired_via = "mail", purchased_at = os.date("!%Y-%m-%dT%H:%M:%S.000Z") },
+ permission_read = 1,
+ permission_write = 0
+ })
+ end
+ nk.storage_write(skinWrites)
+ end
+
+ table.insert(state.claimed_ids, mailId)
+ if not array_contains(state.read_ids, mailId) then
+ table.insert(state.read_ids, mailId)
+ end
+
+ nk.storage_write({{
+ collection = "inbox",
+ key = "state",
+ user_id = context.user_id,
+ value = state,
+ permission_read = 1,
+ permission_write = 0
+ }})
+
+ return nk.json_encode({ success = true, claimed_ids = state.claimed_ids })
+end
+
+function inbox.rpc_delete_mail(context, payload)
+ if not context.user_id then error("Not authenticated") end
+ local request = nk.json_decode(payload or "{}")
+ local mailId = request.mail_id
+ if not mailId then error("mail_id required") end
+
+ local stateObjs = nk.storage_read({{ collection = "inbox", key = "state", user_id = context.user_id }})
+ local state = { claimed_ids = {}, deleted_ids = {}, read_ids = {} }
+ if #stateObjs > 0 then
+ local val = stateObjs[1].value
+ state.claimed_ids = val.claimed_ids or {}
+ state.deleted_ids = val.deleted_ids or {}
+ state.read_ids = val.read_ids or {}
+ end
+
+ local function array_contains(arr, val)
+ for _, v in ipairs(arr) do if v == val then return true end end
+ return false
+ end
+
+ if not array_contains(state.deleted_ids, mailId) then table.insert(state.deleted_ids, mailId) end
+ if not array_contains(state.read_ids, mailId) then table.insert(state.read_ids, mailId) end
+
+ nk.storage_write({{
+ collection = "inbox",
+ key = "state",
+ user_id = context.user_id,
+ value = state,
+ permission_read = 1,
+ permission_write = 0
+ }})
+
+ return nk.json_encode({ success = true, deleted_ids = state.deleted_ids })
+end
+
+function inbox.rpc_save_mail_state(context, payload)
+ if not context.user_id then error("Not authenticated") end
+ local request = nk.json_decode(payload or "{}")
+
+ local stateObjs = nk.storage_read({{ collection = "inbox", key = "state", user_id = context.user_id }})
+ local state = { claimed_ids = {}, deleted_ids = {}, read_ids = {} }
+ if #stateObjs > 0 then
+ local val = stateObjs[1].value
+ state.claimed_ids = val.claimed_ids or {}
+ state.deleted_ids = val.deleted_ids or {}
+ state.read_ids = val.read_ids or {}
+ end
+
+ local function array_contains(arr, val)
+ for _, v in ipairs(arr) do if v == val then return true end end
+ return false
+ end
+
+ local newReadIds = request.read_ids or {}
+ for _, rid in ipairs(newReadIds) do
+ if not array_contains(state.read_ids, rid) then
+ table.insert(state.read_ids, rid)
+ end
+ end
+
+ nk.storage_write({{
+ collection = "inbox",
+ key = "state",
+ user_id = context.user_id,
+ value = state,
+ permission_read = 1,
+ permission_write = 0
+ }})
+
+ return nk.json_encode({ success = true })
+end
+
+function inbox.rpc_admin_list_mail(context, payload)
+ utils.require_admin(context)
+
+ local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
+ for _, m in ipairs(globalMails) do m.type = "global" end
+
+ local personalMails = {}
+ local cursor = nil
+
+ repeat
+ local status, listResult = pcall(nk.storage_list, "", "inbox", 100, cursor)
+ if status and listResult then
+ local objects = listResult.objects or {}
+ for _, obj in ipairs(objects) do
+ if obj.key == "personal" then
+ local ownerUserId = obj.user_id
+ local mails = obj.value.mails or {}
+ for _, m in ipairs(mails) do
+ m.type = "personal"
+ m.target_user_id = ownerUserId
+ table.insert(personalMails, m)
+ end
+ end
+ end
+ cursor = listResult.cursor
+ else
+ cursor = nil
+ end
+ until not cursor or cursor == ""
+
+ local allMails = {}
+ for _, m in ipairs(globalMails) do table.insert(allMails, m) end
+ for _, m in ipairs(personalMails) do table.insert(allMails, m) end
+
+ table.sort(allMails, function(a, b)
+ local d1 = a.date or ""
+ local d2 = b.date or ""
+ return d1 > d2
+ end)
+
+ return nk.json_encode({ mails = allMails })
+end
+
+function inbox.rpc_admin_update_mail(context, payload)
+ utils.require_admin(context)
+ local request = nk.json_decode(payload or "{}")
+ local mailId = request.mail_id
+ if not mailId then error("mail_id required") end
+
+ local isGlobal = request.type ~= "personal"
+ local targetUserId = request.target_user_id or ""
+ local newTargetUserId = request.new_target_user_id
+ local hasNewTarget = (newTargetUserId ~= nil)
+
+ local mailObj = nil
+
+ if isGlobal then
+ local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
+
+ for i, m in ipairs(globalMails) do
+ if m.id == mailId then
+ mailObj = table.remove(globalMails, i)
+ break
+ end
+ end
+ if not mailObj then error("Mail not found in global") end
+
+ nk.storage_write({{
+ collection = "config",
+ key = "global_mail",
+ user_id = "00000000-0000-0000-0000-000000000000",
+ value = { mails = globalMails },
+ permission_read = 2,
+ permission_write = 0
+ }})
+ else
+ if targetUserId == "" then error("target_user_id required for personal mail") end
+ local pObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = targetUserId }})
+ local personalMails = (#pObjs > 0) and (pObjs[1].value.mails or {}) or {}
+
+ for i, m in ipairs(personalMails) do
+ if m.id == mailId then
+ mailObj = table.remove(personalMails, i)
+ break
+ end
+ end
+ if not mailObj then error("Mail not found in personal inbox") end
+
+ nk.storage_write({{
+ collection = "inbox",
+ key = "personal",
+ user_id = targetUserId,
+ value = { mails = personalMails },
+ permission_read = 1,
+ permission_write = 0
+ }})
+ end
+
+ if request.title ~= nil then mailObj.title = request.title end
+ if request.content ~= nil then mailObj.content = request.content end
+ if request.end_date ~= nil then mailObj.end_date = request.end_date end
+ if request.expiry_date ~= nil then mailObj.expiry_date = request.expiry_date end
+
+ local destUserId = ""
+ if hasNewTarget then destUserId = newTargetUserId else
+ if not isGlobal then destUserId = targetUserId end
+ end
+
+ if destUserId == "" then
+ mailObj.type = "global"
+ local gObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local gMails = (#gObjs > 0) and (gObjs[1].value.mails or {}) or {}
+ table.insert(gMails, mailObj)
+
+ nk.storage_write({{
+ collection = "config",
+ key = "global_mail",
+ user_id = "00000000-0000-0000-0000-000000000000",
+ value = { mails = gMails },
+ permission_read = 2,
+ permission_write = 0
+ }})
+ else
+ mailObj.type = "personal"
+ local dObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = destUserId }})
+ local dMails = (#dObjs > 0) and (dObjs[1].value.mails or {}) or {}
+ table.insert(dMails, mailObj)
+
+ nk.storage_write({{
+ collection = "inbox",
+ key = "personal",
+ user_id = destUserId,
+ value = { mails = dMails },
+ permission_read = 1,
+ permission_write = 0
+ }})
+ end
+
+ nk.logger_info("Admin updated mail " .. mailId .. " by " .. context.user_id)
+ return nk.json_encode({ success = true })
+end
+
+function inbox.rpc_admin_delete_mail_server(context, payload)
+ utils.require_admin(context)
+ local request = nk.json_decode(payload or "{}")
+ local mailId = request.mail_id
+ if not mailId then error("mail_id required") end
+
+ local isGlobal = request.type ~= "personal"
+ local targetUserId = request.target_user_id or ""
+
+ if isGlobal then
+ local globalObjs = nk.storage_read({{ collection = "config", key = "global_mail", user_id = "00000000-0000-0000-0000-000000000000" }})
+ local globalMails = (#globalObjs > 0) and (globalObjs[1].value.mails or {}) or {}
+ local before = #globalMails
+ local filtered = {}
+ for _, m in ipairs(globalMails) do
+ if m.id ~= mailId then table.insert(filtered, m) end
+ end
+ if #filtered == before then error("Mail not found") end
+
+ nk.storage_write({{
+ collection = "config",
+ key = "global_mail",
+ user_id = "00000000-0000-0000-0000-000000000000",
+ value = { mails = filtered },
+ permission_read = 2,
+ permission_write = 0
+ }})
+ else
+ if targetUserId == "" then error("target_user_id required for personal mail") end
+ local pObjs = nk.storage_read({{ collection = "inbox", key = "personal", user_id = targetUserId }})
+ local personalMails = (#pObjs > 0) and (pObjs[1].value.mails or {}) or {}
+ local before = #personalMails
+ local filtered = {}
+ for _, m in ipairs(personalMails) do
+ if m.id ~= mailId then table.insert(filtered, m) end
+ end
+ if #filtered == before then error("Mail not found") end
+
+ nk.storage_write({{
+ collection = "inbox",
+ key = "personal",
+ user_id = targetUserId,
+ value = { mails = filtered },
+ permission_read = 1,
+ permission_write = 0
+ }})
+ end
+
+ nk.logger_info("Admin deleted mail " .. mailId .. " from server by " .. context.user_id)
+ return nk.json_encode({ success = true })
+end
+
+nk.register_rpc(inbox.rpc_admin_send_mail, "admin_send_mail")
+nk.register_rpc(inbox.rpc_get_mail, "get_mail")
+nk.register_rpc(inbox.rpc_claim_mail_reward, "claim_mail_reward")
+nk.register_rpc(inbox.rpc_delete_mail, "delete_mail")
+nk.register_rpc(inbox.rpc_save_mail_state, "save_mail_state")
+nk.register_rpc(inbox.rpc_admin_list_mail, "admin_list_mail")
+nk.register_rpc(inbox.rpc_admin_update_mail, "admin_update_mail")
+nk.register_rpc(inbox.rpc_admin_delete_mail_server, "admin_delete_mail_server")
+
+nk.logger_info("LUA TEST: inbox module loaded")
+
+return inbox
diff --git a/server/nakama/lua/leaderboard.lua b/server/nakama/lua/leaderboard.lua
new file mode 100644
index 0000000..8fb5d6e
--- /dev/null
+++ b/server/nakama/lua/leaderboard.lua
@@ -0,0 +1,249 @@
+local nk = require("nakama")
+local utils = require("lua.utils")
+
+local leaderboard = {}
+
+function leaderboard.rpc_get_leaderboard_stats(context, payload)
+ local status, records_or_err = pcall(nk.leaderboard_records_list, "global_high_score", nil, 50, nil)
+
+ if not status then
+ nk.logger_error("Failed to get native leaderboard stats: " .. tostring(records_or_err))
+ return nk.json_encode({ leaderboard = {} })
+ end
+
+ local leaderboardData = {}
+ local ownerRecords = records_or_err.records or {}
+
+ for _, record in ipairs(ownerRecords) do
+ local metadata = {}
+ if record.metadata then
+ local s, m = pcall(nk.json_decode, record.metadata)
+ if s then metadata = m end
+ end
+
+ table.insert(leaderboardData, {
+ user_id = record.owner_id,
+ username = record.username,
+ display_name = record.username, -- Native lua leaderboard returns owner_id, username, score, subscore, num_score, max_num_score, metadata, create_time, update_time
+ avatar_url = metadata.avatar_url or "",
+ loadout_character = metadata.loadout_character or "Copper",
+ high_score = record.score or 0,
+ games_played = metadata.games_played or 0,
+ games_won = metadata.games_won or 0
+ })
+ end
+
+ return nk.json_encode({ leaderboard = leaderboardData })
+end
+
+function leaderboard.rpc_submit_score(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ local request = nk.json_decode(payload or "{}")
+ local score = tonumber(request.score) or 0
+ local account = nk.account_get_id(context.user_id)
+
+ local metadata = {
+ games_played = request.games_played or 0,
+ games_won = request.games_won or 0,
+ avatar_url = request.avatar_url or account.user.avatar_url or "",
+ loadout_character = request.loadout_character or "Copper"
+ }
+
+ local status, err = pcall(nk.leaderboard_record_write,
+ "global_high_score",
+ context.user_id,
+ account.user.username,
+ score,
+ 0,
+ metadata
+ )
+
+ if not status then
+ nk.logger_error("Failed to submit score for " .. context.user_id .. ": " .. tostring(err))
+ error("Failed to submit score")
+ end
+
+ nk.logger_info("Score submitted for user " .. context.user_id .. ": " .. score)
+ return nk.json_encode({ success = true })
+end
+
+function leaderboard.rpc_sync_leaderboard(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ local status, result = pcall(nk.storage_list, nil, "stats", 100, "")
+ if not status then error("Sync failed: " .. tostring(result)) end
+
+ local statsObjects = result.objects or {}
+ local userGroup = {}
+
+ for _, obj in ipairs(statsObjects) do
+ local userId = obj.user_id
+ local value = obj.value
+
+ if not userGroup[userId] then
+ userGroup[userId] = {
+ high_score = value.high_score or 0,
+ games_played = value.games_played or 0,
+ games_won = value.games_won or 0,
+ avatar_url = value.avatar_url or "",
+ loadout_character = value.loadout_character or ""
+ }
+ else
+ userGroup[userId].high_score = math.max(userGroup[userId].high_score, value.high_score or 0)
+ userGroup[userId].games_played = math.max(userGroup[userId].games_played, value.games_played or 0)
+ userGroup[userId].games_won = math.max(userGroup[userId].games_won, value.games_won or 0)
+ end
+
+ if obj.key == "game_stats" or userGroup[userId].avatar_url == "" then
+ if value.avatar_url then userGroup[userId].avatar_url = value.avatar_url end
+ if value.loadout_character then userGroup[userId].loadout_character = value.loadout_character end
+ end
+ end
+
+ local statusProf, profileResult = pcall(nk.storage_list, nil, "profiles", 100, "")
+ if statusProf and profileResult and profileResult.objects then
+ for _, obj in ipairs(profileResult.objects) do
+ if obj.key == "profile" then
+ local userId = obj.user_id
+ local value = obj.value
+
+ if not userGroup[userId] then
+ userGroup[userId] = { high_score = 0, games_played = 0, games_won = 0, avatar_url = "", loadout_character = "" }
+ end
+
+ if value.avatar_url and userGroup[userId].avatar_url == "" then
+ userGroup[userId].avatar_url = value.avatar_url
+ end
+ if value.loadout_character and userGroup[userId].loadout_character == "" then
+ userGroup[userId].loadout_character = value.loadout_character
+ end
+ end
+ end
+ end
+
+ local count = 0
+ local debugLogs = {}
+
+ for uid, stats in pairs(userGroup) do
+ local s, err = pcall(function()
+ local account = nk.account_get_id(uid)
+ local avatar = stats.avatar_url
+ if not avatar or avatar == "" then
+ avatar = account.user.avatar_url
+ end
+ if not avatar or avatar == "" then
+ avatar = "res://assets/graphics/character_selection/sc_characters/sc_copper.png"
+ end
+
+ local meta = {
+ games_played = stats.games_played or 0,
+ games_won = stats.games_won or 0,
+ avatar_url = avatar,
+ loadout_character = stats.loadout_character or "Copper"
+ }
+ nk.leaderboard_record_write("global_high_score", uid, account.user.username, stats.high_score, 0, meta)
+ count = count + 1
+ end)
+ if not s then
+ table.insert(debugLogs, "Error user " .. uid .. ": " .. tostring(err))
+ nk.logger_error("Failed to sync record for " .. uid .. ": " .. tostring(err))
+ end
+ end
+
+ nk.logger_info("Synced " .. count .. " records to leaderboard by user " .. context.user_id)
+ return nk.json_encode({ success = true, synced = count, objects_found = #statsObjects, debug = debugLogs })
+end
+
+function leaderboard.rpc_reset_stats(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ pcall(nk.leaderboard_record_delete, "global_high_score", context.user_id)
+
+ local zeros = { games_played = 0, games_won = 0, high_score = 0, total_kills = 0, total_deaths = 0 }
+ nk.storage_write({{
+ collection = "stats",
+ key = "game_stats",
+ user_id = context.user_id,
+ value = zeros,
+ permission_read = 2,
+ permission_write = 1
+ }})
+
+ return nk.json_encode({ success = true })
+end
+
+function leaderboard.rpc_admin_update_stats(context, payload)
+ utils.require_admin(context)
+
+ local request = nk.json_decode(payload)
+ local targetUserId = request.user_id
+ local stats = request.stats
+
+ if not targetUserId or not stats then
+ error("User ID and stats are required")
+ end
+
+ nk.storage_write({{
+ collection = "stats",
+ key = "game_stats",
+ user_id = targetUserId,
+ value = stats,
+ permission_read = 1,
+ permission_write = 0
+ }})
+
+ local account = nk.account_get_id(targetUserId)
+ local score = stats.high_score or 0
+ local metadata = {
+ games_played = stats.games_played or 0,
+ games_won = stats.games_won or 0,
+ avatar_url = account.user.avatar_url or "",
+ loadout_character = stats.loadout_character or "Copper"
+ }
+
+ nk.leaderboard_record_write("global_high_score", targetUserId, account.user.username, score, 0, metadata)
+
+ nk.logger_info("Stats updated for user " .. targetUserId .. " by admin " .. context.user_id)
+ return nk.json_encode({ success = true })
+end
+
+function leaderboard.rpc_admin_delete_stats(context, payload)
+ utils.require_admin(context)
+
+ local request = nk.json_decode(payload)
+ local targetUserId = request.user_id
+
+ if not targetUserId then error("User ID is required") end
+
+ nk.storage_delete({
+ { collection = "stats", key = "stats", user_id = targetUserId },
+ { collection = "stats", key = "game_stats", user_id = targetUserId }
+ })
+
+ pcall(nk.leaderboard_record_delete, "global_high_score", targetUserId)
+
+ nk.logger_info("Stats deleted for user " .. targetUserId .. " by admin " .. context.user_id)
+ return nk.json_encode({ success = true })
+end
+
+function leaderboard.rpc_admin_sync_leaderboard(context, payload)
+ utils.require_admin(context)
+ return leaderboard.rpc_sync_leaderboard(context, payload)
+end
+
+nk.register_rpc(leaderboard.rpc_get_leaderboard_stats, "get_leaderboard_stats")
+nk.register_rpc(leaderboard.rpc_submit_score, "submit_score")
+nk.register_rpc(leaderboard.rpc_sync_leaderboard, "sync_leaderboard")
+nk.register_rpc(leaderboard.rpc_reset_stats, "reset_stats")
+nk.register_rpc(leaderboard.rpc_admin_update_stats, "admin_update_stats")
+nk.register_rpc(leaderboard.rpc_admin_delete_stats, "admin_delete_stats")
+nk.register_rpc(leaderboard.rpc_admin_sync_leaderboard, "admin_sync_leaderboard")
+
+-- Create default native leaderboard
+-- id: "global_high_score", authoritative: true, sort: "desc", operator: "best", reset: None
+pcall(nk.leaderboard_create, "global_high_score", true, "desc", "best", nil, {})
+
+nk.logger_info("LUA TEST: leaderboard module loaded")
+
+return leaderboard
diff --git a/server/nakama/lua/user.lua b/server/nakama/lua/user.lua
new file mode 100644
index 0000000..a817cb4
--- /dev/null
+++ b/server/nakama/lua/user.lua
@@ -0,0 +1,260 @@
+local nk = require("nakama")
+
+local user = {}
+
+function user.rpc_get_user_profile(context, payload)
+ local request = nk.json_decode(payload or "{}")
+ local targetUserId = request.user_id or context.user_id
+
+ local status, account = pcall(nk.account_get_id, targetUserId)
+ if not status then error("Account not found") end
+
+ local metadata = {}
+ if account.user.metadata then
+ status, metadata = pcall(nk.json_decode, account.user.metadata)
+ if not status then metadata = {} end
+ end
+
+ if metadata.banned and targetUserId == context.user_id then
+ if metadata.ban_expires then
+ -- Note: ban_expires stored as Unix time in Lua (seconds) or ISO string depending on how it was stored
+ -- Let's check against current os.time() assuming Unix time
+ local expiresAt = tonumber(metadata.ban_expires)
+ if not expiresAt and type(metadata.ban_expires) == "string" then
+ -- basic check if we stored iso string
+ -- We assume it's valid ISO string and lua os.time might not parse it easily without custom function
+ -- As a fallback, we'll keep the ban if we can't parse it
+ error("Account banned until " .. metadata.ban_expires .. ". Reason: " .. (metadata.ban_reason or ""))
+ end
+
+ if expiresAt and expiresAt <= os.time() then
+ metadata.banned = nil
+ metadata.ban_reason = nil
+ metadata.ban_expires = nil
+ nk.account_update_id(targetUserId, nil, nil, nil, nil, nil, nil, nk.json_encode(metadata))
+ else
+ error("Account banned until " .. tostring(metadata.ban_expires) .. ". Reason: " .. (metadata.ban_reason or ""))
+ end
+ else
+ error("Account permanently banned. Reason: " .. (metadata.ban_reason or ""))
+ end
+ end
+
+ return nk.json_encode({
+ user_id = account.user.id,
+ username = account.user.username,
+ display_name = account.user.display_name,
+ avatar_url = account.user.avatar_url,
+ create_time = account.user.create_time,
+ role = metadata.role or "player"
+ })
+end
+
+function user.rpc_update_user_profile(context, payload)
+ if not context.user_id then error("Not authenticated") end
+ local request = nk.json_decode(payload)
+
+ local status, err = pcall(nk.account_update_id,
+ context.user_id,
+ nil,
+ request.display_name or nil,
+ nil,
+ nil,
+ nil,
+ request.avatar_url or nil,
+ nil
+ )
+
+ if not status then
+ nk.logger_error("Failed to update profile: " .. tostring(err))
+ error("Failed to update profile")
+ end
+
+ return nk.json_encode({ success = true })
+end
+
+function user.rpc_search_users(context, payload)
+ if not context.user_id then error("Not authenticated") end
+ local request = nk.json_decode(payload or "{}")
+ local query = request.query or ""
+
+ local users = {}
+ local sql = ""
+ local params = {}
+
+ if query == "" then
+ sql = "SELECT id, username, display_name, metadata FROM users WHERE id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100"
+ else
+ sql = "SELECT id, username, display_name, metadata FROM users WHERE (username ILIKE $1 OR display_name ILIKE $1) AND id != '00000000-0000-0000-0000-000000000000' ORDER BY create_time DESC LIMIT 100"
+ params = {"%" .. query .. "%"}
+ end
+
+ local status, rows = pcall(nk.sql_query, sql, params)
+ if status and rows then
+ for _, row in ipairs(rows) do
+ local metadata = {}
+ if row.metadata then
+ local s, m = pcall(nk.json_decode, row.metadata)
+ if s then metadata = m end
+ end
+
+ table.insert(users, {
+ user_id = row.id,
+ username = row.username or "",
+ display_name = row.display_name or row.username or "",
+ avatar_url = metadata.avatar_url or ""
+ })
+ end
+ end
+
+ return nk.json_encode({ users = users })
+end
+
+function user.rpc_change_credentials(context, payload)
+ if not context.user_id then error("Not authenticated") end
+ local req = nk.json_decode(payload or "{}")
+ local account = nk.account_get_id(context.user_id)
+
+ if account.email then
+ if not req.current_password then error("Current password required") end
+ local status = pcall(nk.authenticate_email, account.email, req.current_password, false)
+ if not status then error("Incorrect current password.") end
+ nk.unlink_email(context.user_id, account.email, req.current_password)
+ end
+
+ local status, err = pcall(nk.link_email, context.user_id, req.new_email, req.new_password)
+ if not status then
+ if account.email then pcall(nk.link_email, context.user_id, account.email, req.current_password) end
+ error("Failed to set new credentials: " .. tostring(err))
+ end
+
+ return nk.json_encode({ success = true })
+end
+
+function user.rpc_send_lobby_invite(context, payload)
+ if not context.user_id then error("Not authenticated") end
+ local req = nk.json_decode(payload or "{}")
+ if not req.to_user_id or not req.match_id then error("Missing to_user_id or match_id") end
+
+ local sender = nk.account_get_id(context.user_id)
+ local senderName = sender.user.display_name or sender.user.username or "Someone"
+
+ nk.notification_send(
+ req.to_user_id,
+ senderName .. " invited you to their lobby",
+ nk.json_encode({ match_id = req.match_id, from_name = senderName }),
+ 1001,
+ context.user_id,
+ true
+ )
+
+ nk.logger_info("Lobby invite sent from " .. context.user_id .. " to " .. req.to_user_id .. " for match " .. req.match_id)
+ return nk.json_encode({ success = true })
+end
+
+function user.rpc_send_friend_request(context, payload)
+ if not context.user_id then error("Not authenticated") end
+
+ local request = nk.json_decode(payload or "{}")
+ local targetUserId = request.user_id or ""
+
+ if targetUserId == "" then error("user_id is required") end
+ if targetUserId == context.user_id then error("Cannot add yourself") end
+
+ local senderAccount = nk.account_get_id(context.user_id)
+ local senderName = senderAccount.user.display_name or senderAccount.user.username or "Someone"
+
+ nk.notification_send(
+ targetUserId,
+ "Friend Request",
+ nk.json_encode({ from_user_id = context.user_id, from_name = senderName }),
+ 1002,
+ context.user_id,
+ true
+ )
+
+ nk.logger_info("Friend request notification sent from " .. context.user_id .. " to " .. targetUserId)
+ return nk.json_encode({ success = true })
+end
+
+function user.after_authenticate(context, out, payload)
+ if not context.user_id then return end
+ -- We store the last 10 logins in user metadata or a dedicated collection
+ local login_entry = {
+ time = os.time(),
+ ip = context.client_ip or "unknown"
+ }
+
+ local status, result = pcall(nk.storage_read, {{collection = "history", key = "logins", user_id = context.user_id}})
+ local logins = {}
+ if status and result and #result > 0 then
+ logins = result[1].value.logins or {}
+ end
+
+ table.insert(logins, 1, login_entry)
+ -- Keep only last 20 logins to save space
+ while #logins > 20 do table.remove(logins) end
+
+ pcall(nk.storage_write, {{
+ collection = "history",
+ key = "logins",
+ user_id = context.user_id,
+ value = { logins = logins },
+ permission_read = 0,
+ permission_write = 0
+ }})
+end
+
+function user.rpc_admin_get_user_history(context, payload)
+ local utils = require("lua.utils")
+ utils.require_admin(context)
+
+ local request = nk.json_decode(payload or "{}")
+ local targetUserId = request.user_id
+
+ if not targetUserId then error("user_id is required") end
+
+ local history = {
+ wallet_ledger = {},
+ logins = {},
+ matches = {}
+ }
+
+ -- 1. Fetch Wallet Ledger (Economy History)
+ local status_wallet, wallet_result = pcall(nk.wallet_ledger_list, targetUserId, 50)
+ if status_wallet and wallet_result then
+ history.wallet_ledger = wallet_result.items or {}
+ end
+
+ -- 2. Fetch Login History
+ local status_logins, login_result = pcall(nk.storage_read, {{collection = "history", key = "logins", user_id = targetUserId}})
+ if status_logins and login_result and #login_result > 0 then
+ history.logins = login_result[1].value.logins or {}
+ end
+
+ -- 3. Fetch Match History (If stored in collection 'matches')
+ local status_matches, match_result = pcall(nk.storage_list, targetUserId, "matches", 50, "")
+ if status_matches and match_result then
+ for _, obj in ipairs(match_result.objects or {}) do
+ table.insert(history.matches, obj.value)
+ end
+ end
+
+ return nk.json_encode({ history = history })
+end
+
+nk.register_rpc(user.rpc_get_user_profile, "get_user_profile")
+nk.register_rpc(user.rpc_update_user_profile, "update_user_profile")
+nk.register_rpc(user.rpc_search_users, "search_users")
+nk.register_rpc(user.rpc_change_credentials, "change_credentials")
+nk.register_rpc(user.rpc_send_lobby_invite, "send_lobby_invite")
+nk.register_rpc(user.rpc_send_friend_request, "send_friend_request")
+nk.register_rpc(user.rpc_admin_get_user_history, "admin_get_user_history")
+
+nk.register_req_after(user.after_authenticate, "AuthenticateDevice")
+nk.register_req_after(user.after_authenticate, "AuthenticateEmail")
+nk.register_req_after(user.after_authenticate, "AuthenticateCustom")
+
+nk.logger_info("LUA TEST: user module loaded")
+
+return user
diff --git a/server/nakama/lua/utils.lua b/server/nakama/lua/utils.lua
new file mode 100644
index 0000000..6e51342
--- /dev/null
+++ b/server/nakama/lua/utils.lua
@@ -0,0 +1,52 @@
+local nk = require("nakama")
+
+local utils = {}
+
+utils.ADMIN_ROLES = { ["admin"] = true, ["moderator"] = true, ["owner"] = true }
+
+function utils.is_admin(context)
+ if not context.user_id then return false end
+
+ local status, account = pcall(nk.account_get_id, context.user_id)
+ if not status or not account then return false end
+
+ local metadata = {}
+ if type(account.user.metadata) == "string" then
+ status, metadata = pcall(nk.json_decode, account.user.metadata)
+ if not status then metadata = {} end
+ else
+ metadata = account.user.metadata or {}
+ end
+
+ local role = metadata.role or ""
+ return utils.ADMIN_ROLES[role] == true
+end
+
+function utils.is_match_host(context, match_id)
+ if not context.user_id or not match_id then return false end
+ local status, match = pcall(nk.match_get, match_id)
+ if not status or not match then return false end
+
+ -- Needs to decode match.state if you're using authoritative matches
+ -- Simplified for lua translation:
+ local state = {}
+ if match.state then
+ status, state = pcall(nk.json_decode, match.state)
+ if not status then state = {} end
+ end
+ return state.hostUserId == context.user_id
+end
+
+function utils.require_admin(context)
+ if not utils.is_admin(context) then
+ error("Admin privileges required")
+ end
+end
+
+function utils.require_admin_or_host(context, match_id)
+ if not utils.is_admin(context) and not utils.is_match_host(context, match_id) then
+ error("Admin or host privileges required")
+ end
+end
+
+return utils
diff --git a/server/nakama/main.lua b/server/nakama/main.lua
new file mode 100644
index 0000000..4d75804
--- /dev/null
+++ b/server/nakama/main.lua
@@ -0,0 +1,13 @@
+local nk = require("nakama")
+
+-- Require our lua modules from the lua subfolder so they are executed and their RPCs are registered
+require("lua.utils")
+require("lua.economy")
+require("lua.core")
+require("lua.admin")
+require("lua.daily_rewards")
+require("lua.user")
+require("lua.leaderboard")
+require("lua.inbox")
+
+nk.logger_info("LUA TEST: main.lua entrypoint loaded successfully")