Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 68543fe68f Fix race condition between heartbeat response and game updates
The heartbeat handler was reading game state and sending responses without
holding the GamesManager lock. Since game updates ARE sent while holding
this lock, there was a race condition where:

1. AI processing holds GamesManager lock
2. Updates start being sent to clients via the stream
3. Heartbeat request arrives, acquires EagleServiceImpl lock (different lock)
4. Heartbeat reads game count and sends response (can interleave with updates)
5. Client receives heartbeat before some in-flight updates
6. Client detects sync mismatch despite updates being in transit

This particularly affected slower/remote connections (e.g., Windows clients)
because the timing window for the race was longer.

Fix: Wrap heartbeat handling in gamesManager.synchronized to ensure the
response is sent only after any in-progress game updates have been queued.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:28:13 -08:00
adminandClaude Opus 4.5 92a8e2e66f Use environment dropdown URL for OAuth connections
OAuthManager now gets its server URL from ConnectionHandler's
environment dropdown instead of a hardcoded value:

- Add SetServerUrl() to OAuthManager for deferred configuration
- ConnectionHandler calls SetServerUrl() before OAuth operations
- prod/qa/eagle0.net all use HTTPS as they're remote servers
- AuthClient accepts full URL with scheme (no longer prepends https://)

This ensures OAuth uses the same server as the main gRPC connection.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:14:01 -08:00
adminandClaude Opus 4.5 36a7b53e3e Fix AuthClient to accept full URL with scheme
AuthClient was hardcoding "https://" prefix, causing TLS errors
when connecting to local development server (plain HTTP).

- AuthClient now expects full URL including scheme
- OAuthManager defaults to "http://localhost:40032" for local dev
- Add tooltip explaining URL format

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:14:01 -08:00
adminandClaude Opus 4.5 e00a78f8a2 Fix AuthClient naming collision with generated gRPC client
- Add type alias for generated Auth.AuthClient to avoid namespace collision
- Use CallInvoker with JwtAuthInterceptor (matching Eagle client pattern)
- Add Unity meta files for Auth components and OAuth buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:14:01 -08:00
adminandClaude Opus 4.5 f85cd017e6 Add Unity OAuth client infrastructure
Implements Phase 3 of OAuth plan - Unity client components:

- Auth/TokenStorage.cs: Secure token persistence using PlayerPrefs
- Auth/AuthClient.cs: gRPC client wrapper for Auth service
- Auth/OAuthManager.cs: OAuth flow manager with deep link handling
- Auth/JwtAuthInterceptor.cs: gRPC interceptor for Bearer token auth
- EagleConnection.cs: Add CreateWithJwt() factory for JWT-based connections
- ConnectionHandler.cs: Add OAuth UI fields and event handlers
- protos.csproj: Include auth.proto in proto generation

Unity Editor work still needed:
- Create OAuthManager singleton GameObject
- Wire up ConnectionHandler OAuth UI references
- Build protos to generate C# types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:14:01 -08:00
22 changed files with 2067 additions and 96 deletions
@@ -125,6 +125,7 @@
<Compile Include="Assets/Eagle/Notifications/Notification.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/QuestFailedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/QuestFulfilledDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Auth/OAuthManager.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProfessionGainedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/TextureList.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowManager.cs" />
@@ -146,6 +147,7 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
<Compile Include="Assets/common/ResourceFetcher.cs" />
<Compile Include="Assets/Auth/AuthClient.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowManagerEditor.cs" />
@@ -153,6 +155,7 @@
<Compile Include="Assets/Bluetooth/NativeDiceInterfaceImports.cs" />
<Compile Include="Assets/Eagle/Notifications/ProvinceConqueredNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
<Compile Include="Assets/Auth/JwtAuthInterceptor.cs" />
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ShatteredArmyDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/TurnHistoryPanelController.cs" />
@@ -258,6 +261,7 @@
<Compile Include="Assets/Eagle/CommandSelectors/ResolveTruceCommandSelector.cs" />
<Compile Include="Assets/Eagle/ClientPregeneratedText.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/SwearBrotherhoodCommandSelector.cs" />
<Compile Include="Assets/Auth/TokenStorage.cs" />
<Compile Include="Assets/Eagle/Table Rows/TableRowController.cs" />
<Compile Include="Assets/Eagle/DisplayNames.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerSliderEditor.cs" />
@@ -363,6 +367,7 @@
<Compile Include="Assets/Eagle/Table Rows/AvailableHeroTableRow.cs" />
<Compile Include="Assets/Eagle/Table Rows/HeroRowController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerTooltip.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/NewFactionHeadDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/HexGrid.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Tooltip/TooltipManager.cs" />
<None Include="Assets/TextMesh Pro/Resources/Shaders/TMPro.cginc" />
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 683e069fbc3074e35ac6fc3881a03f4a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,159 @@
using System;
using System.Threading.Tasks;
using Cysharp.Net.Http;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Grpc.Net.Client;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
using GrpcAuthClient = Net.Eagle0.Eagle.Api.Auth.Auth.AuthClient;
namespace Auth {
/// <summary>
/// gRPC client for the Auth service.
/// Handles OAuth URL generation, code exchange, and token refresh.
/// </summary>
public class AuthClient : IDisposable {
private const string RedirectUri = "eagle0://auth/callback";
private readonly GrpcAuthClient _client;
private readonly GrpcChannel _channel;
// State parameter for CSRF protection (stored between GetOAuthUrl and ExchangeCode)
private string _pendingState;
private OAuthProvider _pendingProvider;
/// <summary>
/// Create auth client for the given server URL.
/// </summary>
/// <param name="serverUrl">Full URL with scheme, e.g. "http://localhost:40032" or
/// "https://prod.eagle0.net"</param>
public AuthClient(string serverUrl) {
_channel = GrpcChannel.ForAddress(
serverUrl,
new GrpcChannelOptions {
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
DisposeHttpClient = true
});
// Create invoker with JWT interceptor for authenticated requests
CallInvoker invoker = _channel.Intercept(new JwtAuthInterceptor());
_client = new GrpcAuthClient(invoker);
}
/// <summary>
/// Get OAuth URL to open in system browser.
/// </summary>
/// <param name="provider">Discord or Google</param>
/// <returns>URL to open in browser</returns>
public async Task<string> GetOAuthUrlAsync(OAuthProvider provider) {
var request = new GetOAuthUrlRequest { Provider = provider, RedirectUri = RedirectUri };
var response = await _client.GetOAuthUrlAsync(request);
// Store state for validation when code comes back
_pendingState = response.State;
_pendingProvider = provider;
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
return response.AuthUrl;
}
/// <summary>
/// Exchange authorization code for JWT tokens.
/// Call this after receiving the OAuth callback.
/// </summary>
/// <param name="code">Authorization code from callback</param>
/// <param name="state">State parameter from callback (for CSRF validation)</param>
/// <returns>Exchange response with tokens and user info</returns>
public async Task<ExchangeCodeResponse> ExchangeCodeAsync(string code, string state) {
// Validate state matches what we sent
if (state != _pendingState) {
throw new InvalidOperationException(
$"State mismatch: expected {_pendingState}, got {state}. Possible CSRF attack.");
}
var request = new ExchangeCodeRequest {
Provider = _pendingProvider,
AuthorizationCode = code,
State = state,
RedirectUri = RedirectUri
};
var response = await _client.ExchangeCodeAsync(request);
// Clear pending state
_pendingState = null;
Debug.Log(
$"[AuthClient] Code exchange successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
return response;
}
/// <summary>
/// Refresh access token using stored refresh token.
/// </summary>
/// <returns>New access token and expiry, or null if refresh failed</returns>
public async Task<RefreshTokenResponse> RefreshTokenAsync() {
var refreshToken = TokenStorage.RefreshToken;
if (string.IsNullOrEmpty(refreshToken)) {
throw new InvalidOperationException("No refresh token available");
}
var request = new RefreshTokenRequest { RefreshToken = refreshToken };
var response = await _client.RefreshTokenAsync(request);
// Update stored access token
TokenStorage.UpdateAccessToken(response.AccessToken, response.ExpiresAt);
Debug.Log($"[AuthClient] Token refreshed, expires at {response.ExpiresAt}");
return response;
}
/// <summary>
/// Set display name for new users.
/// Requires valid JWT in interceptor.
/// </summary>
public async Task<SetDisplayNameResponse> SetDisplayNameAsync(string displayName) {
var request = new SetDisplayNameRequest { DisplayName = displayName };
var response = await _client.SetDisplayNameAsync(request);
if (response.Success) {
TokenStorage.UpdateDisplayName(displayName);
Debug.Log($"[AuthClient] Display name set to: {displayName}");
} else {
Debug.LogWarning(
$"[AuthClient] Failed to set display name: {response.ErrorMessage}");
}
return response;
}
/// <summary>
/// Get current user info. Validates the stored JWT.
/// </summary>
public async Task<GetCurrentUserResponse> GetCurrentUserAsync() {
var response = await _client.GetCurrentUserAsync(new GetCurrentUserRequest());
Debug.Log($"[AuthClient] Current user: {response.User?.DisplayName}");
return response;
}
/// <summary>
/// Logout - invalidates refresh token on server.
/// </summary>
public async Task LogoutAsync() {
try {
await _client.LogoutAsync(new LogoutRequest());
} catch (Exception ex) {
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
}
// Clear local tokens regardless of server response
TokenStorage.Clear();
Debug.Log("[AuthClient] Logged out, tokens cleared");
}
public void Dispose() { _channel?.Dispose(); }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ba94126c2d2145b8875612eaf5bc371
@@ -0,0 +1,75 @@
using System;
using Grpc.Core;
using Grpc.Core.Interceptors;
namespace Auth {
/// <summary>
/// gRPC interceptor that attaches JWT Bearer token to requests.
/// Reads token from TokenStorage on each request.
/// </summary>
public class JwtAuthInterceptor : Interceptor {
private const string HeaderName = "Authorization";
private string GetBearerHeader() {
var token = TokenStorage.AccessToken;
return string.IsNullOrEmpty(token) ? null : $"Bearer {token}";
}
public override TResponse BlockingUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
BlockingUnaryCallContinuation<TRequest, TResponse> continuation) {
return continuation(request, ContextWithHeaders(context));
}
public override AsyncUnaryCall<TResponse> AsyncUnaryCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncUnaryCallContinuation<TRequest, TResponse> continuation) {
return continuation(request, ContextWithHeaders(context));
}
public override AsyncServerStreamingCall<TResponse>
AsyncServerStreamingCall<TRequest, TResponse>(
TRequest request,
ClientInterceptorContext<TRequest, TResponse> context,
AsyncServerStreamingCallContinuation<TRequest, TResponse> continuation) {
return continuation(request, ContextWithHeaders(context));
}
public override AsyncClientStreamingCall<TRequest, TResponse>
AsyncClientStreamingCall<TRequest, TResponse>(
ClientInterceptorContext<TRequest, TResponse> context,
AsyncClientStreamingCallContinuation<TRequest, TResponse> continuation) {
return continuation(ContextWithHeaders(context));
}
public override AsyncDuplexStreamingCall<TRequest, TResponse>
AsyncDuplexStreamingCall<TRequest, TResponse>(
ClientInterceptorContext<TRequest, TResponse> context,
AsyncDuplexStreamingCallContinuation<TRequest, TResponse> continuation) {
return continuation(ContextWithHeaders(context));
}
private ClientInterceptorContext<TRequest, TResponse>
ContextWithHeaders<TRequest, TResponse>(
ClientInterceptorContext<TRequest, TResponse> original)
where TRequest : class
where TResponse : class {
var bearerHeader = GetBearerHeader();
if (string.IsNullOrEmpty(bearerHeader)) {
// No token available - return original context
// Auth service endpoints don't require token
return original;
}
var headers = new Metadata();
headers.Add(HeaderName, bearerHeader);
return new ClientInterceptorContext<TRequest, TResponse>(
original.Method,
original.Host,
original.Options.WithHeaders(headers));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8528d8ee967e042ecb2a5e8285c425e0
@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
namespace Auth {
/// <summary>
/// Manages OAuth authentication flow including:
/// - Opening system browser for OAuth consent
/// - Handling deep link callbacks (eagle0://auth/callback)
/// - Token storage and refresh
/// </summary>
public class OAuthManager : MonoBehaviour {
public static OAuthManager Instance { get; private set; }
private AuthClient _authClient;
private string _currentServerUrl;
private TaskCompletionSource<ExchangeCodeResponse> _pendingAuth;
// Events for UI updates
public event Action<UserInfo> OnLoginSuccess;
public event Action<string> OnLoginFailed;
public event Action OnLogout;
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
public bool IsAuthenticated => TokenStorage.HasValidToken;
public string DisplayName => TokenStorage.DisplayName;
public string UserId => TokenStorage.UserId;
private void Awake() {
if (Instance != null && Instance != this) {
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
// AuthClient is created lazily when SetServerUrl is called
}
/// <summary>
/// Set the server URL for OAuth requests. Call this before any OAuth operations.
/// </summary>
/// <param name="serverUrl">Full URL with scheme, e.g. "https://prod.eagle0.net"</param>
public void SetServerUrl(string serverUrl) {
if (_currentServerUrl == serverUrl && _authClient != null) {
return; // Already configured for this URL
}
_authClient?.Dispose();
_currentServerUrl = serverUrl;
_authClient = new AuthClient(serverUrl);
Debug.Log($"[OAuthManager] Configured for server: {serverUrl}");
}
private void OnEnable() {
// Register for deep link callbacks
Application.deepLinkActivated += OnDeepLinkActivated;
// Check if app was launched via deep link
if (!string.IsNullOrEmpty(Application.absoluteURL)) {
OnDeepLinkActivated(Application.absoluteURL);
}
}
private void OnDisable() { Application.deepLinkActivated -= OnDeepLinkActivated; }
private void OnDestroy() {
_authClient?.Dispose();
if (Instance == this) { Instance = null; }
}
private void EnsureConfigured() {
if (_authClient == null) {
throw new InvalidOperationException(
"OAuthManager not configured. Call SetServerUrl() first.");
}
}
/// <summary>
/// Start OAuth login with specified provider.
/// Opens system browser for user consent.
/// </summary>
public async Task<ExchangeCodeResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
try {
// Get OAuth URL from server
var authUrl = await _authClient.GetOAuthUrlAsync(provider);
// Create completion source for callback
_pendingAuth = new TaskCompletionSource<ExchangeCodeResponse>();
// Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
Application.OpenURL(authUrl);
// Wait for deep link callback
var response = await _pendingAuth.Task;
// Store tokens
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName ?? "");
// Notify listeners
if (response.IsNewUser) {
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
OnLoginSuccess?.Invoke(response.User);
}
return response;
} catch (Exception ex) {
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
throw;
} finally { _pendingAuth = null; }
}
/// <summary>
/// Handle deep link callback from OAuth provider.
/// URL format: eagle0://auth/callback?code=xxx&state=yyy
/// </summary>
private async void OnDeepLinkActivated(string url) {
Debug.Log($"[OAuthManager] Deep link received: {url}");
if (!url.StartsWith("eagle0://auth/callback")) { return; }
try {
// Parse query parameters
var queryParams = ParseQueryString(url);
if (queryParams.TryGetValue("error", out var error)) {
var errorDesc =
queryParams.GetValueOrDefault("error_description", "Unknown error");
throw new Exception($"OAuth error: {error} - {errorDesc}");
}
if (!queryParams.TryGetValue("code", out var code)) {
throw new Exception("No authorization code in callback");
}
if (!queryParams.TryGetValue("state", out var state)) {
throw new Exception("No state parameter in callback");
}
// Exchange code for tokens
var response = await _authClient.ExchangeCodeAsync(code, state);
// Complete pending auth task
_pendingAuth?.TrySetResult(response);
} catch (Exception ex) {
Debug.LogError($"[OAuthManager] Callback handling failed: {ex.Message}");
_pendingAuth?.TrySetException(ex);
}
}
/// <summary>
/// Set display name for new user after OAuth.
/// </summary>
public async Task<bool> SetDisplayNameAsync(string displayName) {
EnsureConfigured();
var response = await _authClient.SetDisplayNameAsync(displayName);
if (response.Success) { OnLoginSuccess?.Invoke(response.User); }
return response.Success;
}
/// <summary>
/// Try to restore session from stored tokens.
/// Returns true if valid session exists.
/// Must call SetServerUrl() before this method.
/// </summary>
public async Task<bool> TryRestoreSessionAsync() {
if (_authClient == null) {
Debug.Log("[OAuthManager] Not configured yet, skipping session restore");
return false;
}
if (!TokenStorage.HasValidToken && !TokenStorage.HasRefreshToken) {
Debug.Log("[OAuthManager] No stored session");
return false;
}
// If token is expired but we have refresh token, try to refresh
if (!TokenStorage.HasValidToken && TokenStorage.HasRefreshToken) {
try {
await _authClient.RefreshTokenAsync();
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Token refresh failed: {ex.Message}");
TokenStorage.Clear();
return false;
}
}
// Validate session with server
try {
var response = await _authClient.GetCurrentUserAsync();
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
return true;
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
TokenStorage.Clear();
return false;
}
}
/// <summary>
/// Refresh access token if needed.
/// Call this before making API requests.
/// </summary>
public async Task EnsureValidTokenAsync() {
EnsureConfigured();
if (TokenStorage.NeedsRefresh && TokenStorage.HasRefreshToken) {
await _authClient.RefreshTokenAsync();
}
}
/// <summary>
/// Logout and clear stored tokens.
/// </summary>
public async Task LogoutAsync() {
// LogoutAsync can work even without server connection - just clear local tokens
if (_authClient != null) {
await _authClient.LogoutAsync();
} else {
TokenStorage.Clear();
}
OnLogout?.Invoke();
}
/// <summary>
/// Parse query string from URL.
/// </summary>
private static Dictionary<string, string> ParseQueryString(string url) {
var result = new Dictionary<string, string>();
var queryStart = url.IndexOf('?');
if (queryStart < 0) return result;
var query = url.Substring(queryStart + 1);
var pairs = query.Split('&');
foreach (var pair in pairs) {
var parts = pair.Split('=');
if (parts.Length == 2) {
result[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]);
}
}
return result;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0e9d5b0cb9f26477eaf26e8a09c41707
@@ -0,0 +1,122 @@
using System;
using UnityEngine;
namespace Auth {
/// <summary>
/// Secure storage for OAuth tokens using Unity's PlayerPrefs.
/// In production, consider using platform-specific secure storage
/// (Keychain on iOS, Keystore on Android).
/// </summary>
public static class TokenStorage {
private const string AccessTokenKey = "eagle0_access_token";
private const string RefreshTokenKey = "eagle0_refresh_token";
private const string ExpiresAtKey = "eagle0_expires_at";
private const string UserIdKey = "eagle0_user_id";
private const string DisplayNameKey = "eagle0_display_name";
public static bool HasValidToken {
get {
var token = AccessToken;
var expiresAt = ExpiresAt;
return !string.IsNullOrEmpty(token) &&
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
}
public static bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
public static string AccessToken {
get => PlayerPrefs.GetString(AccessTokenKey, null);
private
set => PlayerPrefs.SetString(AccessTokenKey, value ?? "");
}
public static string RefreshToken {
get => PlayerPrefs.GetString(RefreshTokenKey, null);
private
set => PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
}
public static long ExpiresAt {
get => long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
private
set => PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
}
public static string UserId {
get => PlayerPrefs.GetString(UserIdKey, null);
private
set => PlayerPrefs.SetString(UserIdKey, value ?? "");
}
public static string DisplayName {
get => PlayerPrefs.GetString(DisplayNameKey, null);
private
set => PlayerPrefs.SetString(DisplayNameKey, value ?? "");
}
/// <summary>
/// Store tokens received from OAuth exchange.
/// </summary>
public static void StoreTokens(
string accessToken,
string refreshToken,
long expiresAt,
string userId,
string displayName) {
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresAt = expiresAt;
UserId = userId;
DisplayName = displayName;
PlayerPrefs.Save();
Debug.Log(
$"[TokenStorage] Stored tokens for user {displayName} (expires at {expiresAt})");
}
/// <summary>
/// Update access token after refresh.
/// </summary>
public static void UpdateAccessToken(string accessToken, long expiresAt) {
AccessToken = accessToken;
ExpiresAt = expiresAt;
PlayerPrefs.Save();
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
}
/// <summary>
/// Update display name after user sets it.
/// </summary>
public static void UpdateDisplayName(string displayName) {
DisplayName = displayName;
PlayerPrefs.Save();
}
/// <summary>
/// Clear all stored tokens (logout).
/// </summary>
public static void Clear() {
PlayerPrefs.DeleteKey(AccessTokenKey);
PlayerPrefs.DeleteKey(RefreshTokenKey);
PlayerPrefs.DeleteKey(ExpiresAtKey);
PlayerPrefs.DeleteKey(UserIdKey);
PlayerPrefs.DeleteKey(DisplayNameKey);
PlayerPrefs.Save();
Debug.Log("[TokenStorage] Cleared all tokens");
}
/// <summary>
/// Check if token needs refresh (expires within 5 minutes).
/// </summary>
public static bool NeedsRefresh {
get {
var expiresAt = ExpiresAt;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return expiresAt > 0 && expiresAt - now < 300; // 5 minutes
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4b1f130cec656466893fa04ba789d34f
@@ -7,15 +7,18 @@ using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Auth;
using common;
using common.GUIUtils;
using eagle;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Api.Auth;
using TMPro;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using Object = System.Object;
public class AuthInterceptor : Interceptor {
@@ -82,6 +85,22 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TMP_InputField passwordField;
public TMP_Dropdown resolutionDropdown;
[Header("OAuth UI")]
public GameObject oauthPanel;
public Button discordLoginButton;
public Button googleLoginButton;
public TextMeshProUGUI oauthStatusText;
[Header("Display Name Setup")]
public GameObject displayNamePanel;
public TMP_InputField displayNameField;
public Button setDisplayNameButton;
public TextMeshProUGUI displayNameErrorText;
[Header("Legacy Auth (for testing)")]
public GameObject legacyAuthPanel;
public Button useLegacyAuthButton;
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
@@ -112,6 +131,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private bool listen = false;
private CancellationTokenSource _cancellationTokenSource;
private readonly Object pendingReplyLock = new Object();
private bool _useOAuth = true; // Default to OAuth, fall back to legacy if needed
public ClientPregeneratedText clientPregeneratedText;
@@ -131,6 +151,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
return prefix + BaseDomain;
}
/// <summary>
/// Get full URL with scheme for the selected environment.
/// Remote servers use HTTPS, could be extended for local HTTP.
/// </summary>
private string GetFullUrlFromEnvironment() { return "https://" + GetUrlFromEnvironment(); }
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
_handleLobbyResponse(lobbyResponse);
}
@@ -166,8 +192,146 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
errorHandler.gameObject.SetActive(true);
// Initialize OAuth UI
SetupOAuthUI();
// Initialize status text
UpdateConnectionStatus();
// Try to restore existing OAuth session
TryRestoreSession();
}
private void SetupOAuthUI() {
// Set up OAuth button click handlers
if (discordLoginButton != null) {
discordLoginButton.onClick.AddListener(
() => OnOAuthLoginClicked(OAuthProvider.Discord));
}
if (googleLoginButton != null) {
googleLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Google));
}
if (setDisplayNameButton != null) {
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
}
if (useLegacyAuthButton != null) {
useLegacyAuthButton.onClick.AddListener(OnUseLegacyAuthClicked);
}
// Subscribe to OAuthManager events
if (OAuthManager.Instance != null) {
OAuthManager.Instance.OnLoginSuccess += OnOAuthLoginSuccess;
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
OAuthManager.Instance.OnLogout += OnOAuthLogout;
}
// Show appropriate panel based on auth state
ShowOAuthPanel();
}
private void ShowOAuthPanel() {
// Hide all auth panels first
if (oauthPanel != null) oauthPanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
// Show OAuth panel by default
if (oauthPanel != null) oauthPanel.SetActive(true);
}
private async void TryRestoreSession() {
if (OAuthManager.Instance == null) return;
// Configure OAuthManager with current environment URL
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
if (oauthStatusText != null) { oauthStatusText.text = "Checking for existing session..."; }
var restored = await OAuthManager.Instance.TryRestoreSessionAsync();
if (restored) {
// Session restored, connect to lobby
ConnectWithOAuth();
} else if (oauthStatusText != null) {
oauthStatusText.text = "";
}
}
private async void OnOAuthLoginClicked(OAuthProvider provider) {
// Configure OAuthManager with current environment URL (user may have changed dropdown)
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
if (oauthStatusText != null) {
oauthStatusText.text = $"Opening browser for {provider} login...";
}
try {
await OAuthManager.Instance.LoginAsync(provider);
// OnLoginSuccess or OnNewUserNeedsDisplayName will be called
} catch (Exception ex) {
Debug.LogError($"OAuth login failed: {ex.Message}");
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {ex.Message}"; }
}
}
private void OnOAuthLoginSuccess(UserInfo user) {
MainQueue.Q.Enqueue(() => {
if (oauthStatusText != null) { oauthStatusText.text = $"Welcome, {user.DisplayName}!"; }
ConnectWithOAuth();
});
}
private void OnOAuthLoginFailed(string error) {
MainQueue.Q.Enqueue(() => {
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {error}"; }
});
}
private void OnNewUserNeedsDisplayName(bool isNewUser) {
MainQueue.Q.Enqueue(() => {
// Show display name panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(true);
if (displayNameErrorText != null) displayNameErrorText.text = "";
});
}
private async void OnSetDisplayNameClicked() {
if (displayNameField == null || OAuthManager.Instance == null) return;
var displayName = displayNameField.text.Trim();
if (string.IsNullOrEmpty(displayName)) {
if (displayNameErrorText != null) {
displayNameErrorText.text = "Please enter a display name";
}
return;
}
if (displayNameErrorText != null) { displayNameErrorText.text = "Setting display name..."; }
var success = await OAuthManager.Instance.SetDisplayNameAsync(displayName);
if (!success && displayNameErrorText != null) {
displayNameErrorText.text = "Name already taken or invalid. Try another.";
}
// OnLoginSuccess will be called if successful
}
private void OnOAuthLogout() {
MainQueue.Q.Enqueue(() => {
ShowOAuthPanel();
if (oauthStatusText != null) { oauthStatusText.text = ""; }
});
}
private void OnUseLegacyAuthClicked() {
_useOAuth = false;
if (oauthPanel != null) oauthPanel.SetActive(false);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
}
private void ConnectWithOAuth() {
_useOAuth = true;
_internalConnectEagle();
}
private float _statusUpdateTimer = 0f;
@@ -358,8 +522,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private void _createConnection() {
PlayerPrefs.SetInt(EnvironmentKey, environmentDropdown.value);
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
// Dispose existing connections before creating new ones
_httpClient?.Dispose();
@@ -371,7 +533,26 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_cancellationTokenSource = new CancellationTokenSource();
string url = GetUrlFromEnvironment();
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
if (_useOAuth && TokenStorage.HasValidToken) {
// Use JWT-based connection
eagleConnection = EagleConnection.CreateWithJwt(url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
} else {
// Legacy Basic Auth connection
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
}
_persistentClientConnection = new PersistentClientConnection(
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
@@ -379,10 +560,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_persistentClientConnection.Connect();
errorHandler.PersistentClientConnection = _persistentClientConnection;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
ResourceFetcher.SetUpConnection(_httpClient);
}
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e661753a1e43a4f2da81b76457cf55c8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 8d524cd6fa08d47c99b2462c2686b8d0
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 082e5448b115147a4b3ad1b92d3521ed
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: c58a03150343047bab655aa351ab1df0
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,5 +1,6 @@
using System;
using System.Threading;
using Auth;
using Cysharp.Net.Http;
using eagle;
using Grpc.Core;
@@ -57,6 +58,25 @@ public class EagleConnection : IDisposable {
};
}
private CallInvoker MakeJwtInvoker(string url) {
var jwtInterceptor = new JwtAuthInterceptor();
_channel = GrpcChannel.ForAddress(
"https://" + url,
new GrpcChannelOptions {
HttpHandler = CreateHttpHandler(),
DisposeHttpClient = true,
LoggerFactory = _loggerFactory,
MaxReceiveMessageSize = null
});
var invoker = _channel.Intercept(jwtInterceptor);
return invoker;
}
/// <summary>
/// Create connection using Basic Auth (legacy).
/// </summary>
public EagleConnection(string playerName, string password, string url) {
this.playerName = playerName;
credentials = new Metadata { { "user", playerName } };
@@ -68,6 +88,26 @@ public class EagleConnection : IDisposable {
EagleGrpcClient = new Eagle.EagleClient(MakeInvoker(playerName, password, url));
}
/// <summary>
/// Create connection using JWT Bearer token auth.
/// Tokens are read from TokenStorage on each request.
/// </summary>
public static EagleConnection CreateWithJwt(string url) {
return new EagleConnection(url, useJwt: true);
}
private EagleConnection(string url, bool useJwt) {
// For JWT auth, get display name from TokenStorage
playerName = TokenStorage.DisplayName ?? "Unknown";
credentials = new Metadata { { "user", TokenStorage.UserId ?? "" } };
authHeader = null; // Not used for JWT
_loggerFactory = LoggerFactory.Create(
builder => { builder.AddProvider(new CustomFileLoggerProvider()); });
EagleGrpcClient = new Eagle.EagleClient(MakeJwtInvoker(url));
}
public void Dispose() {
try {
_channel?.Dispose();
@@ -8505,7 +8505,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -174}
m_AnchoredPosition: {x: 0, y: -235}
m_SizeDelta: {x: 484.2846, y: 53}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &77360321
@@ -15112,6 +15112,7 @@ RectTransform:
- {fileID: 596196019}
- {fileID: 1102342356}
- {fileID: 1633635478}
- {fileID: 536714608}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -15135,6 +15136,16 @@ MonoBehaviour:
nameField: {fileID: 610801682}
passwordField: {fileID: 1091790539}
resolutionDropdown: {fileID: 1583232697}
oauthPanel: {fileID: 809949464}
discordLoginButton: {fileID: 233390733}
googleLoginButton: {fileID: 578365698}
oauthStatusText: {fileID: 1508533759}
displayNamePanel: {fileID: 0}
displayNameField: {fileID: 0}
setDisplayNameButton: {fileID: 0}
displayNameErrorText: {fileID: 0}
legacyAuthPanel: {fileID: 0}
useLegacyAuthButton: {fileID: 0}
connectionStatusText: {fileID: 77360322}
connectionPanel: {fileID: 596196018}
gameSelectionPanel: {fileID: 1102342355}
@@ -25788,6 +25799,127 @@ RectTransform:
type: 3}
m_PrefabInstance: {fileID: 232967528}
m_PrefabAsset: {fileID: 0}
--- !u!1 &233390731
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 233390732}
- component: {fileID: 233390735}
- component: {fileID: 233390734}
- component: {fileID: 233390733}
m_Layer: 5
m_Name: Discord Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &233390732
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 233390731}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1360842438}
m_Father: {fileID: 809949465}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 43.999985}
m_SizeDelta: {x: 280, y: 60}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &233390733
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 233390731}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 233390734}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!114 &233390734
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 233390731}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &233390735
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 233390731}
m_CullTransparentMesh: 1
--- !u!1 &235168739
GameObject:
m_ObjectHideFlags: 0
@@ -44761,7 +44893,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: -230, y: -150}
m_AnchoredPosition: {x: -166, y: -127}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &413176791
@@ -56065,7 +56197,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 0, y: 0.5}
m_AnchoredPosition: {x: 290, y: -48}
m_AnchoredPosition: {x: 290, y: -128}
m_SizeDelta: {x: 180, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &505529790
@@ -59945,6 +60077,55 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 535298361}
m_CullTransparentMesh: 0
--- !u!1 &536714607
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 536714608}
- component: {fileID: 536714609}
m_Layer: 5
m_Name: OAuth Manager
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &536714608
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 536714607}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 135486719}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &536714609
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 536714607}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 0e9d5b0cb9f26477eaf26e8a09c41707, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Auth.OAuthManager
eagleServerUrl: localhost:40032
--- !u!1 &538095771
GameObject:
m_ObjectHideFlags: 0
@@ -67853,6 +68034,127 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 577851600}
m_CullTransparentMesh: 0
--- !u!1 &578365696
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 578365697}
- component: {fileID: 578365700}
- component: {fileID: 578365699}
- component: {fileID: 578365698}
m_Layer: 5
m_Name: Google Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &578365697
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 578365696}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1700212691}
m_Father: {fileID: 809949465}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -33}
m_SizeDelta: {x: 280, y: 60}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &578365698
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 578365696}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 578365699}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!114 &578365699
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 578365696}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 0}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &578365700
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 578365696}
m_CullTransparentMesh: 1
--- !u!1 &580666205
GameObject:
m_ObjectHideFlags: 0
@@ -69830,22 +70132,20 @@ RectTransform:
- {fileID: 413176790}
- {fileID: 927223277}
- {fileID: 605216914}
- {fileID: 784992951}
- {fileID: 610801681}
- {fileID: 1742856846}
- {fileID: 1091790538}
- {fileID: 1795546184}
- {fileID: 1356834056}
- {fileID: 670923486}
- {fileID: 505529789}
- {fileID: 701797664}
- {fileID: 1583232696}
- {fileID: 77360320}
- {fileID: 1583232696}
- {fileID: 1795546184}
- {fileID: 809949465}
m_Father: {fileID: 135486719}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 800, y: 600}
m_SizeDelta: {x: 850, y: 650}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &596196020
MonoBehaviour:
@@ -70630,7 +70930,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: 155, y: -150}
m_AnchoredPosition: {x: 218.99998, y: -127.00003}
m_SizeDelta: {x: 350, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &605216915
@@ -71559,17 +71859,17 @@ RectTransform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 610801680}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 255875935}
m_Father: {fileID: 596196019}
m_Father: {fileID: 1356834056}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: 60, y: -200}
m_AnchoredPosition: {x: 90.000046, y: -42.440994}
m_SizeDelta: {x: 350, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &610801682
@@ -80005,7 +80305,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 0, y: 0.5}
m_AnchoredPosition: {x: 520, y: -48}
m_AnchoredPosition: {x: 520, y: -128.00002}
m_SizeDelta: {x: 180, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &670923487
@@ -83158,7 +83458,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 0, y: 0.5}
m_AnchoredPosition: {x: 290, y: -100}
m_AnchoredPosition: {x: 290, y: -180.00002}
m_SizeDelta: {x: 180, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &701797665
@@ -94084,16 +94384,16 @@ RectTransform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 784992950}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 596196019}
m_Father: {fileID: 1356834056}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: -230, y: -200}
m_AnchoredPosition: {x: -199.99997, y: -42.440994}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &784992952
@@ -97610,6 +97910,84 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: bd19585e1f7d445a3b2c459d2c8535eb, type: 3}
m_Name:
m_EditorClassIdentifier:
--- !u!1 &809949464
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 809949465}
- component: {fileID: 809949467}
- component: {fileID: 809949466}
m_Layer: 5
m_Name: OAuth Panel
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &809949465
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 809949464}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 233390732}
- {fileID: 578365697}
- {fileID: 1508533758}
m_Father: {fileID: 596196019}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 252.63776, y: 70.62991}
m_SizeDelta: {x: -505.277, y: -315.1181}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &809949466
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 809949464}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 0}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10907, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &809949467
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 809949464}
m_CullTransparentMesh: 1
--- !u!1 &811567745
GameObject:
m_ObjectHideFlags: 0
@@ -112153,7 +112531,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: -70, y: -150.00005}
m_AnchoredPosition: {x: -6.0000153, y: -127.00008}
m_SizeDelta: {x: 90, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &927223278
@@ -132814,17 +133192,17 @@ RectTransform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1091790537}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2094473068}
m_Father: {fileID: 596196019}
m_Father: {fileID: 1356834056}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: 60, y: -250}
m_AnchoredPosition: {x: 90.000046, y: -92.44104}
m_SizeDelta: {x: 350, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1091790539
@@ -163903,6 +164281,85 @@ RectTransform:
type: 3}
m_PrefabInstance: {fileID: 1356631037}
m_PrefabAsset: {fileID: 0}
--- !u!1 &1356834055
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1356834056}
- component: {fileID: 1356834058}
- component: {fileID: 1356834057}
m_Layer: 5
m_Name: Classic Signin
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1356834056
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1356834055}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 784992951}
- {fileID: 610801681}
- {fileID: 1742856846}
- {fileID: 1091790538}
m_Father: {fileID: 596196019}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: -160.00003, y: 69.72052}
m_SizeDelta: {x: -320, y: -454.559}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1356834057
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1356834055}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 0}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10907, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &1356834058
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1356834055}
m_CullTransparentMesh: 1
--- !u!1 &1358051968
GameObject:
m_ObjectHideFlags: 0
@@ -163993,6 +164450,78 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1358051968}
m_CullTransparentMesh: 0
--- !u!1 &1360842437
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1360842438}
- component: {fileID: 1360842440}
- component: {fileID: 1360842439}
m_Layer: 5
m_Name: RawImage
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1360842438
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1360842437}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 233390732}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 264.7, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1360842439
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1360842437}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1344c3c82d62a2a41a3576d8abb8e3ea, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.RawImage
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Texture: {fileID: 2800000, guid: 8d524cd6fa08d47c99b2462c2686b8d0, type: 3}
m_UVRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
--- !u!222 &1360842440
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1360842437}
m_CullTransparentMesh: 1
--- !u!1 &1361516379
GameObject:
m_ObjectHideFlags: 0
@@ -181316,6 +181845,144 @@ RectTransform:
m_AnchoredPosition: {x: 100, y: 40}
m_SizeDelta: {x: 160, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &1508533757
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1508533758}
- component: {fileID: 1508533760}
- component: {fileID: 1508533759}
m_Layer: 5
m_Name: Error Text
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1508533758
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1508533757}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 809949465}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: -30.561, y: -117.22}
m_SizeDelta: {x: 261.1221, y: 100.44}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1508533759
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1508533757}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: This is an error.
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4278190335
m_fontColor: {r: 1, g: 0, b: 0, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 256
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: -59.75766, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!222 &1508533760
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1508533757}
m_CullTransparentMesh: 1
--- !u!1 &1509860496
GameObject:
m_ObjectHideFlags: 0
@@ -192593,7 +193260,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -266}
m_AnchoredPosition: {x: 0, y: -295}
m_SizeDelta: {x: 200, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1583232697
@@ -203980,6 +204647,78 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1698798142}
m_CullTransparentMesh: 0
--- !u!1 &1700212690
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1700212691}
- component: {fileID: 1700212693}
- component: {fileID: 1700212692}
m_Layer: 5
m_Name: RawImage
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1700212691
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1700212690}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 578365697}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1700212692
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1700212690}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1344c3c82d62a2a41a3576d8abb8e3ea, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.RawImage
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Texture: {fileID: 2800000, guid: 082e5448b115147a4b3ad1b92d3521ed, type: 3}
m_UVRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
--- !u!222 &1700212693
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1700212690}
m_CullTransparentMesh: 1
--- !u!1 &1702063421
GameObject:
m_ObjectHideFlags: 0
@@ -208053,16 +208792,16 @@ RectTransform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1742856845}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 596196019}
m_Father: {fileID: 1356834056}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: -230, y: -250}
m_AnchoredPosition: {x: -199.99997, y: -92.44104}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1742856847
@@ -213303,7 +214042,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0.5}
m_AnchorMax: {x: 0, y: 0.5}
m_AnchoredPosition: {x: 737, y: -266}
m_AnchoredPosition: {x: 737, y: -295}
m_SizeDelta: {x: 80, y: 40}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1795546185
@@ -253,5 +253,8 @@
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\api\command\util\expanded_combat_unit.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
<Link>src\main\protobuf\net\eagle0\eagle\api\command\util\expanded_combat_unit.proto</Link>
</Protobuf>
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\api\auth.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
<Link>src\main\protobuf\net\eagle0\eagle\api\auth.proto</Link>
</Protobuf>
</ItemGroup>
</Project>
@@ -359,79 +359,86 @@ class EagleServiceImpl(
responseObserver: SyncResponseObserver
): Unit = {
lockAndDoWithUserName { userName =>
println(
s"got heartbeat from $userName. Client timestamp ${request.clientTimestamp}, server timestamp ${System.currentTimeMillis()}"
)
// Synchronize on gamesManager to ensure heartbeat response is sent AFTER
// any pending game updates have been queued. Without this, there's a race
// condition where the heartbeat response can interleave with game updates,
// causing clients to see a sync mismatch before receiving all updates.
// This particularly affects slower/remote connections (e.g., Windows clients).
gamesManager.synchronized {
println(
s"got heartbeat from $userName. Client timestamp ${request.clientTimestamp}, server timestamp ${System.currentTimeMillis()}"
)
// Verify sync status for each game the client reports
val gameSyncResults = request.gameSyncStatuses.flatMap { clientGameStatus =>
gamesManager.gameControllerInfos.get(clientGameStatus.gameId).flatMap { controllerInfo =>
val controller = controllerInfo.controller
controller.userNameToFactionId.get(userName).map { factionId =>
val serverUnfilteredCount = controller.engine.history.count
// Verify sync status for each game the client reports
val gameSyncResults = request.gameSyncStatuses.flatMap { clientGameStatus =>
gamesManager.gameControllerInfos.get(clientGameStatus.gameId).flatMap { controllerInfo =>
val controller = controllerInfo.controller
controller.userNameToFactionId.get(userName).map { factionId =>
val serverUnfilteredCount = controller.engine.history.count
// Check Shardok sync status
val shardokSyncResults = clientGameStatus.shardokSyncStatuses.map { clientShardokStatus =>
val serverShardokCount = controller.engine.history.shardokPlayerCount(
clientShardokStatus.shardokGameId,
factionId
)
ShardokSyncResult(
shardokGameId = clientShardokStatus.shardokGameId,
inSync = clientShardokStatus.filteredResultCount == serverShardokCount,
serverFilteredResultCount = serverShardokCount
// Check Shardok sync status
val shardokSyncResults = clientGameStatus.shardokSyncStatuses.map { clientShardokStatus =>
val serverShardokCount = controller.engine.history.shardokPlayerCount(
clientShardokStatus.shardokGameId,
factionId
)
ShardokSyncResult(
shardokGameId = clientShardokStatus.shardokGameId,
inSync = clientShardokStatus.filteredResultCount == serverShardokCount,
serverFilteredResultCount = serverShardokCount
)
}
GameSyncResult(
gameId = clientGameStatus.gameId,
eagleInSync = clientGameStatus.unfilteredResultCount == serverUnfilteredCount,
serverUnfilteredResultCount = serverUnfilteredCount,
shardokSyncResults = shardokSyncResults
)
}
GameSyncResult(
gameId = clientGameStatus.gameId,
eagleInSync = clientGameStatus.unfilteredResultCount == serverUnfilteredCount,
serverUnfilteredResultCount = serverUnfilteredCount,
shardokSyncResults = shardokSyncResults
)
}
}
}
// Only include sync results if there are mismatches (to reduce message size)
val mismatchedResults = gameSyncResults.filter { result =>
!result.eagleInSync || result.shardokSyncResults.exists(!_.inSync)
}
if mismatchedResults.nonEmpty then {
// Include both client and server counts for debugging
val mismatchDetails = mismatchedResults.zip(request.gameSyncStatuses).map {
case (result, clientStatus) =>
val eagleDetail =
if !result.eagleInSync then
s"eagle: client=${clientStatus.unfilteredResultCount} server=${result.serverUnfilteredResultCount}"
else "eagle: in_sync"
val shardokDetails = result.shardokSyncResults.filter(!_.inSync).map { sr =>
val clientCount = clientStatus.shardokSyncStatuses
.find(_.shardokGameId == sr.shardokGameId)
.map(_.filteredResultCount)
.getOrElse(-1)
s"shardok[${sr.shardokGameId}]: client=$clientCount server=${sr.serverFilteredResultCount}"
}
s"game ${result.gameId}: $eagleDetail${
if shardokDetails.nonEmpty then ", " + shardokDetails.mkString(", ") else ""
}"
// Only include sync results if there are mismatches (to reduce message size)
val mismatchedResults = gameSyncResults.filter { result =>
!result.eagleInSync || result.shardokSyncResults.exists(!_.inSync)
}
println(s"[HEARTBEAT] Detected sync mismatches for $userName: ${mismatchDetails.mkString("; ")}")
}
val _ = responseObserver.onNext(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.HeartbeatResponse(
HeartbeatResponse(
serverTimestamp = System.currentTimeMillis(),
gameSyncResults = mismatchedResults
if mismatchedResults.nonEmpty then {
// Include both client and server counts for debugging
val mismatchDetails = mismatchedResults.zip(request.gameSyncStatuses).map {
case (result, clientStatus) =>
val eagleDetail =
if !result.eagleInSync then
s"eagle: client=${clientStatus.unfilteredResultCount} server=${result.serverUnfilteredResultCount}"
else "eagle: in_sync"
val shardokDetails = result.shardokSyncResults.filter(!_.inSync).map { sr =>
val clientCount = clientStatus.shardokSyncStatuses
.find(_.shardokGameId == sr.shardokGameId)
.map(_.filteredResultCount)
.getOrElse(-1)
s"shardok[${sr.shardokGameId}]: client=$clientCount server=${sr.serverFilteredResultCount}"
}
s"game ${result.gameId}: $eagleDetail${
if shardokDetails.nonEmpty then ", " + shardokDetails.mkString(", ") else ""
}"
}
println(s"[HEARTBEAT] Detected sync mismatches for $userName: ${mismatchDetails.mkString("; ")}")
}
val _ = responseObserver.onNext(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.HeartbeatResponse(
HeartbeatResponse(
serverTimestamp = System.currentTimeMillis(),
gameSyncResults = mismatchedResults
)
)
)
)
)
}
}
()
}