Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 1eea60e40d Save Gameplay.unity scene changes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 18:44:56 -08:00
14a2eab47d Add Unity client support for VassalRises notification (#5643)
Adds notification generator and dispatcher registration for the VassalRises
notification added in #5642. Displays appropriate messages when a vassal
rises to lead a faction after all leaders are killed or imprisoned.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 18:19:00 -08:00
481f42d01c Add sound effect hooks to weather effects (#5641)
* Add sound effect hooks to WeatherEffectAnimator

Audio clips for weather sounds (assign in Inspector):
- rainLoopSound: gentle pitter-patter for normal rain
- thunderstormLoopSound: more intense rain for thunderstorm
- thunderSound: one-shot thunder clap (plays on lightning flash)
- blizzardLoopSound: intense wind sound

Volume controls:
- loopVolume: volume for looping sounds (default 0.5)
- thunderVolume: volume for thunder (default 0.8)

Sound behavior:
- Rain: plays rainLoopSound continuously
- Thunderstorm: plays thunderstormLoopSound + thunderSound on each flash
- Snow: no sound
- Blizzard: plays blizzardLoopSound continuously
- Sun/Clouds: no sound

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Support multiple thunder sounds with random selection

- Change thunderSound field to thunderSounds array
- Each lightning flash picks a random sound from the array
- Allows assigning Lightning Spelll 01-08 for variety

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add rain loop sound effect from Freesound

- rain_loop.ogg by aesqe (CC BY 4.0)
- Source: https://freesound.org/people/aesqe/sounds/37618/
- Rain falling on clay roof tiles, loopable
- Added attribution to docs/ASSET_AUDIT.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Document CC BY 4.0 sound effects for weather and runaway

Added to ASSET_AUDIT.md:
- rain_loop.ogg by aesqe (Freesound #37618) - for rain effect
- blizzard_wind_loop.wav by nsstudios (Freesound #651540) - for blizzard
- runaway.mp3 by Yap_Audio_Production (Freesound #218997) - replaces unlicensed file

Note: Files must be downloaded manually from Freesound (requires login).
Only 1 unlicensed sound effect remaining: failure_horn.mp3

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove stray download file

* Add rain and blizzard wind sound effects

- rain_loop.ogg by aesqe (CC BY 4.0, Freesound #37618)
- blizzard_wind_loop.wav by nsstudios (CC BY 4.0, Freesound #651540)

Location: Assets/Shardok/Sounds/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Replace runaway.mp3 with licensed version

MedievalArmyRunningLoop by Yap_Audio_Production (CC BY 4.0)
Source: https://freesound.org/people/Yap_Audio_Production/sounds/218997/

This resolves the licensing issue for runaway.mp3.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up weather sound effects in scene

- Assign rain_loop.ogg to rainLoopSound
- Assign blizzard_wind_loop.wav to blizzardLoopSound
- Configure thunder sounds array

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:31:22 -08:00
49af7c7524 Add vassal rises to leader when faction has no available leaders (#5642)
* Add vassal rises to leader when faction has no available leaders

When all faction leaders are killed or imprisoned, the strongest vassal
(by HeroUtils.power) is now promoted to faction leader instead of
destroying the faction. This prevents "zombie factions" that have
provinces and armies but can't act due to imprisoned leaders.

Changes:
- Add VassalRises notification and VassalRisesMessage LLM request types
- Add proto definitions for new types in notification and LLM protos
- Add proto converters for VassalRises types
- Create VassalRisesPromptGenerator for LLM narrative generation
- Modify CheckForFactionChangesAction to detect imprisoned leaders and
  promote vassals when no available leaders remain
- Generate appropriate notifications and LLM messages for vassal rises

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Enhance VassalRises prompt with previous leader details

Add previousLeaderIds to VassalRisesMessage to track fallen/imprisoned
leaders. The prompt generator now includes detailed descriptions for
each previous leader including:
- Their name and full backstory via HeroDescriptionGenerator
- Status: KILLED IN ACTION or IMPRISONED in [province] by [faction]

This gives the LLM more context to generate a message that properly
acknowledges and references the fallen/captured leaders.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:21:16 -08:00
b6dcc48bdd Add weather visual effects to Shardok battles (#5640)
* Add weather visual effects to Shardok battles

Adds full-screen weather effects based on the current weather conditions:
- SUN: Subtle brightness overlay
- CLOUDS: Slight dimming overlay
- RAIN: Falling rain particles with wind influence
- THUNDERSTORM: Heavy rain + periodic lightning flashes
- SNOW: Falling snowflakes with drift animation
- BLIZZARD: Heavy snow with strong horizontal wind

The WeatherEffectAnimator creates particles on a dedicated canvas
overlay and updates them continuously. Wind speed from the weather
data influences particle movement.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add procedural sprite generation and pre-made sprite assets

- Add PNG sprites for rain drops and snowflakes in Assets/Shardok/Sprites/
- Add procedural sprite generation as fallback if sprites not assigned
- Update tooltip to indicate sprites are optional
- Clean up generated textures in OnDestroy to prevent memory leaks

The component now works out of the box without requiring any assets
to be manually assigned. Sprites will be generated procedurally if
the inspector fields are left empty.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove procedural sprite generation, fix canvas render mode

- Remove procedural sprite generation code (use pre-made PNGs instead)
- Force canvas to Screen Space - Overlay mode so particles fall
  correctly relative to the screen instead of the 3D world

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add Weather Canvas and wire up WeatherEffectAnimator in scene

- Add Weather Canvas with Screen Overlay for weather effects
- Wire up rainDropSprite and snowFlakeSprite references
- Connect weatherEffectAnimator to ShardokGameController

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix particle positioning to use screen coordinates

- Anchor particles to bottom-left (0,0) so anchoredPosition is in
  screen pixel coordinates
- Use Screen.width/height directly instead of canvas rect size
- Remove unused _canvasRect field

This ensures particles appear correctly on screen regardless of
canvas RectTransform configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix particle positioning using canvas rect size

- Use canvas RectTransform rect.size instead of Screen dimensions
- This properly accounts for CanvasScaler settings
- Anchor particles to center (0.5, 0.5) and position relative to center
- Wrap-around logic uses half-width/height from center

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Double particle count and size for all weather effects

- Rain: 100→200 particles, 2x12→4x24 size
- Thunderstorm: 200→400 particles
- Snow: 80→160 particles, 3-8→6-16 size
- Blizzard: 250→500 particles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 14:49:35 -08:00
7d5320220c Remove PregeneratedText proto and unused service handling (#5637)
* Remove PregeneratedText proto and unused service handling

Now that clients no longer request pregenerated texts (they're sent via
normal ClientTextStore streaming), remove the unused proto messages:
- PregeneratedTextRequest/Response from eagle.proto
- pregenerated_text.proto file entirely
- Empty case handler in EagleServiceImpl.scala

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove pregenerated_text.proto reference from protos.csproj

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:52:12 -08:00
3eda670759 Replace free_icons with licensed Asset Store equivalents (#5639)
Replace all icons in Assets/free_icons/ (unknown license) with
equivalents from purchased Asset Store packs and delete the folder:

- 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)

These icons were only used in Map Editor (dev tool).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:50:02 -08:00
ea79b80d7b Replace snow.png with 16_blizzard_nobg.png from Asset Store (#5636)
Replace the free_icons/snow.png (unknown license) with
16_blizzard_nobg.png from 4000_Fantasy_Icons (purchased Asset Store
pack) in all locations:
- Gameplay.unity (Province Info blizzard indicator)
- Dominion Wide.prefab
- Dominion Two Rows.prefab
- Map Editor.unity

Delete the now-unused snow.png file.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:22:31 -08:00
2da74769b9 Remove PregeneratedTextRequest handling (#5633)
Pregenerated texts are now populated into each game's ClientTextStore
and sent via normal text streaming (see #5630). This removes the
separate PregeneratedTextRequest/Response mechanism.

- Remove PregeneratedText import from EagleServiceImpl
- Replace PregeneratedTextRequest handler with no-op for backward compatibility
- Remove getPregeneratedClientText method from GamesManager
- Remove unused pregenerated_text_scala_proto dependency from BUILD

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:21:16 -08:00
bb52212f9f Update asset audit to reflect current state (#5634)
* Update asset audit to reflect current state

- 2 sound effects remaining to replace (failure_horn.mp3, runaway.mp3)
- List the 8 weather icons in free_icons folder
- Reorganize action items into Must Replace / Low Priority / Resolved
- Update recommendation section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Delete unused weather icons (cloud.png, sun.png)

These icons were not referenced anywhere in the codebase.
Update asset audit to reflect 6 remaining weather icons.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:34:04 -08:00
81e8aad93b Remove client-side pregenerated text mechanism (#5635)
* Remove client-side pregenerated text mechanism

Pregenerated texts (hero names, backstories) are now sent to clients
via normal ClientTextStore streaming (thanks to #5630), so the separate
pregenerated text request/response mechanism is no longer needed.

- Remove ClientPregeneratedText.cs class and its Unity component
- Remove PregeneratedTextRequest sending from ConnectionHandler
- Remove PregeneratedTextResponse handling from PersistentClientConnection
- Remove ILobbySubscriber.ReceivePregeneratedTextUpdate method
- Update ClientTextProvider.GetTextEntry to not check pregenerated cache

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add resolved hero names to AvailableLeader for lobby display

The previous commit removed client-side pregenerated text storage, but
lobby UI (hero dropdowns for Create/Join game) still needed hero names.
This fix has the server resolve names and send them in AvailableLeader.name,
so the client can display them immediately without text lookups.

- Add name field to AvailableLeader proto message
- Server resolves names from ClientTextStore (running games) or
  PregeneratedClientTextStore (new game options)
- HeroDropdownController uses PreResolvedNames when available
- RunningGameItem/WaitingGameItem use leader.Name with fallback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:02:49 -08:00
f0bb59b5e5 Don't warn about river crossing when marching to own province (#5632)
The river crossing warning is only relevant when assaulting enemy
territory. When marching to a province owned by the same faction,
there's no assault so the river crossing isn't a concern.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:01:40 -08:00
ff09bda820 Populate pregenerated texts into game-specific ClientTextStore (#5630)
When creating or loading a game, copy the relevant pregenerated texts
(hero names, backstories, initial chronicle) from the global store into
the game's ClientTextStore as complete texts.

This ensures:
- Games are self-contained and immune to TSV file changes
- Texts are saved with the game and sent to clients normally
- Old games get the pregenerated texts they need on load

This is step 1 of removing the separate pregenerated text mechanism.
The global store is still used as a lookup source, but texts are now
stored in the regular completeTexts map for each game.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 08:20:59 -08:00
09919e63eb Layout tweaks and fix provider icon/invalid account bugs (#5631)
* Layout tweaks to game list prefabs and Gameplay scene

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix provider icon bug and handle invalid stored accounts

Bug fixes:
1. Provider icon bug: Session transfer was storing "session_transfer" as
   the provider instead of using the actual provider (discord, twitch,
   etc.) from the response. Now uses response.User.Provider.

2. User not found: When stored account validation fails with "user not
   found" (NotFound gRPC status), the invalid account is now removed from
   storage instead of just clearing the current selection.

3. NullReferenceException: When _createConnection() fails (no valid token),
   _internalConnectEagle() was continuing to use _persistentClientConnection
   which was null. Now returns early if connection creation failed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 08:19:40 -08:00
65d5e6867e Sanitize OAuth username for display name suggestion (#5629)
When suggesting a display name from the OAuth provider's username,
sanitize it to meet our validation requirements:
- Replace spaces with underscores
- Remove invalid characters (only allow alphanumeric + underscore)
- Collapse multiple underscores into one
- Trim leading/trailing underscores
- Truncate to 20 characters max
- Return empty if result is less than 3 characters

This prevents users from seeing a pre-filled invalid name that they
would need to manually fix.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:39:03 -08:00
f6c52ad5ab Use download buttons for both Windows and Mac in welcome email (#5628)
Change the Mac download from a text link to a button to match
the Windows download button.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:34:55 -08:00
3f1f9ebf07 Send welcome email after invitation flow completion (#5627)
After a user completes the invitation flow (OAuth sign-in, display name
selection), send them a welcome email with:
- Download instructions for Windows and Mac
- Download links for both platforms
- Deep link button to complete sign-in automatically
- Info about which provider/account they used

Also extends session transfer code expiration from 5 minutes to 1 hour
to give users time to install the game before the deep link expires.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:11:46 -08:00
9c279d85bd Fix resolution not being restored after fullscreen exit (#5626)
When minimizing the game for OAuth browser flow, we were storing
fullscreen mode but not resolution. When restoring, we only set
Screen.fullScreen=true, which uses whatever windowed resolution
Unity had stored (often 1280x720).

Now we:
- Store both fullscreen mode AND resolution (width/height) before exiting
- Use Screen.SetResolution() to restore both together

This affects both WindowFocusManager (OAuth flow) and ConnectionHandler
(new user sign-in flow).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:00:31 -08:00
e91e349d76 Fix accounts page delete behavior (#5625)
- Remove confirmation dialog for deleting non-pending invitations (already
  redeemed/expired/revoked, no need to confirm)
- Reload page after delete actions instead of htmx partial updates (the
  search endpoint returns rows with different columns than the accounts page)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 06:55:19 -08:00
358086154d Improve login tutorial for new users from invitation flow (#5624)
Update the sign-in tutorial text to:
- Mention the "Complete Sign-In with Eagle0" button on the download page
- Clarify they should use the same account they created

Also exit fullscreen when showing the auth panel to new users (no stored
accounts) so they can see the browser with the sign-in link, then restore
fullscreen after successful login.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 06:51:54 -08:00
7a6e871914 Fix chronicle dismiss crash from stale StartingState (#5623)
Root cause: HandleStartingState unconditionally overwrites ChronicleEntries.
During startup, multiple StartingState messages can arrive - a stale one
(e.g., from stateAfter(1) early in the game before chronicles existed)
arriving after a current state would wipe out existing entries.

Fix:
1. Don't overwrite existing chronicle entries with an empty list, since
   chronicles are historical records that accumulate (never disappear)
2. Close the chronicle canvas if entries are cleared while visible
   (defense-in-depth + correct UX behavior)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 06:43:57 -08:00
287b31f4c2 Use named pipes for Windows deep link IPC (#5622)
On Windows, when a second instance is launched via deep link, the URL
needs to be passed to the existing instance. Previously we just
activated the window and exited, losing the URL.

Now uses named pipes (the standard .NET IPC mechanism):
- First instance creates a NamedPipeServerStream and listens
- Second instance connects, sends the URL, then exits
- First instance receives the URL and processes it via OnDeepLinkReceived

This is the same pattern used by Electron's requestSingleInstanceLock().

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:38:47 -08:00
18c64713bc Fix Accounts page filter conflicts and event handling (#5621)
- Add unique IDs to filter inputs to prevent hx-include conflicts
- Fix invitation actions to only refresh invitations table, not users
- Use ID-based selectors instead of name-based for HTMX includes

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:29:21 -08:00
cb19483f34 Fix session transfer deep link arriving before auth client ready (#5620)
When the app is launched via a deep link (e.g., from the invitation
download page), the deep link can arrive in Awake() before
ConnectionHandler calls SetServerUrls(). This caused the session
transfer to silently fail because _authClient was null.

Fix by queuing the session transfer code and processing it once
SetServerUrls() is called and the auth client is available.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:21:14 -08:00
19e2f22b57 Combine Users and Invitations into single Accounts tab (#5619)
Merges the separate Users and Invitations pages into a unified
two-column layout under a single "Accounts" navigation tab.

- New /accounts route with side-by-side Users and Invitations panels
- Each panel has independent search/filter functionality
- Responsive layout stacks columns on smaller screens
- Keeps existing /users and /invitations routes for HTMX partial updates

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:17:59 -08:00
d8d3cb1317 Use gRPC for session transfer instead of HTTP (#5618)
* Use gRPC for session transfer instead of HTTP

The session transfer was using an HTTP endpoint on port 8080 which isn't
exposed to clients. Convert to use gRPC on port 40033 like all other auth
methods.

- Add ExchangeSessionTransferCode RPC to auth.proto
- Update AuthClient.cs to call gRPC instead of HTTP
- Add gRPC handler in Go auth service
- Remove HttpClient and SessionTransferResponse class from C#

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add exchangeSessionTransferCode to Scala auth service

The Scala Eagle server also implements the Auth gRPC service and needs
the new method. Add a stub that proxies to the external Go auth service
when available, or returns UNIMPLEMENTED when not configured.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:51:17 -08:00
a999f8b936 Add single-instance enforcement for Windows deep links (#5617)
* Add single-instance enforcement for Windows deep links

On Windows, URL schemes just launch the executable - they don't redirect
to an existing instance like macOS does. This causes deep links to open
a new game window instead of activating the existing one.

Added SingleInstanceEnforcer which:
- Uses a named mutex to detect if another instance is running
- If launched via deep link (eagle0://) and another instance exists:
  - Finds the existing window via FindWindow
  - Brings it to foreground via SetForegroundWindow
  - Exits the duplicate instance
- Normal launches (without deep link) still allow multiple instances

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix single-instance detection: use process name instead of window title

- Use Process.GetProcessesByName instead of FindWindow - more reliable
- Use Environment.Exit(0) instead of Application.Quit() - works early in lifecycle
- Increase sleep to 500ms to ensure window activation completes

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:40:24 -08:00
5035fe1a70 Add session transfer for seamless sign-in after invitation (#5615)
After completing OAuth on the web invitation flow, users can now click a
"Complete Sign-In in Eagle0" button to automatically sign in to the game
without going through OAuth again.

Implementation:
- New SessionTransferService generates one-time codes after OAuth
- Codes are 256-bit random, expire in 5 minutes, single-use
- Download page shows deep link button: eagle0://auth/session/{code}
- Unity client handles deep link, exchanges code for tokens via HTTP
- Login screen shows hint for users coming from invitations

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:20:12 -08:00
1c1066c1d8 Fix installer self-update not completing (#5616)
Two issues fixed:
1. WebView Close() was calling Destroy() directly from a goroutine,
   but this didn't properly terminate the event loop on the main thread.
   Now uses Dispatch(Terminate()) to properly exit the event loop.

2. handleInstallerReplacement was using fmt.Println instead of the UI,
   so the WebView showed "Initializing..." while the actual logic was
   invisible. Now uses ui.UpdateStatus/ShowError/etc for visibility.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:13:04 -08:00
d3d72ff8dc Add LoadIntoImage method to ResourceFetcher (#5613)
Adds LoadIntoImage alongside LoadIntoRawImage for loading runtime
textures into Unity Image components (which support Preserve Aspect
in layout groups). Factored out common functionality into helpers:
- CreateTexture: creates Texture2D from bytes or placeholder
- CreateSprite: creates Sprite from Texture2D
- CleanupRawImageTexture/CleanupImageSprite: cleanup helpers

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:00:53 -08:00
149872bea7 Fix OAuth timeout showing exception popup (#5612)
Changed Debug.LogError to Debug.LogWarning for handled OAuth errors.
The SimpleErrorHandler subscribes to Unity log messages and shows
popups for errors, but OAuth timeouts are expected conditions that
are already displayed in the status text.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:56:22 -08:00
96 changed files with 21588 additions and 2491 deletions
+1
View File
@@ -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
View File
@@ -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.");
@@ -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
@@ -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;
}
@@ -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) {
@@ -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);
@@ -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,
@@ -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
@@ -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,
@@ -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) {
@@ -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();
}
}
@@ -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;
@@ -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 ||
@@ -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;
@@ -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 }
@@ -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);
}
}
}
@@ -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) {
@@ -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
@@ -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
@@ -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
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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;
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 45dc0f911ac9447d7aa3e9279539c459
@@ -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;
}
}
@@ -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;
}
}
}
}
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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:
@@ -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",
],
+37 -7
View File
@@ -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">
+8 -2
View File
@@ -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">&#10003;</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",
@@ -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",
],
)
@@ -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
}
@@ -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")
}
@@ -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(