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 + + + + + + + + + + + + + +
+
+
+
+ Godot +
+
+ Nakama + x Godot 4 + Production Blueprint +
+
+
+ + + Production Ready + + + + +
+
+ + + +
+ +
+ + +
+

+ Knowledge Base & 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)

+
+
+
+
+ + +
+
+ +

1. Regional Infrastructure & Regulatory Compliance

+
+

+ Deploying cross-border games requires careful partitioning of databases and game nodes to satisfy + extreme technical boundaries (e.g., latency issues caused by the Great Firewall) and data protection + statutes (e.g., GDPR, local municipal privacy mandates). +

+ +
+ +
+
+
+ + Europe + (EU) - Webdock +
+ +
+

GDPR & DMA Pipeline

+
    +
  • Central + Cluster: Host on high-density Webdock.io Ryzen-powered VPS + profiles located in Frankfurt or Vienna.
  • +
  • + Sovereignty: Webdock offers EU-owned infrastructure with strict + hardware insulation and zero overseas sub-processor data leaks.
  • +
  • User + Control: Integrate explicit game-level telemetry opt-out flags which halt + outgoing Nakama analytics scripts instantly.
  • +
+
+ + +
+
+
+ + + China + Transit - HostHatch +
+ +
+

HostHatch HK Edge Gateway

+
    +
  • GFW + Proximity: Deploy regional proxy logic to HostHatch.com + Hong Kong nodes, featuring direct low-latency peering tunnels.
  • +
  • Regulatory + Separation: Standalone DB instances keep Mainland China data segregated + from Western clusters while resolving network hops near the target audience.
  • +
  • NPPA + Integrations: Route localized traffic from HK edge to Chinese validation + and anti-addiction registry backends efficiently.
  • +
+
+ + +
+
+
+ + + + Asia + (Ex-CN) - Following CN +
+ +
+

APAC Edge Arrays

+
    +
  • Transit + Integration: Set up edge nodes in Tokyo, Seoul, and Singapore configured to + capture traffic spills when cross-border HK connections are saturated.
  • +
  • Latency + Optimization: Run high-performance CockroachDB clusters to maintain global + synchronization while serving nearby regional users under 45ms.
  • +
  • Data + Privacy: Satisfy local guidelines (Japan's APPI, South Korea's PIPA) using + explicit user data deletion interfaces inside Nakama profile routes.
  • +
+
+
+ + +
+ +
+ Critical GFW Operational Warning: + Real-time WebSocket and UDP connection signals across the Great Firewall suffer massive packet + losses (≥ 25%). Incorporating HostHatch's Hong Kong node acts as an indispensable entry buffer; + however, complete logical segregation of the Chinese client backend instance is still mandatory. +
+
+
+ + +
+
+
+ +

2. Storefront Commissions & Licensing Pipeline

+
+
+

+ Operating margins rely on optimizing each storefront's fee parameters. Before collecting gross revenue, + publishers must clear the initial platform entry fees. +

+ + +
+

+ + Initial Publishing Costs & First Fee Settlement Grid +

+

+ Below is the required capital breakdown needed to register your identity and prepare storefront + slots before pushing your initial Godot client build to production channels. +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PlatformFirst Fee AmountMarket FlagFee Recurrence TypeRefundability StatusSettlement Methods
Steamworks (PC)$100.00 USD (per product) + + One-time per App SlotYes (Refunded after $1,000 in gross sales) + Credit Card, PayPal, Steam Wallet, Wire Transfer
Itch.io (PC)$0.00 USD (Zero entry fee) + + NoneNot ApplicableNone (Optional tax documentation verification)
Google Play$25.00 USD + + One-time per Developer IdentityNoCredit/Debit Card (requires Google Pay Profile)
Apple App Store$99.00 USD + + Annual Subscription RenewalNoCredit Card (linked to Apple ID Developer Account)
TapTap Developer$0.00 USD (Corporate validation) + + + NoneNot ApplicableRequires legal corporate identity / ICP verification
+
+
+ +
+ Net Yield Production + Formula +
+ R_net = R_gross × (1 - C_store - T_tax) - F_fees +
+

+ Where C_store matches the target store platform commission, T_tax matches regional withholding taxes, and F_fees encapsulates external server transaction margins and API + query operations. +

+
+ + +
+
+ + + + + +
+ +
+ +
+
+
+

Steamworks Implementation Matrix

+ + + + + + + + + + + + + + + + + +
Commission Rate30% (Default)
Volume Scaling25% at $10M | 20% at $50M
First Fee$100 USD (Steam Direct + App Deposit)
Nakama VerificationServer Authenticated + Session Ticket
+
+
+

Technical + Execution

+

+ Initialize GodotSteam + dynamically inside your client. The user retrieves their secure hex session ticket + locally, passing it down to Nakama using the native client driver hook to eliminate + credential spoofing risks. +

+ Endpoint: + client.authenticate_steam_async(ticket) +
+
+
+ + + + + + + + + + + + +
+
+
+ + +
+
+ +

3. Monetization Architecture & Secure IAP Loop

+
+

+ Client-side reporting is inherently untrustworthy. Memory injectors (e.g., Lucky Patcher on Android, + memory editing tools on PC) can manipulate the client runtime to simulate successful purchases. + Implementing a robust, asynchronous verification cycle ensures validation is handled strictly by the + server. +

+ + +
+

Asynchronous Verification Topology

+ +
+ +
+
+ 1
+

Purchase & Tokenization

+

The Godot client requests checkout. The player submits payment + to the store network, which issues an encrypted, signed platform transaction token.

+
+ +
+
+ 2
+

Nakama RPC Ingestion

+

The client forwards the raw transaction token securely to + Nakama via an RPC function call: verify_purchase.

+
+ +
+
+ 3
+

Server Validation Check

+

Nakama blocks immediate user manipulation. It connects + server-to-server with Google, Apple, or Steam APIs to verify status and signatures.

+
+ +
+
+ 4
+

Ledger Provisioning

+

Upon verification, Nakama updates the persistent storage + wallet data and broadcasts confirmation back to the Godot client.

+
+
+
+
+ + +
+
+ +

4. Core Architecture: Unified Identity Manager Decision Flow + Chart

+
+

+ The Unified Identity Manager dynamically discovers platforms at runtime, resolving features and + singletons without breaking compilations on platforms lacking those SDK wrappers. Click the platform + modes below to preview the path execution, safety hooks, and token routing. +

+ + +
+ +
+ + + + + +
+ + +
+ + +
+
+
Game + Initialization
+

_ready() / dispatch_platform_auth()

+

Queries environment architecture and singletons

+
+
+ + +
+
+
+ + +
+ + +
+
+ Steam + PC + Steam Target +
+
+
+
Class Check
+ ClassDB.has_singleton("Steam") +
+
+
+
Ticket Retrieval
+

Grabs session hex ticket asynchronously +

+
+
+
+
Nakama Endpoint
+ authenticate_steam_async() +
+
+
+ + +
+
+ Google Play + Android + OS Target +
+
+
+
Feature Gating
+ OS.has_feature("taptap") +
+
+ YesNo
+
+
+
TapSDK
+

Fetch OAuth Token

+
+
+
Google Play
+

Fetch Auth Code

+
+
+
+
+
Nakama Endpoint
+ authenticate_custom_async()
OR
authenticate_google_async()
+
+
+
+ + +
+
+ Apple + iOS + OS Target +
+
+
+
Feature Gating
+ OS.has_feature("taptap") +
+
+ YesNo
+
+
+
TapSDK
+

Fetch OAuth Token

+
+
+
Apple Auth
+

Fetch Identity Token

+
+
+
+
+
Nakama Endpoint
+ authenticate_custom_async()
OR
authenticate_apple_async()
+
+
+
+ + +
+
+ Itch.io + PC/Itch.io + Standalone +
+
+
+
Device Hardware ID
+ OS.get_unique_id() +
+
+
+
Hardware Hashing
+

Generate hardware fingerprint

+
+
+
+
Nakama Endpoint
+ authenticate_device_async() +
+
+
+ +
+ + +
+
+
+ + +
+
+
+ + + + + Nakama Server Response Node +
+

NakamaSession Established

+

+ Session token decoded & validated, persistent profiles resolved, and socket pipelines + opened. Global matching/telemetry gates unlocked. +

+
+
+ +
+
+
+ + + + + + +
+
+
+ +

5. Project Management (PR) Board & AI Checklist

+
+
+

+ This section serves as a fully detailed tracking board. It merges production readiness, backend reconstruction, gameflow audit, and Steam depot release tasks. + Every task is fully expanded with checklists and automated testing criteria so you can track AI execution seamlessly. +

+ +
+

Priority Rule

+

+ Do not spend release time on Steam depot upload, signing polish, or branch promotion until P0 backend authority + is fixed. Current audit found client-authoritative economy, gacha paths, and sync loopholes. + Those are launch blockers because they can corrupt wallet, inventory, match state, and account identity before first public build. +

+
+ + +
+ +
+ +
+ P0 + PRD-P0-1 +

Economy Authority

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ server/nakama/tekton_admin.js, user_profile_manager.gd +
+
+ +
+

Goal / Risk

+

Stop trusting client prices, categories, package IDs. Reconstruct server-authoritative economy.

+
+ +
+

Execution Checklist

+
    +
  • Create server catalog mapping item IDs to category, price, currency type, stack rules.
  • Change purchase request so client sends only item ID, quantity, and optional idempotency key.
  • Validate balance and inventory capacity server-side before mutation.
  • Replace fake currency purchase with receipt verification placeholder interface per platform.
  • Write wallet/inventory mutation audit entry with user ID, request ID, before/after values.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Analyze current Tekton economy flow in server/nakama/tekton_admin.js and Godot callers. Reconstruct shop purchase authority so the client no longer sends trusted price_gold, price_star, category, or reward data. Add a server-side item catalog and update rpcPurchaseItem to accept only item_id, quantity, and idempotency_key. Replace rpcBuyCurrency behavior with a receipt-verification-safe interface that records pending/verified transactions and never grants premium currency from package ID alone. Preserve existing profile/wallet behavior where possible. Add validation, normalized errors, and audit ledger writes. Update Godot callers to match new payload shape. Acceptance: no wallet or inventory mutation depends on client-submitted price/category/package intent; duplicate idempotency key does not duplicate grant; existing shop UI can still request purchases.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Call `rpcPurchaseItem` with modified price/category from client. Assert server rejects or ignores client price and uses catalog price. Assert duplicate idempotency keys return the exact same transaction result without deducting twice.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-P0-1]: Economy Authority**
+- **Goal:** Stop trusting client prices, categories, package IDs. Reconstruct server-authoritative economy.
+- **Status:** Integrated & verified. Code changes applied to: server/nakama/tekton_admin.js, user_profile_manager.gd
+ +
+
+
+
+
+ +
+ +
+ P0 + PRD-P0-2 +

Gacha Authority

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ gacha_manager.gd, Nakama economy RPCs +
+
+ +
+

Goal / Risk

+

Move RNG, pity, cost consume, and rewards server-side.

+
+ +
+

Execution Checklist

+
    +
  • Add server RPC for gacha pull with banner ID, pull count, and idempotency key.
  • Store pity and banner state server-side.
  • Server consumes cost, rolls reward, writes item/fragment result, and returns canonical result.
  • Client only animates returned result; no local grant or deduction.
  • Add migration note for existing local pity/fragment data.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Refactor Tekton gacha so authority lives in Nakama. Read scripts/managers/gacha_manager.gd, user_profile_manager.gd, and server/nakama/tekton_admin.js before editing. Add server-side RPCs for gacha_pull and any needed banner/profile state. Server must own RNG, pity counter, cost deduction, reward choice, inventory/fragment writes, and audit/idempotency. Client must become presentation-only: it sends banner_id, pull_count, and idempotency_key, then animates the canonical server response. Remove local reward grant and local currency deduction from gacha_manager.gd. Acceptance: editing client RNG/pity code cannot change real rewards; duplicate pull request cannot duplicate rewards; profile refresh after pull shows server state.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Trigger `rpcGachaPull`. Assert client currency deduction happens only upon server response. Assert client cannot specify reward or manipulate RNG seed.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-P0-2]: Gacha Authority**
+- **Goal:** Move RNG, pity, cost consume, and rewards server-side.
+- **Status:** Integrated & verified. Code changes applied to: gacha_manager.gd, Nakama economy RPCs
+ +
+
+
+
+
+ +
+ +
+ P0 + PRD-P0-3 +

Auth & Secrets Lock

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ auth_manager.gd, nakama_manager.gd, project.godot +
+
+ +
+

Goal / Risk

+

Remove insecure Steam fallback, default App ID 480, hardcoded release secrets.

+
+ +
+

Execution Checklist

+
    +
  • Replace production Steam App ID placeholder only when real ID exists.
  • Fail hard in Steam build if Steam ticket cannot be acquired.
  • Remove fallback email/custom auth from Steam release path.
  • Externalize server host, scheme, key, encryption key, and secrets.
  • Delete or environment-gate admin topup RPC and admin UI entry points.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Audit and harden Tekton authentication and admin mutation paths. Read project.godot, scripts/services/steamworks_manager.gd, scripts/managers/auth_manager.gd, scripts/nakama_manager.gd, scripts/ui/admin_panel.gd, and server/nakama/tekton_admin.js. Remove insecure Steam release fallback behavior so Steam builds authenticate only with valid Steam tickets. Add clear release guards for Steam App ID 480 so production export fails or warns loudly if still using test ID. Externalize backend config and local encryption material away from hardcoded production defaults. Remove or environment-gate rpcAdminTopupGold and ensure admin panel scenes/scripts are not included in player exports unless explicitly feature-flagged. Acceptance: Steam build cannot silently fall back to insecure auth; test App ID 480 is blocked for production; admin mint path is unavailable in production runtime.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Export project with Steam features. Disconnect Steam client. Assert game fails to authenticate and does NOT fallback to custom/device auth. Assert admin UI is entirely hidden in release builds.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-P0-3]: Auth & Secrets Lock**
+- **Goal:** Remove insecure Steam fallback, default App ID 480, hardcoded release secrets.
+- **Status:** Integrated & verified. Code changes applied to: auth_manager.gd, nakama_manager.gd, project.godot
+ +
+
+
+
+
+ +
+ +
+ P0 + PRD-P0-4 +

Backend Deploy Safety

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ server/, Nakama runtime module +
+
+ +
+

Goal / Risk

+

Replace manual module copy/restart with staging/prod deploy, health check, rollback.

+
+ +
+

Execution Checklist

+
    +
  • Separate dev/staging/prod Nakama config and secrets.
  • Script module package/copy/restart with version label.
  • Add health check RPC after deploy.
  • Keep previous module artifact for rollback.
  • Add smoke checklist: auth, profile, shop, mail, gacha, friends, leaderboard.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Create a production-safe Nakama deployment workflow for Tekton. Review server/docker-compose.yaml, server/nakama/README.md, and current runtime module layout. Replace manual docker cp guidance with scripts or documented commands for staging and production deploys. Include environment-specific config/secrets, module version labeling, restart procedure, health check, smoke test commands, and rollback to previous module. Do not commit real secrets. Acceptance: a developer can deploy to staging, verify health, promote to production, and rollback using documented repeatable steps without manually editing containers.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Run deploy script. Assert Nakama server restarts without losing data. Trigger health check RPC to verify new module loaded successfully.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-P0-4]: Backend Deploy Safety**
+- **Goal:** Replace manual module copy/restart with staging/prod deploy, health check, rollback.
+- **Status:** Integrated & verified. Code changes applied to: server/, Nakama runtime module
+ +
+
+
+
+
+ +
+ +
+ P0 + PRD-GF-P0-1 +

Spawn/Sync Authority Lock

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ main.gd, player.gd +
+
+ +
+

Goal / Risk

+

Retain: deterministic pre-spawn sync. Remove: client-trusted teleport/update paths.

+
+ +
+

Execution Checklist

+
    +
  • Keep deterministic pre-spawn strategy (client pre-creates lobby roster).
  • Remove client-trusted position mutation path that can move authoritative state without server validation.
  • Introduce server-owned spawn_revision and state_revision integers.
  • Reject stale updates on client and server.
  • Ensure reconnect flow requests full player sync, then grid sync, then mode-specific sync.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Audit and harden player spawn/sync authority in scenes/main.gd and scenes/player.gd. Keep deterministic pre-spawn strategy and existing server-authoritative item randomization pattern, but remove any client-trusted position mutation path that can move authoritative state without server validation. Introduce server-owned spawn_revision and state_revision integers sent with spawn and full-sync payloads. Reject stale updates on client and server. Ensure reconnect flow requests full player sync first, then full grid sync, then mode-specific sync (Stop n Go / Tekton Doors) with explicit ack step. Acceptance: client cannot force authoritative teleport; reconnecting client converges to identical player positions/goals/playerboards after one sync cycle; stale packets no longer overwrite newer state.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Simulate client sending stale spawn_revision. Assert server rejects. Reconnect mid-match, assert player converges to exact same grid position as before.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-GF-P0-1]: Spawn/Sync Authority Lock**
+- **Goal:** Retain: deterministic pre-spawn sync. Remove: client-trusted teleport/update paths.
+- **Status:** Integrated & verified. Code changes applied to: main.gd, player.gd
+ +
+
+
+
+
+ +
+ +
+ P0 + PRD-GF-P0-2 +

Lobby Start Gate Hardening

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ lobby.gd, lobby_manager.gd +
+
+ +
+

Goal / Risk

+

Add preflight checklist RPC, check ready-state and host authority.

+
+ +
+

Execution Checklist

+
    +
  • Preserve LAN/Nakama dual-mode behavior and tutorial fast path.
  • Add preflight readiness checks before _on_game_starting transition.
  • Verify session valid, host authority true, all player records present, mode config validated.
  • Add one typed preflight result object and render actionable errors.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Rework lobby game-start gating in scenes/lobby.gd and scripts/managers/lobby_manager.gd. Preserve LAN/Nakama dual-mode behavior and tutorial fast path, but add preflight readiness checks before _on_game_starting transition: session valid (or explicit guest/LAN mode), host authority true, all required player records present, mode config validated, and scene dependencies reachable. Add one typed preflight result object and render actionable errors in connection_status/status_label. Acceptance: start button cannot trigger broken scene load with partial state; host and clients see same preflight result; loading screen transition only occurs after preflight pass.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Attempt to start match without full player records. Assert UI blocks start and shows specific error string from preflight check.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-GF-P0-2]: Lobby Start Gate Hardening**
+- **Goal:** Add preflight checklist RPC, check ready-state and host authority.
+- **Status:** Integrated & verified. Code changes applied to: lobby.gd, lobby_manager.gd
+ +
+
+
+
+
+ +
+ +
+ P0 + PRD-GF-P0-3 +

RPC Sender Identity & Contract Clamp

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ main.gd, player.gd, lobby_manager.gd +
+
+ +
+

Goal / Risk

+

Remove payload fields that claim identity. Validate sender natively.

+
+ +
+

Execution Checklist

+
    +
  • Read all any_peer RPC entry points.
  • Remove payload fields that pretend to identify requester/authority (use get_remote_sender_id).
  • Verify sender identity and authority explicitly for state-mutation RPCs.
  • Normalize RPC contracts to carry stable error codes.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Clamp multiplayer RPC trust boundaries across scenes/main.gd, scenes/player.gd, and scripts/managers/lobby_manager.gd. Read all any_peer RPC entry points before editing. Keep fast RPC update flow, but remove payload fields that pretend to identify requester/authority when sender can be derived from multiplayer.get_remote_sender_id(). For room info, start flow, rematch, and state-mutation RPCs, verify sender identity and authority explicitly. Normalize RPC contracts so request payloads contain only data the caller is allowed to propose, and response payloads carry canonical server state plus stable error codes. Acceptance: spoofed requester IDs are ignored; unauthorized peers cannot mutate host/server-owned state; RPC errors are debuggable and consistent.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Call state-mutation RPC pretending to be another peer ID in payload. Assert server overrides payload with actual `get_remote_sender_id()` and blocks if unauthorized.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-GF-P0-3]: RPC Sender Identity & Contract Clamp**
+- **Goal:** Remove payload fields that claim identity. Validate sender natively.
+- **Status:** Integrated & verified. Code changes applied to: main.gd, player.gd, lobby_manager.gd
+ +
+
+
+
+
+ +
+ +
+ P0 + PRD-GF-P0-4 +

Chat/DM Abuse Control

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ lobby.gd, Nakama chat +
+
+ +
+

Goal / Risk

+

Add moderation, throttling, sanitation, flood guard, and permission matrix.

+
+ +
+

Execution Checklist

+
    +
  • Keep current channel UX, DM tabs, and history pull.
  • Add per-user send cooldown and max payload length limits.
  • Add command permission matrix (/clear admin only, all other slash commands explicit).
  • Mark unsent/failed messages in UI with retry policy.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Harden global chat and DM flow in scenes/lobby.gd and related Nakama chat policy. Keep current channel UX, DM tabs, and history pull, but add abuse controls: per-user send cooldown, max payload length, profanity/moderation hook placeholder, and command permission matrix (/clear admin only, all other slash commands explicit). Fix any DM append/state bug found during read-through. Prevent silent local-only divergence by marking unsent/failed messages in UI and retry policy. Acceptance: flood attempts are throttled; unauthorized command execution blocked server-side; message rendering sanitized and bounded; chat remains responsive under burst traffic.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Send 50 chat messages in 1 second. Assert Nakama throttles request and UI shows 'failed to send/cooldown' UI marker. Attempt `/clear` as non-admin, assert blocked.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-GF-P0-4]: Chat/DM Abuse Control**
+- **Goal:** Add moderation, throttling, sanitation, flood guard, and permission matrix.
+- **Status:** Integrated & verified. Code changes applied to: lobby.gd, Nakama chat
+ +
+
+
+
+
+ +
+ +
+ P1 + PRD-P1-1 +

Module Split & RPC Validation

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ tekton_admin.js +
+
+ +
+

Goal / Risk

+

Split monolith into auth, economy, admin, mail, social, leaderboard, validation helpers.

+
+ +
+

Execution Checklist

+
    +
  • Refactor tekton_admin.js into domain modules without changing external RPC names.
  • Create modules for auth, economy, admin, mail, social, leaderboard, storage, validation.
  • Add central validators for payload shape, types, limits.
  • Add normalized error responses with stable error codes.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Refactor server/nakama/tekton_admin.js into maintainable domain modules without changing external RPC names unless necessary. Create or plan modules for auth, economy, admin, mail, social, leaderboard, storage, and validation helpers. Add central validators for payload shape, types, limits, and allowed enum values. Add normalized error responses with stable error codes. Keep behavior compatible while moving code in small steps. Acceptance: RPC registration remains clear; each RPC validates payload before mutation; error responses are consistent; module split does not break existing smoke tests.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Call split RPC with missing payload fields. Assert central validator catches it and returns `INVALID_ARGUMENT` standard error code.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-P1-1]: Module Split & RPC Validation**
+- **Goal:** Split monolith into auth, economy, admin, mail, social, leaderboard, validation helpers.
+- **Status:** Integrated & verified. Code changes applied to: tekton_admin.js
+ +
+
+
+
+
+ +
+ +
+ P1 + PRD-P1-2 +

Ledger, Idempotency & Storage Model

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ Wallet, inventory, fragments, mail rewards +
+
+ +
+

Goal / Risk

+

Add mutation audit ledger, idempotency keys, and canonical fragment storage path.

+
+ +
+

Execution Checklist

+
    +
  • Define one canonical fragment storage location and migration path.
  • Add idempotency keys for mail claim, daily reward, purchase, gacha, and admin adjustments.
  • Add audit records with source, user_id, mutation type, request_id.
  • Make mail claim transactional (claim, mark, return canonical state).
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Implement a canonical mutation ledger and idempotency policy for Tekton live-service rewards. Read server/nakama/tekton_admin.js, scripts/managers/user_profile_manager.gd, scripts/managers/mail_manager.gd, and gacha/profile storage code. Define one canonical fragment storage location and migration path. Add idempotency keys for mail claim, daily reward, purchase, gacha, and admin adjustments. Add audit records with source, user_id, mutation type, request_id, before/after summary, and timestamp. Make mail claim transactional: claim rewards, mark claimed, and return canonical updated state in one server response. Acceptance: repeated claim/purchase/reward requests do not duplicate grants; fragments read/write from one canonical path; mail UI refreshes from server-returned state.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Send identical mail claim RPC twice simultaneously. Assert only one processes successfully and the second returns 'already claimed'.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-P1-2]: Ledger, Idempotency & Storage Model**
+- **Goal:** Add mutation audit ledger, idempotency keys, and canonical fragment storage path.
+- **Status:** Integrated & verified. Code changes applied to: Wallet, inventory, fragments, mail rewards
+ +
+
+
+
+
+ +
+ +
+ P1 + PRD-P1-3 +

Client Backend Facade

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ nakama_manager.gd, auth_manager.gd, backend_service.gd +
+
+ +
+

Goal / Risk

+

Make one typed backend owner for session, socket, RPC calls, and central errors.

+
+ +
+

Execution Checklist

+
    +
  • Decide whether BackendService becomes the sole typed backend facade or is deleted.
  • Implement one owner for client/session/socket.
  • Add typed methods for RPCs, central error handling.
  • Remove direct UI RPC scatter for economy/auth/mail/gacha/social flows.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Clean up Tekton client backend ownership. Read scripts/nakama_manager.gd, scripts/managers/auth_manager.gd, scripts/services/backend_service.gd, and UI/manager scripts that call NakamaManager.client.rpc_async directly. Decide whether BackendService becomes the sole typed backend facade or is deleted. Implement chosen direction in small steps: one owner for client/session/socket, typed methods for RPCs, central error handling, and no direct UI RPC scatter for economy/auth/mail/gacha/social flows. Acceptance: UI calls typed service/manager methods, not raw client.rpc_async; session/socket ownership is clear; duplicate auth/bootstrap code is removed or delegated.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Global search for `client.rpc_async` in `scripts/ui/`. Assert 0 results found (all go through facade).

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-P1-3]: Client Backend Facade**
+- **Goal:** Make one typed backend owner for session, socket, RPC calls, and central errors.
+- **Status:** Integrated & verified. Code changes applied to: nakama_manager.gd, auth_manager.gd, backend_service.gd
+ +
+
+
+
+
+ +
+ +
+ P1 + PRD-GF-P1-1 +

Tutorial Isolation Contract

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ tutorial_manager.gd +
+
+ +
+

Goal / Risk

+

Remove multiplayer-side effects during pause/freeze phases. Isolate tutorial boundaries.

+
+ +
+

Execution Checklist

+
    +
  • Keep onboarding sequence and camera storytelling.
  • Enforce contract: no persistent wallet/profile mutation during tutorial.
  • Ensure no shared lobby state leakage.
  • Ensure clean bot/timer restore on exit, deterministic return-to-lobby handshake.
  • Replace broad pause/freeze side effects with scoped tutorial-state toggles.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Isolate tutorial runtime from multiplayer/session side effects. Review scripts/managers/tutorial_manager.gd and match lifecycle hooks. Keep onboarding sequence and camera storytelling, but enforce tutorial contract: no persistent wallet/profile mutation, no shared lobby state leakage, clean bot/timer restore on exit, deterministic return-to-lobby handshake. Replace broad pause/freeze side effects with scoped tutorial-state toggles where possible. Acceptance: exiting tutorial leaves no stale bot freeze, no leaked paused systems, and no corrupted room/session flags.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Abort tutorial midway. Assert main game tree is fully unpaused, bots are reset, and no 'tutorial_active' flags leak into lobby.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-GF-P1-1]: Tutorial Isolation Contract**
+- **Goal:** Remove multiplayer-side effects during pause/freeze phases. Isolate tutorial boundaries.
+- **Status:** Integrated & verified. Code changes applied to: tutorial_manager.gd
+ +
+
+
+
+
+ +
+ +
+ P1 + PRD-GF-P1-2 +

Mode Config Completeness

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ main.gd, lobby mode configs +
+
+ +
+

Goal / Risk

+

Remove duplicated/inconsistent option toggles. Add schema-driven validation.

+
+ +
+

Execution Checklist

+
    +
  • Keep existing Stop n Go custom UI.
  • Remove duplicated/fragile control toggles.
  • Implement Tekton Doors options with same host-authoritative lock and sync callbacks.
  • Introduce schema-driven config validation shared by host, client, and bootstrap.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Complete mode-configuration parity between Stop n Go and Tekton Doors in lobby and match bootstrap flow. Keep existing Stop n Go custom UI, but remove duplicated/fragile control toggles and implement Tekton Doors options with same host-authoritative lock and sync callbacks. Introduce schema-driven config validation shared by host, client display logic, and match bootstrap. Acceptance: both modes expose full validated config; non-host clients always mirror host values; invalid config rejected before match start.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Join as client, attempt to spoof mode config RPC. Assert host rejects invalid mode config changes and overrides client.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-GF-P1-2]: Mode Config Completeness**
+- **Goal:** Remove duplicated/inconsistent option toggles. Add schema-driven validation.
+- **Status:** Integrated & verified. Code changes applied to: main.gd, lobby mode configs
+ +
+
+
+
+
+ +
+ +
+ P1 + PRD-GF-P1-3 +

Backend Facade & Flow Decoupling

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ backend_service.gd, UI panels +
+
+ +
+

Goal / Risk

+

Improve service ownership and typed errors. Add one backend facade.

+
+ +
+

Execution Checklist

+
    +
  • Identify remaining UI/manager scripts calling client.rpc_async.
  • Migrate calls to the central BackendService or unified manager.
  • Implement central error mapping and retry policy.
  • Verify all gameflow-adjacent UI uses new typed methods.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Finish client backend decoupling for gameflow-adjacent live-service features. Read scripts/services/backend_service.gd plus UI/manager scripts that still call NakamaManager.client.rpc_async directly (profile, social, leaderboard, daily reward, mail, admin, friend flows). Decide whether BackendService becomes real facade or is removed. Implement one typed backend owner for auth/session/socket/RPC calls, central error mapping, and retry policy. Acceptance: gameflow-adjacent UI does not call raw client.rpc_async directly for production paths; backend ownership is obvious; future auth/RPC changes touch one service layer first, not many UI panels.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Trigger network failure during profile fetch. Assert BackendService retry policy handles it gracefully without UI hard-crashing.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-GF-P1-3]: Backend Facade & Flow Decoupling**
+- **Goal:** Improve service ownership and typed errors. Add one backend facade.
+- **Status:** Integrated & verified. Code changes applied to: backend_service.gd, UI panels
+ +
+
+
+
+
+ +
+ +
+ P1 + PRD-P1-4 +

Versioning & Patch Integrity

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ tools/, export_presets.cfg, version.json +
+
+ +
+

Goal / Risk

+

Single release version source, checksums, compatibility rules, changelog archive.

+
+ +
+

Execution Checklist

+
    +
  • Create one release version source (version.json or python script).
  • Update project version, export versions, Android version deterministically.
  • Update patch manifest and changelog archive.
  • Add patch integrity fields: checksum, size, minimum compatible app version.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Rebuild Tekton versioning workflow. Review tools/generate_version_json.py, tools/build_patch.gd, export_presets.cfg, project.godot, assets/data/version.json, README.md, and CHANGELOG_DRAFT.md. Create one release version source and update all platform metadata deterministically: project version, export versions, Android version/code, patch manifest, changelog archive, and Git tag instructions. Add patch integrity fields such as checksum, size, minimum compatible app version, and signature placeholder if signing is not available yet. Acceptance: one command or documented flow bumps release version; generated metadata matches across files; patch manifest can reject incompatible or corrupted patch.pck.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Run version bump script. Assert export_presets.cfg Android version code increments correctly and patch manifest checksum is updated.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-P1-4]: Versioning & Patch Integrity**
+- **Goal:** Single release version source, checksums, compatibility rules, changelog archive.
+- **Status:** Integrated & verified. Code changes applied to: tools/, export_presets.cfg, version.json
+ +
+
+
+
+
+ +
+ +
+ P2 + PRD-P2-1 +

Steam Depot & Store Packaging

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ tools/steam/, export presets +
+
+ +
+

Goal / Risk

+

Create SteamPipe VDFs, branch SOP, signing/notarization, platform filters.

+
+ +
+

Execution Checklist

+
    +
  • Create tools/steam/app_build_.vdf and per-platform depot templates.
  • Document steamcmd upload command, branch promotion path.
  • Add guidance for Windows signing, macOS notarization, Android package name.
  • Configure store-specific export filters.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Add Steam and storefront release packaging workflow for Tekton after P0/P1 backend gates are complete. Review export_presets.cfg, docs/STEAMWORKS_SETUP.md, README.md, and current build output conventions. Create tools/steam/app_build_.vdf and per-platform depot VDF templates using placeholders only. Document steamcmd upload command, branch promotion path internal -> beta -> default, and smoke tests required before promotion. Add guidance for Windows signing, macOS bundle/team/notarization, Android final package name/version code, and store-specific export filters so Steam libraries are not shipped in non-Steam builds. Acceptance: no real IDs/secrets committed; SteamPipe templates exist; release checklist blocks default branch promotion until smoke tests pass.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Trigger dry-run of SteamPipe VDF. Assert paths resolve to output directory without committing real credentials.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-P2-1]: Steam Depot & Store Packaging**
+- **Goal:** Create SteamPipe VDFs, branch SOP, signing/notarization, platform filters.
+- **Status:** Integrated & verified. Code changes applied to: tools/steam/, export presets
+ +
+
+
+
+
+ +
+ +
+ P2 + PRD-GF-P2-1 +

Dead Path, Debug Gate & Telemetry Cleanup

+
+
+ + +
+
+
+ +
+
+

Files / Areas

+
+ main.gd, player.gd, placeholders +
+
+ +
+

Goal / Risk

+

Remove release-noisy debug hooks. Add safe-remove candidate matrix + SLO dashboard.

+
+ +
+

Execution Checklist

+
    +
  • Build matrix: keep, safe-remove, needs-runtime-proof, feature-flag.
  • Remove or feature-gate release-only noise (e.g., debug key hooks, excessive prints).
  • Instrument events: room_joined, preflight_pass, loading_screen, match_sync.
  • Do not delete autoload/runtime-loaded scripts without proof.
  • +
+
+
+ + +
+
+

AI Execution Prompt

+
+
Create dead-path/debug-path cleanup and telemetry gates for lobby-to-match lifecycle. Review main.gd, player.gd, login_screen.gd, backend_service.gd, and other placeholders/debug hooks. Build matrix with columns: keep, safe-remove, needs-runtime-proof, feature-flag. Remove or feature-gate release-only noise such as debug key hooks and excessive prints, but do not delete autoload/runtime-loaded scripts without proof. Instrument events: room_joined, preflight_pass/fail, loading_screen_start/finish, match_sync_complete, reconnect_success/fail, match_end_summary. Acceptance: safe-remove candidates are evidence-backed; release export excludes debug-only hooks; branch promotion can check match-start and reconnect SLO metrics.
+ +
+
+ +
+

Testing / Auto-Check

+

AI AUTO-CHECK: Search codebase for `Input.is_key_pressed(KEY_F9)`. Assert wrapped in `OS.has_feature("debug")` or completely removed.

+
+ +
+

MS Teams Daily Report

+
+
**Completed [PRD-GF-P2-1]: Dead Path, Debug Gate & Telemetry Cleanup**
+- **Goal:** Remove release-noisy debug hooks. Add safe-remove candidate matrix + SLO dashboard.
+- **Status:** Integrated & verified. Code changes applied to: main.gd, player.gd, placeholders
+ +
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + \ 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")