Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 c5aed72b06 Default Give Alms slider to 1000 (or max available)
Instead of starting at 0 food, default to 1000 or the maximum available
food, whichever is less. This is a more practical default for most uses.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:12:19 -08:00
adminandClaude Opus 4.5 0438ffea63 Tune weather effect materials for better visual quality
DroughtEffect:
- Increase texture tiling from 1x1 to 10x10 to reduce pixelation

FloodEffect:
- Increase texture tiling from 10x10 to 40x40 for finer detail
- Increase distortion strength from 0.02 to 0.1 for more visible waves
- Increase distortion speed from 2 to 5 for livelier animation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:02:28 -08:00
d3ecaaf908 Remove overly aggressive animation cancellation from ModelUpdated (#5745)
The CancelAllAnimations() call in ModelUpdated() was interrupting normal
animations (like archery) because ModelUpdated() is called frequently
during gameplay. The ChargeAnimator fix alone (tracking _weapon in a
class field) is sufficient to prevent floating weapons.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:40:11 -08:00
60ee36017c Account for devastation in TotalDevelopmentQuest current value (#5744)
Use effective development values (base - devastation) when showing
current total development, matching how the quest completion is
actually evaluated.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:39:49 -08:00
7e7821ab55 Fix quest fulfillment to only credit acting faction, not allies (#5742)
SuppressRiotByForceQuest and FightBeastsAloneQuest were incorrectly
checking all provinces for quest fulfillment. This meant if an ally
performed the action, unaffiliated heroes in your provinces would
have their quests fulfilled.

Now filters to only check provinces ruled by the acting faction.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:29:17 -08:00
6304d5e3f9 Fix floating weapons by properly cleaning up combat animations (#5741)
- Add _weapon field to ChargeAnimator to track created weapon GameObjects
- Add CleanupWeapon() method to ChargeAnimator for proper cleanup
- Update CancelAnimation() in ChargeAnimator to destroy lingering weapons
- Add CancelAllAnimations() method to ShardokGameController
- Call CancelAllAnimations() at start of ModelUpdated() to clean up any
  lingering animation effects when the model state changes

Previously, if a charge animation was interrupted (e.g., by rapid clicking
or state changes), the weapon GameObject would remain floating on the map
because it was stored in a local variable rather than a class field.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:21:03 -08:00
15f32ed0e2 Fix weather shader texture property name for Unity UI compatibility (#5740)
Rename _EffectTex to _MainTex in the ProvinceWeather shader and update
all weather effect materials to use the new property name. Unity's
RawImage component expects _MainTex as the default texture property.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:56:49 -08:00
37d07b2bfa Add all missing quest type cases in pastTenseQuestDescription (#5739)
Adds the following missing cases:
- RescueImprisonedLeaderQuest
- ExpandToProvincesQuest
- SuppressRiotByForceQuest
- FightBeastsAloneQuest

This ensures all Quest subtypes are handled in pastTenseQuestDescription.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:52:28 -08:00
eead7bddfd Add TotalDevelopmentQuest case in pastTenseQuestDescription (#5738)
Fixes crash when generating hero backstory update for a completed
TotalDevelopmentQuest.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:37:09 -08:00
18a0c573aa Wire up province weather effect overlays in scene (#5737)
Hook up ProvinceWeatherController references in the Gameplay scene to
enable animated weather overlays (blizzard, flood, drought) on the
strategic map.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:36:40 -08:00
f10fd322e5 Show current development in TotalDevelopmentQuest description (#5736)
Display the current total development value alongside the target in the
quest description, making it easier for players to track their progress.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:33:48 -08:00
a60e64f1bf Add missing RandomSequential case in PerformVassalCommandsPhaseAction (#5735)
The TCommand.RandomSequential case was missing from the match expression,
causing a MatchError when SuppressBeastsCommand (which returns RandomSequential)
was executed during vassal commands phase.

Also made randomResults public in ProtolessRandomSequentialResultsAction
so it can be called from the match.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:14:00 -08:00
f069475f99 Add public credits/attributions web page (#5733)
Adds /credits endpoint to the auth service that displays all attribution
information from attributions.json in a readable HTML format.

The page includes:
- Creative Commons music (31 tracks with artist and license)
- Sound effects from Freesound (with source links)
- Fonts (with license info)
- Open source software dependencies (Java, Go, C++, C#)

The attributions.json file is now included in the auth server Docker
image so it can be served at runtime.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:46:54 -08:00
f40a860c93 Add attributions.json for license disclosures (#5732)
Structured JSON file containing attribution data for:
- Creative Commons music (31 tracks)
- CC sound effects from Freesound (7 files)
- Fonts (Open Sans, Alata, Josefin Sans, etc.)
- Open source software dependencies (Java, Go, C++, C#)
- License reference definitions

File is placed in both server resources and Unity Resources
folder for use in web and in-app attribution displays.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:40:24 -08:00
a3d64a17a2 Update SMALL_EAGLE_TODO with completed items (#5731)
* Update SMALL_EAGLE_TODO with completed items

Mark as done:
- Fix the Mac installer
- Generatedtext healing
- Audit assets for anything we don't have rights to and replace it
- Replace heroes that are based on real 20th or 21st century people or IP
- Running low on food tutorial

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

* Mark warlord profession tutorial as complete

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:18:26 -08:00
49ac693501 Remove obsolete documentation (#5730)
Delete outdated docs for completed work:
- OAuth implementation plans (OAuth is implemented)
- Scala 3 migration docs (migration is complete)
- Admin server enhancements (implemented)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:15:02 -08:00
be4519b51e Add starting position indicator to Trade slider (#5729)
Shows an arrow indicating the original food/gold balance, making it
easy to return the slider to its starting position (no change).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:10:50 -08:00
1eaa70fa47 Layout improvements (#5728)
* HeroesAndBattalions layout improvements

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

* Additional layout improvements

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:43:07 -08:00
3f685b05f5 Restore ransom offer scoring for AI acceptance and offering (#5726)
Restores the ransom offer scoring logic that was removed during deproto work.

**Acceptance side (ResolveDiplomacyCommandSelector):**
- AI now scores ransom offers using: prisoners + hostages + gold - base threshold
- Offers with positive score are accepted, others rejected

**Offering side (CommandChoiceHelpers.maybeRansomLeaderCommand):**
- Uses RansomOfferHelpers.chosenOffer to create minimal acceptable offers
- Checks trust threshold between factions before offering
- Enforces minimum time between repeat offers to same faction
- Filters out faction leaders from hostage offers
- Calculates minimal offer (prisoners + gold first, adds hostages only if needed)

**New file: RansomOfferHelpers.scala**
- ransomOfferScore(): Calculate offer value using settings
- chosenResolution(): Accept/reject based on score
- chosenOffer(): Create a reasonable offer from available options

Settings used:
- RansomOfferScorePerPrisoner, RansomOfferScorePerHostage, RansomOfferScorePerGold
- RansomOfferBaseNegative (threshold)
- MinimumTrustToOfferRansom, MinimumMonthsBeforeRepeatRansomOffer

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:37:23 -08:00
85a7b2669f Add widescreen-aware HeroesAndBattalions width and layout improvements (#5727)
* Add client support for SuppressRiotByForceQuest and FightBeastsAloneQuest

- DisplayNames.cs: Add display names for new quest types
- UnaffiliatedHeroRowController.cs: Add quest text formatting

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

* Clarify FightBeastsAloneQuest text

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

* Add widescreen-aware HeroesAndBattalions width and layout improvements

- Add popupCanvasLeftSpacer and popupCanvasRightSpacer LayoutElement refs
- In widescreen: HeroesAndBattalions width = left + right spacer
- In non-widescreen: HeroesAndBattalions width = left spacer only
- PleaseRecruitMe layout improvements

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:16:00 -08:00
228b63e2e0 Add client support for SuppressRiotByForceQuest and FightBeastsAloneQuest (#5725)
* Add client support for SuppressRiotByForceQuest and FightBeastsAloneQuest

- DisplayNames.cs: Add display names for new quest types
- UnaffiliatedHeroRowController.cs: Add quest text formatting

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

* Clarify FightBeastsAloneQuest text

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:59:16 -08:00
8fab725ac8 Add FightBeastsAloneQuest (#5720)
* Add FightBeastsAloneQuest - fulfilled when hero fights beasts without battalion

- Add proto message FightBeastsAloneQuest (field 27)
- Add Quest case class and converter
- Add soothsayer text for divine message prompt
- Add to CheckForFulfilledQuestsAction (returns false - action-based quest)
- Add to QuestCreationUtils for provinces with BeastsEvent
- Modify SuppressBeastsCommand to check quest fulfillment when optionalBattalion.isEmpty
- Add TCommand.RandomSequential variant for commands with both random rolls and multiple results
- Update RandomStateSequencer to handle RandomSequential

The quest is fulfilled simply by making the attempt to fight beasts alone,
regardless of whether the hero survives or not.

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

* Make FightBeastsAloneQuest province-agnostic

Fighting beasts alone in any province now fulfills the quest, rather than
requiring a specific province. Also replace isInstanceOf with pattern matching.

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

* Add missing override modifier to withProtolessRandomSequentialResultsAction

The method in RandomStateSequencerImpl overrides the trait definition
but was missing the override keyword, causing a compilation error.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:05:43 -08:00
71298e2cce Make SuppressRiotByForceQuest always available (#5724)
The quest should be offered regardless of whether a riot is currently
imminent - it's a general "when riots happen, crack down on them" quest
requirement that applies to future riots.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:39:41 -08:00
1c36b75201 Filter faction leaders from AI ransom offers (#5723)
When the AI makes ransom offers, it was including faction leaders as
potential hostages. This change:

1. Filters out any hostages that are leaders of the offering faction
2. Skips making the offer entirely if after filtering there's nothing
   left to offer (no prisoners, no non-leader hostages, no gold)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:33:56 -08:00
3b393ea84e Always enforce single instance - never allow second copy to run (#5721)
Previously, when a second instance was launched without a deep link,
it would allow itself to run as a duplicate. This caused issues in
the invitation flow where the user could end up with two copies.

Now the second instance always exits, regardless of whether it has
a deep link. If you want to run a new instance, close the existing one.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:18:41 -08:00
e4bcbc2083 Add widescreen spacer and layout improvements (#5722)
- Add wideSpacer GameObject reference to EagleGameController
- Enable spacer only when aspect ratio > 2.0 (widescreen)
- Additional layout adjustments in Gameplay.unity

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:05:59 -08:00
70c4c0025e Add SuppressRiotByForceQuest (#5719)
* Add SuppressRiotByForceQuest

Adds a new quest type that is fulfilled when a player successfully
suppresses a riot using the crack down command (hero survives).

Changes:
- Add SuppressRiotByForceQuest to proto and Quest.scala
- Add TCommand.RandomSequential variant for commands that return
  multiple results with random rolls
- Refactor HandleRiotCrackDownCommand to use the new variant and
  check for quest fulfillment on success
- Add quest creation for provinces with ImminentRiotEvent
- Update tests for new command signature

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

* Make SuppressRiotByForceQuest province-agnostic

Suppressing a riot in any province now fulfills the quest, rather than
requiring a specific province.

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

* Replace isInstanceOf with pattern matching in QuestCreationUtils

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:02:21 -08:00
75e32393b3 Improve OAuth landing pages for different user scenarios (#5718)
- New user without invitation (when required): Show clear error page
  explaining that an invitation is needed, no deep link
- Existing user: Auto-redirect to app via JavaScript (no button click)
- New user with invitation: Auto-redirect to app for registration flow

The invitation flow (separate handler) keeps the button with deep link
as users may need to download the app first.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 12:29:00 -08:00
79fa0bbee0 Upgrade Unity from 6000.3.0f1 to 6000.3.6f1 (#5717)
* Upgrade Unity from 6000.3.0f1 to 6000.3.6f1

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

* Add missing .meta files for notification generators

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:41:27 -08:00
9433577fc3 Fix OAuth flow for new users - don't create user prematurely (#5716)
* Fix OAuth flow for new users - don't create user prematurely

The generateDeepLinkURL function was calling FindOrCreateUser immediately
in the OAuth callback, which:
1. Created users before invitation code validation
2. Created users before display name was set

This caused new users without invitations to get through the OAuth flow
successfully but then fail to log in because they had no display name.

Fix:
- Server: Check if user exists first. For existing users, generate
  session transfer code. For new users, return state parameter so
  client can poll CheckOAuthStatus which properly handles invitation
  validation and new user registration.
- Client: Add handling for eagle0://auth/result/{state} deep link
  that polls CheckOAuthStatus for new user registration flow.

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

* Add CheckOAuthStatusAsync method to AuthClient

The HandleOAuthResultAsync method was calling a non-existent method.
Add a public single-check method that wraps the gRPC call.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:36:27 -08:00
c7b26828d6 Automate Unity version sync and CI installation (#5714)
* Automate Unity version sync and CI installation

1. UnityVersionSync.cs: Editor script that automatically updates
   ci/unity_version.sh when the project is opened in a newer Unity version.
   This runs on project load via [InitializeOnLoad].

2. ensure_unity_installed.sh: CI script that checks if the required Unity
   version is installed and attempts to install it via Unity Hub CLI if not.
   Added to all Unity build workflows.

3. Updated workflow paths to trigger on ci/unity_version.sh changes.

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

* Add platform-specific Unity modules and concurrent install safety

- Pass platform argument (mac/windows/ios) to ensure_unity_installed.sh
- Add lock file mechanism to prevent concurrent Unity installations
- Install only needed modules per platform (mac-il2cpp, windows-mono, ios)

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

* Read Unity version from ProjectVersion.txt instead of manual file

- Remove ci/unity_version.sh (manually maintained)
- Remove Assets/Editor/UnityVersionSync.cs (auto-sync script)
- Update ensure_unity_installed.sh to parse ProjectVersion.txt directly
- Unity automatically maintains ProjectVersion.txt, no manual step needed

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

* Update all build scripts to read Unity version from ProjectVersion.txt

- build_windows.sh
- build_unity_ios.sh
- build_ios_addressables.sh
- build_mac.sh
- ensure_unity_installed.sh (fix comment)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:25:23 -08:00
672adc2554 Add GaveToAvertRiot backstory event for riot prevention attempts (#5715)
When a hero gives food or gold to avert a potential riot, a backstory
event is now recorded with whether the attempt succeeded or failed.
This complements the existing SuppressedRiot event for crackdowns.

- Add GaveToAvertRiotBackstoryEvent to proto with province_id, food_given,
  gold_given, and succeeded fields
- Add GaveToAvertRiot case to EventForHeroBackstory enum
- Update HandleRiotGiveCommand to accept currentDate and create the
  backstory event for the ruling hero
- Add handler in HeroBackstoryUpdatePromptGenerator for LLM text generation
- Add tests verifying backstory events are created for both success and
  failure cases

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:19:30 -08:00
4fb50142ff Quit app when opening browser for OAuth (#5713)
* Quit app when opening browser for OAuth

Instead of keeping the app running and polling for OAuth completion,
quit the app after opening the browser. The server's "Return to Eagle"
deep link will relaunch a fresh instance with the auth session code,
which HandleSessionTransferAsync processes.

This eliminates the dual-instance problem where both the original app
(polling/waiting) and the deep-linked instance were running, leaving
an orphaned app window.

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

* Generate session transfer code in OAuth callbacks

When a desktop client completes OAuth, the callback now:
1. Creates/finds the user immediately (instead of during polling)
2. Generates a session transfer code
3. Returns deep link with the code: eagle0://auth/session/{code}

This allows the Unity app to quit during OAuth and relaunch cleanly
when the user clicks "Return to Eagle" - the new instance exchanges
the session transfer code for tokens via HandleSessionTransferAsync.

Updated all three OAuth callbacks (generic, Apple, Steam) to use
the new generateDeepLinkURL helper.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:06:51 -08:00
1129d81435 Add weekly Bazel cache cleanup workflow (#5710)
* Add weekly Bazel cache cleanup workflow

Removes Bazel output bases not accessed in 7 days to prevent disk
space accumulation on self-hosted runners. Keeps 'cache' and 'install'
directories. Remote cache means minimal perf impact from clearing
local output bases.

Runs weekly on Sunday at 00:00 UTC, can also be triggered manually.

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

* Use bazel clean instead of find-based cleanup

The previous approach looked for output bases not accessed in 7 days,
but since the runner is used regularly, those directories always have
recent access times and would never be cleaned up.

Using `bazel clean` weekly clears the local output base, freeing disk
space. The next build will be slightly slower but will pull from remote
cache.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:49:36 -08:00
ff9c455e81 Use AllowSetForegroundWindow to bring game to foreground (#5712)
* Use AllowSetForegroundWindow to let game take foreground

Instead of complex window closing timing, use the Windows API
AllowSetForegroundWindow() to grant the game process permission
to bring itself to foreground. This is the proper way to allow
a child process to take focus.

Changes:
- Add foreground_windows.go with AllowSetForegroundWindow wrapper
- Installer calls AllowSetForegroundWindow(pid) after launching game
- Pass --foreground flag to game
- Re-add --foreground handling in SingleInstanceEnforcer
- Game calls WindowFocusManager.BringToForeground() after 500ms delay

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

* Add foreground_stub.go for non-Windows builds

The AllowSetForegroundWindow function is Windows-only but the
installer needs to compile on all platforms for testing.

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

* Remove unused unsafe import

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

* Add foreground_windows.go to WebView genrule srcs

The genrule for the WebView build manually lists source files
and was missing the new foreground_windows.go file.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:45:04 -08:00
a6b0c78d48 Add UX support for ExpandToProvincesQuest and TotalDevelopmentQuest (#5711)
* Add UX support for ExpandToProvincesQuest and TotalDevelopmentQuest

- ExpandToProvincesQuest: Shows "Control X provinces (currently Y)"
- TotalDevelopmentQuest: Shows "Raise total development to X in {province}"

All quest types are now handled in the client.

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

* Fix ExpandToProvincesQuest to count developed provinces only

A province is considered "developed" when support >= 40 (MinSupportForTaxes).
Changed text from "Control X provinces" to "Have X developed provinces".

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:29:23 -08:00
5f44a79b18 Add TotalDevelopmentQuest for comprehensive province improvement (#5702)
New quest type that challenges players to raise the total development
(effective agriculture + economy + infrastructure) of the quest province
significantly higher than its current level. Uses 2x the standard
improvement quest range.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:16:17 -08:00
62a1aaff07 Fix installer launch order for proper foreground behavior (#5709)
The installer must close its window before launching the game so the
game can naturally become the foreground window. But ui.Close()
terminates the event loop, causing main() to exit.

Solution: Use a channel to make main() wait for the game to launch
after the window closes:
1. Goroutine: ui.Close() - closes window
2. Main thread: ui.Run() returns, waits on gameLaunched channel
3. Goroutine: sleeps 200ms (window now fully closed)
4. Goroutine: cmd.Start() - launches game (no foreground competition)
5. Goroutine: close(gameLaunched)
6. Main thread: receives signal, exits

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:10:22 -08:00
52498bff60 Add ExpandToProvincesQuest for faction expansion goals (#5701)
* Add ExpandToProvincesQuest for faction expansion goals

New quest type that challenges factions to expand to X provinces (where X is
current count + 2 to 5). Success requires having X provinces with support at
or above the tax minimum threshold. No failure condition.

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

* Cap ExpandToProvincesQuest target to total map size

Ensures the quest never requires more provinces than exist on the map.
If the faction already controls enough provinces that the minimum
expansion (2 provinces) would exceed the map size, don't offer the quest.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:04:56 -08:00
760435183b Add cleanup for /tmp/eagle0-* build directories in workflows (#5708)
All four workflows that use EAGLE0_BUILD_DIR were creating build
directories at /tmp/eagle0-{run_id} but never cleaning them up,
causing disk space accumulation on self-hosted runners.

Added `rm -rf "$EAGLE0_BUILD_DIR"` cleanup step with `if: always()`
to ensure cleanup runs even if the build fails.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:02:10 -08:00
599ae2db7f Rename QuestC to Quest and flatten directory structure (#5707)
* move out of concrete/

* Rename QuestC to Quest

The "C" suffix was for "Concrete" to distinguish from the old QuestT trait.
Now that QuestT is removed, simplify to just Quest.

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

* Remove redundant explicit imports

The wildcard import already covers all quest types.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:27:48 -08:00
1fccff00ff Close installer window before launching game for natural foreground (#5706)
Instead of trying to force the game to foreground with SetForegroundWindow
(which doesn't work reliably from a background process), close the installer
window before launching the game. This allows the game to naturally become
the foreground window since there's no competing window.

Also removes the --foreground flag handling that was added but didn't work.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:23:28 -08:00
2f8468af4d Simplify questDescription in DivineMessagePromptGenerator (#5705)
Refactor nested flatMap/match to use idiomatic Option chaining with
.flatMap(_.quest).map(...).getOrElse(...) pattern.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 08:52:04 -08:00
d9aa51d368 Bring game window to foreground after install (#5703)
The installer now passes --foreground flag when launching the game.
SingleInstanceEnforcer detects this flag and calls WindowFocusManager.BringToForeground()
after a 500ms delay to ensure the window is fully created.

This fixes the issue where the game window would appear behind the installer
after a fresh install or update.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 08:35:57 -08:00
bb3ed2bd17 Remove QuestT trait, use QuestC directly for exhaustive matching (#5704)
Replace the non-sealed QuestT trait with the sealed QuestC trait throughout
the codebase. This enables compile-time exhaustiveness checking on quest
type pattern matches, ensuring all quest types are handled.

Changes:
- Delete QuestT.scala and its BUILD target
- Update all imports from quest.QuestT to quest.concrete.QuestC
- Update method signatures and type annotations
- Add quest/concrete to BUILD exports for transitive visibility

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 08:29:48 -08:00
1ee0b3283b Add UX support for RescueImprisonedLeaderQuest (#5700)
* Add UX support for RescueImprisonedLeaderQuest

Display the new quest type in the free heroes list with the imprisoned
hero's name and the imprisoning faction. Uses dynamic hero name loading
like other hero-specific quests.

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

* Use hero name instead of 'imprisoned leader' in quest text

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

* Use actual hero name in rescue quest fallback text

Look up the imprisoned hero's name from the model instead of showing
generic "a hero" text.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:20:36 -08:00
c240778819 Fix RescueImprisonedLeaderQuest fulfillment check (#5699)
The quest should only be fulfilled when the hero is back as a faction
leader, not just when they're no longer imprisoned. Previously, the
quest would be incorrectly marked as fulfilled if the hero was executed.

- Fulfillment: Hero must be back in the faction's leaderIds
- Failure: Hero is no longer imprisoned but also not back as a leader
  (e.g., executed or released as traveler)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:06:22 -08:00
08072b2a3b Display quests concerning prisoners in Manage Prisoners UI (#5696)
When viewing a prisoner in the Manage Prisoners command selector, the UI
now shows any known quests from free heroes that relate to this prisoner.
For example, if a free hero has a quest to execute or release a specific
prisoner, that information is now displayed in the message text area.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:58:45 -08:00
04b6f21cd3 Add RescueImprisonedLeaderQuest for free heroes (#5698)
A new quest type that free heroes may offer when the divining faction
has at least one faction leader imprisoned by another faction.

The quest is to rescue that leader, either by ransom or by force
(conquering the province where they are held).

- Quest is fulfilled when the leader is no longer imprisoned by the
  specified faction
- Quest fails if the imprisoning faction is eliminated

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:55:17 -08:00
5c8b324dc8 Fix Mac codesigning to use correct keychain in CI (#5697)
* Remove --keychain flag from codesign commands

The --keychain flag was causing codesign to look for the private key in
the build keychain, but the matching cert+key is in login.keychain.
Since we now use the unambiguous SHA-1 hash, codesign will find the
correct certificate and key pair in whichever keychain contains them.

This fixes the intermittent errSecInternalComponent failures.

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

* Use build keychain specifically for CI codesigning

In CI, the workflow imports the signing certificate into a temporary
build keychain. Previously, the script searched ALL keychains and
picked the first certificate found, which could be an old certificate
from login.keychain instead of the freshly imported one.

Now when KEYCHAIN_NAME is set (CI environment), the script looks for
certificates only in that specific keychain. For local dev (no
KEYCHAIN_NAME), it still searches all keychains.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:51:15 -08:00
f8e8ca4fab Warn when sending faction leader on Suppress Beasts command (#5695)
* Warn when sending faction leader on Suppress Beasts command

Add warning when the player is about to send their faction leader on a
beast suppression mission. Losing the faction leader ends the game.

Three warning cases:
- No battalion: "Your hero may be killed"
- Faction leader with battalion: "You're risking your faction leader!"
- Faction leader without battalion: Combined warning for both risks

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

* Fix: Use TryGetValue instead of GetValueOrDefault

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

* Update warning text to use sworn brother/sister title

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:35:11 -08:00
adminandGitHub 3bf44093a5 Add LLM-generated text to outlaw apprehended notification (#5691) 2026-01-29 17:35:54 -08:00
9a7dd953f9 Add starvation notification visible to all factions (#5694)
* Add starvation notification visible to all factions

When a province runs out of food and battalions suffer casualties, a notification
is now generated with a darkly humorous LLM message from the province's ruling
hero. The notification is visible to all factions (targetFactionIds = empty).

Changes:
- Add StarvationDetails proto message for notification details
- Add StarvationNotificationMessage proto for LLM text generation
- Add Scala case classes for both notification and LLM request types
- Create StarvationNotificationPromptGenerator with gallows humor prompt
- Wire up in LlmResolver and PerformFoodConsumptionPhaseAction
- Add C# notification generator for Unity client display
- Update proto converters for serialization/deserialization

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

* Add LLM-generated text to outlaw apprehended notification

The captured outlaw now delivers a reflection on their situation.
Also adds apprehendingHeroId to the notification details.

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

* Revert "Add LLM-generated text to outlaw apprehended notification"

This reverts commit 60295e09ba74fd0b755bf4808353be05fa8554f5.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:16:39 -08:00
9171222a45 Fix iOS artifact storage and broken pipe error (#5693)
1. Don't upload IPA artifact after TestFlight upload - it's redundant
   and takes 150MB per build. Only keep artifact when skipping upload
   (for debugging), with 1-day retention.

2. Fix "broken pipe" error in artifact storage check by writing to temp
   file instead of piping through sort | head (which causes SIGPIPE).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:44:28 -08:00
adminandGitHub 921b11c075 Fix iOS TestFlight upload and add auto-incrementing build numbers (#5692) 2026-01-29 12:53:29 -08:00
53446667f9 Add hero backstory events for epidemic, weather, and starvation (#5689)
* Add hero backstory events for epidemic, weather, and starvation

- Add StartedEpidemic backstory event when a hero starts an epidemic
- Add ChangedWeather backstory event when a hero changes weather
- Add SurvivedStarvation backstory event for heroes in starving provinces

Changes:
- EventForHeroBackstoryT: Add 3 new enum cases
- Proto: Add corresponding proto messages
- EventForHeroBackstoryConverter: Add toProto/fromProto conversions
- HeroBackstoryUpdatePromptGenerator: Add LLM prompt text for events
- StartEpidemicCommand: Generate backstory event when executed
- ControlWeatherCommand: Generate backstory event when executed
- PerformFoodConsumptionPhaseAction: Generate backstory events for heroes
  in provinces experiencing starvation
- CommandFactory: Pass required date/faction params to commands
- Tests: Update to include new command parameters

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

* Improve backstory events: add acting province, enum weather type

- Add actingProvinceId to StartedEpidemic and ChangedWeather events
  to record the province the hero acted from (not just the target)
- Change weatherChangeType from String to WeatherChangeType enum
  for type safety in both Scala and proto
- Remove percentage from starvation prompt text (keep severity only)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 06:36:33 -08:00
c3ad03c270 Fix Mac codesign by using certificate hash (#5690)
* Fix Mac codesign by isolating build keychain during signing

The --keychain flag alone doesn't prevent codesign from finding matching
identities in other keychains on the search list. Fix by temporarily
setting ONLY the build keychain as the search list during signing, then
restoring the original list on exit.

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

* Include System.keychain for Apple root certificates

The previous fix broke certificate chain verification because we removed
the login keychain but also lost access to Apple's root certificates.
Include System.keychain (which has Apple roots) but not login.keychain
(which has the duplicate signing identity).

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

* Use certificate SHA-1 hash instead of name to avoid ambiguity

Instead of manipulating the keychain search list (which breaks certificate
chain verification), extract the SHA-1 hash of the specific certificate
from the build keychain and use that as the signing identity. SHA-1 hashes
are unambiguous and codesign will use the exact certificate specified.

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

* Fix certificate hash extraction - get first valid identity

The previous grep for SIGNING_IDENTITY failed because GitHub Actions
masks the value. Instead, get the first valid codesigning identity from
the keychain (there should only be one since we just imported it).

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

* Use full keychain path for find-identity

security find-identity requires the full path to the keychain file,
not just the keychain name. Resolve the full path from list-keychains
output before querying for identities.

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

* Add debug output to certificate import and remove set-keychain-settings

Add debug output to see what identities are available after import.
Also remove the set-keychain-settings line which may be causing issues
(the -u flag locks keychain on sleep).

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

* Search all keychains for identity hash

The build keychain import isn't creating a recognizable codesigning
identity, but the certificate exists in login.keychain. Search all
keychains and use the hash of the first valid identity - hashes are
unique and unambiguous regardless of which keychain contains them.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 06:34:58 -08:00
adminandGitHub 5389c2ca19 Refactor EventForHeroBackstoryT from sealed trait to Scala 3 enum (#5687) 2026-01-28 23:14:42 -08:00
8336db7b29 Fix Mac codesign ambiguous identity error (#5688)
Add --keychain flag to all codesign commands to explicitly specify which
keychain to use. This fixes the "ambiguous" error when the same signing
identity exists in multiple keychains (build keychain and login keychain).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:08:32 -08:00
77791da7c7 Fix Mac build keychain access (errSecInternalComponent) (#5686)
The Mac build was failing with errSecInternalComponent because the newly
created keychain wasn't added to the search list. Without this step,
codesign cannot find the certificate.

Changes:
- Add keychain to search list with security list-keychains
- Add -T /usr/bin/security to import command for completeness
- Set 1-hour keychain timeout for signing large app bundles

This matches the keychain setup used in the iOS TestFlight workflow.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:49:22 -08:00
adminandGitHub 7bef82a419 Add new profession proposals document (#4900) 2026-01-28 17:19:05 -08:00
558b2e54d6 Fix fullscreen toggle not working on Windows (#5685)
* Fix fullscreen toggle not working on Windows

Screen.fullScreen alone doesn't reliably toggle fullscreen on Windows.
Use Screen.SetResolution with explicit FullScreenMode instead.

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

* Fix fullscreen toggle control wiring in Gameplay scene

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 17:07:03 -08:00
9e464eac2c Add SuppressedRiotBackstoryEvent for crack down command (#5684)
Heroes who suppress riots via the crack down command now get a backstory
event recording the encounter. The event captures the province, riot size,
casualties, battalion used (if any), and whether suppression succeeded.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 16:53:04 -08:00
8aebf6bbc8 Add province weather effect overlays for strategic map (#5661)
* Add province weather effect overlays for strategic map

Implements animated weather effect overlays (blizzard, flood, drought) on
the Eagle strategic map. Effects are province-specific, dynamically created
as child overlays when weather events are active.

- Add ProvinceWeatherShader with scrolling animation and distortion support
- Add ProvinceWeatherController that dynamically creates overlays per province
- Add placeholder textures for snow, water caustics, and cracked earth
- Add effect materials with configured animation parameters
- Integrate weather updates into MapController's model setter

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

* Update weather effect materials with proper settings

- BlizzardEffect: Set scroll direction to move snow downward
- Assign texture tiling for appropriate particle size
- Wire up weatherController reference in Gameplay scene

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 16:50:17 -08:00
fbbee0e9ee Skip loading stale commands when pending command matches token (#5682)
When HandleAvailableCommands receives commands with a token that matches
a pending command, skip loading the commands entirely. The token is still
set (so TryPendingCommands can match and post the pending command), but
the commands are not loaded since they're stale - we already acted on them.

This is simpler than the flag-based approach:
- No SuppressNextUIUpdate flag
- Direct check in HandleAvailableCommands via callback
- If pending command fails, error handling refreshes state

Changes:
- Add HasPendingCommandWithToken callback to IClientConnectionSubscriber
- Add HasPendingEagleCommandWithToken helper to PersistentClientConnection
- Wire up callback in Subscribe method
- In HandleAvailableCommands, skip loading commands if token matches pending
- Preserve LastPostedToken across reconnects (don't clear in HandleStartingState)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:41:38 -08:00
ac0cc60fc2 Add low food warning notification with LLM-backed messaging (#5683)
When a province has less than 3 months of food remaining after
consumption, generate a notification where the ruling hero warns
about the food shortage in their personality/voice.

- Add LowFoodWarningDetails proto message for notification details
- Add LowFoodWarningMessage proto message for LLM request
- Create LowFoodWarningPromptGenerator for hero-voiced warnings
- Wire up notification generation in PerformFoodConsumptionPhaseAction
- Add C# notification generator for Unity client display

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:29:54 -08:00
75fed990ca Fix infinite reconnect loop when server rejects command with ERROR (#5681)
* Fix infinite reconnect loop when server rejects command with ERROR

When the server returned a command ERROR (e.g., invalid province selection),
the client would dispose the connection but NOT clear the pending command.
On reconnect, it would retry the same invalid command, get ERROR again,
and loop forever with increasing backoff.

The fix: handle ERROR the same way as BAD_TOKEN - remove the pending command
(it was processed/rejected, don't retry) and refresh the game subscription
to get current valid state. Don't disconnect since the connection is fine.

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

* Display command error to user via ErrorHandler

When server rejects a command with ERROR, invoke OnCommandError callback
which displays the error message via Debug.LogError (caught by ErrorHandler).

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:42:08 -08:00
38a07b6373 Reduce default lightning flash frequency by half (#5680)
Change default lightning interval from (1, 4) to (2, 8) seconds,
making flashes occur approximately half as often.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:41:44 -08:00
49c458bc5d Add debugging to iOS export step to diagnose certificate issue (#5679)
- List available keychains
- List available signing identities
- Show export options plist
- Remove -allowProvisioningUpdates (not needed for manual signing)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 13:27:39 -08:00
2e4d458759 Fix GameEnded notification crash when no victor (#5678)
The notification generator was accessing victor.Value unconditionally,
causing a NullReferenceException when the game ended without a victor.
This prevented the "Game Over" notification from being displayed.

Now safely handles the no-victor case and uses TryGetValue for the hero
lookup.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 13:25:24 -08:00
569194a68d Fix allHumansDefeated to check for imprisoned leaders (#5677)
The previous logic only checked if a faction leader was ruling a
province. If all leaders were traveling in armies (or in any other
non-ruling state), the game would incorrectly end.

Simpler fix: a faction can play if ANY leader is NOT imprisoned.
This covers all valid states (ruling, traveling, in province, etc.)
without enumerating them.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:54:41 -08:00
d39aa53a76 Reconnect immediately for expected stream timeouts (#5676)
For DeadlineExceeded (5 min gRPC timeout) and StreamEndedNormally,
reconnect immediately without backoff delay. These are expected events,
not failures - there's no reason to wait 2+ seconds before reconnecting.

This eliminates the "Retrying in..." status for normal stream refreshes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:52:32 -08:00
43d56ee5a4 Use full signing certificate name for iOS export (#5675)
Try using full certificate name "Apple Distribution: Daniel Crosby (UWJ88DX8WQ)"
instead of just "Apple Distribution" to see if that resolves the export signing issue.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:52:13 -08:00
17441c4cdb Add design doc for notification diff batching optimization (#5674)
Documents analysis of why client notification generators currently
require per-action-result game state diffs, and proposes a solution
to enable diff batching by moving display data to server-side.

Deferred for now as current performance is acceptable.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:20:47 -08:00
6bdb6e4aa9 Fix iOS signing: archive without signing, sign during export (#5673)
- Archive step now skips signing (CODE_SIGN_IDENTITY="-")
- This avoids "UnityFramework does not support provisioning profiles" error
- Export step handles all signing with proper certificate and profile
- Added signingCertificate to export options

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:09:47 -08:00
3b379e55bc Optimize ActionResultFilter with Set and ListBuffer (#5672)
- Change UNIVERSALLY_VISIBLE_TYPES from Vector to Set for O(1) contains
- Replace O(n²) Vector concatenation with O(n) ListBuffer in filterForPlayer

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:19:37 -08:00
e99df3b900 Set DEVELOPER_DIR to use Xcode.app for iOS archive (#5671)
Fixes "xcode-select: error: tool 'xcodebuild' requires Xcode" when
the build machine's active developer directory is set to Command
Line Tools instead of Xcode.app.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:00:30 -08:00
335500a145 Fix isGameValid returning false during game loading (#5670)
The unrequestedTextHandler was being called BEFORE the game was added to
gameControllerInfos. This caused isGameValid(eagleGameId) to return false
for the game being loaded, causing all its LLM requests to be skipped
as "game deleted".

The fix adds the game to gameControllerInfos BEFORE calling the text
handlers, then updates it again after text handling completes.

Also add eagleGameId (in decimal and hex) to LLM request logging.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 10:38:26 -08:00
f84bf524f1 Add logging to diagnose stuck LLM requests (#5669)
Logs each unrequested text being processed with its type, ID, and
requestedAfterHistoryCount. Also logs the result of each request
(submitted, bypassed, deferred, or waiting on dependency).

This helps diagnose why certain LLM requests are repeatedly triggering
disk loads for old game states.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 10:02:09 -08:00
e7a97990d6 Fix duplicate connection on session restore (#5668)
TryRestoreSessionAsync() fires OnLoginSuccess on success, which triggers
ConnectWithOAuth() via the OnOAuthLoginSuccess handler. The calling code
was also calling ConnectWithOAuth() directly when restore returned true,
causing _createConnection() to be called twice in quick succession.

This caused the first connection to be disposed while still connecting,
leading to ObjectDisposedException and slow reconnection recovery.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 09:52:10 -08:00
3907f59c60 Skip validation when replaying persisted action results (#5667)
* Skip validation when replaying persisted action results

Create separate appliers:
- validatingApplier: for new results (e.g., new game creation)
- replayApplier: for replaying persisted results from storage

Persisted results were already validated when first applied, so
re-validating them during replay is unnecessary overhead.

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

* Add logging to track when stateAfter loads from disk

Logs the requested count, persisted count, and how far back in history
the request is going. This helps diagnose performance issues with LLM
prompt generation that requires loading historical game states.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 09:49:09 -08:00
e72e79c96c Fix O(n²) performance in formAwrs by using ListBuffer (#5666)
The previous implementation used Vector with `:+` append in a foldLeft,
which is O(n) per append, giving O(n²) total time for n results.

Changed both formAwrs and formAwrsFromScala to use:
- ListBuffer for O(1) amortized append
- foldLeft with tuple to track (buffer, currentState)

This should significantly improve performance when replaying persisted
game history.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 09:31:21 -08:00
173dfbafd9 Use run-specific keychain names to fix parallel signing conflicts (#5665)
When multiple runners on the same machine try to sign simultaneously,
they were conflicting on the shared keychain names (build.keychain,
ios-build.keychain). This caused errSecInternalComponent errors.

Changes:
- mac_build.yml: Use build-${run_id}.keychain
- ios_testflight.yml: Use ios-build-${run_id}.keychain
- codesign_mac_app.sh: Support KEYCHAIN_NAME env var with fallback

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:25:09 -08:00
18dd663b4e Fix reconnect bug where client gets stuck showing YourTurn with no commands (#5663)
When a command POST fails due to disconnect, the client would get stuck
after reconnecting:

1. PostCommand() sets LastPostedToken before POST succeeds
2. POST fails due to DeadlineExceeded, command kept in pending queue
3. After reconnect, server sends update with same token
4. HandleAvailableCommands() skips update because token == LastPostedToken
5. Client stuck with no commands despite server saying YourTurn

Fix: Instead of skipping when token matches LastPostedToken and we have
no commands, accept the commands. The server will return BAD_TOKEN if
we try to re-post a successfully processed command. But if our POST
failed, we need these commands to proceed.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:14:03 -08:00
171d4fd333 Add Map Editor scene assets and missing meta file (#5662)
- Add Map Editor lighting data and reflection probe assets
- Add missing VassalRisesDetailsNotificationGenerator.cs.meta

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:12:27 -08:00
a9b8e27e2a Use runner-specific build directories for parallel CI builds (#5664)
Add EAGLE0_BUILD_DIR environment variable to workflows, defaulting to
/tmp/eagle0-${github.run_id}. This allows multiple runners with the
unity-mac label to run builds in parallel without path conflicts.

Changes:
- Workflows set EAGLE0_BUILD_DIR based on run ID
- Build scripts use EAGLE0_BUILD_DIR with fallback to /tmp/eagle0
- Library cache remains shared (beneficial for build speed)

To add a second parallel runner:
1. Set up a new runner with the same 'unity-mac' label
2. GitHub Actions will automatically distribute jobs to available runners

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 07:35:43 -08:00
3e00e01c1f Add actions:write permission to iOS TestFlight workflow (#5660)
The cleanup job needs this permission to delete intermediate
xcode-project artifacts. Without it, the gh api DELETE call
fails with HTTP 403.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 07:02:25 -08:00
412207c4d4 Fix iOS bundle ID and signing for TestFlight (#5659)
- Update bundle identifier to net.eagle0.eagle (matches provisioning profile)
- Set appleDeveloperTeamID in Unity project settings
- Add CODE_SIGN_IDENTITY="Apple Distribution" to use distribution cert

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 06:47:22 -08:00
f128f7ea3c Enable PleaseRecruitMe from prisoners (#5658)
* Enable PleaseRecruitMe from prisoners

Prisoners can now trigger PleaseRecruitMe, allowing them to request
to join the faction that holds them. The LLM prompt includes context
about their prisoner status, which faction captured them, and when
they were captured.

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

* Fix BUILD deps and use correct API for prisoner prompt

- Add missing BUILD dependencies for event_for_hero_backstory_trait and
  unaffiliated_hero
- Use TextGenerationSuccess instead of non-existent TextGenerationResult.pure
- Use GeneratorUtilities.dateString for date formatting

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 06:41:46 -08:00
06b5a15f0c Fix iOS archive script: use Unity-iPhone scheme, remove xcpretty (#5657)
- Always use "Unity-iPhone" scheme (Unity's default app scheme)
- Remove xcpretty dependency (not installed on build machine)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:11:57 -08:00
25c85e4524 Use existing APPLE_ID credentials for TestFlight upload (#5656)
Reuse the same credentials already used for Mac notarization instead
of requiring a separate App Store Connect API key.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:56:41 -08:00
e24dace474 Add iOS TestFlight workflow for manual builds (#5655)
- New workflow_dispatch-only workflow for iOS TestFlight builds
- build_unity_ios.sh: Builds Unity iOS player (generates Xcode project)
- archive_ios.sh: Archives and exports IPA using xcodebuild
- upload_testflight.sh: Uploads IPA to TestFlight via App Store Connect API

Required secrets:
- IOS_CERTIFICATE: Apple Distribution certificate (.p12, base64)
- IOS_CERTIFICATE_PWD: Certificate password
- IOS_PROVISIONING_PROFILE: App Store provisioning profile (base64)
- APP_STORE_CONNECT_API_KEY: JSON with key_id, issuer_id, and key

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:34:39 -08:00
eb558ee8b8 Improve rain particle randomization and wind scaling (#5654)
* Additional layout improvements

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

* Wire up ambient volume slider

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

* Improve rain particle randomization and wind scaling

- Add per-particle speed variation (0.7x to 1.3x) to break up "sheet" effect
- Scale wind offset proportionally to rain speed so angle stays consistent
- Add rainWindMultiplier setting to Inspector for easy tuning

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:32:49 -08:00
9616f31cfc Show health status inline in admin console nav bar (#5653)
Replace the Health link that navigated to a separate page with an
inline status indicator that auto-refreshes every 10 seconds using
htmx. This provides at-a-glance health visibility without requiring
navigation.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:52:24 -08:00
cf18884cc7 Additional layout improvements (#5652)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:51:23 -08:00
ce3ed26cc2 Consider alliances when vassals decide to mobilize (#5650)
* Consider alliances when vassals decide to mobilize

Change chosenMobilizeIfAdjacentEnemyCommand to use FactionUtils.hostileNeighbors
instead of ProvinceUtils.adjacentHostiles. The latter treats any province owned
by a different faction as hostile, causing vassals to mobilize (and withhold
supplies) even when bordered only by allied provinces.

Now vassals will correctly send supplies to their faction head when their only
neighbors are allies or have truces, rather than unnecessarily mobilizing.

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

* Delete misleading ProvinceUtils.adjacentHostiles method

This method was named "adjacentHostiles" but actually returned provinces
owned by any different faction, ignoring alliance/truce relationships.
It had only one production usage which was just fixed to use the correct
FactionUtils.hostileNeighbors method instead.

Deleting to avoid future confusion.

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

* Add tests for FactionUtils.hostileNeighbors

Test coverage for:
- Returns hostile (no relationship) neighbors
- Excludes allied provinces
- Excludes truce provinces
- Excludes unruled provinces
- Excludes own faction's provinces
- Returns empty for province with no neighbors
- Correctly filters mixed neighbor types

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:47:25 -08:00
b928c3779e Improve HandleCapturedHero layout (#5651)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:35:48 -08:00
6b90112568 Replace failure_horn.mp3 with licensed Negative Effect 04.wav (#5649)
Replace the last unlicensed sound effect with Negative Effect 04.wav
from the purchased Magic Spells Sound Effects LITE pack.

This completes the asset audit - all required items are now resolved.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:09:11 -08:00
d2cb5328cc Handle game loading failures gracefully (#5648)
Wrap ensureGameLoaded in try-catch to prevent one corrupted/broken game
from blocking all game loading for a user. On failure:
- Log loudly to stderr with CRITICAL prefix and stack trace
- Send exception to Sentry for monitoring
- Return false so other games can continue loading

This ensures players can still access their other games even if one
game's save data is corrupted or fails to deserialize.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:52:56 -08:00
f090efe04a Add fullscreen settings and F11 shortcut (#5647)
* Add fullscreen toggle, resolution dropdown, and F11 shortcut

- Add fullscreen toggle to settings panel
- Add resolution dropdown (same as connection canvas)
- F11 key toggles fullscreen from anywhere
- Resolution change preserves current fullscreen/windowed mode

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

* Wire up fullscreen toggle and resolution dropdown in Unity

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:52:24 -08:00
291026e044 Add ambient volume setting and improved thunder sounds (#5646)
* Add ambient volume setting for weather sounds

- Add ambientSlider to SettingsPanelController with static AmbientVolume property
- Weather effects (rain, thunderstorm, blizzard) respect ambient volume
- Volume updates in real-time when slider is adjusted during playback

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

* Add distant thunder sound effect

Source: Freesound #351526 by LittleRainySeasons (CC BY 4.0)

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

* Add more thunder sound effects

- thunder_loud.mp3 - mokasza (CC BY 4.0, Freesound #810746)
- thunder_crack.wav - OneSoundToRuleThemAll (CC BY 4.0, Freesound #238796)
- thunder_clap.wav - FreqMan (CC BY 4.0, Freesound #32544)

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

* Wire up thunder sounds and ambient volume slider in Unity

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:34:45 -08:00
173569f82a End game when all human players are defeated (#5645)
When all human factions are eliminated or have no provinces ruled by
faction leaders, the game now ends automatically instead of continuing
to run AI commands indefinitely.

Changes:
- Add endGame() method to Engine trait and EngineImpl to create a
  GameEnded action result
- Add allHumansDefeated() check in GameController.performAiCommands
- Check both before the AI loop (handles game load case) and after
  each batch of AI commands (handles AI destroying human factions)
- Handle edge case where game state has no factions yet (initialization)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:32:08 -08:00
a37d540411 Add resolution change detection to hex grid (#5644)
* Add resolution change detection to hex grid

Detects when the mapArea size changes (e.g., resolution change) and
rebuilds the grid with new metrics. Preserves terrain textures while
recreating all UI elements with correct sizes for the new resolution.

- Track mapArea size and detect changes in Update()
- RebuildGrid() destroys and recreates cells with new HexMetrics
- OnGridRebuilt event notifies ShardokGameController to refresh labels

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

* Update Unity scene and remove unused turnHistoryButtonText

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:16:42 -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
0216ceb26b Fix OAuth deep link redirect closing before permission dialog (#5611)
The auto-redirect with window.close() was closing the page before
users could accept the browser's permission dialog to open the app.

Changed to a button-based approach where the user clicks to return
to the app, giving them control over when the deep link triggers.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:41:30 -08:00
5b840a4523 Fix Overlay Mesh transform for Shardok map (#5607)
* Fix Overlay Mesh transform for Shardok map

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

* Fix Province Info Panel being disabled on dominion toggle

Changed DominionViewToggleClicked to hide only the panel content
instead of disabling the entire controller GameObject.

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

* Add iOS Addressables content state and image meta file

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

* Update Unity-generated files (csproj, fonts, addressables settings)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:33:38 -08:00
a51cad0ca0 Raise timing log thresholds from 10ms/50ms to 500ms (#5610)
Only log timing warnings when operations take more than 500ms,
reducing noise from normal operation while still catching slow
operations worth investigating.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:32:58 -08:00
04928f0e2c Register eagle0:// URL scheme on Windows for OAuth deep links (#5609)
* Register eagle0:// URL scheme on Windows for OAuth deep links

The Windows installer now registers the eagle0:// URL scheme in the
registry (HKEY_CURRENT_USER\Software\Classes\eagle0) pointing to the
game executable. This allows OAuth callbacks to redirect back to the
app automatically.

Also updated OAuthManager to check command line arguments for deep
links since Windows passes URL scheme launches as command line args.

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

* Add golang.org/x/sys dependency for Windows registry access

The Windows installer uses golang.org/x/sys/windows/registry to register
the eagle0:// URL scheme. This adds the required dependency to go.mod,
go.sum, and MODULE.bazel.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:32:39 -08:00
132abfa7a4 Add deep link redirect for OAuth callbacks (#5608)
After OAuth authentication completes, the browser now redirects to
eagle0://auth/complete to bring the app back to foreground. This
provides a better UX than asking users to manually close the browser.

- Unity client registers for deep link events and focuses window
- DeepLinkPostProcessor adds URL scheme to Info.plist on macOS/iOS
- All OAuth handlers (Google/Discord, Apple, Steam) redirect via deep link

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:10:58 -08:00
bbe301bc04 Unity layout adjustments (#5606)
* Unity layout adjustments

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

* Additional Unity layout adjustments

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:12:51 -08:00
138e7d89f3 Refactor Shardok map to use RectTransform for sizing (#5605)
- Change HexMetrics to accept RectTransform instead of Canvas
- Rename mapCanvas to mapArea in HexGrid (RectTransform type)
- Constrain map area to exclude sidebar for proper sizing at
  different aspect ratios

This allows the map to be properly sized based on the available
space, accounting for the sidebar at various screen resolutions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:53:36 -08:00
d858f8243d Add three-tier layout mode (Wide/Medium/Narrow) for command panels (#5603)
* Add three-tier layout mode (Wide/Medium/Narrow) for command panels

- Add LayoutMode enum: Wide (>2.0), Medium (>=1.35), Narrow (<1.35)
- EagleGameController exposes CurrentLayoutMode property
- CommandSelector base class receives layout mode from CommandPanelController
- Adds IsNarrowLayout helper for selectors to check narrow mode

In narrow mode (iPad-like 4:3 aspect ratios), selectors can hide
hero/battalion pickers since users can select via the Resident
Heroes and Battalions panels instead.

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

* Add heroesColumn and battalionsColumn fields to March/Defend selectors

These GameObject fields allow hiding the hero and battalion selection
columns in narrow layout mode. Wire them up in Unity editor to the
appropriate column GameObjects.

Also fixes DefendCommandSelector import: UnityEditor -> UnityEngine

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

* Hide hero/battalion columns in narrow layout mode

In SetUpUI(), hide heroesColumn and battalionsColumn when IsNarrowLayout
is true. Users can still select heroes and battalions via the Resident
Heroes and Battalions side panels.

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

* Wire up heroesColumn and battalionsColumn in Unity

Connect the column GameObjects to MarchCommandSelector and
DefendCommandSelector so they can be hidden in narrow layout mode.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:35:59 -08:00
5572a806af Update splash screen and app icon (#5604)
- Add new icon.png for app icon
- Remove old unused image asset
- Update ProjectSettings for splash screen

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:27:54 -08:00
1b153fbad6 Change default LLM provider to Gemini (#5602)
Gemini offers better cost/performance for standard LLM requests.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:04:21 -08:00
e53b899d7c Add turn mismatch detection with auto-reconnection (#5600)
* Add turn mismatch detection with auto-reconnection

Detects when server reports YourTurn but client has no available
commands. After a 3-second grace period (to avoid false positives
during normal update processing), logs an error via ErrorHandler
and triggers ForceReconnect() to recover state.

This addresses intermittent bugs where exceptions or token mismatches
leave the connection status showing "Your Turn" but the UI doesn't
display any turn actions.

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

* Read ServerGameStatus from GameUpdate level, add BattleInProgress display

- Move ServerGameStatus reading from ActionResultResponse to GameUpdate
  level so status updates work for all update types including Shardok
- Add "Battle in progress" display for the new BattleInProgress status

This fixes the turn mismatch false positives during Shardok battles -
the server now sends BattleInProgress instead of YourTurn during battles.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:57:49 -08:00
89c8fea438 Add BATTLE_IN_PROGRESS status and report during Shardok battles (#5601)
- Add new BATTLE_IN_PROGRESS status to ServerGameStatus enum
- Add server_game_status field to GameUpdate so any update type can
  include status (not just ActionResultResponse)
- Report BATTLE_IN_PROGRESS when there are outstanding Shardok battles
  instead of YOUR_TURN
- Set serverGameStatus on both GameUpdate and ActionResultResponse
  for backwards compatibility

This fixes the issue where "Your Turn" was shown during battles,
including observed battles where the player isn't participating.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:42:26 -08:00
7e19476167 Fix March command panel layout (#5599)
Adjust layout to prevent overflow on narrow aspect ratios.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:28:47 -08:00
54cd089106 Fix command panel layout for narrow aspect ratios (#5598)
Adjust layout to prevent overflow on iPad (4:3) and similar resolutions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 07:47:39 -08:00
a6a0d58d20 Refactor widescreen layout to use enable/disable instead of reparenting (#5597)
* Refactor widescreen layout to use enable/disable instead of reparenting

Instead of moving UI panels between containers at runtime, use duplicate
panels positioned in Unity and enable/disable the appropriate version
based on aspect ratio.

Changes:
- Add separate narrow/wide versions of FactionsTable, MovingArmiesTable,
  and DominionPanel
- ArrangeLayout() now just enables/disables panels instead of reparenting
- Remove unused container GameObjects (factionsAndMovingArmiesRow, etc.)
- Both panel copies are kept in sync with model data

This approach is simpler to maintain and allows layouts to be fully
configured in the Unity editor.

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

* Update Unity scene with layout adjustments

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 20:31:41 -08:00
c2f1064742 Trim map textures to remove excess water border (#5596)
* Trim map textures to remove excess water border

Crop all map textures (map_bw_labels.png and province textures 1-43.png)
from 4096x2064 to 3786x1834, removing:
- Left: 200px
- Top: 130px
- Right: 110px
- Bottom: 100px

This leaves a small water margin around the landmass edges.

Also update newMapPrefab.prefab to set AnchoredPosition and SizeDelta to 0
instead of previous offset values.

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

* Trim rawGray.bytes to match trimmed map textures

Apply same crop (left 200, top 130, right 110, bottom 100) to the
province hit-test data so pixel lookups remain aligned with the
trimmed map images.

Dimensions: 4096x2064 → 3786x1834

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

* Update texture import settings and RectTransforms for trimmed map

- Set Non-Power of 2 to "None" for map texture to preserve actual
  dimensions (3786x1834) instead of scaling to 4096x2048
- Update RectTransforms to remove offsets now that images are trimmed

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:01:09 -08:00
7173cf8faa Re-wire Shardok buttons after ShardokGameController move (#5595)
After moving ShardokGameController from ShardokCanvas to ShardokContainer,
several button onClick events lost their target references. Re-wire:
- EndTurn button (Back to Eagle / End Turn)
- DisplayActionsButtonClicked (9 action buttons)
- WarningPanelCommitClicked
- WarningPanelCancelClicked

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:50:22 -08:00
44dde46e39 Add missing Unity .meta files and update .gitignore (#5594)
Add .meta files for:
- BuildInfo.cs (build info feature)
- Editor/ folder and its scripts
- TouchAwareTooltip.cs

Also add ServerData/ to .gitignore to prevent local data from being tracked.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:09:20 -08:00
4c74265447 Move ShardokGameController from ShardokCanvas to ShardokContainer (#5593)
* Move ShardokGameController from ShardokCanvas to ShardokContainer

When exiting a battle, ShardokGameController calls this.gameObject.SetActive(false).
Previously this deactivated ShardokCanvas, leaving it inactive for the next battle.
Since ShardokCanvas is now inside ShardokContainer, GetComponentInChildren couldn't
find the controller because the canvas was inactive.

Fix: Move ShardokGameController to ShardokContainer so SetActive(false) deactivates
the whole container. Also update code to use GetComponent instead of GetComponentInChildren.

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

* Use serialized eagleCommonTextures field instead of GetComponent

After moving ShardokGameController to ShardokContainer, EagleCommonTextures
was also moved. Replace runtime GetComponent calls with a serialized field
reference for better performance and clearer dependencies.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:09:07 -08:00
30fd79f4ff Add null checks with specific messages to GoToBattle (#5592)
Adds explicit null checks to identify exactly which reference is null:
- Model
- shardokContainer
- shardokController (from GetComponentInChildren)

This replaces generic NullReferenceException with specific error messages.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:31:04 -08:00
53e1dd623b Add git commit hash to exception logs (#5591)
- Add BuildInfo.cs with commit hash, short hash, and build timestamp
- Add BuildInfoGenerator.cs to auto-generate BuildInfo before builds
- Update ErrorHandler to include commit hash in error messages

This helps identify which exact build a production error came from.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:04:00 -08:00
1604343975 Link ShardokContainer in SettingsPanelController (#5590)
The ShardokContainer reference was missing (fileID: 0), causing a
NullReferenceException when clicking "Return to Lobby" in settings.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:33:05 -08:00
aa7d07d7c6 Fix GoToBattle button not hidden when Model becomes null (#5589)
Root cause: SwapModel() returns early when Model is null (line 504),
but goToBattleButton visibility is updated after that early return
(line 626). When StopAll() sets Model to null via SwapModel(), the
button was never hidden.

Fix: Hide the goToBattleButton in the early return path when Model
becomes null.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:27:14 -08:00
0042d56386 Recreate connection when gRPC channel is disposed (#5588)
When the underlying GrpcChannel is disposed (e.g., during environment
switch), the PersistentClientConnection now invokes an OnChannelDead
callback instead of futilely attempting to reconnect on the dead channel.

ConnectionHandler sets this callback to recreate the EagleConnection
and PersistentClientConnection, properly recovering from channel disposal.

Fixes ObjectDisposedException spam when channel dies.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:49:40 -08:00
0bb61da38c Fix overlay mesh position offset (#5586)
Adjust the overlay mesh local position to align correctly with the
hex grid on widescreen displays.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:29:24 -08:00
5dcf3b9c17 Fix Eagle UI layout on widescreen monitors (#5585)
Add HorizontalLayoutGroup to Improve Panel to prevent overlapping
elements on ultrawide displays (e.g., 3440x1440). The improvement
type buttons were covering the lock toggle and hero dropdown when
the screen was wider than the reference resolution.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:03:08 -08:00
8814f2a955 Render overlay text above 3D bridges (#5584)
Create separate Overlay Canvas with smaller plane distance so text
labels render in front of 3D bridge objects.

Changes:
- Add ShardokContainer parent to hold both canvases
- Rename shardokCanvas references to shardokContainer
- Add overlayContent RectTransform field to HexGrid for label parenting
- Update all scripts to use shardokContainer for activation

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:26:54 -08:00
c0b2e41212 Narrow iOS addressables workflow trigger paths (#5583)
Only trigger on changes to:
- AddressableAssetsData config
- Music assets (the addressable content)
- BuildScript.cs
- Build scripts

Previously triggered on any Unity file change, which was too broad.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:54:10 -08:00
902a65be08 Prefer bridge orientations that connect two land tiles (#5582)
When placing default bridges, check for opposite pairs of land tiles
and prefer orientations where both endpoints touch land, rather than
just orienting toward the first land tile found.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:48:10 -08:00
ac66d372f8 Fix iOS addressables building for wrong platform (#5581)
The -buildTarget iOS flag doesn't reliably switch the active build
target before editor scripts run. The addressables were being built
for StandaloneOSX instead of iOS.

Added BuildiOSAddressables() method that explicitly calls
EditorUserBuildSettings.SwitchActiveBuildTarget() before building.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:38:45 -08:00
14308026ea Update ASSET_AUDIT.md: mark Terrain Hexes and Discord logo as verified (#5580)
* Update ASSET_AUDIT.md: mark Terrain Hexes and Discord logo as verified

- Terrain Hexes: confirmed Unity Asset Store purchase
- Discord logo: complies with Discord brand guidelines
- Updated action items and recommendations to reflect remaining work

Remaining items:
- 6 music tracks (1 unknown source, 5 Audius non-CC license)
- 56 Shardok sound effects (unknown origin)
- 138 StrategyGameIcons (unknown source)

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

* Update ASSET_AUDIT.md: Medieval Victory Theme is CC0 Public Domain

Found source: RandomMind on Chosic, CC0 Public Domain license.
No attribution required, free for commercial use.

Remaining items:
- 5 Dima Koltsov/Audius tracks (non-CC license)
- 56 Shardok sound effects (unknown origin)
- 138 StrategyGameIcons (unknown source)

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

* Update ASSET_AUDIT.md: Dima Koltsov tracks mostly verified as CC BY 4.0

- No Time for Greatness: CC BY 4.0 (YouTube)
- Warriors of Demacia: CC BY 4.0 (YouTube)
- Valor: CC BY 4.0 (YouTube)
- Forest Queen Tale: Presumed CC BY 4.0 (not found on YouTube)
- Clouds: Presumed CC BY 4.0 (not found on YouTube)

Remaining items:
- 56 Shardok sound effects (unknown origin)
- 138 StrategyGameIcons (unknown source)

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

* Update ASSET_AUDIT.md: StrategyGameIcons verified as Asset Store purchase

Confirmed as "Strategy Game Icons" by REXARD from Unity Asset Store.

Remaining items:
- 56 Shardok sound effects (unknown origin)

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

* Update ASSET_AUDIT.md: identify 3 sounds from Zombie Monster Undead Collection

Verified from Unity Asset Store purchase:
- raise_undead.mp3
- undead_break_control.mp3
- undead_grew.wav

Also corrected count: 37 audio files (not 56 - was counting .meta files)

34 sound effects still unverified.

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

* Update ASSET_AUDIT.md: flag anybody.mp3 and runaway.mp3 for replacement

These two sound effects have licensing issues and must be replaced.

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

* Update ASSET_AUDIT.md: also flag burnination.mp3 for replacement

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

* Update ASSET_AUDIT.md: mark remaining sound effects as presumed Asset Store

Owner believes 31 remaining sounds are from purchased Asset Store packs:
- Fantasy Interface Sounds
- Medieval Combat Sounds
- Magic Spells Sound Effects LITE
- Medieval Battle Sound Pack

Only 3 files need replacement: anybody.mp3, burnination.mp3, runaway.mp3

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

* Replace anybody.mp3 and burnination.mp3 with licensed alternatives

- anybody.mp3: replaced with Positive Effect 6.wav (Magic Spells Sound Effects LITE)
- burnination.mp3: replaced with Magic Element Fire 04.wav (Medieval Combat Sounds)

Still need replacements for:
- failure_horn.mp3
- runaway.mp3

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:44:53 -08:00
4b7a1791e0 Add bridge rotation based on builder position or adjacent land (#5577)
* Add bridge rotation based on builder position or adjacent land

Bridges now rotate to face meaningful directions:
- For bridges built by engineers: one end faces the builder's location
- For bridges present at battle start: one end faces adjacent land

Implementation:
- Add rotationDegrees parameter to HexGrid.SetCellModifier3D()
- Track bridge builder locations in _bridgeBuilderLocations dictionary
- Add GetHexNeighbors() to find adjacent hex cells
- Add CalculateBridgeRotation() to compute angle between cells
- Add CalculateBridgeRotationFromLand() to find land for initial bridges

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

* Simplify bridge rotation to 3-orientation lookup table

Replace complex trigonometry with simple lookup based on hex grid
direction. Only 3 possible orientations exist:
- Horizontal (0°): left ↔ right
- Diagonal up (124°): down-left ↔ up-right
- Diagonal down (56°): up-left ↔ down-right

Add BridgeRotationCalculator with unit tests for all 6 hex directions
from both even and odd rows.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:34:36 -08:00
a6a3e26853 Add iOS addressables build workflow (#5579)
* Add iOS addressables build workflow

Creates a separate workflow to build and upload iOS addressables to CDN.
This is needed for iOS clients to load remote assets.

- Adds build_ios_addressables.sh script
- Adds ios_addressables_build.yml workflow
- Uploads to https://assets.eagle0.net/addressables/iOS/

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

* Fix iOS addressables build: add proto build step

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:18:07 -08:00
dc1c9e8284 Update ASSET_AUDIT.md with new AI-generated bridge icon (#5578)
Document that bridge.png was replaced with AI-generated wooden rope
bridge icon created with ChatGPT/DALL-E 3.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:17:23 -08:00
21eb5ff79a Replace bridge icon with AI-generated wooden rope bridge (#5576)
New 512x512 PNG icon featuring an isometric wooden rope bridge,
better suited for the fantasy game aesthetic. Generated with
ChatGPT/DALL-E, has transparent background.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:32:34 -08:00
2cd0583bb0 Add 3D bridge prefab support for hex map terrain modifiers (#5575)
Replace 2D bridge texture overlay with 3D bridge prefabs for more
visually appealing bridges on water tiles. The system now supports
an array of bridge prefabs for variety, selected deterministically
based on cell index.

- Add TerrainModifier3D field to HexGrid Cell struct
- Add SetCellModifier3D() and ClearCellModifier3Ds() methods
- Change ShardokGameController.bridgeImage to bridgePrefabs array
- Bridge positioned at true cell center using Geometry.AnchoredPosition
- Assign 6 bridge prefabs from TileableBridgePack in Gameplay scene

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:09:33 -08:00
e45ad8fc14 Silence log messages for LLM responses to deleted warmup games (#5573)
During blue-green deployments, warmup games may be invalidated while
LLM requests are in flight. The responses are correctly ignored, but
the log messages were noisy. Remove them since this is expected behavior.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:23:00 -08:00
d41535ffc0 Add missing TMP shader include files to Shaders directory (#5572)
The mobile shaders in TextMesh Pro/Shaders/ reference these include
files with relative paths but they only exist in Resources/Shaders/.
Copy them here to fix shader compilation errors.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:17:06 -08:00
c0eff139bf Add eagle-logs helper for tailing active instance logs (#5571)
Usage:
  eagle-logs           # Tail logs (follow mode)
  eagle-logs -n 100    # Show last 100 lines and follow
  eagle-logs --no-follow -n 50   # Last 50 lines without following

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:00:16 -08:00
4338600896 Replace fire icon with UXWing flame and consolidate duplicates (#5570)
* Replace fire icon with UXWing flame and consolidate duplicates

Replaced the startFire.png icon with a clean flat-style flame from UXWing
(free for commercial use, no attribution required). Consolidated from two
duplicate copies to a single canonical copy in Assets/Images/, updating all
Unity scene references.

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

* Update ASSET_AUDIT.md with UXWing flame icon source

Document that startFire.png was replaced with the flame icon from UXWing
(https://uxwing.com/flame-icon/) which is free for commercial use with no
attribution required. Also note the consolidation from two duplicate copies
to one canonical copy.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:59:51 -08:00
1ac7477597 Fix eagle-exec deployment and simplify implementation (#5569)
- Remove sudo symlink creation (deploy user lacks passwordless sudo)
- Simplify eagle-exec to read from /opt/eagle0/.active-instance file
- Update deploy-blue-green.sh to write active instance to file
- Keep fallback to checking running containers if file doesn't exist

Users can add their own alias:
  alias eagle-exec='/opt/eagle0/scripts/eagle-exec.sh'

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:47:16 -08:00
23cad2f383 Add iOS/mobile support with touch-aware tooltips (#5550)
* Add iOS/mobile support with touch-aware tooltips

- Add melee button to Shardok action bar (button 4 with [SHFT] label)
  - Changed MeleeAttackGroup from -1 to 4 to show melee commands as button
  - Updated Gameplay.unity to assign melee/charge commands to group 4
- Add touch-aware tooltip system using long-press pattern
  - Updated HoveringTooltipTextProvider to detect touch vs mouse input
  - On desktop: tooltips appear immediately on hover
  - On mobile: tooltips appear after 0.4s long-press
  - Created TouchAwareTooltip.cs as reusable component
- Add iOS build method to BuildScript.cs
  - Generates Xcode project for building/signing

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

* Add dedicated melee button (button9) for mobile/tablet support

- Add button9 and buttonText9 fields to ShardokGameController
- Change MeleeAttackGroup from 4 to 9 for dedicated button
- Change EndTurnCommandGroup from 9 to 12 (handled separately)
- Update melee/charge commands in Gameplay.unity to group 9
- Update End Turn command to group 12
- Add null checks for unassigned buttons in UpdateActionButtons

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

* Fix melee/charge command groups in Gameplay.unity

Set commandGroup to 9 for melee and charge commands so they
appear on the dedicated melee button and indicators work correctly.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:45:29 -08:00
463 changed files with 30077 additions and 9177 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
+4 -2
View File
@@ -27,10 +27,12 @@ jobs:
echo "::error::Artifact storage is ${total_mb} MB, which exceeds the 500 MB threshold!"
echo ""
echo "Largest artifacts:"
# Save to temp file to avoid SIGPIPE/broken pipe errors with head
gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' | \
sort -rn | head -20 | \
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
sort -rn /tmp/artifacts.txt | head -20 | \
awk -F'\t' '{printf "%d MB\t%s\t%s\n", $1/1024/1024, $2, $3}'
rm -f /tmp/artifacts.txt
exit 1
fi
+36
View File
@@ -0,0 +1,36 @@
name: Bazel Cache Cleanup
on:
schedule:
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual trigger
jobs:
cleanup:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Show disk usage before cleanup
run: |
echo "=== Disk usage before cleanup ==="
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
echo "Bazel user root: $BAZEL_USER_ROOT"
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
df -h .
- name: Run bazel clean
run: |
echo "=== Running bazel clean ==="
bazel clean
echo "Clean complete"
- name: Show disk usage after cleanup
run: |
echo "=== Disk usage after cleanup ==="
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
df -h .
+1 -3
View File
@@ -217,7 +217,7 @@ jobs:
# Copy files
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh scripts/eagle-logs.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
- name: Deploy to production droplet
@@ -374,8 +374,6 @@ jobs:
# Note: Auth is deployed separately via auth_build.yml - do NOT touch auth here
chmod +x /opt/eagle0/scripts/*.sh
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
# Create eagle-exec alias (symlink in /usr/local/bin)
sudo ln -sf /opt/eagle0/scripts/eagle-exec.sh /usr/local/bin/eagle-exec
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
@@ -0,0 +1,76 @@
name: iOS Addressables Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/ios_addressables_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/AddressableAssetsData/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Music/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/BuildScript.cs"
- "ci/github_actions/build_ios_addressables.sh"
- "ci/github_actions/upload_addressables.sh"
pull_request:
paths:
- ".github/workflows/ios_addressables_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/AddressableAssetsData/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Music/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/BuildScript.cs"
- "ci/github_actions/build_ios_addressables.sh"
- "ci/github_actions/upload_addressables.sh"
workflow_dispatch:
permissions:
contents: read
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
jobs:
build-ios-addressables:
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
lfs: true
clean: true
- name: Pull LFS files
run: git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Addressables
run: ./ci/github_actions/build_ios_addressables.sh "$EAGLE0_BUILD_DIR/editor_ios_addressables.log"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_ios_addressables.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios_addressables.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
+213
View File
@@ -0,0 +1,213 @@
name: iOS TestFlight
on:
workflow_dispatch:
inputs:
skip_upload:
description: 'Skip TestFlight upload (build and archive only)'
required: false
default: 'false'
type: boolean
permissions:
contents: read
actions: write
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
# Runner-specific keychain to avoid conflicts when multiple runners sign simultaneously
KEYCHAIN_NAME: ios-build-${{ github.run_id }}.keychain
jobs:
build-unity:
runs-on: [self-hosted, macOS, unity-mac]
outputs:
xcode_project_path: ${{ steps.build.outputs.xcode_project_path }}
steps:
- uses: actions/checkout@v4
with:
lfs: true
clean: true
fetch-depth: 0
- name: Pull LFS files
run: git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Unity Project
id: build
run: |
./ci/github_actions/build_unity_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS"
echo "xcode_project_path=$EAGLE0_BUILD_DIR/eagle0iOS" >> $GITHUB_OUTPUT
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success()
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Zip Xcode project for artifact
run: |
cd $EAGLE0_BUILD_DIR
# Use tar for speed - Xcode projects have many small files
tar -czf eagle0iOS.tar.gz eagle0iOS
- name: Upload Xcode project
uses: actions/upload-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0iOS.tar.gz
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_ios.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
archive-and-upload:
needs: build-unity
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
ci
scripts
- name: Clean download directory
run: rm -rf $EAGLE0_BUILD_DIR/eagle0iOS
- name: Download Xcode project
uses: actions/download-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}
- name: Extract Xcode project
run: |
cd $EAGLE0_BUILD_DIR
tar -xzf eagle0iOS.tar.gz
rm eagle0iOS.tar.gz
ls -la eagle0iOS/
- name: Install Signing Certificate
env:
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
IOS_CERTIFICATE_PWD: ${{ secrets.IOS_CERTIFICATE_PWD }}
run: |
# Generate random keychain password
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
# Decode certificate
echo "$IOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security default-keychain -s "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security set-keychain-settings -t 3600 -u "$KEYCHAIN_NAME"
# Import certificate
security import certificate.p12 -k "$KEYCHAIN_NAME" -P "$IOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Add keychain to search list
security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain
rm certificate.p12
- name: Install Provisioning Profile
env:
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
run: |
# Decode provisioning profile
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
# Extract UUID from provisioning profile
PROFILE_UUID=$(/usr/libexec/PlistBuddy -c "Print :UUID" /dev/stdin <<< $(security cms -D -i profile.mobileprovision))
echo "PROFILE_UUID=$PROFILE_UUID" >> $GITHUB_ENV
# Install to standard location
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$PROFILE_UUID.mobileprovision
rm profile.mobileprovision
echo "Installed provisioning profile: $PROFILE_UUID"
- name: Archive and Export IPA
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./ci/github_actions/archive_ios.sh
./ci/github_actions/archive_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS" "$EAGLE0_BUILD_DIR/archive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
- name: Upload to TestFlight
if: ${{ github.event.inputs.skip_upload != 'true' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
run: |
chmod +x ./ci/github_actions/upload_testflight.sh
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.ipa"
- name: Upload IPA artifact
# Only keep artifact if we skipped TestFlight upload (for debugging)
if: success() && github.event.inputs.skip_upload == 'true'
uses: actions/upload-artifact@v4
with:
name: eagle0-ios-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/archive/eagle0.ipa
retention-days: 1
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
cleanup:
needs: [build-unity, archive-and-upload]
if: always()
runs-on: ubuntu-latest
steps:
- name: Delete intermediate artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
artifact_name="xcode-project-${{ github.run_id }}"
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
+61 -26
View File
@@ -21,6 +21,8 @@ on:
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
pull_request:
paths:
@@ -40,6 +42,8 @@ on:
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
workflow_dispatch:
inputs:
@@ -53,6 +57,12 @@ permissions:
contents: read
actions: write # Required to delete artifacts after deploy
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
# Runner-specific keychain to avoid conflicts when multiple runners sign simultaneously
KEYCHAIN_NAME: build-${{ github.run_id }}.keychain
jobs:
build-and-sign:
runs-on: [self-hosted, macOS, unity-mac]
@@ -70,13 +80,16 @@ jobs:
- name: Pull LFS files
run: git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh mac
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/restore_library.sh
- name: Build Mac Unity
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
run: ./ci/github_actions/build_unity_mac.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC"
- name: Persist Library/
if: success()
@@ -97,7 +110,7 @@ jobs:
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
run: |
chmod +x ./scripts/inject_sparkle.sh
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
./scripts/inject_sparkle.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
- name: Check if should deploy
id: check-deploy
@@ -126,18 +139,31 @@ jobs:
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain build.keychain 2>/dev/null || true
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security default-keychain -s "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Import certificate
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
echo "=== Importing certificate ==="
security import certificate.p12 -k "$KEYCHAIN_NAME" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security
# Allow codesign to access keychain
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
echo "=== Setting key partition list ==="
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Add keychain to search list (required for codesign to find certificates)
echo "=== Adding keychain to search list ==="
security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain
# Debug: Check what's in the keychain after import
echo "=== Debug: Identities in build keychain ==="
KEYCHAIN_PATH="$HOME/Library/Keychains/$KEYCHAIN_NAME-db"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true
echo "=== Debug: All available identities ==="
security find-identity -v -p codesigning || true
# Clean up
rm certificate.p12
@@ -148,7 +174,7 @@ jobs:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
chmod +x ./scripts/codesign_mac_app.sh
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
./scripts/codesign_mac_app.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
- name: Submit for Notarization
id: notarize-submit
@@ -159,17 +185,17 @@ jobs:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_submit.sh
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
./scripts/notarize_submit.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
- name: Zip signed app for artifact
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
run: |
cd /tmp/eagle0/eagle0MAC
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload signed app
@@ -177,7 +203,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: signed-mac-app-${{ github.run_id }}
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Archive Build Log
@@ -185,8 +211,11 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
wait-notarization:
needs: build-and-sign
@@ -199,20 +228,20 @@ jobs:
sparse-checkout: scripts
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
with:
name: signed-mac-app-${{ github.run_id }}
path: /tmp/eagle0/eagle0MAC
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Unzip signed app
run: |
cd /tmp/eagle0/eagle0MAC
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
ls -la ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app/
- name: Wait for Notarization and Staple
env:
@@ -221,11 +250,11 @@ jobs:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_wait.sh
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
- name: Zip notarized app for artifact
run: |
cd /tmp/eagle0/eagle0MAC
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
rm -f eagle0.app.zip
ditto -c -k --keepParent eagle0.app eagle0.app.zip
@@ -233,8 +262,11 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: notarized-mac-app-${{ github.run_id }}
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
deploy:
needs: [build-and-sign, wait-notarization]
@@ -247,17 +279,17 @@ jobs:
fetch-depth: 0 # For version numbering
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
with:
name: notarized-mac-app-${{ github.run_id }}
path: /tmp/eagle0/eagle0MAC
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Unzip notarized app
run: |
cd /tmp/eagle0/eagle0MAC
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
@@ -279,12 +311,12 @@ jobs:
BACKGROUND_PATH="$(pwd)/ci/mac/dmg/background.png"
# Read version from the built app's Info.plist to ensure appcast matches the actual app
APP_PATH="/tmp/eagle0/eagle0MAC/eagle0.app"
APP_PATH="${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
BUILD_NUMBER=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP_PATH/Contents/Info.plist")
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist")
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
"/tmp/eagle0/eagle0MAC/eagle0.app" \
"${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" \
"$VERSION" \
"$BUILD_NUMBER" \
"$BACKGROUND_PATH" \
@@ -307,6 +339,9 @@ jobs:
fi
done
echo "Cleanup complete"
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
# Cleanup job runs regardless of success/failure to prevent artifact accumulation
cleanup:
+17 -4
View File
@@ -18,6 +18,8 @@ on:
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "WORKSPACE"
workflow_dispatch:
@@ -37,12 +39,18 @@ on:
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "WORKSPACE"
permissions:
contents: read
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
jobs:
windows-unity:
runs-on: [self-hosted, macOS, unity-windows]
@@ -54,12 +62,14 @@ jobs:
clean: true # Remove untracked files from previous builds
- name: Pull lfs files
run: git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh windows
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/restore_library.sh
- name: Build Windows unity
run: ./ci/github_actions/build_unity.sh "/tmp/eagle0/eagle0WIN"
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
- name: Persist Library/
if: success()
env:
@@ -76,7 +86,7 @@ jobs:
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN" "/tmp/unity_manifest.txt"
- name: Update unified manifest
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
@@ -106,5 +116,8 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: editor_win.log
path: /tmp/eagle0/editor_win.log
retention-days: 5
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
+1
View File
@@ -122,6 +122,7 @@ use_repo(
"com_github_webview_webview_go",
"org_golang_google_grpc",
"org_golang_google_protobuf",
"org_golang_x_sys",
)
#
+8
View File
@@ -317,6 +317,13 @@ pkg_tar(
package_dir = "/app",
)
# Package the attributions.json for the credits page
pkg_tar(
name = "auth_attributions_layer",
srcs = ["//src/main/resources/net/eagle0:attributions"],
package_dir = "/app",
)
oci_image(
name = "auth_server_image",
base = "@alpine_linux_linux_amd64",
@@ -328,6 +335,7 @@ oci_image(
tars = [
":busybox_layer",
":auth_binary_layer",
":auth_attributions_layer",
],
workdir = "/app",
)
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Archive and export iOS app for App Store / TestFlight
set -euxo pipefail
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
XCODE_PROJECT_PATH=${1:?Usage: archive_ios.sh <xcode_project_path> <output_path> <team_id> <profile_uuid>}
OUTPUT_PATH=${2:?Missing output path}
TEAM_ID=${3:?Missing team ID}
PROFILE_UUID=${4:?Missing provisioning profile UUID}
ARCHIVE_PATH="$OUTPUT_PATH/eagle0.xcarchive"
EXPORT_PATH="$OUTPUT_PATH"
echo "Archiving iOS app..."
echo " Xcode project: $XCODE_PROJECT_PATH"
echo " Archive path: $ARCHIVE_PATH"
echo " Team ID: $TEAM_ID"
mkdir -p "$OUTPUT_PATH"
# Find the .xcodeproj file
XCODEPROJ=$(find "$XCODE_PROJECT_PATH" -name "*.xcodeproj" -maxdepth 1 | head -1)
if [ -z "$XCODEPROJ" ]; then
echo "Error: No .xcodeproj found in $XCODE_PROJECT_PATH"
exit 1
fi
echo "Found Xcode project: $XCODEPROJ"
# Unity always generates "Unity-iPhone" as the main app scheme
SCHEME="Unity-iPhone"
echo "Using scheme: $SCHEME"
# Archive without signing - we'll sign during export
# This avoids issues with provisioning profiles on framework targets
xcodebuild archive \
-project "$XCODEPROJ" \
-scheme "$SCHEME" \
-archivePath "$ARCHIVE_PATH" \
-destination "generic/platform=iOS" \
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
echo "Archive complete: $ARCHIVE_PATH"
# Create export options plist
# Signing happens here, not during archive
EXPORT_OPTIONS_PLIST="$OUTPUT_PATH/ExportOptions.plist"
cat > "$EXPORT_OPTIONS_PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution: Daniel Crosby (UWJ88DX8WQ)</string>
<key>provisioningProfiles</key>
<dict>
<key>net.eagle0.eagle</key>
<string>$PROFILE_UUID</string>
</dict>
</dict>
</plist>
EOF
echo "Exporting IPA..."
# Debug: List available keychains and signing identities
echo "=== Debug: Available keychains ==="
security list-keychains -d user
echo "=== Debug: Available signing identities ==="
security find-identity -v -p codesigning
echo "=== Debug: Export options plist ==="
cat "$EXPORT_OPTIONS_PLIST"
echo "=== End debug ==="
# Export IPA (removed -allowProvisioningUpdates as we're using manual signing)
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST"
# Find and rename the IPA to a consistent name
IPA_FILE=$(find "$EXPORT_PATH" -name "*.ipa" | head -1)
if [ -n "$IPA_FILE" ] && [ "$IPA_FILE" != "$EXPORT_PATH/eagle0.ipa" ]; then
mv "$IPA_FILE" "$EXPORT_PATH/eagle0.ipa"
fi
echo "Export complete: $EXPORT_PATH/eagle0.ipa"
ls -la "$EXPORT_PATH"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Build iOS Addressables only (no player build)
# This switches Unity to iOS target and builds addressables for CDN upload
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
WORKSPACE=$(pwd)
echo "Building protos"
./scripts/build_protos.sh
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
LOG_PATH=${1:-"${BUILD_BASE}/editor_ios_addressables.log"}
echo "Building iOS Addressables"
mkdir -p "$(dirname "$LOG_PATH")"
# Build Addressables for iOS target
# Uses BuildiOSAddressables which explicitly switches build target
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-executeMethod BuildScript.BuildiOSAddressables \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
echo "iOS Addressables build complete"
echo "Bundles should be in: $WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0/ServerData/iOS/"
+2 -1
View File
@@ -2,7 +2,8 @@
set -euxo pipefail
. ./ci/unity_version.sh
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
+3 -2
View File
@@ -2,7 +2,8 @@
set -euxo pipefail
LOG_PATH=""
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
@@ -12,7 +13,7 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
git log -3
/bin/echo "build Windows"
LOG_PATH="/tmp/eagle0/editor_win.log"
LOG_PATH="${BUILD_BASE}/editor_win.log"
BUILD_DIR=$1
./ci/github_actions/build_windows.sh $BUILD_DIR $LOG_PATH
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Build iOS Unity player (generates Xcode project)
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
WORKSPACE=$(pwd)
BUILD_PATH=${1:-"${BUILD_BASE}/eagle0iOS"}
LOG_PATH="${BUILD_BASE}/editor_ios.log"
# Generate unique build number from git commit count
# This ensures each build has a unique number for TestFlight
BUILD_NUMBER=$(git rev-list --count HEAD)
echo "Build number (git commit count): $BUILD_NUMBER"
echo "Building protos"
./scripts/build_protos.sh
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
echo "Building iOS Unity player to: $BUILD_PATH"
mkdir -p "$(dirname "$LOG_PATH")"
mkdir -p "$BUILD_PATH"
# Build iOS player - this generates an Xcode project
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-executeMethod BuildScript.BuildiOSPlayer \
-buildPath "$BUILD_PATH" \
-buildNumber "$BUILD_NUMBER" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
echo "iOS Unity build complete"
echo "Xcode project generated at: $BUILD_PATH"
ls -la "$BUILD_PATH"
+4 -1
View File
@@ -2,6 +2,9 @@
set -euxo pipefail
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
@@ -13,7 +16,7 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
git log -3
/bin/echo "build Mac"
LOG_PATH="/tmp/eagle0/editor_mac.log"
LOG_PATH="${BUILD_BASE}/editor_mac.log"
BUILD_DIR=$1
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
+2 -1
View File
@@ -2,7 +2,8 @@
set -euxo pipefail
. ./ci/unity_version.sh
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
WORKSPACE=`pwd`
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env bash
# Ensures the required Unity version is installed via Unity Hub.
# Usage: ./ensure_unity_installed.sh [PLATFORM]
#
# PLATFORM can be: mac, windows, ios, or all (default: all)
#
# This script:
# 1. Reads the required version from ProjectSettings/ProjectVersion.txt
# 2. Checks if it's already installed
# 3. If not, attempts to install it via Unity Hub CLI with appropriate modules
#
# Note: Unity Hub CLI installation may require:
# - Unity Hub to be installed
# - User to be logged in to Unity Hub (for some versions)
# - Appropriate licenses
set -euo pipefail
# Read Unity version directly from the project file (maintained by Unity itself)
PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
UNITY_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
UNITY_HUB_CLI="/Applications/Unity Hub.app/Contents/MacOS/Unity Hub"
LOCK_FILE="/tmp/unity_install.lock"
LOCK_TIMEOUT=1800 # 30 minutes max wait for another installation
PLATFORM="${1:-all}"
echo "Required Unity version: ${UNITY_VERSION}"
echo "Platform: ${PLATFORM}"
# Check if already installed
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✓ Unity ${UNITY_VERSION} is already installed at ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
exit 0
fi
echo "✗ Unity ${UNITY_VERSION} not found at ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
# Check if Unity Hub CLI is available
if [ ! -f "${UNITY_HUB_CLI}" ]; then
echo ""
echo "Unity Hub CLI not found at ${UNITY_HUB_CLI}"
echo ""
echo "To install Unity ${UNITY_VERSION} manually:"
echo " 1. Open Unity Hub"
echo " 2. Go to Installs -> Install Editor"
echo " 3. Select version ${UNITY_VERSION}"
echo " 4. Add modules based on platform: mac-il2cpp, windows-mono, ios"
exit 1
fi
# Acquire lock to prevent concurrent installations
acquire_lock() {
local waited=0
while ! mkdir "${LOCK_FILE}" 2>/dev/null; do
if [ $waited -ge $LOCK_TIMEOUT ]; then
echo "ERROR: Timed out waiting for Unity installation lock after ${LOCK_TIMEOUT}s"
echo "Another installation may be stuck. Remove ${LOCK_FILE} manually if needed."
exit 1
fi
echo "Another Unity installation is in progress, waiting... (${waited}s)"
sleep 10
waited=$((waited + 10))
done
# Store PID for debugging
echo $$ > "${LOCK_FILE}/pid"
trap release_lock EXIT
}
release_lock() {
rm -rf "${LOCK_FILE}" 2>/dev/null || true
}
# Re-check after acquiring lock (another process may have installed it)
acquire_lock
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✓ Unity ${UNITY_VERSION} was installed while waiting for lock"
exit 0
fi
echo ""
echo "Attempting to install Unity ${UNITY_VERSION} via Unity Hub CLI..."
echo ""
# Build the install command with platform-specific modules
# Unity Hub CLI syntax: Unity\ Hub -- --headless install --version VERSION [--module MODULE]...
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install --version "${UNITY_VERSION}")
case "${PLATFORM}" in
mac)
INSTALL_CMD+=(--module mac-il2cpp)
;;
windows)
INSTALL_CMD+=(--module windows-mono)
;;
ios)
INSTALL_CMD+=(--module ios)
;;
all)
INSTALL_CMD+=(--module mac-il2cpp)
INSTALL_CMD+=(--module windows-mono)
INSTALL_CMD+=(--module ios)
;;
*)
echo "Unknown platform: ${PLATFORM}"
echo "Valid platforms: mac, windows, ios, all"
exit 1
;;
esac
echo "Running: ${INSTALL_CMD[*]}"
echo ""
if "${INSTALL_CMD[@]}"; then
echo ""
echo "Unity Hub CLI command completed"
else
EXIT_CODE=$?
echo ""
echo "Unity Hub CLI command failed (exit code: ${EXIT_CODE})"
echo ""
echo "This may happen if:"
echo " - The Unity version is not available for download"
echo " - You need to log in to Unity Hub first"
echo " - Unity Hub requires a GUI interaction"
echo ""
echo "To install manually:"
echo " 1. Open Unity Hub"
echo " 2. Go to Installs -> Install Editor"
echo " 3. Select version ${UNITY_VERSION}"
echo " 4. Add modules: ${PLATFORM}"
exit 1
fi
# Verify installation
echo ""
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✓ Verified: Unity ${UNITY_VERSION} is now installed"
exit 0
else
echo "✗ Unity ${UNITY_VERSION} installation could not be verified"
echo " Expected path: ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
echo ""
echo "The installation may still be in progress, or may require manual intervention."
exit 1
fi
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Upload IPA to TestFlight using Apple ID credentials (same as Mac notarization)
set -euxo pipefail
IPA_PATH=${1:?Usage: upload_testflight.sh <ipa_path>}
if [ ! -f "$IPA_PATH" ]; then
echo "Error: IPA file not found: $IPA_PATH"
exit 1
fi
echo "Uploading to TestFlight: $IPA_PATH"
# Uses same credentials as Mac notarization:
# - APPLE_ID: Your Apple ID email
# - APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
if [ -z "${APPLE_ID:-}" ]; then
echo "Error: APPLE_ID environment variable not set"
exit 1
fi
if [ -z "${APP_SPECIFIC_PASSWORD:-}" ]; then
echo "Error: APP_SPECIFIC_PASSWORD environment variable not set"
exit 1
fi
echo "Uploading with xcrun altool..."
# Capture output to check for errors (altool may return 0 even on failure)
OUTPUT=$(xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--username "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" 2>&1) || true
echo "$OUTPUT"
# Check for error indicators in output
if echo "$OUTPUT" | grep -q "ERROR:"; then
echo "ERROR: Upload failed. See error messages above."
exit 1
fi
echo "Upload complete! Check App Store Connect for processing status."
echo "The build should appear in TestFlight within 15-30 minutes after processing."
-1
View File
@@ -1 +0,0 @@
UNITY_VERSION='6000.3.0f1'
-471
View File
@@ -1,471 +0,0 @@
# Admin Server Enhancement Plan
## Overview
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
### Current State
The admin server provides a full web UI with htmx interactivity:
- `GET /` - Redirect to games list
- `GET /games` - Game list page (HTML)
- `GET /games/{id}` - Game detail with action history
- `GET /games/{id}/history` - History rows (htmx partial, infinite scroll)
- `GET /games/{id}/action/{index}` - Action detail (htmx partial)
- `POST /games/{id}/rewind` - Rewind game to target action
- `GET /settings` - Settings list with live search
- `POST /settings/update` - Update setting value
- `GET /health` - Health check (JSON)
- `GET /api/games` - JSON API for programmatic access
- `GET /api/games/{id}/history` - JSON API for history
### Goals
1. **Web UI**: Replace raw JSON with an interactive HTML interface
2. **Settings Management**: View and modify the 275+ game settings at runtime
3. **Game Rewind**: Restore a game to a previous action count
---
## Architecture
### Technology Choice: Go Templates + htmx
**Rationale:**
- Single binary deployment (no separate frontend build)
- htmx provides interactivity without JavaScript framework complexity
- Familiar HTML/CSS, minimal learning curve
- Excellent for admin tools where SEO and bundle size don't matter
**Alternatives Considered:**
- React/Vue SPA: Adds build complexity, separate deployment artifact
- Server-side only: Less interactive, full page reloads
### Directory Structure
```
src/main/go/net/eagle0/admin_server/
├── admin_server.go # Main entry point, HTTP routes
├── handlers/
│ ├── games.go # Game list and detail handlers
│ ├── settings.go # Settings list and update handlers
│ └── rewind.go # Game rewind handlers
├── templates/
│ ├── layout.html # Base layout with nav, htmx includes
│ ├── games/
│ │ ├── list.html # Game list page
│ │ ├── detail.html # Single game view with history
│ │ └── history.html # Partial for history table (htmx)
│ ├── settings/
│ │ ├── list.html # Settings list with search/filter
│ │ └── edit.html # Inline edit partial (htmx)
│ └── rewind/
│ └── confirm.html # Rewind confirmation modal
├── static/
│ ├── style.css # Minimal CSS (Pico CSS or similar)
│ └── htmx.min.js # htmx library
└── BUILD.bazel
```
---
## Feature 1: Web UI
### Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/` | GET | Redirect to `/games` |
| `/games` | GET | Game list page (HTML) |
| `/games/{id}` | GET | Game detail page with history |
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
| `/api/games/{id}/history` | GET | JSON API (existing) |
### Game List Page
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Settings] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Running Games (3) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game abc123f Round 45 │ │
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
│ │ Actions: 1,234 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game def456a Round 12 │ │
│ │ Players: Test Player (Human) │ │
│ │ Actions: 456 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Game Detail Page
Shows game info and scrollable action history:
- **Reverse chronological order**: Most recent actions displayed first
- Each action shows: index, type, round ID
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
- "Rewind to here" button on each action row
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
### Implementation Notes
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
---
## Feature 2: Settings Management
### New gRPC Endpoints (Eagle Server)
Add to `eagle.proto`:
```protobuf
message Setting {
string name = 1;
string type = 2; // "Int" or "Double"
string value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
string description = 5; // Optional, for UI hints
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message UpdateSettingRequest {
string name = 1;
string value = 2;
}
message UpdateSettingResponse {
Setting setting = 1; // Updated setting
string error = 2; // Empty on success
}
service Eagle {
// ... existing methods ...
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
}
```
### Eagle Server Implementation
Create a settings registry that:
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
2. Provides get/set by name
3. Validates types on update
```scala
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
object SettingsRegistry {
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
"ActionVigorCost" -> Left(ActionVigorCost),
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
// ... register all 275 settings
)
def getAll(filter: Option[String]): Seq[Setting] = ...
def get(name: String): Option[Setting] = ...
def update(name: String, value: String): Either[String, Setting] = ...
}
```
**Alternative: Code generation**
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
### Admin Server Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/settings` | GET | Settings list page with search |
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
| `/settings/{name}` | PUT | Update setting value |
| `/api/settings` | GET | JSON API |
| `/api/settings/{name}` | PUT | JSON API |
### Settings UI
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Games] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Settings [Search: __________ ] │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ActionVigorCost (Int) │ │
│ │ Current: [15 ] Default: 15 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ BaseFoodBuyPrice (Double) │ │
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ... (275 settings, virtualized/paginated) ... │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Considerations
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
4. **Audit log**: Log setting changes with timestamp for debugging
---
## Feature 3: Game Rewind
### Concept
Restore a game to a previous point in its action history. This is useful for:
- Debugging issues that occurred at a specific point
- Testing "what if" scenarios
- Recovering from bugs that corrupted state
### New gRPC Endpoint
Add to `eagle.proto`:
```protobuf
message RewindGameRequest {
int64 game_id = 1;
int32 target_action_count = 2; // Rewind to state after this many actions
}
message RewindGameResponse {
bool success = 1;
string error = 2;
int32 new_action_count = 3;
int32 disconnected_clients = 4; // Number of clients that were disconnected
}
service Eagle {
// ... existing methods ...
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
}
```
### Eagle Server Implementation
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
2. **Get target state**: Retrieve `GameState` at target action count from history
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
5. **Reset AI state**: Clear any cached AI state that depends on current game state
```scala
// GameController.scala (pseudocode)
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
if (targetActionCount < 0 || targetActionCount > engine.history.count)
return Left(s"Invalid action count: $targetActionCount")
// Get state at target point
val targetState = engine.history.stateAt(targetActionCount)
val truncatedHistory = engine.history.truncateTo(targetActionCount)
// Disconnect all human clients
val disconnectedCount = humanClients.length
humanClients.foreach(_.disconnect("Game rewound by admin"))
// Create new engine at target state
val newEngine = EngineImpl(
gameId = engine.gameId,
currentState = targetState,
history = truncatedHistory,
// ... other fields
)
// Replace controller's engine
this.engine = newEngine
Right(RewindResult(targetActionCount, disconnectedCount))
}
```
### GameHistory Enhancement
Add method to get state at a specific action count:
```scala
trait GameHistory {
// ... existing methods ...
def stateAt(actionCount: Int): GameState = {
if (actionCount == 0) initialState
else all(actionCount - 1).resultingState
}
def truncateTo(actionCount: Int): GameHistory = {
GameHistoryImpl(
initialState = initialState,
actions = all.take(actionCount)
)
}
}
```
### Admin Server Route
| Route | Method | Description |
|-------|--------|-------------|
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
### Rewind UI Flow
1. User views game history
2. User clicks "Rewind to here" on an action row
3. Confirmation modal appears via htmx:
```
┌─────────────────────────────────────────┐
│ Rewind Game abc123f? │
│ │
│ This will: │
│ • Restore to action 456 (Round 23) │
│ • Discard 778 subsequent actions │
│ • Disconnect 2 connected players │
│ │
│ This cannot be undone. │
│ │
│ [Cancel] [Rewind] │
└─────────────────────────────────────────┘
```
4. On confirm, POST to `/games/{id}/rewind`
5. Success: redirect to game detail showing new state
6. Error: show error message
### Safety Considerations
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
2. **Client disconnect**: All connected clients are forcibly disconnected.
3. **AI state**: Ensure AI clients restart cleanly after rewind.
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
5. **Authorization**: In production, require admin authentication.
---
## Implementation Phases
### Phase 1: Web UI Foundation
**Status: Complete**
1. ✅ Set up Go templates with `embed`
2. ✅ Add Pico CSS and htmx
3. ✅ Create base layout with navigation
4. ✅ Convert `/games` to HTML with styling
5. ✅ Add game detail page with history table
6. ✅ Implement htmx infinite scroll for history
7. ✅ Reverse history order (most recent first)
8. ✅ Clickable action rows that expand to show JSON representation
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
**Deliverable**: Browsable game list and history in HTML with clickable action details
### Phase 2: Settings Management
**Status: Complete**
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
3. ✅ Implement `getSettings` in `EagleServiceImpl`
4. ✅ Create settings list page with live search
5. ✅ Add inline editing with htmx
6. ✅ Modified settings are highlighted
**Deliverable**: View and edit settings via admin UI
### Phase 3: Game Rewind
**Status: Complete**
1. ✅ Add `RewindGame` to `eagle.proto`
2. ✅ Implement `stateAt` and `truncateTo` in `GameHistory`
3. ✅ Implement rewind logic in `Engine` and `GameController`
4. ✅ Add rewind confirmation (htmx `hx-confirm` dialog)
5. ✅ Handle client disconnection gracefully
6. ✅ Add rewind button to history rows
7. ✅ Implement `rewindGame` in `GamesManager` and `EagleServiceImpl`
8. ✅ Add admin server `/games/{id}/rewind` POST handler
9. ✅ Add success/error feedback UI
**Deliverable**: Rewind games to any previous action
### Phase 4: Polish
**Status: Not Started**
#### High Priority
1. **Add tests for rewind functionality**
- `PersistedHistory.truncateTo` (handles complex persisted vs recent logic)
- `InMemoryHistory.truncateTo`
- `EngineImpl.rewindTo`
- `GameController.rewindTo`
- `GamesManager.rewindGame`
2. **Improve action history display**
- Human-readable action type names (e.g., "New Round" instead of "NewRoundAction")
- Show acting faction/province when available
- Action summaries from the `summary` field in `GameHistoryEntry`
#### Medium Priority
3. **Settings improvements**
- Group settings by category prefix (AI*, Combat*, Economy*, etc.)
- Show setting descriptions where available
- Pagination for large settings lists
4. **Error handling improvements**
- Better error messages on failed operations
- Retry logic for transient gRPC failures
#### Low Priority (Nice to Have)
5. **Basic auth** - HTTP Basic Auth or OAuth for production use
6. **Audit logging** - Log admin actions with timestamps
7. **Documentation** - Usage guide, deployment notes
#### Future Considerations
- Game creation from admin UI
- Player management (view connected players, force disconnect)
- Export game history to file
- Metrics/stats dashboard
---
## Security Notes
The admin server is intended for local/trusted network use only. For production:
1. **Do not expose to public internet** without authentication
2. Consider adding HTTP Basic Auth or OAuth
3. Run on internal network or behind VPN
4. Log all admin actions for audit trail
---
## Open Questions
1. **Settings persistence**: Should we add optional persistence to disk/database?
2. **Game snapshots**: Should rewind create a backup first?
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
+107 -43
View File
@@ -115,14 +115,35 @@ All 26 tracks have CC licenses with proper attribution:
|-------|--------|---------|--------|
| Market Day | RandomMind | Free without attribution | [Chosic](https://www.chosic.com/download-audio/27016/) |
| Shopping List | Komiku | Free without attribution | [Chosic](https://www.chosic.com/download-audio/24714/) |
| Medieval: Victory Theme | Unknown | **NEEDS VERIFICATION** | Unknown |
| No Time for Greatness | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
| Warriors of Demacia | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
| Forest Queen Tale | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
| Valor | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
| Clouds | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
| Medieval: Victory Theme | RandomMind | CC0 Public Domain | [Chosic](https://www.chosic.com/download-audio/28492/) |
| No Time for Greatness | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=cQh0OWIFdgM) |
| Warriors of Demacia | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=yktSUMJn9ao) |
| Forest Queen Tale | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
| Valor | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=uoHYJRPcS2Y) |
| Clouds | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
**Note on Dima Koltsov tracks:** Audius uses an "Open Music License" by default, which is not the same as Creative Commons. These should eventually be replaced with CC-licensed alternatives.
**Note on Dima Koltsov tracks:** 3 of 5 tracks confirmed CC BY 4.0 via YouTube. 2 remaining tracks (Forest Queen Tale, Clouds) presumed same license but not verified.
---
## 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/) |
| `thunder_distant.mp3` | Distant thunder rumble | LittleRainySeasons | CC BY 4.0 | [Freesound #351526](https://freesound.org/people/LittleRainySeasons/sounds/351526/) |
| `thunder_loud.mp3` | Loud thunder clap | mokasza | CC BY 4.0 | [Freesound #810746](https://freesound.org/people/mokasza/sounds/810746/) |
| `thunder_crack.wav` | Thunder crack | OneSoundToRuleThemAll | CC BY 4.0 | [Freesound #238796](https://freesound.org/people/OneSoundToRuleThemAll/sounds/238796/) |
| `thunder_clap.wav` | Thunder clap | FreqMan | CC BY 4.0 | [Freesound #32544](https://freesound.org/people/FreqMan/sounds/32544/) |
**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/) |
---
@@ -138,32 +159,61 @@ All 26 tracks have CC licenses with proper attribution:
## 4. Potentially Problematic Assets (Review Needed)
### Clip Art (Unknown License)
| File | Concern |
|------|---------|
| `Assets/Shardok/commandImages/bridge.png` | Clip art style wooden bridge, unknown source - **needs replacement** |
| `Assets/Images/startFire.png` | Icon, unknown source - **needs verification or replacement** |
### ~~Clip Art (Unknown License)~~ RESOLVED
| File | Status |
|------|--------|
| ~~`Assets/Shardok/commandImages/bridge.png`~~ | **REPLACED** (2026-01-23) with AI-generated wooden rope bridge icon (ChatGPT/DALL-E 3, 512x512 PNG). No licensing restrictions - AI-generated for this project. |
| ~~`Assets/Images/startFire.png`~~ | **REPLACED** (2026-01-23) with "Flame Icon" from [UXWing](https://uxwing.com/flame-icon/) (free for commercial use, no attribution required). Consolidated duplicate removed. |
| ~~`Assets/Shardok/commandImages/startFire.png`~~ | **DELETED** (2026-01-23) - duplicate removed, all references updated to use `Assets/Images/startFire.png` |
### Shardok Sound Effects
- **Location:** `Assets/Shardok/soundEffects/`
- **Count:** 56 MP3 files
- **Count:** 37 audio files (was incorrectly counted as 56 including .meta files)
- **Contents:** Spell effects, movement, combat sounds
- **Status:** Unknown origin - may be custom or need verification
### Free Icons
- **Location:** `Assets/free_icons/`
- **Count:** 8 PNG weather icons
- **Status:** Verify "free" means commercially usable
**Verified from [Zombie Monster - Undead Collection](https://assetstore.unity.com/packages/audio/sound-fx/creatures/zombie-monster-undead-collection-70662) (Unity Asset Store):**
- `raise_undead.mp3`
- `undead_break_control.mp3`
- `undead_grew.wav`
### Terrain Hexes
**⚠️ 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`~~ - **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.
- `archery.mp3`, `battle_shout.mp3`, `boo.mp3`, `braved_water.mp3`
- `build_bridge.mp3`, `build_bridge_failure.mp3`, `charge.mp3`
- `dismiss_unit.mp3`, `duel_challenged.mp3`, `failure_horn.mp3`, `fear.mp3`, `fear_failed.mp3`
- `fire_extinguish.mp3`, `fire_spread.mp3`, `fire_start.mp3`, `fire_start_failure.mp3`
- `freeze.mp3`, `holy_wave.mp3`, `holy_wave_damage.mp3`, `jail_door.mp3`, `lightning.mp3`
- `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~~ 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/`
- **Count:** 85 PNG files
- **Status:** Unknown source - verify licensing
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
### StrategyGameIcons
### ~~StrategyGameIcons~~ VERIFIED
- **Location:** `Assets/StrategyGameIcons/`
- **Count:** 138 PNG files
- **Status:** Unknown source - verify licensing
- **Publisher:** REXARD
- **Asset Store Link:** https://assetstore.unity.com/packages/2d/gui/icons/strategy-game-icons-64816
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
---
@@ -193,28 +243,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** - Unknown license, need replacement with properly licensed alternatives:
- `Assets/Shardok/commandImages/bridge.png` - wooden bridge icon
- `Assets/Images/startFire.png` - fire icon
1. ~~**Clip art images**~~ - **RESOLVED** (2026-01-23): Replaced with properly licensed alternatives
2. **Shardok sound effects** - 56 MP3 files of unknown origin. Either:
- Document their source
- Replace with known-licensed alternatives
- Confirm they were custom-created
2. ~~**Shardok sound effects**~~ - **ALL RESOLVED**:
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23)
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23)
- ~~`failure_horn.mp3`~~ - **REPLACED** (2026-01-27) with `Negative Effect 04.wav` from Magic Spells Sound Effects LITE
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with Freesound CC BY 4.0
3. **Terrain Hexes** - 85 hex tiles of unknown source
- **TODO:** Confirm if this is a Unity Asset Store purchase (owner believes it is)
### Low Priority (Verify):
4. **StrategyGameIcons** - 138 icons of unknown source
- **TODO:** Investigate origin - check Unity Asset Store purchase history
3. **Dima Koltsov tracks** - 2 of 5 not verified: `Forest Queen Tale`, `Clouds` (presumed CC BY 4.0 like his other tracks)
5. **Medieval: Victory Theme** - Cannot find source or license. Either verify origin or replace.
### Already Resolved:
6. **Dima Koltsov (Audius) tracks** - 5 tracks under Audius "Open Music License" (not Creative Commons). Replace with CC-licensed alternatives eventually.
4. ~~**Free Icons**~~ - **RESOLVED** (2026-01-27): All replaced with Asset Store equivalents, folder deleted
7. **Discord logo** (`Eagle/Discord-Logo-Blurple.png`) - Likely fine for "Login with Discord" button per Discord brand guidelines, but verify usage complies with their terms
5. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
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:
@@ -228,12 +281,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 (e.g., from [game-icons.net](https://game-icons.net) CC BY 3.0)
2. Verify source of `Assets/Shardok/soundEffects/` MP3s
3. Verify source of `Assets/Terrain Hexes/` and `Assets/StrategyGameIcons/`
4. Replace Dima Koltsov Audius tracks with CC-licensed alternatives
5. Find source of Medieval: Victory Theme or replace
None! All required items resolved.
**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
- ~~failure_horn.mp3~~ **DONE** - replaced with Negative Effect 04.wav
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
+103
View File
@@ -0,0 +1,103 @@
# New Profession Proposals
This document proposes 5 new hero professions for Eagle0. Each profession has abilities for both the Eagle (strategic) and Shardok (tactical) game layers.
## Current Professions Reference
| Profession | Eagle Ability | Shardok Abilities |
|------------|---------------|-------------------|
| **Mage** | Control Weather | Lightning Bolt, Meteor, Freeze Water, Start Fire (enhanced) |
| **Necromancer** | Start Epidemic | Raise Dead, Fear |
| **Engineer** | (general) | Repair, Fortify, Build Bridge, Reduce (siege) |
| **Paladin** | Alms (prioritized) | Holy Wave |
| **Ranger** | Recon | Scout, Hide (enhanced), Brave Water (enhanced) |
| **Champion** | (general) | Challenge Duel |
---
## Proposed New Professions
### 1. HERALD (Morale & Communication Specialist)
**Fantasy:** The inspiring leader who rallies troops and carries messages across the battlefield.
**Eagle Ability: "Rally Province"**
- Spend vigor to boost recruitment in a province for one turn, or reduce unrest
- Synergy: Pairs well with provinces in turmoil or after losses
**Shardok Ability: "Inspire"**
- Target friendly unit within 3 hexes gains +1 action point this turn
- Creates interesting tactical choices about when to act vs. when to buff allies
- Cannot target self (prevents simple optimization)
---
### 2. ALCHEMIST (Fire & Transformation Specialist)
**Fantasy:** The mad scientist who manipulates the elements through science, not magic.
**Eagle Ability: "Transmute"**
- Convert one resource type to another in a province (gold to food or vice versa, at a loss)
- Provides economic flexibility during shortages
**Shardok Ability: "Wildfire"**
- Start a fire that spreads to 2 additional adjacent hexes immediately (not just at end of round)
- More aggressive than Mage's Start Fire but less controlled
- Cannot freeze water (that's magic, not science)
---
### 3. WARDEN (Defensive Specialist)
**Fantasy:** The stalwart defender who holds the line and protects allies.
**Eagle Ability: "Garrison"**
- A province with a Warden-led unit gets +1 to defense when attacked
- Encourages strategic placement of defensive heroes
**Shardok Ability: "Intercept"**
- Once per turn, when an adjacent friendly unit is attacked, the Warden's unit can take the hit instead
- Creates a "bodyguard" mechanic that protects valuable units
- Costs action points to maintain readiness
---
### 4. INQUISITOR (Anti-Magic & Intelligence)
**Fantasy:** The witch-hunter who counters supernatural threats and uncovers secrets.
**Eagle Ability: "Expose"**
- Reveal hidden information about enemy heroes in a province (stats, profession, vigor)
- Counter to Ranger's stealth/recon advantages
**Shardok Ability: "Dispel"**
- Cancel an active magical effect: stop a meteor cast, remove Fear from friendly unit, or reveal a hidden unit
- Direct counter to Mage and Necromancer abilities
- Creates meaningful profession rock-paper-scissors
---
### 5. BEASTMASTER (Animal Control)
**Fantasy:** The wild one who commands beasts and understands nature's fury.
**Eagle Ability: "Suppress Beasts" (Enhanced)**
- Already exists in game, but Beastmaster does it at reduced vigor cost
- Additionally: Can redirect beast attacks to enemy provinces instead of just suppressing
**Shardok Ability: "Beast Call"**
- Summon a wolf pack (weak undead-style unit) that attacks the nearest enemy
- Wolves act immediately but disappear at end of round
- Provides disposable units for screening or harassing archers
---
## Design Philosophy
These professions were designed to:
1. **Fill mechanical gaps**: Warden provides defensive depth, Inquisitor counters magic-heavy strategies
2. **Create counterplay**: Inquisitor vs Mage/Necromancer, Beastmaster vs Rangers (nature vs nature)
3. **Avoid overlap**: Each has a unique niche not covered by existing professions
4. **Support both layers**: Each ability is meaningful in its respective game mode
5. **Enable interesting decisions**: Intercept creates bodyguard tactics, Inspire creates action economy choices
+150
View File
@@ -0,0 +1,150 @@
# Notification Diff Batching Optimization
## Problem
`ActionResultFilter.filterForPlayer` generates per-action-result game state diffs, which is expensive. Profiling shows significant time spent in `filteredGameStateDiff``GameStateViewFilter.filteredGameState` (called twice per result) → `GameStateViewDiffer.diff`.
A potential optimization is to batch diffs: instead of computing N diffs for N action results, compute one combined diff representing the final state change. However, this is blocked by how client notification generators work.
## Current Architecture
### Server Side
1. `ActionResultFilter.filterForPlayer` processes each action result
2. For each result, computes `filteredGameStateDiff(before, after, factionId)`
3. Returns `Vector[ActionResultView]` where each view has its own `gameStateDiff`
### Client Side
1. `EagleGameModel.HandleNewHistoryEntry` processes each `ActionResultView`:
```csharp
private void HandleNewHistoryEntry(ActionResultView arv) {
_currentModel.HistoryCount++;
MaybeSendNotification(arv); // Uses currentModel state
ApplyGameStateViewDiff(arv.GameStateDiff); // Updates currentModel
}
```
2. Notification generators receive `(ActionResultView, IGameModel)` and look up display data from the model:
```csharp
var province = currentModel.Provinces[details.ProvinceId];
var factionName = currentModel.FactionName(details.FactionId);
var hero = currentModel.Heroes[heroId];
var affectedProvinces = currentModel.ProvincesForFaction(factionId);
```
### The Problem
Notification for action N sees model state after actions 1..N-1 have been applied. If we batch diffs, notification N would see model state before ANY actions, potentially showing stale data.
Example:
1. Action 1: Province X conquered by Faction B (was Faction A)
2. Action 2: Notification needs to show Province X's current ruler
With batching, action 2's notification would incorrectly show Faction A.
## Audit of Client Notification Generators
~50 generators access `currentModel`. Key lookup patterns:
| Lookup | Count | Mutable? | Risk |
|--------|-------|----------|------|
| `currentModel.PlayerId` | 22 | No | Safe |
| `currentModel.Provinces[id]` | 17 | Yes | `RulingFactionId` changes on conquest |
| `currentModel.Heroes[id]` | ~20 | Mostly safe | Hero data stable, used for display |
| `currentModel.FactionName(id)` | ~10 | No | Names don't change |
| `currentModel.MaybeDestroyedFaction(id)` | ~15 | Yes | Faction may be destroyed |
| `currentModel.ProvincesForFaction(id)` | ~15 | Yes | Changes on conquest |
### State-Changing Action Types
- `ProvinceConquered` - changes province ownership
- `FactionDestroyed` - removes faction
- `FactionLeaderRemoved` - changes faction head
## Proposed Solution: Server-Side Display Data
Eliminate client model lookups by including all display data in server-generated notifications.
### Current Notification Structure
```scala
case class NotificationC(
details: NotificationDetails,
targetFactionIds: Vector[FactionId],
affectedProvinceIds: Vector[ProvinceId], // Already exists, underused
affectedHeroIds: Vector[HeroId], // Already exists, underused
llm: NotificationT.Llm,
deferred: Boolean
)
```
### Proposed Changes
**Option A: Add fields to NotificationC**
```scala
case class NotificationC(
details: NotificationDetails,
targetFactionIds: Vector[FactionId],
affectedProvinceIds: Vector[ProvinceId],
affectedHeroIds: Vector[HeroId],
// New fields:
factionNames: Map[FactionId, String],
displayedHeroViews: Vector[HeroView],
provinceNames: Map[ProvinceId, String],
llm: NotificationT.Llm,
deferred: Boolean
)
```
**Option B: Enrich each NotificationDetails type**
```scala
case class TruceAccepted(
offeringFactionId: FactionId,
offeringFactionName: String, // New
targetFactionId: FactionId,
targetFactionName: String, // New
ambassadorHeroId: HeroId,
ambassadorHeroView: HeroView // New
)
```
### Implementation Steps
1. **Proto changes**: Add new fields to `Notification` message
2. **Server**: Populate display fields when creating notifications
3. **Client**: Update ~50 generators to use notification fields
4. **Test**: Add test that greps for `currentModel.` access in generators (excluding `PlayerId`)
5. **Server optimization**: With client decoupled from model state, batch diffs in `filterForPlayer`
### Trade-offs
**Pros:**
- Clean separation: server provides all display data
- Enables diff batching optimization
- Easier to reason about notification correctness
- Test can enforce the invariant
**Cons:**
- Larger notification messages (includes names, hero views)
- Proto changes required
- ~50 generators need updating (mechanical but tedious)
- Server must know what display data each notification type needs
## Alternative Approaches Considered
### A: Selective Per-Action Diffs
Only generate individual diffs for action results with notifications that need mutable model state. Requires tracking which notification types need which state.
**Rejected because:** Fragile; easy to add a new generator that breaks the invariant.
### B: State-Change-Triggered Diffs
If batch contains state-changing action types (ProvinceConquered, etc.), generate individual diffs from that point. Otherwise batch.
**Rejected because:** Still conservative; many batches would fall back to individual diffs.
## Status
**Deferred** - Current performance is acceptable. This doc captures the analysis for future reference if optimization becomes necessary.
## References
- `ActionResultFilter.scala` - Server-side filtering
- `EagleGameModel.cs` - Client-side model updates
- `Assets/Eagle/Notifications/` - All notification generators
- `NotificationT.scala` - Server notification types
-383
View File
@@ -1,383 +0,0 @@
# Plan: Extract OAuth to Go Service
## Goal
Move OAuth authentication handling from the Eagle Scala server into a separate Go service. This simplifies Eagle (gRPC-only, no HTTP), reduces complexity, and sets up for potentially moving JWT validation outside Eagle too.
## Architecture Decision: Sidecar Service (Not DO Functions)
**Recommendation: Go sidecar service on the same droplet, in a separate container**
**Why not DO Functions:**
- OAuth requires **stateful sessions** (pendingOAuth/completedOAuth maps with 10-min TTL)
- Client polling pattern (every 2 seconds) would incur high function invocation costs
- Cold start latency problematic for auth flows
- State would require external store (Redis), adding complexity
**Why sidecar (separate container):**
- Simple process on same droplet, minimal network latency
- In-memory state management (like current Scala impl)
- Easy to monitor/debug alongside Eagle
- Can share filesystem for key files (RSA keys) via volume mounts
- **Independent deployment**: Deploying Eagle doesn't restart auth service (and vice versa)
- **Independent scaling**: Could move to separate droplet later if needed
## Current Architecture (What Exists)
```
Unity Client
├── GetOAuthUrl RPC → Eagle AuthServiceImpl → OAuthService.getAuthUrl()
├── [User browser auth] → HTTP callback → OAuthHttpHandler → OAuthService.handleCallback()
├── CheckOAuthStatus RPC (polling) → AuthServiceImpl → OAuthService.checkStatus()
└── All other RPCs include JWT → AuthorizationInterceptor validates
```
**Key files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow, state management
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD (persisted)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala` - HTTP callback handler
## Target Architecture (Phase 1)
```
Unity Client
├── GetOAuthUrl RPC ──────────────┐
├── CheckOAuthStatus RPC (polling)├──→ Eagle (port 40032) ──proxy──→ Go Auth Container (port 40033)
├── RefreshToken RPC ─────────────┘ │
├── [User browser] → HTTP callback ────────────────────────────────────────┤
│ ↓
│ (Internal gRPC: GetOrCreateUser, GetUser)
│ ↓
└── Game RPCs with JWT ─────────────────────→ Eagle (port 40032) ← JWT validation stays here
[Same Droplet]
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
│ │ Go Auth Container │◄────────►│ Eagle Container │ │
│ │ (eagle0-auth) │ internal │ (eagle0-server) │ │
│ │ │ gRPC │ │ │
│ │ - OAuth flow │ │ - JWT validation │ │
│ │ - JWT creation │ │ - UserService (persistence) │ │
│ │ - HTTP callback │ │ - Game logic │ │
│ └──────────────────────┘ └────────────────────────────────────┘ │
│ │ │ │
│ └────────────────┬───────────────────────┘ │
│ ▼ │
│ /etc/eagle0/keys/ (shared volume) │
│ - private.pem │
│ - public.pem │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
### Go Auth Service (NEW - separate container)
- **OAuth flow**: getAuthUrl, handleCallback (HTTP), checkStatus
- **State management**: pendingOAuth, completedOAuth maps with TTL
- **JWT creation**: Issue access/refresh tokens (shares RSA private key with Eagle)
- **Token refresh**: Validate refresh token, issue new access token
- Calls Eagle's internal UserService gRPC to find/create users
### Eagle Server (SIMPLIFIED)
- **JWT validation**: AuthorizationInterceptor stays (validates tokens on game RPCs)
- **UserService**: Stays in Eagle (user persistence, display name logic)
- **New internal gRPC**: Expose GetOrCreateUser, GetUser for Go service to call
- **Proxy (Phase 1)**: Forward OAuth RPCs to Go service
- **Remove (Phase 2)**: OAuthService, OAuthHttpHandler, HTTP server setup
### Unity Client (NO CHANGES in Phase 1)
- Eagle proxies Auth RPCs to Go service
- Client still connects to Eagle on port 40032
## Implementation Phases
### Phase 1: Go Auth Service with Eagle Proxy (Zero Client Changes)
1. **Create Go service structure**
```
src/main/go/net/eagle0/authservice/
├── main.go # Entry point, starts gRPC + HTTP servers
├── oauth.go # OAuth state management, provider configs
├── jwt.go # JWT creation (copy logic from Scala)
├── handlers.go # gRPC handlers for Auth service
├── http_callback.go # HTTP handler for OAuth callback
└── BUILD.bazel
```
2. **Internal gRPC proto for Eagle UserService**
```protobuf
// src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto
service InternalUserService {
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetOrCreateUserRequest {
string provider = 1; // "discord" or "google"
string provider_user_id = 2;
string email = 3;
string avatar_url = 4;
}
message GetOrCreateUserResponse {
string user_id = 1;
string display_name = 2;
string avatar_url = 3;
bool is_admin = 4;
bool is_new_user = 5;
}
```
3. **Eagle: Expose InternalUserService**
- New `InternalUserServiceImpl.scala` wrapping UserService
- Bind to same port, different service name (internal only)
4. **Eagle: Proxy Auth RPCs to Go**
- AuthServiceImpl delegates GetOAuthUrl, CheckOAuthStatus, RefreshToken to Go service
- SetDisplayName, GetCurrentUser, Logout stay in Eagle
5. **Share RSA keys via volume mount**
- Go service reads same key files as Eagle
- Both can create valid JWTs
- Eagle continues to validate JWTs
6. **Docker/Container setup**
- New Dockerfile for Go auth service
- docker-compose or Kubernetes config for both containers
- Shared volume for /etc/eagle0/keys/
- Internal network for container-to-container gRPC
### Phase 2: Client Direct to Go Service (Future)
1. **Update Unity client**
- Connect to Go Auth service directly for OAuth RPCs
- Keep connecting to Eagle for game RPCs
2. **Remove Eagle proxy code**
- Delete AuthServiceImpl OAuth delegation
- AuthServiceImpl only handles SetDisplayName, GetCurrentUser, Logout
### Phase 3: Move JWT Validation to Go (Optional Future)
1. **Go service validates JWTs**
- Add ValidateToken RPC or use shared middleware pattern
2. **Eagle calls Go for validation**
- AuthorizationInterceptor calls Go to validate tokens
- OR: Use stateless validation (both share public key)
## Files to Create
### Go Service
- `src/main/go/net/eagle0/authservice/main.go`
- `src/main/go/net/eagle0/authservice/oauth.go`
- `src/main/go/net/eagle0/authservice/jwt.go`
- `src/main/go/net/eagle0/authservice/handlers.go`
- `src/main/go/net/eagle0/authservice/http_callback.go`
- `src/main/go/net/eagle0/authservice/BUILD.bazel`
### Protos
- `src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto`
### Scala
- `src/main/scala/net/eagle0/eagle/service/InternalUserServiceImpl.scala`
### Docker/Deployment
- `ci/auth_service.Dockerfile`
- Update `docker-compose.yml` (or equivalent)
## Files to Modify
### Scala (Phase 1)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - Proxy OAuth RPCs to Go
- `src/main/scala/net/eagle0/eagle/Main.scala` - Start internal user service, add auth-service-url flag
### Scala (Phase 2 - Removal)
- Delete `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala`
- Delete `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala`
- Simplify `src/main/scala/net/eagle0/eagle/Main.scala` - Remove HTTP server
### Unity (Phase 2)
- `Assets/Auth/OAuthManager.cs` - Point OAuth RPCs to Go service port
- `Assets/EagleConnection.cs` - Add second channel for auth service
## Key Implementation Details
### State Management in Go
```go
type OAuthState struct {
Provider string
CreatedAt time.Time
}
type OAuthResult struct {
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
}
var pendingOAuth = sync.Map{} // state -> OAuthState
var completedOAuth = sync.Map{} // state -> OAuthResult
const stateExpiration = 10 * time.Minute
// Background goroutine cleans expired states every minute
func cleanupExpiredStates() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
cutoff := time.Now().Add(-stateExpiration)
pendingOAuth.Range(func(key, value any) bool {
if value.(OAuthState).CreatedAt.Before(cutoff) {
pendingOAuth.Delete(key)
}
return true
})
// Similar for completedOAuth
}
}
```
### JWT Creation in Go
```go
import "github.com/golang-jwt/jwt/v5"
type EagleClaims struct {
jwt.RegisteredClaims
UserId string `json:"userId"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
}
func CreateAccessToken(userId, displayName string, isAdmin bool) (string, error) {
claims := EagleClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
UserId: userId,
DisplayName: displayName,
IsAdmin: isAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
```
### OAuth Provider Configs
- Read from environment variables (same as current OAuthConfig.scala)
- DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
- OAUTH_CALLBACK_URL (e.g., https://eagle0.shardok.games/oauth/callback)
### Container Networking
```yaml
# docker-compose.yml example
services:
eagle0-auth:
build:
context: .
dockerfile: ci/auth_service.Dockerfile
ports:
- "40033:40033" # gRPC
- "8080:8080" # HTTP callback
volumes:
- ./keys:/etc/eagle0/keys:ro
environment:
- DISCORD_CLIENT_ID
- DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
- EAGLE_INTERNAL_URL=eagle0-server:40034
eagle0-server:
build:
context: .
dockerfile: ci/eagle_run.Dockerfile
ports:
- "40032:40032" # Public gRPC
expose:
- "40034" # Internal gRPC (container-to-container only)
volumes:
- ./keys:/etc/eagle0/keys:ro
- ./data:/var/lib/eagle0
environment:
- AUTH_SERVICE_URL=eagle0-auth:40033
```
## Deployment
### Development
```bash
# Terminal 1: Go Auth Service
bazel run //src/main/go/net/eagle0/authservice:authservice -- \
--grpc-port=40033 \
--http-port=8080 \
--eagle-internal-url=localhost:40034
# Terminal 2: Eagle Server
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- \
--eagle-grpc-port=40032 \
--internal-grpc-port=40034 \
--auth-service-url=localhost:40033
```
### Production
- Both containers on same droplet via docker-compose
- Shared volume for RSA keys at /etc/eagle0/keys/
- Internal Docker network for container-to-container communication
- External access: 40032 (Eagle gRPC), 8080 (OAuth HTTP callback)
## Testing Strategy
1. **Unit tests for Go service**
- OAuth state management (expiration, cleanup)
- JWT creation matches Scala output (test with same keys)
- HTTP callback parsing
2. **Integration tests**
- Go service ↔ Eagle internal gRPC
- Full OAuth flow with mock provider
3. **Existing tests continue to pass**
- All Scala tests (JWT validation, user service)
4. **End-to-end test**
- Spin up both containers
- Run OAuth flow through proxy
## Risks and Mitigations
| Risk | Mitigation |
|------|------------|
| Key file permissions | Shared volume with read-only mount |
| State loss on Go restart | Document this (same as current Scala behavior); consider Redis later |
| Clock skew affecting JWT | Both on same machine |
| OAuth callback race | HTTP callback completes before gRPC poll |
| Container networking | Use docker-compose for reliable internal DNS |
| Proxy adds latency | Minimal (same machine), remove in Phase 2 |
## Estimated Scope
- **Phase 1**: ~500-700 lines Go, ~100 lines Scala changes, ~50 lines Docker config
- **Phase 2**: ~50 lines Unity, deletion of ~300 lines Scala
- **Phase 3**: Optional, separate decision
## Alternative Considered: Move Everything to Go
Could move UserService to Go as well, but:
- UserService is tightly integrated with game persistence
- Would require duplicating persistence layer
- Not worth the complexity for now
Keep UserService in Eagle, expose via internal gRPC.
## Open Questions
1. **HTTP callback routing**: Does the OAuth callback URL need to change, or can we route traffic from the existing URL to the new Go service?
2. **Health checks**: Should we add health check endpoints for container orchestration?
3. **Logging**: Should Go service log to same format/destination as Eagle?
-189
View File
@@ -1,189 +0,0 @@
# Discord + Google OAuth Implementation Plan
## Overview
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
## Architecture
```
Unity Client Eagle Server
| |
| 1. Click "Login with Discord/Google" |
| -------------------------------------------------> |
| GetOAuthUrl(provider) -> auth_url + state |
| |
| 2. Open system browser -> OAuth consent |
| 3. User authenticates with provider |
| 4. Redirect to eagle0://auth/callback?code=xxx |
| |
| 5. ExchangeCode(code, state) |
| -------------------------------------------------> |
| Exchange code with provider |
| Fetch user info (id, email, avatar) |
| Create/update user record |
| Issue JWT + refresh token |
| <------------------------------------------------- |
| (jwt, refresh_token, user_info, is_new_user) |
| |
| 6. [If new user] SetDisplayName(name) |
| -------------------------------------------------> |
| |
| 7. Subsequent gRPC calls |
| Authorization: Bearer <jwt> |
| -------------------------------------------------> |
```
## Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| OAuth flow | System browser + deep link | Secure, supports password managers |
| Code exchange | Eagle server directly | No separate auth service needed |
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
| User storage | Protobuf file via Persister | Consistent with existing patterns |
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
## Implementation Phases
### Phase 1: Proto Definitions & Infrastructure
**New files:**
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
**Key proto messages:**
```protobuf
// API
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
ExchangeCodeRequest/Response // Exchange auth code for JWT
SetDisplayNameRequest/Response // Set user's display name
RefreshTokenRequest/Response // Refresh expired access token
// Internal storage
User // user_id, display_name, oauth_identities
UserDatabase // All users + indexes for lookup
```
### Phase 2: Eagle Server Auth Services
**New Scala files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
**Modify:**
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
- Replace Basic Auth parsing with JWT validation
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
- Change context keys from `userName` to `userId` + `displayName`
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
- Wire up new auth services and JWT key loading
### Phase 3: Unity Client OAuth Flow
**New C# files:**
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
**Modify:**
- `Assets/EagleConnection.cs`
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
- `Assets/ConnectionHandler/ConnectionHandler.cs`
- Replace username/password UI with Discord/Google login buttons
- Add display name setup flow for new users
### Phase 4: Platform Configuration
**Deep link registration:**
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
- Desktop: Register URL scheme (Windows registry / macOS plist)
**OAuth provider setup:**
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
2. Google Cloud Console: Create OAuth client, add redirect URI
**Environment variables (server):**
```
DISCORD_CLIENT_ID
DISCORD_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
JWT_PRIVATE_KEY_PATH
JWT_PUBLIC_KEY_PATH
```
### Phase 5: Testing
**Unit tests:**
- `JwtServiceSpec.scala` - Token creation/validation
- `UserServiceSpec.scala` - Display name validation, uniqueness
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
**Integration tests:**
- Full OAuth flow with mock provider
- JWT validation in AuthorizationInterceptor
- gRPC calls with valid/invalid tokens
**Manual testing:**
- [ ] Discord login (Windows, macOS)
- [ ] Google login (Windows, macOS)
- [ ] Deep link callback works
- [ ] Display name validation
- [ ] Session persistence across restarts
- [ ] Token refresh
## Files Summary
### Create
| File | Purpose |
|------|---------|
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
| `Assets/Auth/TokenStorage.cs` | Token storage |
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
### Modify
| File | Changes |
|------|---------|
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
| `AuthorizationUtils.scala` | userName -> userId + displayName |
| `Main.scala` | Wire auth services |
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
### Delete
- nginx htpasswd configuration (no longer needed)
## Security Considerations
1. **State parameter** - CSRF protection in OAuth flow
2. **PKCE** - Consider adding for mobile (enhancement)
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
4. **Token refresh** - 7-day access tokens with 30-day refresh
5. **Rate limiting** - Limit login attempts per IP
## Dependencies to Add
**Scala (MODULE.bazel):**
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
- HTTP client (e.g., `sttp` for OAuth requests)
**Unity:**
- Deep linking is built-in (Unity 2021+)
- No additional packages required
## Rollback Plan
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
-350
View File
@@ -1,350 +0,0 @@
# OAuth Implementation: Next Steps and Design
## Executive Summary
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
## Current State (Updated January 2026)
### What Works ✅
- Discord OAuth flow (server-mediated polling)
- Google OAuth flow
- JWT token generation and validation
- User creation and display name setting
- Auto-login with stored tokens
- Basic game creation and play with OAuth users
- Headshot fetching via public CDN (no auth required)
- Logout button in lobby (preserves tokens for quick reconnect)
- Environment (prod/qa) and user display in lobby
- Game identity with userName = displayName (PR #4964 merged)
### Known Issues
#### 1. Game Identity Model Fragility (Deferred)
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
**Current behavior**:
- Games store `userNameToFactionId: Map[String, Int]`
- For JWT users, this maps displayName → factionId
- displayName is technically mutable (users could change it)
- No migration path when displayName changes
**Why this is acceptable**:
1. We don't currently have a "change display name" feature
2. The alternative (using userId) requires more extensive changes
3. Can migrate to userId-based identity later if needed
#### 2. In-Game Headshot Fetching ✅ FIXED
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
- No authentication required
- Works for both OAuth and Basic Auth users
- Simpler architecture, no dependency on home Mac server
#### 3. Logout from Lobby ✅ FIXED
**Solution**: Added logout button to lobby UI (PR #4967).
- Button disconnects from server and returns to connection screen
- Intentionally does NOT clear OAuth tokens
- Allows quick reconnect with same account without full OAuth flow
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
**Problem**: User was able to set displayName "nolen" when that name was already taken.
**Root cause**: Unknown - needs investigation. Either:
- The uniqueness check is buggy
- The displayNameIndex wasn't populated correctly during user creation
- Race condition during concurrent registrations
#### 5. Admin Server Crashes ✅ FIXED
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
**Status**: Added diagnostic logging in PR #4974 to trace:
- State creation in `getAuthUrl`
- State lookup in `handleCallback`
- Result lookup in `checkStatus`
**Possible causes**:
- State mismatch between client and server
- Race condition in polling
- Cleanup running at wrong time
#### 7. Token Expiry Field Bug ✅ FIXED
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
---
## Proposed User Identity Model
### Design Principles
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
2. **Display Name is Cosmetic**: Can change without breaking game associations
3. **Backwards Compatibility**: Basic Auth continues to work for local development
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
### Data Model
```
User {
userId: String (UUID) // Primary key, immutable, used for all internal references
displayName: String // Unique, user-visible, mutable with migration
displayNameLower: String // Case-insensitive uniqueness
email: String // Primary email for account recovery/linking
avatarUrl: String // Current avatar URL
avatarData: bytes // Cached avatar for offline/fast access (future)
oauthIdentities: [OAuthIdentity]
createdAt: Timestamp
lastLoginAt: Timestamp
isAdmin: Boolean
}
OAuthIdentity {
provider: String // "discord", "google", etc.
providerUserId: String // Provider's user ID
providerEmail: String // Email from this provider
avatarUrl: String // Avatar from this provider
linkedAt: Timestamp
}
```
### Identity Resolution Strategy
The key question: **What should `AuthorizationUtils.userName` return?**
#### Option A: userName = displayName (Current PR #4964)
- **Pro**: Human-readable in logs, game saves, debugging
- **Con**: Breaks if displayName changes
- **Migration**: None needed now, complex later
#### Option B: userName = userId (Recommended)
- **Pro**: Stable identity, displayName changes are safe
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
#### Option C: Hybrid with Migration Support
- **userName** = userId for new games
- **Legacy lookup** for old games by displayName
- **Display layer** resolves userId → displayName for UI
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
### Account Linking Strategy
#### Automatic Linking (Future)
When a user logs in with a new OAuth provider:
1. Check if the provider email matches an existing user's email
2. If match found, prompt: "An account exists with this email. Link accounts?"
3. If confirmed, add new OAuthIdentity to existing user
4. If declined, create separate account (different email required)
#### Manual Linking (MVP)
1. User logs in with primary account
2. User goes to Settings → Linked Accounts
3. User clicks "Link Discord" or "Link Google"
4. OAuth flow adds new identity to current user
### Avatar/Headshot Strategy
#### Phase 1: OAuth Avatars (MVP)
- Store `avatarUrl` from OAuth provider during login
- Server proxies avatar requests to avoid CORS issues
- Cache avatars locally with TTL
#### Phase 2: Avatar Caching
- Download avatar to local storage on login
- Serve from local storage for reliability
- Refresh periodically or on login
#### Phase 3: Custom Avatars (Future)
- Allow users to upload custom avatar
- Store in S3/DO Spaces
- Custom avatar overrides OAuth avatar
---
## Implementation Plan
### Phase 1: Stabilization ✅ COMPLETE
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
- [ ] Investigate why "nolen" was allowed when it existed
- [ ] Add logging to `setDisplayName` to trace the issue
- [ ] Ensure `displayNameIndex` is correctly maintained
- [ ] Add unit tests for uniqueness enforcement
#### 1.2 Add Logout Button to Lobby ✅ DONE
- [x] Add "Logout" button to lobby UI
- [x] Disconnect from server
- [x] Navigate to connection screen
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
- [x] Merged - games work with OAuth users
- [x] Documented limitation (games break if displayName changes)
#### 1.4 Fix Headshot Fetching ✅ DONE
- [x] Made eagle0-headshots bucket public
- [x] Client fetches directly from CDN
- [x] No authentication required
#### 1.5 Add Lobby Status Display ✅ DONE
- [x] Show environment (prod/qa) in lobby
- [x] Show current user in lobby (OAuth displayName or classic username)
### Phase 2: Remaining Work (Priority Order)
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
- [x] Add diagnostic logging (PR #4974)
- [ ] Deploy and reproduce the issue
- [ ] Analyze logs to identify root cause
- [ ] Implement fix based on findings
#### 2.2 Fix Display Name Uniqueness
- [ ] Investigate UserService.setDisplayName logic
- [ ] Check displayNameIndex population
- [ ] Add logging to trace the issue
- [ ] Fix the bug and add tests
#### 2.3 Wire Up Lobby UI in Unity
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
#### 2.4 Implement Token Refresh During Gameplay
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
- [ ] Store refresh tokens server-side for validation
- [ ] Add proactive refresh in client before token expires
- [ ] Handle refresh during reconnection attempts
### Phase 3: Nice-to-Haves (Future)
#### 3.1 Proactive Token Refresh
- [ ] Monitor token expiry in client
- [ ] Refresh automatically when < 5 minutes remaining
- [ ] Update TokenStorage with new access token
#### 3.2 Better Error Messages
- [ ] Distinguish between network errors and auth errors
- [ ] Show user-friendly messages for OAuth failures
- [ ] Add retry suggestions
#### 3.3 Session Persistence Across Server Restarts
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
- [ ] Move completedOAuth to Redis with TTL
- [ ] Server can restart without breaking in-flight OAuth flows
#### 3.4 Migrate to userId-based Game Identity (Deferred)
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
- [ ] Update game UI to resolve userIds to displayNames
- [ ] Existing Basic Auth games continue to work (userName is literal)
#### 3.5 Display Name Change Support (Requires 3.4)
- [ ] Add `ChangeDisplayName` RPC
- [ ] Validate new name is unique
- [ ] Update user record
- [ ] No game migration needed (games use userId)
### Phase 3: Multi-Provider Support (Future)
#### 3.1 Account Linking UI
- [ ] Add Settings page with "Linked Accounts" section
- [ ] Show currently linked providers
- [ ] "Link Another Account" button triggers OAuth flow
- [ ] `LinkOAuthProvider` RPC adds identity to current user
#### 3.2 Login Provider Selection
- [ ] If user has multiple providers, any can be used to login
- [ ] All resolve to same userId
- [ ] Session shows which provider was used
#### 3.3 Account Merging (Complex)
- [ ] Handle case where user created separate accounts
- [ ] Merge game history, stats, etc.
- [ ] Delete duplicate user record
- [ ] This is complex - may defer or not implement
### Phase 4: Enhanced Avatars (Future)
#### 4.1 Avatar Caching
- [ ] Download avatars to S3/DO Spaces on login
- [ ] Serve from our CDN
- [ ] Refresh on login if changed
#### 4.2 Custom Avatar Upload
- [ ] Upload endpoint with size/format validation
- [ ] Store in S3/DO Spaces
- [ ] Custom avatar overrides OAuth avatar
---
## Technical Debt to Address
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
- Should Basic Auth be deprecated for production?
- Should it remain for local development only?
- How do Basic Auth users interact with OAuth users in the same game?
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
- Implement refresh token storage and validation
- Handle token refresh in client
- Consider refresh token rotation for security
4. **Session Management**: No server-side session tracking. Consider:
- Track active sessions per user
- Allow "logout all devices"
- Detect concurrent logins
---
## Open Questions
1. **What happens when a Basic Auth user and OAuth user have the same name?**
- Currently possible - Basic Auth doesn't check UserService
- Could cause confusion in games
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
2. **Should displayName changes be allowed?**
- With userId-based identity, it's safe
- But could cause confusion ("who is this new player?")
- Consider: rate limit changes, show "formerly known as" temporarily
3. **How to handle OAuth provider account deletion?**
- User deletes their Discord account
- Their Eagle0 account still exists
- They can't login unless they linked another provider
- Solution: Encourage linking multiple providers, or add email/password fallback
4. **Admin impersonation with OAuth**
- Currently works via X-Impersonate-User header
- Should this use userId or displayName?
- Probably userId for stability
---
## Appendix: File Locations
### Server (Scala)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
### Client (C#)
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
### Protos
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
-205
View File
@@ -1,205 +0,0 @@
# Scala 3 Modernization Guide
## Overview
This document outlines opportunities to modernize the Eagle0 codebase to use Scala 3 best practices and features. The migration to Scala 3 is complete, but the code still uses many Scala 2 patterns that can be improved.
## Modernization Opportunities
### 1. **Convert Sealed Traits to Enums** 🎯 HIGH IMPACT
**Benefits**: Better performance, more concise syntax, improved exhaustiveness checking
**Current pattern** (`ExternalTextGenerationCaller.scala:23-31`):
```scala
sealed trait ExternalTextGenerationError extends Error {
def message: String
}
case class ExternalTextGenerationRateLimitError(code: Int, message: String)
extends ExternalTextGenerationError
case class ExternalTextGenerationHttpError(code: Int, message: String)
extends ExternalTextGenerationError
case class ExternalTextGenerationTimeoutError(message: String)
extends ExternalTextGenerationError
```
**Scala 3 improvement**:
```scala
enum ExternalTextGenerationError extends Error:
case RateLimit(code: Int, message: String)
case Http(code: Int, message: String)
case Timeout(message: String)
def message: String = this match
case RateLimit(_, msg) => msg
case Http(_, msg) => msg
case Timeout(msg) => msg
```
**Files to check**:
- `/src/main/scala/net/eagle0/common/llm_integration/ExternalTextGenerationCaller.scala`
- `/src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/GeneratedTextRequestT.scala`
- `/src/main/scala/net/eagle0/eagle/model/state/quest/concrete/QuestC.scala`
### 2. **Convert Implicit Classes to Extension Methods** 🎯 HIGH IMPACT
**Benefits**: Modern syntax, better IDE support, cleaner imports
**Current pattern** (`MoreSeq.scala:23-26`):
```scala
implicit def SeqCollect[A, Repr[_]](coll: Repr[A])(implicit
itr: IsIterable[Repr[A]]
): SeqCollect[A, Repr, itr.type] =
new SeqCollect[A, Repr, itr.type](coll, itr)
```
**Scala 3 improvement**:
```scala
extension [A, Repr[_]](coll: Repr[A])(using itr: IsIterable[Repr[A]])
def flatCollect[B](pf: PartialFunction[itr.A, Option[B]])(using Factory[B, Repr[B]]): Repr[B] =
Factory[B, Repr[B]].fromSpecific(itr(coll).collect(pf).flatten)
def flatCollectFirst[B](pf: PartialFunction[itr.A, Option[B]]): Option[B] =
itr(coll).collect(pf).flatten.headOption
```
**Files to check**:
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
- `/src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/CommandChooser.scala`
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/NewGameCreation.scala`
- `/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala`
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/StartGameActionResultUtils.scala`
- `/src/main/scala/net/eagle0/eagle/model/state/date/Date.scala`
### 3. **Convert Implicit Parameters to Using Clauses** 🎯 MEDIUM IMPACT
**Benefits**: Cleaner syntax, better tooling support, clearer intent
**Current pattern**:
```scala
def method[T](value: T)(implicit ec: ExecutionContext): Future[T]
def process[A](items: Seq[A])(implicit ord: Ordering[A]): Seq[A]
```
**Scala 3 improvement**:
```scala
def method[T](value: T)(using ExecutionContext): Future[T]
def process[A](items: Seq[A])(using Ordering[A]): Seq[A]
```
**Files to check**:
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
- `/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
- `/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala`
- `/src/main/scala/net/eagle0/common/llm_integration/OpenAIChatCompletionsServiceImpl.scala`
- `/src/main/scala/net/eagle0/common/llm_integration/ClaudeServiceImpl.scala`
### 4. **Opaque Types for Type Safety** 🎯 MEDIUM IMPACT
**Benefits**: Zero runtime cost, compile-time type safety, prevents mixing up similar types
**Pattern to look for**: Type aliases that represent distinct concepts
```scala
// Instead of: type UserId = String, type GameId = String
opaque type UserId = String
object UserId:
def apply(s: String): UserId = s
extension (id: UserId)
def value: String = id
def isValid: Boolean = id.nonEmpty && id.length > 3
opaque type GameId = Long
object GameId:
def apply(l: Long): GameId = l
extension (id: GameId) def value: Long = id
```
**Candidates**: Look for simple type aliases and ID types throughout the codebase.
### 5. **Inline Methods for Performance** 🎯 LOW IMPACT
**Benefits**: Compile-time optimization, better performance for hot paths
**Pattern**: Mark small, frequently-called methods as `inline`
```scala
inline def isValidId(id: String): Boolean =
id.nonEmpty && id.length > 3
inline def calculateScore(base: Int, multiplier: Double): Double =
base * multiplier
```
**Candidates**: Small utility methods in performance-critical paths (AI calculations, game state updates).
### 6. **Union Types Instead of Complex Hierarchies** 🎯 LOW IMPACT
**Benefits**: Simpler type definitions for either/or scenarios
**Pattern**: Simple sealed traits with only case classes
```scala
// Instead of:
sealed trait Result
case class Success(value: String) extends Result
case class Error(message: String) extends Result
// Consider:
type Result = Success | Error
case class Success(value: String)
case class Error(message: String)
```
### 7. **Context Functions for Cleaner APIs** 🎯 LOW IMPACT
**Benefits**: Cleaner API design, implicit context passing
**Pattern**: Replace implicit function parameters
```scala
// Old
type Handler = GameState => Unit
def withGameState(gs: GameState)(handler: Handler): Unit = handler(gs)
// New
type Handler = GameState ?=> Unit
def withGameState(gs: GameState)(handler: Handler): Unit =
given GameState = gs
handler
```
## Implementation Priority
### Phase 1: Quick Wins (High Impact, Low Risk)
1. **Convert Extension Methods** in `MoreSeq.scala` - immediate readability improvement
2. **Update Using Clauses** - simple find/replace operation
3. **Convert Simple Sealed Traits to Enums** - start with error types
### Phase 2: Type Safety Improvements
4. **Add Opaque Types** for IDs and measurements - improves type safety
5. **Inline Performance-Critical Methods** - measure before/after impact
### Phase 3: Advanced Features (Lower Priority)
6. **Union Types** where appropriate - only for simple either/or cases
7. **Context Functions** for complex API improvements
## Implementation Guidelines
### Style Consistency
- **Keep curly braces**: Continue using Scala 2 style `{}` instead of indentation-based syntax
- **Gradual adoption**: Modernize files as they're touched for other reasons
- **Test thoroughly**: Each modernization should include verification that behavior is unchanged
### Performance Considerations
- **Measure enum performance**: Verify that enum conversion actually improves performance in hot paths
- **Benchmark inline methods**: Use profiling to confirm performance gains
- **Consider compilation time**: Some features may increase compile time
### Migration Strategy
- **File-by-file approach**: Complete modernization of one file at a time
- **Separate PRs**: Each modernization type should be its own PR for easier review
- **Documentation**: Update this document as patterns are modernized
## Success Criteria
- [ ] All extension methods converted from implicit classes
- [ ] All implicit parameters converted to using clauses
- [ ] Key sealed traits converted to enums where appropriate
- [ ] Opaque types introduced for important ID types
- [ ] Performance-critical methods marked as inline (with benchmarks)
- [ ] No regression in functionality or performance
- [ ] Code remains readable and maintainable
## Notes
- Focus on high-impact, low-risk improvements first
- Each change should be driven by clear benefits (performance, readability, type safety)
- Maintain backward compatibility where possible
- Document any breaking changes clearly
+6 -6
View File
@@ -15,10 +15,10 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [x] ~~Shardok performance similar to QA~~
- [x] ~~Error logging & alerting~~
- [x] ~~Fix long disconnects on deployments~~
- [ ] Fix the Mac installer
- [x] Fix the Mac installer
- [x] ~~Still not reconnecting after deployments~~
- [ ] Notify about client updates, button to come directly back
- [ ] Generatedtext healing
- [x] Generatedtext healing
- [ ] Kill outstanding shardok requests when game is deleted
### Other Productionization
@@ -44,8 +44,8 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [ ] Document and make available licenses for art & music
- [ ] Required open source disclosures
- [ ] Audit assets for anything we don't have rights to and replace it
- [ ] Replace heroes that are based on real 20th or 21st century people or IP
- [x] Audit assets for anything we don't have rights to and replace it
- [x] Replace heroes that are based on real 20th or 21st century people or IP
- [ ] Privacy policy (collecting accounts, OAuth data, gameplay data)
- [ ] Terms of service (basic liability protection)
- [ ] Data deletion capability (user requests account removal)
@@ -53,13 +53,13 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
### Basic Gameplay
- [ ] Tutorial
- [ ] In the Your Warlord panel, say what the profession is
- [x] In the Your Warlord panel, say what the profession is
- [x] ~~And separate panels for each profession when you encounter one~~
- [x] ~~Command tutorial for each command the first time it's clicked~~
- [x] ~~Time to recruit / expand~~
- [x] ~~And how expansion works~~
- [ ] Province events
- [ ] Running low on food
- [x] Running low on food
- [x] ~~Time to swear brotherhood~~
- [x] ~~When you get large, or~~
- [x] ~~When you get a good candidate~~
-310
View File
@@ -1,310 +0,0 @@
# Scala 3 Migration: Reflection Issues Found
This document catalogs all reflection-related problems discovered during the Scala 2.13.16 → Scala 3.7.2 migration of the Eagle0 codebase.
## Summary
The migration revealed several categories of reflection issues that needed to be addressed for Scala 3 compatibility:
1. **Scala 2 Runtime Reflection API** - No longer available in Scala 3
2. **Settings System Reflection** - Custom reflection for loading settings singletons
3. **json4s Automatic Case Class Extraction** - Uses reflection that fails with Scala 3 metaprogramming classes
4. **ScalaTest Exception Handling** - Syntax changes affecting exception variable binding
## 1. Scala 2 Runtime Reflection (FIXED)
### Issue
Tests using `scala.reflect.runtime.universe` fail because this reflection API doesn't exist in Scala 3.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
### Error
```scala
import scala.reflect.runtime.universe // Not available in Scala 3
```
### Solution Applied
**Deleted the test entirely** as it was redundant. The test was verifying that auto-generated Scala objects (created by Bazel from proto enum values) matched their source proto values - something already guaranteed by the build system. Since the objects are generated directly from the proto definitions, this test provided no value.
**Files deleted:**
- `src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
## 2. Settings System Reflection (FIXED)
### Issue
Custom `SettingsLoader` class used reflection to access Scala object singletons, but the reflection pattern changed between Scala 2 and Scala 3.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/settings/loaders/SettingsLoader.scala`
### Error
```
java.lang.NoSuchMethodException: net.eagle0.eagle.library.settings.ApprehendOutlawVigorCost$.MODULE$
```
### Root Cause
In Scala 2, singleton objects are accessed via `ClassName$.MODULE$()`, but in Scala 3, they're accessed directly via `ClassName$` field. Additionally, `scala.reflect.runtime.universe` is not available in Scala 3.
### Solution Applied
**Completely eliminated reflection** by auto-generating the entire `SettingsLoader.scala` file from BUILD.bazel definitions:
1. **Created generator**: `src/main/go/net/eagle0/build/settings_loader_generator/settings_loader_generator.go` - parses BUILD.bazel and generates complete SettingsLoader.scala with pattern matching for all 272 settings
2. **Added genrule**: In `src/main/scala/net/eagle0/eagle/library/settings/loaders/BUILD.bazel`:
```python
genrule(
name = "settings_loader_src",
srcs = ["//src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel"],
outs = ["SettingsLoader.scala"],
cmd = "$(location //src/main/go/net/eagle0/build/settings_loader_generator) $(location //src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel) > $@",
tools = ["//src/main/go/net/eagle0/build/settings_loader_generator"],
)
```
3. **Result**: SettingsLoader now uses compile-time pattern matching instead of reflection:
```scala
private def settingObjectForKey(key: String): Any = key match {
case "ActionVigorCost" => ActionVigorCost
case "BaseFoodBuyPrice" => BaseFoodBuyPrice
// ... all 272 settings auto-generated
case _ => throw NoSuchSettingException(key)
}
```
### Benefits
- **No reflection** - Completely Scala 3 compatible
- **Maintainable** - New settings automatically included when added to BUILD.bazel
- **Performance** - Pattern matching is faster than reflection
- **Type-safe** - Compile-time checking of all settings
## 3. json4s Reflection Issues (MULTIPLE LOCATIONS)
### 3.1 EagleServiceImpl JSON Serialization (FIXED)
#### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala`
#### Error
```
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
```
#### Root Cause
json4s automatic case class serialization uses reflection that tries to access Scala 3 metaprogramming classes (`scala.quoted.staging.package$`) which aren't available at runtime.
#### Solution Applied
Replaced automatic json4s serialization with ScalaPB's built-in JSON support:
```scala
// Old (reflection-based):
// implicit val formats: DefaultFormats.type = DefaultFormats
// write(actionResultView)
// New (ScalaPB JSON support):
import scalapb.json4s.JsonFormat
JsonFormat.toJsonString(actionResultView.toProto)
```
### 3.2 ShardokMapInfo JSON Parsing (FIXED)
#### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala` (Line 44)
#### Error
```
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
at org.json4s.reflect.ScalaSigReader$.readConstructor(ScalaSigReader.scala:42)
```
#### Root Cause
The line `val extracted = parsedJson.extract[List[ShardokMapInfo]]` uses json4s automatic case class extraction which relies on reflection.
#### Solution Applied
Replaced automatic extraction with manual JSON parsing:
```scala
// OLD (reflection-based):
val extracted = parsedJson.extract[List[ShardokMapInfo]]
// NEW (manual parsing, no reflection):
val extracted = parsedJson match {
case JArray(items) => items.map { item =>
val name = (item \ "name").extract[String]
val castleCount = (item \ "castleCount").extract[Int]
val positions = (item \ "positions").extract[Map[Int, Int]]
ShardokMapInfo(name, castleCount, positions)
}
case _ => throw new Exception("Expected JSON array for map info")
}
```
#### Testing
The fix was verified - `attack_command_chooser_test` now passes successfully.
### 3.3 HeroNameFetcher JSON Parsing (FIXED)
#### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
#### Issue
Case class extraction `parsedJson.extract[ResponseBody]` uses reflection that may fail in Scala 3.
#### Solution Applied
Replaced automatic case class extraction with manual JSON parsing:
```scala
// OLD (reflection-based):
val parsedJson = json.parse(src.getLines().mkString)
parsedJson.extract[ResponseBody]
// NEW (manual parsing, no reflection):
parsedJson \ "names" match {
case JArray(nameArray) =>
nameArray.map { nameObj =>
val id = (nameObj \ "id").extract[String]
val name = (nameObj \ "name").extract[String]
NameResponse(id, name)
}.toVector
case _ => throw new Exception("Expected 'names' array in response")
}
```
#### Testing
The fix was verified - HeroNameFetcher now builds successfully without reflection.
### 3.4 Other json4s Usage Analysis
#### Files with json4s extraction:
- **✅ SAFE**: OpenAI/Claude Services - Only extract simple types (`String`, `Int`) - no reflection
- **✅ FIXED**: `HeroNameFetcher.scala` - Replaced `extract[ResponseBody]` with manual parsing (no reflection)
- **⚠️ POTENTIAL ISSUES** (not currently causing failures but should be monitored):
- `JsonUtils.scala`: `extract[Map[String, Vector[String]]]` - complex type extraction
- `HexMapJsonUtils.scala`: `extract[List[JObject]]` - may be problematic
#### Recommendation
Apply the same manual parsing pattern to remaining case class extractions if they cause runtime failures during Scala 3 migration.
## 4. ScalaTest Exception Handling Syntax (FIXED)
### Issue
Scala 3 changed how exception variables are bound in ScalaTest's `the[Exception] thrownBy {...}` construct.
### Files Affected
**70+ test files** across the codebase using exception testing patterns.
### Error Pattern
```
Not found: ex
```
### Root Cause
In Scala 2: `the[Exception] thrownBy { ... }` automatically creates an `ex` variable.
In Scala 3: The exception variable must be explicitly bound.
### Solution Applied
Added explicit variable binding across all affected test files:
```scala
// Old Scala 2 syntax:
the[EagleCommandException] thrownBy {
// test code
}
ex.getMessage shouldBe "expected message"
// New Scala 3 syntax:
val ex = the[EagleCommandException] thrownBy {
// test code
}
ex.getMessage shouldBe "expected message"
```
### Script Used
Created and ran a systematic fix script that processed 70+ files:
```bash
# Pattern to find and fix exception handling
find . -name "*.scala" -exec sed -i '' 's/the\[\([^]]*\)\] thrownBy {/val ex = the[\1] thrownBy {/g' {} \;
```
## 5. ScalaTest Import Changes (FIXED)
### Issue
Scala 3 requires different imports for ScalaTest matchers.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/impl/command/DeclineQuestCommandTest.scala`
### Error
```
value convertToAnyShouldWrapper is not a member of object org.scalatest.matchers.should.Matchers
```
### Solution Applied
Changed from specific imports to wildcard import:
```scala
// Old:
import org.scalatest.matchers.should.Matchers.{convertToAnyShouldWrapper, the}
// New:
import org.scalatest.matchers.should.Matchers.*
```
## 6. Mock Framework Issues (FIXED)
### Issue
ScalaMock had type inference issues with Scala 3 for classes with constructor parameters.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/EngineImplTest.scala`
### Error
```
Found: Vector
Required: Vector[net.eagle0.eagle.library.util.hero_generator.hero_with_name.HeroWithName]
```
### Root Cause
Mock framework couldn't properly infer types for `mock[HeroGenerator]` where `HeroGenerator` has constructor parameters.
### Solution Applied
The user updated to a newer ScalaMock version that fixed this issue, plus added some missing Bazel dependencies:
```scala
// Also needed to add missing dependency:
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution"
```
## Migration Status
### ✅ COMPLETED
- [x] Scala 2 runtime reflection removal
- [x] Settings system reflection compatibility
- [x] EagleServiceImpl json4s → ScalaPB JSON
- [x] ScalaTest exception handling syntax (70+ files)
- [x] ScalaTest import changes
- [x] Mock framework issues (via ScalaMock update)
- [x] All test compilation issues resolved
### ⚠️ REMAINING
- [ ] **Potential json4s case class extractions** - May cause runtime failures (JsonUtils, HexMapJsonUtils) - currently no test failures reported
### 📊 PROGRESS
- **Tests passing**: All identified runtime failures resolved
- **Build failures**: 0 (all tests now compile)
- **Runtime failures**: 0 (critical ShardokMapInfo issue resolved)
## Recommendations
1. **✅ COMPLETED**: ShardokMapInfo json4s reflection issue resolved with manual parsing
2. **Monitor remaining json4s usage**: Watch for runtime failures in HeroNameFetcher, JsonUtils, and HexMapJsonUtils during full Scala 3 migration
3. **Consider ScalaPB for new JSON needs**: For new functionality, prefer ScalaPB's JSON support to avoid reflection entirely
4. **Apply manual parsing pattern**: If other json4s case class extractions cause runtime failures, use the same manual parsing approach demonstrated in ShardokMapInfo
## Key Learnings
- **Scala 3 reflection changes**: Major differences in singleton object access patterns
- **json4s compatibility**: Automatic case class extraction doesn't work well with Scala 3 metaprogramming
- **ScalaPB advantage**: Using ScalaPB's JSON support avoids reflection issues entirely
- **Systematic approach**: Many issues followed patterns that could be fixed with scripts across multiple files
+1
View File
@@ -12,6 +12,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
golang.org/x/sys v0.28.0
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.36.3
)
+2
View File
@@ -43,6 +43,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+38 -4
View File
@@ -5,24 +5,58 @@
#
# Environment variables:
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
# KEYCHAIN_PASSWORD - Password to unlock the keychain (optional, CI only)
# KEYCHAIN_NAME - Name of the keychain containing the signing certificate (optional, CI only)
# When set, looks for certificate in this specific keychain.
# When not set, searches all keychains (local dev mode).
set -euxo pipefail
APP_PATH="$1"
ENTITLEMENTS_PATH="${2:-}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
# KEYCHAIN_NAME is set by CI workflow - don't set a default here so we can detect if we're in CI
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
# Unlock keychain if password provided
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
# Unlock keychain if password and keychain name are provided (CI environment)
if [ -n "${KEYCHAIN_PASSWORD:-}" ] && [ -n "${KEYCHAIN_NAME:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" || true
fi
# Get the SHA-1 hash of the signing certificate
# Using the hash avoids "ambiguous" errors when the same identity exists in multiple keychains
# We look in the build keychain specifically if KEYCHAIN_NAME is set (CI environment)
# Otherwise fall back to searching all keychains (local dev)
if [ -n "${KEYCHAIN_NAME:-}" ]; then
# CI environment: look for certificate in the build keychain specifically
KEYCHAIN_PATH="$HOME/Library/Keychains/${KEYCHAIN_NAME}-db"
echo "Looking for signing identity in build keychain: $KEYCHAIN_PATH"
echo "Available identities in build keychain:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
CERT_HASH=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}')
else
# Local dev: search all keychains
echo "Available codesigning identities:"
security find-identity -v -p codesigning
CERT_HASH=$(security find-identity -v -p codesigning | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}')
fi
if [ -z "$CERT_HASH" ]; then
echo "ERROR: No valid signing identity found"
exit 1
fi
echo "Using certificate hash: $CERT_HASH"
# Use the hash as the signing identity to avoid ambiguity
SIGNING_IDENTITY="$CERT_HASH"
echo "=== Signing nested components first ==="
# Sign all dylibs
+5
View File
@@ -35,6 +35,7 @@ WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
SAVES_DIR="${APP_DIR}/saves"
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
ACTIVE_INSTANCE_FILE="${APP_DIR}/.active-instance"
# Colors for output
RED='\033[0;31m'
@@ -298,6 +299,10 @@ main() {
create_flush_marker "${deploy_id}" "eagle-${staging}"
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
# Write active instance file for eagle-exec helper
echo "eagle-${staging}" > "${ACTIVE_INSTANCE_FILE}"
log_info "[DEPLOY:${deploy_id}] Active instance file updated: eagle-${staging}"
# Update .env for admin service
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
+17 -25
View File
@@ -1,43 +1,35 @@
#!/bin/bash
#
# Helper to run docker exec against the active Eagle instance.
# Automatically determines whether blue or green is active based on nginx config.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# Usage:
# ./scripts/eagle-exec.sh printenv GEMINI_API_KEY
# ./scripts/eagle-exec.sh jcmd 1 VM.flags
# ./scripts/eagle-exec.sh sh # Get a shell
# eagle-exec printenv GEMINI_API_KEY
# eagle-exec jcmd 1 VM.flags
# eagle-exec sh # Get a shell
#
# Or create an alias:
# To create an alias, add to ~/.bashrc:
# alias eagle-exec='/opt/eagle0/scripts/eagle-exec.sh'
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
# Determine active instance from nginx config
get_active_instance() {
local backend
backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf 2>/dev/null | head -1 || echo "")
if [ "$backend" = "eagle-blue:40032" ]; then
echo "eagle-blue"
elif [ "$backend" = "eagle-green:40032" ]; then
echo "eagle-green"
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
echo "eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
echo "eagle-green"
else
echo ""
fi
ACTIVE=""
fi
}
ACTIVE=$(get_active_instance)
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
#
# Helper to tail logs from the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# Usage:
# eagle-logs # Tail logs (follow mode)
# eagle-logs -n 100 # Show last 100 lines and follow
# eagle-logs --no-follow -n 50 # Show last 50 lines without following
#
# To create an alias, add to ~/.bashrc:
# alias eagle-logs='/opt/eagle0/scripts/eagle-logs.sh'
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
# Default to follow mode if no args provided
if [ $# -eq 0 ]; then
exec docker logs -f "$ACTIVE"
else
exec docker logs "$@" "$ACTIVE"
fi
@@ -37,4 +37,7 @@ sysinfo.txt
*.apk
*.unitypackage
mono_crash.*
mono_crash.*
# Local server data
ServerData/
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,7 @@ MonoBehaviour:
m_DefaultGroup: 4458439bbfafa478b905417eff3c5741
m_currentHash:
serializedVersion: 2
Hash: 00000000000000000000000000000000
Hash: 0e8900f37a8b7b1c88049de57e371339
m_OptimizeCatalogSize: 0
m_BuildRemoteCatalog: 1
m_CatalogRequestsTimeout: 0
@@ -24,11 +24,11 @@ MonoBehaviour:
m_InternalBundleIdMode: 1
m_AssetLoadMode: 0
m_BundledAssetProviderType:
m_AssemblyName:
m_ClassName:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
m_AssetBundleProviderType:
m_AssemblyName:
m_ClassName:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
m_IgnoreUnsupportedFilesInBuild: 0
m_UniqueBundleIds: 0
m_EnableJsonCatalog: 0
@@ -53,7 +53,7 @@ MonoBehaviour:
m_RemoteCatalogBuildPath:
m_Id: 2af7f42f684fd4296a199c9e60624f6f
m_RemoteCatalogLoadPath:
m_Id: 55b13501972b540abb7332a5bad98342
m_Id: 55b13501972b540abb7332a5bad98342
m_ContentStateBuildPathProfileVariableName:
m_CustomContentStateBuildPath:
m_ContentStateBuildPath:
@@ -77,7 +77,7 @@ MonoBehaviour:
- m_Id: 32a6cb134d6b249acba4c1423e9667a4
m_Value: '[UnityEngine.AddressableAssets.Addressables.BuildPath]/[BuildTarget]'
- m_Id: 55b13501972b540abb7332a5bad98342
m_Value: https://assets.eagle0.net/addressables/[BuildTarget]
m_Value: 'https://assets.eagle0.net/addressables/[BuildTarget]'
- m_Id: b347880d4285c4a25a539211b563a545
m_Value: '[UnityEditor.EditorUserBuildSettings.activeBuildTarget]'
m_ProfileEntryNames:
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 0ebe438313ec24b6894ce84cf4c89605
guid: 568267bd7b6fa4581adaa40faa984594
folderAsset: yes
DefaultImporter:
externalObjects: {}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: a1b5efbbd8a2c4df59c9f7d542172b18
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -67,6 +67,18 @@ namespace Auth {
return (response.AuthUrl, response.State);
}
/// <summary>
/// Check OAuth status once (non-polling). Used when processing deep links
/// where we know OAuth has already completed.
/// Routed to Go auth service.
/// </summary>
/// <param name="state">State token from OAuth flow</param>
/// <returns>Response with status, tokens and user info</returns>
public async Task<CheckOAuthStatusResponse> CheckOAuthStatusAsync(string state) {
var request = new CheckOAuthStatusRequest { State = state };
return await _authServiceClient.CheckOAuthStatusAsync(request);
}
/// <summary>
/// Poll for OAuth completion. Blocks until success, failure, or timeout.
/// The server handles the OAuth callback and token exchange.
@@ -187,6 +199,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,10 @@ 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
private string _pendingOAuthState; // Queued if OAuth result arrives before auth
// client ready
// Events for UI updates
public event Action<UserInfo> OnLoginSuccess;
@@ -45,6 +50,175 @@ namespace Auth {
TokenStorage.InitializeCache();
// AuthClient is created lazily when SetServerUrls is called
// 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);
}
// On Windows, URL scheme launches pass the URL as a command line argument
CheckCommandLineForDeepLink();
}
private void CheckCommandLineForDeepLink() {
var args = System.Environment.GetCommandLineArgs();
// Args[0] is the executable path, check remaining args for eagle0:// URLs
for (int i = 1; i < args.Length; i++) {
if (args[i].StartsWith("eagle0://")) {
Debug.Log($"[OAuthManager] Found deep link in command line: {args[i]}");
OnDeepLinkActivated(args[i]);
break;
}
}
}
private void OnDeepLinkActivated(string url) {
Debug.Log($"[OAuthManager] Deep link received: {url}");
// Handle session transfer deep link: eagle0://auth/session/{code}
// This is for existing users - immediate login with session transfer 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 OAuth result deep link: eagle0://auth/result/{state}
// This is for new users - poll CheckOAuthStatus to complete registration
const string resultPrefix = "eagle0://auth/result/";
if (url.StartsWith(resultPrefix)) {
var state = url.Substring(resultPrefix.Length).TrimEnd('/');
Debug.Log($"[OAuthManager] OAuth result deep link received, state: {state}");
WindowFocusManager.BringToForeground();
_ = HandleOAuthResultAsync(state);
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");
WindowFocusManager.BringToForeground();
}
}
/// <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>
/// Handle OAuth result deep link for new users.
/// Polls CheckOAuthStatus to complete registration flow (invitation validation, display
/// name).
/// </summary>
private async Task HandleOAuthResultAsync(string state) {
if (_authClient == null) {
// Auth client not ready yet - queue the state for later processing
Debug.Log("[OAuthManager] Auth client not ready, queuing OAuth state for later");
_pendingOAuthState = state;
return;
}
try {
Debug.Log($"[OAuthManager] Checking OAuth status for state: {state}");
var response = await _authClient.CheckOAuthStatusAsync(state);
switch (response.Status) {
case OAuthStatus.Success:
// Store tokens
var providerName = response.User.Provider.ToString().ToLowerInvariant();
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName,
providerName);
// Check if new user needs display name
if (response.IsNewUser || string.IsNullOrEmpty(response.User.DisplayName)) {
Debug.Log("[OAuthManager] New user needs to set display name");
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
Debug.Log(
$"[OAuthManager] OAuth successful for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
}
break;
case OAuthStatus.InvitationRequired:
Debug.Log("[OAuthManager] Invitation code required for new account");
OnLoginFailed?.Invoke(
"An invitation code is required to create a new account.");
break;
case OAuthStatus.Failed:
Debug.LogWarning($"[OAuthManager] OAuth failed: {response.ErrorMessage}");
OnLoginFailed?.Invoke(response.ErrorMessage);
break;
case OAuthStatus.Expired:
Debug.LogWarning("[OAuthManager] OAuth session expired");
OnLoginFailed?.Invoke("Sign-in session expired. Please try again.");
break;
case OAuthStatus.Pending:
// This shouldn't happen since OAuth already completed, but handle it
Debug.LogWarning("[OAuthManager] OAuth still pending - unexpected");
OnLoginFailed?.Invoke("Sign-in incomplete. Please try again.");
break;
default:
Debug.LogWarning($"[OAuthManager] Unknown OAuth status: {response.Status}");
OnLoginFailed?.Invoke("Sign-in failed. Please try again.");
break;
}
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] OAuth status check failed: {ex.Message}");
OnLoginFailed?.Invoke($"Sign-in failed: {ex.Message}");
}
}
/// <summary>
@@ -64,9 +238,27 @@ 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);
}
// Process any pending OAuth state that arrived before auth client was ready
if (!string.IsNullOrEmpty(_pendingOAuthState)) {
var state = _pendingOAuthState;
_pendingOAuthState = null;
Debug.Log("[OAuthManager] Processing queued OAuth state");
_ = HandleOAuthResultAsync(state);
}
}
private void OnDestroy() {
Application.deepLinkActivated -= OnDeepLinkActivated;
SingleInstanceEnforcer.OnDeepLinkReceived -= OnDeepLinkActivated;
_authClient?.Dispose();
if (Instance == this) { Instance = null; }
}
@@ -80,7 +272,9 @@ namespace Auth {
/// <summary>
/// Start OAuth login with specified provider.
/// Opens system browser for user consent, then polls for completion.
/// Opens system browser for user consent, then quits the app.
/// The browser's "Return to Eagle" deep link will relaunch the app
/// with the auth session, which HandleSessionTransferAsync will process.
/// </summary>
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
@@ -90,41 +284,23 @@ namespace Auth {
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
// Minimize game window so user can see the browser
WindowFocusManager.MinimizeForExternalBrowser();
// Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
Debug.Log($"[OAuthManager] Opening browser for OAuth: {authUrl}");
Application.OpenURL(authUrl);
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// Quit the app - the browser's "Return to Eagle" deep link will
// relaunch a fresh instance with the auth session code.
// This avoids the dual-instance problem where both the original
// app (waiting/polling) and the deep-linked app are running.
Debug.Log("[OAuthManager] Quitting app - will relaunch via deep link after OAuth");
Application.Quit();
// Bring game back to foreground now that auth is complete
WindowFocusManager.BringToForeground();
// Store tokens with provider info
var providerName = provider.ToString().ToLowerInvariant();
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName ?? "",
providerName);
if (response.IsNewUser) {
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
OnLoginSuccess?.Invoke(response.User);
}
return response;
// This code won't execute since we're quitting, but return null
// to satisfy the compiler. The new instance will handle auth
// via OnDeepLinkActivated -> HandleSessionTransferAsync.
return null;
} catch (Exception ex) {
// Bring game back to foreground even on failure
WindowFocusManager.BringToForeground();
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
Debug.LogWarning($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
throw;
}
@@ -184,6 +360,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();
@@ -274,6 +457,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.");
@@ -0,0 +1,9 @@
/// <summary>
/// Build information generated at build time.
/// This file is auto-generated by BuildInfoGenerator - do not edit manually.
/// </summary>
public static class BuildInfo {
public const string GitCommitHash = "development";
public const string GitCommitShort = "dev";
public const string BuildTimestamp = "";
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9f6bcf450bd4543d39dbba8e09b7605c
@@ -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;
}
@@ -136,7 +136,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Canvas connectionCanvas;
public Canvas eagleCanvas;
public Canvas shardokCanvas;
public GameObject shardokContainer;
public CustomBattleHandler customBattleHandler;
List<GameInfo> fetchedRunningGames;
@@ -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;
@@ -206,7 +204,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
connectionCanvas.gameObject.SetActive(true);
eagleCanvas.gameObject.SetActive(false);
shardokCanvas.gameObject.SetActive(false);
shardokContainer.SetActive(false);
connectionCanvas.enabled = true;
connectionPanel.gameObject.SetActive(true);
@@ -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 = ""; }
}
}
@@ -478,13 +494,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (oauthStatusText != null) { oauthStatusText.text = "Checking for existing session..."; }
// TryRestoreSessionAsync() fires OnLoginSuccess if successful, which triggers
// ConnectWithOAuth() via the OnOAuthLoginSuccess handler. We only need to clear
// the status text if restore failed (user not logged in).
var restored = await OAuthManager.Instance.TryRestoreSessionAsync();
if (restored) {
// Session restored, connect to lobby
ConnectWithOAuth();
} else if (oauthStatusText != null) {
oauthStatusText.text = "";
}
if (!restored && oauthStatusText != null) { oauthStatusText.text = ""; }
}
private async void OnOAuthLoginClicked(OAuthProvider provider) {
@@ -499,7 +513,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
await OAuthManager.Instance.LoginAsync(provider);
// OnLoginSuccess or OnNewUserNeedsDisplayName will be called
} catch (Exception ex) {
Debug.LogError($"OAuth login failed: {ex.Message}");
Debug.LogWarning($"OAuth login failed: {ex.Message}");
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {ex.Message}"; }
}
}
@@ -507,6 +521,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();
});
}
@@ -603,7 +624,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public void CancelCustomBattle() {
customBattlePanel.SetActive(false);
shardokCanvas.gameObject.SetActive(false);
shardokContainer.SetActive(false);
connectionCanvas.gameObject.SetActive(true);
gameSelectionPanel.SetActive(true);
StartListeningForLobbyUpdates();
@@ -820,6 +841,19 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
EagleConnection.EagleCancellationToken);
_persistentClientConnection.OnChannelDead = () => {
// Queue to main thread to avoid threading issues
MainQueue.Q.Enqueue(() => {
Debug.Log("[ConnectionHandler] Channel dead, recreating connection");
_createConnection();
});
};
_persistentClientConnection.OnCommandError = (errorMessage) => {
// Queue to main thread to display error via ErrorHandler
MainQueue.Q.Enqueue(() => {
Debug.LogError($"[ConnectionHandler] Command rejected by server: {errorMessage}");
});
};
_persistentClientConnection.Connect();
errorHandler.PersistentClientConnection = _persistentClientConnection;
@@ -860,13 +894,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);
@@ -22,7 +22,7 @@ using VictoryCondition = Net.Eagle0.Common.VictoryCondition;
public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
public GameObject yourArmiesArea;
public GameObject aiArmiesArea;
public Canvas shardokCanvas;
public GameObject shardokContainer;
public Toggle defenderToggle;
public TMP_Dropdown mapDropdown;
@@ -103,8 +103,8 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
heroImages: _heroImages,
battalionNames: new Dictionary<int, string> { { 0, "no name" } });
shardokCanvas.gameObject.SetActive(true);
shardokCanvas.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
shardokContainer.SetActive(true);
shardokContainer.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
GameId = newGameResponse.EagleGameId;
Register();
@@ -438,6 +438,9 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
public GameId GameId { get; private set; }
public GameId? CurrentEagleToken => null;
// CustomBattleHandler doesn't use Eagle commands, so this is unused
public Func<long, bool> HasPendingCommandWithToken { get; set; }
public int LastUnfilteredResultCount => 0;
public List<IClientConnectionSubscriber.ShardokViewStatus> ShardokViewStatuses =>
new() { new() {
@@ -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;
@@ -58,6 +58,8 @@ namespace eagle {
return;
}
// Pass layout mode to selector before setting up UI
Selector.CurrentLayoutMode = mainController.CurrentLayoutMode;
Selector.UpdateAvailableCommand(Model, AvailableCommand);
headerLabel.text = Selector.HeaderString;
} catch (Exception e) { errorHandler.Add(e); }
@@ -47,8 +47,9 @@ namespace eagle {
AlmsCommand.AvailableHeroIds.Select(hid => _model.Heroes[hid]).ToList();
foodSlider.maxValue = AvailableFoodAmount;
foodSlider.value = 0;
foodGivenLabel.text = "0";
var defaultAmount = Math.Min(1000, AvailableFoodAmount);
foodSlider.value = defaultAmount;
foodGivenLabel.text = defaultAmount.ToString();
}
public void SelectedProvinceChanged(int newValue) {
@@ -14,6 +14,19 @@ namespace eagle {
public abstract class CommandSelector : MonoBehaviour {
public Texture ButtonImage;
/// <summary>
/// Current layout mode. Set by CommandPanelController before SetUpUI is called.
/// Selectors can use this to adjust their UI for narrow screens.
/// </summary>
public LayoutMode CurrentLayoutMode { get; set; } = LayoutMode.Medium;
/// <summary>
/// True when in narrow layout mode (iPad-like 4:3 aspect ratios).
/// In narrow mode, selectors should hide hero/battalion pickers since
/// users can select via the Resident Heroes and Battalions panels.
/// </summary>
protected bool IsNarrowLayout => CurrentLayoutMode == LayoutMode.Narrow;
public abstract AvailableCommand.SealedValueOneofCase CommandType { get; }
public virtual bool IsAppropriateFor(AvailableCommand cmd) => cmd.SealedValueCase
== CommandType;
@@ -5,7 +5,7 @@ using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Api.Command.Util;
using Net.Eagle0.Eagle.Common;
using TMPro;
using UnityEditor;
using UnityEngine;
namespace eagle {
using BattalionId = Int32;
@@ -25,6 +25,10 @@ namespace eagle {
public EventBasedUnitSelector unitSelector;
public TMP_Dropdown rallyPointDropdown;
// Columns to hide in narrow layout mode (users select via side panels instead)
public GameObject heroesColumn;
public GameObject battalionsColumn;
public override AvailableCommand.SealedValueOneofCase CommandType =>
AvailableCommand.SealedValueOneofCase.DefendCommand;
public override string HeaderString => "Defend";
@@ -128,6 +132,10 @@ namespace eagle {
}
protected override void SetUpUI() {
// Hide hero/battalion columns in narrow mode - users select via side panels
if (heroesColumn != null) heroesColumn.SetActive(!IsNarrowLayout);
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
if (DefendCommand.AvailableFleeProvinceIds.Count > 0) {
var availableRallyPointProvinceNames =
DefendCommand.AvailableFleeProvinceIds
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Api.Command.Util;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
@@ -139,7 +141,7 @@ namespace eagle {
.Select(name => new TMP_Dropdown.OptionData(name))
.ToList();
MessageText.text = "";
MessageText.text = GetQuestMessagesForPrisoner(SelectedHero.Id);
// The order here is important: the last available toggle in this list
// will be on by default
@@ -165,6 +167,64 @@ namespace eagle {
PrisonerManagementOption.SealedValueOneofCase.Release);
}
private string GetQuestMessagesForPrisoner(HeroId prisonerId) {
var questMessages = new List<string>();
foreach (var province in _model.Provinces.Values) {
if (province.FullInfo?.UnaffiliatedHeroes == null) continue;
foreach (var uh in province.FullInfo.UnaffiliatedHeroes) {
if (uh.RecruitmentInfo?.Status != RecruitmentStatus.HasQuest) continue;
var quest = uh.RecruitmentInfo.Quest;
if (quest?.Details == null) continue;
var (matches, actionVerb) = GetPrisonerQuestInfo(quest.Details, prisonerId);
if (!matches) continue;
var heroName = GetHeroDisplayName(uh.HeroId);
questMessages.Add($"{heroName} wants you to {actionVerb} this prisoner.");
}
}
return questMessages.Count > 0 ? string.Join("\n", questMessages) : "";
}
private (bool matches, string actionVerb)
GetPrisonerQuestInfo(QuestDetails details, HeroId prisonerId) {
switch (details.SealedValueCase) {
case QuestDetails.SealedValueOneofCase.ExecutePrisonerQuest:
if (details.ExecutePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "execute");
}
break;
case QuestDetails.SealedValueOneofCase.ExilePrisonerQuest:
if (details.ExilePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "exile");
}
break;
case QuestDetails.SealedValueOneofCase.FreePrisonerQuest:
if (details.FreePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "release");
}
break;
case QuestDetails.SealedValueOneofCase.ReturnPrisonerQuest:
if (details.ReturnPrisonerQuest.PrisonerHeroId == prisonerId) {
var factionName =
_model.FactionName(details.ReturnPrisonerQuest.ToFactionId);
return (true, $"return to {factionName}");
}
break;
}
return (false, null);
}
private string GetHeroDisplayName(HeroId heroId) {
if (!_model.Heroes.TryGetValue(heroId, out var hero)) { return "A free hero"; }
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
return textEntry != null && !string.IsNullOrEmpty(textEntry.Text) ? textEntry.Text
: "A free hero";
}
private bool ConditionallyEnable(
Toggle toggle,
PrisonerManagementOption.SealedValueOneofCase option) {
@@ -7,6 +7,7 @@ using Net.Eagle0.Eagle.Api.Command.Util;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
@@ -38,6 +39,10 @@ namespace eagle {
public EventBasedUnitSelector unitSelector;
public TMP_Dropdown fromDropdown;
public TMP_Dropdown toDropdown;
// Columns to hide in narrow layout mode (users select via side panels instead)
public GameObject heroesColumn;
public GameObject battalionsColumn;
public Slider goldSlider;
public Slider foodSlider;
public TMP_Text goldTakenLabel;
@@ -121,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 ||
@@ -294,6 +302,10 @@ namespace eagle {
}
protected override void SetUpUI() {
// Hide hero/battalion columns in narrow mode - users select via side panels
if (heroesColumn != null) heroesColumn.SetActive(!IsNarrowLayout);
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
_sortedOneProvinceCommands =
MarchAvailableCommand.OneProvinceCommands
.OrderBy(cmd => _model.Provinces[cmd.OriginProvinceId].Name)
@@ -20,9 +20,42 @@ namespace eagle {
public override string HeaderString =>
$"Suppress {DisplayNames.Capitalized(BeastTypeName)}";
public override string CommitButtonString => "Commit Suppression";
public override bool WarnOnCommitButton => !SelectedBattalionId.HasValue;
public override string CommitWarningText =>
"You aren't sending a battalion. Your hero may be killed.";
public override bool WarnOnCommitButton =>
!SelectedBattalionId.HasValue || IsSendingFactionLeader;
public override string CommitWarningText {
get {
if (!SelectedBattalionId.HasValue && IsSendingFactionLeader) {
return $"You aren't sending a battalion, and you're risking your {FactionLeaderTitle}!";
}
if (!SelectedBattalionId.HasValue) {
return "You aren't sending a battalion. Your hero may be killed.";
}
if (IsSendingFactionLeader) { return $"You're risking your {FactionLeaderTitle}!"; }
return null;
}
}
private string FactionLeaderTitle {
get {
if (_model?.Heroes == null ||
!_model.Heroes.TryGetValue(SelectedHeroId, out var hero)) {
return "faction leader";
}
return $"sworn {DisplayNames.SiblingDescription(hero.PronounGender)}";
}
}
private bool IsSendingFactionLeader {
get {
if (_model?.PlayerId == null) return false;
if (!_model.ActiveFactions.TryGetValue(
_model.PlayerId.Value,
out var playerFaction)) {
return false;
}
return SelectedHeroId == playerFaction.FactionHeadId;
}
}
public override List<HeroId> TargetedHeroIds => new List<HeroId> { SelectedHeroId };
public override bool HeroIsTargetable(HeroId heroId) =>
@@ -15,6 +15,7 @@ namespace eagle {
public TMP_Text goldText;
public TMP_Text foodText;
public TMP_Text description;
public RectTransform startingPositionIndicator;
public override AvailableCommand.SealedValueOneofCase CommandType =>
AvailableCommand.SealedValueOneofCase.TradeCommand;
@@ -40,6 +41,14 @@ namespace eagle {
buyPrice.text = $"{TradeCommand.FoodBuyPrice:0.##}";
sellPrice.text = $"{TradeCommand.FoodSellPrice:0.##}";
// Position the starting position indicator
if (startingPositionIndicator != null && MaxFood > 0) {
float normalizedStart = (float)TradeCommand.FoodAvailable / MaxFood;
startingPositionIndicator.anchorMin = new Vector2(normalizedStart, 0);
startingPositionIndicator.anchorMax = new Vector2(normalizedStart, 1);
startingPositionIndicator.anchoredPosition = Vector2.zero;
}
SetTextFields();
}
@@ -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();
@@ -155,6 +155,8 @@ namespace eagle {
"<color=green>●</color> Generating...",
ServerGameStatus.Types.Status.ProcessingAction =>
"<color=green>●</color> Processing...",
ServerGameStatus.Types.Status.BattleInProgress =>
"<color=green>●</color> Battle in progress",
_ => "<color=green>●</color> Connected"
};
}
@@ -158,6 +158,11 @@ namespace eagle {
case SealedValueOneofCase.ReturnPrisonerQuest: return "Return Prisoner";
case SealedValueOneofCase.ExilePrisonerQuest: return "Exile Prisoner";
case SealedValueOneofCase.ExecutePrisonerQuest: return "Execute Prisoner";
case SealedValueOneofCase.RescueImprisonedLeaderQuest: return "Rescue Hero";
case SealedValueOneofCase.ExpandToProvincesQuest: return "Expand to Provinces";
case SealedValueOneofCase.TotalDevelopmentQuest: return "Total Development";
case SealedValueOneofCase.SuppressRiotByForceQuest: return "Suppress Riot";
case SealedValueOneofCase.FightBeastsAloneQuest: return "Fight Beasts Alone";
}
throw new ArgumentException($"unknown quest {quest}");
@@ -19,6 +19,14 @@ namespace eagle {
using FactionId = Int32;
using ProvinceId = Int32;
/// <summary>
/// Layout mode based on aspect ratio:
/// - Wide: Ultra-wide screens (ratio > 2.0), factions/armies column on right
/// - Medium: Standard widescreen (ratio >= 1.35), default layout
/// - Narrow: iPad-like 4:3 (ratio < 1.35), compact command panels
/// </summary>
public enum LayoutMode { Wide, Medium, Narrow }
public class EagleGameController : MonoBehaviour {
public SoundManager soundManager;
@@ -26,21 +34,26 @@ namespace eagle {
public TextMeshProUGUI connectionStatusLabel;
private bool _connectionStatusUIInitialized = false;
public GameObject alwaysOnLeftColumn;
public GameObject factionsAndMovingArmiesRow;
public GameObject wideScreenRightColumn;
public GameObject wideScreenBottomRow;
public ProvinceInfoPanelController provinceInfoPanelController;
public FreeHeroesTableController freeHeroesTableController;
public FactionsTableController factionsTableController;
public MovingArmiesTableController movingArmiesTableController;
// Containers for factions & moving armies panels (enable/disable these)
public GameObject factionsAndMovingArmiesNarrow; // Row beneath Free Heroes
public GameObject factionsAndMovingArmiesWide; // Column to right of Province Info
// Controller references for updating data (panels inside containers)
public FactionsTableController factionsTableControllerNarrow;
public MovingArmiesTableController movingArmiesTableControllerNarrow;
public FactionsTableController factionsTableControllerWide;
public MovingArmiesTableController movingArmiesTableControllerWide;
public HeroesAndBattalionsPanelController heroesAndBattalionsPanelController;
public MapController mapController;
public CommandButtonPanelController commandButtonPanelController;
public DominionPanelController hideableDominionPanelController;
public DominionPanelController wideDominionPanelController;
private DominionPanelController _dominionPanelController;
// Narrow: toggleable above Province Info; Wide: always-on below Free Heroes
public DominionPanelController dominionPanelControllerNarrow;
public DominionPanelController dominionPanelControllerWide;
public ChronicleCanvasController chronicleCanvasController;
public ProvinceId? SelectedProvince => mapController.SelectedProvinceId;
@@ -48,6 +61,12 @@ namespace eagle {
public ErrorHandler errorHandler;
public NotificationPanel notificationPanel;
public GameObject popupCanvas;
public GameObject wideSpacer;
// Widescreen layout: match HeroesAndBattalions width to Popup Canvas spacers
public LayoutElement heroesAndBattalionsLayoutElement;
public LayoutElement popupCanvasLeftSpacer;
public LayoutElement popupCanvasRightSpacer; // Same object as wideSpacer
public Button showChronicleButton;
public Button commitCommandButton;
@@ -70,7 +89,22 @@ namespace eagle {
public CommandWarningPanelController commandWarningPanelController;
private double _aspectRatio;
private bool IsWideScreen => _aspectRatio > 2.0;
private const double WideScreenThreshold = 2.0;
private const double NarrowScreenThreshold = 1.35;
private bool IsWideScreen => _aspectRatio > WideScreenThreshold;
private bool IsNarrowScreen => _aspectRatio < NarrowScreenThreshold;
/// <summary>
/// Current layout mode based on aspect ratio. Accessible to CommandSelectors.
/// </summary>
public LayoutMode CurrentLayoutMode {
get {
if (IsWideScreen) return LayoutMode.Wide;
if (IsNarrowScreen) return LayoutMode.Narrow;
return LayoutMode.Medium;
}
}
private int _lastWidth;
private int _lastHeight;
@@ -81,7 +115,7 @@ namespace eagle {
private DynamicFactionTextUpdater factionTextUpdater = new DynamicFactionTextUpdater();
private IGameModel _newModel = null;
public Canvas shardokCanvas;
public GameObject shardokContainer;
private KeyValuePair<ProvinceId, OneProvinceAvailableCommands>? NextActiveProvinceKeyPair =>
Model == null || Model.AvailableCommandsByProvince.Count == 0
@@ -94,8 +128,7 @@ namespace eagle {
mapController.targetedProvincesChangedCallback = MapControllerChangedTarget;
mapController.hoveredProvinceChangedCallback = MapControllerChangedHover;
popupCanvas.SetActive(true);
_dominionPanelController = hideableDominionPanelController;
_dominionPanelController.gameObject.SetActive(false);
dominionPanelControllerNarrow.gameObject.SetActive(false);
showChronicleButton.gameObject.SetActive(false);
errorHandler.gameObject.SetActive(true);
@@ -125,31 +158,35 @@ namespace eagle {
_aspectRatio = (float)newWidth / (float)newHeight;
if (IsWideScreen) {
factionsTableController.gameObject.transform.SetParent(
wideScreenRightColumn.transform);
factionsTableController.GetComponent<LayoutElement>().flexibleWidth = 1;
movingArmiesTableController.gameObject.transform.SetParent(
wideScreenRightColumn.transform);
movingArmiesTableController.GetComponent<LayoutElement>().flexibleWidth = 1;
wideDominionPanelController.gameObject.SetActive(true);
hideableDominionPanelController.gameObject.SetActive(false);
_dominionPanelController = wideDominionPanelController;
_dominionPanelController.Model = Model;
// Show wide layout containers, hide narrow layout containers
factionsAndMovingArmiesWide.SetActive(true);
factionsAndMovingArmiesNarrow.SetActive(false);
dominionPanelControllerWide.gameObject.SetActive(true);
dominionPanelControllerNarrow.gameObject.SetActive(false);
if (wideSpacer != null) wideSpacer.SetActive(true);
factionsAndMovingArmiesRow.SetActive(false);
// Match HeroesAndBattalions width to Popup Canvas spacers
if (heroesAndBattalionsLayoutElement != null && popupCanvasLeftSpacer != null &&
popupCanvasRightSpacer != null) {
heroesAndBattalionsLayoutElement.preferredWidth =
popupCanvasLeftSpacer.preferredWidth +
popupCanvasRightSpacer.preferredWidth;
}
provinceInfoPanelController.WideScreen = true;
} else {
factionsTableController.gameObject.transform.SetParent(
factionsAndMovingArmiesRow.transform);
factionsTableController.GetComponent<LayoutElement>().flexibleWidth = 0;
movingArmiesTableController.gameObject.transform.SetParent(
factionsAndMovingArmiesRow.transform);
movingArmiesTableController.GetComponent<LayoutElement>().flexibleWidth = 0;
wideDominionPanelController.gameObject.SetActive(false);
_dominionPanelController = hideableDominionPanelController;
_dominionPanelController.Model = Model;
factionsAndMovingArmiesRow.SetActive(true);
// Show narrow layout containers, hide wide layout containers
factionsAndMovingArmiesWide.SetActive(false);
factionsAndMovingArmiesNarrow.SetActive(true);
dominionPanelControllerWide.gameObject.SetActive(false);
// Narrow dominion panel visibility controlled by user toggle, don't change here
if (wideSpacer != null) wideSpacer.SetActive(false);
// Match HeroesAndBattalions width to left spacer only
if (heroesAndBattalionsLayoutElement != null && popupCanvasLeftSpacer != null) {
heroesAndBattalionsLayoutElement.preferredWidth =
popupCanvasLeftSpacer.preferredWidth;
}
provinceInfoPanelController.WideScreen = false;
}
@@ -176,16 +213,21 @@ namespace eagle {
if (_commandPanelController.Selector != null) {
_commandPanelController.Selector.TargetedProvinces = newTarget;
freeHeroesTableController.UpdateTables();
movingArmiesTableController.UpdateTables();
factionsTableController.UpdateTables();
movingArmiesTableControllerNarrow.UpdateTables();
movingArmiesTableControllerWide.UpdateTables();
factionsTableControllerNarrow.UpdateTables();
factionsTableControllerWide.UpdateTables();
heroesAndBattalionsPanelController.UpdateTables();
}
}
void MapControllerChangedHover(ProvinceId? newHover) {
_dominionPanelController.ProvinceHovered(newHover);
movingArmiesTableController.ProvinceHovered(newHover);
factionsTableController.ProvinceHovered(newHover);
dominionPanelControllerNarrow.ProvinceHovered(newHover);
dominionPanelControllerWide.ProvinceHovered(newHover);
movingArmiesTableControllerNarrow.ProvinceHovered(newHover);
movingArmiesTableControllerWide.ProvinceHovered(newHover);
factionsTableControllerNarrow.ProvinceHovered(newHover);
factionsTableControllerWide.ProvinceHovered(newHover);
}
void Update() {
@@ -224,7 +266,8 @@ namespace eagle {
public void ReloadFactionDisplaySetting() {
SetHeaderString();
mapController.ReloadFactionDisplaySetting();
factionsTableController.UpdateTables();
factionsTableControllerNarrow.UpdateTables();
factionsTableControllerWide.UpdateTables();
_commandPanelController.RefreshUI();
}
@@ -321,8 +364,10 @@ namespace eagle {
_commandPanelController.SetAvailableCommandAndSelector(cmd, selector);
mapController.ProvincesWithCommands = new List<ProvinceId>();
movingArmiesTableController.UpdateTables();
factionsTableController.UpdateTables();
movingArmiesTableControllerNarrow.UpdateTables();
movingArmiesTableControllerWide.UpdateTables();
factionsTableControllerNarrow.UpdateTables();
factionsTableControllerWide.UpdateTables();
heroesAndBattalionsPanelController.UpdateTables();
freeHeroesTableController.UpdateUnaffiliatedHeroSelections();
}
@@ -352,21 +397,26 @@ namespace eagle {
var currentProvince = Model.Provinces[pid.Value];
provinceInfoPanelController.Province = currentProvince;
freeHeroesTableController.CurrentProvince = currentProvince;
movingArmiesTableController.CurrentProvince = currentProvince;
factionsTableController.CurrentProvince = currentProvince;
movingArmiesTableControllerNarrow.CurrentProvince = currentProvince;
movingArmiesTableControllerWide.CurrentProvince = currentProvince;
factionsTableControllerNarrow.CurrentProvince = currentProvince;
factionsTableControllerWide.CurrentProvince = currentProvince;
heroesAndBattalionsPanelController.CurrentProvince = currentProvince;
} else {
SelectRecommendedCommand(null);
provinceInfoPanelController.Province = null;
freeHeroesTableController.CurrentProvince = null;
movingArmiesTableController.CurrentProvince = null;
factionsTableController.CurrentProvince = null;
movingArmiesTableControllerNarrow.CurrentProvince = null;
movingArmiesTableControllerWide.CurrentProvince = null;
factionsTableControllerNarrow.CurrentProvince = null;
factionsTableControllerWide.CurrentProvince = null;
heroesAndBattalionsPanelController.CurrentProvince = null;
}
UpdateButtons();
SetNextActiveProvinceButton();
_dominionPanelController.ForceUpdate();
dominionPanelControllerNarrow.ForceUpdate();
dominionPanelControllerWide.ForceUpdate();
SetMusic();
// Notify tutorial system of province selection
@@ -494,14 +544,20 @@ namespace eagle {
provinceInfoPanelController.Model = _newModel;
freeHeroesTableController.Model = _newModel;
movingArmiesTableController.Model = _newModel;
factionsTableController.Model = _newModel;
movingArmiesTableControllerNarrow.Model = _newModel;
movingArmiesTableControllerWide.Model = _newModel;
factionsTableControllerNarrow.Model = _newModel;
factionsTableControllerWide.Model = _newModel;
heroesAndBattalionsPanelController.Model = _newModel;
_commandPanelController.Model = _newModel;
_dominionPanelController.Model = _newModel;
dominionPanelControllerNarrow.Model = _newModel;
dominionPanelControllerWide.Model = _newModel;
mapController.Model = _newModel;
if (Model == null) { return; }
if (Model == null) {
goToBattleButton.gameObject.SetActive(false);
return;
}
// Start onboarding after first model update (UI panels are now populated)
if (oldModel == null && TutorialManager.Instance != null &&
@@ -616,8 +672,10 @@ namespace eagle {
}
freeHeroesTableController.UpdateTables();
movingArmiesTableController.UpdateTables();
factionsTableController.UpdateTables();
movingArmiesTableControllerNarrow.UpdateTables();
movingArmiesTableControllerWide.UpdateTables();
factionsTableControllerNarrow.UpdateTables();
factionsTableControllerWide.UpdateTables();
heroesAndBattalionsPanelController.UpdateTables();
Canvas.ForceUpdateCanvases();
@@ -664,8 +722,10 @@ namespace eagle {
mapController.TargetedProvinces = new List<ProvinceId>();
commandButtonPanelController.ClearButtonContents();
freeHeroesTableController.UpdateTables();
movingArmiesTableController.UpdateTables();
factionsTableController.UpdateTables();
movingArmiesTableControllerNarrow.UpdateTables();
movingArmiesTableControllerWide.UpdateTables();
factionsTableControllerNarrow.UpdateTables();
factionsTableControllerWide.UpdateTables();
heroesAndBattalionsPanelController.UpdateTables();
}
@@ -682,6 +742,12 @@ namespace eagle {
// Hide the button now, so if we get an exception below we can at least recover
goToBattleButton.gameObject.SetActive(false);
if (Model == null) { throw new InvalidOperationException("GoToBattle: Model is null"); }
if (shardokContainer == null) {
throw new InvalidOperationException("GoToBattle: shardokContainer is null");
}
ShardokGameModel selectedModel;
var runningBattles = Model.RunningShardokGameModels.Values;
@@ -691,14 +757,19 @@ namespace eagle {
} else if (runningBattles.Any()) {
selectedModel = runningBattles.First();
} else {
throw new ApplicationException(
"Go to Battle button clicked, but there are no battles");
throw new InvalidOperationException(
"GoToBattle: No battles in RunningShardokGameModels");
}
var shardokModel = Model.ShardokGameModels[selectedModel.ShardokGameId];
shardokCanvas.gameObject.SetActive(true);
var shardokController = shardokCanvas.GetComponent<ShardokGameController>();
shardokContainer.SetActive(true);
var shardokController = shardokContainer.GetComponent<ShardokGameController>();
if (shardokController == null) {
throw new InvalidOperationException(
"GoToBattle: shardokContainer missing ShardokGameController component");
}
shardokController.SetUpGame(shardokModel);
shardokController.SetConnection(errorHandler.PersistentClientConnection);
gameObject.SetActive(false);
@@ -80,6 +80,14 @@ namespace eagle {
public Action<IGameModel> UpdateAction { get; set; }
public ReceiveNotes NoteRecipient { get; set; }
/// <summary>
/// Callback to check if there's a pending command with the specified token.
/// Set by PersistentClientConnection. When this returns true, HandleAvailableCommands
/// will set the token (for TryPendingCommands matching) but not load the commands,
/// since the pending command will be posted and we'll get fresh commands after.
/// </summary>
public Func<long, bool> HasPendingCommandWithToken { get; set; }
private void Notify(Notification notification) {
NoteRecipient(
notification.Title,
@@ -138,6 +146,11 @@ namespace eagle {
private DateTime? _commandSubmittedTime;
private const double ProcessingDisplayDelayMs = 500.0;
// Turn mismatch detection: server says YourTurn but client has no commands
// Track when mismatch was first detected to avoid false positives during normal updates
private DateTime? _turnMismatchDetectedTime;
private const double TurnMismatchGracePeriodSeconds = 3.0;
// Track which Shardok games need full state resync after connection drop
// Thread-safe: accessed from both connection thread and Unity main thread
private readonly ConcurrentDictionary<string, bool> _shardokNeedsResync =
@@ -292,18 +305,18 @@ namespace eagle {
public void ReceiveGameUpdate(GameUpdate updateItem) {
if (updateItem.StartingState != null) { HandleStartingState(updateItem.StartingState); }
// Store server-reported game status for UI (available on any update type)
if (updateItem.ServerGameStatus != null) {
ServerStatus = updateItem.ServerGameStatus;
_connectionLogger.LogLine(
$"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
}
switch (updateItem.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
// Clear processing state - we received a response from the server
_commandSubmittedTime = null;
// Store server-reported game status for UI
if (updateItem.ActionResultResponse.ServerGameStatus != null) {
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
_connectionLogger.LogLine(
$"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
}
// Note: _lastUnfilteredResultCount is updated on the gRPC thread in
// UpdateResultCounts() before enqueueing. We don't update it here to avoid
// race conditions where a backlogged MainQueue update overwrites a newer count.
@@ -332,6 +345,9 @@ namespace eagle {
"[UPDATE] SKIPPED - no results, has commands, token matches");
}
// Check for turn state mismatch after processing update
CheckForTurnMismatch();
break;
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
@@ -523,18 +539,27 @@ namespace eagle {
// Clear all stale state before applying new starting state.
// This handles rewind/reset scenarios where the server sends an earlier state.
// NOTE: We intentionally preserve LastPostedToken - if we have a pending command
// from before the reconnect, we need to detect when the server sends us the same
// token so we can skip loading those stale commands.
_currentModel.ShardokGameModels.Clear();
_shardokResultCounts.Clear();
_shardokNeedsResync.Clear();
_currentModel.CommandToken = null;
_currentModel.LastPostedToken = -1;
_currentModel.AvailableCommandsByProvince.Clear();
_commandSubmittedTime = null;
_turnMismatchDetectedTime = null;
_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;
@@ -582,9 +607,17 @@ namespace eagle {
}
if (availableCommands.Token == _currentModel.LastPostedToken) {
// We're receiving a token for a command we already posted. That probably indicates
// a race, where we're receiving a game update from some other source before our
// last command was processed.
// We're receiving a token for a command we already posted. This can happen in two
// scenarios:
// 1. Race condition: receiving a game update from another source before our command
// was processed.
// 2. Our POST failed (e.g., due to disconnect) and the server is re-sending the
// same
// token after reconnection.
//
// In case 1, if we already have commands loaded, that's unexpected - throw.
// In case 2, we have no commands and should accept the server's commands rather
// than skipping, otherwise we'll be stuck with no commands.
if (_currentModel.CommandToken != null ||
_currentModel.AvailableCommandsByProvince.Count > 0) {
@@ -592,9 +625,12 @@ namespace eagle {
$"Received token {availableCommands.Token} that matches last posted token, but we already have commands: {_currentModel.AvailableCommandsByProvince}");
}
// Don't skip - accept the commands. If we successfully posted before, the server
// will return BAD_TOKEN when we try to post again. But if our POST failed, we need
// these commands to proceed.
_connectionLogger.LogLine(
$"Received token {availableCommands.Token} that matches last posted token; skipping");
return;
$"Received token {availableCommands.Token} that matches last posted token; accepting commands (POST may have failed)");
_currentModel.LastPostedToken = -1; // Clear to avoid future skips
}
_connectionLogger.LogLine(
@@ -615,6 +651,19 @@ namespace eagle {
_currentModel.CommandToken = availableCommands.Token;
// Check if there's a pending command with this token waiting to be posted.
// If so, don't load the commands - they're stale (we already acted on them).
// The pending command will be posted by TryPendingCommands, and we'll get
// fresh commands after it's processed.
if (HasPendingCommandWithToken != null &&
HasPendingCommandWithToken(availableCommands.Token)) {
_connectionLogger.LogLine(
$"Token {availableCommands.Token} matches pending command; " +
"skipping command load, pending command will be posted");
_currentModel.AvailableCommandsByProvince.Clear();
return;
}
// does this not clear existing UX if there's a token replacement?
_currentModel.AvailableCommandsByProvince =
new Dictionary<UnitId, OneProvinceAvailableCommands>(
@@ -925,6 +974,69 @@ namespace eagle {
(DateTime.UtcNow - _commandSubmittedTime.Value).TotalMilliseconds >
ProcessingDisplayDelayMs;
#endregion
#region Turn Mismatch Detection
/// <summary>
/// Checks for turn state mismatch: server reports YourTurn but client has no commands.
/// If mismatch persists beyond the grace period, logs an error and triggers reconnection.
/// </summary>
private void CheckForTurnMismatch() {
// Only check if we have server status
if (ServerStatus == null) {
_turnMismatchDetectedTime = null;
return;
}
bool serverSaysYourTurn = ServerStatus.Status == ServerGameStatus.Types.Status.YourTurn;
bool clientHasNoCommands = _currentModel.AvailableCommandsByProvince.Count == 0 &&
!_currentModel.CommandToken.HasValue;
bool isProcessingCommand = _commandSubmittedTime.HasValue;
// Mismatch: server says it's our turn but we have no commands to act on
// Exclude cases where we just submitted a command (waiting for response)
bool hasMismatch = serverSaysYourTurn && clientHasNoCommands && !isProcessingCommand;
if (!hasMismatch) {
// No mismatch - reset detection state
_turnMismatchDetectedTime = null;
return;
}
// Mismatch detected - track when it started
if (!_turnMismatchDetectedTime.HasValue) {
_turnMismatchDetectedTime = DateTime.UtcNow;
_connectionLogger.LogLine(
"[TURN_MISMATCH] Detected: server says YourTurn but client has no commands. " +
"Waiting for grace period...");
return;
}
// Check if grace period has elapsed
var elapsedSeconds = (DateTime.UtcNow - _turnMismatchDetectedTime.Value).TotalSeconds;
if (elapsedSeconds < TurnMismatchGracePeriodSeconds) { return; }
// Grace period exceeded - this is a real mismatch, trigger recovery
_connectionLogger.LogLine(
$"[TURN_MISMATCH] ERROR: Turn mismatch persisted for {elapsedSeconds:F1}s. " +
$"ServerStatus={ServerStatus.Status}, " +
$"AvailableCommands={_currentModel.AvailableCommandsByProvince.Count}, " +
$"CommandToken={_currentModel.CommandTokenString}, " +
$"LastPostedToken={_currentModel.LastPostedToken}. " +
"Triggering reconnection to recover state.");
// Throw exception for ErrorHandler to capture (provides stack trace for debugging)
var errorMessage =
$"Turn mismatch detected: server reports YourTurn but client has no commands " +
$"after {elapsedSeconds:F1}s. Reconnecting to recover state.";
ErrorHandler?.Add(new InvalidOperationException(errorMessage));
// Reset detection state and trigger reconnection
_turnMismatchDetectedTime = null;
PersistentConnection.ForceReconnect();
}
#endregion
}
}
@@ -9,6 +9,13 @@ namespace eagle {
public Int64? CurrentEagleToken { get; }
public Int64? CurrentShardokToken(string shardokGameId);
/// <summary>
/// Callback to check if there's a pending command with the specified token.
/// Set by PersistentClientConnection. Used to skip loading stale commands
/// when a pending command is about to be posted.
/// </summary>
public Func<long, bool> HasPendingCommandWithToken { get; set; }
public void ReceiveGameUpdate(GameUpdate update);
/// <summary>
@@ -14,6 +14,7 @@ namespace eagle {
public RawImage mapBWImage;
public List<RawImage> provinceImagesList;
public ProvinceWeatherController weatherController;
public TMP_Text provinceNameText;
public TMP_Text provinceOwnerText;
@@ -111,6 +112,8 @@ namespace eagle {
SetDefaultProvinceColor(province.Id);
}
if (weatherController != null) { weatherController.UpdateWeatherEffects(value); }
eagleGameController.ProvinceWasSelected(_selectedProvinceId);
}
}

Some files were not shown because too many files have changed in this diff Show More