mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 10:35:42 +00:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eea60e40d | ||
|
|
14a2eab47d | ||
|
|
481f42d01c | ||
|
|
49af7c7524 | ||
|
|
b6dcc48bdd | ||
|
|
7d5320220c | ||
|
|
3eda670759 | ||
|
|
ea79b80d7b | ||
|
|
2da74769b9 | ||
|
|
bb52212f9f | ||
|
|
81e8aad93b | ||
|
|
f0bb59b5e5 | ||
|
|
ff09bda820 | ||
|
|
09919e63eb | ||
|
|
65d5e6867e | ||
|
|
f6c52ad5ab | ||
|
|
3f1f9ebf07 | ||
|
|
9c279d85bd | ||
|
|
e91e349d76 | ||
|
|
358086154d | ||
|
|
7a6e871914 | ||
|
|
287b31f4c2 | ||
|
|
18c64713bc | ||
|
|
cb19483f34 | ||
|
|
19e2f22b57 | ||
|
|
d8d3cb1317 | ||
|
|
a999f8b936 | ||
|
|
5035fe1a70 | ||
|
|
1c1066c1d8 | ||
|
|
d3d72ff8dc | ||
|
|
149872bea7 |
@@ -10,3 +10,4 @@
|
||||
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
|
||||
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
|
||||
*.herodata filter=lfs diff=lfs merge=lfs -text
|
||||
*.ogg filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
+63
-25
@@ -126,6 +126,23 @@ All 26 tracks have CC licenses with proper attribution:
|
||||
|
||||
---
|
||||
|
||||
## 2b. Creative Commons Sound Effects
|
||||
|
||||
**Location:** `Assets/Shardok/Sounds/`
|
||||
|
||||
| File | Description | Artist | License | Source |
|
||||
|------|-------------|--------|---------|--------|
|
||||
| `rain_loop.ogg` | Rain falling on clay roof tiles (loopable) | aesqe | CC BY 4.0 | [Freesound #37618](https://freesound.org/people/aesqe/sounds/37618/) |
|
||||
| `blizzard_wind_loop.wav` | Wind draft loop (indoor recording, loops seamlessly) | nsstudios | CC BY 4.0 | [Freesound #651540](https://freesound.org/people/nsstudios/sounds/651540/) |
|
||||
|
||||
**Location:** `Assets/Shardok/soundEffects/`
|
||||
|
||||
| File | Description | Artist | License | Source |
|
||||
|------|-------------|--------|---------|--------|
|
||||
| `runaway.mp3` | Medieval army running loop (gravel + metal/chain) | Yap_Audio_Production | CC BY 4.0 | [Freesound #218997](https://freesound.org/people/Yap_Audio_Production/sounds/218997/) |
|
||||
|
||||
---
|
||||
|
||||
## 3. CC0 / Public Domain Assets
|
||||
|
||||
### SimpleFileBrowser Icons
|
||||
@@ -155,11 +172,11 @@ All 26 tracks have CC licenses with proper attribution:
|
||||
- `undead_break_control.mp3`
|
||||
- `undead_grew.wav`
|
||||
|
||||
**⚠️ MUST REPLACE:**
|
||||
**⚠️ MUST REPLACE (1 remaining):**
|
||||
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23) with `Positive Effect 6.wav` from Magic Spells Sound Effects LITE
|
||||
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23) with `Magic Element Fire 04.wav` from Medieval Combat Sounds
|
||||
- `failure_horn.mp3` - licensing issue, no replacement found in purchased assets
|
||||
- `runaway.mp3` - licensing issue, no replacement found in purchased assets
|
||||
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with `MedievalArmyRunningLoop.mp3` from Freesound (CC BY 4.0)
|
||||
|
||||
**Presumed from Unity Asset Store purchases (31 files):**
|
||||
Owner believes these are from: Fantasy Interface Sounds, Medieval Combat Sounds, Magic Spells Sound Effects LITE, and/or Medieval Battle Sound Pack.
|
||||
@@ -171,10 +188,16 @@ Owner believes these are from: Fantasy Interface Sounds, Medieval Combat Sounds,
|
||||
- `melee.mp3`, `meteor.mp3`, `mind_control.mp3`, `move.mp3`, `move 1.mp3`
|
||||
- `raging_fire.mp3`, `reduce.mp3`, `repair.mp3`, `repair_failed.mp3`, `splash.mp3`
|
||||
|
||||
### Free Icons
|
||||
- **Location:** `Assets/free_icons/`
|
||||
- **Count:** 8 PNG weather icons
|
||||
- **Status:** Verify "free" means commercially usable
|
||||
### ~~Free Icons~~ RESOLVED
|
||||
- **Location:** `Assets/free_icons/` - **DELETED** (2026-01-27)
|
||||
- **Resolution:** All icons replaced with equivalents from purchased Asset Store packs:
|
||||
- `blizzard.png` → `16_blizzard_nobg.png` (4000_Fantasy_Icons)
|
||||
- `rain.png` → `12_Magic_rain_nobg.png` (4000_Fantasy_Icons)
|
||||
- `thunderstorm.png` → `27_Storm_nobg.png` (4000_Fantasy_Icons)
|
||||
- `wind.png` → `23_Light_blow_nobg.png` (4000_Fantasy_Icons)
|
||||
- `thermometer.png` → `startFire.png` (existing licensed asset)
|
||||
- `snow.png` → `16_blizzard_nobg.png` (4000_Fantasy_Icons)
|
||||
- `cloud.png`, `sun.png` → deleted (unused)
|
||||
|
||||
### ~~Terrain Hexes~~ VERIFIED
|
||||
- **Location:** `Assets/Terrain Hexes/`
|
||||
@@ -216,26 +239,31 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
|
||||
|
||||
## Action Items
|
||||
|
||||
### Must Verify Before Opening Public Access:
|
||||
### Must Replace Before Opening Public Access:
|
||||
|
||||
1. ~~**Clip art images**~~ - **RESOLVED** (2025-01-23): Replaced with game-icons.net CC BY 3.0 icons
|
||||
1. ~~**Clip art images**~~ - **RESOLVED** (2026-01-23): Replaced with properly licensed alternatives
|
||||
|
||||
2. **Shardok sound effects** - 3 files must be replaced:
|
||||
- `anybody.mp3` - licensing issue
|
||||
- `burnination.mp3` - licensing issue
|
||||
- `runaway.mp3` - licensing issue
|
||||
2. **Shardok sound effects** - 1 file remaining:
|
||||
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23)
|
||||
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23)
|
||||
- `failure_horn.mp3` - licensing issue, needs replacement
|
||||
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with Freesound CC BY 4.0
|
||||
|
||||
Remaining 31 files presumed from Asset Store purchases; 3 verified from Zombie Monster Undead Collection.
|
||||
### Low Priority (Verify):
|
||||
|
||||
3. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
|
||||
3. **Dima Koltsov tracks** - 2 of 5 not verified: `Forest Queen Tale`, `Clouds` (presumed CC BY 4.0 like his other tracks)
|
||||
|
||||
4. ~~**StrategyGameIcons**~~ - **VERIFIED** (2026-01-23): Unity Asset Store purchase (REXARD)
|
||||
### Already Resolved:
|
||||
|
||||
5. ~~**Medieval: Victory Theme**~~ - **VERIFIED** (2026-01-23): CC0 Public Domain by RandomMind ([Chosic](https://www.chosic.com/download-audio/28492/))
|
||||
4. ~~**Free Icons**~~ - **RESOLVED** (2026-01-27): All replaced with Asset Store equivalents, folder deleted
|
||||
|
||||
6. ~~**Dima Koltsov tracks**~~ - **MOSTLY VERIFIED** (2026-01-23): 3 of 5 confirmed CC BY 4.0 via YouTube. 2 remaining (Forest Queen Tale, Clouds) presumed same license.
|
||||
5. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
|
||||
|
||||
7. ~~**Discord logo**~~ - **OK** (2026-01-23): Usage complies with Discord brand guidelines for "Login with Discord" button
|
||||
6. ~~**StrategyGameIcons**~~ - **VERIFIED** (2026-01-23): Unity Asset Store purchase (REXARD)
|
||||
|
||||
7. ~~**Medieval: Victory Theme**~~ - **VERIFIED** (2026-01-23): CC0 Public Domain by RandomMind ([Chosic](https://www.chosic.com/download-audio/28492/))
|
||||
|
||||
8. ~~**Discord logo**~~ - **OK** (2026-01-23): Usage complies with Discord brand guidelines for "Login with Discord" button
|
||||
|
||||
### Already Safe:
|
||||
|
||||
@@ -249,13 +277,23 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
|
||||
|
||||
## Recommendation
|
||||
|
||||
Before public release:
|
||||
**Remaining before public release:**
|
||||
|
||||
1. ~~Replace clip art images (`bridge.png`, `startFire.png`) with properly licensed alternatives~~ **DONE** - see Section 4
|
||||
2. ~~Verify source of `Assets/Shardok/soundEffects/` MP3s~~ **MOSTLY DONE** - 3 files flagged for replacement, rest presumed Asset Store
|
||||
3. ~~Verify source of `Assets/Terrain Hexes/`~~ **DONE** - confirmed Asset Store purchase
|
||||
4. ~~Verify source of `Assets/StrategyGameIcons/`~~ **DONE** - Unity Asset Store (REXARD)
|
||||
5. ~~Replace Dima Koltsov Audius tracks~~ **MOSTLY DONE** - 3/5 confirmed CC BY 4.0, 2 presumed same
|
||||
6. ~~Find source of Medieval: Victory Theme or replace~~ **DONE** - CC0 Public Domain by RandomMind
|
||||
1. Replace 1 sound effect with licensing issue:
|
||||
- `failure_horn.mp3`
|
||||
|
||||
**Low priority:**
|
||||
|
||||
2. Verify 2 Dima Koltsov tracks (`Forest Queen Tale`, `Clouds`) - presumed CC BY 4.0
|
||||
|
||||
**Already resolved:**
|
||||
|
||||
- ~~Clip art images~~ **DONE** - replaced with properly licensed alternatives
|
||||
- ~~Terrain Hexes~~ **DONE** - confirmed Asset Store purchase
|
||||
- ~~StrategyGameIcons~~ **DONE** - Unity Asset Store (REXARD)
|
||||
- ~~Medieval: Victory Theme~~ **DONE** - CC0 Public Domain
|
||||
- ~~3 other Dima Koltsov tracks~~ **DONE** - confirmed CC BY 4.0
|
||||
- ~~anybody.mp3, burnination.mp3~~ **DONE** - replaced
|
||||
- ~~free_icons~~ **DONE** - replaced with Asset Store equivalents
|
||||
|
||||
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
|
||||
|
||||
@@ -208,6 +208,7 @@
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ExileVassalCommandSelector.cs" />
|
||||
<Compile Include="Assets/Tutorial/TutorialState.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIconEditor.cs" />
|
||||
<Compile Include="Assets/Shardok/WeatherEffectAnimator.cs" />
|
||||
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoop.cs" />
|
||||
<Compile Include="Assets/Eagle/MovingArmiesTableController.cs" />
|
||||
<Compile Include="Assets/ConnectionHandler/ConnectionHandler.cs" />
|
||||
@@ -277,7 +278,6 @@
|
||||
<Compile Include="Assets/Shardok/MeteorAnimator.cs" />
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/ReturnCommandSelector.cs" />
|
||||
<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" />
|
||||
@@ -310,6 +310,7 @@
|
||||
<Compile Include="Assets/Eagle/CommandSelectors/RecruitHeroesCommandSelector.cs" />
|
||||
<Compile Include="Assets/Shardok/CommandTypeUIManager.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/PrisonerReleasedDetailsNotificationGenerator.cs" />
|
||||
<Compile Include="Assets/common/SingleInstanceEnforcer.cs" />
|
||||
<Compile Include="Assets/common/GUIUtils/TableRowClickDetector.cs" />
|
||||
<Compile Include="Assets/PlayerColors.cs" />
|
||||
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/CapturedHeroReturnedDetailsNotificationGenerator.cs" />
|
||||
|
||||
@@ -187,6 +187,23 @@ namespace Auth {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exchange a session transfer code for tokens.
|
||||
/// Used when user clicks the deep link from the invitation download page.
|
||||
/// Routed to Go auth service.
|
||||
/// </summary>
|
||||
public async Task<ExchangeSessionTransferCodeResponse> ExchangeSessionTransferCodeAsync(
|
||||
string code) {
|
||||
Debug.Log("[AuthClient] Exchanging session transfer code via gRPC...");
|
||||
|
||||
var request = new ExchangeSessionTransferCodeRequest { Code = code };
|
||||
var response = await _authServiceClient.ExchangeSessionTransferCodeAsync(request);
|
||||
|
||||
Debug.Log(
|
||||
$"[AuthClient] Session transfer successful: userId={response.User?.UserId}, displayName={response.User?.DisplayName}");
|
||||
return response;
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
_authServiceChannel?.Dispose();
|
||||
_eagleChannel?.Dispose();
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using common;
|
||||
using Grpc.Core;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -20,6 +21,8 @@ namespace Auth {
|
||||
private string _currentAuthServiceUrl;
|
||||
private string _currentEagleUrl;
|
||||
private OAuthProvider _currentLoginProvider; // Track provider during login
|
||||
private string _pendingSessionTransferCode; // Queued if deep link arrives before auth
|
||||
// client ready
|
||||
|
||||
// Events for UI updates
|
||||
public event Action<UserInfo> OnLoginSuccess;
|
||||
@@ -49,6 +52,9 @@ namespace Auth {
|
||||
// Register deep link handler for OAuth callback
|
||||
Application.deepLinkActivated += OnDeepLinkActivated;
|
||||
|
||||
// On Windows, listen for deep links from other instances via named pipe
|
||||
SingleInstanceEnforcer.OnDeepLinkReceived += OnDeepLinkActivated;
|
||||
|
||||
// Check if app was launched via deep link
|
||||
if (!string.IsNullOrEmpty(Application.absoluteURL)) {
|
||||
OnDeepLinkActivated(Application.absoluteURL);
|
||||
@@ -73,6 +79,17 @@ namespace Auth {
|
||||
private void OnDeepLinkActivated(string url) {
|
||||
Debug.Log($"[OAuthManager] Deep link received: {url}");
|
||||
|
||||
// Handle session transfer deep link: eagle0://auth/session/{code}
|
||||
const string sessionTransferPrefix = "eagle0://auth/session/";
|
||||
if (url.StartsWith(sessionTransferPrefix)) {
|
||||
var code = url.Substring(sessionTransferPrefix.Length).TrimEnd('/');
|
||||
Debug.Log(
|
||||
$"[OAuthManager] Session transfer deep link received, code length: {code.Length}");
|
||||
WindowFocusManager.BringToForeground();
|
||||
_ = HandleSessionTransferAsync(code);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle eagle0://auth/complete - brings app to foreground after OAuth
|
||||
if (url.StartsWith("eagle0://auth")) {
|
||||
Debug.Log("[OAuthManager] OAuth callback deep link - bringing app to foreground");
|
||||
@@ -80,6 +97,43 @@ namespace Auth {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle session transfer code from invitation deep link.
|
||||
/// Exchanges the one-time code for tokens and logs the user in.
|
||||
/// </summary>
|
||||
private async Task HandleSessionTransferAsync(string code) {
|
||||
if (_authClient == null) {
|
||||
// Auth client not ready yet - queue the code for later processing
|
||||
Debug.Log(
|
||||
"[OAuthManager] Auth client not ready, queuing session transfer code for later");
|
||||
_pendingSessionTransferCode = code;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Debug.Log("[OAuthManager] Exchanging session transfer code for tokens...");
|
||||
var response = await _authClient.ExchangeSessionTransferCodeAsync(code);
|
||||
|
||||
// Store tokens with actual provider from response
|
||||
var providerName = response.User.Provider.ToString().ToLowerInvariant();
|
||||
TokenStorage.StoreTokens(
|
||||
response.AccessToken,
|
||||
response.RefreshToken,
|
||||
response.ExpiresAt,
|
||||
response.User.UserId,
|
||||
response.User.DisplayName,
|
||||
providerName);
|
||||
|
||||
Debug.Log(
|
||||
$"[OAuthManager] Session transfer successful for {response.User.DisplayName}");
|
||||
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Session transfer failed: {ex.Message}");
|
||||
OnLoginFailed?.Invoke($"Sign-in link expired or invalid. Please sign in manually.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the server URLs for OAuth and game requests. Call this before any OAuth operations.
|
||||
/// </summary>
|
||||
@@ -97,10 +151,19 @@ namespace Auth {
|
||||
_currentEagleUrl = eagleUrl;
|
||||
_authClient = new AuthClient(authServiceUrl, eagleUrl);
|
||||
Debug.Log($"[OAuthManager] Configured: authService={authServiceUrl}, eagle={eagleUrl}");
|
||||
|
||||
// Process any pending session transfer that arrived before auth client was ready
|
||||
if (!string.IsNullOrEmpty(_pendingSessionTransferCode)) {
|
||||
var code = _pendingSessionTransferCode;
|
||||
_pendingSessionTransferCode = null;
|
||||
Debug.Log("[OAuthManager] Processing queued session transfer code");
|
||||
_ = HandleSessionTransferAsync(code);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy() {
|
||||
Application.deepLinkActivated -= OnDeepLinkActivated;
|
||||
SingleInstanceEnforcer.OnDeepLinkReceived -= OnDeepLinkActivated;
|
||||
_authClient?.Dispose();
|
||||
if (Instance == this) { Instance = null; }
|
||||
}
|
||||
@@ -218,6 +281,13 @@ namespace Auth {
|
||||
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
return true;
|
||||
} catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) {
|
||||
// User no longer exists on server - remove the invalid account
|
||||
var accountKey = TokenStorage.CurrentAccount?.AccountKey;
|
||||
Debug.LogWarning(
|
||||
$"[OAuthManager] User not found on server, removing invalid account: {accountKey}");
|
||||
if (accountKey != null) { TokenStorage.RemoveAccount(accountKey); }
|
||||
return false;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
|
||||
TokenStorage.Clear();
|
||||
@@ -308,6 +378,13 @@ namespace Auth {
|
||||
$"[OAuthManager] Connected with stored account: {response.User.DisplayName}");
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
return true;
|
||||
} catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) {
|
||||
// User no longer exists on server - remove the invalid account
|
||||
Debug.LogWarning(
|
||||
$"[OAuthManager] User not found on server, removing account: {account.AccountKey}");
|
||||
TokenStorage.RemoveAccount(account.AccountKey);
|
||||
OnLoginFailed?.Invoke("Account no longer exists. Please sign in again.");
|
||||
return false;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
|
||||
OnLoginFailed?.Invoke("Session invalid. Please sign in again.");
|
||||
|
||||
+14
-14
@@ -94,8 +94,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 30
|
||||
m_fontSizeBase: 30
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -152,11 +152,11 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 450
|
||||
m_MinWidth: 240
|
||||
m_MinHeight: 60
|
||||
m_PreferredWidth: 450
|
||||
m_PreferredWidth: 240
|
||||
m_PreferredHeight: 60
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &3769343953609136136
|
||||
@@ -253,8 +253,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 36
|
||||
m_fontSizeBase: 36
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -311,9 +311,9 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 180
|
||||
m_MinWidth: 200
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 180
|
||||
m_PreferredWidth: 200
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
@@ -540,7 +540,7 @@ MonoBehaviour:
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 200
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &4995675505853930853
|
||||
@@ -636,8 +636,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 36
|
||||
m_fontSizeBase: 36
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -1285,9 +1285,9 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 700
|
||||
m_MinWidth: 300
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 700
|
||||
m_PreferredWidth: 300
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
|
||||
+6
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using eagle;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
@@ -73,6 +74,11 @@ public class AvailableGameItem : MonoBehaviour {
|
||||
});
|
||||
}
|
||||
|
||||
// Pass pre-resolved names from AvailableLeader.Name so names display
|
||||
// immediately without needing to look them up via ClientTextProvider
|
||||
heroDropdownController.PreResolvedNames =
|
||||
leaders.Where(l => !string.IsNullOrEmpty(l.Name))
|
||||
.ToDictionary(l => l.NameTextId, l => l.Name);
|
||||
heroDropdownController.FallbackText = "Random";
|
||||
heroDropdownController.AvailableHeroes = heroOptions;
|
||||
}
|
||||
|
||||
+35
-12
@@ -150,8 +150,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
private readonly Object pendingReplyLock = new Object();
|
||||
private int _connectedEnvironmentIndex = -1; // -1 means not connected
|
||||
private List<StoredAccountButton> _storedAccountButtons = new();
|
||||
|
||||
public ClientPregeneratedText clientPregeneratedText;
|
||||
private bool _exitedFullscreenForNewUser = false; // Track if we exited fullscreen for login
|
||||
private FullScreenMode _previousFullScreenMode; // Store mode to restore after login
|
||||
private int _previousWidth; // Store resolution to restore after login
|
||||
private int _previousHeight;
|
||||
|
||||
const String BaseDomain = "eagle0.net";
|
||||
const String EnvironmentKey = "environmentKey";
|
||||
@@ -192,10 +194,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
_handleLobbyResponse(lobbyResponse);
|
||||
}
|
||||
|
||||
public void ReceivePregeneratedTextUpdate(PregeneratedTextResponse response) {
|
||||
clientPregeneratedText.Load(response.PregeneratedText);
|
||||
}
|
||||
|
||||
void Start() {
|
||||
var currentResolution = Screen.currentResolution;
|
||||
var resolutions = Screen.resolutions;
|
||||
@@ -301,6 +299,24 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
var accounts = OAuthManager.Instance?.GetStoredAccounts();
|
||||
if (accounts == null || accounts.Count == 0) {
|
||||
TutorialManager.Instance?.OnGameEvent("auth_panel_shown");
|
||||
// Show hint for users coming from invitation flow
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text =
|
||||
"Came from an invitation? Check your browser for a 'Complete Sign-In' button.";
|
||||
}
|
||||
// Exit fullscreen so user can see browser with the sign-in link
|
||||
if (Screen.fullScreen) {
|
||||
_exitedFullscreenForNewUser = true;
|
||||
_previousFullScreenMode = Screen.fullScreenMode;
|
||||
_previousWidth = Screen.width;
|
||||
_previousHeight = Screen.height;
|
||||
Screen.fullScreen = false;
|
||||
Debug.Log(
|
||||
$"[ConnectionHandler] Exited fullscreen for new user sign-in flow (was {_previousWidth}x{_previousHeight})");
|
||||
}
|
||||
} else {
|
||||
// Clear any previous hint
|
||||
if (oauthStatusText != null) { oauthStatusText.text = ""; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +523,13 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
private void OnOAuthLoginSuccess(UserInfo user) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (oauthStatusText != null) { oauthStatusText.text = $"Welcome, {user.DisplayName}!"; }
|
||||
// Restore fullscreen if we exited it for the new user sign-in flow
|
||||
if (_exitedFullscreenForNewUser) {
|
||||
_exitedFullscreenForNewUser = false;
|
||||
Screen.SetResolution(_previousWidth, _previousHeight, _previousFullScreenMode);
|
||||
Debug.Log(
|
||||
$"[ConnectionHandler] Restored fullscreen: {_previousWidth}x{_previousHeight} {_previousFullScreenMode}");
|
||||
}
|
||||
ConnectWithOAuth();
|
||||
});
|
||||
}
|
||||
@@ -867,13 +890,13 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
_createConnection();
|
||||
|
||||
// If connection creation failed (e.g., no valid token), don't proceed
|
||||
if (_persistentClientConnection == null) {
|
||||
Debug.LogError("[ConnectionHandler] Connection creation failed, cannot proceed");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_persistentClientConnection.SendUpdateStreamRequestAsync(new UpdateStreamRequest {
|
||||
PregeneratedTextRequest =
|
||||
new PregeneratedTextRequest {
|
||||
KnownPregeneratedTextHash = clientPregeneratedText.Hash
|
||||
}
|
||||
});
|
||||
RequestMaps();
|
||||
StartListeningForLobbyUpdates();
|
||||
} catch (RpcException e) {
|
||||
|
||||
+427
-452
File diff suppressed because it is too large
Load Diff
+5
@@ -72,6 +72,11 @@ public class CreateGameItem : MonoBehaviour {
|
||||
});
|
||||
}
|
||||
|
||||
// Pass pre-resolved names from AvailableLeader.Name so names display
|
||||
// immediately without needing to look them up via ClientTextProvider
|
||||
heroDropdownController.PreResolvedNames =
|
||||
leaders.Where(l => !string.IsNullOrEmpty(l.Name))
|
||||
.ToDictionary(l => l.NameTextId, l => l.Name);
|
||||
heroDropdownController.FallbackText = "Random";
|
||||
heroDropdownController.AvailableHeroes = heroOptions;
|
||||
heroDropdownController.HeadshotImage.gameObject.SetActive(false);
|
||||
|
||||
+8
-2
@@ -30,8 +30,14 @@ public class RunningGameItem : MonoBehaviour {
|
||||
this.gameId = gameId;
|
||||
|
||||
gameIdField.text = string.Format("{0:X}", gameId);
|
||||
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
|
||||
var leaderName = textEntry != null ? textEntry.Text : "Hero";
|
||||
// Use pre-resolved name from server if available, otherwise fall back to text lookup
|
||||
string leaderName;
|
||||
if (!string.IsNullOrEmpty(leader.Name)) {
|
||||
leaderName = leader.Name;
|
||||
} else {
|
||||
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
|
||||
leaderName = textEntry != null ? textEntry.Text : "Hero";
|
||||
}
|
||||
leaderField.text = string.Format(
|
||||
"{0} ({1})",
|
||||
leaderName,
|
||||
|
||||
+40
-98
@@ -29,9 +29,9 @@ RectTransform:
|
||||
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: 3084837499067662000}
|
||||
m_RootOrder: 1
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
@@ -101,24 +101,27 @@ MonoBehaviour:
|
||||
m_fontSizeMin: 18
|
||||
m_fontSizeMax: 72
|
||||
m_fontStyle: 0
|
||||
m_HorizontalAlignment: 2
|
||||
m_HorizontalAlignment: 1
|
||||
m_VerticalAlignment: 512
|
||||
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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 1
|
||||
m_EmojiFallbackSupport: 1
|
||||
m_parseCtrlCharacters: 1
|
||||
m_isOrthographic: 1
|
||||
m_isCullingEnabled: 0
|
||||
@@ -149,9 +152,9 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 700
|
||||
m_MinWidth: 300
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 700
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
@@ -185,9 +188,9 @@ RectTransform:
|
||||
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: 3084837499067662000}
|
||||
m_RootOrder: 2
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
@@ -261,20 +264,23 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 1
|
||||
m_EmojiFallbackSupport: 1
|
||||
m_parseCtrlCharacters: 1
|
||||
m_isOrthographic: 1
|
||||
m_isCullingEnabled: 0
|
||||
@@ -305,11 +311,11 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 180
|
||||
m_MinWidth: 300
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 180
|
||||
m_PreferredWidth: 300
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &1722990544386167487
|
||||
@@ -322,8 +328,6 @@ GameObject:
|
||||
m_Component:
|
||||
- component: {fileID: 5405233146667072049}
|
||||
- component: {fileID: 9089165643261525487}
|
||||
- component: {fileID: 3309908850111668078}
|
||||
- component: {fileID: 7352302768073002882}
|
||||
- component: {fileID: 28101565677326836}
|
||||
- component: {fileID: 4394564974352927053}
|
||||
- component: {fileID: 1180739972031081654}
|
||||
@@ -344,10 +348,10 @@ RectTransform:
|
||||
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: 933970514342940104}
|
||||
m_Father: {fileID: 3084837499067662000}
|
||||
m_RootOrder: 3
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
@@ -362,74 +366,6 @@ CanvasRenderer:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1722990544386167487}
|
||||
m_CullTransparentMesh: 0
|
||||
--- !u!114 &3309908850111668078
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1722990544386167487}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 1a12ddbc47b17cd478cb447d1113a22b, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
buttonText: waiting...
|
||||
buttonEvent:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
hoverSound: {fileID: 0}
|
||||
clickSound: {fileID: 0}
|
||||
normalText: {fileID: 1528463102076488116}
|
||||
soundSource: {fileID: 0}
|
||||
useCustomContent: 0
|
||||
enableButtonSounds: 0
|
||||
useHoverSound: 1
|
||||
useClickSound: 1
|
||||
--- !u!114 &7352302768073002882
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1722990544386167487}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: a9d2f9067277ade4e9b47012ea4d9e17, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
_effectGradient:
|
||||
serializedVersion: 2
|
||||
key0: {r: 0, g: 0.5568628, b: 1, a: 1}
|
||||
key1: {r: 0.23529412, g: 0.85457677, b: 0.972549, a: 1}
|
||||
key2: {r: 0, g: 0, b: 0, a: 0}
|
||||
key3: {r: 0, g: 0, b: 0, a: 0}
|
||||
key4: {r: 0, g: 0, b: 0, a: 0}
|
||||
key5: {r: 0, g: 0, b: 0, a: 0}
|
||||
key6: {r: 0, g: 0, b: 0, a: 0}
|
||||
key7: {r: 0, g: 0, b: 0, a: 0}
|
||||
ctime0: 0
|
||||
ctime1: 65535
|
||||
ctime2: 0
|
||||
ctime3: 0
|
||||
ctime4: 0
|
||||
ctime5: 0
|
||||
ctime6: 0
|
||||
ctime7: 0
|
||||
atime0: 0
|
||||
atime1: 65535
|
||||
atime2: 0
|
||||
atime3: 0
|
||||
atime4: 0
|
||||
atime5: 0
|
||||
atime6: 0
|
||||
atime7: 0
|
||||
m_Mode: 0
|
||||
m_NumColorKeys: 2
|
||||
m_NumAlphaKeys: 2
|
||||
_gradientType: 0
|
||||
_blendMode: 2
|
||||
_offset: 0
|
||||
--- !u!114 &28101565677326836
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -474,7 +410,7 @@ MonoBehaviour:
|
||||
m_OnClick:
|
||||
m_PersistentCalls:
|
||||
m_Calls:
|
||||
- m_Target: {fileID: 0}
|
||||
- m_Target: {fileID: 7494010136792472269}
|
||||
m_TargetAssemblyTypeName:
|
||||
m_MethodName: JoinClicked
|
||||
m_Mode: 1
|
||||
@@ -529,11 +465,11 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 200
|
||||
m_MinWidth: 120
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 200
|
||||
m_PreferredWidth: 120
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &3410015936189372563
|
||||
@@ -565,9 +501,9 @@ RectTransform:
|
||||
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: 3084837499067662000}
|
||||
m_RootOrder: 0
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
@@ -630,8 +566,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 30
|
||||
m_fontSizeBase: 30
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -641,20 +577,23 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 1
|
||||
m_EmojiFallbackSupport: 1
|
||||
m_parseCtrlCharacters: 1
|
||||
m_isOrthographic: 1
|
||||
m_isCullingEnabled: 0
|
||||
@@ -685,11 +624,11 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 450
|
||||
m_MinWidth: 240
|
||||
m_MinHeight: 60
|
||||
m_PreferredWidth: 450
|
||||
m_PreferredWidth: 240
|
||||
m_PreferredHeight: 60
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &7494010136792472269
|
||||
@@ -722,13 +661,13 @@ RectTransform:
|
||||
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: 4435729753412350698}
|
||||
- {fileID: 590678445898122332}
|
||||
- {fileID: 9118077588552391645}
|
||||
- {fileID: 5405233146667072049}
|
||||
m_Father: {fileID: 0}
|
||||
m_RootOrder: 0
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
@@ -843,9 +782,9 @@ RectTransform:
|
||||
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: 5405233146667072049}
|
||||
m_RootOrder: 0
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 1, y: 1}
|
||||
@@ -908,31 +847,34 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 24
|
||||
m_fontSizeBase: 24
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
m_fontSizeMax: 72
|
||||
m_fontStyle: 0
|
||||
m_HorizontalAlignment: 2
|
||||
m_HorizontalAlignment: 1
|
||||
m_VerticalAlignment: 512
|
||||
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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 1
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
m_ActiveFontFeatures: 6e72656b
|
||||
m_enableExtraPadding: 0
|
||||
checkPaddingRequired: 0
|
||||
m_isRichText: 1
|
||||
m_EmojiFallbackSupport: 1
|
||||
m_parseCtrlCharacters: 1
|
||||
m_isOrthographic: 1
|
||||
m_isCullingEnabled: 0
|
||||
|
||||
+8
-2
@@ -16,8 +16,14 @@ public class WaitingGameItem : MonoBehaviour {
|
||||
gameIdField.text = string.Format("{0:X}", gameId);
|
||||
playerCountField.text = playersString;
|
||||
|
||||
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
|
||||
var leaderName = textEntry != null ? textEntry.Text : "Hero";
|
||||
// Use pre-resolved name from server if available, otherwise fall back to text lookup
|
||||
string leaderName;
|
||||
if (!string.IsNullOrEmpty(leader.Name)) {
|
||||
leaderName = leader.Name;
|
||||
} else {
|
||||
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
|
||||
leaderName = textEntry != null ? textEntry.Text : "Hero";
|
||||
}
|
||||
leaderField.text = string.Format(
|
||||
"{0} ({1})",
|
||||
leaderName,
|
||||
|
||||
+7
-1
@@ -44,7 +44,13 @@ namespace eagle {
|
||||
var wasEmpty = _entries.Count == 0;
|
||||
_entries = value != null ? value.ToList() : new List<ChronicleEntry>();
|
||||
|
||||
if (_entries.Count == 0) return;
|
||||
if (_entries.Count == 0) {
|
||||
// Hide canvas if entries are cleared while visible (e.g., during state resync).
|
||||
// Without this, user could click dismiss while entries are empty, causing
|
||||
// crash.
|
||||
if (gameObject.activeSelf) { gameObject.SetActive(false); }
|
||||
return;
|
||||
}
|
||||
|
||||
// Jump to the last entry when first populating, or if not currently viewing
|
||||
if (wasEmpty || !gameObject.activeSelf) {
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Google.Protobuf;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
using UnityEngine;
|
||||
|
||||
namespace eagle {
|
||||
public class ClientPregeneratedText : MonoBehaviour {
|
||||
public static ClientPregeneratedText Provider = null;
|
||||
|
||||
public void Load(PregeneratedText incomingText) {
|
||||
var directoryPath = Path.GetDirectoryName(_localPregeneratedTextPath);
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
|
||||
_pregeneratedTexts = incomingText.Entries.ToDictionary(x => x.TextId, x => x.Text);
|
||||
Hash = incomingText.Hash;
|
||||
|
||||
File.WriteAllBytes(_localPregeneratedTextPath, incomingText.ToByteArray());
|
||||
}
|
||||
|
||||
public void Awake() {
|
||||
_localPregeneratedTextPath = Path.Combine(
|
||||
Application.persistentDataPath,
|
||||
"eagle0",
|
||||
"Resources",
|
||||
"pregenerated_text.txt");
|
||||
|
||||
if (File.Exists(_localPregeneratedTextPath)) {
|
||||
var data = File.ReadAllBytes(_localPregeneratedTextPath);
|
||||
var fromFile = PregeneratedText.Parser.ParseFrom(data);
|
||||
_pregeneratedTexts = fromFile.Entries.ToDictionary(x => x.TextId, x => x.Text);
|
||||
Hash = fromFile.Hash;
|
||||
} else {
|
||||
_pregeneratedTexts = new();
|
||||
Hash = 0;
|
||||
}
|
||||
|
||||
Provider = this;
|
||||
}
|
||||
|
||||
public Int64 Hash { get; private set; }
|
||||
public bool TryGetText(string textId, out string text) =>
|
||||
_pregeneratedTexts.TryGetValue(textId, out text);
|
||||
|
||||
private string _localPregeneratedTextPath;
|
||||
private Dictionary<string, string> _pregeneratedTexts = new();
|
||||
}
|
||||
}
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 347714dfb2a64d76ac4b4d87060e6159
|
||||
timeCreated: 1717958518
|
||||
@@ -104,10 +104,6 @@ namespace eagle {
|
||||
public TextEntry GetTextEntry(string streamId) {
|
||||
if (String.IsNullOrEmpty(streamId)) return new TextEntry("", false);
|
||||
|
||||
if (ClientPregeneratedText.Provider.TryGetText(streamId, out var text)) {
|
||||
return new TextEntry(text, true);
|
||||
}
|
||||
|
||||
lock (_lock) {
|
||||
_streamingTexts.TryGetValue(streamId, out var entry);
|
||||
return entry;
|
||||
|
||||
+4
-1
@@ -126,9 +126,12 @@ namespace eagle {
|
||||
DestinationProvince is { RulingFactionId : not null } &&
|
||||
DestinationProvince.RulingFactionId.Value != _playerId;
|
||||
|
||||
// Only warn about river crossing if destination is NOT owned by the same faction
|
||||
// (friendly territory doesn't require assault)
|
||||
private bool RequiresRiverCrossing => SelectedDestinationProvinceIndex is {}
|
||||
idx && _sortedAvailableDestinations != null && idx < _sortedAvailableDestinations.Count &&
|
||||
_sortedAvailableDestinations[idx].RequiresRiverCrossing;
|
||||
_sortedAvailableDestinations[idx].RequiresRiverCrossing &&
|
||||
DestinationRulingFactionId != _playerId;
|
||||
|
||||
public override bool WarnOnCommitButton =>
|
||||
WouldAbandonProvince || NotEnoughFood || MarchingOnTrucePartner ||
|
||||
|
||||
+26
-8
@@ -20,6 +20,13 @@ namespace eagle {
|
||||
public accessor ConditionAccessor = hero => hero.Vigor;
|
||||
public string ConditionName = "VIG";
|
||||
|
||||
/// <summary>
|
||||
/// Optional dictionary of pre-resolved names keyed by NameTextId.
|
||||
/// Set this before setting AvailableHeroes to use resolved names directly
|
||||
/// instead of looking them up via ClientTextProvider.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> PreResolvedNames { get; set; } = new();
|
||||
|
||||
private List<HeroView> _availableHeroes;
|
||||
public List<HeroView> AvailableHeroes {
|
||||
get => _availableHeroes;
|
||||
@@ -32,17 +39,28 @@ namespace eagle {
|
||||
listeners = new List<SingleEntryListener>();
|
||||
for (int i = 0; i < _availableHeroes.Count; i++) {
|
||||
HeroView hero = _availableHeroes[i];
|
||||
var listener = new SingleEntryListener(
|
||||
hero.NameTextId,
|
||||
"",
|
||||
var suffix =
|
||||
showCondition
|
||||
? $" ({GUIUtils.ConditionString(ConditionAccessor(hero), ConditionName)})"
|
||||
: "",
|
||||
i,
|
||||
SetGeneratedText);
|
||||
: "";
|
||||
|
||||
listeners.Add(listener);
|
||||
ClientTextProvider.Provider.AddListener(listener);
|
||||
// If we have a pre-resolved name, use it directly
|
||||
if (!string.IsNullOrEmpty(hero.NameTextId) &&
|
||||
PreResolvedNames.TryGetValue(hero.NameTextId, out var resolvedName) &&
|
||||
!string.IsNullOrEmpty(resolvedName)) {
|
||||
SetGeneratedText(i, resolvedName + suffix);
|
||||
} else {
|
||||
// Fall back to listener-based resolution
|
||||
var listener = new SingleEntryListener(
|
||||
hero.NameTextId,
|
||||
"",
|
||||
suffix,
|
||||
i,
|
||||
SetGeneratedText);
|
||||
|
||||
listeners.Add(listener);
|
||||
ClientTextProvider.Provider.AddListener(listener);
|
||||
}
|
||||
}
|
||||
|
||||
SetHeadshot();
|
||||
|
||||
@@ -543,7 +543,13 @@ namespace eagle {
|
||||
_currentModel.GsView = startingState;
|
||||
_currentModel.BattalionTypes =
|
||||
startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
|
||||
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
|
||||
// Don't overwrite existing chronicle entries with an empty list.
|
||||
// Chronicle entries are historical records that accumulate over time.
|
||||
// A stale StartingState (e.g., from early in the game before chronicles existed)
|
||||
// arriving after a current state could otherwise wipe out existing entries.
|
||||
if (startingState.ChronicleEntries.Any() || _currentModel.ChronicleEntries == null) {
|
||||
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
|
||||
}
|
||||
|
||||
// For any outstanding battles in the new state, create models and mark for resync.
|
||||
bool needsResubscribe = false;
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ namespace eagle.Notifications {
|
||||
{ AllianceRejectedDetails, AllianceRejectedDetailsNotificationGenerator.Generator },
|
||||
{ BreakAllianceAcceptedDetails, BreakAllianceAcceptedNotificationGenerator.Generator },
|
||||
{ VassalExiledDetails, VassalExiledDetailsNotificationGenerator.Generator },
|
||||
{ VassalRisesDetails, VassalRisesDetailsNotificationGenerator.Generator },
|
||||
{ WithdrewForTruce, WithdrewForTruceDetailsNotificationGenerator.Generator },
|
||||
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator },
|
||||
{ NewFactionHeadDetails, NewFactionHeadDetailsNotificationGenerator.Generator }
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
using ProvinceId = Int32;
|
||||
using HeroId = Int32;
|
||||
|
||||
public static class VassalRisesDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static readonly Random Random = new();
|
||||
|
||||
private static readonly List<string> NotificationTitles = new() {
|
||||
"A Vassal Rises",
|
||||
"From Servant to Leader",
|
||||
"Unexpected Succession",
|
||||
"Rise of the Unsung",
|
||||
"Fate's Choice",
|
||||
"The Loyal Promoted",
|
||||
"New Dawn"
|
||||
};
|
||||
|
||||
private static string GetNotificationTitle() {
|
||||
return NotificationTitles[Random.Next(NotificationTitles.Count)];
|
||||
}
|
||||
|
||||
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
|
||||
foreach (var province in model.Provinces.Values) {
|
||||
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
|
||||
return province.Id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
Net.Eagle0.Eagle.Common.Notification notification,
|
||||
IGameModel currentModel) {
|
||||
var details = notification.Details.VassalRisesDetails;
|
||||
var risingHero = currentModel.Heroes[details.RisingHeroId];
|
||||
|
||||
string textTemplate;
|
||||
List<ProvinceId> affectedProvinces;
|
||||
|
||||
if (details.FactionId == currentModel.PlayerId) {
|
||||
// Player's own faction - a vassal has risen to lead
|
||||
if (details.IsOnlyLeader) {
|
||||
textTemplate =
|
||||
"With all your faction's leaders fallen or imprisoned, your loyal vassal {RisingHero} has risen to lead your forces.\n\n";
|
||||
} else {
|
||||
textTemplate =
|
||||
"Your vassal {RisingHero} has risen to join the leadership of your faction.\n\n";
|
||||
}
|
||||
|
||||
var heroProvinceId = FindProvinceForHero(details.RisingHeroId, currentModel);
|
||||
affectedProvinces = heroProvinceId.HasValue
|
||||
? new List<ProvinceId> { heroProvinceId.Value }
|
||||
: currentModel.ProvincesForFaction(details.FactionId);
|
||||
} else {
|
||||
// Another faction
|
||||
var factionName = currentModel.FactionName(details.FactionId);
|
||||
if (details.IsOnlyLeader) {
|
||||
textTemplate =
|
||||
$"With all of {factionName}'s leaders fallen or imprisoned, {{RisingHero}} has risen from obscurity to lead their forces.\n\n";
|
||||
} else {
|
||||
textTemplate =
|
||||
$"{{RisingHero}} has risen to join the leadership of {factionName}.\n\n";
|
||||
}
|
||||
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
|
||||
}
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
|
||||
{ "RisingHero", (risingHero.NameTextId, "A vassal") }
|
||||
};
|
||||
|
||||
var displayedHeroes = new List<HeroView> { risingHero };
|
||||
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: GetNotificationTitle(),
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: affectedProvinces,
|
||||
displayedHeroes: displayedHeroes);
|
||||
}
|
||||
}
|
||||
}
|
||||
-14
@@ -26,7 +26,6 @@ namespace eagle {
|
||||
|
||||
public interface ILobbySubscriber {
|
||||
void ReceiveLobbyUpdate(LobbyResponse lobbyResponse);
|
||||
void ReceivePregeneratedTextUpdate(PregeneratedTextResponse response);
|
||||
void HandleJoinGameResponse(JoinGameResponse response);
|
||||
void HandleCreateGameResponse(CreateGameResponse response);
|
||||
void HandleCustomBattleResponse(CustomBattleResponse response);
|
||||
@@ -1075,19 +1074,6 @@ namespace eagle {
|
||||
break;
|
||||
}
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase
|
||||
.PregeneratedTextResponse: {
|
||||
var lobbySubscriber = _lobbySubscriber;
|
||||
if (lobbySubscriber != null) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
lobbySubscriber.ReceivePregeneratedTextUpdate(
|
||||
current.PregeneratedTextResponse);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.JoinGameResponse: {
|
||||
var lobbySubscriber = _lobbySubscriber;
|
||||
if (lobbySubscriber != null) {
|
||||
|
||||
+1
-1
@@ -3405,7 +3405,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: e4bb37b7e56df4d1ab5e894387c9eb26, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2189208f745b6864c918942b534d0648, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
|
||||
+1
-1
@@ -1077,7 +1077,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: e4bb37b7e56df4d1ab5e894387c9eb26, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2189208f745b6864c918942b534d0648, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 04df2e7394da44f258040fe2fa230ab1
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10603,7 +10603,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: 8a8ef7ae2668e41e78c9eaae1d12b76b, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2c8cc471ef7bfd2409974b6128642a22, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -20218,7 +20218,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: e4bb37b7e56df4d1ab5e894387c9eb26, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2189208f745b6864c918942b534d0648, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -24129,7 +24129,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: 9b27e24f2af7a436084d697f568bef8f, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: b7edeab8e9ac55d49a266f812d0ea8d4, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -26891,7 +26891,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: c804420ff739f4ad4b28e0459a58d2c1, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: dbabcdc1307aefb4bae749574b34316a, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -28299,7 +28299,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: ca456c8b4dafa49f8b513162ada0e9e3, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 7ec94926d897644858bd8a23522e1787, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -46148,7 +46148,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: 65d6ab023603741f0aec6308079be48b, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2189208f745b6864c918942b534d0648, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
|
||||
+4
@@ -134,6 +134,7 @@ namespace Shardok {
|
||||
public DismissAnimator dismissAnimator;
|
||||
public FleeAnimator fleeAnimator;
|
||||
public DuelAnimator duelAnimator;
|
||||
public WeatherEffectAnimator weatherEffectAnimator;
|
||||
|
||||
public EagleCommonTextures eagleCommonTextures;
|
||||
|
||||
@@ -574,6 +575,9 @@ namespace Shardok {
|
||||
if (weather != null) {
|
||||
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
|
||||
}
|
||||
|
||||
// Update weather visual effects
|
||||
if (weatherEffectAnimator != null) { weatherEffectAnimator.SetWeather(weather); }
|
||||
}
|
||||
|
||||
if (Model.History.Count == 0) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a8b7c6d5e4f3a1b9c0d8e7f6a5b4c3d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Binary file not shown.
+20
@@ -0,0 +1,20 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c0d9e8f7a6b5c3d2e1f0a9b8c7d6e5f
|
||||
AudioImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 7
|
||||
defaultSettings:
|
||||
loadType: 0
|
||||
sampleRateSetting: 0
|
||||
sampleRateOverride: 44100
|
||||
compressionFormat: 1
|
||||
quality: 1
|
||||
conversionMode: 0
|
||||
preloadAudioData: 1
|
||||
platformSettingOverrides: {}
|
||||
forceToMono: 0
|
||||
normalize: 1
|
||||
ambisonic: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Binary file not shown.
+20
@@ -0,0 +1,20 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b9c8d7e6f5a4b2c1d0e9f8a7b6c5d4e
|
||||
AudioImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 7
|
||||
defaultSettings:
|
||||
loadType: 0
|
||||
sampleRateSetting: 0
|
||||
sampleRateOverride: 44100
|
||||
compressionFormat: 1
|
||||
quality: 1
|
||||
conversionMode: 0
|
||||
preloadAudioData: 1
|
||||
platformSettingOverrides: {}
|
||||
forceToMono: 0
|
||||
normalize: 1
|
||||
ambisonic: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e6c5d4b3a2f1e0d9c8b7a6f5e4d3c2b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Binary file not shown.
+29
-56
@@ -1,12 +1,12 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65d6ab023603741f0aec6308079be48b
|
||||
guid: 8f4a3e7b2c1d5a9e6b0f8c4d7a2e1b3c
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
@@ -20,9 +20,12 @@ TextureImporter:
|
||||
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
|
||||
@@ -31,33 +34,38 @@ TextureImporter:
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
nPOTScale: 1
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 1
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spritePixelsPerUnit: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
@@ -69,42 +77,7 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
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:
|
||||
@@ -113,16 +86,16 @@ TextureImporter:
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
spriteID: 5e97eb03825dee720800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
nameFileIdTable: {}
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Binary file not shown.
+29
-56
@@ -1,12 +1,12 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 297854d016fdc49d9b9d6c7904b00ac4
|
||||
guid: 9a5b4e8c3d2f6b0a7c1e9d5f8a3b2c4d
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
enableMipMap: 0
|
||||
sRGBTexture: 1
|
||||
linearTexture: 0
|
||||
fadeOut: 0
|
||||
@@ -20,9 +20,12 @@ TextureImporter:
|
||||
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
|
||||
@@ -31,33 +34,38 @@ TextureImporter:
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
nPOTScale: 1
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 1
|
||||
wrapV: 1
|
||||
wrapW: 1
|
||||
nPOTScale: 0
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
spriteMode: 0
|
||||
spriteMode: 1
|
||||
spriteExtrude: 1
|
||||
spriteMeshType: 1
|
||||
alignment: 0
|
||||
spritePivot: {x: 0.5, y: 0.5}
|
||||
spritePixelsToUnits: 100
|
||||
spritePixelsPerUnit: 100
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureType: 8
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
@@ -69,42 +77,7 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
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:
|
||||
@@ -113,16 +86,16 @@ TextureImporter:
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
spriteID: 6f08fc14936eef830800000000000000
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
nameFileIdTable: {}
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Shardok.Common;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace Shardok {
|
||||
/// <summary>
|
||||
/// Manages full-screen weather visual effects for the Shardok battle view.
|
||||
/// Effects are continuous and update based on the current weather conditions.
|
||||
/// </summary>
|
||||
public class WeatherEffectAnimator : MonoBehaviour {
|
||||
[Header("References")]
|
||||
[Tooltip("Canvas to render weather effects on (should overlay the game)")]
|
||||
public Canvas weatherCanvas;
|
||||
|
||||
[Tooltip("Full-screen overlay image for brightness/dimming effects")]
|
||||
public Image screenOverlay;
|
||||
|
||||
[Header("Particle Sprites")]
|
||||
[Tooltip("Sprite for rain drops")]
|
||||
public Sprite rainDropSprite;
|
||||
|
||||
[Tooltip("Sprite for snow flakes")]
|
||||
public Sprite snowFlakeSprite;
|
||||
|
||||
[Header("Rain Settings")]
|
||||
[Tooltip("Number of rain particles")]
|
||||
public int rainParticleCount = 200;
|
||||
|
||||
[Tooltip("Rain fall speed")]
|
||||
public float rainSpeed = 800f;
|
||||
|
||||
[Tooltip("Rain color")]
|
||||
public Color rainColor = new Color(0.7f, 0.8f, 1f, 0.6f);
|
||||
|
||||
[Header("Thunderstorm Settings")]
|
||||
[Tooltip("Number of rain particles in thunderstorm")]
|
||||
public int thunderstormParticleCount = 400;
|
||||
|
||||
[Tooltip("Lightning flash interval range (min, max) in seconds")]
|
||||
public Vector2 lightningInterval = new Vector2(1f, 4f);
|
||||
|
||||
[Tooltip("Lightning flash duration")]
|
||||
public float lightningDuration = 0.1f;
|
||||
|
||||
[Tooltip("Lightning flash color")]
|
||||
public Color lightningColor = new Color(1f, 1f, 1f, 0.8f);
|
||||
|
||||
[Header("Snow Settings")]
|
||||
[Tooltip("Number of snow particles")]
|
||||
public int snowParticleCount = 160;
|
||||
|
||||
[Tooltip("Snow fall speed")]
|
||||
public float snowSpeed = 100f;
|
||||
|
||||
[Tooltip("Snow drift amount")]
|
||||
public float snowDrift = 50f;
|
||||
|
||||
[Tooltip("Snow color")]
|
||||
public Color snowColor = new Color(1f, 1f, 1f, 0.8f);
|
||||
|
||||
[Header("Blizzard Settings")]
|
||||
[Tooltip("Number of snow particles in blizzard")]
|
||||
public int blizzardParticleCount = 500;
|
||||
|
||||
[Tooltip("Blizzard snow speed")]
|
||||
public float blizzardSpeed = 300f;
|
||||
|
||||
[Tooltip("Blizzard horizontal wind speed")]
|
||||
public float blizzardWindSpeed = 400f;
|
||||
|
||||
[Header("Sound Effects")]
|
||||
[Tooltip("Looping rain sound for normal rain (gentle pitter-patter)")]
|
||||
public AudioClip rainLoopSound;
|
||||
|
||||
[Tooltip("Looping rain sound for thunderstorm (more intense)")]
|
||||
public AudioClip thunderstormLoopSound;
|
||||
|
||||
[Tooltip("Thunder clap sounds (random one plays on each lightning flash)")]
|
||||
public AudioClip[] thunderSounds;
|
||||
|
||||
[Tooltip("Looping wind sound for blizzard")]
|
||||
public AudioClip blizzardLoopSound;
|
||||
|
||||
[Tooltip("Volume for looping weather sounds")]
|
||||
[Range(0f, 1f)]
|
||||
public float loopVolume = 0.5f;
|
||||
|
||||
[Tooltip("Volume for thunder sound")]
|
||||
[Range(0f, 1f)]
|
||||
public float thunderVolume = 0.8f;
|
||||
|
||||
[Header("Screen Overlay Colors")]
|
||||
[Tooltip("Sun brightness overlay")]
|
||||
public Color sunOverlay = new Color(1f, 1f, 0.9f, 0.05f);
|
||||
|
||||
[Tooltip("Clouds dimming overlay")]
|
||||
public Color cloudsOverlay = new Color(0.5f, 0.5f, 0.6f, 0.15f);
|
||||
|
||||
[Tooltip("Rain dimming overlay")]
|
||||
public Color rainOverlay = new Color(0.4f, 0.45f, 0.5f, 0.2f);
|
||||
|
||||
[Tooltip("Thunderstorm darkness overlay")]
|
||||
public Color thunderstormOverlay = new Color(0.2f, 0.2f, 0.3f, 0.35f);
|
||||
|
||||
[Tooltip("Snow overlay")]
|
||||
public Color snowOverlayColor = new Color(0.8f, 0.85f, 0.9f, 0.1f);
|
||||
|
||||
[Tooltip("Blizzard fog overlay")]
|
||||
public Color blizzardOverlay = new Color(0.9f, 0.9f, 0.95f, 0.4f);
|
||||
|
||||
private Weather.Types.Conditions _currentConditions = Weather.Types.Conditions.Unknown;
|
||||
private int _currentWindSpeed = 0;
|
||||
private List<GameObject> _particles = new List<GameObject>();
|
||||
private float _nextLightningTime;
|
||||
private bool _isLightningFlashing;
|
||||
private float _lightningEndTime;
|
||||
private Color _baseOverlayColor;
|
||||
private RectTransform _canvasRect;
|
||||
private AudioSource _loopAudioSource;
|
||||
private AudioSource _oneShotAudioSource;
|
||||
|
||||
private void Start() {
|
||||
if (weatherCanvas != null) {
|
||||
_canvasRect = weatherCanvas.GetComponent<RectTransform>();
|
||||
}
|
||||
|
||||
// Initialize with no overlay
|
||||
if (screenOverlay != null) { screenOverlay.color = Color.clear; }
|
||||
|
||||
// Create audio sources for weather sounds
|
||||
_loopAudioSource = gameObject.AddComponent<AudioSource>();
|
||||
_loopAudioSource.loop = true;
|
||||
_loopAudioSource.playOnAwake = false;
|
||||
_loopAudioSource.volume = loopVolume;
|
||||
|
||||
_oneShotAudioSource = gameObject.AddComponent<AudioSource>();
|
||||
_oneShotAudioSource.loop = false;
|
||||
_oneShotAudioSource.playOnAwake = false;
|
||||
_oneShotAudioSource.volume = thunderVolume;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the canvas size in local coordinates, accounting for CanvasScaler.
|
||||
/// </summary>
|
||||
private Vector2 GetCanvasSize() {
|
||||
if (_canvasRect != null) { return _canvasRect.rect.size; }
|
||||
return new Vector2(Screen.width, Screen.height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the weather effect to match the current conditions.
|
||||
/// Call this when weather changes.
|
||||
/// </summary>
|
||||
public void SetWeather(Weather weather) {
|
||||
if (weather == null) {
|
||||
ClearEffects();
|
||||
_currentConditions = Weather.Types.Conditions.Unknown;
|
||||
_currentWindSpeed = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
var newConditions = weather.Conditions;
|
||||
int newWindSpeed = weather.Wind?.SpeedInMph ?? 0;
|
||||
|
||||
// Only rebuild if conditions changed significantly
|
||||
if (newConditions != _currentConditions) {
|
||||
_currentConditions = newConditions;
|
||||
_currentWindSpeed = newWindSpeed;
|
||||
RebuildEffects();
|
||||
} else if (Mathf.Abs(newWindSpeed - _currentWindSpeed) > 5) {
|
||||
_currentWindSpeed = newWindSpeed;
|
||||
// Wind speed affects particle movement but doesn't require rebuild
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildEffects() {
|
||||
ClearEffects();
|
||||
|
||||
if (weatherCanvas == null) return;
|
||||
|
||||
switch (_currentConditions) {
|
||||
case Weather.Types.Conditions.Sun: SetOverlayColor(sunOverlay); break;
|
||||
|
||||
case Weather.Types.Conditions.Clouds: SetOverlayColor(cloudsOverlay); break;
|
||||
|
||||
case Weather.Types.Conditions.Rain:
|
||||
SetOverlayColor(rainOverlay);
|
||||
CreateRainParticles(rainParticleCount);
|
||||
PlayLoopSound(rainLoopSound);
|
||||
break;
|
||||
|
||||
case Weather.Types.Conditions.Thunderstorm:
|
||||
SetOverlayColor(thunderstormOverlay);
|
||||
CreateRainParticles(thunderstormParticleCount);
|
||||
ScheduleNextLightning();
|
||||
PlayLoopSound(thunderstormLoopSound);
|
||||
break;
|
||||
|
||||
case Weather.Types.Conditions.Snow:
|
||||
SetOverlayColor(snowOverlayColor);
|
||||
CreateSnowParticles(snowParticleCount, false);
|
||||
break;
|
||||
|
||||
case Weather.Types.Conditions.Blizzard:
|
||||
SetOverlayColor(blizzardOverlay);
|
||||
CreateSnowParticles(blizzardParticleCount, true);
|
||||
PlayLoopSound(blizzardLoopSound);
|
||||
break;
|
||||
|
||||
default: SetOverlayColor(Color.clear); break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetOverlayColor(Color color) {
|
||||
_baseOverlayColor = color;
|
||||
if (screenOverlay != null) { screenOverlay.color = color; }
|
||||
}
|
||||
|
||||
private void PlayLoopSound(AudioClip clip) {
|
||||
if (_loopAudioSource == null || clip == null) return;
|
||||
_loopAudioSource.clip = clip;
|
||||
_loopAudioSource.volume = loopVolume;
|
||||
_loopAudioSource.Play();
|
||||
}
|
||||
|
||||
private void StopLoopSound() {
|
||||
if (_loopAudioSource != null && _loopAudioSource.isPlaying) { _loopAudioSource.Stop(); }
|
||||
}
|
||||
|
||||
private void PlayThunderSound() {
|
||||
if (_oneShotAudioSource == null || thunderSounds == null || thunderSounds.Length == 0)
|
||||
return;
|
||||
AudioClip clip = thunderSounds[Random.Range(0, thunderSounds.Length)];
|
||||
if (clip == null) return;
|
||||
_oneShotAudioSource.volume = thunderVolume;
|
||||
_oneShotAudioSource.PlayOneShot(clip);
|
||||
}
|
||||
|
||||
private void ClearEffects() {
|
||||
foreach (var particle in _particles) {
|
||||
if (particle != null) { Destroy(particle); }
|
||||
}
|
||||
_particles.Clear();
|
||||
|
||||
if (screenOverlay != null) { screenOverlay.color = Color.clear; }
|
||||
|
||||
StopLoopSound();
|
||||
_isLightningFlashing = false;
|
||||
}
|
||||
|
||||
private void CreateRainParticles(int count) {
|
||||
if (rainDropSprite == null) return;
|
||||
|
||||
Vector2 canvasSize = GetCanvasSize();
|
||||
float halfWidth = canvasSize.x / 2f;
|
||||
float halfHeight = canvasSize.y / 2f;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
GameObject drop = new GameObject($"RainDrop_{i}");
|
||||
drop.transform.SetParent(weatherCanvas.transform, false);
|
||||
|
||||
Image img = drop.AddComponent<Image>();
|
||||
img.sprite = rainDropSprite;
|
||||
img.color = rainColor;
|
||||
img.raycastTarget = false;
|
||||
|
||||
RectTransform rt = drop.GetComponent<RectTransform>();
|
||||
// Anchor to center of canvas
|
||||
rt.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
rt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
rt.sizeDelta = new Vector2(4f, 24f);
|
||||
|
||||
// Randomize starting position relative to center
|
||||
float x = Random.Range(-halfWidth, halfWidth);
|
||||
float y = Random.Range(-halfHeight, halfHeight * 1.1f);
|
||||
rt.anchoredPosition = new Vector2(x, y);
|
||||
|
||||
// Slight angle for wind effect
|
||||
float angle = -5f - (_currentWindSpeed * 0.5f);
|
||||
rt.localRotation = Quaternion.Euler(0, 0, angle);
|
||||
|
||||
_particles.Add(drop);
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateSnowParticles(int count, bool isBlizzard) {
|
||||
if (snowFlakeSprite == null) return;
|
||||
|
||||
Vector2 canvasSize = GetCanvasSize();
|
||||
float halfWidth = canvasSize.x / 2f;
|
||||
float halfHeight = canvasSize.y / 2f;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
GameObject flake = new GameObject($"SnowFlake_{i}");
|
||||
flake.transform.SetParent(weatherCanvas.transform, false);
|
||||
|
||||
Image img = flake.AddComponent<Image>();
|
||||
img.sprite = snowFlakeSprite;
|
||||
img.color = snowColor;
|
||||
img.raycastTarget = false;
|
||||
|
||||
RectTransform rt = flake.GetComponent<RectTransform>();
|
||||
// Anchor to center of canvas
|
||||
rt.anchorMin = new Vector2(0.5f, 0.5f);
|
||||
rt.anchorMax = new Vector2(0.5f, 0.5f);
|
||||
rt.pivot = new Vector2(0.5f, 0.5f);
|
||||
float size = Random.Range(6f, 16f);
|
||||
rt.sizeDelta = new Vector2(size, size);
|
||||
|
||||
// Randomize starting position relative to center
|
||||
float x = Random.Range(-halfWidth, halfWidth);
|
||||
float y = Random.Range(-halfHeight, halfHeight * 1.1f);
|
||||
rt.anchoredPosition = new Vector2(x, y);
|
||||
|
||||
// Store random phase for drift animation
|
||||
SnowParticleData data = flake.AddComponent<SnowParticleData>();
|
||||
data.phase = Random.Range(0f, Mathf.PI * 2f);
|
||||
data.driftSpeed = Random.Range(0.5f, 1.5f);
|
||||
data.fallSpeed = Random.Range(0.8f, 1.2f);
|
||||
|
||||
_particles.Add(flake);
|
||||
}
|
||||
}
|
||||
|
||||
private void ScheduleNextLightning() {
|
||||
_nextLightningTime = Time.time + Random.Range(lightningInterval.x, lightningInterval.y);
|
||||
}
|
||||
|
||||
private void Update() {
|
||||
Vector2 canvasSize = GetCanvasSize();
|
||||
float halfWidth = canvasSize.x / 2f;
|
||||
float halfHeight = canvasSize.y / 2f;
|
||||
|
||||
// Update rain particles
|
||||
if (_currentConditions == Weather.Types.Conditions.Rain ||
|
||||
_currentConditions == Weather.Types.Conditions.Thunderstorm) {
|
||||
float speed = _currentConditions == Weather.Types.Conditions.Thunderstorm
|
||||
? rainSpeed * 1.3f
|
||||
: rainSpeed;
|
||||
|
||||
float windOffset = _currentWindSpeed * 2f;
|
||||
|
||||
foreach (var particle in _particles) {
|
||||
if (particle == null) continue;
|
||||
|
||||
RectTransform rt = particle.GetComponent<RectTransform>();
|
||||
Vector2 pos = rt.anchoredPosition;
|
||||
|
||||
pos.y -= speed * Time.deltaTime;
|
||||
pos.x += windOffset * Time.deltaTime;
|
||||
|
||||
// Wrap around when off screen (positions are relative to center)
|
||||
if (pos.y < -halfHeight - 20f) {
|
||||
pos.y = halfHeight + 20f;
|
||||
pos.x = Random.Range(-halfWidth, halfWidth);
|
||||
}
|
||||
if (pos.x > halfWidth + 20f) { pos.x = -halfWidth - 20f; }
|
||||
if (pos.x < -halfWidth - 20f) { pos.x = halfWidth + 20f; }
|
||||
|
||||
rt.anchoredPosition = pos;
|
||||
}
|
||||
}
|
||||
|
||||
// Update snow particles
|
||||
if (_currentConditions == Weather.Types.Conditions.Snow ||
|
||||
_currentConditions == Weather.Types.Conditions.Blizzard) {
|
||||
bool isBlizzard = _currentConditions == Weather.Types.Conditions.Blizzard;
|
||||
float speed = isBlizzard ? blizzardSpeed : snowSpeed;
|
||||
float wind = isBlizzard ? blizzardWindSpeed : snowDrift;
|
||||
|
||||
foreach (var particle in _particles) {
|
||||
if (particle == null) continue;
|
||||
|
||||
RectTransform rt = particle.GetComponent<RectTransform>();
|
||||
SnowParticleData data = particle.GetComponent<SnowParticleData>();
|
||||
Vector2 pos = rt.anchoredPosition;
|
||||
|
||||
// Vertical fall
|
||||
pos.y -= speed * data.fallSpeed * Time.deltaTime;
|
||||
|
||||
// Horizontal drift (sinusoidal for snow, constant for blizzard)
|
||||
if (isBlizzard) {
|
||||
pos.x += wind * Time.deltaTime;
|
||||
} else {
|
||||
float drift = Mathf.Sin(Time.time * data.driftSpeed + data.phase) * wind;
|
||||
pos.x += drift * Time.deltaTime;
|
||||
}
|
||||
|
||||
// Wind effect
|
||||
pos.x += _currentWindSpeed * 0.5f * Time.deltaTime;
|
||||
|
||||
// Wrap around (positions are relative to center)
|
||||
if (pos.y < -halfHeight - 20f) {
|
||||
pos.y = halfHeight + 20f;
|
||||
pos.x = Random.Range(-halfWidth, halfWidth);
|
||||
}
|
||||
if (pos.x > halfWidth + 20f) { pos.x = -halfWidth - 20f; }
|
||||
if (pos.x < -halfWidth - 20f) { pos.x = halfWidth + 20f; }
|
||||
|
||||
rt.anchoredPosition = pos;
|
||||
|
||||
// Slight rotation for snow
|
||||
if (!isBlizzard) { rt.Rotate(0, 0, 30f * Time.deltaTime * data.driftSpeed); }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle lightning flashes
|
||||
if (_currentConditions == Weather.Types.Conditions.Thunderstorm) {
|
||||
if (_isLightningFlashing) {
|
||||
if (Time.time >= _lightningEndTime) {
|
||||
_isLightningFlashing = false;
|
||||
if (screenOverlay != null) { screenOverlay.color = _baseOverlayColor; }
|
||||
ScheduleNextLightning();
|
||||
}
|
||||
} else if (Time.time >= _nextLightningTime) {
|
||||
// Flash!
|
||||
_isLightningFlashing = true;
|
||||
_lightningEndTime = Time.time + lightningDuration;
|
||||
if (screenOverlay != null) { screenOverlay.color = lightningColor; }
|
||||
PlayThunderSound();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy() { ClearEffects(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper component to store per-particle snow animation data.
|
||||
/// </summary>
|
||||
public class SnowParticleData : MonoBehaviour {
|
||||
public float phase;
|
||||
public float driftSpeed;
|
||||
public float fallSpeed;
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 45dc0f911ac9447d7aa3e9279539c459
|
||||
BIN
Binary file not shown.
+3
-2
@@ -954,8 +954,9 @@ namespace Eagle0.Tutorial {
|
||||
Title = "Sign In to Play",
|
||||
Description =
|
||||
"Welcome to <b>Eagle0</b>!\n\n" +
|
||||
"Sign in using the <b>same account</b> you used to create your display name on the website.\n\n" +
|
||||
"Click one of the sign-in buttons below to continue.",
|
||||
"If you just completed the invitation signup, look for the " +
|
||||
"<b>\"Complete Sign-In with Eagle0\"</b> button on the download page in your browser.\n\n" +
|
||||
"Otherwise, sign in below using the <b>same account</b> you used when you created your account.",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
|
||||
@@ -249,7 +249,8 @@ namespace common {
|
||||
}
|
||||
|
||||
private class ImageLoadQueueItem {
|
||||
public RawImage image;
|
||||
public RawImage rawImage; // For LoadIntoRawImage
|
||||
public Image image; // For LoadIntoImage
|
||||
public string path;
|
||||
}
|
||||
|
||||
@@ -266,7 +267,11 @@ namespace common {
|
||||
if (elt != null) {
|
||||
_imageLoadQueue.Remove(elt);
|
||||
|
||||
MainQueue.Q.Enqueue(() => LoadIntoRawImage(elt.image, path));
|
||||
if (elt.rawImage != null) {
|
||||
MainQueue.Q.Enqueue(() => LoadIntoRawImage(elt.rawImage, path));
|
||||
} else if (elt.image != null) {
|
||||
MainQueue.Q.Enqueue(() => LoadIntoImage(elt.image, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,32 +279,86 @@ namespace common {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Helper to create and configure a Texture2D from local bytes (or placeholder if null)
|
||||
private Texture2D CreateTexture(byte[] localBytes) {
|
||||
var tex2D = new Texture2D(1, 1);
|
||||
if (localBytes != null) {
|
||||
tex2D.LoadImage(localBytes);
|
||||
} else {
|
||||
tex2D.SetPixel(0, 0, Color.clear);
|
||||
}
|
||||
return tex2D;
|
||||
}
|
||||
|
||||
// Helper to create a Sprite from a Texture2D
|
||||
private Sprite CreateSprite(Texture2D texture) {
|
||||
return Sprite.Create(
|
||||
texture,
|
||||
new Rect(0, 0, texture.width, texture.height),
|
||||
new Vector2(0.5f, 0.5f));
|
||||
}
|
||||
|
||||
// Helper to clean up existing texture on a RawImage
|
||||
private void CleanupRawImageTexture(RawImage rawImage) {
|
||||
if (rawImage.texture) {
|
||||
var toDestroy = rawImage.texture;
|
||||
rawImage.texture = null;
|
||||
Object.DestroyImmediate(toDestroy, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to clean up existing sprite/texture on an Image
|
||||
private void CleanupImageSprite(Image image) {
|
||||
if (image.sprite != null) {
|
||||
var oldSprite = image.sprite;
|
||||
var oldTexture = oldSprite.texture;
|
||||
image.sprite = null;
|
||||
Object.DestroyImmediate(oldSprite, true);
|
||||
if (oldTexture != null) { Object.DestroyImmediate(oldTexture, true); }
|
||||
}
|
||||
}
|
||||
|
||||
// This must be called from the main thread
|
||||
public void LoadIntoRawImage(RawImage image, string path) {
|
||||
public void LoadIntoRawImage(RawImage rawImage, string path) {
|
||||
string pathToFetch = null;
|
||||
lock (_imageLoadQueue) {
|
||||
_imageLoadQueue.RemoveAll(x => x.image.GetInstanceID() == image.GetInstanceID());
|
||||
_imageLoadQueue.RemoveAll(
|
||||
x => x.rawImage != null &&
|
||||
x.rawImage.GetInstanceID() == rawImage.GetInstanceID());
|
||||
|
||||
if (image.texture) {
|
||||
var toDestroy = image.texture;
|
||||
image.texture = null;
|
||||
Object.DestroyImmediate(toDestroy, true);
|
||||
}
|
||||
CleanupRawImageTexture(rawImage);
|
||||
|
||||
var tex2D = new Texture2D(1, 1);
|
||||
image.texture = tex2D;
|
||||
|
||||
// If the image is present locally, load it immediately
|
||||
var localBytes = GetLocal(path);
|
||||
if (localBytes != null) {
|
||||
tex2D.LoadImage(localBytes);
|
||||
} else {
|
||||
tex2D.SetPixel(0, 0, Color.clear);
|
||||
var tex2D = CreateTexture(localBytes);
|
||||
rawImage.texture = tex2D;
|
||||
|
||||
if (localBytes == null) {
|
||||
var elt = new ImageLoadQueueItem { rawImage = rawImage, path = path };
|
||||
_imageLoadQueue.Add(elt);
|
||||
pathToFetch = path;
|
||||
}
|
||||
}
|
||||
|
||||
if (pathToFetch != null) { FetchRemote(pathToFetch); }
|
||||
}
|
||||
|
||||
// This must be called from the main thread
|
||||
public void LoadIntoImage(Image image, string path) {
|
||||
string pathToFetch = null;
|
||||
lock (_imageLoadQueue) {
|
||||
_imageLoadQueue.RemoveAll(
|
||||
x => x.image != null && x.image.GetInstanceID() == image.GetInstanceID());
|
||||
|
||||
CleanupImageSprite(image);
|
||||
|
||||
var localBytes = GetLocal(path);
|
||||
var tex2D = CreateTexture(localBytes);
|
||||
image.sprite = CreateSprite(tex2D);
|
||||
|
||||
if (localBytes == null) {
|
||||
var elt = new ImageLoadQueueItem { image = image, path = path };
|
||||
_imageLoadQueue.Add(elt);
|
||||
|
||||
// Then enqueue a load from remote
|
||||
pathToFetch = elt.path;
|
||||
pathToFetch = path;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using Debug = UnityEngine.Debug;
|
||||
|
||||
namespace common {
|
||||
/// <summary>
|
||||
/// Enforces single-instance behavior on Windows using named pipes for IPC.
|
||||
/// When a second instance is launched (e.g., via deep link), it sends the
|
||||
/// URL to the existing instance via named pipe, then exits.
|
||||
///
|
||||
/// On macOS/iOS, the OS handles this automatically via Apple Events.
|
||||
/// </summary>
|
||||
public static class SingleInstanceEnforcer {
|
||||
private const string MutexName = "Eagle0_SingleInstance_Mutex";
|
||||
private const string PipeName = "Eagle0_DeepLink_Pipe";
|
||||
private const string ProcessName = "eagle0";
|
||||
|
||||
private static Mutex _mutex;
|
||||
private static CancellationTokenSource _pipeCts;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when a deep link is received from another instance.
|
||||
/// </summary>
|
||||
public static event Action<string> OnDeepLinkReceived;
|
||||
|
||||
#if UNITY_STANDALONE_WIN && !UNITY_EDITOR
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool IsIconic(IntPtr hWnd);
|
||||
|
||||
private const int SW_RESTORE = 9;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Check if another instance is already running.
|
||||
/// If so, send the deep link URL via named pipe and exit.
|
||||
/// If not, acquire the mutex and start listening for incoming URLs.
|
||||
/// </summary>
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
public static void CheckAndEnforce() {
|
||||
#if UNITY_STANDALONE_WIN && !UNITY_EDITOR
|
||||
// Check if launched with a deep link URL
|
||||
string deepLinkUrl = null;
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
for (int i = 1; i < args.Length; i++) {
|
||||
if (args[i].StartsWith("eagle0://")) {
|
||||
deepLinkUrl = args[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to create the mutex
|
||||
bool createdNew;
|
||||
try {
|
||||
_mutex = new Mutex(true, MutexName, out createdNew);
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[SingleInstance] Failed to create mutex: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createdNew) {
|
||||
// Another instance is running
|
||||
Debug.Log("[SingleInstance] Another instance detected");
|
||||
|
||||
if (deepLinkUrl != null) {
|
||||
// Send the deep link to the existing instance via named pipe
|
||||
Debug.Log(
|
||||
$"[SingleInstance] Sending deep link to existing instance: {deepLinkUrl}");
|
||||
SendDeepLinkToExistingInstance(deepLinkUrl);
|
||||
ActivateExistingWindow();
|
||||
|
||||
// Exit immediately
|
||||
Debug.Log("[SingleInstance] Exiting duplicate instance");
|
||||
Environment.Exit(0);
|
||||
} else {
|
||||
// Normal launch (not deep link) - let it run as a second instance
|
||||
Debug.Log("[SingleInstance] Normal launch - allowing second instance");
|
||||
_mutex.Dispose();
|
||||
_mutex = null;
|
||||
}
|
||||
} else {
|
||||
Debug.Log("[SingleInstance] First instance - mutex acquired, starting pipe server");
|
||||
// Start listening for deep links from other instances
|
||||
StartPipeServer();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_STANDALONE_WIN && !UNITY_EDITOR
|
||||
private static void SendDeepLinkToExistingInstance(string url) {
|
||||
try {
|
||||
using (var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out)) {
|
||||
// Try to connect with a short timeout
|
||||
client.Connect(1000);
|
||||
|
||||
using (var writer = new StreamWriter(client, Encoding.UTF8)) {
|
||||
writer.WriteLine(url);
|
||||
writer.Flush();
|
||||
}
|
||||
}
|
||||
Debug.Log("[SingleInstance] Deep link sent successfully");
|
||||
} catch (TimeoutException) {
|
||||
Debug.LogWarning(
|
||||
"[SingleInstance] Pipe connection timed out - existing instance may not be listening");
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[SingleInstance] Failed to send deep link: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void StartPipeServer() {
|
||||
_pipeCts = new CancellationTokenSource();
|
||||
Task.Run(() => PipeServerLoop(_pipeCts.Token));
|
||||
}
|
||||
|
||||
private static async Task PipeServerLoop(CancellationToken ct) {
|
||||
while (!ct.IsCancellationRequested) {
|
||||
try {
|
||||
using (var server = new NamedPipeServerStream(
|
||||
PipeName,
|
||||
PipeDirection.In,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous)) {
|
||||
// Wait for a client to connect
|
||||
await server.WaitForConnectionAsync(ct);
|
||||
|
||||
using (var reader = new StreamReader(server, Encoding.UTF8)) {
|
||||
string url = await reader.ReadLineAsync();
|
||||
if (!string.IsNullOrEmpty(url)) {
|
||||
Debug.Log(
|
||||
$"[SingleInstance] Received deep link from another instance: {url}");
|
||||
|
||||
// Dispatch to main thread
|
||||
MainQueue.Q.Enqueue(() => { OnDeepLinkReceived?.Invoke(url); });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
// Normal shutdown
|
||||
break;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[SingleInstance] Pipe server error: {ex.Message}");
|
||||
// Brief delay before retrying
|
||||
await Task.Delay(100, ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ActivateExistingWindow() {
|
||||
// Find the existing eagle0 process
|
||||
var currentPid = Process.GetCurrentProcess().Id;
|
||||
var processes = Process.GetProcessesByName(ProcessName);
|
||||
|
||||
foreach (var proc in processes) {
|
||||
if (proc.Id == currentPid) continue; // Skip self
|
||||
|
||||
IntPtr hwnd = proc.MainWindowHandle;
|
||||
if (hwnd == IntPtr.Zero) {
|
||||
Debug.Log($"[SingleInstance] Process {proc.Id} has no main window");
|
||||
continue;
|
||||
}
|
||||
|
||||
Debug.Log($"[SingleInstance] Found existing window: {hwnd} (PID {proc.Id})");
|
||||
|
||||
// If window is minimized, restore it
|
||||
if (IsIconic(hwnd)) { ShowWindow(hwnd, SW_RESTORE); }
|
||||
|
||||
// Bring to foreground
|
||||
SetForegroundWindow(hwnd);
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.LogWarning("[SingleInstance] Could not find existing window");
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Release the mutex and stop the pipe server when the application exits.
|
||||
/// </summary>
|
||||
public static void Release() {
|
||||
_pipeCts?.Cancel();
|
||||
_pipeCts?.Dispose();
|
||||
_pipeCts = null;
|
||||
|
||||
if (_mutex != null) {
|
||||
try {
|
||||
_mutex.ReleaseMutex();
|
||||
_mutex.Dispose();
|
||||
} catch {}
|
||||
_mutex = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30c1b0ce52a2c498eb99d5b19afda349
|
||||
@@ -11,6 +11,8 @@ namespace common {
|
||||
public static class WindowFocusManager {
|
||||
private static bool _wasFullScreen;
|
||||
private static FullScreenMode _previousFullScreenMode;
|
||||
private static int _previousWidth;
|
||||
private static int _previousHeight;
|
||||
|
||||
#if UNITY_STANDALONE_WIN
|
||||
[DllImport("user32.dll")]
|
||||
@@ -76,9 +78,11 @@ namespace common {
|
||||
public static void MinimizeForExternalBrowser() {
|
||||
Debug.Log("[WindowFocusManager] Minimizing for external browser");
|
||||
|
||||
// Store fullscreen state for later restoration
|
||||
// Store fullscreen state and resolution for later restoration
|
||||
_wasFullScreen = Screen.fullScreen;
|
||||
_previousFullScreenMode = Screen.fullScreenMode;
|
||||
_previousWidth = Screen.width;
|
||||
_previousHeight = Screen.height;
|
||||
|
||||
#if UNITY_STANDALONE_WIN
|
||||
var handle = GetWindowHandle();
|
||||
@@ -142,10 +146,10 @@ namespace common {
|
||||
|
||||
// Restore fullscreen if it was enabled before
|
||||
if (_wasFullScreen && !Screen.fullScreen) {
|
||||
Screen.fullScreenMode = _previousFullScreenMode;
|
||||
Screen.fullScreen = true;
|
||||
// Use SetResolution to restore both resolution and fullscreen mode together
|
||||
Screen.SetResolution(_previousWidth, _previousHeight, _previousFullScreenMode);
|
||||
Debug.Log(
|
||||
$"[WindowFocusManager] Restored fullscreen mode: {_previousFullScreenMode}");
|
||||
$"[WindowFocusManager] Restored fullscreen {_previousWidth}x{_previousHeight} mode: {_previousFullScreenMode}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ebe438313ec24b6894ce84cf4c89605
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,128 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a8ef7ae2668e41e78c9eaae1d12b76b
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
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
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
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: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
applyGammaDecoding: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -1,128 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e4bb37b7e56df4d1ab5e894387c9eb26
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
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
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
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: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
applyGammaDecoding: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -1,128 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b83e8b22f0024667a08918c613a7759
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
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
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
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: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
applyGammaDecoding: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
-128
@@ -1,128 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ca456c8b4dafa49f8b513162ada0e9e3
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
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
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
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: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
applyGammaDecoding: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Binary file not shown.
-128
@@ -1,128 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9b27e24f2af7a436084d697f568bef8f
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
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
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
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: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
applyGammaDecoding: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Binary file not shown.
@@ -1,128 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c804420ff739f4ad4b28e0459a58d2c1
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
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
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
seamlessCubemap: 0
|
||||
textureFormat: 1
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
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: 1
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
applyGammaDecoding: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
internalID: 0
|
||||
vertices: []
|
||||
indices:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -145,9 +145,6 @@
|
||||
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\views\faction_relationship_view.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
|
||||
<Link>src\main\protobuf\net\eagle0\eagle\views\faction_relationship_view.proto</Link>
|
||||
</Protobuf>
|
||||
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\api\pregenerated_text.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
|
||||
<Link>src\main\protobuf\net\eagle0\eagle\api\pregenerated_text.proto</Link>
|
||||
</Protobuf>
|
||||
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\common\tribute_amount.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
|
||||
<Link>src\main\protobuf\net\eagle0\eagle\common\tribute_amount.proto</Link>
|
||||
</Protobuf>
|
||||
|
||||
@@ -76,6 +76,7 @@ var (
|
||||
usersRowsTemplate *template.Template
|
||||
invitationsTemplate *template.Template
|
||||
invitationsRowsTemplate *template.Template
|
||||
accountsTemplate *template.Template
|
||||
loginTemplate *template.Template
|
||||
)
|
||||
|
||||
@@ -139,6 +140,11 @@ func main() {
|
||||
log.Fatalf("Failed to parse invitations rows template: %v", err)
|
||||
}
|
||||
|
||||
accountsTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/accounts.html")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse accounts template: %v", err)
|
||||
}
|
||||
|
||||
loginTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/login.html")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse login template: %v", err)
|
||||
@@ -195,6 +201,7 @@ func main() {
|
||||
http.HandleFunc("/games/", requireAuth(handleGameRoutes))
|
||||
http.HandleFunc("/settings", requireAuth(handleSettingsPage))
|
||||
http.HandleFunc("/settings/", requireAuth(handleSettingsRoutes))
|
||||
http.HandleFunc("/accounts", requireAuth(handleAccountsPage))
|
||||
http.HandleFunc("/users", requireAuth(handleUsersPage))
|
||||
http.HandleFunc("/users/", requireAuth(handleUserRoutes))
|
||||
http.HandleFunc("/invitations", requireAuth(handleInvitationsPage))
|
||||
@@ -1594,6 +1601,19 @@ type UsersPageData struct {
|
||||
BuildInfo BuildInfo
|
||||
}
|
||||
|
||||
// AccountsPageData combines users and invitations for the unified accounts page
|
||||
type AccountsPageData struct {
|
||||
Title string
|
||||
Users []UserInfo
|
||||
UsersFilter string
|
||||
UsersTotalCount int
|
||||
Invitations []InvitationInfo
|
||||
InvitationsEmailFilter string
|
||||
InvitationsStatusFilter int
|
||||
InvitationsTotalCount int
|
||||
BuildInfo BuildInfo
|
||||
}
|
||||
|
||||
// UsersRowsData is the data for the users rows partial
|
||||
type UsersRowsData struct {
|
||||
Users []UserInfo
|
||||
@@ -1659,6 +1679,69 @@ func convertAdminUserInfo(user *adminpb.AdminUserInfo) UserInfo {
|
||||
}
|
||||
}
|
||||
|
||||
func handleAccountsPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := createAdminContext(r)
|
||||
defer cancel()
|
||||
|
||||
// Fetch users
|
||||
usersResp, err := adminClient.ListUsers(ctx, &adminpb.ListUsersRequest{
|
||||
Filter: "",
|
||||
Offset: 0,
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to list users: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to list users: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var users []UserInfo
|
||||
for _, user := range usersResp.Users {
|
||||
users = append(users, convertAdminUserInfo(user))
|
||||
}
|
||||
|
||||
// Fetch invitations
|
||||
invResp, err := adminClient.ListInvitations(ctx, &adminpb.ListInvitationsRequest{
|
||||
EmailFilter: "",
|
||||
StatusFilter: adminpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED,
|
||||
Offset: 0,
|
||||
Limit: 50,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to list invitations: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Failed to list invitations: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var invitations []InvitationInfo
|
||||
for _, inv := range invResp.Invitations {
|
||||
invitations = append(invitations, convertInvitationInfo(inv))
|
||||
}
|
||||
|
||||
data := AccountsPageData{
|
||||
Title: "Accounts",
|
||||
Users: users,
|
||||
UsersFilter: "",
|
||||
UsersTotalCount: int(usersResp.TotalCount),
|
||||
Invitations: invitations,
|
||||
InvitationsEmailFilter: "",
|
||||
InvitationsStatusFilter: 0,
|
||||
InvitationsTotalCount: int(invResp.TotalCount),
|
||||
BuildInfo: getBuildInfo(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
if err := accountsTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
|
||||
log.Printf("Template error: %v", err)
|
||||
http.Error(w, "Template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func handleUsersPage(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
{{define "content"}}
|
||||
<div class="page-header">
|
||||
<h1>Accounts</h1>
|
||||
</div>
|
||||
|
||||
<div class="accounts-grid">
|
||||
<!-- Users Column -->
|
||||
<div class="accounts-column">
|
||||
<div class="column-header">
|
||||
<h2>Users</h2>
|
||||
<span class="count">{{.UsersTotalCount}} user{{if ne .UsersTotalCount 1}}s{{end}}</span>
|
||||
</div>
|
||||
|
||||
<div class="search-controls">
|
||||
<input type="text"
|
||||
name="filter"
|
||||
id="users-filter"
|
||||
placeholder="Search by display name or email..."
|
||||
value="{{.UsersFilter}}"
|
||||
hx-get="/users/search"
|
||||
hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
|
||||
hx-target="#users-table-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="this"
|
||||
class="search-input">
|
||||
</div>
|
||||
|
||||
<div id="users-feedback" class="feedback-area"></div>
|
||||
|
||||
{{if .Users}}
|
||||
<div class="table-container">
|
||||
<table class="users-table compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Display Name</th>
|
||||
<th>OAuth</th>
|
||||
<th>Admin</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="users-table-body" hx-trigger="userUpdated from:body" hx-get="/users/search?filter={{.UsersFilter}}" hx-swap="innerHTML">
|
||||
{{range .Users}}
|
||||
<tr class="user-row" id="user-{{.UserID}}">
|
||||
<td class="user-display-name">
|
||||
{{if .DisplayName}}{{.DisplayName}}{{else}}<em class="no-name">(none)</em>{{end}}
|
||||
</td>
|
||||
<td class="oauth-identities">
|
||||
{{range .OAuthIdentities}}
|
||||
<span class="provider {{.Provider}}" title="{{.Email}}">{{.Provider}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="user-admin">
|
||||
{{if .IsAdmin}}<span class="admin-badge admin">Admin</span>{{end}}
|
||||
</td>
|
||||
<td class="user-actions">
|
||||
<button class="btn-small"
|
||||
onclick="openEditModal('{{.UserID}}', '{{.DisplayName}}', {{.IsAdmin}})">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn-small btn-danger"
|
||||
hx-post="/users/{{.UserID}}/delete"
|
||||
hx-target="#users-feedback"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Delete user permanently?">
|
||||
Del
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>No users found.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Invitations Column -->
|
||||
<div class="accounts-column">
|
||||
<div class="column-header">
|
||||
<h2>Invitations</h2>
|
||||
<span class="count">{{.InvitationsTotalCount}} invitation{{if ne .InvitationsTotalCount 1}}s{{end}}</span>
|
||||
<button class="btn-small btn-primary" onclick="openCreateModal()">+ New</button>
|
||||
</div>
|
||||
|
||||
<div class="search-controls">
|
||||
<input type="text"
|
||||
name="filter"
|
||||
id="invitations-filter"
|
||||
placeholder="Search by email..."
|
||||
value="{{.InvitationsEmailFilter}}"
|
||||
hx-get="/invitations/search"
|
||||
hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
|
||||
hx-target="#invitations-table-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#invitations-status-filter"
|
||||
class="search-input">
|
||||
<select name="status_filter"
|
||||
id="invitations-status-filter"
|
||||
hx-get="/invitations/search"
|
||||
hx-trigger="change"
|
||||
hx-target="#invitations-table-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="#invitations-filter"
|
||||
class="status-filter">
|
||||
<option value="0" {{if eq .InvitationsStatusFilter 0}}selected{{end}}>All</option>
|
||||
<option value="1" {{if eq .InvitationsStatusFilter 1}}selected{{end}}>Pending</option>
|
||||
<option value="2" {{if eq .InvitationsStatusFilter 2}}selected{{end}}>Redeemed</option>
|
||||
<option value="3" {{if eq .InvitationsStatusFilter 3}}selected{{end}}>Expired</option>
|
||||
<option value="4" {{if eq .InvitationsStatusFilter 4}}selected{{end}}>Revoked</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="invitations-feedback" class="feedback-area"></div>
|
||||
|
||||
{{if .Invitations}}
|
||||
<div class="table-container">
|
||||
<table class="invitations-table compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Expires</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="invitations-table-body" hx-trigger="invitationUpdated from:body" hx-get="/invitations/search?filter={{.InvitationsEmailFilter}}&status_filter={{.InvitationsStatusFilter}}" hx-swap="innerHTML">
|
||||
{{range .Invitations}}
|
||||
<tr class="invitation-row status-{{.StatusName}}" id="invitation-{{.InvitationCodeShort}}">
|
||||
<td class="invitation-email" title="{{.Email}}">{{.Email}}</td>
|
||||
<td class="invitation-status">
|
||||
<span class="status-badge {{.StatusName}}">{{.StatusName}}</span>
|
||||
</td>
|
||||
<td class="invitation-expires">{{.ExpiresAt}}</td>
|
||||
<td class="invitation-actions">
|
||||
{{if eq .StatusName "pending"}}
|
||||
<button class="btn-small"
|
||||
hx-post="/invitations/{{.InvitationCode}}/resend"
|
||||
hx-target="#invitations-feedback"
|
||||
hx-swap="innerHTML"
|
||||
title="Resend email">
|
||||
Resend
|
||||
</button>
|
||||
<button class="btn-small btn-danger"
|
||||
hx-post="/invitations/{{.InvitationCode}}/revoke"
|
||||
hx-target="#invitations-feedback"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Revoke invitation?">
|
||||
Revoke
|
||||
</button>
|
||||
{{else}}
|
||||
<button class="btn-small btn-danger"
|
||||
hx-post="/invitations/{{.InvitationCode}}/delete"
|
||||
hx-target="#invitations-feedback"
|
||||
hx-swap="innerHTML">
|
||||
Del
|
||||
</button>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>No invitations found.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit User Modal -->
|
||||
<dialog id="edit-modal">
|
||||
<article>
|
||||
<header>
|
||||
<h3>Edit User</h3>
|
||||
<button aria-label="Close" class="close" onclick="document.getElementById('edit-modal').close()"></button>
|
||||
</header>
|
||||
<form id="edit-form">
|
||||
<input type="hidden" id="edit-user-id" name="user_id">
|
||||
|
||||
<label for="edit-display-name">Display Name</label>
|
||||
<input type="text" id="edit-display-name" name="display_name" placeholder="Display name (3-20 chars, alphanumeric + underscore)">
|
||||
<small>Leave empty to clear the display name</small>
|
||||
|
||||
<fieldset>
|
||||
<label>
|
||||
<input type="checkbox" id="edit-is-admin" name="is_admin" value="true">
|
||||
Grant admin access
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div id="edit-feedback"></div>
|
||||
</form>
|
||||
<footer>
|
||||
<button type="button" class="secondary" onclick="document.getElementById('edit-modal').close()">Cancel</button>
|
||||
<button type="button" onclick="saveUserChanges()">Save Changes</button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<!-- Create Invitation Modal -->
|
||||
<dialog id="create-modal">
|
||||
<article>
|
||||
<header>
|
||||
<h3>Create Invitation</h3>
|
||||
<button aria-label="Close" class="close" onclick="document.getElementById('create-modal').close()"></button>
|
||||
</header>
|
||||
<form id="create-form" hx-post="/invitations/create" hx-target="#create-feedback" hx-swap="innerHTML">
|
||||
<label for="create-email">Email Address</label>
|
||||
<input type="email" id="create-email" name="email" placeholder="user@example.com" required>
|
||||
|
||||
<label for="create-expires">Expires In (days)</label>
|
||||
<input type="number" id="create-expires" name="expires_in_days" value="30" min="1" max="365">
|
||||
<small>Default: 30 days</small>
|
||||
|
||||
<div id="create-feedback"></div>
|
||||
</form>
|
||||
<footer>
|
||||
<button type="button" class="secondary" onclick="document.getElementById('create-modal').close()">Cancel</button>
|
||||
<button type="submit" form="create-form">Create & Send</button>
|
||||
</footer>
|
||||
</article>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
let originalIsAdmin = false;
|
||||
|
||||
function openEditModal(userId, displayName, isAdmin) {
|
||||
document.getElementById('edit-user-id').value = userId;
|
||||
document.getElementById('edit-display-name').value = displayName || '';
|
||||
document.getElementById('edit-is-admin').checked = isAdmin;
|
||||
document.getElementById('edit-feedback').innerHTML = '';
|
||||
originalIsAdmin = isAdmin;
|
||||
document.getElementById('edit-modal').showModal();
|
||||
}
|
||||
|
||||
function saveUserChanges() {
|
||||
const userId = document.getElementById('edit-user-id').value;
|
||||
const displayName = document.getElementById('edit-display-name').value;
|
||||
const isAdmin = document.getElementById('edit-is-admin').checked;
|
||||
const feedback = document.getElementById('edit-feedback');
|
||||
|
||||
feedback.innerHTML = '<p>Saving...</p>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('display_name', displayName);
|
||||
|
||||
fetch(`/users/${userId}/set-display-name`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(resp => {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(text => { throw new Error(text); });
|
||||
}
|
||||
return resp.text();
|
||||
})
|
||||
.then(html => {
|
||||
if (isAdmin !== originalIsAdmin) {
|
||||
const adminFormData = new FormData();
|
||||
adminFormData.append('is_admin', isAdmin ? 'true' : 'false');
|
||||
|
||||
return fetch(`/users/${userId}/set-admin`, {
|
||||
method: 'POST',
|
||||
body: adminFormData
|
||||
}).then(resp => {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(text => { throw new Error(text); });
|
||||
}
|
||||
return resp.text();
|
||||
});
|
||||
}
|
||||
return html;
|
||||
})
|
||||
.then(() => {
|
||||
document.getElementById('edit-modal').close();
|
||||
htmx.trigger(document.getElementById('users-table-body'), 'userUpdated');
|
||||
})
|
||||
.catch(err => {
|
||||
feedback.innerHTML = `<p class="error">${err.message}</p>`;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
document.getElementById('create-email').value = '';
|
||||
document.getElementById('create-expires').value = '30';
|
||||
document.getElementById('create-feedback').innerHTML = '';
|
||||
document.getElementById('create-modal').showModal();
|
||||
}
|
||||
|
||||
// Handle successful invitation creation and actions
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
const path = evt.detail.pathInfo.requestPath;
|
||||
if (path === '/invitations/create' && evt.detail.successful) {
|
||||
const response = evt.detail.xhr.responseText;
|
||||
if (response.includes('alert-success')) {
|
||||
setTimeout(() => {
|
||||
document.getElementById('create-modal').close();
|
||||
// Reload page to refresh the invitations list
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
}
|
||||
} else if (path.startsWith('/invitations/') && evt.detail.successful) {
|
||||
// Invitation actions (revoke, resend, delete) - reload page to refresh
|
||||
window.location.reload();
|
||||
} else if (path.startsWith('/users/') && path.includes('/delete') && evt.detail.successful) {
|
||||
// User delete - reload page to refresh
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.accounts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.accounts-column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.column-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.column-header .count {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.column-header .btn-primary {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.search-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.compact-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.compact-table th, .compact-table td {
|
||||
padding: 0.4rem 0.5rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--pico-muted-border-color);
|
||||
}
|
||||
|
||||
.compact-table th {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-display-name, .invitation-email {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oauth-identities {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.provider {
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.65rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.provider.discord { background-color: #5865F2; color: white; }
|
||||
.provider.google { background-color: #4285F4; color: white; }
|
||||
.provider.github { background-color: #333; color: white; }
|
||||
.provider.apple { background-color: #000; color: white; }
|
||||
.provider.steam { background-color: #1b2838; color: white; }
|
||||
.provider.twitch { background-color: #9146FF; color: white; }
|
||||
|
||||
.admin-badge {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.admin-badge.admin {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.pending { background-color: #ffc107; color: #000; }
|
||||
.status-badge.redeemed { background-color: #28a745; color: white; }
|
||||
.status-badge.expired { background-color: #6c757d; color: white; }
|
||||
.status-badge.revoked { background-color: #dc3545; color: white; }
|
||||
|
||||
.invitation-row.status-redeemed,
|
||||
.invitation-row.status-expired,
|
||||
.invitation-row.status-revoked {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.user-actions, .invitation-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
border-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.no-name {
|
||||
color: var(--pico-muted-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.feedback-area {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
#edit-modal article, #create-modal article {
|
||||
max-width: 450px;
|
||||
}
|
||||
|
||||
#edit-modal small, #create-modal small {
|
||||
display: block;
|
||||
margin-top: -0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--pico-muted-color);
|
||||
}
|
||||
|
||||
/* Responsive: stack columns on smaller screens */
|
||||
@media (max-width: 1200px) {
|
||||
.accounts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
@@ -13,8 +13,7 @@
|
||||
<ul>
|
||||
<li class="brand">Eagle Admin <span class="build-info">{{if .BuildInfo.GitCommit}}({{.BuildInfo.GitCommit}}{{if .BuildInfo.BuildTime}}, {{.BuildInfo.BuildTime}}{{end}}){{end}}</span></li>
|
||||
<li><a href="/">Games</a></li>
|
||||
<li><a href="/users">Users</a></li>
|
||||
<li><a href="/invitations">Invitations</a></li>
|
||||
<li><a href="/accounts">Accounts</a></li>
|
||||
<li><a href="/settings">Settings</a></li>
|
||||
<li><a href="/health">Health</a></li>
|
||||
<li id="jfr-controls" hx-get="/jfr/status" hx-trigger="load" hx-swap="innerHTML">
|
||||
|
||||
@@ -12,6 +12,7 @@ go_library(
|
||||
"main.go",
|
||||
"oauth.go",
|
||||
"sendgrid.go",
|
||||
"session_transfer.go",
|
||||
"steam_auth.go",
|
||||
"users.go",
|
||||
],
|
||||
|
||||
@@ -49,17 +49,19 @@ func extractAccessToken(ctx context.Context) (string, error) {
|
||||
// AuthHandler implements the Auth gRPC service
|
||||
type AuthHandler struct {
|
||||
auth.UnimplementedAuthServer
|
||||
oauthSvc *OAuthService
|
||||
userService *UserService
|
||||
invitationService *InvitationService
|
||||
oauthSvc *OAuthService
|
||||
userService *UserService
|
||||
invitationService *InvitationService
|
||||
sessionTransferService *SessionTransferService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService, invitationService *InvitationService) *AuthHandler {
|
||||
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService, invitationService *InvitationService, sessionTransferService *SessionTransferService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
oauthSvc: oauthSvc,
|
||||
userService: userService,
|
||||
invitationService: invitationService,
|
||||
oauthSvc: oauthSvc,
|
||||
userService: userService,
|
||||
invitationService: invitationService,
|
||||
sessionTransferService: sessionTransferService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +327,34 @@ func (h *AuthHandler) Logout(ctx context.Context, req *auth.LogoutRequest) (*aut
|
||||
return &auth.LogoutResponse{}, nil
|
||||
}
|
||||
|
||||
// ExchangeSessionTransferCode exchanges a one-time session transfer code for tokens.
|
||||
// This is used when the user clicks the deep link from the invitation download page.
|
||||
func (h *AuthHandler) ExchangeSessionTransferCode(ctx context.Context, req *auth.ExchangeSessionTransferCodeRequest) (*auth.ExchangeSessionTransferCodeResponse, error) {
|
||||
result, err := h.sessionTransferService.ExchangeSessionTransferCode(req.Code)
|
||||
if err != nil {
|
||||
log.Printf("[ExchangeSessionTransferCode] Failed: %v", err)
|
||||
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
|
||||
}
|
||||
|
||||
// Get user to find provider
|
||||
user := h.userService.FindByUserID(result.UserID)
|
||||
var provider auth.OAuthProvider
|
||||
if user != nil && len(user.OauthIdentities) > 0 {
|
||||
provider = stringToProvider(user.OauthIdentities[0].Provider)
|
||||
}
|
||||
|
||||
return &auth.ExchangeSessionTransferCodeResponse{
|
||||
AccessToken: result.AccessToken,
|
||||
RefreshToken: result.RefreshToken,
|
||||
ExpiresAt: result.ExpiresAt,
|
||||
User: &auth.UserInfo{
|
||||
UserId: result.UserID,
|
||||
DisplayName: result.DisplayName,
|
||||
Provider: provider,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func providerToString(p auth.OAuthProvider) string {
|
||||
switch p {
|
||||
case auth.OAuthProvider_OAUTH_PROVIDER_DISCORD:
|
||||
|
||||
@@ -20,12 +20,14 @@ import (
|
||||
|
||||
// InvitationHTTPHandler handles HTTP requests for invitation pages
|
||||
type InvitationHTTPHandler struct {
|
||||
invitationService *InvitationService
|
||||
userService *UserService
|
||||
oauthService *OAuthService
|
||||
installerURL string
|
||||
macInstallerURL string
|
||||
cookieSecret []byte
|
||||
invitationService *InvitationService
|
||||
userService *UserService
|
||||
oauthService *OAuthService
|
||||
sessionTransferService *SessionTransferService
|
||||
emailService *EmailService
|
||||
installerURL string
|
||||
macInstallerURL string
|
||||
cookieSecret []byte
|
||||
}
|
||||
|
||||
// OAuthSessionData stores OAuth result in a signed cookie
|
||||
@@ -40,9 +42,10 @@ type OAuthSessionData struct {
|
||||
|
||||
const oauthSessionCookieName = "eagle0_oauth_session"
|
||||
const oauthSessionExpiration = 10 * time.Minute
|
||||
const sessionTransferCookieName = "eagle0_session_transfer"
|
||||
|
||||
// NewInvitationHTTPHandler creates a new invitation HTTP handler
|
||||
func NewInvitationHTTPHandler(invitationService *InvitationService, userService *UserService, oauthService *OAuthService) *InvitationHTTPHandler {
|
||||
func NewInvitationHTTPHandler(invitationService *InvitationService, userService *UserService, oauthService *OAuthService, sessionTransferService *SessionTransferService, emailService *EmailService) *InvitationHTTPHandler {
|
||||
installerURL := os.Getenv("INSTALLER_DOWNLOAD_URL")
|
||||
if installerURL == "" {
|
||||
installerURL = "https://assets.eagle0.net/installer/Eagle0.exe"
|
||||
@@ -64,12 +67,14 @@ func NewInvitationHTTPHandler(invitationService *InvitationService, userService
|
||||
}
|
||||
|
||||
return &InvitationHTTPHandler{
|
||||
invitationService: invitationService,
|
||||
userService: userService,
|
||||
oauthService: oauthService,
|
||||
installerURL: installerURL,
|
||||
macInstallerURL: macInstallerURL,
|
||||
cookieSecret: cookieSecret,
|
||||
invitationService: invitationService,
|
||||
userService: userService,
|
||||
oauthService: oauthService,
|
||||
sessionTransferService: sessionTransferService,
|
||||
emailService: emailService,
|
||||
installerURL: installerURL,
|
||||
macInstallerURL: macInstallerURL,
|
||||
cookieSecret: cookieSecret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,6 +302,15 @@ func (h *InvitationHTTPHandler) handleOAuthCallback(w http.ResponseWriter, r *ht
|
||||
existingUser := h.userService.FindByOAuthIdentity(result.Provider, result.UserInfo.ID)
|
||||
if existingUser != nil {
|
||||
log.Printf("[InviteHTTP] Existing user found: %s (%s)", existingUser.UserId, existingUser.DisplayName)
|
||||
// Generate session transfer code for existing user
|
||||
if h.sessionTransferService != nil {
|
||||
transferCode, err := h.sessionTransferService.GenerateSessionTransferCode(existingUser.UserId, result.Provider)
|
||||
if err != nil {
|
||||
log.Printf("[InviteHTTP] Warning: failed to generate session transfer code: %v", err)
|
||||
} else {
|
||||
h.setSessionTransferCookie(w, transferCode)
|
||||
}
|
||||
}
|
||||
// Existing user - redirect to download page (don't consume invitation)
|
||||
http.Redirect(w, r, fmt.Sprintf("/invite/%s/download", code), http.StatusFound)
|
||||
return
|
||||
@@ -397,6 +411,31 @@ func (h *InvitationHTTPHandler) handleSetDisplayName(w http.ResponseWriter, r *h
|
||||
|
||||
log.Printf("[InviteHTTP] Account created: user=%s displayName=%s code=%s", user.UserId, displayName, code[:8])
|
||||
|
||||
// Generate session transfer code for seamless login after install
|
||||
var transferCode string
|
||||
if h.sessionTransferService != nil {
|
||||
var err error
|
||||
transferCode, err = h.sessionTransferService.GenerateSessionTransferCode(user.UserId, sessionData.Provider)
|
||||
if err != nil {
|
||||
log.Printf("[InviteHTTP] Warning: failed to generate session transfer code: %v", err)
|
||||
} else {
|
||||
h.setSessionTransferCookie(w, transferCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Send welcome email with download instructions
|
||||
if h.emailService != nil && sessionData.Email != "" {
|
||||
emailData := AccountCreatedEmailData{
|
||||
Email: sessionData.Email,
|
||||
DisplayName: displayName,
|
||||
Provider: sessionData.Provider,
|
||||
SessionTransferCode: transferCode,
|
||||
}
|
||||
if err := h.emailService.SendAccountCreated(emailData); err != nil {
|
||||
log.Printf("[InviteHTTP] Warning: failed to send welcome email: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear session cookie
|
||||
h.clearOAuthSessionCookie(w)
|
||||
|
||||
@@ -408,16 +447,23 @@ func (h *InvitationHTTPHandler) handleSetDisplayName(w http.ResponseWriter, r *h
|
||||
func (h *InvitationHTTPHandler) handleDownloadPage(w http.ResponseWriter, r *http.Request, code string) {
|
||||
isMac := isMacUserAgent(r.UserAgent())
|
||||
|
||||
// Get session transfer code from cookie (if available)
|
||||
sessionTransferCode := h.getSessionTransferCookie(r)
|
||||
|
||||
data := struct {
|
||||
Code string
|
||||
IsMac bool
|
||||
InstallerURL string
|
||||
MacInstallerURL string
|
||||
Code string
|
||||
IsMac bool
|
||||
InstallerURL string
|
||||
MacInstallerURL string
|
||||
SessionTransferCode string
|
||||
HasSessionTransfer bool
|
||||
}{
|
||||
Code: code,
|
||||
IsMac: isMac,
|
||||
InstallerURL: h.installerURL,
|
||||
MacInstallerURL: h.macInstallerURL,
|
||||
Code: code,
|
||||
IsMac: isMac,
|
||||
InstallerURL: h.installerURL,
|
||||
MacInstallerURL: h.macInstallerURL,
|
||||
SessionTransferCode: sessionTransferCode,
|
||||
HasSessionTransfer: sessionTransferCode != "",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
@@ -427,6 +473,42 @@ func (h *InvitationHTTPHandler) handleDownloadPage(w http.ResponseWriter, r *htt
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeDisplayNameSuggestion cleans up an OAuth username to be a valid display name suggestion.
|
||||
// Display names must be 3-20 characters, alphanumeric + underscore only.
|
||||
func sanitizeDisplayNameSuggestion(username string) string {
|
||||
if username == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Replace spaces with underscores
|
||||
result := strings.ReplaceAll(username, " ", "_")
|
||||
|
||||
// Remove any characters that aren't alphanumeric or underscore
|
||||
validChars := regexp.MustCompile(`[^a-zA-Z0-9_]`)
|
||||
result = validChars.ReplaceAllString(result, "")
|
||||
|
||||
// Collapse multiple consecutive underscores into one
|
||||
multipleUnderscores := regexp.MustCompile(`_+`)
|
||||
result = multipleUnderscores.ReplaceAllString(result, "_")
|
||||
|
||||
// Trim leading/trailing underscores
|
||||
result = strings.Trim(result, "_")
|
||||
|
||||
// Truncate to 20 characters max
|
||||
if len(result) > 20 {
|
||||
result = result[:20]
|
||||
// Don't end with underscore after truncation
|
||||
result = strings.TrimRight(result, "_")
|
||||
}
|
||||
|
||||
// If result is too short, return empty (user will need to pick their own)
|
||||
if len(result) < 3 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// showDisplayNameForm renders the display name form
|
||||
func (h *InvitationHTTPHandler) showDisplayNameForm(w http.ResponseWriter, code string, session OAuthSessionData, errorMsg string) {
|
||||
data := struct {
|
||||
@@ -435,7 +517,7 @@ func (h *InvitationHTTPHandler) showDisplayNameForm(w http.ResponseWriter, code
|
||||
ErrorMessage string
|
||||
}{
|
||||
Code: code,
|
||||
Username: session.Username,
|
||||
Username: sanitizeDisplayNameSuggestion(session.Username),
|
||||
ErrorMessage: errorMsg,
|
||||
}
|
||||
|
||||
@@ -542,6 +624,38 @@ func (h *InvitationHTTPHandler) clearOAuthSessionCookie(w http.ResponseWriter) {
|
||||
})
|
||||
}
|
||||
|
||||
// Session transfer cookie helpers
|
||||
|
||||
func (h *InvitationHTTPHandler) setSessionTransferCookie(w http.ResponseWriter, code string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionTransferCookieName,
|
||||
Value: code,
|
||||
Path: "/invite/",
|
||||
MaxAge: int(sessionTransferExpiration.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: os.Getenv("COOKIE_SECURE") != "false",
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *InvitationHTTPHandler) getSessionTransferCookie(r *http.Request) string {
|
||||
cookie, err := r.Cookie(sessionTransferCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
func (h *InvitationHTTPHandler) clearSessionTransferCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionTransferCookieName,
|
||||
Value: "",
|
||||
Path: "/invite/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Legacy handlers for backwards compatibility with old clients
|
||||
|
||||
// handleInstallBat serves the Windows batch installer script
|
||||
@@ -1114,6 +1228,21 @@ var downloadPageTemplate = template.Must(template.New("download").Parse(`<!DOCTY
|
||||
.button:hover {
|
||||
background: #154d62;
|
||||
}
|
||||
.button-secondary {
|
||||
display: inline-block;
|
||||
background: #28a745;
|
||||
color: white !important;
|
||||
text-decoration: none;
|
||||
padding: 16px 32px;
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
margin: 10px 0;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.button-secondary:hover {
|
||||
background: #218838;
|
||||
}
|
||||
.instructions {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
@@ -1154,6 +1283,27 @@ var downloadPageTemplate = template.Must(template.New("download").Parse(`<!DOCTY
|
||||
color: #155724;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.post-install {
|
||||
background: #e7f3ff;
|
||||
border: 1px solid #b8daff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.post-install h3 {
|
||||
margin-top: 0;
|
||||
color: #004085;
|
||||
}
|
||||
.post-install p {
|
||||
color: #004085;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.post-install .hint {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1178,7 +1328,7 @@ var downloadPageTemplate = template.Must(template.New("download").Parse(`<!DOCTY
|
||||
<li>Click "Download for Mac" to download the disk image</li>
|
||||
<li>Open the downloaded <strong>eagle0.dmg</strong> file</li>
|
||||
<li>Drag <strong>Eagle0</strong> to the <strong>Applications</strong> folder</li>
|
||||
<li>Launch Eagle0 from Applications and sign in</li>
|
||||
<li>Launch Eagle0 from Applications</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -1194,7 +1344,7 @@ var downloadPageTemplate = template.Must(template.New("download").Parse(`<!DOCTY
|
||||
<li>Click "Download for Windows" to download the installer</li>
|
||||
<li>Run <strong>Eagle0.exe</strong></li>
|
||||
<li>If Windows shows a security prompt, click "More info" then "Run anyway"</li>
|
||||
<li>Launch Eagle0 and sign in with the same account you just used</li>
|
||||
<li>Launch Eagle0</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
@@ -1202,6 +1352,15 @@ var downloadPageTemplate = template.Must(template.New("download").Parse(`<!DOCTY
|
||||
<p><small>Using Mac? <a href="{{.MacInstallerURL}}">Download for Mac</a></small></p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .HasSessionTransfer}}
|
||||
<div class="post-install">
|
||||
<h3>After Installing</h3>
|
||||
<p>Once Eagle0 is running, click this button to sign in automatically:</p>
|
||||
<a href="eagle0://auth/session/{{.SessionTransferCode}}" class="button-secondary">Complete Sign-In in Eagle0</a>
|
||||
<p class="hint">This link expires in 1 hour. If it expires, you can sign in manually in the game.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
|
||||
@@ -121,8 +121,11 @@ func main() {
|
||||
// Create OAuth service
|
||||
oauthSvc := NewOAuthService(getOAuthConfigs())
|
||||
|
||||
// Create session transfer service for seamless OAuth deep links
|
||||
sessionTransferSvc := NewSessionTransferService(userService)
|
||||
|
||||
// Create auth service handler
|
||||
authHandler := NewAuthHandler(oauthSvc, userService, invitationService)
|
||||
authHandler := NewAuthHandler(oauthSvc, userService, invitationService, sessionTransferSvc)
|
||||
|
||||
// Create admin service handler
|
||||
adminHandler := NewAdminHandler(userService, invitationService, emailService)
|
||||
@@ -154,9 +157,12 @@ func main() {
|
||||
})
|
||||
|
||||
// Register invitation landing page routes (with OAuth support for web-based account creation)
|
||||
inviteHandler := NewInvitationHTTPHandler(invitationService, userService, oauthSvc)
|
||||
inviteHandler := NewInvitationHTTPHandler(invitationService, userService, oauthSvc, sessionTransferSvc, emailService)
|
||||
inviteHandler.RegisterRoutes()
|
||||
|
||||
// Register session transfer endpoint for deep link token exchange
|
||||
http.HandleFunc("/oauth/session-transfer/", sessionTransferSvc.HandleSessionTransfer)
|
||||
|
||||
log.Printf("HTTP server listening on port %d", actualHTTPPort)
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", actualHTTPPort), nil); err != nil {
|
||||
log.Fatalf("HTTP server failed: %v", err)
|
||||
|
||||
@@ -317,6 +317,249 @@ func (e *EmailService) buildInvitationHTML(invitation *userpb.Invitation, invite
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// AccountCreatedEmailData contains info for the welcome email
|
||||
type AccountCreatedEmailData struct {
|
||||
Email string
|
||||
DisplayName string
|
||||
Provider string
|
||||
SessionTransferCode string
|
||||
}
|
||||
|
||||
// SendAccountCreated sends a welcome email after account creation
|
||||
func (e *EmailService) SendAccountCreated(data AccountCreatedEmailData) error {
|
||||
if !e.enabled {
|
||||
log.Printf("[Email] Would send welcome email to %s (email disabled)", data.Email)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize JMAP session if needed
|
||||
if e.accountID == "" {
|
||||
if err := e.initSession(); err != nil {
|
||||
return fmt.Errorf("failed to initialize JMAP session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Build email content
|
||||
subject := "Welcome to Eagle0 - Download Instructions"
|
||||
htmlContent, err := e.buildAccountCreatedHTML(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build email content: %w", err)
|
||||
}
|
||||
|
||||
textContent := e.buildAccountCreatedText(data)
|
||||
|
||||
// Send via JMAP
|
||||
if err := e.sendEmail(data.Email, subject, htmlContent, textContent); err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Email] Sent welcome email to %s (display name: %s)", data.Email, data.DisplayName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailService) buildAccountCreatedHTML(data AccountCreatedEmailData) (string, error) {
|
||||
// Capitalize provider name for display
|
||||
providerDisplay := data.Provider
|
||||
switch data.Provider {
|
||||
case "discord":
|
||||
providerDisplay = "Discord"
|
||||
case "google":
|
||||
providerDisplay = "Google"
|
||||
case "github":
|
||||
providerDisplay = "GitHub"
|
||||
case "apple":
|
||||
providerDisplay = "Apple"
|
||||
case "steam":
|
||||
providerDisplay = "Steam"
|
||||
case "twitch":
|
||||
providerDisplay = "Twitch"
|
||||
}
|
||||
|
||||
const htmlTemplate = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to Eagle0</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { text-align: center; padding: 20px 0; }
|
||||
.logo { font-size: 32px; font-weight: bold; color: #1a5f7a; }
|
||||
.success-icon { font-size: 48px; margin: 10px 0; }
|
||||
.content { background: #f8f9fa; border-radius: 8px; padding: 30px; margin: 20px 0; }
|
||||
.button { display: inline-block; background: #1a5f7a; color: white !important; text-decoration: none; padding: 14px 28px; border-radius: 6px; font-weight: bold; margin: 10px 0; }
|
||||
.button:hover { background: #154d62; }
|
||||
.button-green { background: #28a745 !important; }
|
||||
.button-green:hover { background: #218838 !important; }
|
||||
.footer { text-align: center; color: #6c757d; font-size: 12px; margin-top: 30px; }
|
||||
.account-info { background: #d4edda; border: 1px solid #c3e6cb; border-radius: 8px; padding: 15px; margin: 15px 0; color: #155724; }
|
||||
.instructions { background: #e7f3ff; border: 1px solid #b8daff; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
||||
.instructions h3 { margin-top: 0; color: #004085; }
|
||||
.instructions ol { margin: 0; padding-left: 20px; }
|
||||
.instructions li { margin: 8px 0; }
|
||||
.post-install { background: #fff3cd; border: 1px solid #ffeeba; border-radius: 8px; padding: 20px; margin: 20px 0; text-align: center; }
|
||||
.post-install h3 { margin-top: 0; color: #856404; }
|
||||
.hint { font-size: 13px; color: #666; margin-top: 10px; }
|
||||
.other-platform { text-align: center; margin: 15px 0; color: #666; font-size: 14px; }
|
||||
.other-platform a { color: #1a5f7a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="logo">Eagle0</div>
|
||||
<div class="success-icon">✓</div>
|
||||
<h1>Welcome, {{.DisplayName}}!</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="account-info">
|
||||
<strong>Your account has been created!</strong><br>
|
||||
You signed in with <strong>{{.ProviderDisplay}}</strong> ({{.Email}})
|
||||
</div>
|
||||
|
||||
<h3>Download Eagle0</h3>
|
||||
<p style="text-align: center;">
|
||||
<a href="{{.InstallerURL}}" class="button">Download for Windows</a>
|
||||
<a href="{{.MacInstallerURL}}" class="button">Download for Mac</a>
|
||||
</p>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>Installation Instructions (Windows):</h3>
|
||||
<ol>
|
||||
<li>Click "Download for Windows" above to download the installer</li>
|
||||
<li>Run <strong>Eagle0.exe</strong></li>
|
||||
<li>If Windows shows a security prompt, click "More info" then "Run anyway"</li>
|
||||
<li>Launch Eagle0</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>Installation Instructions (Mac):</h3>
|
||||
<ol>
|
||||
<li>Click "Download for Mac" above to download the disk image</li>
|
||||
<li>Open the downloaded <strong>eagle0.dmg</strong> file</li>
|
||||
<li>Drag <strong>Eagle0</strong> to the <strong>Applications</strong> folder</li>
|
||||
<li>Launch Eagle0 from Applications</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{{if .HasSessionTransfer}}
|
||||
<div class="post-install">
|
||||
<h3>After Installing</h3>
|
||||
<p>Once Eagle0 is running, click this button to sign in automatically:</p>
|
||||
<a href="eagle0://auth/session/{{.SessionTransferCode}}" class="button button-green">Complete Sign-In in Eagle0</a>
|
||||
<p class="hint">This link expires in 1 hour. If it expires, you can sign in manually using {{.ProviderDisplay}}.</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="post-install">
|
||||
<h3>After Installing</h3>
|
||||
<p>Sign in with <strong>{{.ProviderDisplay}}</strong> using the same account you used to create your Eagle0 account.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>This is an automated message. If you have questions, contact support.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
tmpl, err := template.New("accountCreated").Parse(htmlTemplate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
templateData := map[string]interface{}{
|
||||
"DisplayName": data.DisplayName,
|
||||
"Email": data.Email,
|
||||
"ProviderDisplay": providerDisplay,
|
||||
"InstallerURL": e.installerURL,
|
||||
"MacInstallerURL": e.getMacInstallerURL(),
|
||||
"SessionTransferCode": data.SessionTransferCode,
|
||||
"HasSessionTransfer": data.SessionTransferCode != "",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (e *EmailService) buildAccountCreatedText(data AccountCreatedEmailData) string {
|
||||
// Capitalize provider name for display
|
||||
providerDisplay := data.Provider
|
||||
switch data.Provider {
|
||||
case "discord":
|
||||
providerDisplay = "Discord"
|
||||
case "google":
|
||||
providerDisplay = "Google"
|
||||
case "github":
|
||||
providerDisplay = "GitHub"
|
||||
case "apple":
|
||||
providerDisplay = "Apple"
|
||||
case "steam":
|
||||
providerDisplay = "Steam"
|
||||
case "twitch":
|
||||
providerDisplay = "Twitch"
|
||||
}
|
||||
|
||||
text := fmt.Sprintf(`Welcome to Eagle0, %s!
|
||||
|
||||
Your account has been created. You signed in with %s (%s).
|
||||
|
||||
DOWNLOAD EAGLE0
|
||||
===============
|
||||
|
||||
Windows: %s
|
||||
Mac: %s
|
||||
|
||||
INSTALLATION INSTRUCTIONS (Windows):
|
||||
1. Download the installer
|
||||
2. Run Eagle0.exe
|
||||
3. If Windows shows a security prompt, click "More info" then "Run anyway"
|
||||
4. Launch Eagle0
|
||||
|
||||
INSTALLATION INSTRUCTIONS (Mac):
|
||||
1. Download the disk image
|
||||
2. Open eagle0.dmg
|
||||
3. Drag Eagle0 to Applications
|
||||
4. Launch Eagle0 from Applications
|
||||
|
||||
`, data.DisplayName, providerDisplay, data.Email, e.installerURL, e.getMacInstallerURL())
|
||||
|
||||
if data.SessionTransferCode != "" {
|
||||
text += fmt.Sprintf(`AFTER INSTALLING
|
||||
================
|
||||
|
||||
Once Eagle0 is running, click this link to sign in automatically:
|
||||
eagle0://auth/session/%s
|
||||
|
||||
This link expires in 1 hour. If it expires, you can sign in manually using %s.
|
||||
|
||||
`, data.SessionTransferCode, providerDisplay)
|
||||
} else {
|
||||
text += fmt.Sprintf(`AFTER INSTALLING
|
||||
================
|
||||
|
||||
Sign in with %s using the same account you used to create your Eagle0 account.
|
||||
|
||||
`, providerDisplay)
|
||||
}
|
||||
|
||||
text += `---
|
||||
This is an automated message. If you have questions, contact support.
|
||||
`
|
||||
return text
|
||||
}
|
||||
|
||||
func (e *EmailService) getMacInstallerURL() string {
|
||||
macURL := os.Getenv("MAC_INSTALLER_URL")
|
||||
if macURL == "" {
|
||||
return "https://assets.eagle0.net/mac/builds/eagle0-latest.dmg"
|
||||
}
|
||||
return macURL
|
||||
}
|
||||
|
||||
func (e *EmailService) buildInvitationText(invitation *userpb.Invitation, invitePageURL string) string {
|
||||
var expiresAt string
|
||||
if invitation.ExpiresAt != nil {
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// Package main provides session transfer functionality for seamless OAuth flows.
|
||||
// After completing OAuth on the web, a one-time session transfer code is generated
|
||||
// that can be exchanged for tokens via deep link from the Unity client.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionTransfer represents a pending session transfer code
|
||||
type SessionTransfer struct {
|
||||
Code string // Random 32-byte code (URL-safe base64)
|
||||
UserID string // User who completed OAuth
|
||||
Provider string // OAuth provider used
|
||||
ExpiresAt time.Time // Short expiry (5 minutes)
|
||||
Used bool // Single-use flag
|
||||
}
|
||||
|
||||
// SessionTransferService manages session transfer codes
|
||||
type SessionTransferService struct {
|
||||
pendingTransfers sync.Map // code -> *SessionTransfer
|
||||
userService *UserService // For looking up user info
|
||||
}
|
||||
|
||||
const sessionTransferExpiration = 1 * time.Hour
|
||||
|
||||
// NewSessionTransferService creates a new session transfer service
|
||||
func NewSessionTransferService(userService *UserService) *SessionTransferService {
|
||||
svc := &SessionTransferService{
|
||||
userService: userService,
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go svc.cleanupLoop()
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
// GenerateSessionTransferCode creates a one-time code for session transfer
|
||||
func (s *SessionTransferService) GenerateSessionTransferCode(userID, provider string) (string, error) {
|
||||
// Generate 32 bytes of cryptographic randomness (256 bits)
|
||||
randomBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
code := base64.RawURLEncoding.EncodeToString(randomBytes)
|
||||
|
||||
transfer := &SessionTransfer{
|
||||
Code: code,
|
||||
UserID: userID,
|
||||
Provider: provider,
|
||||
ExpiresAt: time.Now().Add(sessionTransferExpiration),
|
||||
Used: false,
|
||||
}
|
||||
|
||||
s.pendingTransfers.Store(code, transfer)
|
||||
log.Printf("[SessionTransfer] Generated code for user=%s provider=%s (expires in %v)", userID[:8], provider, sessionTransferExpiration)
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// SessionTransferResponse is returned when a code is successfully exchanged
|
||||
type SessionTransferResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// ExchangeSessionTransferCode exchanges a one-time code for tokens
|
||||
func (s *SessionTransferService) ExchangeSessionTransferCode(code string) (*SessionTransferResponse, error) {
|
||||
value, ok := s.pendingTransfers.Load(code)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid or expired session transfer code")
|
||||
}
|
||||
|
||||
transfer := value.(*SessionTransfer)
|
||||
|
||||
// Check if already used
|
||||
if transfer.Used {
|
||||
return nil, fmt.Errorf("session transfer code has already been used")
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(transfer.ExpiresAt) {
|
||||
s.pendingTransfers.Delete(code)
|
||||
return nil, fmt.Errorf("session transfer code has expired")
|
||||
}
|
||||
|
||||
// Mark as used immediately (single-use)
|
||||
transfer.Used = true
|
||||
|
||||
// Look up user to get display name
|
||||
user := s.userService.FindByUserID(transfer.UserID)
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
accessToken, err := CreateAccessToken(user.UserId, user.DisplayName, user.IsAdmin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create access token: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, err := CreateRefreshToken(user.UserId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create refresh token: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[SessionTransfer] Successfully exchanged code for user=%s displayName=%s", user.UserId[:8], user.DisplayName)
|
||||
|
||||
// Delete the used code
|
||||
s.pendingTransfers.Delete(code)
|
||||
|
||||
return &SessionTransferResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: time.Now().Add(accessTokenDuration).Unix(),
|
||||
UserID: user.UserId,
|
||||
DisplayName: user.DisplayName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleSessionTransfer handles the HTTP endpoint for session transfer code exchange
|
||||
func (s *SessionTransferService) HandleSessionTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
// Only allow GET requests
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract code from path: /oauth/session-transfer/{code}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/oauth/session-transfer/")
|
||||
code := strings.TrimSuffix(path, "/")
|
||||
|
||||
if code == "" {
|
||||
http.Error(w, "Missing session transfer code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := s.ExchangeSessionTransferCode(code)
|
||||
if err != nil {
|
||||
log.Printf("[SessionTransfer] Exchange failed: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
log.Printf("[SessionTransfer] Failed to encode response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupLoop periodically removes expired transfer codes
|
||||
func (s *SessionTransferService) cleanupLoop() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
s.cleanupExpiredCodes()
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExpiredCodes removes expired or used transfer codes
|
||||
func (s *SessionTransferService) cleanupExpiredCodes() {
|
||||
now := time.Now()
|
||||
s.pendingTransfers.Range(func(key, value interface{}) bool {
|
||||
transfer := value.(*SessionTransfer)
|
||||
if transfer.Used || now.After(transfer.ExpiresAt) {
|
||||
s.pendingTransfers.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -195,17 +195,17 @@ func saveInvitationCode(code string) {
|
||||
}
|
||||
|
||||
func handleInstallerReplacement(oldInstallerPath string) {
|
||||
// Use simple logging for replacement mode (no fancy UI)
|
||||
fmt.Println("Updating installer, please wait...")
|
||||
ui.UpdateStatus("Updating installer, please wait...")
|
||||
|
||||
currentInstallerPath, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR: Failed to get current executable path: %v\n", err)
|
||||
ui.ShowError(fmt.Sprintf("Failed to get current executable path: %v", err))
|
||||
ui.WaitForKeypress("Press Enter to exit...")
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for the old process to exit - check if file is still locked
|
||||
fmt.Println("Waiting for previous installer to close...")
|
||||
ui.UpdateStatus("Waiting for previous installer to close...")
|
||||
maxWaitSeconds := 30
|
||||
oldProcessExited := false
|
||||
|
||||
@@ -215,48 +215,54 @@ func handleInstallerReplacement(oldInstallerPath string) {
|
||||
if err == nil {
|
||||
f.Close()
|
||||
oldProcessExited = true
|
||||
fmt.Println("Previous installer has exited, proceeding...")
|
||||
ui.UpdateStatus("Previous installer has exited, proceeding...")
|
||||
} else {
|
||||
fmt.Printf("Waiting for previous installer to close... (%ds)\n", i+1)
|
||||
ui.UpdateProgress(i*100/maxWaitSeconds, fmt.Sprintf("Waiting for previous installer... (%ds)", i+1))
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
if !oldProcessExited {
|
||||
fmt.Printf("ERROR: Old installer process did not exit after %d seconds\n", maxWaitSeconds)
|
||||
ui.ShowError(fmt.Sprintf("Old installer process did not exit after %d seconds", maxWaitSeconds))
|
||||
ui.WaitForKeypress("Press Enter to exit...")
|
||||
return
|
||||
}
|
||||
|
||||
// Backup old installer
|
||||
backupPath := oldInstallerPath + ".backup"
|
||||
fmt.Println("Backing up current installer...")
|
||||
ui.UpdateStatus("Backing up current installer...")
|
||||
if _, err := os.Stat(oldInstallerPath); err == nil {
|
||||
os.Remove(backupPath)
|
||||
if err := os.Rename(oldInstallerPath, backupPath); err != nil {
|
||||
fmt.Printf("ERROR: Failed to backup old installer: %v\n", err)
|
||||
ui.ShowError(fmt.Sprintf("Failed to backup old installer: %v", err))
|
||||
ui.WaitForKeypress("Press Enter to exit...")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Copy new installer to old location
|
||||
fmt.Println("Installing new version...")
|
||||
ui.UpdateStatus("Installing new version...")
|
||||
if err := copyFile(currentInstallerPath, oldInstallerPath); err != nil {
|
||||
fmt.Printf("ERROR: Failed to copy new installer: %v\n", err)
|
||||
ui.ShowError(fmt.Sprintf("Failed to copy new installer: %v", err))
|
||||
// Restore backup
|
||||
if _, err := os.Stat(backupPath); err == nil {
|
||||
os.Rename(backupPath, oldInstallerPath)
|
||||
fmt.Println("Backup restored")
|
||||
ui.ShowInfo("Backup restored")
|
||||
}
|
||||
ui.WaitForKeypress("Press Enter to exit...")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Launching updated installer...")
|
||||
ui.UpdateStatus("Launching updated installer...")
|
||||
cmd := exec.Command(oldInstallerPath)
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Printf("ERROR: Failed to launch new installer: %v\n", err)
|
||||
ui.ShowError(fmt.Sprintf("Failed to launch new installer: %v", err))
|
||||
ui.WaitForKeypress("Press Enter to exit...")
|
||||
return
|
||||
}
|
||||
|
||||
ui.ShowSuccess("Installer updated successfully!")
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -255,9 +255,12 @@ func (ui *WebViewUI) Run() {
|
||||
ui.w.Run()
|
||||
}
|
||||
|
||||
// Close cleans up WebView resources
|
||||
// Close terminates the event loop and cleans up WebView resources.
|
||||
// Must use Dispatch to ensure Terminate runs on the main thread.
|
||||
func (ui *WebViewUI) Close() {
|
||||
ui.w.Destroy()
|
||||
ui.w.Dispatch(func() {
|
||||
ui.w.Terminate()
|
||||
})
|
||||
}
|
||||
|
||||
// escapeJS escapes a string for safe use in JavaScript
|
||||
|
||||
@@ -35,7 +35,6 @@ proto_library(
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":command_proto",
|
||||
":pregenerated_text_proto",
|
||||
":selected_command_proto",
|
||||
":streaming_text_response_proto",
|
||||
"//src/main/protobuf/net/eagle0/common:game_setup_info_proto",
|
||||
@@ -107,21 +106,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "pregenerated_text_scala_proto",
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle/service:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
deps = [":pregenerated_text_proto"],
|
||||
)
|
||||
|
||||
proto_library(
|
||||
name = "pregenerated_text_proto",
|
||||
srcs = ["pregenerated_text.proto"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "selected_command_scala_proto",
|
||||
visibility = [
|
||||
@@ -181,7 +165,6 @@ go_proto_library(
|
||||
protos = [
|
||||
":available_command_proto",
|
||||
":command_proto",
|
||||
":pregenerated_text_proto",
|
||||
":selected_command_proto",
|
||||
":streaming_text_response_proto",
|
||||
],
|
||||
@@ -207,7 +190,6 @@ go_proto_library(
|
||||
":available_command_proto",
|
||||
":command_proto",
|
||||
":eagle_grpc",
|
||||
":pregenerated_text_proto",
|
||||
":selected_command_proto",
|
||||
":streaming_text_response_proto",
|
||||
],
|
||||
|
||||
@@ -32,6 +32,11 @@ service Auth {
|
||||
|
||||
// Logout - invalidates refresh token
|
||||
rpc Logout(LogoutRequest) returns (LogoutResponse) {}
|
||||
|
||||
// Exchange session transfer code for tokens.
|
||||
// Used when user clicks the deep link from the invitation download page.
|
||||
// The code is a one-time-use token created when the user signed in on the web.
|
||||
rpc ExchangeSessionTransferCode(ExchangeSessionTransferCodeRequest) returns (ExchangeSessionTransferCodeResponse) {}
|
||||
}
|
||||
|
||||
// OAuth provider enumeration
|
||||
@@ -129,3 +134,15 @@ message GetCurrentUserResponse {
|
||||
message LogoutRequest {}
|
||||
|
||||
message LogoutResponse {}
|
||||
|
||||
// Exchange session transfer code for tokens
|
||||
message ExchangeSessionTransferCodeRequest {
|
||||
string code = 1; // One-time-use transfer code from the web signup flow
|
||||
}
|
||||
|
||||
message ExchangeSessionTransferCodeResponse {
|
||||
string access_token = 1;
|
||||
string refresh_token = 2;
|
||||
int64 expires_at = 3;
|
||||
UserInfo user = 4;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ package net.eagle0.eagle.api;
|
||||
import "google/protobuf/wrappers.proto";
|
||||
import "src/main/protobuf/net/eagle0/common/game_setup_info.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/api/command.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/api/pregenerated_text.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/api/selected_command.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/api/streaming_text_response.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/profession.proto";
|
||||
@@ -56,16 +55,12 @@ message LobbyResponse {
|
||||
NewGameOptions new_game_options = 5;
|
||||
}
|
||||
|
||||
message PregeneratedTextRequest {
|
||||
int64 known_pregenerated_text_hash = 1;
|
||||
}
|
||||
|
||||
message UpdateStreamRequest {
|
||||
oneof request_details {
|
||||
StreamGameRequest stream_game_request = 1;
|
||||
HeartbeatRequest heartbeat_request = 2;
|
||||
EnterLobbyRequest enter_lobby_request = 3;
|
||||
PregeneratedTextRequest pregenerated_text_request = 4;
|
||||
// Field 4 was pregenerated_text_request, now removed
|
||||
JoinGameRequest join_game_request = 5;
|
||||
CreateGameRequest create_game_request = 6;
|
||||
CustomBattleRequest custom_battle_request = 7;
|
||||
@@ -103,16 +98,12 @@ message UpdateStreamRequest {
|
||||
}
|
||||
}
|
||||
|
||||
message PregeneratedTextResponse {
|
||||
PregeneratedText pregenerated_text = 1;
|
||||
}
|
||||
|
||||
message UpdateStreamResponse {
|
||||
oneof response_details {
|
||||
GameUpdate game_update = 1;
|
||||
HeartbeatResponse heartbeat_response = 2;
|
||||
LobbyResponse lobby_response = 3;
|
||||
PregeneratedTextResponse pregenerated_text_response = 4;
|
||||
// Field 4 was pregenerated_text_response, now removed
|
||||
JoinGameResponse join_game_response = 5;
|
||||
CreateGameResponse create_game_response = 6;
|
||||
CustomBattleResponse custom_battle_response = 7;
|
||||
@@ -215,6 +206,7 @@ message AvailableLeader {
|
||||
string name_text_id = 1;
|
||||
.net.eagle0.eagle.common.Profession profession = 2;
|
||||
string image_path = 3;
|
||||
string name = 4; // Resolved name for display (so client doesn't need to look up name_text_id)
|
||||
}
|
||||
|
||||
message AvailableNewGame {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// Copyright 2024 Dan Crosby
|
||||
//
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package net.eagle0.eagle.api;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "net.eagle0.eagle.api";
|
||||
option java_outer_classname = "PregeneratedTextProto";
|
||||
option objc_class_prefix = "E0G";
|
||||
|
||||
message PregeneratedText {
|
||||
message Entry {
|
||||
string text_id = 1;
|
||||
string text = 2;
|
||||
}
|
||||
|
||||
repeated Entry entries = 1;
|
||||
int64 hash = 2;
|
||||
}
|
||||
@@ -290,6 +290,12 @@ message NewFactionHeadDetails {
|
||||
int32 previous_head_hero_id = 3;
|
||||
}
|
||||
|
||||
message VassalRisesDetails {
|
||||
int32 rising_hero_id = 1;
|
||||
int32 faction_id = 2;
|
||||
bool is_only_leader = 3;
|
||||
}
|
||||
|
||||
message NotificationDetails {
|
||||
oneof sealed_value {
|
||||
AllianceAcceptedDetails alliance_accepted_details = 30;
|
||||
@@ -331,5 +337,6 @@ message NotificationDetails {
|
||||
WithdrewForTruceDetails withdrew_for_truce = 3;
|
||||
ProfessionGainedDetails profession_gained_details = 35;
|
||||
NewFactionHeadDetails new_faction_head_details = 39;
|
||||
VassalRisesDetails vassal_rises_details = 40;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ message GeneratedTextRequestDetails {
|
||||
PrisonerExiledMessage prisoner_exiled_message = 33;
|
||||
PrisonerReturnedMessage prisoner_returned_message = 34;
|
||||
NewFactionHeadMessage new_faction_head_message = 35;
|
||||
VassalRisesMessage vassal_rises_message = 36;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,3 +351,12 @@ message NewFactionHeadMessage {
|
||||
// The previous faction head who was executed
|
||||
int32 previous_head_hero_id = 5;
|
||||
}
|
||||
|
||||
message VassalRisesMessage {
|
||||
int32 rising_hero_id = 1;
|
||||
int32 faction_id = 2;
|
||||
// True if the rising hero is now the only faction leader
|
||||
bool is_only_leader = 3;
|
||||
// The previous faction leaders (before deaths/imprisonments triggered the vassal rise)
|
||||
repeated int32 previous_leader_ids = 4;
|
||||
}
|
||||
|
||||
@@ -53,4 +53,11 @@ trait ClientTextStore {
|
||||
): ClientTextStore
|
||||
|
||||
def withMovedBackToUnrequested(id: ClientTextId): ClientTextStore
|
||||
|
||||
/** Adds a pre-existing complete text directly (for pregenerated texts like hero names). */
|
||||
def withAddedCompleteText(
|
||||
id: ClientTextId,
|
||||
text: String,
|
||||
accessibleTo: Vector[FactionId]
|
||||
): ClientTextStore
|
||||
}
|
||||
|
||||
@@ -202,6 +202,23 @@ case class ClientTextStoreImpl(
|
||||
)
|
||||
this
|
||||
}
|
||||
|
||||
def withAddedCompleteText(
|
||||
id: ClientTextId,
|
||||
text: String,
|
||||
accessibleTo: Vector[FactionId]
|
||||
): ClientTextStore =
|
||||
if completeTexts.contains(id) then this
|
||||
else
|
||||
copy(
|
||||
completeTexts = completeTexts + (id -> CompleteClientText(
|
||||
id = id,
|
||||
text = text,
|
||||
requestedAfterHistoryCount = 0
|
||||
)),
|
||||
accessibleTo = this.accessibleTo + (id -> accessibleTo),
|
||||
accessibleToIsSaved = false
|
||||
)
|
||||
}
|
||||
|
||||
object ClientTextStoreImpl {
|
||||
|
||||
@@ -13,7 +13,9 @@ scala_library(
|
||||
deps = [
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:maximum_faction_leaders",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:returning_heroes",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/ransom_validity",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
|
||||
|
||||
+192
-54
@@ -3,13 +3,18 @@ package net.eagle0.eagle.library.actions.impl.action
|
||||
import net.eagle0.common.{FunctionalRandom, RandomState}
|
||||
import net.eagle0.eagle.{FactionId, GameId, HeroId}
|
||||
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
|
||||
import net.eagle0.eagle.library.settings.MaximumFactionLeaders
|
||||
import net.eagle0.eagle.library.util.hero.HeroUtils
|
||||
import net.eagle0.eagle.library.util.province.ProvinceUtils
|
||||
import net.eagle0.eagle.library.util.ransom_validity.RansomValidity
|
||||
import net.eagle0.eagle.library.util.ReturningHeroes
|
||||
import net.eagle0.eagle.model.action_result.{NotificationDetails, NotificationT}
|
||||
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
|
||||
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ChangedHeroC, NotificationC}
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.LlmRequestT.NewFactionHeadMessage
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.LlmRequestT.{
|
||||
NewFactionHeadMessage,
|
||||
VassalRisesMessage
|
||||
}
|
||||
import net.eagle0.eagle.model.action_result.types.ActionResultType.{
|
||||
FactionDestroyed,
|
||||
FactionLeaderRemoved,
|
||||
@@ -48,6 +53,40 @@ object CheckForFactionChangesAction {
|
||||
recruitmentInfo = RecruitmentInfo.Traveler
|
||||
)
|
||||
}
|
||||
|
||||
/** Get all hero IDs that are currently imprisoned (captured) */
|
||||
def imprisonedHeroIds(provinces: Vector[ProvinceT]): Set[HeroId] =
|
||||
provinces.flatMap(_.capturedHeroes.map(_.heroId)).toSet
|
||||
|
||||
/** Find all vassals (non-leaders) for a faction in provinces and moving armies */
|
||||
def factionVassals(
|
||||
factionId: FactionId,
|
||||
leaderIds: Vector[HeroId],
|
||||
provinces: Vector[ProvinceT],
|
||||
heroes: Vector[HeroT]
|
||||
): Vector[HeroT] = {
|
||||
// Heroes in provinces
|
||||
val provinceVassalIds = provinces
|
||||
.filter(_.rulingFactionId.contains(factionId))
|
||||
.flatMap(_.rulingFactionHeroIds)
|
||||
.filterNot(leaderIds.contains)
|
||||
.distinct
|
||||
|
||||
// Heroes in moving armies
|
||||
val armyVassalIds = provinces
|
||||
.flatMap(_.incomingArmies)
|
||||
.filter(_.army.factionId == factionId)
|
||||
.flatMap(_.army.units.map(_.heroId))
|
||||
.filterNot(leaderIds.contains)
|
||||
.distinct
|
||||
|
||||
(provinceVassalIds ++ armyVassalIds).distinct
|
||||
.flatMap(hid => heroes.find(_.id == hid))
|
||||
}
|
||||
|
||||
/** Find the strongest vassal by HeroUtils.power */
|
||||
def strongestVassal(vassals: Vector[HeroT]): Option[HeroT] =
|
||||
vassals.maxByOption(HeroUtils.power)
|
||||
}
|
||||
|
||||
case class CheckForFactionChangesAction(
|
||||
@@ -61,6 +100,13 @@ case class CheckForFactionChangesAction(
|
||||
|
||||
private val gameIdString = gameId.toHexString
|
||||
|
||||
// Track which heroes were promoted from vassal to leader
|
||||
private case class FactionChangeResult(
|
||||
faction: FactionC,
|
||||
risenVassalId: Option[HeroId] = None,
|
||||
isOnlyLeader: Boolean = false
|
||||
)
|
||||
|
||||
override def randomResults(
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Vector[ActionResultT]] = {
|
||||
@@ -69,26 +115,75 @@ case class CheckForFactionChangesAction(
|
||||
factionHasProvincesOrArmies(provinces, faction.id)
|
||||
}
|
||||
|
||||
val factionChanges = factionsWithProvinces.flatMap {
|
||||
val imprisoned = imprisonedHeroIds(provinces)
|
||||
|
||||
val factionChanges: Vector[FactionChangeResult] = factionsWithProvinces.flatMap {
|
||||
case faction: FactionC =>
|
||||
Option.when(faction.leaderIds.exists(killedHeroIds.contains)) {
|
||||
val newLeaders =
|
||||
faction.leaderIds.diff(killedHeroIds)
|
||||
val newLeaders = faction.leaderIds.diff(killedHeroIds)
|
||||
|
||||
// Only change faction head if the current head was killed
|
||||
val newFactionHeadId =
|
||||
if newLeaders.contains(faction.factionHeadId) then faction.factionHeadId
|
||||
else newLeaders.headOption.getOrElse(0)
|
||||
// Check which of the remaining leaders are available (not imprisoned)
|
||||
val availableLeaders = newLeaders.filterNot(imprisoned.contains)
|
||||
|
||||
faction.copy(
|
||||
leaderIds = newLeaders,
|
||||
factionHeadId = newFactionHeadId
|
||||
)
|
||||
if availableLeaders.isEmpty then {
|
||||
// No available leaders - try to promote a vassal
|
||||
val vassals = factionVassals(faction.id, faction.leaderIds, provinces, heroes)
|
||||
strongestVassal(vassals) match {
|
||||
case Some(vassal) =>
|
||||
// Promote the vassal to leader and faction head
|
||||
val updatedLeaders =
|
||||
if newLeaders.size >= MaximumFactionLeaders.intValue then
|
||||
// Drop the least senior leader (last in list) to make room
|
||||
newLeaders.init :+ vassal.id
|
||||
else newLeaders :+ vassal.id
|
||||
|
||||
println(
|
||||
f"$gameIdString%16s Vassal ${vassal.id} rises to lead faction ${faction.name}"
|
||||
)
|
||||
|
||||
FactionChangeResult(
|
||||
faction = faction.copy(
|
||||
leaderIds = updatedLeaders,
|
||||
factionHeadId = vassal.id
|
||||
),
|
||||
risenVassalId = Some(vassal.id),
|
||||
isOnlyLeader = newLeaders.isEmpty
|
||||
)
|
||||
|
||||
case None =>
|
||||
// No vassals available - faction will be destroyed
|
||||
val newFactionHeadId =
|
||||
if newLeaders.contains(faction.factionHeadId) then faction.factionHeadId
|
||||
else newLeaders.headOption.getOrElse(0)
|
||||
|
||||
FactionChangeResult(
|
||||
faction = faction.copy(
|
||||
leaderIds = newLeaders,
|
||||
factionHeadId = newFactionHeadId
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Some leaders are still available
|
||||
val newFactionHeadId =
|
||||
if availableLeaders.contains(faction.factionHeadId) then faction.factionHeadId
|
||||
else availableLeaders.head
|
||||
|
||||
FactionChangeResult(
|
||||
faction = faction.copy(
|
||||
leaderIds = newLeaders,
|
||||
factionHeadId = newFactionHeadId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val (notDestroyed, allKilled) =
|
||||
factionChanges.partition(_.leaderIds.nonEmpty)
|
||||
val (notDestroyedResults, allKilledResults) =
|
||||
factionChanges.partition(_.faction.leaderIds.nonEmpty)
|
||||
|
||||
val notDestroyed = notDestroyedResults.map(_.faction)
|
||||
val allKilled = allKilledResults.map(_.faction)
|
||||
|
||||
functionalRandom
|
||||
.nextMap((factionsWithoutProvinces ++ allKilled).toVector) {
|
||||
@@ -106,7 +201,7 @@ case class CheckForFactionChangesAction(
|
||||
.map { results =>
|
||||
results ++
|
||||
maybeFactionLeaderRemovedResult(
|
||||
revisedFactions = notDestroyed
|
||||
revisedFactions = notDestroyedResults
|
||||
) ++
|
||||
maybeGameOverResult((factionsWithoutProvinces ++ allKilled).map(_.id))
|
||||
}
|
||||
@@ -212,65 +307,108 @@ case class CheckForFactionChangesAction(
|
||||
)
|
||||
|
||||
private def maybeFactionLeaderRemovedResult(
|
||||
revisedFactions: Iterable[FactionT]
|
||||
revisedFactions: Iterable[FactionChangeResult]
|
||||
): Option[ActionResultT] =
|
||||
revisedFactions match {
|
||||
case Nil => None
|
||||
case fs =>
|
||||
val changedFactionData = fs.map { revisedFaction =>
|
||||
revisedFactions.toVector match {
|
||||
case results if results.isEmpty => None
|
||||
case results =>
|
||||
val changedFactionData = results.map { changeResult =>
|
||||
val revisedFaction = changeResult.faction
|
||||
val originalFaction = factions.find(_.id == revisedFaction.id).get
|
||||
val factionHeadChanged = originalFaction.factionHeadId != revisedFaction.factionHeadId
|
||||
|
||||
// Only include new head info if the head actually changed
|
||||
// Check if this was a vassal rising
|
||||
val (newFactionHeadHeroId, newFactionName, llmRequest, notification) =
|
||||
if factionHeadChanged then {
|
||||
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
|
||||
val newName = newHeadHero.flatMap(_.ledFactionName)
|
||||
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
|
||||
(
|
||||
Some(revisedFaction.factionHeadId),
|
||||
newName,
|
||||
Some(
|
||||
NewFactionHeadMessage(
|
||||
requestId = llmRequestId,
|
||||
eagleGameId = gameId,
|
||||
recipientFactionIds = Vector.empty,
|
||||
alwaysGenerate = false,
|
||||
newHeadHeroId = revisedFaction.factionHeadId,
|
||||
factionId = revisedFaction.id,
|
||||
previousFactionName = originalFaction.name,
|
||||
newFactionName = newName,
|
||||
previousHeadHeroId = originalFaction.factionHeadId
|
||||
)
|
||||
),
|
||||
Some(
|
||||
NotificationC(
|
||||
details = NotificationDetails.NewFactionHead(
|
||||
newHeadHeroId = revisedFaction.factionHeadId,
|
||||
changeResult.risenVassalId match {
|
||||
case Some(vassalId) =>
|
||||
// Vassal rose to leadership
|
||||
val vassalHero = heroes.find(_.id == vassalId)
|
||||
val newName = vassalHero.flatMap(_.ledFactionName)
|
||||
val llmRequestId = s"vassal_rises_${revisedFaction.id}_$vassalId"
|
||||
(
|
||||
Some(vassalId),
|
||||
newName,
|
||||
Some(
|
||||
VassalRisesMessage(
|
||||
requestId = llmRequestId,
|
||||
eagleGameId = gameId,
|
||||
recipientFactionIds = Vector.empty,
|
||||
alwaysGenerate = false,
|
||||
risingHeroId = vassalId,
|
||||
factionId = revisedFaction.id,
|
||||
previousHeadHeroId = originalFaction.factionHeadId
|
||||
),
|
||||
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
|
||||
llm = NotificationT.Llm.Id(llmRequestId),
|
||||
deferred = true
|
||||
isOnlyLeader = changeResult.isOnlyLeader,
|
||||
previousLeaderIds = originalFaction.leaderIds
|
||||
)
|
||||
),
|
||||
Some(
|
||||
NotificationC(
|
||||
details = NotificationDetails.VassalRises(
|
||||
risingHeroId = vassalId,
|
||||
factionId = revisedFaction.id,
|
||||
isOnlyLeader = changeResult.isOnlyLeader
|
||||
),
|
||||
affectedHeroIds = Vector(vassalId),
|
||||
llm = NotificationT.Llm.Id(llmRequestId),
|
||||
deferred = true
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
|
||||
case None if factionHeadChanged =>
|
||||
// Normal faction head change (existing leader promoted)
|
||||
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
|
||||
val newName = newHeadHero.flatMap(_.ledFactionName)
|
||||
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
|
||||
(
|
||||
Some(revisedFaction.factionHeadId),
|
||||
newName,
|
||||
Some(
|
||||
NewFactionHeadMessage(
|
||||
requestId = llmRequestId,
|
||||
eagleGameId = gameId,
|
||||
recipientFactionIds = Vector.empty,
|
||||
alwaysGenerate = false,
|
||||
newHeadHeroId = revisedFaction.factionHeadId,
|
||||
factionId = revisedFaction.id,
|
||||
previousFactionName = originalFaction.name,
|
||||
newFactionName = newName,
|
||||
previousHeadHeroId = originalFaction.factionHeadId
|
||||
)
|
||||
),
|
||||
Some(
|
||||
NotificationC(
|
||||
details = NotificationDetails.NewFactionHead(
|
||||
newHeadHeroId = revisedFaction.factionHeadId,
|
||||
factionId = revisedFaction.id,
|
||||
previousHeadHeroId = originalFaction.factionHeadId
|
||||
),
|
||||
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
|
||||
llm = NotificationT.Llm.Id(llmRequestId),
|
||||
deferred = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
case None =>
|
||||
// No head change
|
||||
(None, None, None, None)
|
||||
}
|
||||
|
||||
// For vassal rises, we need to add the vassal as a new leader
|
||||
val newLeaderHeroIds = changeResult.risenVassalId.toVector
|
||||
|
||||
(
|
||||
ChangedFactionC(
|
||||
factionId = revisedFaction.id,
|
||||
removedLeaderHeroIds = originalFaction.leaderIds.filter(killedHeroIds.contains),
|
||||
newLeaderHeroIds = newLeaderHeroIds,
|
||||
newFactionHeadHeroId = newFactionHeadHeroId,
|
||||
newName = newFactionName
|
||||
),
|
||||
llmRequest,
|
||||
notification
|
||||
)
|
||||
}.toVector
|
||||
}
|
||||
Some(
|
||||
ActionResultC(
|
||||
actionResultType = FactionLeaderRemoved,
|
||||
|
||||
@@ -664,3 +664,21 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "vassal_rises_prompt_generator",
|
||||
srcs = ["VassalRisesPromptGenerator.scala"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":faction_info_utilities",
|
||||
":generator_utilities",
|
||||
":hero_description_generator",
|
||||
":hero_info_utilities",
|
||||
":llm_prompt_generator",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package net.eagle0.eagle.library.actions.llm_prompt_generators
|
||||
|
||||
import net.eagle0.eagle.client_text.{ClientTextStore, TextGenerationResult, TextGenerationSuccess}
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.LlmRequestT
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.hero.HeroT
|
||||
import net.eagle0.eagle.HeroId
|
||||
|
||||
case class VassalRisesPromptGenerator(
|
||||
vassalRisesMessage: LlmRequestT.VassalRisesMessage,
|
||||
gameState: GameState,
|
||||
clientTextStore: ClientTextStore
|
||||
) extends LLMPromptGenerator {
|
||||
private val risingHero = HeroInfoUtilities.getHero(
|
||||
vassalRisesMessage.risingHeroId,
|
||||
gameState
|
||||
)
|
||||
private val faction = FactionInfoUtilities.getFaction(
|
||||
vassalRisesMessage.factionId,
|
||||
gameState
|
||||
)
|
||||
|
||||
// Find where each hero is imprisoned (if they are)
|
||||
private case class ImprisonmentInfo(
|
||||
provinceName: String,
|
||||
capturingFactionName: String
|
||||
)
|
||||
|
||||
private def findImprisonment(heroId: HeroId): Option[ImprisonmentInfo] =
|
||||
gameState.provinces.values.flatMap { province =>
|
||||
province.capturedHeroes.find(_.heroId == heroId).flatMap { _ =>
|
||||
for {
|
||||
rulingFactionId <- province.rulingFactionId
|
||||
rulingFaction <- gameState.factions.values.find(_.id == rulingFactionId)
|
||||
} yield ImprisonmentInfo(province.name, rulingFaction.name)
|
||||
}
|
||||
}.headOption
|
||||
|
||||
private def isHeroDead(heroId: HeroId): Boolean =
|
||||
gameState.killedHeroes.values.exists(_.id == heroId)
|
||||
|
||||
override def generate: TextGenerationResult = for {
|
||||
risingHeroDescription <- HeroDescriptionGenerator.description(
|
||||
heroId = vassalRisesMessage.risingHeroId,
|
||||
gameState = gameState,
|
||||
clientTextStore = clientTextStore
|
||||
)
|
||||
risingHeroName <- clientTextStore.getText(risingHero.nameTextId)
|
||||
setup <- GeneratorUtilities.basicSetup(gameState, clientTextStore)
|
||||
|
||||
// Generate descriptions for each previous leader
|
||||
previousLeadersSection <- generatePreviousLeadersSection()
|
||||
} yield {
|
||||
val possP = HeroDescriptionGenerator.possessivePronoun(risingHero.pronounGender)
|
||||
|
||||
val crisisDescription =
|
||||
if vassalRisesMessage.isOnlyLeader then
|
||||
s"""All of the faction's previous leaders have been killed or imprisoned, leaving the faction in crisis.
|
||||
|$risingHeroName, a vassal who has proven $possP worth through strength and loyalty, has risen to become
|
||||
|the new leader and head of the faction.""".stripMargin.replaceAll("\n", " ")
|
||||
else s"""With several faction leaders killed or imprisoned, the faction faces a crisis of leadership.
|
||||
|$risingHeroName, a strong vassal, has risen to join the remaining leaders and help guide the faction
|
||||
|through these troubled times.""".stripMargin.replaceAll("\n", " ")
|
||||
|
||||
val inputText =
|
||||
s"""$setup
|
||||
|
||||
|RISING VASSAL:
|
||||
|$risingHeroDescription
|
||||
|
|
||||
|FACTION: ${faction.name}
|
||||
|$previousLeadersSection
|
||||
|$crisisDescription
|
||||
|
|
||||
|Write a brief message from $risingHeroName declaring $possP new leadership role in the faction.
|
||||
|The message should reflect $possP personality and the circumstances of $possP rise to power during
|
||||
|this time of crisis. The message should reference the fallen or captured leaders by name and
|
||||
|acknowledge their fate. The tone should convey both resolve in the face of adversity and respect for
|
||||
|those who have fallen or been captured.
|
||||
|
|
||||
|${GeneratorUtilities.limitations}""".stripMargin
|
||||
|
||||
inputText
|
||||
}
|
||||
|
||||
private def generatePreviousLeadersSection(): TextGenerationResult = {
|
||||
val leaderIds = vassalRisesMessage.previousLeaderIds
|
||||
|
||||
if leaderIds.isEmpty then TextGenerationSuccess("")
|
||||
else {
|
||||
// Generate each leader description as a separate TextGenerationResult
|
||||
val leaderDescriptionResults: Seq[TextGenerationResult] =
|
||||
leaderIds.map(generateLeaderDescription)
|
||||
|
||||
// Use mkString to combine them
|
||||
TextGenerationResult.mkString(leaderDescriptionResults, "\n\n").map { combined =>
|
||||
s"""
|
||||
|PREVIOUS FACTION LEADERS:
|
||||
|$combined
|
||||
|""".stripMargin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def generateLeaderDescription(heroId: HeroId): TextGenerationResult = {
|
||||
// Try to find the hero - they might be dead (in killedHeroes) or alive but imprisoned
|
||||
val heroOpt: Option[HeroT] =
|
||||
gameState.heroes.values
|
||||
.find(_.id == heroId)
|
||||
.orElse(
|
||||
gameState.killedHeroes.values.find(_.id == heroId)
|
||||
)
|
||||
|
||||
heroOpt match {
|
||||
case Some(hero) =>
|
||||
for {
|
||||
heroDescription <- HeroDescriptionGenerator.description(
|
||||
heroId = heroId,
|
||||
gameState = gameState,
|
||||
clientTextStore = clientTextStore
|
||||
)
|
||||
heroName <- clientTextStore.getText(hero.nameTextId)
|
||||
} yield {
|
||||
val status =
|
||||
if isHeroDead(heroId) then "KILLED IN ACTION"
|
||||
else
|
||||
findImprisonment(heroId) match {
|
||||
case Some(ImprisonmentInfo(provinceName, factionName)) =>
|
||||
s"IMPRISONED in $provinceName by the $factionName faction"
|
||||
case None =>
|
||||
"STATUS UNKNOWN"
|
||||
}
|
||||
|
||||
s"""--- $heroName ($status) ---
|
||||
|$heroDescription""".stripMargin
|
||||
}
|
||||
|
||||
case None =>
|
||||
// Hero not found - shouldn't happen but handle gracefully
|
||||
TextGenerationSuccess(s"--- Unknown Leader (ID: $heroId) ---")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,4 +275,10 @@ object NotificationDetails {
|
||||
factionId: FactionId,
|
||||
previousHeadHeroId: HeroId
|
||||
) extends NotificationDetails
|
||||
|
||||
case class VassalRises(
|
||||
risingHeroId: HeroId,
|
||||
factionId: FactionId,
|
||||
isOnlyLeader: Boolean
|
||||
) extends NotificationDetails
|
||||
}
|
||||
|
||||
+11
@@ -420,4 +420,15 @@ enum LlmRequestT extends GeneratedTextRequestT:
|
||||
newFactionName: Option[String],
|
||||
previousHeadHeroId: HeroId
|
||||
)
|
||||
|
||||
case VassalRisesMessage(
|
||||
requestId: String,
|
||||
eagleGameId: GameId,
|
||||
recipientFactionIds: Vector[FactionId] = Vector.empty,
|
||||
alwaysGenerate: Boolean = false,
|
||||
risingHeroId: HeroId,
|
||||
factionId: FactionId,
|
||||
isOnlyLeader: Boolean,
|
||||
previousLeaderIds: Vector[HeroId] = Vector.empty
|
||||
)
|
||||
end LlmRequestT
|
||||
|
||||
@@ -42,6 +42,7 @@ import net.eagle0.eagle.common.action_result_notification_details.{
|
||||
TruceAmbassadorImprisonedDetails,
|
||||
TruceRejectedDetails,
|
||||
VassalExiledDetails,
|
||||
VassalRisesDetails,
|
||||
WithdrewForTruceDetails
|
||||
}
|
||||
import net.eagle0.eagle.common.action_result_notification_details.Notification.TargetFaction
|
||||
@@ -487,6 +488,16 @@ object NotificationConverter {
|
||||
factionId = factionId,
|
||||
previousHeadHeroId = previousHeadHeroId
|
||||
)
|
||||
case NotificationDetails.VassalRises(
|
||||
risingHeroId,
|
||||
factionId,
|
||||
isOnlyLeader
|
||||
) =>
|
||||
VassalRisesDetails(
|
||||
risingHeroId = risingHeroId,
|
||||
factionId = factionId,
|
||||
isOnlyLeader = isOnlyLeader
|
||||
)
|
||||
}
|
||||
|
||||
def fromProto(
|
||||
@@ -1024,6 +1035,18 @@ object NotificationConverter {
|
||||
previousHeadHeroId = previousHeadHeroId
|
||||
)
|
||||
|
||||
case VassalRisesDetails(
|
||||
risingHeroId: HeroId,
|
||||
factionId: FactionId,
|
||||
isOnlyLeader: Boolean,
|
||||
_ /* unknownFields */
|
||||
) =>
|
||||
NotificationDetails.VassalRises(
|
||||
risingHeroId = risingHeroId,
|
||||
factionId = factionId,
|
||||
isOnlyLeader = isOnlyLeader
|
||||
)
|
||||
|
||||
case Empty =>
|
||||
throw new ProtoConversionException("Empty NotificationDetailsProto")
|
||||
}
|
||||
|
||||
+39
-2
@@ -37,7 +37,8 @@ import net.eagle0.eagle.internal.generated_text_request.{
|
||||
SuppressBeastsSucceededMessage as SuppressBeastsSucceededMessageProto,
|
||||
SwearBrotherhoodMessage as SwearBrotherhoodMessageProto,
|
||||
TruceOfferMessage as TruceOfferMessageProto,
|
||||
TruceResolutionMessage as TruceResolutionMessageProto
|
||||
TruceResolutionMessage as TruceResolutionMessageProto,
|
||||
VassalRisesMessage as VassalRisesMessageProto
|
||||
}
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.chronicle_event.ChronicleEvent
|
||||
import net.eagle0.eagle.model.action_result.generated_text_request.ChronicleUpdatePreviousEntry
|
||||
@@ -75,7 +76,8 @@ import net.eagle0.eagle.model.action_result.generated_text_request.LlmRequestT.{
|
||||
SuppressBeastsSucceededMessage,
|
||||
SwearBrotherhoodMessage,
|
||||
TruceOfferMessage,
|
||||
TruceResolutionMessage
|
||||
TruceResolutionMessage,
|
||||
VassalRisesMessage
|
||||
}
|
||||
import net.eagle0.eagle.model.proto_converters.date.DateConverter
|
||||
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
|
||||
@@ -702,6 +704,23 @@ object LlmRequestConverter {
|
||||
newFactionName = newFactionName.getOrElse(""),
|
||||
previousHeadHeroId = previousHeadHeroId
|
||||
)
|
||||
|
||||
case VassalRisesMessage(
|
||||
_: String /* requestId */,
|
||||
_: GameId /* eagleGameId */,
|
||||
_: Vector[FactionId] /* recipientFactionIds */,
|
||||
_: Boolean /* alwaysGenerate */,
|
||||
risingHeroId: HeroId,
|
||||
factionId: FactionId,
|
||||
isOnlyLeader: Boolean,
|
||||
previousLeaderIds: Vector[HeroId]
|
||||
) =>
|
||||
VassalRisesMessageProto(
|
||||
risingHeroId = risingHeroId,
|
||||
factionId = factionId,
|
||||
isOnlyLeader = isOnlyLeader,
|
||||
previousLeaderIds = previousLeaderIds
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1345,6 +1364,24 @@ object LlmRequestConverter {
|
||||
previousHeadHeroId = previousHeadHeroId
|
||||
)
|
||||
|
||||
case VassalRisesMessageProto(
|
||||
risingHeroId,
|
||||
factionId,
|
||||
isOnlyLeader,
|
||||
previousLeaderIds,
|
||||
_ /* unknownFields */
|
||||
) =>
|
||||
VassalRisesMessage(
|
||||
requestId = requestId,
|
||||
eagleGameId = eagleGameId,
|
||||
recipientFactionIds = recipientFactionIds,
|
||||
alwaysGenerate = alwaysGenerate,
|
||||
risingHeroId = risingHeroId,
|
||||
factionId = factionId,
|
||||
isOnlyLeader = isOnlyLeader,
|
||||
previousLeaderIds = previousLeaderIds.toVector
|
||||
)
|
||||
|
||||
case GeneratedTextRequestDetails.Empty =>
|
||||
throw new IllegalArgumentException("Empty GeneratedTextRequestDetails")
|
||||
}
|
||||
|
||||
@@ -174,6 +174,22 @@ class AuthServiceImpl(
|
||||
}
|
||||
}
|
||||
|
||||
override def exchangeSessionTransferCode(
|
||||
request: ExchangeSessionTransferCodeRequest
|
||||
): Future[ExchangeSessionTransferCodeResponse] =
|
||||
externalAuthClient match {
|
||||
case Some(client) => client.exchangeSessionTransferCode(request)
|
||||
case None =>
|
||||
// Session transfer is handled by Go auth service only
|
||||
Future.failed(
|
||||
new io.grpc.StatusRuntimeException(
|
||||
io.grpc.Status.UNIMPLEMENTED.withDescription(
|
||||
"exchangeSessionTransferCode requires external auth service"
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private def toUserInfo(user: User, provider: OAuthProvider): UserInfo =
|
||||
UserInfo(
|
||||
userId = user.userId,
|
||||
|
||||
@@ -102,11 +102,9 @@ scala_library(
|
||||
":sync_response_observer",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:pregenerated_text_scala_proto",
|
||||
"//src/main/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/common:simple_timed_logger",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:pregenerated_text_store",
|
||||
"//src/main/scala/net/eagle0/eagle/library:eagle_client_exception",
|
||||
"//src/main/scala/net/eagle0/eagle/library:engine",
|
||||
"//src/main/scala/net/eagle0/eagle/library:game_history",
|
||||
@@ -377,6 +375,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:swear_brotherhood_prompt_generator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:truce_offer_message_prompt_generator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:truce_resolution_message_prompt_generator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:vassal_rises_prompt_generator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:claude_model_name",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:gemini_model_name",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:llm_provider",
|
||||
|
||||
@@ -13,7 +13,6 @@ import net.eagle0.eagle.api.eagle.*
|
||||
import net.eagle0.eagle.api.eagle.EagleGrpc.Eagle
|
||||
import net.eagle0.eagle.api.eagle.HexMapResponse.OneMapResponseInfo
|
||||
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest
|
||||
import net.eagle0.eagle.api.pregenerated_text.PregeneratedText
|
||||
import net.eagle0.eagle.library.settings.MaxSupportedPlayers
|
||||
import net.eagle0.eagle.library.EagleClientException
|
||||
import net.eagle0.eagle.service.new_game_creation.GameParametersUtils
|
||||
@@ -192,37 +191,6 @@ class EagleServiceImpl(
|
||||
lockedSendLobbyUpdate(userName)
|
||||
}
|
||||
()
|
||||
case UpdateStreamRequest.RequestDetails.PregeneratedTextRequest(
|
||||
request
|
||||
) =>
|
||||
this.synchronized {
|
||||
val currentPregeneratedText =
|
||||
gamesManager.getPregeneratedClientText
|
||||
if request.knownPregeneratedTextHash != currentPregeneratedText.hash
|
||||
then {
|
||||
val _ = responseObserver.onNext(
|
||||
UpdateStreamResponse(
|
||||
responseDetails = UpdateStreamResponse.ResponseDetails
|
||||
.PregeneratedTextResponse(
|
||||
PregeneratedTextResponse(
|
||||
pregeneratedText = Some(
|
||||
PregeneratedText(
|
||||
hash = currentPregeneratedText.hash,
|
||||
entries = currentPregeneratedText.texts.map {
|
||||
case (id, text) =>
|
||||
PregeneratedText.Entry(
|
||||
textId = id,
|
||||
text = text
|
||||
)
|
||||
}.toVector
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case UpdateStreamRequest.RequestDetails.JoinGameRequest(request) =>
|
||||
joinGame(request).map { joinGameResponse =>
|
||||
|
||||
@@ -51,6 +51,11 @@ class ExternalAuthClient(channel: ManagedChannel)(implicit ec: ExecutionContext)
|
||||
.logout(request)
|
||||
}
|
||||
|
||||
def exchangeSessionTransferCode(
|
||||
request: ExchangeSessionTransferCodeRequest
|
||||
): Future[ExchangeSessionTransferCodeResponse] =
|
||||
stub.exchangeSessionTransferCode(request)
|
||||
|
||||
def shutdown(): Unit = { val _ = channel.shutdown() }
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ import net.eagle0.eagle.api.eagle.JoinGameResult.INVALID_OPTIONS_RESULT
|
||||
import net.eagle0.eagle.api.eagle.ShardokPlacementCommands.ShardokPlacementCommand
|
||||
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{ShardokViewStatus, StreamingTextStatus}
|
||||
import net.eagle0.eagle.api.selected_command.SelectedCommand
|
||||
import net.eagle0.eagle.client_text.{ClientTextStoreImpl, PregeneratedClientTextStore, UnrequestedClientText}
|
||||
import net.eagle0.eagle.client_text.{
|
||||
ClientTextStore,
|
||||
ClientTextStoreImpl,
|
||||
PregeneratedClientTextStore,
|
||||
TextGenerationSuccess,
|
||||
UnrequestedClientText
|
||||
}
|
||||
import net.eagle0.eagle.internal.action_result.ActionResult
|
||||
import net.eagle0.eagle.internal.hero.Hero
|
||||
import net.eagle0.eagle.internal.running_games.{RunningGame, RunningGames}
|
||||
@@ -116,6 +122,43 @@ object GamesManager {
|
||||
(NewGameCreation.initialChronicleGeneratedTextId -> NewGameCreation.initialChronicleText)
|
||||
)
|
||||
|
||||
/**
|
||||
* Populates pregenerated texts (hero names, backstories) into a ClientTextStore. This ensures games are
|
||||
* self-contained by copying relevant pregenerated texts from the global store into the game's text store where they
|
||||
* will be saved and sent to clients like any other text.
|
||||
*/
|
||||
def withPopulatedPregeneratedTexts(
|
||||
clientTextStore: ClientTextStore,
|
||||
heroes: Iterable[HeroT],
|
||||
factionIds: Vector[FactionId]
|
||||
): ClientTextStore = {
|
||||
var store = clientTextStore
|
||||
|
||||
for hero <- heroes do {
|
||||
// Add name text if it's pregenerated and not already in the store
|
||||
pregeneratedTexts.getText(hero.nameTextId).foreach { nameText =>
|
||||
store = store.withAddedCompleteText(hero.nameTextId, nameText, factionIds)
|
||||
}
|
||||
|
||||
// Add backstory texts if pregenerated
|
||||
for backstoryVersion <- hero.backstoryVersions do
|
||||
pregeneratedTexts.getText(backstoryVersion.textId).foreach { backstoryText =>
|
||||
store = store.withAddedCompleteText(backstoryVersion.textId, backstoryText, factionIds)
|
||||
}
|
||||
}
|
||||
|
||||
// Also add the initial chronicle text
|
||||
pregeneratedTexts.getText(NewGameCreation.initialChronicleGeneratedTextId).foreach { chronicleText =>
|
||||
store = store.withAddedCompleteText(
|
||||
NewGameCreation.initialChronicleGeneratedTextId,
|
||||
chronicleText,
|
||||
factionIds
|
||||
)
|
||||
}
|
||||
|
||||
store
|
||||
}
|
||||
|
||||
def apply(
|
||||
shardokInternalInterface: ShardokInternalInterfaceStub,
|
||||
randomGenerator: Random,
|
||||
@@ -174,14 +217,23 @@ object GamesManager {
|
||||
.get
|
||||
|
||||
// Check all heroes' nameTextIds and recover any missing name requests
|
||||
val allHeroes = (history.last.resultingState.killedHeroes ++
|
||||
val allHeroes = (history.last.resultingState.killedHeroes ++
|
||||
history.last.resultingState.heroes).values
|
||||
val factionIds = history.last.resultingState.factions.keys.toVector
|
||||
|
||||
val recoveryResult = MissingHeroNameRecovery.recoverMissingHeroNames(
|
||||
// Populate pregenerated texts for all heroes in this game.
|
||||
// This ensures games are self-contained and old games get the texts they need.
|
||||
val withPregeneratedTexts = withPopulatedPregeneratedTexts(
|
||||
clientTextStore = loadedTextStore,
|
||||
heroes = allHeroes,
|
||||
factionIds = factionIds
|
||||
)
|
||||
|
||||
val recoveryResult = MissingHeroNameRecovery.recoverMissingHeroNames(
|
||||
clientTextStore = withPregeneratedTexts,
|
||||
heroes = allHeroes,
|
||||
gameId = gameId,
|
||||
factionIds = history.last.resultingState.factions.keys.toVector
|
||||
factionIds = factionIds
|
||||
)
|
||||
|
||||
val clientTextStore = recoveryResult.clientTextStore
|
||||
@@ -264,9 +316,6 @@ class GamesManager(
|
||||
// tracking, a removed game would appear as "unloaded" and be preserved.
|
||||
private var removedGameIds: Set[GameId] = Set.empty
|
||||
|
||||
def getPregeneratedClientText: PregeneratedClientTextStore =
|
||||
pregeneratedClientText
|
||||
|
||||
val shardokClient =
|
||||
new ShardokInterfaceGrpcClient(shardokInternalInterface, this)
|
||||
|
||||
@@ -665,7 +714,12 @@ class GamesManager(
|
||||
case (gameId, c) =>
|
||||
c.controller.userNameToFactionId
|
||||
.get(name)
|
||||
.map(fid =>
|
||||
.map { fid =>
|
||||
val resolveName: String => Option[String] = textId =>
|
||||
c.controller.clientTextStore.getText(textId) match {
|
||||
case TextGenerationSuccess(text) => Some(text)
|
||||
case _ => None
|
||||
}
|
||||
GamePlayerInfo(
|
||||
gameId,
|
||||
Some(fid),
|
||||
@@ -674,11 +728,12 @@ class GamesManager(
|
||||
c.controller.engine.currentState
|
||||
.factions(fid)
|
||||
.factionHeadId
|
||||
)
|
||||
),
|
||||
resolveName
|
||||
),
|
||||
lastPlayedMillis = c.lastPlayedByUser.get(name)
|
||||
)
|
||||
)
|
||||
}
|
||||
}.toVector
|
||||
}
|
||||
|
||||
@@ -688,18 +743,20 @@ class GamesManager(
|
||||
gamesAwaitingPlayers
|
||||
.filterNot(gap => gap.existingHumanPlayers.map(_.user).contains(userName))
|
||||
|
||||
private def availableLeader(hero: HeroT): AvailableLeader =
|
||||
private def availableLeader(hero: HeroT, resolveName: String => Option[String]): AvailableLeader =
|
||||
AvailableLeader(
|
||||
nameTextId = hero.nameTextId,
|
||||
profession = ProfessionConverter.toProto(hero.profession),
|
||||
imagePath = hero.imagePath
|
||||
imagePath = hero.imagePath,
|
||||
name = resolveName(hero.nameTextId).getOrElse("")
|
||||
)
|
||||
|
||||
private def availableLeaderFromProto(hero: Hero): AvailableLeader =
|
||||
AvailableLeader(
|
||||
nameTextId = hero.nameTextId,
|
||||
profession = hero.profession,
|
||||
imagePath = hero.imagePath
|
||||
imagePath = hero.imagePath,
|
||||
name = pregeneratedClientText.getText(hero.nameTextId).getOrElse("")
|
||||
)
|
||||
|
||||
def allLeaders(
|
||||
@@ -1165,6 +1222,31 @@ class GamesManager(
|
||||
)
|
||||
.toMap
|
||||
|
||||
// Create the initial text store
|
||||
val initialTextStore = ClientTextStoreImpl(
|
||||
pregenerated = pregeneratedClientText,
|
||||
persister = gamePersisterCreation.persisterForGame(gameId),
|
||||
completeTexts = Map(),
|
||||
incompleteTexts = Map(),
|
||||
unrequestedTexts = unrequestedTexts,
|
||||
// FIXME: this is making it all accessible to everyone
|
||||
accessibleTo = unrequestedTexts.values
|
||||
.map(ut => ut.id -> initialEar.engine.factionIds)
|
||||
.toMap,
|
||||
savedCompleteCount = 0,
|
||||
incompleteTextsAreSaved = false,
|
||||
accessibleToIsSaved = false
|
||||
)
|
||||
|
||||
// Populate pregenerated texts for all heroes in this game
|
||||
val allHeroes = initialEar.engine.currentState.heroes.values
|
||||
val factionIds = initialEar.engine.factionIds
|
||||
val textStoreWithPregenerated = GamesManager.withPopulatedPregeneratedTexts(
|
||||
clientTextStore = initialTextStore,
|
||||
heroes = allHeroes,
|
||||
factionIds = factionIds
|
||||
)
|
||||
|
||||
val withAiCommands = GameController.performAiCommands(
|
||||
GameController.advancedFrom(
|
||||
engine = initialEar.engine,
|
||||
@@ -1172,20 +1254,7 @@ class GamesManager(
|
||||
userNameToFactionId = userToFid,
|
||||
aiFids = initialEar.engine.factionIds
|
||||
.filterNot(userToFid.values.toVector.contains),
|
||||
clientTextStore = ClientTextStoreImpl(
|
||||
pregenerated = pregeneratedClientText,
|
||||
persister = gamePersisterCreation.persisterForGame(gameId),
|
||||
completeTexts = Map(),
|
||||
incompleteTexts = Map(),
|
||||
unrequestedTexts = unrequestedTexts,
|
||||
// FIXME: this is making it all accessible to everyone
|
||||
accessibleTo = unrequestedTexts.values
|
||||
.map(ut => ut.id -> initialEar.engine.factionIds)
|
||||
.toMap,
|
||||
savedCompleteCount = 0,
|
||||
incompleteTextsAreSaved = false,
|
||||
accessibleToIsSaved = false
|
||||
)
|
||||
clientTextStore = textStoreWithPregenerated
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +54,8 @@ import net.eagle0.eagle.library.actions.llm_prompt_generators.{
|
||||
SuppressBeastsSucceededPromptGenerator,
|
||||
SwearBrotherhoodPromptGenerator,
|
||||
TruceOfferMessagePromptGenerator,
|
||||
TruceResolutionMessagePromptGenerator
|
||||
TruceResolutionMessagePromptGenerator,
|
||||
VassalRisesPromptGenerator
|
||||
}
|
||||
import net.eagle0.eagle.library.settings.{ClaudeModelName, GeminiModelName, LlmProvider, OpenAiModelName}
|
||||
import net.eagle0.eagle.library.EagleInternalException
|
||||
@@ -521,6 +522,13 @@ class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, isGameValid: GameId =>
|
||||
clientTextStore = clientTextStore
|
||||
)
|
||||
|
||||
case vassalRisesMessage: LlmRequestT.VassalRisesMessage =>
|
||||
VassalRisesPromptGenerator(
|
||||
vassalRisesMessage = vassalRisesMessage,
|
||||
gameState = gameState,
|
||||
clientTextStore = clientTextStore
|
||||
)
|
||||
|
||||
// Handle FixedHeroName and GeneratedHeroName - these are not LLM requests
|
||||
case _: FixedHeroName =>
|
||||
throw new EagleInternalException(
|
||||
|
||||
Reference in New Issue
Block a user