feat: 2.3.2
This commit is contained in:
Vendored
+1
-1
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -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
|
||||
Binary file not shown.
@@ -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** ✅
|
||||
@@ -0,0 +1,2191 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Knowledge Base: Multi-Platform & Regional Production Deployment Blueprint</title>
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>tailwind.config = { darkMode: "class" }</script>
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<!-- Flag Icons CSS CDN (Free Country Flags in SVG) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flag-icons/7.2.3/css/flag-icons.min.css" />
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght=300;400;500;600;700;800&family=JetBrains+Mono:wght=400;500;600&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.code-font {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Custom styles to refine the flag layout */
|
||||
.fi {
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-slate-50 dark:bg-slate-900 text-slate-800 dark:text-slate-100 min-h-screen antialiased selection:bg-sky-500 selection:text-slate-900 dark:text-white">
|
||||
|
||||
<!-- Header Navigation Bar -->
|
||||
<header class="border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-white/80 dark:bg-slate-950/80 backdrop-blur-md sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="bg-sky-500 p-2 rounded-lg text-slate-950">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/godot/default.svg" alt="Godot" class="w-6 h-6">
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
class="font-bold text-lg tracking-tight bg-gradient-to-r from-sky-400 to-indigo-400 bg-clip-text text-transparent">Nakama
|
||||
x Godot 4</span>
|
||||
<span class="text-xs block text-slate-500 dark:text-slate-400 font-mono">Production Blueprint</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
||||
<span class="w-1.5 h-1.5 mr-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
Production Ready
|
||||
</span>
|
||||
|
||||
<button id="theme-toggle" class="flex items-center justify-center p-2 rounded-lg text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:text-slate-600 dark:text-slate-400 dark:hover:bg-slate-200 dark:bg-slate-800 transition">
|
||||
<i data-lucide="moon" class="w-5 h-5 hidden dark:block"></i>
|
||||
<i data-lucide="sun" class="w-5 h-5 block dark:hidden"></i>
|
||||
</button>
|
||||
<button onclick="window.print()"
|
||||
class="flex items-center space-x-2 bg-slate-200 dark:bg-slate-800 hover:bg-slate-300 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 px-3.5 py-1.5 rounded-lg text-sm font-semibold transition border border-slate-700">
|
||||
<i data-lucide="printer" class="w-4 h-4"></i>
|
||||
<span>Export / Print</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secondary Navigation Bar -->
|
||||
<div class="border-t border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 hidden md:block">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<nav class="flex space-x-8 overflow-x-auto py-3 text-xs font-medium text-slate-600 dark:text-slate-400">
|
||||
<a href="#infrastructure" class="hover:text-sky-500 dark:hover:text-sky-400 whitespace-nowrap transition">1. Regional Infrastructure</a>
|
||||
<a href="#storefront" class="hover:text-sky-500 dark:hover:text-sky-400 whitespace-nowrap transition">2. Storefront Commissions</a>
|
||||
<a href="#monetization" class="hover:text-sky-500 dark:hover:text-sky-400 whitespace-nowrap transition">3. Monetization Architecture</a>
|
||||
<a href="#architecture" class="hover:text-sky-500 dark:hover:text-sky-400 whitespace-nowrap transition">4. Core Architecture</a>
|
||||
<a href="#priority-board" class="hover:text-sky-500 dark:hover:text-sky-400 whitespace-nowrap transition">5. Priority Board</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
|
||||
<!-- Hero Section / Title -->
|
||||
<div class="mb-12 border-b border-slate-200 dark:border-slate-800 pb-8">
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-slate-900 dark:text-white tracking-tight leading-none mb-4">
|
||||
Knowledge Base & Production Deployment Blueprint
|
||||
</h1>
|
||||
<p class="text-slate-600 dark:text-slate-400 text-lg max-w-4xl">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-8">
|
||||
<div class="bg-white dark:bg-slate-950 p-4 rounded-xl border border-slate-800 flex items-start space-x-3">
|
||||
<div class="text-sky-400 p-2 bg-sky-500/5 rounded-lg"><i data-lucide="globe" class="w-5 h-5"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Core Jurisdictions
|
||||
</h4>
|
||||
<div class="flex items-center space-x-1.5 mt-1">
|
||||
<span class="fi fi-eu" title="European Union"></span>
|
||||
<span class="fi fi-cn" title="China"></span>
|
||||
<span class="fi fi-hk" title="Hong Kong"></span>
|
||||
<span class="fi fi-sg" title="Singapore"></span>
|
||||
<span class="fi fi-jp" title="Japan"></span>
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 ml-1">Europe, Mainland China Transit & APAC
|
||||
Networks</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-950 p-4 rounded-xl border border-slate-800 flex items-start space-x-3">
|
||||
<div class="text-emerald-400 p-2 bg-emerald-500/5 rounded-lg"><i data-lucide="credit-card"
|
||||
class="w-5 h-5"></i></div>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">IAP Validation</h4>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Asynchronous, Server-to-Server, Ledger-Signed
|
||||
Receipt Verification</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-slate-950 p-4 rounded-xl border border-slate-800 flex items-start space-x-3">
|
||||
<div class="text-indigo-400 p-2 bg-indigo-500/5 rounded-lg"><i data-lucide="shield-check"
|
||||
class="w-5 h-5"></i></div>
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Store Integration</h4>
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Steamworks, Google Play, Apple App Store, TapSDK
|
||||
(Zero-Commission)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 1: Regional Infrastructures & Compliance Mapping -->
|
||||
<section id="infrastructure" class="mb-16 scroll-mt-28">
|
||||
<div class="flex items-center space-x-2 mb-6">
|
||||
<i data-lucide="map" class="text-sky-500 w-6 h-6"></i>
|
||||
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">1. Regional Infrastructure & Regulatory Compliance</h2>
|
||||
</div>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
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).
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Europe card -->
|
||||
<div class="bg-white dark:bg-slate-950 border border-slate-800 rounded-xl p-6 hover:border-slate-300 dark:hover:border-slate-700 transition">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="fi fi-eu w-5 h-5 rounded-sm"></span>
|
||||
<span
|
||||
class="text-xs font-mono px-2.5 py-1 rounded bg-sky-500/10 text-sky-400 border border-sky-500/20 font-semibold uppercase">Europe
|
||||
(EU) - Webdock</span>
|
||||
</div>
|
||||
<i data-lucide="shield" class="text-sky-400 w-5 h-5"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-slate-800 dark:text-slate-200 mb-3">GDPR & DMA Pipeline</h3>
|
||||
<ul class="space-y-2.5 text-sm text-slate-600 dark:text-slate-400">
|
||||
<li class="flex items-start"><i data-lucide="check-circle-2"
|
||||
class="text-emerald-500 w-4 h-4 mr-2 mt-0.5 shrink-0"></i> <span><strong>Central
|
||||
Cluster:</strong> Host on high-density <strong>Webdock.io</strong> Ryzen-powered VPS
|
||||
profiles located in Frankfurt or Vienna.</span></li>
|
||||
<li class="flex items-start"><i data-lucide="check-circle-2"
|
||||
class="text-emerald-500 w-4 h-4 mr-2 mt-0.5 shrink-0"></i>
|
||||
<span><strong>Sovereignty:</strong> Webdock offers EU-owned infrastructure with strict
|
||||
hardware insulation and zero overseas sub-processor data leaks.</span></li>
|
||||
<li class="flex items-start"><i data-lucide="check-circle-2"
|
||||
class="text-emerald-500 w-4 h-4 mr-2 mt-0.5 shrink-0"></i> <span><strong>User
|
||||
Control:</strong> Integrate explicit game-level telemetry opt-out flags which halt
|
||||
outgoing Nakama analytics scripts instantly.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- China card (Repositioned to reflect China Transit priority routing) -->
|
||||
<div class="bg-white dark:bg-slate-950 border border-slate-800 rounded-xl p-6 hover:border-slate-300 dark:hover:border-slate-700 transition">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="fi fi-cn w-5 h-5 rounded-sm"></span>
|
||||
<span class="fi fi-hk w-5 h-5 rounded-sm"></span>
|
||||
<span
|
||||
class="text-xs font-mono px-2.5 py-1 rounded bg-rose-500/10 text-rose-400 border border-rose-500/20 font-semibold uppercase">China
|
||||
Transit - HostHatch</span>
|
||||
</div>
|
||||
<i data-lucide="lock" class="text-rose-400 w-5 h-5"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-slate-800 dark:text-slate-200 mb-3">HostHatch HK Edge Gateway</h3>
|
||||
<ul class="space-y-2.5 text-sm text-slate-600 dark:text-slate-400">
|
||||
<li class="flex items-start"><i data-lucide="alert-triangle"
|
||||
class="text-amber-500 w-4 h-4 mr-2 mt-0.5 shrink-0"></i> <span><strong>GFW
|
||||
Proximity:</strong> Deploy regional proxy logic to <strong>HostHatch.com</strong>
|
||||
Hong Kong nodes, featuring direct low-latency peering tunnels.</span></li>
|
||||
<li class="flex items-start"><i data-lucide="alert-triangle"
|
||||
class="text-amber-500 w-4 h-4 mr-2 mt-0.5 shrink-0"></i> <span><strong>Regulatory
|
||||
Separation:</strong> Standalone DB instances keep Mainland China data segregated
|
||||
from Western clusters while resolving network hops near the target audience.</span></li>
|
||||
<li class="flex items-start"><i data-lucide="alert-triangle"
|
||||
class="text-amber-500 w-4 h-4 mr-2 mt-0.5 shrink-0"></i> <span><strong>NPPA
|
||||
Integrations:</strong> Route localized traffic from HK edge to Chinese validation
|
||||
and anti-addiction registry backends efficiently.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Asia Ex-CN card (Now following CN, handling overflow and APAC routing) -->
|
||||
<div class="bg-white dark:bg-slate-950 border border-slate-800 rounded-xl p-6 hover:border-slate-300 dark:hover:border-slate-700 transition">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="fi fi-sg w-5 h-5 rounded-sm"></span>
|
||||
<span class="fi fi-jp w-5 h-5 rounded-sm"></span>
|
||||
<span class="fi fi-kr w-5 h-5 rounded-sm"></span>
|
||||
<span
|
||||
class="text-xs font-mono px-2.5 py-1 rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-semibold uppercase">Asia
|
||||
(Ex-CN) - Following CN</span>
|
||||
</div>
|
||||
<i data-lucide="zap" class="text-emerald-400 w-5 h-5"></i>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-slate-800 dark:text-slate-200 mb-3">APAC Edge Arrays</h3>
|
||||
<ul class="space-y-2.5 text-sm text-slate-600 dark:text-slate-400">
|
||||
<li class="flex items-start"><i data-lucide="check-circle-2"
|
||||
class="text-emerald-500 w-4 h-4 mr-2 mt-0.5 shrink-0"></i> <span><strong>Transit
|
||||
Integration:</strong> Set up edge nodes in Tokyo, Seoul, and Singapore configured to
|
||||
capture traffic spills when cross-border HK connections are saturated.</span></li>
|
||||
<li class="flex items-start"><i data-lucide="check-circle-2"
|
||||
class="text-emerald-500 w-4 h-4 mr-2 mt-0.5 shrink-0"></i> <span><strong>Latency
|
||||
Optimization:</strong> Run high-performance CockroachDB clusters to maintain global
|
||||
synchronization while serving nearby regional users under 45ms.</span></li>
|
||||
<li class="flex items-start"><i data-lucide="check-circle-2"
|
||||
class="text-emerald-500 w-4 h-4 mr-2 mt-0.5 shrink-0"></i> <span><strong>Data
|
||||
Privacy:</strong> Satisfy local guidelines (Japan's APPI, South Korea's PIPA) using
|
||||
explicit user data deletion interfaces inside Nakama profile routes.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning Callout on China Firewall -->
|
||||
<div class="mt-6 bg-amber-500/5 border border-amber-500/20 rounded-xl p-4 flex items-start space-x-3">
|
||||
<i data-lucide="alert-circle" class="text-amber-500 w-5 h-5 shrink-0 mt-0.5"></i>
|
||||
<div class="text-sm text-amber-300">
|
||||
<strong class="font-semibold block">Critical GFW Operational Warning:</strong>
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 2: Financial Strategy, Licenses, & Commissions -->
|
||||
<section id="storefront" class="mb-16 scroll-mt-28">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="dollar-sign" class="text-emerald-500 w-6 h-6"></i>
|
||||
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">2. Storefront Commissions & Licensing Pipeline</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
Operating margins rely on optimizing each storefront's fee parameters. Before collecting gross revenue,
|
||||
publishers must clear the initial platform entry fees.
|
||||
</p>
|
||||
|
||||
<!-- Upfront Fee Settlement Grid -->
|
||||
<div class="mb-8 bg-white dark:bg-slate-950 border border-slate-800 rounded-xl p-6">
|
||||
<h3 class="text-lg font-bold text-slate-800 dark:text-slate-200 mb-4 flex items-center">
|
||||
<i data-lucide="wallet" class="text-sky-400 w-5 h-5 mr-2"></i>
|
||||
Initial Publishing Costs & First Fee Settlement Grid
|
||||
</h3>
|
||||
<p class="text-slate-600 dark:text-slate-400 text-sm mb-6">
|
||||
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.
|
||||
</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm text-slate-700 dark:text-slate-300">
|
||||
<thead class="bg-slate-50 dark:bg-slate-900 text-slate-600 dark:text-slate-400 text-xs font-mono uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="p-4 border-b border-slate-200 dark:border-slate-800">Platform</th>
|
||||
<th class="p-4 border-b border-slate-200 dark:border-slate-800">First Fee Amount</th>
|
||||
<th class="p-4 border-b border-slate-200 dark:border-slate-800 font-semibold text-center">Market Flag</th>
|
||||
<th class="p-4 border-b border-slate-200 dark:border-slate-800">Fee Recurrence Type</th>
|
||||
<th class="p-4 border-b border-slate-200 dark:border-slate-800">Refundability Status</th>
|
||||
<th class="p-4 border-b border-slate-200 dark:border-slate-800">Settlement Methods</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-800">
|
||||
<!-- Steam Row -->
|
||||
<tr class="hover:bg-slate-50 dark:bg-slate-900/40">
|
||||
<td class="p-4 font-semibold text-slate-900 dark:text-white">Steamworks (PC)</td>
|
||||
<td class="p-4 font-mono text-emerald-400">$100.00 USD <span
|
||||
class="text-slate-500 dark:text-slate-400 text-xs">(per product)</span></td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="fi fi-us" title="United States / Global"></span>
|
||||
</td>
|
||||
<td class="p-4">One-time per App Slot</td>
|
||||
<td class="p-4"><span class="text-emerald-400 font-semibold">Yes</span> <span
|
||||
class="text-xs text-slate-500 dark:text-slate-400">(Refunded after $1,000 in gross sales)</span>
|
||||
</td>
|
||||
<td class="p-4 text-xs">Credit Card, PayPal, Steam Wallet, Wire Transfer</td>
|
||||
</tr>
|
||||
<!-- Itch Row -->
|
||||
<tr class="hover:bg-slate-50 dark:bg-slate-900/40">
|
||||
<td class="p-4 font-semibold text-slate-900 dark:text-white">Itch.io (PC)</td>
|
||||
<td class="p-4 font-mono text-slate-600 dark:text-slate-400">$0.00 USD <span
|
||||
class="text-slate-500 dark:text-slate-400 text-xs">(Zero entry fee)</span></td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="fi fi-un" title="International / Open"></span>
|
||||
</td>
|
||||
<td class="p-4">None</td>
|
||||
<td class="p-4 text-slate-500 dark:text-slate-400">Not Applicable</td>
|
||||
<td class="p-4 text-xs">None (Optional tax documentation verification)</td>
|
||||
</tr>
|
||||
<!-- Google Row -->
|
||||
<tr class="hover:bg-slate-50 dark:bg-slate-900/40">
|
||||
<td class="p-4 font-semibold text-slate-900 dark:text-white">Google Play</td>
|
||||
<td class="p-4 font-mono text-emerald-400">$25.00 USD</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="fi fi-us" title="United States / Global"></span>
|
||||
</td>
|
||||
<td class="p-4">One-time per Developer Identity</td>
|
||||
<td class="p-4 text-rose-400 font-semibold">No</td>
|
||||
<td class="p-4 text-xs">Credit/Debit Card (requires Google Pay Profile)</td>
|
||||
</tr>
|
||||
<!-- Apple Row -->
|
||||
<tr class="hover:bg-slate-50 dark:bg-slate-900/40">
|
||||
<td class="p-4 font-semibold text-slate-900 dark:text-white">Apple App Store</td>
|
||||
<td class="p-4 font-mono text-emerald-400">$99.00 USD</td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="fi fi-us" title="United States / Global"></span>
|
||||
</td>
|
||||
<td class="p-4">Annual Subscription Renewal</td>
|
||||
<td class="p-4 text-rose-400 font-semibold">No</td>
|
||||
<td class="p-4 text-xs">Credit Card (linked to Apple ID Developer Account)</td>
|
||||
</tr>
|
||||
<!-- TapTap Row -->
|
||||
<tr class="hover:bg-slate-50 dark:bg-slate-900/40">
|
||||
<td class="p-4 font-semibold text-slate-900 dark:text-white">TapTap Developer</td>
|
||||
<td class="p-4 font-mono text-slate-600 dark:text-slate-400">$0.00 USD <span
|
||||
class="text-slate-500 dark:text-slate-400 text-xs">(Corporate validation)</span></td>
|
||||
<td class="p-4 text-center">
|
||||
<span class="fi fi-cn mr-0.5" title="China"></span>
|
||||
<span class="fi fi-sg" title="Singapore / APAC"></span>
|
||||
</td>
|
||||
<td class="p-4">None</td>
|
||||
<td class="p-4 text-slate-500 dark:text-slate-400">Not Applicable</td>
|
||||
<td class="p-4 text-xs">Requires legal corporate identity / ICP verification</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-950 border border-slate-800 rounded-xl p-6 mb-8 text-center">
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400 uppercase tracking-widest font-mono">Net Yield Production
|
||||
Formula</span>
|
||||
<div class="text-2xl sm:text-3xl font-mono text-sky-400 my-3 select-all">
|
||||
R_net = R_gross × (1 - C_store - T_tax) - F_fees
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 max-w-xl mx-auto">
|
||||
Where <span class="text-sky-400">C_store</span> matches the target store platform commission, <span
|
||||
class="text-sky-400">T_tax</span> matches regional withholding taxes, and <span
|
||||
class="text-sky-400">F_fees</span> encapsulates external server transaction margins and API
|
||||
query operations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform Detail Tabs -->
|
||||
<div class="bg-white dark:bg-slate-950 border border-slate-800 rounded-xl overflow-hidden">
|
||||
<div class="border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/40 p-1 flex overflow-x-auto scrollbar-none"
|
||||
id="store-tabs">
|
||||
<button onclick="switchTab('steam')"
|
||||
class="tab-btn active px-4 py-2 text-sm font-semibold rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:text-white transition whitespace-nowrap bg-slate-200 dark:bg-slate-800 text-slate-900 dark:text-white flex items-center"
|
||||
id="tab-steam">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/steam/default.svg" class="w-4 h-4 mr-1.5" alt="Steam">Steam (PC)
|
||||
</button>
|
||||
<button onclick="switchTab('itch')"
|
||||
class="tab-btn px-4 py-2 text-sm font-semibold rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:text-white transition whitespace-nowrap flex items-center"
|
||||
id="tab-itch">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/itchdotio/default.svg" class="w-4 h-4 mr-1.5 invert" alt="Itch.io">Itch.io (PC)
|
||||
</button>
|
||||
<button onclick="switchTab('google')"
|
||||
class="tab-btn px-4 py-2 text-sm font-semibold rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:text-white transition whitespace-nowrap flex items-center"
|
||||
id="tab-google">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/google-play/default.svg" class="w-4 h-4 mr-1.5" alt="Google Play">Google Play
|
||||
</button>
|
||||
<button onclick="switchTab('apple')"
|
||||
class="tab-btn px-4 py-2 text-sm font-semibold rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:text-white transition whitespace-nowrap flex items-center"
|
||||
id="tab-apple">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/apple/default.svg" class="w-4 h-4 mr-1.5 invert opacity-70" alt="Apple">App Store (iOS)
|
||||
</button>
|
||||
<button onclick="switchTab('taptap')"
|
||||
class="tab-btn px-4 py-2 text-sm font-semibold rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:text-white transition whitespace-nowrap flex items-center"
|
||||
id="tab-taptap">
|
||||
<span class="fi fi-cn mr-1"></span><span class="fi fi-sg mr-1.5"></span>TapTap (Mobile)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Steam Tab Content -->
|
||||
<div id="content-steam" class="tab-content">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-4">Steamworks Implementation Matrix</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Commission Rate</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">30% (Default)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Volume Scaling</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">25% at $10M | 20% at $50M</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">First Fee</td>
|
||||
<td class="py-2 text-emerald-400 font-mono text-right">$100 USD (Steam Direct
|
||||
App Deposit)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Nakama Verification</td>
|
||||
<td class="py-2 text-emerald-400 font-mono text-right">Server Authenticated
|
||||
Session Ticket</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-900/40 p-5 rounded-lg border border-slate-800">
|
||||
<h4 class="text-xs font-semibold tracking-wider text-slate-500 dark:text-slate-400 uppercase mb-2">Technical
|
||||
Execution</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed mb-3">
|
||||
Initialize <code class="text-sky-400 font-mono text-xs">GodotSteam</code>
|
||||
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.
|
||||
</p>
|
||||
<span class="text-xs font-mono text-slate-500 dark:text-slate-400 block">Endpoint:
|
||||
client.authenticate_steam_async(ticket)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Itch Tab Content -->
|
||||
<div id="content-itch" class="tab-content hidden">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-4">Itch.io Standalone Blueprint</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Commission Rate</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">10% (Open Slider to 0%)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">First Fee</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">$0.00 USD (Completely Open
|
||||
Portal)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Target Core Assets</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">No External Platform Hooks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Nakama Verification</td>
|
||||
<td class="py-2 text-emerald-400 font-mono text-right">Device Unique ID /
|
||||
Hardware Hash</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-900/40 p-5 rounded-lg border border-slate-800">
|
||||
<h4 class="text-xs font-semibold tracking-wider text-slate-500 dark:text-slate-400 uppercase mb-2">Technical
|
||||
Execution</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed mb-3">
|
||||
Itch standalone builds must bypass Steam libraries completely. Query the hardware
|
||||
hash utilizing Godot's <code
|
||||
class="text-sky-400 font-mono text-xs">OS.get_unique_id()</code> call. Combine
|
||||
with custom email authentication options inside your client UI for stable user
|
||||
recovery.
|
||||
</p>
|
||||
<span class="text-xs font-mono text-slate-500 dark:text-slate-400 block">Endpoint:
|
||||
client.authenticate_device_async(id)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Google Play Tab Content -->
|
||||
<div id="content-google" class="tab-content hidden">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-4">Google Play Commerce Setup</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Commission Rate</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">15% on first $1M USD annually
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Standard Tier Rate</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">30% (If annual earnings exceed
|
||||
$1M)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">First Fee</td>
|
||||
<td class="py-2 text-emerald-400 font-mono text-right">$25 USD (One-time Console
|
||||
Setup)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Nakama Verification</td>
|
||||
<td class="py-2 text-emerald-400 font-mono text-right">Google Play Games Server
|
||||
Auth Code</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-900/40 p-5 rounded-lg border border-slate-800">
|
||||
<h4 class="text-xs font-semibold tracking-wider text-slate-500 dark:text-slate-400 uppercase mb-2">Technical
|
||||
Execution</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed mb-3">
|
||||
Integrate the Google Play Games GDExtension inside Godot. Retrieve the user's secure
|
||||
authentication code on startup and transfer it to the Nakama server backend to
|
||||
provision accounts asynchronously.
|
||||
</p>
|
||||
<span class="text-xs font-mono text-slate-500 dark:text-slate-400 block">Endpoint:
|
||||
client.authenticate_google_async(token)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apple Tab Content -->
|
||||
<div id="content-apple" class="tab-content hidden">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-4">Apple App Store Integration</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Commission Rate</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">15% via Small Business Program
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Standard Tier Rate</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">30% (Standard Portfolio
|
||||
Account)</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">First Fee</td>
|
||||
<td class="py-2 text-emerald-400 font-mono text-right">$99 USD (Annual
|
||||
Registration Program)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Nakama Verification</td>
|
||||
<td class="py-2 text-emerald-400 font-mono text-right">Apple Identity Token or
|
||||
Game Center</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-900/40 p-5 rounded-lg border border-slate-800">
|
||||
<h4 class="text-xs font-semibold tracking-wider text-slate-500 dark:text-slate-400 uppercase mb-2">Technical
|
||||
Execution</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed mb-3">
|
||||
Process authentication loops using Apple ID identity payloads via Sign In with
|
||||
Apple. For Game Center, extract authentication signatures, timestamps, and salt
|
||||
packets to execute secure matching processes.
|
||||
</p>
|
||||
<span class="text-xs font-mono text-slate-500 dark:text-slate-400 block">Endpoint:
|
||||
client.authenticate_apple_async(token)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TapTap Tab Content -->
|
||||
<div id="content-taptap" class="tab-content hidden">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-4">TapTap Developer Services Strategy</h3>
|
||||
<table class="w-full text-sm">
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Commission Rate</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">0% (Completely Commission-Free)
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Target Core Markets</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">Mainland China & SEA Mobile
|
||||
Communities</td>
|
||||
</tr>
|
||||
<tr class="border-b border-slate-200 dark:border-slate-800">
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">First Fee</td>
|
||||
<td class="py-2 text-slate-900 dark:text-white font-mono text-right">$0.00 USD (Entity verification
|
||||
required)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-2 text-slate-500 dark:text-slate-400">Nakama Verification</td>
|
||||
<td class="py-2 text-emerald-400 font-mono text-right">Custom Auth (via TapTap
|
||||
OAuth Token)</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-900/40 p-5 rounded-lg border border-slate-800">
|
||||
<h4 class="text-xs font-semibold tracking-wider text-slate-500 dark:text-slate-400 uppercase mb-2">Technical
|
||||
Execution</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed mb-3">
|
||||
TapTap operates without a direct native Nakama SDK connector. Retrieve the user's
|
||||
OAuth access token using the TapSDK inside Godot. Execute authentication by passing
|
||||
the token directly to Nakama's custom endpoint, routing background network
|
||||
validations securely.
|
||||
</p>
|
||||
<span class="text-xs font-mono text-slate-500 dark:text-slate-400 block">Endpoint:
|
||||
client.authenticate_custom_async(tap_token)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 3: Secure Server-to-Server Purchase Validation -->
|
||||
<section id="monetization" class="mb-16 scroll-mt-28">
|
||||
<div class="flex items-center space-x-2 mb-6">
|
||||
<i data-lucide="shield-alert" class="text-sky-500 w-6 h-6"></i>
|
||||
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">3. Monetization Architecture & Secure IAP Loop</h2>
|
||||
</div>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<!-- Sequence Layout -->
|
||||
<div class="bg-white dark:bg-slate-950 border border-slate-800 rounded-xl p-6 lg:p-8">
|
||||
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-6 flex items-center"><i data-lucide="git-branch"
|
||||
class="text-sky-400 w-5 h-5 mr-2"></i> Asynchronous Verification Topology</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 relative">
|
||||
<!-- Step 1 -->
|
||||
<div class="bg-slate-50 dark:bg-slate-900/50 p-5 rounded-lg border border-slate-800 relative">
|
||||
<div
|
||||
class="absolute -top-3 -left-3 w-8 h-8 rounded-full bg-sky-500 text-slate-950 flex items-center justify-center font-bold text-sm">
|
||||
1</div>
|
||||
<h4 class="text-sm font-semibold text-slate-800 dark:text-slate-200 mt-2 mb-2">Purchase & Tokenization</h4>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-400">The Godot client requests checkout. The player submits payment
|
||||
to the store network, which issues an encrypted, signed platform transaction token.</p>
|
||||
</div>
|
||||
<!-- Step 2 -->
|
||||
<div class="bg-slate-50 dark:bg-slate-900/50 p-5 rounded-lg border border-slate-800 relative">
|
||||
<div
|
||||
class="absolute -top-3 -left-3 w-8 h-8 rounded-full bg-sky-500 text-slate-950 flex items-center justify-center font-bold text-sm">
|
||||
2</div>
|
||||
<h4 class="text-sm font-semibold text-slate-800 dark:text-slate-200 mt-2 mb-2">Nakama RPC Ingestion</h4>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-400">The client forwards the raw transaction token securely to
|
||||
Nakama via an RPC function call: <code class="text-sky-400">verify_purchase</code>.</p>
|
||||
</div>
|
||||
<!-- Step 3 -->
|
||||
<div class="bg-slate-50 dark:bg-slate-900/50 p-5 rounded-lg border border-slate-800 relative">
|
||||
<div
|
||||
class="absolute -top-3 -left-3 w-8 h-8 rounded-full bg-sky-500 text-slate-950 flex items-center justify-center font-bold text-sm">
|
||||
3</div>
|
||||
<h4 class="text-sm font-semibold text-slate-800 dark:text-slate-200 mt-2 mb-2">Server Validation Check</h4>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-400">Nakama blocks immediate user manipulation. It connects
|
||||
server-to-server with Google, Apple, or Steam APIs to verify status and signatures.</p>
|
||||
</div>
|
||||
<!-- Step 4 -->
|
||||
<div class="bg-slate-50 dark:bg-slate-900/50 p-5 rounded-lg border border-slate-800 relative">
|
||||
<div
|
||||
class="absolute -top-3 -left-3 w-8 h-8 rounded-full bg-emerald-500 text-slate-950 flex items-center justify-center font-bold text-sm">
|
||||
4</div>
|
||||
<h4 class="text-sm font-semibold text-slate-800 dark:text-slate-200 mt-2 mb-2">Ledger Provisioning</h4>
|
||||
<p class="text-xs text-slate-600 dark:text-slate-400">Upon verification, Nakama updates the persistent storage
|
||||
wallet data and broadcasts confirmation back to the Godot client.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Section 4: Architecture Autoload Script Blueprint -->
|
||||
<section id="architecture" class="mb-16 scroll-mt-28">
|
||||
<div class="flex items-center space-x-2 mb-6">
|
||||
<i data-lucide="network" class="text-sky-500 w-6 h-6"></i>
|
||||
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">4. Core Architecture: Unified Identity Manager Decision Flow
|
||||
Chart</h2>
|
||||
</div>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<!-- Dynamic Decision Flowchart Container -->
|
||||
<div class="bg-white dark:bg-slate-950 border border-slate-800 rounded-xl p-6 lg:p-8">
|
||||
<!-- Interactive Controls -->
|
||||
<div
|
||||
class="flex flex-wrap gap-2 mb-8 bg-slate-50 dark:bg-slate-900 p-1.5 rounded-xl border border-slate-800 max-w-2xl mx-auto justify-center">
|
||||
<button onclick="highlightPath('all')"
|
||||
class="path-ctrl-btn active px-4 py-2 text-xs font-semibold rounded-lg transition-all duration-300 bg-sky-500 text-slate-950 shadow-md flex items-center justify-center"
|
||||
id="btn-path-all">
|
||||
Show Full Tree
|
||||
</button>
|
||||
<button onclick="highlightPath('steam')"
|
||||
class="path-ctrl-btn px-4 py-2 text-xs font-semibold rounded-lg transition-all duration-300 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:text-white flex items-center justify-center"
|
||||
id="btn-path-steam">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/steam/default.svg" class="w-3.5 h-3.5 mr-1.5" alt="Steam">PC (Steam)
|
||||
</button>
|
||||
<button onclick="highlightPath('android')"
|
||||
class="path-ctrl-btn px-4 py-2 text-xs font-semibold rounded-lg transition-all duration-300 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:text-white flex items-center justify-center"
|
||||
id="btn-path-android">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/google-play/default.svg" class="w-3.5 h-3.5 mr-1.5" alt="Google Play">Android (Google/TapTap)
|
||||
</button>
|
||||
<button onclick="highlightPath('ios')"
|
||||
class="path-ctrl-btn px-4 py-2 text-xs font-semibold rounded-lg transition-all duration-300 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:text-white flex items-center justify-center"
|
||||
id="btn-path-ios">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/apple/default.svg" class="w-3.5 h-3.5 mr-1.5 invert opacity-70" alt="Apple">iOS (Apple/TapTap)
|
||||
</button>
|
||||
<button onclick="highlightPath('fallback')"
|
||||
class="path-ctrl-btn px-4 py-2 text-xs font-semibold rounded-lg transition-all duration-300 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:text-white flex items-center justify-center"
|
||||
id="btn-path-fallback">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/itchdotio/default.svg" class="w-3.5 h-3.5 mr-1.5 invert" alt="Itch.io">Itch.io / Standalone PC
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Flowchart Graph Layout -->
|
||||
<div class="flex flex-col space-y-8 relative">
|
||||
|
||||
<!-- Root Entry Point -->
|
||||
<div class="flex justify-center transition-all duration-300" id="node-root">
|
||||
<div
|
||||
class="bg-slate-50 dark:bg-slate-900 border-2 border-slate-700 hover:border-sky-500 rounded-xl p-4 text-center max-w-xs shadow-lg transition-all duration-300">
|
||||
<div class="text-xs font-mono font-bold text-sky-400 uppercase tracking-widest mb-1">Game
|
||||
Initialization</div>
|
||||
<h4 class="text-sm font-extrabold text-slate-900 dark:text-white">_ready() / dispatch_platform_auth()</h4>
|
||||
<p class="text-xxs text-slate-500 dark:text-slate-400 mt-1">Queries environment architecture and singletons</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connective Vertical Line from Root -->
|
||||
<div class="flex justify-center h-6 -my-4 relative z-0">
|
||||
<div class="w-0.5 bg-slate-200 dark:bg-slate-800 transition-all duration-300" id="line-root"></div>
|
||||
</div>
|
||||
|
||||
<!-- Platform Routing Split Container -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 relative z-10">
|
||||
|
||||
<!-- Steam Branch -->
|
||||
<div class="flex flex-col items-center bg-slate-50 dark:bg-slate-900/40 border border-slate-800 rounded-xl p-4 transition-all duration-300"
|
||||
id="branch-steam">
|
||||
<div class="flex items-center space-x-1.5 mb-3">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/steam/default.svg" class="w-4 h-4" alt="Steam">
|
||||
<span
|
||||
class="text-xxs font-mono font-semibold px-2 py-0.5 rounded bg-sky-500/10 text-sky-400 border border-sky-500/10">PC
|
||||
Steam Target</span>
|
||||
</div>
|
||||
<div class="w-full text-center space-y-3">
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-slate-800 dark:text-slate-200">Class Check</div>
|
||||
<code class="text-xxs text-amber-400">ClassDB.has_singleton("Steam")</code>
|
||||
</div>
|
||||
<div class="text-slate-600 text-center flex justify-center"><i data-lucide="arrow-down"
|
||||
class="w-4 h-4"></i></div>
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-slate-800 dark:text-slate-200">Ticket Retrieval</div>
|
||||
<p class="text-xxs text-slate-600 dark:text-slate-400 mt-0.5">Grabs session hex ticket asynchronously
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-slate-600 text-center flex justify-center"><i data-lucide="arrow-down"
|
||||
class="w-4 h-4"></i></div>
|
||||
<div class="bg-white dark:bg-slate-950 border border-sky-500/30 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-sky-400">Nakama Endpoint</div>
|
||||
<code class="text-xxs text-slate-600 dark:text-slate-400">authenticate_steam_async()</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Android Branch -->
|
||||
<div class="flex flex-col items-center bg-slate-50 dark:bg-slate-900/40 border border-slate-800 rounded-xl p-4 transition-all duration-300"
|
||||
id="branch-android">
|
||||
<div class="flex items-center space-x-1.5 mb-3">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/google-play/default.svg" class="w-4 h-4" alt="Google Play">
|
||||
<span
|
||||
class="text-xxs font-mono font-semibold px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/10">Android
|
||||
OS Target</span>
|
||||
</div>
|
||||
<div class="w-full text-center space-y-3">
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-slate-800 dark:text-slate-200">Feature Gating</div>
|
||||
<code class="text-xxs text-emerald-400">OS.has_feature("taptap")</code>
|
||||
</div>
|
||||
<div class="text-slate-600 text-center flex justify-center flex-row justify-around">
|
||||
<span class="text-xxs text-emerald-500">Yes</span><span
|
||||
class="text-xxs text-sky-500">No</span></div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-1.5 shadow text-xxs">
|
||||
<div class="font-bold text-emerald-400">TapSDK</div>
|
||||
<p class="text-[9px] text-slate-500 dark:text-slate-400 mt-0.5">Fetch OAuth Token</p>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-1.5 shadow text-xxs">
|
||||
<div class="font-bold text-sky-400">Google Play</div>
|
||||
<p class="text-[9px] text-slate-500 dark:text-slate-400 mt-0.5">Fetch Auth Code</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-slate-600 text-center flex justify-center"><i data-lucide="arrow-down"
|
||||
class="w-4 h-4"></i></div>
|
||||
<div class="bg-white dark:bg-slate-950 border border-sky-500/30 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-sky-400">Nakama Endpoint</div>
|
||||
<code
|
||||
class="text-xxs text-slate-600 dark:text-slate-400">authenticate_custom_async()<br>OR<br>authenticate_google_async()</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- iOS Branch -->
|
||||
<div class="flex flex-col items-center bg-slate-50 dark:bg-slate-900/40 border border-slate-800 rounded-xl p-4 transition-all duration-300"
|
||||
id="branch-ios">
|
||||
<div class="flex items-center space-x-1.5 mb-3">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/apple/default.svg" class="w-4 h-4 invert opacity-70" alt="Apple">
|
||||
<span
|
||||
class="text-xxs font-mono font-semibold px-2 py-0.5 rounded bg-violet-500/10 text-violet-400 border border-violet-500/10">iOS
|
||||
OS Target</span>
|
||||
</div>
|
||||
<div class="w-full text-center space-y-3">
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-slate-800 dark:text-slate-200">Feature Gating</div>
|
||||
<code class="text-xxs text-violet-400">OS.has_feature("taptap")</code>
|
||||
</div>
|
||||
<div class="text-slate-600 text-center flex justify-center flex-row justify-around">
|
||||
<span class="text-xxs text-violet-500">Yes</span><span
|
||||
class="text-xxs text-sky-500">No</span></div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-1.5 shadow text-xxs">
|
||||
<div class="font-bold text-violet-400">TapSDK</div>
|
||||
<p class="text-[9px] text-slate-500 dark:text-slate-400 mt-0.5">Fetch OAuth Token</p>
|
||||
</div>
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-1.5 shadow text-xxs">
|
||||
<div class="font-bold text-sky-400">Apple Auth</div>
|
||||
<p class="text-[9px] text-slate-500 dark:text-slate-400 mt-0.5">Fetch Identity Token</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-slate-600 text-center flex justify-center"><i data-lucide="arrow-down"
|
||||
class="w-4 h-4"></i></div>
|
||||
<div class="bg-white dark:bg-slate-950 border border-sky-500/30 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-sky-400">Nakama Endpoint</div>
|
||||
<code
|
||||
class="text-xxs text-slate-600 dark:text-slate-400">authenticate_custom_async()<br>OR<br>authenticate_apple_async()</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback / Itch.io Standalone Branch -->
|
||||
<div class="flex flex-col items-center bg-slate-50 dark:bg-slate-900/40 border border-slate-800 rounded-xl p-4 transition-all duration-300"
|
||||
id="branch-fallback">
|
||||
<div class="flex items-center space-x-1.5 mb-3">
|
||||
<img src="https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/itchdotio/default.svg" class="w-4 h-4 invert" alt="Itch.io">
|
||||
<span
|
||||
class="text-xxs font-mono font-semibold px-2 py-0.5 rounded bg-rose-500/10 text-rose-400 border border-rose-500/10">PC/Itch.io
|
||||
Standalone</span>
|
||||
</div>
|
||||
<div class="w-full text-center space-y-3">
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-slate-800 dark:text-slate-200">Device Hardware ID</div>
|
||||
<code class="text-xxs text-rose-400">OS.get_unique_id()</code>
|
||||
</div>
|
||||
<div class="text-slate-600 text-center flex justify-center"><i data-lucide="arrow-down"
|
||||
class="w-4 h-4"></i></div>
|
||||
<div class="bg-slate-50 dark:bg-slate-900 border border-slate-800 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-slate-800 dark:text-slate-200">Hardware Hashing</div>
|
||||
<p class="text-xxs text-slate-600 dark:text-slate-400 mt-0.5">Generate hardware fingerprint</p>
|
||||
</div>
|
||||
<div class="text-slate-600 text-center flex justify-center"><i data-lucide="arrow-down"
|
||||
class="w-4 h-4"></i></div>
|
||||
<div class="bg-white dark:bg-slate-950 border border-sky-500/30 rounded-lg p-2.5 shadow text-xs">
|
||||
<div class="font-bold text-sky-400">Nakama Endpoint</div>
|
||||
<code class="text-xxs text-slate-600 dark:text-slate-400">authenticate_device_async()</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Connective Vertical Line to Target Server -->
|
||||
<div class="flex justify-center h-6 -my-4 relative z-0">
|
||||
<div class="w-0.5 bg-slate-200 dark:bg-slate-800 transition-all duration-300" id="line-terminal"></div>
|
||||
</div>
|
||||
|
||||
<!-- Unified Target Server Block -->
|
||||
<div class="flex justify-center transition-all duration-300" id="node-terminal">
|
||||
<div
|
||||
class="bg-slate-50 dark:bg-slate-900 border-2 border-emerald-500/60 rounded-xl p-5 text-center max-w-sm shadow-xl transition-all duration-300">
|
||||
<div
|
||||
class="flex items-center justify-center space-x-2 text-emerald-400 text-xs font-mono font-bold uppercase tracking-wider mb-2">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
||||
</span>
|
||||
<span>Nakama Server Response Node</span>
|
||||
</div>
|
||||
<h4 class="text-sm font-extrabold text-slate-900 dark:text-white">NakamaSession Established</h4>
|
||||
<p class="text-xxs text-slate-600 dark:text-slate-400 mt-1.5 leading-relaxed">
|
||||
Session token decoded & validated, persistent profiles resolved, and socket pipelines
|
||||
opened. Global matching/telemetry gates unlocked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
<!-- Section 5: Project Management (PR) Board -->
|
||||
|
||||
<!-- Section 5: Project Management (PR) Board -->
|
||||
<section id="priority-board" class="mb-16 scroll-mt-28">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="kanban-square" class="text-sky-500 w-6 h-6"></i>
|
||||
<h2 class="text-2xl font-bold text-slate-900 dark:text-white">5. Project Management (PR) Board & AI Checklist</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-slate-600 dark:text-slate-400 mb-8">
|
||||
This section serves as a fully detailed tracking board. It merges production readiness, backend reconstruction, gameflow audit, and Steam depot release tasks.
|
||||
<strong>Every task is fully expanded with checklists and automated testing criteria so you can track AI execution seamlessly.</strong>
|
||||
</p>
|
||||
|
||||
<div class="bg-rose-50 dark:bg-rose-500/5 border border-rose-200 dark:border-rose-500/25 rounded-xl p-5 mb-8">
|
||||
<h3 class="text-md font-bold text-rose-700 dark:text-rose-200 mb-3 flex items-center"><i data-lucide="flame" class="w-4 h-4 mr-2"></i>Priority Rule</h3>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Detailed Task List (Expandable Accordions) -->
|
||||
<div class="space-y-4">
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-rose-500/30 bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-300 text-xs font-bold">P0</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-P0-1</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Economy Authority</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
server/nakama/tekton_admin.js, user_profile_manager.gd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Stop trusting client prices, categories, package IDs. Reconstruct server-authoritative economy.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Create server catalog mapping item IDs to category, price, currency type, stack rules.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Change purchase request so client sends only item ID, quantity, and optional idempotency key.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Validate balance and inventory capacity server-side before mutation.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Replace fake currency purchase with receipt verification placeholder interface per platform.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Write wallet/inventory mutation audit entry with user ID, request ID, before/after values.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-P0-1">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-P0-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-P0-1">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-P0-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-rose-500/30 bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-300 text-xs font-bold">P0</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-P0-2</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Gacha Authority</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
gacha_manager.gd, Nakama economy RPCs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Move RNG, pity, cost consume, and rewards server-side.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add server RPC for gacha pull with banner ID, pull count, and idempotency key.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Store pity and banner state server-side.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Server consumes cost, rolls reward, writes item/fragment result, and returns canonical result.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Client only animates returned result; no local grant or deduction.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add migration note for existing local pity/fragment data.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-P0-2">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-P0-2').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Trigger `rpcGachaPull`. Assert client currency deduction happens only upon server response. Assert client cannot specify reward or manipulate RNG seed.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-P0-2">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-P0-2').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-rose-500/30 bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-300 text-xs font-bold">P0</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-P0-3</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Auth & Secrets Lock</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
auth_manager.gd, nakama_manager.gd, project.godot
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Remove insecure Steam fallback, default App ID 480, hardcoded release secrets.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Replace production Steam App ID placeholder only when real ID exists.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Fail hard in Steam build if Steam ticket cannot be acquired.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Remove fallback email/custom auth from Steam release path.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Externalize server host, scheme, key, encryption key, and secrets.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Delete or environment-gate admin topup RPC and admin UI entry points.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-P0-3">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-P0-3').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-P0-3">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-P0-3').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-rose-500/30 bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-300 text-xs font-bold">P0</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-P0-4</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Backend Deploy Safety</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
server/, Nakama runtime module
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Replace manual module copy/restart with staging/prod deploy, health check, rollback.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Separate dev/staging/prod Nakama config and secrets.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Script module package/copy/restart with version label.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add health check RPC after deploy.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Keep previous module artifact for rollback.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add smoke checklist: auth, profile, shop, mail, gacha, friends, leaderboard.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-P0-4">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-P0-4').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Run deploy script. Assert Nakama server restarts without losing data. Trigger health check RPC to verify new module loaded successfully.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-P0-4">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-P0-4').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-rose-500/30 bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-300 text-xs font-bold">P0</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-GF-P0-1</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Spawn/Sync Authority Lock</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
main.gd, player.gd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Retain: deterministic pre-spawn sync. Remove: client-trusted teleport/update paths.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Keep deterministic pre-spawn strategy (client pre-creates lobby roster).</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Remove client-trusted position mutation path that can move authoritative state without server validation.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Introduce server-owned spawn_revision and state_revision integers.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Reject stale updates on client and server.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Ensure reconnect flow requests full player sync, then grid sync, then mode-specific sync.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-GF-P0-1">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-GF-P0-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-GF-P0-1">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-GF-P0-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-rose-500/30 bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-300 text-xs font-bold">P0</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-GF-P0-2</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Lobby Start Gate Hardening</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
lobby.gd, lobby_manager.gd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Add preflight checklist RPC, check ready-state and host authority.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Preserve LAN/Nakama dual-mode behavior and tutorial fast path.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add preflight readiness checks before _on_game_starting transition.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Verify session valid, host authority true, all player records present, mode config validated.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add one typed preflight result object and render actionable errors.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-GF-P0-2">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-GF-P0-2').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Attempt to start match without full player records. Assert UI blocks start and shows specific error string from preflight check.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-GF-P0-2">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-GF-P0-2').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-rose-500/30 bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-300 text-xs font-bold">P0</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-GF-P0-3</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">RPC Sender Identity & Contract Clamp</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
main.gd, player.gd, lobby_manager.gd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Remove payload fields that claim identity. Validate sender natively.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Read all any_peer RPC entry points.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Remove payload fields that pretend to identify requester/authority (use get_remote_sender_id).</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Verify sender identity and authority explicitly for state-mutation RPCs.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Normalize RPC contracts to carry stable error codes.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-GF-P0-3">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-GF-P0-3').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-GF-P0-3">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-GF-P0-3').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-rose-500/30 bg-rose-50 dark:bg-rose-500/10 text-rose-600 dark:text-rose-300 text-xs font-bold">P0</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-GF-P0-4</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Chat/DM Abuse Control</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
lobby.gd, Nakama chat
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Add moderation, throttling, sanitation, flood guard, and permission matrix.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Keep current channel UX, DM tabs, and history pull.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add per-user send cooldown and max payload length limits.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add command permission matrix (/clear admin only, all other slash commands explicit).</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Mark unsent/failed messages in UI with retry policy.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-GF-P0-4">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-GF-P0-4').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">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.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-GF-P0-4">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-GF-P0-4').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-300 text-xs font-bold">P1</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-P1-1</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Module Split & RPC Validation</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
tekton_admin.js
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Split monolith into auth, economy, admin, mail, social, leaderboard, validation helpers.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Refactor tekton_admin.js into domain modules without changing external RPC names.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Create modules for auth, economy, admin, mail, social, leaderboard, storage, validation.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add central validators for payload shape, types, limits.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add normalized error responses with stable error codes.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-P1-1">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-P1-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Call split RPC with missing payload fields. Assert central validator catches it and returns `INVALID_ARGUMENT` standard error code.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-P1-1">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-P1-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-300 text-xs font-bold">P1</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-P1-2</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Ledger, Idempotency & Storage Model</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
Wallet, inventory, fragments, mail rewards
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Add mutation audit ledger, idempotency keys, and canonical fragment storage path.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Define one canonical fragment storage location and migration path.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add idempotency keys for mail claim, daily reward, purchase, gacha, and admin adjustments.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add audit records with source, user_id, mutation type, request_id.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Make mail claim transactional (claim, mark, return canonical state).</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-P1-2">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-P1-2').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Send identical mail claim RPC twice simultaneously. Assert only one processes successfully and the second returns 'already claimed'.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-P1-2">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-P1-2').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-300 text-xs font-bold">P1</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-P1-3</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Client Backend Facade</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
nakama_manager.gd, auth_manager.gd, backend_service.gd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Make one typed backend owner for session, socket, RPC calls, and central errors.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Decide whether BackendService becomes the sole typed backend facade or is deleted.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Implement one owner for client/session/socket.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add typed methods for RPCs, central error handling.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Remove direct UI RPC scatter for economy/auth/mail/gacha/social flows.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-P1-3">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-P1-3').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Global search for `client.rpc_async` in `scripts/ui/`. Assert 0 results found (all go through facade).</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-P1-3">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-P1-3').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-300 text-xs font-bold">P1</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-GF-P1-1</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Tutorial Isolation Contract</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
tutorial_manager.gd
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Remove multiplayer-side effects during pause/freeze phases. Isolate tutorial boundaries.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Keep onboarding sequence and camera storytelling.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Enforce contract: no persistent wallet/profile mutation during tutorial.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Ensure no shared lobby state leakage.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Ensure clean bot/timer restore on exit, deterministic return-to-lobby handshake.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Replace broad pause/freeze side effects with scoped tutorial-state toggles.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-GF-P1-1">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-GF-P1-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Abort tutorial midway. Assert main game tree is fully unpaused, bots are reset, and no 'tutorial_active' flags leak into lobby.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-GF-P1-1">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-GF-P1-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-300 text-xs font-bold">P1</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-GF-P1-2</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Mode Config Completeness</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
main.gd, lobby mode configs
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Remove duplicated/inconsistent option toggles. Add schema-driven validation.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Keep existing Stop n Go custom UI.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Remove duplicated/fragile control toggles.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Implement Tekton Doors options with same host-authoritative lock and sync callbacks.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Introduce schema-driven config validation shared by host, client, and bootstrap.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-GF-P1-2">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-GF-P1-2').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Join as client, attempt to spoof mode config RPC. Assert host rejects invalid mode config changes and overrides client.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-GF-P1-2">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-GF-P1-2').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-300 text-xs font-bold">P1</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-GF-P1-3</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Backend Facade & Flow Decoupling</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
backend_service.gd, UI panels
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Improve service ownership and typed errors. Add one backend facade.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Identify remaining UI/manager scripts calling client.rpc_async.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Migrate calls to the central BackendService or unified manager.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Implement central error mapping and retry policy.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Verify all gameflow-adjacent UI uses new typed methods.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-GF-P1-3">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-GF-P1-3').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Trigger network failure during profile fetch. Assert BackendService retry policy handles it gracefully without UI hard-crashing.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-GF-P1-3">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-GF-P1-3').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-300 text-xs font-bold">P1</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-P1-4</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Versioning & Patch Integrity</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
tools/, export_presets.cfg, version.json
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Single release version source, checksums, compatibility rules, changelog archive.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Create one release version source (version.json or python script).</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Update project version, export versions, Android version deterministically.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Update patch manifest and changelog archive.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add patch integrity fields: checksum, size, minimum compatible app version.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-P1-4">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-P1-4').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Run version bump script. Assert export_presets.cfg Android version code increments correctly and patch manifest checksum is updated.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-P1-4">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-P1-4').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-violet-500/30 bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-300 text-xs font-bold">P2</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-P2-1</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Steam Depot & Store Packaging</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
tools/steam/, export presets
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Create SteamPipe VDFs, branch SOP, signing/notarization, platform filters.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Create tools/steam/app_build_<STEAM_APP_ID>.vdf and per-platform depot templates.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Document steamcmd upload command, branch promotion path.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Add guidance for Windows signing, macOS notarization, Android package name.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Configure store-specific export filters.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-P2-1">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_<STEAM_APP_ID>.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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-P2-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Trigger dry-run of SteamPipe VDF. Assert paths resolve to output directory without committing real credentials.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-P2-1">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-P2-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group bg-white dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden" open>
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer bg-slate-50 dark:bg-slate-900/50 hover:bg-slate-100 dark:hover:bg-slate-900 transition list-none outline-none">
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="px-2 py-1 rounded border border-violet-500/30 bg-violet-50 dark:bg-violet-500/10 text-violet-600 dark:text-violet-300 text-xs font-bold">P2</span>
|
||||
<span class="font-mono text-xs text-slate-500 dark:text-slate-400">PRD-GF-P2-1</span>
|
||||
<h3 class="text-md font-bold text-slate-900 dark:text-white">Dead Path, Debug Gate & Telemetry Cleanup</h3>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3 text-slate-400">
|
||||
<span class="text-xs font-semibold uppercase tracking-wider hidden md:inline-block border border-slate-200 dark:border-slate-700 px-2 py-1 rounded">TODO</span>
|
||||
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform group-open:rotate-180"></i>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="p-5 border-t border-slate-200 dark:border-slate-800 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Left Column: Details & Checklists -->
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Files / Areas</h4>
|
||||
<div class="text-xs font-mono text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/20 px-2 py-1 rounded-md inline-block">
|
||||
main.gd, player.gd, placeholders
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Goal / Risk</h4>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300">Remove release-noisy debug hooks. Add safe-remove candidate matrix + SLO dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400 mb-2">Execution Checklist</h4>
|
||||
<ul class="space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Build matrix: keep, safe-remove, needs-runtime-proof, feature-flag.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Remove or feature-gate release-only noise (e.g., debug key hooks, excessive prints).</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Instrument events: room_joined, preflight_pass, loading_screen, match_sync.</span></li><li class="flex items-start"><i data-lucide="square" class="w-4 h-4 mr-2 mt-0.5 text-slate-400 shrink-0"></i> <span>Do not delete autoload/runtime-loaded scripts without proof.</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: AI Prompt & Testing -->
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="bg-indigo-50 dark:bg-indigo-500/5 border border-indigo-200 dark:border-indigo-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400 mb-2 flex items-center"><i data-lucide="bot" class="w-4 h-4 mr-2"></i> AI Execution Prompt</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-mono h-32 overflow-y-auto" id="prompt-PRD-GF-P2-1">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.</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('prompt-PRD-GF-P2-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Prompt">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/20 rounded-lg p-4 mb-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2 flex items-center"><i data-lucide="test-tube" class="w-4 h-4 mr-2"></i> Testing / Auto-Check</h4>
|
||||
<p class="text-xs text-slate-700 dark:text-slate-300 font-mono leading-relaxed">AI AUTO-CHECK: Search codebase for `Input.is_key_pressed(KEY_F9)`. Assert wrapped in `OS.has_feature("debug")` or completely removed.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-sky-50 dark:bg-sky-500/5 border border-sky-200 dark:border-sky-500/20 rounded-lg p-4">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wider text-sky-600 dark:text-sky-400 mb-2 flex items-center"><i data-lucide="message-square" class="w-4 h-4 mr-2"></i> MS Teams Daily Report</h4>
|
||||
<div class="relative group">
|
||||
<pre class="text-xs text-slate-700 dark:text-slate-300 whitespace-pre-wrap font-sans" id="teams-PRD-GF-P2-1">**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</pre>
|
||||
<button onclick="navigator.clipboard.writeText(document.getElementById('teams-PRD-GF-P2-1').textContent)" class="absolute top-0 right-0 p-1.5 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-md shadow-sm text-slate-500 hover:text-sky-500 opacity-0 group-hover:opacity-100 transition" title="Copy Teams Report">
|
||||
<i data-lucide="copy" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 py-10 mt-20 text-center">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
||||
Blueprint released under MIT authorization. Secure server implementations are responsibility of the
|
||||
deployment architecture team.
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<!-- Interactive Script Operations -->
|
||||
<script>
|
||||
// Init Lucide Icons
|
||||
lucide.createIcons();
|
||||
|
||||
// Highlight flowchart pipelines
|
||||
function highlightPath(target) {
|
||||
// Remove selection formatting from all controls
|
||||
document.querySelectorAll('.path-ctrl-btn').forEach(btn => {
|
||||
btn.classList.remove('bg-sky-500', 'text-slate-950', 'shadow-md');
|
||||
btn.classList.add('text-slate-600 dark:text-slate-400', 'hover:text-slate-900 dark:text-white');
|
||||
});
|
||||
// Assign selection styles to current target control
|
||||
const activeBtn = document.getElementById('btn-path-' + target);
|
||||
if (activeBtn) {
|
||||
activeBtn.classList.add('bg-sky-500', 'text-slate-950', 'shadow-md');
|
||||
activeBtn.classList.remove('text-slate-600 dark:text-slate-400', 'hover:text-slate-900 dark:text-white');
|
||||
}
|
||||
|
||||
// Target elements to manipulate
|
||||
const branches = {
|
||||
steam: document.getElementById('branch-steam'),
|
||||
android: document.getElementById('branch-android'),
|
||||
ios: document.getElementById('branch-ios'),
|
||||
fallback: document.getElementById('branch-fallback')
|
||||
};
|
||||
|
||||
const root = document.getElementById('node-root');
|
||||
const terminal = document.getElementById('node-terminal');
|
||||
const lineRoot = document.getElementById('line-root');
|
||||
const lineTerminal = document.getElementById('line-terminal');
|
||||
|
||||
// Apply opacity filters based on selection
|
||||
if (target === 'all') {
|
||||
Object.values(branches).forEach(b => {
|
||||
b.classList.remove('opacity-25', 'border-sky-500/50');
|
||||
b.classList.add('opacity-100');
|
||||
});
|
||||
root.classList.remove('opacity-50');
|
||||
terminal.classList.remove('opacity-50');
|
||||
lineRoot.classList.remove('bg-slate-200 dark:bg-slate-800/20');
|
||||
lineTerminal.classList.remove('bg-slate-200 dark:bg-slate-800/20');
|
||||
} else {
|
||||
root.classList.remove('opacity-50');
|
||||
terminal.classList.remove('opacity-50');
|
||||
lineRoot.classList.remove('bg-slate-200 dark:bg-slate-800/20');
|
||||
lineTerminal.classList.remove('bg-slate-200 dark:bg-slate-800/20');
|
||||
|
||||
Object.keys(branches).forEach(key => {
|
||||
if (key === target) {
|
||||
branches[key].classList.remove('opacity-25');
|
||||
branches[key].classList.add('opacity-100', 'border-sky-500/50');
|
||||
} else {
|
||||
branches[key].classList.add('opacity-25');
|
||||
branches[key].classList.remove('opacity-100', 'border-sky-500/50');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Switch Store Tabs UI
|
||||
function switchTab(platform) {
|
||||
// Hide all contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.add('hidden');
|
||||
});
|
||||
// Show target
|
||||
document.getElementById('content-' + platform).classList.remove('hidden');
|
||||
|
||||
// Deactivate all buttons
|
||||
document.querySelectorAll('#store-tabs button').forEach(btn => {
|
||||
btn.classList.remove('bg-slate-200 dark:bg-slate-800', 'text-slate-900 dark:text-white');
|
||||
btn.classList.add('text-slate-600 dark:text-slate-400');
|
||||
});
|
||||
// Activate target button
|
||||
const activeBtn = document.getElementById('tab-' + platform);
|
||||
activeBtn.classList.remove('text-slate-600 dark:text-slate-400');
|
||||
activeBtn.classList.add('bg-slate-200 dark:bg-slate-800', 'text-slate-900 dark:text-white');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<script>
|
||||
// Init Lucide Icons
|
||||
lucide.createIcons();
|
||||
|
||||
// Theme logic
|
||||
function toggleTheme() {
|
||||
if (document.documentElement.classList.contains('dark')) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
}
|
||||
|
||||
// Init theme
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
if (localStorage.theme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+9
-9
@@ -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
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
|
||||
+265
-13
@@ -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"
|
||||
|
||||
+288
-107
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
+205
-60
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
|
||||
+135
-3
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
+106
-22
@@ -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
|
||||
|
||||
+6
-16
@@ -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
|
||||
|
||||
@@ -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<val.claimed_days; i++) arr.push(i);
|
||||
for (var i = 0; i < val.claimed_days; i++) arr.push(i);
|
||||
state.claimed_days = arr;
|
||||
} else if (Array.isArray(val.claimed_days)) {
|
||||
state.claimed_days = val.claimed_days;
|
||||
@@ -684,46 +570,46 @@ function rpcClaimDailyReward(ctx, logger, nk, payload) {
|
||||
state.claimed_days = [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (state.month !== currentMonth) {
|
||||
state.claimed_days = [];
|
||||
state.month = currentMonth;
|
||||
}
|
||||
|
||||
|
||||
if (state.last_claim_date === todayStr) {
|
||||
throw new Error("Already claimed today");
|
||||
}
|
||||
|
||||
|
||||
var configObjs = nk.storageRead([{ collection: "config", key: "daily_rewards", userId: "00000000-0000-0000-0000-000000000000" }]);
|
||||
var config = {};
|
||||
if (configObjs && configObjs.length > 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<val.claimed_days; i++) arr.push(i);
|
||||
for (var i = 0; i < val.claimed_days; i++) arr.push(i);
|
||||
state.claimed_days = arr;
|
||||
} else if (Array.isArray(val.claimed_days)) {
|
||||
state.claimed_days = val.claimed_days;
|
||||
@@ -787,7 +673,7 @@ function rpcGetDailyRewardState(ctx, logger, nk, payload) {
|
||||
state.claimed_days = [];
|
||||
state.month = currentMonth;
|
||||
}
|
||||
|
||||
|
||||
var configObjs = nk.storageRead([{ collection: "config", key: "daily_rewards", userId: "00000000-0000-0000-0000-000000000000" }]);
|
||||
var config = {};
|
||||
if (configObjs && configObjs.length > 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: [] });
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user