Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 0a612d614e Fix auth service deployment not updating container
The crane+docker-load approach was causing image tagging issues where the
loaded image didn't have the expected registry tag, causing docker-compose
to not find the correct image.

Changes:
- Use simple `docker pull` instead of crane pull + docker load (since
  deploy server is already logged into the registry)
- Add verification that the running container is using the expected image
- Fail the workflow if container is running the wrong image

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:40:04 -08:00
adminandClaude Opus 4.5 bdd985cd06 Add Apple OAuth debugging and GitHub email fetch
- Add comprehensive logging to Apple callback handler
- Fetch GitHub email from /user/emails when not in main response

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:28:20 -08:00
cc5c6422bd Improve OAuth flow by minimizing game before opening browser (#5363)
When user clicks an OAuth login button:
- Game window minimizes (Windows) or exits fullscreen (macOS/Linux)
- Browser opens and is immediately visible to user
- After auth completes (success or failure), game returns to foreground
- Fullscreen mode is restored if it was enabled

Adds WindowFocusManager utility with platform-specific native calls:
- Windows: P/Invoke to user32.dll (ShowWindow, SetForegroundWindow)
- macOS: Placeholder for native plugin, falls back to fullscreen toggle

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:22:23 -08:00
c71bf10c0e Add scalaActionResult to ActionWithResultingState and use Scala types in ActionResultFilter (#5362)
- Add precomputedScalaActionResult parameter and lazy scalaActionResult property to
  ActionWithResultingState, following the same pattern as scalaGameState
- Update GameHistory.withNewResultsScala to preserve Scala ActionResultT to avoid
  re-conversion
- Convert ActionResultFilter.includeForPlayer to use Scala types (ActionResultT,
  NotificationT, ActionResultType) instead of proto types
- Add UNIVERSALLY_VISIBLE_TYPES_SCALA constant with Scala ActionResultType values

This continues Phase 10 of the deproto migration, moving internal logic to use
Scala types while keeping proto for boundaries.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:18:52 -08:00
fd94d2ac8b Fetch GitHub email from /user/emails endpoint (#5358)
* Use single quotes in .env to handle JSON and special chars

Double quotes don't work when values contain embedded quotes
(like JWT_PRIVATE_KEY JSON). Single quotes treat content literally.

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

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

* Use exported env vars instead of .env file for deployment

The .env file approach was fragile for complex values like JSON
(JWT_PRIVATE_KEY) and base64 (APPLE_SIGNIN_PRIVATE_KEY).

Export environment variables directly in the deploy script so
docker compose reads them from the shell environment.

Also adds Apple Sign-In credentials to auth_build.yml workflow.

Note: Delete /opt/eagle0/.env on the server before deploying.

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

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

* Fetch GitHub email from /user/emails endpoint

GitHub only returns email in the /user endpoint if the user has made
their email public. For private emails, we need to call /user/emails.

This ensures we get the user's primary verified email even when they
have their email set to private on GitHub.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:40:10 -08:00
46f3c52174 Make GameStateViewFilter and GameStateViewDiffer protoless (#5360)
This PR completes Phase 7 of the deproto migration by making the view
filter and differ components work with Scala types internally, converting
to proto only at boundaries.

## New Scala Types

- `GameStateView` - full game state view
- `GameStateViewDiff` - diff between two game state views
- `ProvinceViewDiff` - diff for province views
- `HeroViewDiff` - diff for hero views
- `FactionViewDiff` - diff for faction views
- `FullProvinceInfoDiff` - diff for full province info
- `ShardokBattleView` - battle view type
- `Hostility` - enum for hostility levels

## New Converters

- `GameStateViewConverter` - converts Scala GameStateView to proto
- `GameStateViewDiffConverter` - converts Scala GameStateViewDiff to proto
- `ProvinceViewDiffConverter` - converts Scala ProvinceViewDiff to proto
- `HeroViewDiffConverter` - converts Scala HeroViewDiff to proto
- `FactionViewDiffConverter` - converts Scala FactionViewDiff to proto
- `ShardokBattleViewConverter` - converts Scala ShardokBattleView to proto
- `HostilityConverter` - converts Scala Hostility to proto

## Updated Components

- `GameStateViewFilter` - now returns Scala `GameStateView`
- `GameStateViewDiffer` - now uses Scala diff types internally
- `ActionResultFilter` - converts Scala diff to proto at boundary
- `HumanPlayerClientConnectionState` - converts Scala GameStateView to proto

## Test Updates

- Updated tests to use Scala `ProvinceOrderType` instead of proto

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:39:37 -08:00
8e952c3611 Add validation for empty hexMapName when creating battles (#5359)
Fail fast with a clear error message if a province has an empty
hexMapName when a battle is being created. Previously this would
fail downstream in Shardok with a generic "Must include map path"
error, making it harder to diagnose.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:23:12 -08:00
a61be38ea6 Use exported env vars instead of .env file for deployment (#5357)
* Use single quotes in .env to handle JSON and special chars

Double quotes don't work when values contain embedded quotes
(like JWT_PRIVATE_KEY JSON). Single quotes treat content literally.

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

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

* Use exported env vars instead of .env file for deployment

The .env file approach was fragile for complex values like JSON
(JWT_PRIVATE_KEY) and base64 (APPLE_SIGNIN_PRIVATE_KEY).

Export environment variables directly in the deploy script so
docker compose reads them from the shell environment.

Also adds Apple Sign-In credentials to auth_build.yml workflow.

Note: Delete /opt/eagle0/.env on the server before deploying.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:49:35 -08:00
4120196f11 Include state parameter in OAuth callback redirect (#5356)
* Include state parameter in OAuth callback redirect

When redirecting from /oauth/callback to /invite/{code}/callback,
include the state parameter so the invitation handler can look up
the OAuth result.

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

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

* Quote values in .env file to handle special characters

Base64-encoded values contain / characters that break unquoted
.env parsing. Wrap all values in double quotes.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:28:48 -08:00
191ee501f4 Add Apple Sign-In secrets to deploy workflow (#5355)
Pass APPLE_SIGNIN_CLIENT_ID, APPLE_TEAM_ID, APPLE_SIGNIN_KEY_ID,
and APPLE_SIGNIN_PRIVATE_KEY to the auth container during deployment.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:19:27 -08:00
9a26173ceb Fix connection background layer bleeding into lobby screen (#5354)
Add connectionBackgroundLayer field to ConnectionHandler that can be
linked in the Unity Editor. The layer is hidden when entering the lobby
and shown when returning to the connection screen.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:11:49 -08:00
7cf2072dad Simplify client invitation flow and fix OAuth issues (#5353)
* Remove invitation code handling from Unity client

Account creation now happens on the web landing page, so the client
no longer needs to handle invitation codes.

Removed:
- InvitationCodeManager.cs (entire file)
- Invitation code parameter from AuthClient.GetOAuthUrlAsync()
- OAuthStatus.InvitationRequired handling in AuthClient
- OnInvitationRequired event and handlers in OAuthManager
- Invitation code panel UI fields in ConnectionHandler
- OnInvitationRequired and OnSubmitInvitationCodeClicked methods

The display name panel is retained for edge cases where a user
somehow doesn't have a display name set.

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

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

* Fix OAuth issues on landing page

- Fix Apple OAuth redirect_uri mismatch in token exchange
  The token exchange was using /oauth/callback but the auth request
  uses /oauth/apple/callback, causing redirect_uri mismatch error

- Add nginx route for /oauth/apple/callback
  Apple OAuth uses form_post response mode which posts to a separate
  callback path that wasn't proxied through nginx

- Add credential validation in GetAuthURL
  Only show OAuth buttons if provider credentials are configured,
  preventing broken auth URLs when client ID/secret are missing

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

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

* Pass GitHub and Apple OAuth credentials to auth container

The GH_OAUTH_CLIENT_ID/SECRET and Apple Sign-In credentials were
set in GitHub secrets but not passed to the auth service container
in docker-compose.prod.yml, causing the OAuth providers to appear
unconfigured.

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

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

* Update OAuth tests to expect error on empty credentials

The credential validation now returns an error for empty client ID
or client secret, so update the test to expect this behavior.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:10:22 -08:00
c6c7430dce Delete unused RansomOfferHelpers and its tests (#5351)
RansomOfferHelpers had no production callers - it was only tested.
The ransom offer logic in CommandChoiceHelpers already uses
Scala DiplomacyOptionType.Ransom and Scala RansomOfferDetails.

This removes the last 3 proto imports from command_choice_helpers/.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:46:20 -08:00
c4fc30d0bd Fix registry cleanup script to use JSON output for reliable parsing (#5352)
The doctl --format output was being incorrectly parsed, causing the script
to read size values (18.67 MB) as dates. This resulted in all images being
deleted, including those with the 'latest' tag.

Switch to --output json with jq parsing for reliable field extraction.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:45:33 -08:00
b6dd273c12 Add GitHub OAuth button support in Unity client (#5343)
* Add GitHub OAuth button support in Unity client

- Add githubLoginButton and githubProviderIcon properties
- Add click handler for GitHub OAuth login
- Update stored account icon logic to show GitHub icon

Unity editor changes (button assignment, icon sprite) to follow.

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

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

* Add GitHub OAuth button and update login UI layout

- Add GitHub login button with github-mark.png icon
- Add additional Google button image variants
- Update Gameplay.unity scene with OAuth button layout
- Remove orphaned SparklePlugin.bundle.meta

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:35:53 -08:00
56df06d21b Web-based invitation flow with OAuth on landing page (#5349)
Move account creation from Unity client to the web landing page.
Users now complete OAuth sign-in and display name selection in the
browser before downloading the client.

New flow:
1. User visits /invite/{code} → sees OAuth buttons
2. User clicks provider → OAuth flow
3. Existing user: redirect to download page (code not consumed)
4. New user: show display name form → create account → redeem code
5. Download page with platform-specific installer links

Changes:
- Landing page shows OAuth buttons instead of download buttons
- New routes: /invite/{code}/auth/{provider}, /invite/{code}/callback,
  /invite/{code}/set-name, /invite/{code}/download
- HMAC-signed cookies for session management between OAuth and form
- 4 new HTML templates: landing, display name form, download, error
- Legacy .bat/.command handlers retained for backwards compatibility

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:34:45 -08:00
a7a64fea11 Add GH_OAUTH secrets to deployment workflows (#5350)
Pass GH_OAUTH_CLIENT_ID and GH_OAUTH_CLIENT_SECRET through both
auth_build.yml and docker_build.yml to enable GitHub OAuth login.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:32:08 -08:00
b48a44d910 Delete unused stats() method from IncomingArmyUtils (#5348)
The stats() method that returns proto ArmyStats has no callers.
Removing it eliminates both proto imports from IncomingArmyUtils.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:20:25 -08:00
4bec466f54 Add GitHub and Apple to OAuth provider mapping (#5346)
The providerToString and stringToProvider functions were missing
cases for GITHUB and APPLE providers, causing them to return
"unknown" which resulted in "unsupported provider" errors.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:14:34 -08:00
84be65cfc3 Delete unused proto overloads from StatWithConditionUtils and ProvinceEventUtils (#5345)
- StatWithConditionUtils: Remove supportSc() and scWithRanges() proto versions
  (only Scala versions supportScala() and scWithRangesScala() were being used)
- ProvinceEventUtils: Remove all proto overloads (only Scala overloads used)
- Remove proto dependencies from BUILD.bazel files

Both files are now completely protoless.
Other Utilities: 13 → 9 proto imports (5 → 3 files)
Total: ~84 → ~80 proto imports remaining

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:09:09 -08:00
3834471840 Make ProvinceView use Scala ProvinceEvent instead of proto (#5341)
- Update ProvinceView.knownEvents from proto ProvinceEvent to Scala type
- Update ProvinceViewConverter to convert events with ProvinceEventConverter
- Update ProvinceViewFilter to work with Scala events internally:
  - Remove proto import
  - Use Scala overloads of ProvinceEventUtils checkers
  - Simplify event filtering logic
- Add visibility for event target to proto_converters/view and model/view

ProvinceViewFilter is now completely protoless - zero proto imports.
View filters directory reduced from 4 proto imports to 3 (in 2 files).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:39:52 -08:00
fdeb2ccf20 Add isEagleGame() for routing decisions, fix remaining lazy-load bugs (#5344)
- Add GamesManager.isEagleGame(gameId) wrapper for routing decisions
  with clear documentation warning against using gameControllerInfos directly
- Fix postCommand routing for ShardokCommand and PlacementCommands
  to use isEagleGame() instead of checking empty gameControllerInfos map
- Update streamOneUpdate to use isEagleGame() for consistency

These are the remaining places that had the same bug pattern as the
streamOneUpdate fix (PR #5342): after deployment the map is empty,
causing Eagle games to be incorrectly routed to customBattleManager.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:34:01 -08:00
f10a749ffe Fix StreamGameRequest to lazy-load games before routing (#5342)
The streamOneUpdate function was checking if the game was already in
gameControllerInfos to decide between gamesManager and customBattleManager.
After deployment (empty map), Eagle games would incorrectly route to
customBattleManager, which doesn't load the game. Then subsequent commands
would fail with "key not found".

Fix: Call ensureGameLoaded first to try loading the game from storage.
If it loads successfully, use gamesManager. If not (game doesn't exist),
fall back to customBattleManager.

Also made ensureGameLoaded public so EagleServiceImpl can call it.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:22:52 -08:00
f636b2b0df Server: ensure game is loaded before processing commands (#5339)
Add ensureGameLoaded() calls to:
- postCommand, postShardokCommand, postPlacementCommands
- joinGame case None (for started games)

This handles the case where a command arrives before the game subscription
has loaded the game into memory. The check is cheap (map contains) if the
game is already loaded.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:07:12 -08:00
cb0111a00f Make HeroViewFilter return Scala types instead of proto (#5336)
* Make HeroViewFilter return Scala types instead of proto

- Create HeroView.scala - Scala case class for the hero view type
- Create HeroViewConverter - converts Scala HeroView to proto
- Update HeroViewFilter to return Scala HeroView
- Update GameStateViewFilter to convert to proto at the edge
- Update AvailableCommandConverter to convert HeroView at the edge
- Update tests to use Scala types

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

* Consolidate DEPROTO_PLAN.md files and update with current stats

- Move detailed proto import inventory from root DEPROTO_PLAN.md to docs/
- Delete root level DEPROTO_PLAN.md (duplicate)
- Update all proto import counts based on current codebase state:
  - Total: ~85 imports remaining (down from 149)
  - Command choice helpers: 3 imports in 1 file (was 57 in 17)
  - View filters: 4 imports in 3 files
  - LLM generators: 56 imports in 34 files
- Add HeroViewFilter to recent completions (PR #5336)
- Update Phase 7 table with current view filter statuses

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:10:54 -08:00
508bfabece Add tutorials for all command panels (#5335)
* Add tutorials for all remaining command panels

Add 21 new command tutorials:
- Travel, Return, Diplomacy, Send Supplies, Recon
- Divine, Issue Orders, Control Weather, Swear Brotherhood
- Apprehend Outlaw, Suppress Beasts, Exile Vassal
- Handle Captured Hero, Manage Prisoners, Decline Quest
- Start Epidemic (plague), Handle Riot (3 variants)
- Attack Decision, Free-For-All Decision, Resolve Tribute

All command panels now have tutorials that appear after onboarding.

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

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

* Fix tutorial text accuracy and remove duplicates

- Rest: Only restores hero vigor, not troops
- Feast: Adds vigor alongside loyalty, cost based on hero count
- Travel: Goes to town within province, enables various activities
- Organize Troops: Emphasize hiring battalions, mention requirements
- Remove End Turn step (no End Turn button in Eagle)
- Remove duplicate Diplomacy tutorial (command panel version remains)

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

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

* Fix Trade, Travel, and Return tutorial descriptions

- Trade: Exchange food/gold within province, market takes cut
- Travel: Mention multiple actions per turn, reference Return
- Return: Opposite of Travel, returns to camp and ends turn

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 15:44:44 -08:00
de532a3448 Make FactionViewFilter return Scala types instead of proto (#5334)
* Make FactionViewFilter return Scala types instead of proto

- Create FactionView.scala - Scala case class for the view type
- Create FactionViewConverter - converts Scala FactionView to proto
- Create FactionRelationshipViewConverter - converts FactionRelationship to proto view
- Update FactionViewFilter to return Scala FactionView
- Update GameStateViewFilter to convert to proto at the edge
- Update tests to use Scala types

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

* Update DEPROTO_PLAN.md with recent progress

- Actions layer now at 0 proto imports (PR #5332)
- ProvinceUtils converted to Scala BattalionType (PR #5333)
- FactionViewFilter returns Scala types (PR #5334)
- Updated summary table and success criteria
- Reorganized remaining work into clear phases

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:25:16 -08:00
29c3d5f2c1 Add contextual tutorials for command panels (#5329)
* Add contextual tutorials for command panels

When a command panel is shown for the first time (after onboarding
completes), display a tutorial explaining what the command does
and its options.

- Hook CommandSelector.Show() to trigger tutorial events
- Add OnCommandPanelShown() to TutorialTriggerRegistry
- Create tutorials for: Improve, Alms, March, Defend, Rest, Trade,
  Feast, Hero Gift, Train, Arm Troops, Organize Troops, Recruit Heroes
- Improve tutorial includes tip about selecting heroes via dropdown
  or by clicking in the Resident Heroes panel

All command tutorials require onboarding completion before showing.

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

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

* Fix command panel tutorial trigger timing and step count display

Two fixes:

1. Move tutorial trigger from Show() to UpdateAvailableCommand()
   - Show() only fires when selector changes, not when same command is re-selected
   - During onboarding, users explore commands but prerequisites aren't met yet
   - After onboarding, re-selecting same command wouldn't trigger Show()
   - UpdateAvailableCommand() fires every time a command is selected

2. Use visible step count instead of total step count
   - Onboarding has 16 total steps but includes hidden wait steps and tactical steps
   - Add VisibleStepCount and GetVisibleStepIndex to TutorialSequence
   - Now shows "Step 3 of 15" (visible) instead of "Step 3 of 16" (total)

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

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

* Mark onboarding complete after strategic portion, fix highlight

Two fixes:

1. Add MarksOnboardingComplete flag to TutorialStep
   - When set, marks onboarding complete when that step finishes
   - Set on "strategic_complete" step so command tutorials can appear
   - Tactical portion continues when battle becomes available

2. Remove highlight from "Province Commands" step
   - Was causing yellow box to appear over modal text
   - The buttons are explained in the text, no highlight needed

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

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

* Fix command panel tutorials: step count, trigger timing, and highlight

- VisibleStepCount now stops at MarksOnboardingComplete step (8 vs 15)
- Allow contextual tutorials to interrupt hidden DisplayMode.None steps
- Restore Province Commands highlighting with HighlightBoundsFromChildren
- Add defensive highlight clearing before showing new modals

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

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

* Fix command tutorial positioning, initial trigger, and switching

- Position command tutorials above CommandPanel using AdjacentTargetPath
- Track last command shown and re-trigger after onboarding completes
- Allow command tutorials to interrupt each other when switching commands
- Same command tutorial won't restart if already showing

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

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

* Register CommandPanel target and add debug logging

- Add CommandPanel to TutorialTargetRegistry static targets
- Register commandPanel from EagleGameController on tutorial init
- Add debug logging to RetriggerLastCommandTutorial for diagnosis

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

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

* Fix command tutorial timing and switching behavior

- Defer RetriggerLastCommandTutorial until after advancing to hidden step
  (was firing while onboarding modal was still active)
- When switching to a command whose tutorial is already completed,
  hide the current command tutorial without marking it complete

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

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

* Delay onboarding start until first model update

Move StartOnboarding() from SetUpGame() to SwapModel() so it runs after
the first game model is received and UI panels are populated. This
prevents the tutorial from appearing before the province info panel
is visible/positioned.

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

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

* Link CommandPanel in TutorialTargetRegistry and enable debug logging

- Add CommandPanel reference for tutorial positioning
- Enable tutorial debug logging for testing

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:13:32 -08:00
7b118f24b6 Convert ProvinceUtils to use Scala BattalionType (#5333)
Replace proto BattalionType import with Scala version in
ProvinceUtils.availableBattalionTypeIds method. Update test
to use Scala BattalionType with helper function for test data.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:52:21 -08:00
2ddb627c58 Remove proto enum import from Actions layer (#5332)
Use Scala ActionResultType enum instead of proto enum in
ChronicleEventGenerator. This completes the deproto migration
for the Actions layer (0 proto imports remaining).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:43:29 -08:00
154fac85a5 Remove legacy proto CommandSelection and rename ScalaCommandSelection (#5330)
- Delete old proto-based CommandSelection class
- Rename ScalaCommandSelection to CommandSelection
- Delete ProtoCommandChooser and ProtoCommandChooserImplicits
- Delete ProtoAvailableCommandSelector
- Update all imports (47 files) to use CommandSelection
- Remove proto dependencies from BUILD.bazel files
- Delete AvailableCommandSelectorTest (tested deleted proto functionality)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:13:27 -08:00
5e61f43183 Make CommandChoiceHelpers and AI clients protoless (#5326)
* Make CommandChoiceHelpers protoless

Convert all command choice helper files to use Scala types instead of
proto types. This eliminates proto dependencies from the command
selection logic in the library.

Key changes:
- Update AvailableCommandSelector, CommandChoiceHelpers, and all
  command selector files to use Scala AvailableCommand/SelectedCommand
- Convert CommandSelection to ScalaCommandSelection throughout
- Update action files (EndHandleRiotsPhaseAction,
  PerformVassalCommandsPhaseAction, PerformVassalDefenseDecisionsAction)
  to work with Scala commands directly
- Update all quest command choosers to use Scala types
- Fix ArmedBattalion Scala definition (battalionTypeId -> newArmament)
- Add exports to combat_unit_selector BUILD.bazel

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

* Make AI clients protoless

Convert AI client source and test files to use Scala AvailableCommand
and ScalaCommandSelection types instead of proto types.

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

* Update CommandChoiceHelpers tests to use Scala types

Convert test files to use Scala AvailableCommand and ScalaCommandSelection
types instead of proto types. Also includes additional source updates for
AvailableCommandSelector and CommandChooser.

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

* Fix remaining test conversions to Scala types

- Fix CommandChoiceHelpersTest to use DiplomacyAvailable Scala type
- Convert ExpandCommandSelectorTest fixtures from proto to Scala types
- Remove tests that relied on ScalaPB .update() lens syntax (marked with TODO)

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

* Fix AIClient and GameController to use Scala types consistently

- AIClient now works entirely with Scala command types
- GameController.withPostedCommand accepts Scala SelectedCommand
- postHumanCommand converts proto to Scala at the API boundary
- All 202 tests pass

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

* Restore 5 missing tests in ExpandCommandSelectorTest

Tests were removed during protoless migration because they used ScalaPB
.update() lens syntax. Rewrote them using .copy() syntax:
- "return nothing if not enough heroes can move to keep balance"
- "move some heroes to a friendly province if there's an imbalance"
- "return a march command with one hero if that's close enough"
- "return a march command with hero count rounding up if possible"
- "keep hero count balanced even if lots are available"

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:58:47 -08:00
a79209891d Tutorial row highlighting for province info, warlord, and vassals (#5328)
* Add tutorial row highlighting for province info, warlord, and vassals

- Highlight entire Province Info panel for province_stats step
- Register hero table rows dynamically for tutorial targeting
- Add WarlordRow target highlighting for heroes_warlord step
- Add VassalRow1+VassalRow2 combined highlight for heroes_vassals step
- Add AdditionalHighlightTargets field for multi-element highlights
- Add HighlightMultiple method to compute combined bounding boxes
- Add GetRowRectTransform helper to EventBasedTable

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

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

* Clamp tutorial highlights to stay within screen bounds

Adds screen bounds clamping to both PositionHighlight and
PositionHighlightMultiple methods to prevent highlights from
going off-screen. Uses a 5px margin from canvas edges.

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

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

* Add highlighting for Command Buttons tutorial step

The command_buttons step had AdjacentTargetPath for panel positioning
but was missing TargetGameObjectPath for highlighting. Added both
TargetGameObjectPath and HighlightPulsing to show the highlight.

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

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

* Fix highlight bounds to stay within screen

Two fixes for tutorial highlighting:

1. Account for HighlightPadding in screen bounds clamping - the padding
   was added to size AFTER clamping, pushing edges off-screen again.
   Now the margin includes HighlightPadding so final bounds stay on screen.

2. Add HighlightBoundsFromChildren option for containers where the
   RectTransform is larger than visible content. When enabled, highlight
   bounds are computed from active child elements instead of the target's
   own RectTransform. Used for CommandButtonsPanel where buttons are
   85x85 with 5px spacing.

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

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

* Point CommandButtonsPanel to actual button container

Changed the reference to target the button container directly
rather than the larger parent panel.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:25:08 -08:00
cbcad13d23 Tutorial: non-blocking positioned panels with updated content (#5322)
* Tutorial: non-blocking panels with positioning and updated content

- Add TutorialPanelAnchor enum (Center, Left, Right, Top, Bottom)
- Add BlocksInteraction property to TutorialStep for non-modal tutorials
- Update TutorialModalPanel with PositionPanel() method for anchored placement
- Update TutorialUIManager fallback UI to support positioning and non-blocking
- Revise tax/Support content: explain taxes provide both Gold AND Food
- Add vassals tutorial step with loyalty mechanics warning
- Set province-related tutorial steps to non-blocking and right-anchored

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

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

* Tutorial: smaller panel, adjacent positioning, Support highlight

- Reduce panel size from 800x550 to 500x400
- Reduce font sizes (title 28, desc 18, progress 14, buttons 16)
- Add AdjacentTargetPath property to position panel next to UI elements
- Add PositionAdjacentTo() method for target-relative positioning
- Highlight Support field during Support tutorial step
- Fix "Give Alms" to say "costs Food" not "costs Gold"
- Fix turn cycle text (no End Turn button - turns end automatically)
- Fix vassals text: "feasts" instead of "victories"
- Emphasize that commands are safe to explore until Commit

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

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

* Tutorial: add TutorialTargetRegistry for Unity-linked targets

- Create TutorialTargetRegistry component with serialized fields for UI targets
- Add TargetRegistry reference to TutorialManager
- Update TutorialUIManager to use registry instead of GameObject.Find
- Update TutorialModalPanel to use registry for adjacent positioning
- Registry provides drag-and-drop configuration in Inspector
- Falls back to GameObject.Find for unregistered targets

Supported targets:
- ProvinceInfoPanel, SupportField, AgricultureField, EconomyField,
  InfrastructureField, HeroesPanel, CommandButtonsPanel, ImproveButton,
  AlmsButton, MarchButton, CommitButton, BattleButton

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

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

* Tutorial: support dynamic target registration for prefab buttons

- Remove individual command button fields (ImproveButton, AlmsButton, etc.)
- Remove BattleButton (not needed yet)
- Add RegisterTarget/UnregisterTarget methods for runtime registration
- Command buttons can be registered when instantiated from prefabs

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

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

* Add TutorialTargetRegistry.cs.meta

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

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

* Tutorial: fix Infrastructure description

- Infrastructure improves troop armament and disaster resilience
- Note that all three stats increase storage capacity

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

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

* Tutorial: increase panel size to 540x500

Panel was too small, causing title to clip at top and buttons below bottom.

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

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

* Tutorial: soften Warlord warning text

Changed from "game over" to "protect them" - the full mechanic
is more nuanced and doesn't need to be explained in onboarding.

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

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

* Tutorial: add debug logging for highlight targeting

Helps diagnose why Support field highlight may not be appearing.

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

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

* Add diagnostic logging to debug registry lookup and panel positioning

- Log TutorialManager.Instance and TargetRegistry state
- Log which target is found (static vs dynamic vs not found)
- Log panel positioning anchor and resulting position
- Log step details when Show() is called

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

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

* Wire TutorialTargetRegistry to TutorialManager in scene

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

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

* Fix tutorial panel positioning and highlight sizing

- Add screen bounds clamping to prevent panels from going off-screen
- Add Top/Bottom positioning support for adjacent panel placement
- Fix highlight frame using canvas-local coordinates instead of screen coords
- Position Warlord/Vassals panels adjacent to HeroesPanel
- Position Command panel above CommandButtonsPanel with highlight

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

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

* Clean up debug logging and remove command button highlight

- Remove diagnostic debug logs from TutorialModalPanel and TutorialTargetRegistry
- Remove command buttons highlight (panel keeps oversized element bounds)
- Keep panel positioning adjacent to CommandButtonsPanel

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 11:31:25 -08:00
9e7eaa6367 Fix diplomacy commands not deducting gold costs (#5327)
The diplomacyOptionTypeToOption method was incorrectly setting goldCost=0
for all diplomacy types (Alliance, Truce, Invitation, BreakAlliance).
This meant gold was never deducted when these commands were executed.

Fix uses the proper settings values for each diplomacy type.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:37:41 -08:00
3fc8589847 Add GitHub and Apple OAuth provider support (#5323)
Server-side implementation for GitHub and Apple Sign-In OAuth:

- Add OAUTH_PROVIDER_GITHUB and OAUTH_PROVIDER_APPLE to auth.proto enum
- Add GitHub OAuth config (standard OAuth 2.0 flow)
- Add Apple Sign-In config with JWT client_secret generation
- Handle Apple's POST callback and id_token parsing for user info
- Support per-provider callback URLs (Apple requires /oauth/apple/callback)

Environment variables required:
- GitHub: GH_OAUTH_CLIENT_ID, GH_OAUTH_CLIENT_SECRET
- Apple: APPLE_SIGNIN_CLIENT_ID, APPLE_SIGNIN_KEY_ID, APPLE_SIGNIN_PRIVATE_KEY
  (APPLE_TEAM_ID already exists for notarization)

Client UI changes will follow in a separate PR.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:16:54 -08:00
abec33585d Improve onboarding tutorial flow and simplify UI buttons (#5321)
* Improve onboarding tutorial flow and simplify UI buttons

- Revise onboarding to focus on the single starting province:
  - Province stats (Agriculture/Economy/Infrastructure)
  - Support importance (40 by January for taxes)
  - Faction Head and hero panel
  - Command buttons (Improve and Give Alms)
  - Turn cycle explanation

- Simplify tutorial modal buttons to just two options:
  - "Continue" - advance to next step
  - "Skip Tutorial" - skip all remaining steps (onboarding only)
  - Remove redundant "Skip" button (was identical to Continue)

- Add note in welcome step about restarting from Settings

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

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

* Use "Warlord" instead of "Faction Head" in tutorial

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:20:36 -08:00
e82c174814 Add diagnostic logging for stalled LLM text generation (#4711)
Add lastUpdateAtMillis field to IncompleteClientText to track when data
was last received from the LLM stream. This enables better diagnosis of
stalled incomplete texts by distinguishing between:
- Streams that stalled immediately (small partialLen, secsSinceLastUpdate ≈ secsSinceRequest)
- Streams that received data then went silent (larger partialLen, secsSinceLastUpdate << secsSinceRequest)

This helps verify the hypothesis that HTTP/2 streams can go into a zombie
state where no data, error, or completion is received.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:02:38 -08:00
38732c9255 Improve Unity Library/ cache resilience (#5320)
1. Separate cache paths per platform (/tmp/eagle0/Library-mac vs Library-windows)
   - Prevents cross-platform contamination if runners share /tmp

2. Only persist cache on successful builds
   - Prevents failed builds from poisoning the cache

3. Exclude Library/Bee/ from cache
   - Bee contains DAG files with hardcoded paths that become stale
   - Prevents "Data at the root level is invalid" XML errors
   - ScriptAssemblies and ShaderCache are still cached for speed

Also adds clean: true to Windows Unity workflow.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:53:47 -08:00
7cd56e5980 Fix token mismatch causing connection retry loop after deploy (#5308)
Two related fixes for handling stale game state after blue-green deploy:

Server (EagleServiceImpl.scala):
- Catch "Token mismatch" exceptions in postCommand and return BAD_TOKEN
  status instead of throwing an exception
- Previously the exception caused an RPC error, bypassing the client's
  BAD_TOKEN handling which refreshes game state

Client (PersistentClientConnection.cs):
- When WriteAsync times out or fails, dispose the dead connection and
  schedule reconnect
- Previously the connection was left in a zombie state (appeared alive
  but couldn't communicate)
- Add better logging for write errors

Root cause: After a deploy, the client reconnects but may have stale
game state. When posting a command with an old token, the server threw
an exception instead of returning BAD_TOKEN. The client didn't know to
refresh its state, and the stale command stayed in the retry queue.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:42:11 -08:00
454d4e2fc7 Remove GoDice Bluetooth dice integration (#5312)
* Remove GoDice Bluetooth dice integration

The GoDice integration for physical Bluetooth dice was incomplete and
causing Mac build failures due to orphaned .meta files for plugin
binaries that weren't tracked in git.

This commit removes all GoDice-related code:
- Deleted Assets/Bluetooth folder with all dice interface code
- Removed DarwinGodiceBundle.bundle.meta and GoDiceDll.dll.meta
- Removed RollFetcher interface and references from game models
- Removed GoDice settings from SettingsPanelController
- Updated ShardokGameModel to always pass null for rolls (server
  generates random rolls when no physical roll is provided)

The feature can be re-added later when there's time to properly
implement and test GoDice integration.

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

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

* Remove GoDice Canvas with orphaned script references

Removed the GoDice Canvas GameObject from the scene which contained
components referencing the deleted Bluetooth/RollPanelController scripts.

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

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

* Remove GoDice plugin build steps from CI

Since GoDice integration is removed, no need to build the
DarwinGodiceBundle or GoDiceDll plugins in CI.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:30:26 -08:00
7ad5ebdb56 Re-enable Sparkle auto-update integration for Mac builds (#5318)
* Add native Sparkle plugin to enable Mac auto-updates

The Sparkle framework was being injected into the app bundle, but
nothing was initializing it. This adds:

- Native Objective-C plugin (SparklePlugin.m) that initializes
  SPUStandardUpdaterController at runtime
- C# wrapper (SparkleUpdater.cs) for Unity to call the native plugin
- SparkleInitializer.cs uses RuntimeInitializeOnLoadMethod to
  automatically initialize Sparkle at app startup
- Build script to compile the plugin as a universal binary

The plugin is weak-linked against Sparkle.framework, which is injected
separately by inject_sparkle.sh during the build process.

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

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

* Convert Sparkle plugin build from clang to Bazel

- Add Sparkle framework as http_archive dependency in MODULE.bazel
- Add BUILD.sparkle to import the framework
- Add BUILD.bazel for SparklePlugin using macos_bundle rule
- Update build_sparkle_plugin.sh to use Bazel instead of direct clang
- Register Apple CC toolchain extension

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

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

* Fix symbol exports for SparklePlugin native library

The C functions need to be exported with visibility("default") and
explicit linker flags for Unity P/Invoke to find them. Without this,
the bundle binary had no exported symbols.

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

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

* Re-enable Sparkle auto-update integration for Mac builds

Restores Sparkle integration that was temporarily removed in #5317:
- Restore inject_sparkle.sh script
- Add Sparkle injection step to mac_build.yml
- Re-enable Sparkle signing and appcast updates in deploy step

Combined with native SparklePlugin that initializes Sparkle at runtime.

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

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

* Build SparklePlugin.bundle before Unity build

The SparklePlugin.bundle.meta file tells Unity to include the plugin,
but the actual bundle needs to be built by Bazel first.

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

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

* Convert SparklePlugin Info.plist to XML format

Unity's build system requires Info.plist files in XML format,
but Bazel outputs them in binary plist format.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 05:28:58 -08:00
1e3ae0d82c Fix ArmedBattalion field name: battalionTypeId -> newArmament (#5319)
The ArmedBattalion.battalionTypeId field was misnamed - it represents
the armament level to raise troops to, not a battalion type ID.
Renamed to newArmament to match the proto field name and the
semantic meaning.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:46:17 -08:00
92efdbae8b Remove Sparkle auto-update integration from Mac builds (#5317)
* Remove Sparkle auto-update integration from Mac builds

Temporarily removing Sparkle integration to get Mac builds working:
- Remove inject_sparkle.sh script and workflow step
- Make mac_build_handler's Sparkle private key optional
- Skip Sparkle signing and appcast updates when no key provided

This allows Mac builds to complete without Sparkle. Auto-updates can
be re-enabled later once the basic build pipeline is stable.

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

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

* Use clean checkout to remove stale SparklePlugin.bundle

The runner had a leftover SparklePlugin.bundle from previous builds
which was causing Unity to fail when trying to process it.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:28:17 -08:00
378d3f6828 Fix crash when LLM responses arrive for deleted games (#5315)
When warmup games (or any games) are deleted while LLM requests are
in flight, the async responses would crash with "key not found" because
GamesManager used direct Map access that throws NoSuchElementException.

This caused two problems:
1. The exception disrupted LLM response processing
2. Other games' text generation could get blocked as a result

Changed three methods to use safe .get() access and gracefully ignore
responses for deleted games:
- receiveStreamingLlmResponses: logs and returns early
- receiveStreamingLlmFailure: logs and returns early
- aiPlayers: returns empty vector

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:02:51 -08:00
ca0b3930dc Delete unused DateProtoUtils (#5314)
DateProtoUtils has no production callers - it's dead code that only
has a test file. The Scala Date type at model/state/date/ is the
preferred way to work with dates in the codebase.

Proto imports in library/ after this change: 143

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:26:09 -08:00
99fef8312b Move IDable to test code (#5313)
IDable is a test-only utility that provides mapify* helper methods
for converting proto collections to Maps. This moves it from the
main library/ directory to test code.

Changes:
- Inline IDable methods in StartGameActionResultUtils (the only main
  code user)
- Move IDable.scala to src/test/scala/net/eagle0/eagle/library/util/
- Update all test BUILD.bazel files to use the test version

Proto imports in library/ after this change: 145

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:25:16 -08:00
cdb8631e91 Make IncomingArmyUtils protoless (#5310)
Delete all proto overloads from IncomingArmyUtils since all callers use
Scala types. This required adding an export to ProvinceOrderTypeConverter
to properly expose the proto type to callers.

Proto imports in library/: 149 → 146

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:35:37 -08:00
7c0bbd8b9a Delete unused ArmyUtils (#5311)
ArmyUtils.heroCount and ArmyUtils.troopCount methods are never called.
Delete the file entirely.

Proto imports in library/: 149 → 147

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:35:10 -08:00
7c9e434430 Add comprehensive proto import inventory to DEPROTO_PLAN (#5306)
Document all 149 proto imports in library/ with:
- Proto deps by BUILD.bazel file (for build-level tracking)
- Detailed imports by file/line (for code-level tracking)
- Cleanup priority candidates

This inventory makes it easier to track deproto progress.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:57:00 -08:00
268 changed files with 9296 additions and 13241 deletions
+40 -37
View File
@@ -104,6 +104,12 @@ jobs:
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
@@ -112,16 +118,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy update-env script to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "deploy/update-env.sh,deploy/env.template"
target: /opt/eagle0/
strip_components: 1
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
with:
@@ -129,51 +125,58 @@ jobs:
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,APPLE_SIGNIN_CLIENT_ID,APPLE_TEAM_ID,APPLE_SIGNIN_KEY_ID,APPLE_SIGNIN_PRIVATE_KEY,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -x
cd /opt/eagle0
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"AUTH_IMAGE=${AUTH_IMAGE}" \
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
# Export env vars for docker compose (appleboy/ssh-action sets them but doesn't export)
export AUTH_IMAGE="${AUTH_IMAGE}"
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
echo "Deploying auth service: $AUTH_IMAGE"
# Use crane to pull image
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
echo "Pulling Auth image with crane..."
./crane pull "${AUTH_IMAGE}" auth.tar || { echo "ERROR: Failed to pull auth image"; exit 1; }
echo "Loading Auth image into Docker..."
docker load -i auth.tar
rm auth.tar
rm ./crane
# Pull the image directly (docker is already logged in)
echo "Pulling Auth image..."
docker pull "${AUTH_IMAGE}" || { echo "ERROR: Failed to pull auth image"; exit 1; }
# Only recreate the auth container (not eagle, shardok, etc.)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
# Wait for health check
sleep 5
docker compose -f docker-compose.prod.yml ps auth
# Verify container is using correct image
# Verify container is using the correct image
echo "=== Verifying auth container image ==="
docker compose -f docker-compose.prod.yml images auth
RUNNING_IMAGE=$(docker inspect auth-server --format '{{.Config.Image}}')
echo "Expected image: ${AUTH_IMAGE}"
echo "Running image: ${RUNNING_IMAGE}"
if [ "$RUNNING_IMAGE" != "$AUTH_IMAGE" ]; then
echo "ERROR: Container is running wrong image!"
echo "Container details:"
docker inspect auth-server --format '{{.Id}} {{.Config.Image}}'
exit 1
fi
# Show container status
docker compose -f docker-compose.prod.yml ps auth
# Cleanup old images
docker image prune -f
+34 -45
View File
@@ -162,6 +162,12 @@ jobs:
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
@@ -209,57 +215,40 @@ jobs:
set -ex
cd /opt/eagle0
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
ADMIN_IMAGE="${ADMIN_IMAGE}"
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
OPENAI_API_KEY="${OPENAI_API_KEY}"
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
SENTRY_DSN="${SENTRY_DSN}"
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Export environment variables for docker compose
# These are passed via heredoc and exported so child processes (docker compose) can access them
export EAGLE_IMAGE="${EAGLE_IMAGE}"
export ADMIN_IMAGE="${ADMIN_IMAGE}"
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
export OPENAI_API_KEY="${OPENAI_API_KEY}"
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
export DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
export SHARDOK_ADDRESS="${SHARDOK_ADDRESS:-shardok:40042}"
export SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
export SENTRY_DSN="${SENTRY_DSN}"
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Check Docker has IPv6 support
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
fi
# Update env vars
chmod +x deploy/update-env.sh
cd deploy && ./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
"SENTRY_DSN=\${SENTRY_DSN}" \
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
cd ..
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
+6 -1
View File
@@ -61,19 +61,24 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: true
clean: false
clean: true # Remove untracked files like old SparklePlugin.bundle
fetch-depth: 0 # For version numbering from git history
- name: Pull LFS files
run: git lfs pull
- 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"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/persist_library.sh
- name: Inject Sparkle Framework
+7 -7
View File
@@ -46,26 +46,26 @@ jobs:
for REPO in $REPOS; do
echo "=== Processing repository: ${REPO} ==="
# Get all manifests with their tags and dates
# Filter out header row and empty lines
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null | grep -v '^Digest' | grep -v '^$' || echo "")
# Get all manifests with their tags and dates using JSON output for reliable parsing
MANIFESTS_JSON=$(doctl registry repository list-manifests "${REPO}" --output json 2>/dev/null || echo "[]")
if [ -z "$MANIFESTS" ]; then
if [ "$MANIFESTS_JSON" = "[]" ] || [ -z "$MANIFESTS_JSON" ]; then
echo " No manifests found"
continue
fi
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
# Parse JSON and process each manifest
echo "$MANIFESTS_JSON" | jq -r '.[] | "\(.digest) \(.updated_at) \(.tags // [] | join(","))"' | while read -r DIGEST UPDATED_AT TAGS; do
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
continue
fi
# Parse the date
# Parse the date (ISO 8601 format from JSON)
MANIFEST_DATE=$(date -d "$UPDATED_AT" +%s 2>/dev/null || echo "0")
# Skip protected tags (latest, arm64-latest)
if echo "$TAGS" | grep -qE '(^|,)(latest|arm64-latest)(,|$)'; then
if echo ",$TAGS," | grep -qE ',(latest|arm64-latest),'; then
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
continue
fi
+6 -1
View File
@@ -49,14 +49,19 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: true
clean: false
clean: true # Remove untracked files from previous builds
- name: Pull lfs files
run: git lfs pull
- 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"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/persist_library.sh
- name: Deploy Windows unity
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
-213
View File
@@ -1,213 +0,0 @@
# Deproto Migration Plan
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
## Architectural Decisions
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
## Recent Completed Work
### GameState Round-Trip Elimination (PRs #4913, #4914, #4915)
Eliminated wasteful Scala→proto→Scala conversions in the hot path:
1. **LLM Pipeline** (#4913): `LlmRequestWithGameState` now uses Scala `GameState` instead of proto. All ~38 prompt generators updated to use Scala model types (`FactionT`, `HeroT`, `ProvinceT`).
2. **ActionWithResultingState Caching** (#4914): Added `precomputedScalaState: Option[GameState]` to cache Scala state when available, avoiding `fromProto()` conversion in `stateAfter()`.
3. **PostResults Simplification** (#4915): Changed `PostResults.gameState` from proto to `Option[GameState]` (Scala), eliminating `toProto()` calls when creating PostResults.
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
## Migration Status
### Fully Protoless (no proto imports)
**Utilities:**
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
**Command Selectors (all use native GameState):**
- [x] `AllianceOfferCommandSelector`
- [x] `AlmsCommandSelector`
- [x] `AttackCommandChooser`
- [x] `ExpandCommandSelector`
- [x] `HeroGiftCommandSelector`
- [x] `ImproveCommandSelector`
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
- [x] `OrganizeCommandSelector`
- [x] `RansomOfferHelpers`
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
- [x] `TruceOfferCommandSelector`
- [x] `TrustForDiplomacy`
**Quest Command Selectors (all protoless):**
- [x] `AllianceQuestCommandChooser`
- [x] `AlmsAcrossRealmQuestCommandChooser`
- [x] `AlmsToProvinceQuestCommandChooser`
- [x] `DismissSpecificVassalCommandChooser`
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
- [x] `ImproveQuestCommandChooser`
- [x] `QuestCommandChooser`
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Fully Protoless
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
### AI Layer ✅ COMPLETE
All AI and command chooser code is now fully protoless:
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
- [x] `CommandChooser` - trait uses Scala GameState
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
- [x] `MidGameAIClient` - uses Scala GameState internally
### Still Using Proto GameState (Boundary Code)
These files use proto GameState because they're at system boundaries:
**View Filters (client projection):**
- `view_filters/GameStateViewFilter` - has Scala overload, uses Scala sub-filters
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
- `view_filters/FactionViewFilter` - has Scala overload
- `view_filters/HeroViewFilter` - has Scala overload
- `view_filters/BattalionNameFilter` - has Scala overload
- `view_filters/BattleFilter` - has Scala overload
**Legacy Utilities (to be deprecated):**
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
- Used by code that still needs proto GameState
**Persistence/Action System:**
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
- `ActionWithResultingState` - caches both proto and Scala state
**Shardok Interface (gRPC boundary):**
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
## Next Steps
### Phase 1-3: AI Layer ✅ COMPLETE
The entire AI decision-making layer is now protoless.
### Phase 4: View Filters ✅ COMPLETE
The view_filters package migration is complete:
**Completed:**
- [x] `GameStateViewFilter` - added Scala GameState overload (uses Scala sub-filters)
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
- [x] `HeroViewFilter` - added Scala overload
- [x] `FactionViewFilter` - added Scala overload
- [x] `Visibility` - added Scala overloads
**Still Using Proto:**
- [x] `BattalionNameFilter` - has Scala overload
- [x] `BattleFilter` - has Scala overload
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
**Strategy:**
1. Add Scala GameState overloads to view filter methods
2. Update callers to pass Scala GameState where available
3. Eventually deprecate proto versions
### Phase 5: Legacy Utility Cleanup (IN PROGRESS)
Remove Legacy* utilities by migrating remaining callers:
1. Identify callers of each Legacy* util
2. Update callers to use protoless versions
3. Delete Legacy* files when no longer needed
**Deleted (no production callers):**
- [x] `LegacyProvinceDistances` - deleted (no callers)
- [x] `LegacyBattalionSuitability` - deleted (no callers)
- [x] `LegacyFoodConsumptionUtils` - deleted (no callers)
- [x] `LegacyHandleRiotUtils` - deleted (no callers)
**Refactored to Thin Wrappers (delegating to protoless versions):**
- [x] `LegacyRansomValidity` - already a thin wrapper delegating to `RansomValidity`
- [x] `LegacyRecruitmentOdds` - refactored to delegate to `RecruitmentOdds`
**Parallel Implementations (proto mirrors protoless):**
- [x] `FactionUtils` / `LegacyFactionUtils` - both have matching APIs; LegacyFactionUtils used by boundary code (24+ callers)
- [x] `HeroUtils` / `LegacyHeroUtils` - both have matching APIs; LegacyHeroUtils used by boundary code (10 callers)
- [x] `ProvinceUtils` / `LegacyProvinceUtils` - both have matching APIs; LegacyProvinceUtils used by boundary code (20 callers: availability factories, view filters)
**Parallel Implementations (awaiting migration of callers):**
- [x] `BattalionUtils` / `LegacyBattalionUtils` - both have matching core methods; LegacyBattalionUtils used by boundary code (4 callers)
- [x] `BattalionViewFilter` / `LegacyBattalionViewFilter` - protoless version exists; Legacy used by view filters, action appliers (3 callers)
- [x] `BattalionTypeFinder` / `LegacyBattalionTypeFinder` - protoless version exists; Legacy used by validators (1 caller: RuntimeValidator)
### Recent Caller Migration
**CheckForFulfilledQuestsAction** - migrated to use protoless `BattalionTypeFinder`:
- Changed `battalionTypes` parameter from proto `Vector[BattalionType]` to Scala `Vector[BattalionType]`
- Updated callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
- Eliminated wasteful `BattalionTypeConverter.toProto()` conversions
**ExpandedUnaffiliatedHeroUtils** - added Scala overload:
- New overload takes Scala `GameState` and `UnaffiliatedHeroT` directly
- Added `UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto()` helper for efficient enum conversion
- Proto overload retained for backward compatibility
**AvailablePleaseRecruitMeCommandFactory** - eliminated wasteful proto conversions:
- Now uses `ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)` directly
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
- Factory is now fully protoless internally (still returns proto types for API boundary)
**ProvinceViewFilter** - added Scala overload with faction filtering:
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
- Scala overload now fully protoless internally
- Uses the new ProvinceViewFilter Scala overload with faction filtering
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
## Key Files
### Protoless Model Types
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
### Proto Converters
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
-3
View File
@@ -9,9 +9,6 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
./scripts/build_protos.sh
/bin/echo "build plugins"
./scripts/build_windows_plugin.sh
git log -3
/bin/echo "build Windows"
+2 -2
View File
@@ -7,8 +7,8 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
./scripts/build_protos.sh
/bin/echo "build Mac plugin"
./scripts/build_mac_plugin.sh
/bin/echo "build Sparkle plugin"
./scripts/build_sparkle_plugin.sh
git log -3
+15 -2
View File
@@ -1,12 +1,25 @@
#!/bin/bash
#
# Persist Unity Library/ cache to persistent storage
#
# Environment variables:
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
# Defaults to "mac" if not set
#
# Note: Library/Bee/ is excluded because it contains DAG files with hardcoded
# file paths that become stale when project files change. This prevents
# "Data at the root level is invalid" XML errors from stale references.
set -uxo pipefail
/bin/echo "persist Library/"
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
/bin/echo "persist Library/ to $CACHE_DIR (excluding Bee/)"
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
# temporary files vanish during the copy. This is acceptable for a cache.
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
/usr/bin/rsync -rtlDvq --exclude='Bee/' src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ "$CACHE_DIR/"
rsync_exit=$?
if [ $rsync_exit -eq 0 ]; then
+12 -3
View File
@@ -1,7 +1,16 @@
#!/bin/bash
#
# Restore Unity Library/ cache from persistent storage
#
# Environment variables:
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
# Defaults to "mac" if not set
set -euxo pipefail
/bin/echo "restore Library/"
/bin/mkdir -p /tmp/eagle0/Library
/usr/bin/rsync -rtlDvq /tmp/eagle0/Library/ src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
/bin/echo "restore Library/ from $CACHE_DIR"
/bin/mkdir -p "$CACHE_DIR"
/usr/bin/rsync -rtlDvq "$CACHE_DIR/" src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
+9 -3
View File
@@ -44,13 +44,19 @@ for arg in "$@"; do
fi
# Remove existing line for this key and add new one
# Use single quotes to avoid issues with special characters in values
# (JSON strings, base64 with /, etc.)
# Escape any single quotes in the value by replacing ' with '\''
ESCAPED_VALUE="${VALUE//\'/\'\\\'\'}"
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
# Key exists, update it
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
# Key exists, remove old line and add new one (sed with special chars is fragile)
grep -v "^${KEY}=" "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
echo "${KEY}='${ESCAPED_VALUE}'" >> "$ENV_FILE"
echo "Updated $KEY"
else
# Key doesn't exist, add it
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
echo "${KEY}='${ESCAPED_VALUE}'" >> "$ENV_FILE"
echo "Added $KEY"
fi
done
+7
View File
@@ -133,6 +133,13 @@ services:
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
GH_OAUTH_CLIENT_ID: "${GH_OAUTH_CLIENT_ID:-}"
GH_OAUTH_CLIENT_SECRET: "${GH_OAUTH_CLIENT_SECRET:-}"
# Apple Sign-In credentials
APPLE_SIGNIN_CLIENT_ID: "${APPLE_SIGNIN_CLIENT_ID:-}"
APPLE_TEAM_ID: "${APPLE_TEAM_ID:-}"
APPLE_SIGNIN_KEY_ID: "${APPLE_SIGNIN_KEY_ID:-}"
APPLE_SIGNIN_PRIVATE_KEY: "${APPLE_SIGNIN_PRIVATE_KEY:-}"
# Server base URL for OAuth callbacks
SERVER_BASE_URL: "${SERVER_BASE_URL:-https://prod.eagle0.net}"
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
+196 -299
View File
@@ -33,7 +33,23 @@
---
## Current State
## Current State (January 2026)
### Summary
| Area | Proto Imports | Status |
|------|---------------|--------|
| AI (`/ai/`) | **0** | ✅ **Complete** |
| Actions (`/library/actions/impl/action/`) | **0** | ✅ **Complete** |
| Commands (`/library/actions/impl/command/`) | **0** | ✅ **Complete** |
| Availability (`/library/actions/availability/`) | **0** | ✅ **Complete** |
| Command Choice Helpers (`/library/util/command_choice_helpers/`) | **0** | ✅ **Complete** |
| View Filters (`/library/util/view_filters/`) | **0** | ✅ **Complete** (except boundary code) |
| LLM Prompt Generators (`/library/actions/llm_prompt_generators/`) | **56** | ⏳ Remaining work (34 files) |
| Other Utilities (`/library/util/`) | **7** | ⏳ Remaining work (2 files) |
| Root Library (`/library/`) | **8** | Boundary code (3 files) |
**Total: ~65 proto imports remaining** (down from 149)
### Completed Phases
@@ -43,346 +59,227 @@
| Phase 2: EngineImpl | **Complete** | Holds Scala `GameState` internally |
| Phase 3: GameHistory | **Complete** | `stateAfter` returns Scala GameState |
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
| Phase 5: Action Base Classes | **Complete** | All actions converted to T-type base classes |
| Phase 5b-d: RoundPhaseAdvancer | **Complete** | Uses Scala GameState throughout |
| Phase 6: Core Engine | **Complete** | ActionResultApplier, RandomStateSequencer protoless |
| Phase 6b: CommandFactory | **Complete** | Uses Scala SelectedCommand and AvailableCommand |
| Phase 6c: AI Clients | **Complete** | AIClient, command choosers all protoless |
| Phase 6d: CommandSelection | **Complete** | Renamed ScalaCommandSelection → CommandSelection |
### Phase 5c/5d Progress (Complete)
### Recent Completions (PRs #5326, #5330, #5332, #5333, #5334, #5336, #5341, #5342)
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
| Action | PR | Status |
|--------|-----|--------|
| `PrisonerExchangeAction` | #4670 | ✅ Merged |
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
### EngineImpl Progress
| Change | PR | Status |
|--------|-----|--------|
| `recursiveTransform` deleted | #4677 | ✅ Merged |
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
### Current Architecture
**ActionResultT Production (100% Complete):**
- All actions produce `ActionResultT`
- Conversion to `ActionResultProto` happens via `ActionResultProtoConverter.toProto()`
- No direct `ActionResultProto` construction outside the converter
**ActionResultProto Consumption (Next Target):**
- `ActionResultProtoApplierImpl` - applies proto results to proto GameState
- `RoundPhaseAdvancer` - calls converter, passes protos to applier
- `InMemoryHistory` / `PersistedHistory` - stores proto results
- Service layer (`GameController`, `GamesManager`, etc.) - uses proto for client communication
1. **CommandFactory protoless** - Now accepts/returns Scala `SelectedCommand` types
2. **AI clients protoless** - `AIClient`, `MidGameAIClient`, all command choosers use Scala types
3. **CommandSelection cleanup** - Deleted legacy proto-based `CommandSelection`, renamed `ScalaCommandSelection``CommandSelection`
4. **AvailableCommandsFactory protoless** - Returns Scala `OneProvinceAvailableCommands`
5. **All CommandChoiceHelpers protoless** - All selector/chooser files converted
6. **ChronicleEventGenerator protoless** (PR #5332) - Removed last proto enum import from Actions layer
7. **ProvinceUtils protoless** (PR #5333) - Converted to use Scala `BattalionType`
8. **FactionViewFilter protoless** (PR #5334) - Returns Scala `FactionView`, converts to proto at edge
9. **HeroViewFilter protoless** (PR #5336) - Returns Scala `HeroView`, converts to proto at edge
10. **ProvinceViewFilter protoless** (PR #5341) - `ProvinceView.knownEvents` uses Scala `ProvinceEvent`, converts at edge
11. **StatWithConditionUtils & ProvinceEventUtils protoless** (PR #5342) - Deleted unused proto overloads
12. **IncomingArmyUtils protoless** (PR #5348) - Deleted unused `stats()` method
13. **RansomOfferHelpers deleted** (PR #5351) - Deleted unused dead code file
---
## Phase 6: Migrate to ActionResultT Consumers
## Remaining Work
### Objective
### Phase 7: View Filters ✅ Complete
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
| File | Status | Migration Path |
|------|--------|----------------|
| `FactionViewFilter.scala` | ✅ **Complete** | Returns Scala `FactionView` (PR #5334) |
| `HeroViewFilter.scala` | ✅ **Complete** | Returns Scala `HeroView` (PR #5336) |
| `ProvinceViewFilter.scala` | ✅ **Complete** | Returns Scala `ProvinceView` with Scala events (PR #5341) |
| `GameStateViewFilter.scala` | ✅ **Complete** | Returns Scala `GameStateView`, converted at boundary |
| `GameStateViewDiffer.scala` | ✅ **Complete** | Uses Scala diff types internally, converted at boundary |
| `BattleFilter.scala` | Keep | Uses `ShardokBattleView` (boundary code for battles) |
### Current Flow (Proto-Heavy)
```
Action.execute()
→ ActionResultT
→ ActionResultProtoConverter.toProto()
→ ActionResultProto
→ ActionResultProtoApplierImpl.applyActionResults()
→ GameStateProto
→ GameStateConverter.fromProto()
→ GameStateC
```
**Pattern established**: Create Scala view type → Create converter → Update filter to return Scala type → Convert at edge.
### Target Flow (T-Types Throughout)
```
Action.execute()
→ ActionResultT
→ ActionResultApplier.applyActionResults()
→ GameStateC
### Phase 8: LLM Prompt Generators (~56 proto imports in 34 files)
(Proto conversion only at boundaries)
```
The LLM prompt generators still use proto types for hero/faction/province data:
### Key Files to Convert
| File Category | Files | Proto Usage |
|---------------|-------|-------------|
| Diplomacy prompts | 12 | `Hero`, `Faction`, `Province` protos |
| Quest prompts | 4 | `Hero`, `Faction` protos |
| Chronicle prompts | 3 | `Hero`, `Faction`, `Province` protos |
| Other prompts | 15 | Various proto types |
**Tier 1 - Core Applier:****Complete**
```
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
```
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
**Migration strategy**: These files generate text for LLM prompts. They can be migrated to accept Scala types (`HeroT`, `FactionT`, `ProvinceT`) with converters at the call sites if needed.
**Tier 2 - RoundPhaseAdvancer:****Complete**
```
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
```
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
**Priority**: Medium - These don't block other migrations and are isolated.
**Tier 3 - Sequencers:**
```
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateTSequencer.scala
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateProtoSequencer.scala
```
Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently converts to proto internally). Then evaluate whether `RandomStateProtoSequencer` is still needed at all.
### Phase 9: Remaining Utilities (~1 proto import in 1 file)
**Current State**: `RandomStateTSequencer` accepts Scala `GameState` via its `apply()` method but internally converts to proto. All callback methods (`withRandomActionResult`, `withActionResults`, etc.) pass `GameStateProto` to callers, forcing actions that use the sequencer to work with proto types internally.
| File | Proto Usage | Migration Path |
|------|-------------|----------------|
| `ProvinceEventUtils.scala` | ✅ **0** | Deleted unused proto overloads (PR #5342) |
| `StatWithConditionUtils.scala` | ✅ **0** | Deleted unused proto overloads (PR #5342) |
| `MapGenerator.scala` | 1 import | Convert to Scala types |
| `IncomingArmyUtils.scala` | ✅ **0** | Deleted unused `stats()` method (PR #5348) |
| `GameStateViewDiffer.scala` | ✅ **0** | Uses Scala diff types, converts at boundary |
**Target State**: Create a fully protoless sequencer where:
1. `lastState` returns Scala `GameState` (not `lastStateProto`)
2. All callback methods pass Scala `GameState` to callers
3. Actions using the sequencer can be fully protoless
### Phase 10: History APIs 🔄 In Progress
**Migration Path**:
1. Add `lastState: GameState` method alongside `lastStateProto` (non-breaking)
2. Add parallel callback methods that pass Scala GameState (e.g., `withScalaActionResult`)
3. Migrate actions one by one to use the new Scala-based callbacks
4. Once all actions migrated, deprecate/remove proto-based callbacks
5. Remove `lastStateProto` once no longer used
**Completed:**
- `ActionWithResultingState` now has `scalaActionResult` property (lazy converted from proto)
- `ActionResultFilter.includeForPlayer` uses Scala types (`ActionResultT`, `NotificationT`, `ActionResultType`)
- `GameHistory.withNewResultsScala` preserves both Scala `GameState` and `ActionResultT` to avoid re-conversion
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
| Action | Status |
|--------|--------|
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformReconResolutionAction` | ✅ Migrated |
| `NewRoundAction` | ✅ Migrated (PR #4698) |
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
**TCommandFactory Extraction** (PR #4684):
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
- `TCommandFactory` - lightweight trait with just `makeTCommand`
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
- Actions accepting command factories now use `TCommandFactory` type for better testability
**Tier 4 - History APIs:**
**Remaining:**
```
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
src/main/scala/net/eagle0/eagle/service/PersistedHistory.scala
```
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions. `PersistedHistory` converts to proto internally for disk persistence; `InMemoryHistory` doesn't need proto at all.
### ActionResultProto Consumer Inventory
| File | Usage | Status |
|------|-------|--------|
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
| `ResolveBattleAction.scala` | ✅ Fully protoless | Complete (PR #5048) |
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
### Remaining Proto Usage in Actions
**Progress: 52 of 52 action files (100%) are fully protoless.**
All action files have been migrated to use Scala types:
| Action | Status | Notes |
|--------|--------|-------|
| `ResolveBattleAction` | ✅ Complete | PR #5048 - uses Scala GameState and ActionResultApplier |
| `PerformVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndHandleRiotsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `PerformVassalDefenseDecisionsAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
### Estimated Effort (Remaining)
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~100** | | |
**Completed:**
-`ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
-`CommandChoiceHelpers` migrated to Scala types
-`ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
### Enum Type Migrations
Proto enums are being converted to Scala sealed traits with converters at boundaries:
| Enum | Scala Type | Status | Notes |
|------|------------|--------|-------|
| `DiplomacyOfferStatus` | `Status` sealed trait | ✅ **Complete** | PR #5093 - `EligibleDiplomacyStatuses` uses Scala types internally |
| `RoundPhase` | `RoundPhase` sealed trait | Partial | Some usages converted |
| `BattalionType` | `BattalionType` sealed trait | Partial | Some usages converted |
**DiplomacyOfferStatus Migration (PR #5093):**
- `EligibleDiplomacyStatuses.scala` now returns `Vector[Status]` instead of `Vector[DiplomacyOfferStatus]`
- Call sites in `AvailableResolve*CommandFactory` files convert to proto via `StatusConverter.toProto` at the boundary
- This pattern should be applied to other proto enums
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
| File | Status | Notes |
|------|--------|-------|
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState` throughout |
| `ProvinceGoldSurplusCalculator.scala` | ✅ **Protoless** | Uses Scala types |
**All CommandChoiceHelpers selectors have been migrated to Scala types.**
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 52 / 52 (100%) ✅ |
| Proto usages in remaining actions | 0 |
| Next target | See "Next Candidates" section below |
### Next Candidates
Priority candidates for further deproto work:
1. **More Enum Migrations** - Apply the `DiplomacyOfferStatus` pattern to other proto enums:
- Files importing `net.eagle0.eagle.common.round_phase.RoundPhase` (proto) could use Scala `RoundPhase`
- Files importing `net.eagle0.eagle.common.battalion_type.BattalionType` (proto) could use Scala `BattalionType`
2. **AvailableCommandsFactory Files** - Many still use proto `GameState` internally:
- These files build proto `AvailableCommand` messages but could use Scala types for internal logic
- Convert to accept Scala `GameState`, only convert fields to proto when building the response
3. **History APIs** - `InMemoryHistory` and `PersistedHistory`:
- Change to vend Scala `GameState` and `ActionResultT` instead of proto versions
- `PersistedHistory` converts to proto internally for disk persistence
### Validation
- [x] `ActionResultApplier` created and tested
- [x] `RandomStateSequencer` threads Scala GameState throughout
- [x] `RoundPhaseAdvancer` uses T-types internally
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
- [x] `CommandChoiceHelpers` uses Scala types ✅
- [x] All action files (52/52) are fully protoless ✅
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
- [ ] All tests pass
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions.
---
## Phase 7: Clean Up Legacy Utilities
## Architecture Summary
### Objective
Remove remaining direct proto imports from utility classes.
### Fully Protoless Areas ✅
### Files to Modify
- **AI layer** (`/ai/`) - All AI clients and command choosers
- **Actions** (`/library/actions/impl/action/`) - All 52 action files
- **Commands** (`/library/actions/impl/command/`) - CommandFactory and all commands
- **Availability** (`/library/actions/availability/`) - AvailableCommandsFactory and all factories
- **Command helpers** (`/library/util/command_choice_helpers/`) - All selectors and choosers
- **Core types** - `CommandSelection`, `SelectedCommand`, `AvailableCommand`, `OneProvinceAvailableCommands`
- **View types** - `FactionView`, `HeroView`, `ProvinceView` (return Scala, convert at edge)
| File | Status |
|------|--------|
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
### Proto at Boundaries (Expected) ✅
### View Filters (Partially Complete)
The view filter utilities now have Scala overloads for server-side use:
| File | Status | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
**Unblocked Actions** (PR #4752):
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
- `PerformReconResolutionAction` - can now use Scala overload
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
**Remaining Work**:
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
- `withdrawnFromProvinceView` still uses proto types
- These are needed for client-facing views with visibility restrictions
---
## Phase 8: Verify Boundaries
### Objective
Confirm protos are used correctly at boundaries — and ONLY there.
### Expected Proto Usage (Keep)
- `EagleServiceImpl.scala` - gRPC boundary
- `InMemoryHistory.scala` / `PersistedHistory.scala` - Persistence boundary
- `GameController.scala` - Client communication
- `GameStateViewFilter.scala` - Converts Scala views to proto for client
- `*Converter.scala` - Explicit conversion utilities
- `*Loader.scala` - File loading utilities
- `PersistedHistory.scala` - Disk persistence
### Expected No Proto Usage (Verify)
- `/library/actions/impl/` - Pure Scala models
- `/library/util/` - Pure Scala models (except loaders)
- `/model/state/` - Pure Scala models
### Remaining Proto Usage
- LLM prompt generators (56 imports in 34 files) - Medium priority
- MapGenerator (1 import) - Low priority
- BattleFilter (1 import) - Keep (boundary)
- History API internals - Low priority
---
## Open Questions
## Estimated Remaining Effort
1. **Persistence Format**: Currently game state is persisted as proto. Should we keep proto for persistence (good for schema evolution) or switch to a different format?
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
| Component | Files | Imports | Priority |
|-----------|-------|---------|----------|
| View Filters | 0 | 0 | ✅ Complete |
| LLM Prompt Generators | 34 | 56 | Medium |
| Utility files | 1 | 1 | Low |
| Root Library (boundary) | 3 | 8 | Keep |
| **Total** | **37** | **~65** | |
---
## Success Criteria
### Code Quality
- [ ] Zero proto imports in `/library/actions/` (except boundaries)
- [ ] Zero proto imports in `/library/` utilities (except loaders)
- [ ] `GameStateT` used throughout engine internals
- [ ] Proto usage limited to: `EagleServiceImpl`, loaders, converters, persistence
- [x] Zero proto imports in `/ai/`
- [x] Zero proto imports in `/library/actions/impl/command/`
- [x] Zero proto imports in `/library/actions/availability/`
- [x] Zero proto imports in `/library/util/command_choice_helpers/`
- [x] Zero proto imports in `/library/actions/impl/action/`
- [x] Zero proto imports in `/library/util/view_filters/` (except BattleFilter boundary code)
- [ ] Zero proto imports in `/library/actions/llm_prompt_generators/`
- [ ] Zero proto imports in `/library/util/` (except view filters)
### Architecture
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
- [ ] Converters as the only bridge between domains
- [x] Clear separation: Scala models (internal) vs Proto (boundaries)
- [x] Converters as the only bridge between domains
- [x] CommandFactory accepts/returns Scala types
- [x] AI layer fully protoless
- [x] FactionViewFilter returns Scala types
- [x] HeroViewFilter returns Scala types
- [x] ProvinceViewFilter returns Scala types
- [x] All view filters return Scala types
- [ ] LLM layer uses Scala types
- [ ] No "proto creep" into business logic
---
## Open Questions
1. **LLM Prompt Generators**: Should these accept Scala types directly, or is it acceptable to have proto usage here since they're generating text (not core game logic)?
2. ~~**GameStateViewDiffer**: This works with view protos for client updates. Should it remain proto-based since it's generating client-facing data?~~ **Resolved**: GameStateViewDiffer now uses Scala types internally (`GameStateViewDiff`, `ProvinceViewDiff`, etc.) and converts to proto at the boundary in `ActionResultFilter`.
3. **History Serialization**: Keep proto for persistence (good for schema evolution) or consider alternatives?
---
## Proto Import Inventory (Detailed)
**Current: ~65 proto imports across 37 files** (as of 2026-01-14)
### Summary by Directory
| Directory | Files | Imports | Status |
|-----------|-------|---------|--------|
| `ai/` | 0 | 0 | ✅ Clean |
| `actions/availability/` | 0 | 0 | ✅ Clean |
| `actions/impl/action/` | 0 | 0 | ✅ Clean |
| `actions/impl/command/` | 0 | 0 | ✅ Clean |
| `actions/llm_prompt_generators/` | 34 | 56 | LLM request types |
| `util/command_choice_helpers/` | 0 | 0 | ✅ Clean |
| `util/view_filters/` | 1 | 1 | ✅ Clean (except boundary) |
| `util/` (other) | 1 | 1 | ✅ Clean |
| Root (`library/`) | 3 | 9 | Boundary code |
### util/view_filters/ (0 imports, except boundary)
| File | Import | Notes |
|------|--------|-------|
| `GameStateViewFilter.scala` | ✅ **0** | Returns Scala `GameStateView`, converts at edge |
| `GameStateViewDiffer.scala` | ✅ **0** | Uses Scala diff types, converts at edge |
| `BattleFilter.scala` | 1 | Battle view boundary (keep proto) |
### util/ other files (1 import, 1 file)
| File | Imports | Notes |
|------|---------|-------|
| `MapGenerator.scala` | 1 | Map generation |
### Root library/ (8 imports, 3 files)
| File | Imports | Notes |
|------|---------|-------|
| `ActionResultFilter.scala` | 2 | Action result filtering (boundary, uses Scala types internally) |
| `Engine.scala` | 1 | Trait interface |
| `EngineImpl.scala` | 5 | Returns proto for persistence (boundary) |
### actions/llm_prompt_generators/ (56 imports, 34 files)
These files generate LLM prompts and primarily use `internal.generated_text_request.*` types plus some common enums like `DiplomacyOfferStatus` and `BattalionTypeId`.
**Migration strategy**: Can be migrated to Scala types when convenient, but low priority as they're isolated from core game logic.
---
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
4. Delete `Legacy*` files when no longer needed
### Deleted Legacy Files (no production callers)
- `LegacyProvinceDistances`
- `LegacyBattalionSuitability`
- `LegacyFoodConsumptionUtils`
- `LegacyHandleRiotUtils`
+7
View File
@@ -112,6 +112,13 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Apple OAuth callback (Apple uses POST with form_post response mode)
location /oauth/apple/callback {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Invitation landing page (proxied to Go auth service)
location /invite/ {
proxy_pass http://auth:8080;
+3
View File
@@ -23,5 +23,8 @@ mkdir -p "$OUTPUT_DIR"
rm -rf "$OUTPUT_DIR/SparklePlugin.bundle"
unzip -o "$ZIP_PATH" -d "$OUTPUT_DIR/"
# Convert Info.plist from binary to XML format (Unity requires XML)
/usr/bin/plutil -convert xml1 "$OUTPUT_DIR/SparklePlugin.bundle/Contents/Info.plist"
echo "=== SparklePlugin built successfully ==="
ls -la "$OUTPUT_DIR/SparklePlugin.bundle/"
@@ -57,20 +57,10 @@ namespace Auth {
/// Returns both the URL and the state token for polling.
/// Routed to Go auth service.
/// </summary>
/// <param name="provider">Discord or Google</param>
/// <param name="invitationCode">Optional invitation code for new account
/// registration</param> <returns>Tuple of (URL to open in browser, state token for
/// polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(
OAuthProvider provider,
string invitationCode = null) {
/// <param name="provider">OAuth provider (Discord, Google, GitHub, Apple)</param>
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
var request = new GetOAuthUrlRequest { Provider = provider };
if (!string.IsNullOrEmpty(invitationCode)) {
request.InvitationCode = invitationCode;
Debug.Log("[AuthClient] Including invitation code in OAuth request");
}
var response = await _authServiceClient.GetOAuthUrlAsync(request);
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
@@ -103,10 +93,6 @@ namespace Auth {
case OAuthStatus.Expired:
throw new Exception("OAuth session expired. Please try again.");
case OAuthStatus.InvitationRequired:
Debug.Log("[AuthClient] Server requires invitation code for new account");
return response; // Return so OAuthManager can handle this
case OAuthStatus.Pending:
// Check timeout
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
@@ -1,141 +0,0 @@
using System;
using System.IO;
using UnityEngine;
namespace Auth {
/// <summary>
/// Manages invitation codes for new account registration.
/// Reads codes from installer-provided file or allows manual entry.
/// </summary>
public static class InvitationCodeManager {
private const string InvitationFileName = "invitation.json";
private const string PlayerPrefsKey = "InvitationCode";
// Cache the code to avoid repeated file reads
private static string _cachedCode;
private static bool _cacheInitialized;
/// <summary>
/// Get the invitation code, if available.
/// Checks: 1) Cached value, 2) PlayerPrefs, 3) File from installer
/// </summary>
public static string GetInvitationCode() {
if (_cacheInitialized) { return _cachedCode; }
// Check PlayerPrefs first (manual entry takes priority)
string prefsCode = PlayerPrefs.GetString(PlayerPrefsKey, null);
if (!string.IsNullOrEmpty(prefsCode)) {
_cachedCode = prefsCode;
_cacheInitialized = true;
Debug.Log("[InvitationCodeManager] Using invitation code from PlayerPrefs");
return _cachedCode;
}
// Try to read from installer file
string fileCode = ReadFromFile();
if (!string.IsNullOrEmpty(fileCode)) {
_cachedCode = fileCode;
_cacheInitialized = true;
Debug.Log("[InvitationCodeManager] Using invitation code from installer file");
return _cachedCode;
}
_cacheInitialized = true;
return null;
}
/// <summary>
/// Set invitation code manually (e.g., from UI input).
/// </summary>
public static void SetInvitationCode(string code) {
if (string.IsNullOrEmpty(code)) {
PlayerPrefs.DeleteKey(PlayerPrefsKey);
_cachedCode = null;
} else {
PlayerPrefs.SetString(PlayerPrefsKey, code);
_cachedCode = code;
}
_cacheInitialized = true;
PlayerPrefs.Save();
Debug.Log(
$"[InvitationCodeManager] Invitation code {(string.IsNullOrEmpty(code) ? "cleared" : "set")}");
}
/// <summary>
/// Clear the invitation code after successful account creation.
/// </summary>
public static void ClearInvitationCode() {
PlayerPrefs.DeleteKey(PlayerPrefsKey);
_cachedCode = null;
_cacheInitialized = true;
PlayerPrefs.Save();
// Also delete the installer file if it exists
DeleteInstallerFile();
Debug.Log("[InvitationCodeManager] Invitation code cleared");
}
/// <summary>
/// Check if an invitation code is available.
/// </summary>
public static bool HasInvitationCode => !string.IsNullOrEmpty(GetInvitationCode());
private static string ReadFromFile() {
try {
string installDir = GetInstallDirectory();
if (string.IsNullOrEmpty(installDir)) { return null; }
string filePath = Path.Combine(installDir, InvitationFileName);
if (!File.Exists(filePath)) { return null; }
string json = File.ReadAllText(filePath);
// Simple JSON parsing for {"invitation_code":"XXXX"}
var parsed = JsonUtility.FromJson<InvitationFile>(json);
return parsed?.invitation_code;
} catch (Exception ex) {
Debug.LogWarning(
$"[InvitationCodeManager] Failed to read invitation file: {ex.Message}");
return null;
}
}
private static void DeleteInstallerFile() {
try {
string installDir = GetInstallDirectory();
if (string.IsNullOrEmpty(installDir)) { return; }
string filePath = Path.Combine(installDir, InvitationFileName);
if (File.Exists(filePath)) {
File.Delete(filePath);
Debug.Log("[InvitationCodeManager] Deleted installer invitation file");
}
} catch (Exception ex) {
Debug.LogWarning(
$"[InvitationCodeManager] Failed to delete invitation file: {ex.Message}");
}
}
private static string GetInstallDirectory() {
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
// Windows: %LOCALAPPDATA%\eagle0
string localAppData =
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "eagle0");
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
// Mac: ~/Library/Application Support/eagle0
string appSupport =
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return Path.Combine(appSupport, "eagle0");
#else
// Other platforms not yet supported
return null;
#endif
}
[Serializable]
private class InvitationFile {
public string invitation_code;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a2a19f11d1f82412e8e2811b366113ac
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using common;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
@@ -25,7 +26,6 @@ namespace Auth {
public event Action<string> OnLoginFailed;
public event Action OnLogout;
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
public event Action OnInvitationRequired; // new user needs invitation code
public bool IsAuthenticated => TokenStorage.HasValidToken;
public string DisplayName => TokenStorage.DisplayName;
@@ -87,11 +87,11 @@ namespace Auth {
_currentLoginProvider = provider; // Track for later storage
try {
// Get invitation code if available (for new account registration)
string invitationCode = InvitationCodeManager.GetInvitationCode();
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider, invitationCode);
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}");
@@ -100,12 +100,8 @@ namespace Auth {
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// Handle invitation required status
if (response.Status == OAuthStatus.InvitationRequired) {
Debug.Log("[OAuthManager] Server requires invitation code for new account");
OnInvitationRequired?.Invoke();
throw new Exception("An invitation code is required to create a new account");
}
// Bring game back to foreground now that auth is complete
WindowFocusManager.BringToForeground();
// Store tokens with provider info
var providerName = provider.ToString().ToLowerInvariant();
@@ -117,9 +113,7 @@ namespace Auth {
response.User.DisplayName ?? "",
providerName);
// Clear invitation code after successful new account creation
if (response.IsNewUser) {
InvitationCodeManager.ClearInvitationCode();
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
OnLoginSuccess?.Invoke(response.User);
@@ -127,6 +121,9 @@ namespace Auth {
return response;
} catch (Exception ex) {
// Bring game back to foreground even on failure
WindowFocusManager.BringToForeground();
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
throw;
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 6ddb90d3140384d50a98a19e72539b62
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,221 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using common;
using TMPro;
using UnityEngine;
using UnityGoDiceInterface;
public class DiceConfigurationPanelController : MonoBehaviour {
private DiceInterface _diceInterface;
public EventBasedTable diceRollGroup;
public PickerRowController pickerRowPrefab;
public TMP_Text instructionsLabel;
public delegate void ConfigurationCallback(DieInfo? tensDieInfo, DieInfo? onesDieInfo);
private ConfigurationCallback _configurationCallback;
private DieInfo? _tensDieInfo = null;
private DieInfo? _onesDieInfo = null;
private List<DieInfo> _knownDice = new List<DieInfo>();
private HashSet<string> _connectingIdentifiers = new HashSet<string>();
void Awake() {}
public void GetConfiguration(ConfigurationCallback cb) {
if (_diceInterface == null) { _diceInterface = GetComponentInParent<DiceInterface>(); }
_diceInterface.deviceFoundCallback = DeviceFoundCallback;
_diceInterface.connectionCallback = ConnectionCallback;
_diceInterface.connectionFailedCallback = ConnectionFailedCallback;
_diceInterface.disconnectionCallback = DisconnectionCallback;
_diceInterface.listenerStoppedCallback = ListenerStoppedCallback;
_diceInterface.colorCallback = ReceiveColorCallback;
string currentPath = NewLogLocation();
Directory.CreateDirectory(Path.GetDirectoryName(currentPath));
_diceInterface.logger = log => {
MainQueue.Q.Enqueue(() => {
Debug.Log(log);
File.AppendAllText(currentPath, log);
});
};
_diceInterface.StartListening();
_configurationCallback = cb;
_tensDieInfo = null;
_onesDieInfo = null;
instructionsLabel.text = "Looking for dice...";
gameObject.SetActive(true);
}
string NewLogLocation() {
return Path.Combine(
Application.persistentDataPath,
"eagle0",
"Resources",
"DiceConfigurationLogs",
DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss") + ".log");
}
public void RowClicked(int rowIndex) {
if (_tensDieInfo == null) {
_tensDieInfo = _knownDice[rowIndex];
_knownDice.RemoveAt(rowIndex);
SetUpTable();
} else {
_diceInterface.StopListening();
_onesDieInfo = _knownDice[rowIndex];
_knownDice.RemoveAt(rowIndex);
_knownDice.ForEach(dieInfo => _diceInterface.Disconnect(dieInfo.identifier));
// _diceInterface.deviceFoundCallback = null;
// _diceInterface.connectionCallback = null;
// _diceInterface.disconnectionCallback = null;
// _diceInterface.listenerStoppedCallback = null;
// _diceInterface.rollCallback = null;
// _diceInterface.colorCallback = null;
_configurationCallback(_tensDieInfo, _onesDieInfo);
gameObject.SetActive(false);
}
}
private void OnEnable() {}
private void OnDisable() { diceRollGroup.RowCount = 0; }
void ListenerStoppedCallback() { Debug.Log("Listener stopped!"); }
void SetUpTable() {
diceRollGroup.RowCount = _knownDice.Count;
if (_knownDice.Count == 0) {
instructionsLabel.text = "Looking for dice...";
} else if (_tensDieInfo == null) {
instructionsLabel.text = "Select a TENS die:";
} else {
instructionsLabel.text = "Select an ONES die:";
}
for (int i = 0; i < _knownDice.Count; i++) {
var row = diceRollGroup.ComponentAt<PickerRowController>(i);
row.Text = _knownDice[i].deviceName;
row.TextColor = UnityDieColors.ColorFromDieColor(_knownDice[i].color);
}
}
void ReceiveColorCallback(string identifier, DieColor dieColor) {
MainQueue.Q.Enqueue(() => {
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
var colorName = dieColor.ToString();
_knownDice[existingIndex] = new DieInfo(
identifier: _knownDice[existingIndex].identifier,
deviceName: colorName,
color: dieColor,
connected: true);
} else {
Debug.Log("This shouldn't happen");
}
SetUpTable();
});
}
void DeviceFoundCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if (_connectingIdentifiers.Contains(identifier)) { return; }
_connectingIdentifiers.Add(identifier);
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
_knownDice[existingIndex] =
new DieInfo(identifier, "Unknown", DieColor.DieColorBlack);
} else {
_knownDice.Add(new DieInfo(identifier, "Unknown", DieColor.DieColorBlack));
}
SetUpTable();
_diceInterface.Connect(identifier);
});
}
void ConnectionCallback(string identifier, string deviceName) {
MainQueue.Q.Enqueue(() => {
_connectingIdentifiers.Remove(identifier);
if (_tensDieInfo != null && _tensDieInfo.Value.identifier.Equals(identifier)) {
return;
}
if (_onesDieInfo != null && _onesDieInfo.Value.identifier.Equals(identifier)) {
return;
}
var shortenedName = deviceName.Split('_')[1];
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
_knownDice[existingIndex] =
new DieInfo(identifier, shortenedName, DieColor.DieColorBlack);
} else {
_knownDice.Add(new DieInfo(identifier, shortenedName, DieColor.DieColorBlack));
}
SetUpTable();
_diceInterface.RequestColor(identifier);
});
}
void ConnectionFailedCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
_connectingIdentifiers.Remove(identifier);
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
_knownDice.RemoveAt(existingIndex);
SetUpTable();
}
});
}
void DisconnectionCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
_connectingIdentifiers.Remove(identifier);
if (_tensDieInfo is {} tensInfo && tensInfo.identifier.Equals(identifier)) {
_tensDieInfo = null;
_onesDieInfo = null;
} else if (_onesDieInfo is {} onesInfo && onesInfo.identifier.Equals(identifier)) {
_onesDieInfo = null;
}
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) { _knownDice.RemoveAt(existingIndex); }
SetUpTable();
_diceInterface.Connect(identifier);
});
}
public void DismissButtonClicked() {
gameObject.SetActive(false);
_configurationCallback(null, null);
}
// Update is called once per frame
void Update() {}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: e694fb18ea1df4034b392352bf3aa04a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,213 +0,0 @@
#if UNITY_IOS || UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
#define USE_SWIFT_INTERFACE
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN || ENABLE_WINMD_SUPPORT
#define USE_WINDOWS_INTERFACE
#else
#endif
using System.Collections.Generic;
using UnityEngine;
using UnityGoDiceInterface;
public class DiceInterface : MonoBehaviour {
public struct Commands {
public static readonly byte PulseLed = 16;
public static readonly byte GetColor = 23;
}
readonly NativeDiceInterfaceImports _diceInterfaceImports = new();
private static DiceInterface _singleton = null;
private static Dictionary<string, string> _deviceNames = new Dictionary<string, string>();
public delegate void DeviceFoundCallback(string identifier);
public delegate void ConnectionCallback(string identifier, string deviceName);
public delegate void ConnectionFailedCallback(string identifier);
public delegate void DisconnectionCallback(string identifier);
public delegate void ListenerStoppedCallback();
public delegate void RollCallback(string identifier, sbyte x, sbyte y, sbyte z);
public delegate void ColorCallback(string identifier, DieColor color);
public delegate void LoggerCallback(string log);
public DeviceFoundCallback deviceFoundCallback = null;
public ConnectionCallback connectionCallback = null;
public ConnectionFailedCallback connectionFailedCallback = null;
public DisconnectionCallback disconnectionCallback = null;
public ListenerStoppedCallback listenerStoppedCallback = null;
public RollCallback rollCallback = null;
public ColorCallback colorCallback = null;
public LoggerCallback logger = null;
public void StartListening() { _diceInterfaceImports.StartListening(); }
public void StopListening() { _diceInterfaceImports.StopListening(); }
public void Connect(string identifier) { _diceInterfaceImports.Connect(identifier); }
public void Disconnect(string identifier) { _diceInterfaceImports.Disconnect(identifier); }
public void Reset() {
_diceInterfaceImports.Reset();
_deviceNames.Clear();
}
public void RequestColor(string identifier) {
_diceInterfaceImports.Send(identifier, new List<byte> { Commands.GetColor });
}
public void FlashColor(
string identifier,
byte pulseCount,
byte onTime10ms,
byte offTime10ms,
byte red,
byte green,
byte blue) {
_diceInterfaceImports.Send(
identifier,
new List<byte> {
Commands.PulseLed,
pulseCount,
onTime10ms,
offTime10ms,
red,
green,
blue,
0x01,
0x00
});
}
// Start is called before the first frame update
void Start() {
_singleton = this;
_diceInterfaceImports.SetDeviceConnectedCallback(DeviceConnectedDelegateMessageReceived);
_diceInterfaceImports.SetDeviceConnectionFailedCallback(
DeviceConnectionFailedDelegateMessageReceived);
_diceInterfaceImports.SetDeviceDisconnectedCallback(
DeviceDisconnectedDelegateMessageReceived);
_diceInterfaceImports.SetDeviceFoundCallback(DeviceFoundDelegateMessageReceived);
_diceInterfaceImports.SetDataCallback(DataDelegateMessageReceived);
_diceInterfaceImports.SetListenerStoppedCallback(ListenerStoppedDelegateMessageReceived);
_diceInterfaceImports.SetLoggerCallback(LoggerDelegateMessageReceived);
}
// Update is called once per frame
void Update() {}
private static sbyte[] GetRollVector(List<byte> rawData) {
if (rawData.Count < 1) {
Debug.Log("rollVector: no data");
return null;
}
byte firstByte = rawData[0];
if (firstByte != 83) {
Debug.Log("rollVector: first byte is not 83");
return null;
}
if (rawData.Count != 4) {
Debug.Log("rollVector: data length is not 4");
return null;
}
return new[] { (sbyte)rawData[1], (sbyte)rawData[2], (sbyte)rawData[3] };
}
private static void DeviceFoundDelegateMessageReceived(string identifier, string deviceName) {
if (_singleton != null) {
_deviceNames[identifier] = deviceName;
if (_singleton.deviceFoundCallback != null) {
_singleton.deviceFoundCallback(identifier);
}
}
}
private static void DeviceConnectedDelegateMessageReceived(string identifier) {
if (_singleton != null && _singleton.connectionCallback != null) {
_singleton.connectionCallback(identifier, _deviceNames[identifier]);
}
}
private static void DeviceConnectionFailedDelegateMessageReceived(string identifier) {
if (_singleton != null && _singleton.connectionFailedCallback != null) {
_singleton.connectionFailedCallback(identifier);
}
}
private static void DeviceDisconnectedDelegateMessageReceived(string identifier) {
if (_singleton != null && _singleton.disconnectionCallback != null) {
_singleton.disconnectionCallback(identifier);
}
}
private static void DataDelegateMessageReceived(string identifier, List<byte> byteList) {
if (_singleton != null) {
if (byteList.Count == 0) {
if (_singleton.connectionCallback != null) {
// I don't think this one should still happen
return;
}
} else {
byte firstByte = byteList[0];
switch (firstByte) {
case 82:
// Roll started
break;
case 66:
// Battery level
break;
case (byte)'C':
if (byteList[1] == (byte)'o' && byteList[2] == (byte)'l') {
byte colorRawValue = byteList[3];
_singleton.colorCallback(identifier, (DieColor)colorRawValue);
}
// Color (fetched)
break;
case 83: {
var roll = GetRollVector(byteList);
if (roll != null && _singleton.rollCallback != null) {
_singleton.rollCallback(identifier, roll[0], roll[1], roll[2]);
}
break;
}
case 70:
case 84:
case 77: {
byteList.RemoveAt(0);
var roll = GetRollVector(byteList);
if (roll != null && _singleton.rollCallback != null) {
_singleton.rollCallback(identifier, roll[0], roll[1], roll[2]);
}
break;
}
default: Debug.Log("Not yet handled"); break;
}
}
}
}
private static void ListenerStoppedDelegateMessageReceived() {
if (_singleton != null && _singleton.listenerStoppedCallback != null) {
_singleton.listenerStoppedCallback();
}
}
private static void LoggerDelegateMessageReceived(string log) {
if (_singleton != null && _singleton.logger != null) { _singleton.logger(log); }
}
public void OnDestroy() {
_diceInterfaceImports.SetDeviceConnectedCallback(null);
_diceInterfaceImports.SetDeviceConnectionFailedCallback(null);
_diceInterfaceImports.SetDeviceDisconnectedCallback(null);
_diceInterfaceImports.SetDeviceFoundCallback(null);
_diceInterfaceImports.SetDataCallback(null);
_diceInterfaceImports.SetListenerStoppedCallback(null);
_diceInterfaceImports.SetLoggerCallback(null);
#if UNITY_EDITOR
StopListening();
Reset();
#endif
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 75ea5d911cfab4851831d1de4b61f559
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,204 +0,0 @@
using System.Collections.Generic;
namespace UnityGoDiceInterface {
public class DiceVectors {
public struct Vector3 {
public sbyte x;
public sbyte y;
public sbyte z;
public Vector3(sbyte x, sbyte y, sbyte z) {
this.x = x;
this.y = y;
this.z = z;
}
}
public static int D6Value(sbyte x, sbyte y, sbyte z) {
return DieValueForVector(d6, new Vector3(x, y, z));
}
public static int D10Value(sbyte x, sbyte y, sbyte z) {
return d10Transform[DieValueForVector(d20, new Vector3(x, y, z))];
}
private static int sq(int x) { return x * x; }
private static int DieValueForVector(List<Vector3> table, Vector3 vector) {
var closestIndex = -1;
var closestDistance = int.MaxValue;
for (int i = 0; i < table.Count; i++) {
var entry = table[i];
var distance =
sq(entry.x - vector.x) + sq(entry.y - vector.y) + sq(entry.z - vector.z);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = i;
}
}
return closestIndex + 1;
}
// The private vectors are 0-based, so we need to add 1 to the index
private static List<Vector3> d6 = new List<Vector3> {
new Vector3(-64, 0, 0),
new Vector3(-0, 0, 64),
new Vector3(0, 64, 0),
new Vector3(0, -64, 0),
new Vector3(0, 0, -64),
new Vector3(64, 0, 0)
};
private static List<Vector3> d20 = new List<Vector3> {
new Vector3(-64, 0, -22), new Vector3(42, -42, 40), new Vector3(0, 22, -64),
new Vector3(0, 22, 64), new Vector3(-42, -42, 42), new Vector3(22, 64, 0),
new Vector3(-42, -42, -42), new Vector3(64, 0, -22), new Vector3(-22, 64, 0),
new Vector3(42, -42, -42), new Vector3(-42, 42, 42), new Vector3(22, -64, 0),
new Vector3(-64, 0, 22), new Vector3(42, 42, 42), new Vector3(-22, -64, 0),
new Vector3(42, 42, -42), new Vector3(0, -22, -64), new Vector3(0, -22, 64),
new Vector3(-42, 42, -42), new Vector3(64, 0, 22),
};
// static d24Vectors = {
// new Vector3(20, -60, -20),
// new Vector3(20, 0, 60),
// new Vector3(-40, -40, 40),
// new Vector3(-60, 0, 20),
// new Vector3(40, 20, 40),
// new Vector3(-20, -60, -20),
// new Vector3(20, 60, 20),
// new Vector3(-40, 20, -40),
// new Vector3(-40, 40, 40),
// new Vector3(-20, 0, 60),
// new Vector3(-20, -60, 20),
// new Vector3(60, 0, 20),
// new Vector3(-60, 0, -20),
// new Vector3(20, 60, -20),
// new Vector3(20, 0, -60),
// new Vector3(40, -20, -40),
// new Vector3(-20, 60, -20),
// new Vector3(-40, -40, -40),
// new Vector3(40, -20, 40),
// new Vector3(20, -60, 20),
// new Vector3(60, 0, -20),
// new Vector3(40, 20, -40),
// new Vector3(-20, 0, -60),
// new Vector3(-20, 60, 20),
// }
//
// Transforms from each shell type to according number on shell
// D20 Transforms
private static Dictionary<int, int> d10Transform = new Dictionary<int, int> {
{ 1, 8 }, { 2, 2 }, { 3, 6 }, { 4, 1 }, { 5, 4 }, { 6, 3 }, { 7, 9 },
{ 8, 0 }, { 9, 7 }, { 10, 5 }, { 11, 5 }, { 12, 7 }, { 13, 0 }, { 14, 9 },
{ 15, 3 }, { 16, 4 }, { 17, 1 }, { 18, 6 }, { 19, 2 }, { 20, 8 },
};
//
// static d10XTransform = {
// 1: 80,
// 2: 20,
// 3: 60,
// 4: 10,
// 5: 40,
// 6: 30,
// 7: 90,
// 8: 0,
// 9: 70,
// 10: 50,
// 11: 50,
// 12: 70,
// 13: 0,
// 14: 90,
// 15: 30,
// 16: 40,
// 17: 10,
// 18: 60,
// 19: 20,
// 20: 80,
// }
//
// // D24 Transforms
// static d4Transform = {
// 1: 3,
// 2: 1,
// 3: 4,
// 4: 1,
// 5: 4,
// 6: 4,
// 7: 1,
// 8: 4,
// 9: 2,
// 10: 3,
// 11: 1,
// 12: 1,
// 13: 1,
// 14: 4,
// 15: 2,
// 16: 3,
// 17: 3,
// 18: 2,
// 19: 2,
// 20: 2,
// 21: 4,
// 22: 1,
// 23: 3,
// 24: 2,
// }
//
// static d8Transform = {
// 1: 3,
// 2: 3,
// 3: 6,
// 4: 1,
// 5: 2,
// 6: 8,
// 7: 1,
// 8: 1,
// 9: 4,
// 10: 7,
// 11: 5,
// 12: 5,
// 13: 4,
// 14: 4,
// 15: 2,
// 16: 5,
// 17: 7,
// 18: 7,
// 19: 8,
// 20: 2,
// 21: 8,
// 22: 3,
// 23: 6,
// 24: 6,
// }
//
// static d12Transform = {
// 1: 1,
// 2: 2,
// 3: 3,
// 4: 4,
// 5: 5,
// 6: 6,
// 7: 7,
// 8: 8,
// 9: 9,
// 10: 10,
// 11: 11,
// 12: 12,
// 13: 1,
// 14: 2,
// 15: 3,
// 16: 4,
// 17: 5,
// 18: 6,
// 19: 7,
// 20: 8,
// 21: 9,
// 22: 10,
// 23: 11,
// 24: 12,
// }
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: e9d67d9a2f8145a999d04de91c27073d
timeCreated: 1702612480
@@ -1,28 +0,0 @@
namespace UnityGoDiceInterface {
public enum DieColor : byte {
DieColorBlack = 0,
DieColorRed = 1,
DieColorGreen = 2,
DieColorBlue = 3,
DieColorYellow = 4,
DieColorOrange = 5,
}
public struct DieInfo {
public string identifier;
public string deviceName;
public DieColor color;
public bool connected;
public DieInfo(
string identifier,
string deviceName,
DieColor color,
bool connected = false) {
this.identifier = identifier;
this.deviceName = deviceName;
this.color = color;
this.connected = connected;
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 2e1000782bb845399869ca7910eebcd6
timeCreated: 1703172326
@@ -1,177 +0,0 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using AOT;
namespace UnityGoDiceInterface {
public class NativeDiceInterfaceImports {
public delegate void DeviceFoundDelegateMessage(string identifier, string name);
public delegate void DataDelegateMessage(string identifier, List<byte> bytes);
public delegate void DeviceConnectedDelegateMessage(string identifier);
public delegate void DeviceConnectionFailedDelegateMessage(string identifier);
public delegate void DeviceDisconnectedDelegateMessage(string identifier);
public delegate void ListenerStoppedDelegateMessage();
public delegate void LoggerDelegateMessage(string log);
private static DeviceFoundDelegateMessage deviceFoundDelegate;
private static DataDelegateMessage dataDelegate;
private static DeviceConnectedDelegateMessage deviceConnectedDelegate;
private static DeviceConnectionFailedDelegateMessage deviceConnectionFailedDelegate;
private static DeviceDisconnectedDelegateMessage deviceDisconnectedDelegate;
private static ListenerStoppedDelegateMessage listenerStoppedDelegate;
private static LoggerDelegateMessage loggerDelegateMessage;
#if UNITY_IOS
private const string BundleName = "__Internal";
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
private const string BundleName = "DarwinGodiceBundle";
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
private const string BundleName = "GoDiceDll.dll";
#endif
private delegate void MonoDeviceFoundDelegateMessage(string identifier, string name);
private delegate void
MonoDataDelegateMessage(string identifier, UInt32 byteCount, IntPtr bytePtr);
private delegate void MonoDeviceConnectedDelegateMessage(string identifier);
private delegate void MonoDeviceConnectionFailedDelegateMessage(string identifier);
private delegate void MonoDeviceDisconnectedDelegateMessage(string identifier);
private delegate void MonoListenerStoppedDelegateMessage();
private delegate void MonoLoggerDelegateMessage(string log);
[DllImport(dllName: BundleName, EntryPoint = "godice_start_listening")]
private static extern void _NativeBridgeStartListening();
[DllImport(dllName: BundleName, EntryPoint = "godice_stop_listening")]
private static extern void _NativeBridgeStopListening();
[DllImport(dllName: BundleName, EntryPoint = "godice_set_callbacks")]
private static extern void _NativeBridgeSetCallbacks(
MonoDeviceFoundDelegateMessage deviceFoundDelegate,
MonoDataDelegateMessage dataDelegate,
MonoDeviceConnectedDelegateMessage deviceConnectedDelegate,
MonoDeviceConnectionFailedDelegateMessage deviceConnectionFailedDelegateMessage,
MonoDeviceDisconnectedDelegateMessage deviceDisconnectedDelegate,
MonoListenerStoppedDelegateMessage listenerStoppedDelegate);
[DllImport(dllName: BundleName, EntryPoint = "godice_connect")]
private static extern void _NativeBridgeConnect(string identifier);
[DllImport(dllName: BundleName, EntryPoint = "godice_disconnect")]
private static extern void _NativeBridgeDisconnect(string identifier);
[DllImport(dllName: BundleName, EntryPoint = "godice_send")]
private static extern void
_NativeBridgeSend(string identifier, UInt32 byteCount, IntPtr bytePtr);
[DllImport(dllName: BundleName, EntryPoint = "godice_set_logger")]
private static extern void _NativeBridgeSetLogger(MonoLoggerDelegateMessage loggerDelegate);
[DllImport(dllName: BundleName, EntryPoint = "godice_reset")]
private static extern void _NativeBridgeReset();
private static List<byte> BytesFromRawPointer(UInt32 byteCount, IntPtr bytes) {
byte[] array = new byte[byteCount];
if (byteCount > 0) { Marshal.Copy(bytes, array, 0, (int)byteCount); }
return new List<byte>(array);
}
/*
* Callback methods
*/
[MonoPInvokeCallback(typeof(MonoDeviceFoundDelegateMessage))]
private static void MonoDeviceFoundMessageReceived(string identifier, string name) {
if (deviceFoundDelegate != null) { deviceFoundDelegate(identifier, name); }
}
[MonoPInvokeCallback(typeof(MonoDataDelegateMessage))]
private static void
MonoDataMessageReceived(string identifier, UInt32 byteCount, IntPtr bytePtr) {
if (dataDelegate != null) {
dataDelegate(identifier, BytesFromRawPointer(byteCount, bytePtr));
}
}
[MonoPInvokeCallback(typeof(MonoDeviceConnectedDelegateMessage))]
private static void MonoDeviceConnected(string identifier) {
if (deviceConnectedDelegate != null) { deviceConnectedDelegate(identifier); }
}
[MonoPInvokeCallback(typeof(MonoDeviceConnectionFailedDelegateMessage))]
private static void MonoDeviceConnectionFailed(string identifier) {
if (deviceConnectionFailedDelegate != null) {
deviceConnectionFailedDelegate(identifier);
}
}
[MonoPInvokeCallback(typeof(MonoDeviceDisconnectedDelegateMessage))]
private static void MonoDeviceDisconnected(string identifier) {
if (deviceDisconnectedDelegate != null) { deviceDisconnectedDelegate(identifier); }
}
[MonoPInvokeCallback(typeof(MonoListenerStoppedDelegateMessage))]
private static void MonoListenerStopped() {
if (listenerStoppedDelegate != null) { listenerStoppedDelegate(); }
}
[MonoPInvokeCallback(typeof(MonoLoggerDelegateMessage))]
private static void MonoLogger(string log) {
if (loggerDelegateMessage != null) { loggerDelegateMessage(log); }
}
public void StartListening() {
_NativeBridgeSetCallbacks(
MonoDeviceFoundMessageReceived,
MonoDataMessageReceived,
MonoDeviceConnected,
MonoDeviceConnectionFailed,
MonoDeviceDisconnected,
MonoListenerStopped);
_NativeBridgeSetLogger(MonoLogger);
_NativeBridgeStartListening();
}
public void StopListening() { _NativeBridgeStopListening(); }
public void Connect(string identifier) { _NativeBridgeConnect(identifier); }
public void Disconnect(string identifier) { _NativeBridgeDisconnect(identifier); }
public void Send(string identifier, List<byte> data) {
byte[] byteArray = data.ToArray();
GCHandle pinnedArray = GCHandle.Alloc(byteArray, GCHandleType.Pinned);
IntPtr pointer = pinnedArray.AddrOfPinnedObject();
_NativeBridgeSend(identifier, (uint)data.Count, pointer);
pinnedArray.Free();
}
public void SetDeviceFoundCallback(DeviceFoundDelegateMessage deviceFound) {
deviceFoundDelegate = deviceFound;
}
public void SetDataCallback(DataDelegateMessage data) { dataDelegate = data; }
public void SetDeviceConnectedCallback(DeviceConnectedDelegateMessage deviceConnected) {
deviceConnectedDelegate = deviceConnected;
}
public void SetDeviceConnectionFailedCallback(
DeviceConnectionFailedDelegateMessage deviceConnectionFailed) {
deviceConnectionFailedDelegate = deviceConnectionFailed;
}
public void SetDeviceDisconnectedCallback(
DeviceDisconnectedDelegateMessage deviceDisconnected) {
deviceDisconnectedDelegate = deviceDisconnected;
}
public void SetListenerStoppedCallback(ListenerStoppedDelegateMessage listenerStopped) {
listenerStoppedDelegate = listenerStopped;
}
public void SetLoggerCallback(LoggerDelegateMessage logger) {
loggerDelegateMessage = logger;
}
public void Reset() { _NativeBridgeReset(); }
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 424476bb5b18c47039d649c28a58a8fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,447 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &2154262805260441194
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7181142147825592926}
- component: {fileID: 4917217417751722011}
- component: {fileID: 8876282893591316834}
- component: {fileID: 1837103631940551462}
- component: {fileID: 7123394496182663843}
m_Layer: 0
m_Name: OneDiceResult
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7181142147825592926
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
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: 145111913694346175}
- {fileID: 6185631331142294830}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &4917217417751722011
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 59f8146938fff824cb5fd77236b75775, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Padding:
m_Left: 0
m_Right: 0
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 4
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 1
m_ChildControlHeight: 1
m_ChildScaleWidth: 0
m_ChildScaleHeight: 0
m_ReverseArrangement: 0
--- !u!114 &8876282893591316834
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &1837103631940551462
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 680b7d1179eb34a0fa338286236db2e9, type: 3}
m_Name:
m_EditorClassIdentifier:
nameLabel: {fileID: 9015301821484574050}
d6Label: {fileID: 0}
d10Label: {fileID: 4483020407950697354}
--- !u!222 &7123394496182663843
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_CullTransparentMesh: 1
--- !u!1 &6943825177879701675
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6185631331142294830}
- component: {fileID: 4032026062387785650}
- component: {fileID: 4483020407950697354}
- component: {fileID: 742703408719749652}
- component: {fileID: 4292989854150447437}
m_Layer: 0
m_Name: result
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6185631331142294830
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
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: 7181142147825592926}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4032026062387785650
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_CullTransparentMesh: 1
--- !u!114 &4483020407950697354
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: 9
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4278190080
m_fontColor: {r: 0, g: 0, b: 0, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 48
m_fontSizeBase: 48
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!114 &742703408719749652
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &4292989854150447437
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &8198927819804470251
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 145111913694346175}
- component: {fileID: 8510898822381381753}
- component: {fileID: 9015301821484574050}
- component: {fileID: 4918688670369599320}
m_Layer: 0
m_Name: Name
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &145111913694346175
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
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: 7181142147825592926}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8510898822381381753
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
m_CullTransparentMesh: 1
--- !u!114 &9015301821484574050
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: 'Tens
'
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4278190080
m_fontColor: {r: 0, g: 0, b: 0, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 16
m_fontSizeBase: 16
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!114 &4918688670369599320
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 200
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: 1
m_LayoutPriority: 1
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 9acf3ba64a8c04e6083aae699248b0c4
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,32 +0,0 @@
using TMPro;
public class OneDiceRollController : TableRowController {
private string _identifier;
public TMP_Text nameLabel;
public TMP_Text d6Label;
public TMP_Text d10Label;
// Start is called before the first frame update
void Start() {}
// Update is called once per frame
void Update() {}
public void SetUp(string identifier, string deviceName) {
_identifier = identifier;
nameLabel.text = deviceName;
d6Label.text = "?";
d10Label.text = "?";
}
public void SetRoll(string identifier, string d6, string d10) {
_identifier = identifier;
d6Label.text = d6;
d10Label.text = d10;
}
public string GetIdentifier() { return _identifier; }
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 680b7d1179eb34a0fa338286236db2e9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,281 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &3756143511112798868
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5260824885446700081}
- component: {fileID: 757087991837136050}
- component: {fileID: 8474069203316727232}
m_Layer: 0
m_Name: Text (TMP)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5260824885446700081
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3756143511112798868}
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: 3793282440965013679}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &757087991837136050
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3756143511112798868}
m_CullTransparentMesh: 1
--- !u!114 &8474069203316727232
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3756143511112798868}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Orange
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4278229493
m_fontColor: {r: 0.9607843, g: 0.6, b: 0, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &5800426311821892848
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3793282440965013679}
- component: {fileID: 6926687573176944590}
- component: {fileID: -2050598564750628712}
- component: {fileID: 2781819584472077043}
- component: {fileID: -2772541110306127347}
- component: {fileID: 5171470299130298381}
m_Layer: 0
m_Name: PickerRow
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &3793282440965013679
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
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: 5260824885446700081}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6926687573176944590
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_CullTransparentMesh: 1
--- !u!114 &-2050598564750628712
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: 30
m_PreferredWidth: -1
m_PreferredHeight: 30
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &2781819584472077043
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &-2772541110306127347
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d7b0b988957134dbc9c5e270bd5e9e70, type: 3}
m_Name:
m_EditorClassIdentifier:
nameLabel: {fileID: 8474069203316727232}
--- !u!114 &5171470299130298381
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 876f18cbcbaea4cba9510a977f343c42, type: 3}
m_Name:
m_EditorClassIdentifier:
onClick:
m_PersistentCalls:
m_Calls: []
onRightClick:
m_PersistentCalls:
m_Calls: []
onLeftDown:
m_PersistentCalls:
m_Calls: []
onLeftUp:
m_PersistentCalls:
m_Calls: []
onRightDown:
m_PersistentCalls:
m_Calls: []
onRightUp:
m_PersistentCalls:
m_Calls: []
selectable: 1
rightSelectable: 0
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 9a5c83874e75141728c54f347958d4ae
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,19 +0,0 @@
using TMPro;
using UnityEngine.UI;
public class PickerRowController : TableRowController {
public TMP_Text nameLabel;
public string Text {
get => nameLabel.text;
set => nameLabel.text = value;
}
override public void ColorRow() { gameObject.GetComponent<Image>().color = BackgroundColor; }
// Start is called before the first frame update
void Start() {}
// Update is called once per frame
void Update() {}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: d7b0b988957134dbc9c5e270bd5e9e70
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,9 +0,0 @@
using Net.Eagle0.Shardok.Api;
namespace UnityGoDiceInterface {
public interface RollFetcher {
public delegate void RollResultCallback(int? result);
public void GetRoll(RollRequest rollType, RollResultCallback cb);
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: aade83941cf241b398049ff3fe0475cb
timeCreated: 1703812608
@@ -1,375 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityGoDiceInterface;
public class RollPanelController : MonoBehaviour, RollFetcher {
public DiceConfigurationPanelController diceConfigurationPanelController;
public Button skipContinueButton;
public TMP_Text skipContinueButtonText;
private DiceInterface _diceInterface;
public TMP_Text tensResultLabel;
public TMP_Text onesResultLabel;
public TMP_Text header;
public TMP_Text reconnectionLabel;
public TMP_Text runningTotalLabel;
private RollFetcher.RollResultCallback _rollResultCallback;
private RollRequest _rollRequest;
private string logLocation;
enum OpenEndedState { None, High, Low, RollComplete }
private OpenEndedState _openEndedState = OpenEndedState.None;
private readonly int _d100OpenEndedHighThreshold = 96;
private readonly int _d100OpenEndedLowThreshold = 5;
private DateTime _lastRollTime = DateTime.MinValue;
private readonly TimeSpan _disconnectionDelay = TimeSpan.FromMinutes(5);
private readonly TimeSpan _displayTime = TimeSpan.FromSeconds(3);
private readonly TimeSpan _openEndedResetTime = TimeSpan.FromSeconds(2);
public void GetRoll(RollRequest rollRequest, RollFetcher.RollResultCallback cb) {
// Check for dice enabled
if (PlayerPrefs.GetInt(SettingsPanelController.UseGoDiceKey, 0) == 0) {
cb(null);
return;
}
skipContinueButton.gameObject.SetActive(true);
if (_diceInterface == null) { _diceInterface = GetComponentInParent<DiceInterface>(); }
_onesResult = null;
_tensResult = null;
_runningTotal = 0;
_openEndedState = OpenEndedState.None;
skipContinueButtonText.text = "Skip";
_rollResultCallback = cb;
_rollRequest = rollRequest;
SetResultLabels();
logLocation = NewLogLocation();
Directory.CreateDirectory(Path.GetDirectoryName(logLocation));
if (string.IsNullOrEmpty(_tensDieInfo.identifier) ||
string.IsNullOrEmpty(_onesDieInfo.identifier)) {
GetConfiguration();
} else {
_diceInterface.deviceFoundCallback = DeviceFoundCallback;
_diceInterface.connectionCallback = ConnectionCallback;
_diceInterface.rollCallback = RollCallback;
_diceInterface.logger = LogFromNative;
// _diceInterface.StartListening();
SetAlphas();
if (!_tensDieInfo.connected) { _diceInterface.Connect(_tensDieInfo.identifier); }
if (!_onesDieInfo.connected) { _diceInterface.Connect(_onesDieInfo.identifier); }
}
gameObject.SetActive(true);
}
private DieInfo _tensDieInfo;
private DieInfo _onesDieInfo;
private int? _tensResult = null;
private int? _onesResult = null;
private int _runningTotal = 0;
private void SetAlphas() {
tensResultLabel.alpha = _tensDieInfo.connected ? 1.0f : 0.25f;
onesResultLabel.alpha = _onesDieInfo.connected ? 1.0f : 0.25f;
}
void OnEnable() {}
private void GetConfiguration() {
diceConfigurationPanelController.GetConfiguration(ConfigurationCallback);
}
public void ConfigureClicked() { GetConfiguration(); }
public void SkipClicked() {
gameObject.SetActive(false);
_rollResultCallback(_runningTotal);
_diceInterface.StopListening();
}
void ConfigurationCallback(DieInfo? tensOpt, DieInfo? onesOpt) {
MainQueue.Q.Enqueue(() => {
if (tensOpt is DieInfo tens && onesOpt is DieInfo ones) {
gameObject.SetActive(true);
_diceInterface.connectionCallback = ConnectionCallback;
_diceInterface.connectionFailedCallback = ConnectionFailedCallback;
_diceInterface.disconnectionCallback = DisconnectionCallback;
_diceInterface.listenerStoppedCallback = ListenerStoppedCallback;
_diceInterface.rollCallback = RollCallback;
_tensDieInfo = tens;
_onesDieInfo = ones;
reconnectionLabel.gameObject.SetActive(false);
if (!_tensDieInfo.connected) _diceInterface.Connect(_tensDieInfo.identifier);
if (!_onesDieInfo.connected) _diceInterface.Connect(_onesDieInfo.identifier);
SetAlphas();
SetResultLabels();
tensResultLabel.color = UnityDieColors.ColorFromDieColor(_tensDieInfo.color);
onesResultLabel.color = UnityDieColors.ColorFromDieColor(_onesDieInfo.color);
} else {
gameObject.SetActive(false);
_rollResultCallback(null);
}
});
}
void DeviceFoundCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if ((_tensDieInfo.identifier == identifier && !_tensDieInfo.connected) ||
(_onesDieInfo.identifier == identifier && !_onesDieInfo.connected)) {
_diceInterface.Connect(identifier);
}
});
}
void ConnectionCallback(string identifier, string deviceName) {
MainQueue.Q.Enqueue(() => {
if (identifier == _tensDieInfo.identifier) {
_tensDieInfo.connected = true;
} else if (identifier == _onesDieInfo.identifier) {
_onesDieInfo.connected = true;
}
SetAlphas();
if (_tensDieInfo.connected && _onesDieInfo.connected) {
reconnectionLabel.gameObject.SetActive(false);
_diceInterface.StopListening();
}
});
}
void ConnectionFailedCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if (identifier == _onesDieInfo.identifier || identifier == _tensDieInfo.identifier) {
reconnectionLabel.gameObject.SetActive(true);
_diceInterface.StartListening();
} else {
Debug.Log("Got connection failed for unknown identifier");
}
});
}
void DisconnectionCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if (identifier == _tensDieInfo.identifier) {
_tensDieInfo.connected = false;
SetAlphas();
_diceInterface.Connect(identifier);
} else if (identifier == _onesDieInfo.identifier) {
_onesDieInfo.connected = false;
SetAlphas();
_diceInterface.Connect(identifier);
} else {
Debug.Log("Got disconnection callback for unknown identifier");
}
});
}
void ListenerStoppedCallback() {
MainQueue.Q.Enqueue(() => { reconnectionLabel.text = "Unable to connect."; });
}
private readonly Dictionary<CommandType, string> _baseHeaderText = new() {
{ CommandType.ArcheryCommand, "Archery Attack" },
{ CommandType.BuildBridgeCommand, "Build Bridge" },
{ CommandType.ChargeCommand, "Charge" },
{ CommandType.BraveWaterCommand, "Brave Water" },
{ CommandType.MeleeCommand, "Melee Attack" }
};
private string BaseHeaderText(CommandType commandType) {
if (_baseHeaderText.ContainsKey(commandType)) { return _baseHeaderText[commandType]; }
return "Action";
}
void SetResultLabels() {
var baseText = BaseHeaderText(_rollRequest.CommandType);
string fullText = "";
if (_openEndedState == OpenEndedState.High) {
fullText = $"You rolled OPEN ENDED HIGH! Keep rolling for {baseText}!";
} else if (_openEndedState == OpenEndedState.Low) {
fullText = $"You rolled OPEN ENDED LOW! Keep rolling for {baseText}!";
} else {
switch (_rollRequest.RollType) {
case RollType.D100: fullText = $"Roll D100 for {baseText}"; break;
case RollType.D100OpenEnded:
fullText = $"Roll Open Ended D100 for {baseText}";
break;
case RollType.D100OpenEndedHigh:
fullText = $"Roll Open Ended High D100 for {baseText}";
break;
case RollType.D100OpenEndedLow:
fullText = $"Roll Open Ended Low D100 for {baseText}";
break;
case RollType.Unspecified:
default: throw new ArgumentException("Invalid roll type");
}
}
if (_rollRequest.Odds is OddsView odds) {
int minRoll = 101 - odds.SuccessChance;
fullText += $" ({minRoll}+ to succeed)";
}
header.text = fullText;
if (_tensResult is int tr) {
tensResultLabel.text = tr.ToString();
} else {
tensResultLabel.text = "?";
}
if (_onesResult is int or) {
onesResultLabel.text = or.ToString();
} else {
onesResultLabel.text = "?";
}
runningTotalLabel.text = $"Running total: {_runningTotal}";
runningTotalLabel.gameObject.SetActive(_runningTotal != 0);
}
void RollCallback(string identifier, sbyte x, sbyte y, sbyte z) {
MainQueue.Q.Enqueue(() => {
if (gameObject.activeSelf) {
var d10Roll = DiceVectors.D10Value(x, y, z);
if (identifier == _tensDieInfo.identifier) {
if (_tensResult == null) { _tensResult = d10Roll; }
} else if (identifier == _onesDieInfo.identifier) {
if (_onesResult == null) { _onesResult = d10Roll; }
}
SetResultLabels();
CheckCompletion();
}
});
}
void HandleOneRoll(int newD100Result) {
if (_openEndedState == OpenEndedState.Low) {
_runningTotal -= newD100Result;
} else {
_runningTotal += newD100Result;
}
// Rolls in the middle always end the open ended state.
if (newD100Result > _d100OpenEndedLowThreshold &&
newD100Result < _d100OpenEndedHighThreshold) {
_openEndedState = OpenEndedState.RollComplete;
return;
}
// For a LOW roll, if we are currently in a None state, move to OpenEnded
// Low so we can roll again, but if we were already there, this ends the roll.
if (newD100Result <= _d100OpenEndedLowThreshold) {
if ((_rollRequest.RollType == RollType.D100OpenEnded ||
_rollRequest.RollType == RollType.D100OpenEndedLow) &&
_openEndedState == OpenEndedState.None) {
_openEndedState = OpenEndedState.Low;
} else {
_openEndedState = OpenEndedState.RollComplete;
}
return;
}
// At this point we know the current roll is high. If we were already in OpenEndedLow,
// stay there and keep subtracting, otherwise move into High.
if (_openEndedState == OpenEndedState.Low) {
_openEndedState = OpenEndedState.Low;
} else {
_openEndedState = OpenEndedState.High;
}
}
void CheckCompletion() {
if (_onesResult is int ones && _tensResult is int tens) {
// FIXME: open ended rolls
var currentResult = tens * 10 + ones;
HandleOneRoll(currentResult);
// TODO: flash the dice here for high rolls
if (_openEndedState == OpenEndedState.RollComplete) {
skipContinueButtonText.text = "Dismiss";
SetResultLabels();
StartCoroutine(AfterDelay(_displayTime, () => {
if (gameObject.activeSelf) {
gameObject.SetActive(false);
_rollResultCallback(_runningTotal);
}
}));
// Disconnect only after _disconnectionDelay time
_lastRollTime = DateTime.Now;
StartCoroutine(AfterDelay(_disconnectionDelay, () => {
if (DateTime.Now - _lastRollTime > _disconnectionDelay) {
_diceInterface.StopListening();
_tensDieInfo.connected = false;
_onesDieInfo.connected = false;
_diceInterface.Disconnect(_tensDieInfo.identifier);
_diceInterface.Disconnect(_onesDieInfo.identifier);
}
}));
} else {
// We keep rolling, so reset the dice and keep going.
StartCoroutine(AfterDelay(_openEndedResetTime, () => {
_tensResult = null;
_onesResult = null;
SetResultLabels();
}));
}
}
}
IEnumerator AfterDelay(TimeSpan timeSpan, Action action) {
yield return new WaitForSeconds(timeSpan.Seconds);
action();
}
void LogFromNative(string log) {
MainQueue.Q.Enqueue(() => {
Debug.Log(log);
File.AppendAllText(logLocation, log);
});
}
string NewLogLocation() {
return Path.Combine(
Application.persistentDataPath,
"eagle0",
"Resources",
"RollPanelLogs",
DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss") + ".log");
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: db0669ab8b7684b2bb7eb30a0e66f5e8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,18 +0,0 @@
using System;
using UnityEngine;
namespace UnityGoDiceInterface {
public class UnityDieColors {
public static Color ColorFromDieColor(DieColor dieColor) {
switch (dieColor) {
case DieColor.DieColorBlack: return Color.black;
case DieColor.DieColorRed: return Color.red;
case DieColor.DieColorGreen: return Color.green;
case DieColor.DieColorBlue: return Color.blue;
case DieColor.DieColorYellow: return Color.yellow;
case DieColor.DieColorOrange: return new Color(1.0f, 0.5f, 0.0f);
default: throw new ArgumentOutOfRangeException(nameof(dieColor), dieColor, null);
}
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: d26c35650c67416e86ab5a26906cfb5a
timeCreated: 1703173073
@@ -86,6 +86,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject oauthPanel;
public Button discordLoginButton;
public Button googleLoginButton;
public Button githubLoginButton;
public TextMeshProUGUI oauthStatusText;
[Header("Stored Accounts")]
@@ -93,6 +94,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject storedAccountButtonPrefab;
public Sprite discordProviderIcon;
public Sprite googleProviderIcon;
public Sprite githubProviderIcon;
[Header("Display Name Setup")]
public GameObject displayNamePanel;
@@ -100,12 +102,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button setDisplayNameButton;
public TextMeshProUGUI displayNameErrorText;
[Header("Invitation Code Entry")]
public GameObject invitationCodePanel;
public TMP_InputField invitationCodeField;
public Button submitInvitationCodeButton;
public TextMeshProUGUI invitationCodeErrorText;
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
@@ -114,6 +110,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TMP_Dropdown connectionEnvironmentDropdown;
public GameObject connectionPanel;
public GameObject connectionBackgroundLayer;
public GameObject gameSelectionPanel;
public GameObject customBattlePanel;
public ErrorHandler errorHandler;
@@ -213,6 +210,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
connectionCanvas.enabled = true;
connectionPanel.gameObject.SetActive(true);
if (connectionBackgroundLayer != null) { connectionBackgroundLayer.SetActive(true); }
gameSelectionPanel.gameObject.SetActive(false);
customBattlePanel.gameObject.SetActive(false);
@@ -259,12 +257,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (googleLoginButton != null) {
googleLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Google));
}
if (githubLoginButton != null) {
githubLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Github));
}
if (setDisplayNameButton != null) {
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
}
if (submitInvitationCodeButton != null) {
submitInvitationCodeButton.onClick.AddListener(OnSubmitInvitationCodeClicked);
}
// Subscribe to OAuthManager events
if (OAuthManager.Instance != null) {
@@ -272,7 +270,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
OAuthManager.Instance.OnLogout += OnOAuthLogout;
OAuthManager.Instance.OnInvitationRequired += OnInvitationRequired;
}
// Show auth panel with stored accounts
@@ -282,7 +279,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private void ShowAuthPanel() {
// Hide other panels
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
@@ -328,8 +324,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (providerIconTransform != null) {
var providerImage = providerIconTransform.GetComponent<Image>();
if (providerImage != null) {
var isGoogle = account.Provider.ToLowerInvariant() == "google";
providerImage.sprite = isGoogle ? googleProviderIcon : discordProviderIcon;
var providerLower = account.Provider.ToLowerInvariant();
providerImage.sprite = providerLower switch { "google" => googleProviderIcon,
"github" => githubProviderIcon,
_ => discordProviderIcon };
}
}
@@ -440,6 +438,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Return to connection screen
gameSelectionPanel.SetActive(false);
connectionPanel.SetActive(true);
if (connectionBackgroundLayer != null) { connectionBackgroundLayer.SetActive(true); }
// Re-initialize the auth UI
ShowAuthPanel();
@@ -497,53 +496,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
MainQueue.Q.Enqueue(() => {
// Show display name panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(true);
if (displayNameErrorText != null) displayNameErrorText.text = "";
});
}
private void OnInvitationRequired() {
MainQueue.Q.Enqueue(() => {
// Show invitation code entry panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(true);
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text =
"An invitation code is required to create a new account.";
}
if (invitationCodeField != null) invitationCodeField.text = "";
});
}
private void OnSubmitInvitationCodeClicked() {
if (invitationCodeField == null) return;
var code = invitationCodeField.text.Trim();
if (string.IsNullOrEmpty(code)) {
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text = "Please enter an invitation code";
}
return;
}
// Save the code and return to login screen to retry
InvitationCodeManager.SetInvitationCode(code);
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text = "Code saved. Please sign in again.";
}
// Return to auth panel after a short delay
MainQueue.Q.Enqueue(() => {
if (oauthStatusText != null) {
oauthStatusText.text = "Invitation code saved. Please sign in to continue.";
}
ShowAuthPanel();
});
}
private async void OnSetDisplayNameClicked() {
if (displayNameField == null || OAuthManager.Instance == null) return;
@@ -700,6 +657,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
fetchedNewGameLeaders.Select(a => a.ImagePath));
connectionPanel.gameObject.SetActive(false);
if (connectionBackgroundLayer != null) { connectionBackgroundLayer.SetActive(false); }
gameSelectionPanel.gameObject.SetActive(true);
// Update lobby status displays
@@ -20,8 +20,6 @@ using Random = System.Random;
using VictoryCondition = Net.Eagle0.Common.VictoryCondition;
public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
public RollPanelController rollPanelController;
public GameObject yourArmiesArea;
public GameObject aiArmiesArea;
public Canvas shardokCanvas;
@@ -103,8 +101,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
players: players,
heroNameTextIds: _heroNames,
heroImages: _heroImages,
battalionNames: new Dictionary<int, string> { { 0, "no name" } },
rollFetcher: rollPanelController);
battalionNames: new Dictionary<int, string> { { 0, "no name" } });
shardokCanvas.gameObject.SetActive(true);
shardokCanvas.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Views;
using UnityEngine;
@@ -88,6 +89,9 @@ namespace eagle {
_model = model;
_availableCommand = ac;
SetUpUI();
// Trigger tutorial for this command type (fires every time command is selected)
TutorialManager.Instance?.TriggerRegistry?.OnCommandPanelShown(CommandType);
}
}
}
@@ -22,8 +22,6 @@ namespace eagle {
public class EagleGameController : MonoBehaviour {
public SoundManager soundManager;
public RollPanelController rollPanelController;
public TextMeshProUGUI roundStatusLabel;
public TextMeshProUGUI connectionStatusLabel;
private bool _connectionStatusUIInitialized = false;
@@ -248,11 +246,7 @@ namespace eagle {
int? playerId,
PersistentClientConnection persistentClientConnection,
string environmentName = null) {
ModelUpdater = new GameModelUpdater(
gameId,
playerId,
persistentClientConnection,
rollPanelController) {
ModelUpdater = new GameModelUpdater(gameId, playerId, persistentClientConnection) {
UpdateAction = ModelUpdated,
NoteRecipient = (title, text, llmId, pids, displayedHeroes) =>
notificationPanel.AddNote(title, text, llmId, pids, displayedHeroes)
@@ -284,11 +278,13 @@ namespace eagle {
MainQueue.Q.EnqueueForNextUpdate(
() => { _ = ModelUpdater.StartListeningForUpdates(); });
// Initialize tutorial system
// Initialize tutorial system (onboarding starts after first model update in SwapModel)
TutorialManager.Instance?.Initialize(this, null);
if (TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
// Register command panel for tutorial positioning
if (TutorialManager.Instance?.TargetRegistry != null && commandPanel != null) {
TutorialManager.Instance.TargetRegistry.RegisterTarget(
"CommandPanel",
commandPanel.GetComponent<RectTransform>());
}
#if UNITY_EDITOR
@@ -507,6 +503,12 @@ namespace eagle {
if (Model == null) { return; }
// Start onboarding after first model update (UI panels are now populated)
if (oldModel == null && TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
}
PrefetchHeadshots(Model);
SetMusic();
@@ -13,7 +13,6 @@ using Net.Eagle0.Eagle.Views;
using Net.Eagle0.Shardok.Common;
using TMPro;
using UnityEngine;
using UnityGoDiceInterface;
using Logger = common.Logger;
using Notification = eagle.Notifications.Notification;
@@ -58,8 +57,6 @@ namespace eagle {
public List<ProvinceId> ProvincesForFaction(FactionId factionId);
public List<ChronicleEntry> ChronicleEntries { get; }
RollFetcher RollFetcher { get; }
}
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
@@ -150,8 +147,6 @@ namespace eagle {
private readonly ConcurrentDictionary<string, int> _shardokResultCounts =
new ConcurrentDictionary<string, int>();
private readonly RollFetcher _rollFetcher;
// State synced with server
private struct ConcreteGameModel : IGameModel {
public PlayerId? PlayerId { get; set; }
@@ -220,8 +215,6 @@ namespace eagle {
var faction = MaybeDestroyedFaction(factionId);
textUpdater.SetFactionText(textComponent, faction, this, template, fallbackText);
}
public RollFetcher RollFetcher { get; set; }
}
private ConcreteGameModel _currentModel;
@@ -229,11 +222,9 @@ namespace eagle {
public GameModelUpdater(
GameId eagleGameId,
FactionId? factionId,
PersistentClientConnection pers,
RollFetcher rollFetcher) {
PersistentClientConnection pers) {
GameId = eagleGameId;
PersistentConnection = pers;
this._rollFetcher = rollFetcher;
_currentModel.LastPostedToken = -1;
_currentModel.PlayerId = factionId;
@@ -245,8 +236,6 @@ namespace eagle {
new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();
_currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
_currentModel.RollFetcher = rollFetcher;
}
private ShardokGameModel MakeGameModel(ShardokGameId shardokGameId) {
@@ -294,8 +283,7 @@ namespace eagle {
kv => kv.Value.ImagePath),
battalionNames: _currentModel.BattalionNames.ToDictionary(
kv => kv.Key,
kv => kv.Value),
rollFetcher: _rollFetcher);
kv => kv.Value));
return shardokModel;
}
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using common;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Views;
using UnityEngine;
@@ -136,6 +137,25 @@ namespace eagle {
row.Hero = hero;
SetHeroRowSelections(row, hero);
});
// Register hero rows with tutorial system for highlighting
RegisterHeroRowsForTutorial();
}
private void RegisterHeroRowsForTutorial() {
var registry = TutorialManager.Instance?.TargetRegistry;
if (registry == null) return;
// Register first row as WarlordRow (index 0)
var warlordRow = heroesTable.GetRowRectTransform(0);
if (warlordRow != null) { registry.RegisterTarget("WarlordRow", warlordRow); }
// Register rows 1 and 2 as VassalRows
var vassalRow1 = heroesTable.GetRowRectTransform(1);
if (vassalRow1 != null) { registry.RegisterTarget("VassalRow1", vassalRow1); }
var vassalRow2 = heroesTable.GetRowRectTransform(2);
if (vassalRow2 != null) { registry.RegisterTarget("VassalRow2", vassalRow2); }
}
private void SetUpBattalionsTable() {
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 68f43a341139b445688f2326a98d5567
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 7bf9379c6210e47b88bbe9c2a359d54f
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 564fa5e6e47304264b8d471e1b6f81a8
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: d3ab99e02ad304404a2d663e3d77db5d
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 8bac7b3a2d18d452197ea3aa4e3e4855
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -810,12 +810,19 @@ namespace eagle {
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
// WriteAsync timed out - connection is likely dead
// WriteAsync timed out - connection is dead
LogConnectionEvent(
"write_timeout",
$"WriteAsync timed out after {WriteAsyncTimeoutMs}ms");
_remoteEagleClientLogger.LogLine(
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, connection may be dead");
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, triggering reconnect");
// Dispose the dead connection and schedule reconnect
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
}
ScheduleReconnect("WriteAsyncTimeout");
return false;
}
@@ -823,15 +830,31 @@ namespace eagle {
timeoutCts.Cancel();
return await actionTask.ConfigureAwait(false);
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
return false;
} else {
throw;
_remoteEagleClientLogger.LogLine(
$"[WRITE_ERROR] RpcException: {e.StatusCode} - {e.Message}");
// Connection is broken - dispose and reconnect
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
}
if (e.StatusCode != StatusCode.Cancelled) {
// Cancelled is expected during normal shutdown, don't reconnect
ScheduleReconnect($"RpcException-{e.StatusCode}");
}
return false;
} catch (OperationCanceledException) {
// Timeout was triggered
return false;
} catch (Exception e) {
_remoteEagleClientLogger.LogLine(
$"[WRITE_ERROR] Exception: {e.GetType().Name} - {e.Message}");
// Unknown error - dispose and reconnect
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
}
ScheduleReconnect($"WriteException-{e.GetType().Name}");
return false;
}
}
File diff suppressed because it is too large Load Diff
@@ -1,33 +0,0 @@
fileFormatVersion: 2
guid: b5b6db3c9b9e34b82ae9163a61276c8a
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 1
settings:
DefaultValueInitialized: true
- first:
Standalone: OSXUniversal
second:
enabled: 1
settings:
CPU: AnyCPU
userData:
assetBundleName:
assetBundleVariant:
@@ -1,27 +0,0 @@
fileFormatVersion: 2
guid: 1fc634da07f5047c99dba5ce558b5886
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 1
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
DefaultValueInitialized: true
userData:
assetBundleName:
assetBundleVariant:
@@ -1,45 +0,0 @@
fileFormatVersion: 2
guid: 4d6e8f0a2b3c5d7e9f1a3b5c7d9e1f3a
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
: Any
second:
enabled: 0
settings:
Exclude Editor: 1
Exclude Linux64: 1
Exclude OSXUniversal: 0
Exclude Win: 1
Exclude Win64: 1
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
CPU: AnyCPU
DefaultValueInitialized: true
OS: AnyOS
- first:
Standalone: OSXUniversal
second:
enabled: 1
settings:
CPU: AnyCPU
userData:
assetBundleName:
assetBundleVariant:
@@ -23,10 +23,6 @@ public class SettingsPanelController : MonoBehaviour {
public Slider tileBorderWidthSlider;
public TMP_Text tileBorderWidthLabel;
public Toggle useGoDiceToggle;
public Button testGoDice;
public RollPanelController rollPanelController;
public Toggle tooltipHugsCursorToggle;
public HoveringTooltip hoveringTooltip;
@@ -42,7 +38,6 @@ public class SettingsPanelController : MonoBehaviour {
private const string SoundEffectsVolumeKey = "soundEffectsVolume";
private const string MusicVolumeKey = "musicVolume";
public const string UseGoDiceKey = "useGoDice";
private const string TooltipHugsCursorKey = "tooltipHugsCursor";
private const string AnimationToggleKey = "animationToggle";
@@ -62,7 +57,6 @@ public class SettingsPanelController : MonoBehaviour {
soundEffectsSlider.value = soundEffectsSource.volume;
musicSlider.value = musicSource.volume;
useGoDiceToggle.isOn = PlayerPrefs.GetInt(UseGoDiceKey, 0) == 1;
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
@@ -111,17 +105,6 @@ public class SettingsPanelController : MonoBehaviour {
PlayerPrefs.SetFloat(MusicVolumeKey, val);
}
public void OnUseGoDiceToggleChange(bool val) {
PlayerPrefs.SetInt(UseGoDiceKey, val ? 1 : 0);
testGoDice.interactable = val;
}
public void OnTestGoDiceClick() {
rollPanelController.GetRoll(
new RollRequest { RollType = RollType.D100OpenEnded },
(result) => { Console.Out.WriteLine($"Got ${result}"); });
}
public void OnCursorHugToggleChange(bool val) {
PlayerPrefs.SetInt(TooltipHugsCursorKey, val ? 1 : 0);
hoveringTooltip.HugsCursor = val;
@@ -6,7 +6,6 @@ using eagle0;
using UnityEngine;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using UnityGoDiceInterface;
using BattalionId = System.Int32;
using UnitId = System.Int32;
using PlayerId = System.Int32;
@@ -133,8 +132,6 @@ public class ShardokGameModel {
private const int StartingHistoryCapacity = 100;
private readonly RollFetcher _rollFetcher;
public ShardokGameModel(
EagleGameId eagleGameId,
ShardokGameId shardokGameId,
@@ -146,13 +143,11 @@ public class ShardokGameModel {
List<PlayerWithHostility> players,
Dictionary<HeroId, String> heroNameTextIds,
Dictionary<HeroId, String> heroImages,
Dictionary<BattalionId, String> battalionNames,
RollFetcher rollFetcher) {
Dictionary<BattalionId, String> battalionNames) {
EagleGameId = eagleGameId;
ShardokGameId = shardokGameId;
History = new List<ActionResultView>(StartingHistoryCapacity);
_persistentClientConnection = persistentClientConnection;
this._rollFetcher = rollFetcher;
PlayerId = playerId;
this.players = new List<PlayerWithHostility>(players);
@@ -350,25 +345,14 @@ public class ShardokGameModel {
var index = AvailableCommands.IndexOf(action);
AvailableCommands.Clear();
if (action.RollRequest != null) {
_rollFetcher.GetRoll(action.RollRequest, roll => {
_persistentClientConnection.PostShardokCommand(
EagleGameId,
ShardokGameId,
PlayerId,
History.Count(),
index,
roll);
});
} else {
_persistentClientConnection.PostShardokCommand(
EagleGameId,
ShardokGameId,
PlayerId,
History.Count(),
index,
null);
}
// Physical dice roll support removed - server generates random rolls
_persistentClientConnection.PostShardokCommand(
EagleGameId,
ShardokGameId,
PlayerId,
History.Count(),
index,
null);
}
private void PostGameSetupActions() {
@@ -16,6 +16,9 @@ namespace Eagle0.Tutorial {
var onboarding = CreateOnboardingSequence();
manager.OnboardingSequence = onboarding;
// Register command panel tutorials (shown after onboarding)
RegisterCommandPanelTutorials(registry);
// Register strategic contextual tutorials
RegisterStrategicTutorials(registry);
@@ -23,7 +26,7 @@ namespace Eagle0.Tutorial {
RegisterTacticalTutorials(registry);
}
// ========== ONBOARDING SEQUENCE (13 steps) ==========
// ========== ONBOARDING SEQUENCE ==========
private static TutorialSequence CreateOnboardingSequence() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
@@ -37,60 +40,146 @@ namespace Eagle0.Tutorial {
StepId = "welcome",
Title = "Welcome to Eagle0",
Description =
"Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.\n\nLet's walk through the basics!",
"You are a new ruler in a fractured realm. Build your power, recruit heroes, and expand your domain.\n\n" +
"Let's look at your first province!\n\n" +
"<size=80%><color=#888888>(You can skip this tutorial and restart it later from Settings.)</color></size>",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 2: Map overview - select a province
// Step 2: Province Info Panel - Stats Overview
new TutorialStep {
StepId = "select_province",
Title = "The Strategic Map",
StepId = "province_stats",
Title = "Province Statistics",
Description =
"This is your kingdom. Each colored region is a province.\n\nTap a province you control (shown in your color) to see what you can do there.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "province_selected",
AllowSkip = true
},
// Step 3: Province info panel
new TutorialStep {
StepId = "province_panel",
Title = "Province Information",
Description =
"This panel shows province details: its name, terrain, any armies present, and the commands available to you.\n\nCommands let you move troops, recruit heroes, and more.",
"This panel shows your province's vital statistics. <b>Hover over icons and numbers</b> for detailed tooltips.\n\n" +
"• <b>Agriculture</b> produces Food to feed your troops\n" +
"• <b>Economy</b> generates Gold for hiring, gifts, and equipment\n" +
"• <b>Infrastructure</b> improves troop armament and disaster resilience\n\n" +
"All three stats increase your storage capacity.",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Right,
AdjacentTargetPath = "ProvinceInfoPanel",
TargetGameObjectPath = "ProvinceInfoPanel",
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 4: Try March command
// Step 3: Province Info Panel - Support (CRITICAL)
new TutorialStep {
StepId = "try_march",
Title = "Issue a Command",
StepId = "province_support",
Title = "Support is Critical!",
Description =
"Try issuing a March command to move your army to an adjacent province.\n\nSelect a destination and confirm the order.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "command_issued",
"<b>Support</b> measures how much your people trust you. This is your most important number right now!\n\n" +
"You need <b>40 Support by January</b> to collect taxes. Taxes provide both <b>Gold</b> and <b>Food</b>:\n" +
"• <b>Food</b> feeds your troops and lets you Give Alms\n" +
"• <b>Gold</b> pays for hiring, hero gifts, equipment, and more\n\n" +
"Use <b>Improve</b> and <b>Give Alms</b> to raise Support quickly.",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Right,
AdjacentTargetPath = "ProvinceInfoPanel",
TargetGameObjectPath = "SupportField",
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 5: Turn cycle explanation
// Step 4: Heroes Panel - Warlord
new TutorialStep {
StepId = "heroes_warlord",
Title = "Your Warlord",
Description =
"The <b>Resident Heroes</b> panel shows heroes in this province. The first hero is your <b>Warlord</b> - this is essentially <i>you</i>.\n\n" +
"<b>Hover over a hero</b> to see their backstory, stats, and abilities. Your Warlord has a special profession that grants unique powers.\n\n" +
"<color=#FF6666>Your Warlord is very important - protect them!</color>",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Right,
AdjacentTargetPath = "HeroesPanel",
TargetGameObjectPath = "WarlordRow",
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 5: Heroes Panel - Vassals
new TutorialStep {
StepId = "heroes_vassals",
Title = "Your Vassals",
Description =
"The other heroes in your service are your <b>Vassals</b>. They lead troops, govern provinces, and perform tasks for you.\n\n" +
"<b>Watch their Loyalty!</b> At the end of each year, vassal loyalty may drop. If it falls below <b>50</b>, they might leave your service - or worse.\n\n" +
"Keep vassals happy with gifts and feasts!",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Right,
AdjacentTargetPath = "HeroesPanel",
TargetGameObjectPath = "VassalRow1",
AdditionalHighlightTargets = new[] { "VassalRow2" },
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 6: Command Buttons - Focus on Improve/Alms
new TutorialStep {
StepId = "command_buttons",
Title = "Province Commands",
Description =
"The buttons below let you issue commands. <b>Feel free to click them</b> - nothing happens until you click <b>Commit</b>!\n\n" +
"For now, focus on:\n" +
"• <b>Improve</b> - Raises province stats AND Support\n" +
"• <b>Give Alms</b> - Quickly boosts Support (costs Food)\n\n" +
"Get to <b>40 Support before January</b> to collect taxes!",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Top,
AdjacentTargetPath = "CommandButtonsPanel",
TargetGameObjectPath = "CommandButtonsPanel",
HighlightPulsing = true,
HighlightBoundsFromChildren = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 7: Turn cycle explanation
new TutorialStep {
StepId = "turn_cycle",
Title = "The Turn Cycle",
Description =
"Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.\n\nWhen all players are ready, the server processes everyone's commands and shows the results.",
"Eagle0 uses <b>simultaneous turns</b>. All players give orders at the same time, then everything resolves together.\n\n" +
"Your turn ends when you've given an order in each of your provinces. Once all players are ready, the server processes commands and shows results.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 6: Hidden wait for battle
// Step 8: Completion of strategic intro
new TutorialStep {
StepId = "strategic_complete",
Title = "You're Ready to Begin!",
Description =
"That's the basics of managing your province. Remember:\n\n" +
"• Get <b>Support to 40</b> before January for taxes (Gold + Food)\n" +
"• <b>Hover over everything</b> for tooltips\n" +
"• Protect your <b>Warlord</b> at all costs!\n" +
"• Keep your <b>Vassals</b> loyal with gifts\n\n" +
"When armies clash, you'll learn tactical combat. Good luck, ruler!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = false,
// Mark onboarding complete here so command tutorials can appear
// Tactical tutorial continues in background when battle becomes available
MarksOnboardingComplete = true
},
// === TACTICAL TUTORIAL (triggered later when battle occurs) ===
// Step 9: Hidden wait for battle
new TutorialStep {
StepId = "wait_for_battle",
Title = "",
@@ -101,103 +190,413 @@ namespace Eagle0.Tutorial {
AllowSkip = true
},
// Step 7: Battle introduction
// Step 10: Battle introduction
new TutorialStep {
StepId = "battle_intro",
Title = "Battle Time!",
Description =
"When armies collide, you'll fight tactical battles on a hex grid.\n\nYou command individual units - infantry, cavalry, archers, and heroes with special abilities.",
"When armies collide, you fight tactical battles on a hex grid.\n\n" +
"You command individual units - infantry, cavalry, archers, and heroes with special abilities.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 8: Battle button highlight
// Step 11: Battle button highlight
new TutorialStep {
StepId = "enter_battle",
Title = "Enter the Battle",
Description = "Tap the Battle button to enter tactical combat.",
Description = "Tap the <b>Battle</b> button to enter tactical combat.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_entered",
AllowSkip = true
},
// Step 9: Tactical overview
// Step 12: Tactical overview
new TutorialStep {
StepId = "tactical_overview",
Title = "Tactical Combat",
Description =
"Each unit has movement points and attack power. Position your troops wisely!\n\nUnits attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage.",
"Each unit has movement points and attack power. Position your troops wisely!\n\n" +
"Units attack adjacent enemies. <b>Flanking</b> (attacking from multiple sides) deals bonus damage.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 10: Move a unit
// Step 13: Move a unit
new TutorialStep {
StepId = "move_unit",
Title = "Move Your Units",
Description =
"Tap one of your units to select it, then tap a highlighted hex to move there.\n\nBlue hexes show where you can move.",
"Tap one of your units to select it, then tap a highlighted hex to move there.\n\n" +
"<b>Blue hexes</b> show where you can move.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_action",
AllowSkip = true
},
// Step 11: Attack an enemy
// Step 14: Attack an enemy
new TutorialStep {
StepId = "attack_enemy",
Title = "Attack!",
Description =
"Move next to an enemy unit, then tap the enemy to attack.\n\nRed highlights show valid attack targets.",
Description = "Move next to an enemy unit, then tap the enemy to attack.\n\n" +
"<b>Red highlights</b> show valid attack targets.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_action",
AllowSkip = true
},
// Step 12: End turn
new TutorialStep {
StepId = "end_turn",
Title = "End Your Turn",
Description =
"When you've moved all units or want to pass, tap End Turn.\n\nThe enemy will then take their turn.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "turn_ended",
AllowSkip = true
},
// Step 13: Completion
// Step 15: Final completion
new TutorialStep {
StepId = "complete",
Title = "You're Ready!",
Title = "Tutorial Complete!",
Description =
"You now know the basics of Eagle0!\n\nExplore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander!",
"You now know the basics of Eagle0!\n\n" +
"Explore diplomacy, recruit powerful heroes, and conquer the realm. May your reign be long and prosperous!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = false // Don't allow skipping the final step
AllowSkip = false
}
};
return sequence;
}
// ========== COMMAND PANEL TUTORIALS ==========
// These appear the first time a command panel is shown, after onboarding completes.
private static void RegisterCommandPanelTutorials(TutorialTriggerRegistry registry) {
// Improve command - most important early game command
var improve = CreateCommandPanelTutorial(
"command_improve",
"Improve Province",
"Use <b>Improve</b> to develop your province and increase Support.\n\n" +
"• <b>Agriculture</b> - Increases food production\n" +
"• <b>Economy</b> - Increases gold income\n" +
"• <b>Infrastructure</b> - Improves armament and resilience\n" +
"• <b>Repair Devastation</b> - Restores damaged stats\n\n" +
"<b>Tip:</b> Select a hero using the dropdown, or click a hero in the <b>Resident Heroes</b> panel to the left!");
registry.RegisterTutorial(improve, "command_panel_ImproveCommand");
// Alms command
var alms = CreateCommandPanelTutorial(
"command_alms",
"Give Alms",
"Give Alms distributes food to the people, quickly raising <b>Support</b>.\n\n" +
"This is the fastest way to boost Support, but costs Food from your stores. Use it when you need Support urgently!");
registry.RegisterTutorial(alms, "command_panel_AlmsCommand");
// March command
var march = CreateCommandPanelTutorial(
"command_march",
"March",
"March moves your army to another province.\n\n" +
"• Select a <b>destination</b> province\n" +
"• Choose which <b>heroes and battalions</b> to take\n" +
"• Optionally bring <b>supplies</b> (food and gold)\n\n" +
"If you march into enemy territory, battle may ensue!");
registry.RegisterTutorial(march, "command_panel_MarchCommand");
// Defend command
var defend = CreateCommandPanelTutorial(
"command_defend",
"Defend",
"Defend prepares your forces to repel attackers.\n\n" +
"Choose a <b>rally point</b> where your troops will gather. Units defending have advantages in tactical combat.");
registry.RegisterTutorial(defend, "command_panel_DefendCommand");
// Rest command
var rest = CreateCommandPanelTutorial(
"command_rest",
"Rest",
"Rest restores your heroes' <b>Vigor</b>.\n\n" +
"Heroes need rest to recover from exertion. Use this when your heroes are tired and you don't need to take other actions.");
registry.RegisterTutorial(rest, "command_panel_RestCommand");
// Trade command
var trade = CreateCommandPanelTutorial(
"command_trade",
"Trade",
"Trade lets you exchange <b>Food</b> for <b>Gold</b> or vice versa.\n\n" +
"The market takes its cut - buy and sell prices differ. Useful when you have surplus of one resource and need the other.");
registry.RegisterTutorial(trade, "command_panel_TradeCommand");
// Feast command
var feast = CreateCommandPanelTutorial(
"command_feast",
"Hold Feast",
"Hold a Feast to boost hero <b>Loyalty</b> and <b>Vigor</b>.\n\n" +
"The cost depends on how many heroes are in the province. A feast keeps your vassals happy and well-rested!");
registry.RegisterTutorial(feast, "command_panel_FeastCommand");
// Hero Gift command
var heroGift = CreateCommandPanelTutorial(
"command_hero_gift",
"Give Gift",
"Give a gift to increase a hero's <b>Loyalty</b>.\n\n" +
"Select a hero and choose how much gold to spend. More expensive gifts have greater effect.");
registry.RegisterTutorial(heroGift, "command_panel_HeroGiftCommand");
// Train command
var train = CreateCommandPanelTutorial(
"command_train",
"Train",
"Train improves your troops' <b>combat effectiveness</b>.\n\n" +
"Select a hero to lead training. Better trainers produce better results!");
registry.RegisterTutorial(train, "command_panel_TrainCommand");
// Arm Troops command
var armTroops = CreateCommandPanelTutorial(
"command_arm_troops",
"Arm Troops",
"Arm Troops equips your battalions with better weapons and armor.\n\n" +
"Higher <b>Infrastructure</b> allows better equipment. Well-armed troops are more effective in battle.");
registry.RegisterTutorial(armTroops, "command_panel_ArmTroopsCommand");
// Organize Troops command
var organizeTroops = CreateCommandPanelTutorial(
"command_organize_troops",
"Organize Troops",
"Organize Troops lets you <b>hire new battalions</b> and restructure existing ones.\n\n" +
"Light Infantry and Longbowmen are always available. Other battalion types require minimum <b>Economy</b> or <b>Agriculture</b> levels. You can also combine, split, or reassign troops between battalions.");
registry.RegisterTutorial(organizeTroops, "command_panel_OrganizeTroopsCommand");
// Recruit Heroes command
var recruitHeroes = CreateCommandPanelTutorial(
"command_recruit_heroes",
"Recruit Heroes",
"Recruit a free hero to join your faction!\n\n" +
"Heroes lead armies, govern provinces, and have special abilities. Choose wisely - their stats and professions vary.");
registry.RegisterTutorial(recruitHeroes, "command_panel_RecruitHeroesCommand");
// Travel command
var travel = CreateCommandPanelTutorial(
"command_travel",
"Travel",
"Travel sends your hero to <b>town</b> within the province.\n\n" +
"While traveling, you can do as many actions as you want in a turn: trade, arm troops, divine or recruit heroes, and manage prisoners. Use <b>Return</b> when done.");
registry.RegisterTutorial(travel, "command_panel_TravelCommand");
// Return command
var returnCmd = CreateCommandPanelTutorial(
"command_return",
"Return to Camp",
"Return your province leader from town back to camp.\n\n" +
"This ends <b>Travel</b> mode and completes your turn. Use it when you're done with town activities.");
registry.RegisterTutorial(returnCmd, "command_panel_ReturnCommand");
// Diplomacy command
var diplomacy = CreateCommandPanelTutorial(
"command_diplomacy",
"Diplomacy",
"Send a diplomatic envoy to another faction.\n\n" +
"Propose truces to pause hostilities, form alliances for mutual defense, or invite factions to join your cause. Select an ambassador - sending your leader is risky!");
registry.RegisterTutorial(diplomacy, "command_panel_DiplomacyCommand");
// Ransom command (uses DiplomacyCommand type but separate selector)
// Note: RansomCommandSelector also uses DiplomacyCommand, triggered by same event
// Send Supplies command
var sendSupplies = CreateCommandPanelTutorial(
"command_send_supplies",
"Send Supplies",
"Send gold and food to another province.\n\n" +
"Useful for resupplying distant armies or supporting provinces under siege. Select a hero to escort the supplies.");
registry.RegisterTutorial(sendSupplies, "command_panel_SendSuppliesCommand");
// Recon command
var recon = CreateCommandPanelTutorial(
"command_recon",
"Recon",
"Send a hero to scout an enemy province.\n\n" +
"Gather intelligence on enemy forces, resources, and defenses. Your scout may encounter danger!");
registry.RegisterTutorial(recon, "command_panel_ReconCommand");
// Divine command
var divine = CreateCommandPanelTutorial(
"command_divine",
"Divine Heroes",
"Use divination to learn a hero's hidden potential.\n\n" +
"Costs gold but reveals the hero's true abilities and stats. Divine all available heroes at once for efficiency.");
registry.RegisterTutorial(divine, "command_panel_DivineCommand");
// Issue Orders command
var issueOrders = CreateCommandPanelTutorial(
"command_issue_orders",
"Issue Orders",
"Set standing orders for your provinces.\n\n" +
"Orders determine how provinces behave each turn - prioritize military, economy, or defense. You can also designate a Focus Province for special attention.");
registry.RegisterTutorial(issueOrders, "command_panel_IssueOrdersCommand");
// Control Weather command
var controlWeather = CreateCommandPanelTutorial(
"command_control_weather",
"Control Weather",
"Use magical power to control the weather.\n\n" +
"Create favorable conditions for your forces or harsh weather to hinder enemies. Requires a hero with weather magic.");
registry.RegisterTutorial(
controlWeather,
"command_panel_ControlWeatherAvailableCommand");
// Swear Brotherhood command
var swearBrotherhood = CreateCommandPanelTutorial(
"command_swear_brotherhood",
"Swear Brotherhood",
"Swear an oath of brotherhood with a hero.\n\n" +
"Creates a powerful bond that boosts loyalty and grants special bonuses. Choose your sworn brothers wisely - this bond is sacred!");
registry.RegisterTutorial(swearBrotherhood, "command_panel_SwearBrotherhoodCommand");
// Apprehend Outlaw command
var apprehendOutlaw = CreateCommandPanelTutorial(
"command_apprehend_outlaw",
"Apprehend Outlaw",
"Capture an outlaw hiding in your province.\n\n" +
"Send a hero and optionally a battalion to apprehend the fugitive. Outlaws may be former heroes from defeated factions.");
registry.RegisterTutorial(apprehendOutlaw, "command_panel_ApprehendOutlawCommand");
// Suppress Beasts command
var suppressBeasts = CreateCommandPanelTutorial(
"command_suppress_beasts",
"Suppress Beasts",
"Deal with dangerous creatures threatening your province.\n\n" +
"Send a hero with a battalion to suppress the beasts. Going without troops is risky - your hero could be killed!");
registry.RegisterTutorial(suppressBeasts, "command_panel_SuppressBeastsCommand");
// Exile Vassal command
var exileVassal = CreateCommandPanelTutorial(
"command_exile_vassal",
"Exile Vassal",
"Exile a hero from your faction.\n\n" +
"Sometimes a disloyal or troublesome vassal must be removed. The exiled hero becomes an outlaw and may seek revenge!");
registry.RegisterTutorial(exileVassal, "command_panel_ExileVassalCommand");
// Handle Captured Hero command
var handleCapturedHero = CreateCommandPanelTutorial(
"command_handle_captured_hero",
"Handle Captured Hero",
"Decide the fate of a captured enemy hero.\n\n" +
"You can recruit them to your cause, imprison them for ransom, exile them, execute them, or return them for diplomatic favor.");
registry.RegisterTutorial(
handleCapturedHero,
"command_panel_HandleCapturedHeroCommand");
// Manage Prisoners command
var managePrisoners = CreateCommandPanelTutorial(
"command_manage_prisoners",
"Manage Prisoners",
"Manage heroes you've imprisoned.\n\n" +
"Release them, move them between provinces, exile them, execute them, or return them to their faction for diplomatic benefit.");
registry.RegisterTutorial(managePrisoners, "command_panel_ManagePrisonerCommand");
// Decline Quest command
var declineQuest = CreateCommandPanelTutorial(
"command_decline_quest",
"Decline Quest",
"Decline a quest offered by a hero.\n\n" +
"Some heroes seek specific quests before joining. If you can't fulfill their request, you may decline - but they may leave.");
registry.RegisterTutorial(declineQuest, "command_panel_DeclineQuestCommand");
// Start Epidemic command
var startEpidemic = CreateCommandPanelTutorial(
"command_start_epidemic",
"Start Plague",
"Unleash a deadly plague upon an enemy province.\n\n" +
"A devastating but dishonorable tactic. Requires a hero with plague magic. The disease will spread and cause great suffering.");
registry.RegisterTutorial(startEpidemic, "command_panel_StartEpidemicCommand");
// Handle Riot - Crack Down command
var handleRiotCrackDown = CreateCommandPanelTutorial(
"command_handle_riot_crack_down",
"Crack Down",
"Use force to suppress the rioting populace.\n\n" +
"Send a hero with troops to restore order. Effective but may damage your reputation and reduce support.");
registry.RegisterTutorial(
handleRiotCrackDown,
"command_panel_HandleRiotCrackDownAvailableCommand");
// Handle Riot - Do Nothing command
var handleRiotDoNothing = CreateCommandPanelTutorial(
"command_handle_riot_do_nothing",
"Ignore Riot",
"Choose to ignore the riot and let it run its course.\n\n" +
"The unrest may subside on its own, or it may worsen. A risky choice that avoids immediate costs.");
registry.RegisterTutorial(
handleRiotDoNothing,
"command_panel_HandleRiotDoNothingAvailableCommand");
// Handle Riot - Give command
var handleRiotGive = CreateCommandPanelTutorial(
"command_handle_riot_give",
"Give to Populace",
"Appease the rioters with gifts of food or gold.\n\n" +
"A peaceful solution that costs resources but preserves your reputation. Choose how much to give.");
registry.RegisterTutorial(
handleRiotGive,
"command_panel_HandleRiotGiveAvailableCommand");
// Attack Decision command
var attackDecision = CreateCommandPanelTutorial(
"command_attack_decision",
"Attack Decision",
"Your army has encountered enemy forces! Choose your action.\n\n" +
"Advance to attack, demand tribute, request safe passage, or withdraw. Consider your strength before engaging.");
registry.RegisterTutorial(attackDecision, "command_panel_AttackDecisionCommand");
// Free For All Decision command
var freeForAllDecision = CreateCommandPanelTutorial(
"command_free_for_all_decision",
"Free-For-All Battle",
"Multiple armies have converged! A chaotic battle is imminent.\n\n" +
"You must decide whether to join the fray or attempt to withdraw from this dangerous situation.");
registry.RegisterTutorial(
freeForAllDecision,
"command_panel_FreeForAllDecisionCommand");
// Resolve Tribute command
var resolveTribute = CreateCommandPanelTutorial(
"command_resolve_tribute",
"Tribute Demanded",
"Another faction is demanding tribute from you.\n\n" +
"You can pay to avoid conflict, refuse and risk war, or imprison the messenger (a hostile act).");
registry.RegisterTutorial(resolveTribute, "command_panel_ResolveTributeCommand");
}
/// <summary>
/// Creates a command panel tutorial with onboarding as a prerequisite.
/// </summary>
private static TutorialSequence
CreateCommandPanelTutorial(string id, string title, string description) {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = id;
sequence.DisplayName = title;
sequence.IsOnboarding = false;
// Require onboarding completion before showing command tutorials
sequence.RequiredCompletedTutorials = new List<string> { "onboarding" };
sequence.Steps = new List<TutorialStep> { new TutorialStep {
StepId = id + "_step",
Title = title,
Description = description,
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
// Position above the command selector panel
PanelAnchor = TutorialPanelAnchor.Top,
AdjacentTargetPath = "CommandPanel",
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
} };
return sequence;
}
// ========== STRATEGIC CONTEXTUAL TUTORIALS ==========
private static void RegisterStrategicTutorials(TutorialTriggerRegistry registry) {
// Diplomacy introduction
var diplomacy = CreateSingleStepTutorial(
"diplomacy_intro",
"Diplomacy",
"You can negotiate with other factions!\n\nOffer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(diplomacy, "diplomacy_available");
// Hero recruitment
var heroRecruitment = CreateSingleStepTutorial(
"hero_recruitment",
@@ -64,6 +64,37 @@ namespace Eagle0.Tutorial {
/// </summary>
public int StepCount => Steps?.Count ?? 0;
/// <summary>
/// Number of visible steps up to and including the first MarksOnboardingComplete step.
/// This gives an accurate count for the current "phase" of onboarding.
/// </summary>
public int VisibleStepCount {
get {
if (Steps == null) return 0;
int count = 0;
foreach (var step in Steps) {
if (step.DisplayMode != TutorialDisplayMode.None) count++;
// Stop counting after the step that marks onboarding complete
if (step.MarksOnboardingComplete) break;
}
return count;
}
}
/// <summary>
/// Gets the visible step index for a given actual step index.
/// Returns 0-based index among visible steps only.
/// </summary>
public int GetVisibleStepIndex(int actualIndex) {
if (Steps == null || actualIndex < 0) return 0;
int visibleIndex = 0;
for (int i = 0; i < actualIndex && i < Steps.Count; i++) {
if (Steps[i].DisplayMode != TutorialDisplayMode.None) visibleIndex++;
}
return visibleIndex;
}
/// <summary>
/// Checks if all prerequisites are met.
/// </summary>
@@ -22,6 +22,26 @@ namespace Eagle0.Tutorial {
None
}
/// <summary>
/// Where to position the tutorial panel on screen.
/// </summary>
public enum TutorialPanelAnchor {
/// <summary>Centered on screen (default modal behavior).</summary>
Center,
/// <summary>Panel anchored to left side of screen.</summary>
Left,
/// <summary>Panel anchored to right side of screen.</summary>
Right,
/// <summary>Panel anchored to top of screen.</summary>
Top,
/// <summary>Panel anchored to bottom of screen.</summary>
Bottom
}
/// <summary>
/// How the tutorial step is completed/advanced.
/// </summary>
@@ -66,16 +86,31 @@ namespace Eagle0.Tutorial {
[Tooltip("How this step should be displayed")]
public TutorialDisplayMode DisplayMode = TutorialDisplayMode.Modal;
[Tooltip("Whether this step blocks game interaction (false = user can interact with game)")]
public bool BlocksInteraction = true;
[Tooltip("Where to position the tutorial panel on screen")]
public TutorialPanelAnchor PanelAnchor = TutorialPanelAnchor.Center;
[Header("UI Targeting")]
[Tooltip("Path to GameObject to highlight (e.g., 'Canvas/CommandPanel/MarchButton')")]
public string TargetGameObjectPath;
[Tooltip("Additional targets to highlight together (combined bounding box)")]
public string[] AdditionalHighlightTargets;
[Tooltip("Path to GameObject to position the panel adjacent to (for non-blocking modals)")]
public string AdjacentTargetPath;
[Tooltip("Offset from target element for tooltip/arrow positioning")]
public Vector2 HighlightOffset;
[Tooltip("Whether the highlight should pulse")]
public bool HighlightPulsing = true;
[Tooltip("Compute highlight bounds from active children instead of target's RectTransform")]
public bool HighlightBoundsFromChildren;
[Header("Completion")]
[Tooltip("How this step is completed")]
public TutorialCompletionType CompletionType = TutorialCompletionType.ButtonClick;
@@ -93,6 +128,10 @@ namespace Eagle0.Tutorial {
[Tooltip("Jump to specific step ID instead of sequential (empty = next step)")]
public string NextStepOverride;
[Tooltip(
"Marks onboarding as complete when this step finishes (for mid-sequence completion)")]
public bool MarksOnboardingComplete;
[Header("Audio")]
[Tooltip("Optional narration audio")]
public AudioClip NarrationClip;
@@ -80,6 +80,36 @@ namespace Eagle0.Tutorial {
}
}
// ========== Command Panel Events ==========
// Track the last command type shown so we can re-trigger after onboarding
private AvailableCommand.SealedValueOneofCase? _lastCommandTypeShown;
/// <summary>
/// Called when a command panel is shown in the strategic view.
/// Triggers contextual tutorials for each command type.
/// </summary>
public void OnCommandPanelShown(AvailableCommand.SealedValueOneofCase commandType) {
_lastCommandTypeShown = commandType;
string eventId = $"command_panel_{commandType}";
OnGameEvent(eventId);
}
/// <summary>
/// Re-triggers the last command panel tutorial.
/// Called after onboarding completes to show tutorial for already-selected command.
/// </summary>
public void RetriggerLastCommandTutorial() {
if (_lastCommandTypeShown.HasValue) {
Debug.Log(
$"[Tutorial] RetriggerLastCommandTutorial: Triggering for {_lastCommandTypeShown.Value}");
string eventId = $"command_panel_{_lastCommandTypeShown.Value}";
OnGameEvent(eventId);
} else {
Debug.Log("[Tutorial] RetriggerLastCommandTutorial: No last command type recorded");
}
}
// ========== Strategic Layer Events ==========
/// <summary>
@@ -23,6 +23,9 @@ namespace Eagle0.Tutorial {
[Tooltip("Reference to the tutorial UI manager")]
public TutorialUIManager UIManager;
[Tooltip("Registry of UI targets for tutorials")]
public TutorialTargetRegistry TargetRegistry;
[Header("Debug")]
[Tooltip("Enable verbose logging")]
public bool DebugLogging;
@@ -147,15 +150,48 @@ namespace Eagle0.Tutorial {
/// </summary>
public void TriggerContextualTutorial(string tutorialId) {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return;
if (_state.HasCompletedTutorial(tutorialId)) return;
// Don't interrupt an active tutorial with a contextual one
// (onboarding can be interrupted by explicit StartSequence calls)
if (_activeSequence != null) {
Log($"TriggerContextualTutorial: Skipping '{tutorialId}' - another tutorial is active");
bool isAlreadyCompleted = _state.HasCompletedTutorial(tutorialId);
bool isCommandTutorial = tutorialId.StartsWith("command_");
bool activeIsCommandTutorial =
_activeSequence?.SequenceId.StartsWith("command_") ?? false;
// If switching to a completed command tutorial while another command tutorial
// is showing, just hide the current one (don't mark it complete)
if (isAlreadyCompleted && isCommandTutorial && activeIsCommandTutorial) {
Log($"TriggerContextualTutorial: '{tutorialId}' already completed, " +
$"hiding active command tutorial '{_activeSequence.SequenceId}'");
StopCurrentSequence(skipped: true);
return;
}
if (isAlreadyCompleted) return;
// Check if we should allow interruption
if (_activeSequence != null) {
var currentStep = CurrentStep;
bool isHiddenStep = currentStep?.DisplayMode == TutorialDisplayMode.None;
// Allow interruption if:
// 1. Current step is hidden (DisplayMode.None) - waiting for async events
// 2. Both tutorials are command tutorials - allow switching between commands
bool bothAreCommandTutorials = isCommandTutorial && activeIsCommandTutorial;
if (!isHiddenStep && !bothAreCommandTutorials) {
Log($"TriggerContextualTutorial: Skipping '{tutorialId}' - another tutorial is active");
return;
}
if (bothAreCommandTutorials && _activeSequence.SequenceId == tutorialId) {
// Same command tutorial already showing, don't restart
return;
}
Log($"TriggerContextualTutorial: Allowing '{tutorialId}' to interrupt " +
(isHiddenStep ? "hidden step"
: $"command tutorial '{_activeSequence.SequenceId}'"));
}
// Look up the tutorial sequence by ID
var sequence = _triggerRegistry?.GetSequenceById(tutorialId);
if (sequence != null) { StartSequence(sequence); }
@@ -168,6 +204,18 @@ namespace Eagle0.Tutorial {
if (_activeSequence == null) return;
var currentStep = CurrentStep;
// Check if this step marks onboarding as complete (mid-sequence)
bool shouldRetriggerCommandTutorial = false;
if (currentStep != null && currentStep.MarksOnboardingComplete &&
_activeSequence.IsOnboarding) {
Log("Marking onboarding complete (mid-sequence)");
_state.CompleteOnboarding();
_state.MarkTutorialCompleted(_activeSequence.SequenceId);
// Defer re-triggering until after we advance to the hidden step
shouldRetriggerCommandTutorial = true;
}
string nextStepId = currentStep?.NextStepOverride;
// Determine next step index
@@ -192,6 +240,11 @@ namespace Eagle0.Tutorial {
Log($"Advanced to step {_currentStepIndex}: '{CurrentStep?.StepId}'");
ShowCurrentStep();
// Now that we've advanced to the hidden step, re-trigger command tutorial
if (shouldRetriggerCommandTutorial) {
_triggerRegistry?.RetriggerLastCommandTutorial();
}
}
/// <summary>
@@ -297,12 +350,14 @@ namespace Eagle0.Tutorial {
// Handle different display modes
switch (step.DisplayMode) {
case TutorialDisplayMode.Modal:
// Use visible step index/count for better UX (excludes hidden steps)
int visibleIndex = _activeSequence.GetVisibleStepIndex(_currentStepIndex);
int visibleCount = _activeSequence.VisibleStepCount;
UIManager?.ShowModal(
step,
_currentStepIndex,
_activeSequence.StepCount,
visibleIndex,
visibleCount,
OnStepComplete,
OnStepSkip,
_activeSequence.IsOnboarding);
break;
@@ -343,8 +398,6 @@ namespace Eagle0.Tutorial {
private void OnStepComplete() { AdvanceStep(); }
private void OnStepSkip() { SkipCurrentStep(); }
private void CompleteCurrentSequence() {
if (_activeSequence == null) return;
@@ -0,0 +1,107 @@
using System.Collections.Generic;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Registry of UI elements that tutorials can reference.
/// Assign static targets via Inspector, register dynamic targets via code.
/// </summary>
public class TutorialTargetRegistry : MonoBehaviour {
[Header("Province Info Panel")]
[Tooltip("The main Province Info panel")]
public RectTransform ProvinceInfoPanel;
[Tooltip("The Support value display")]
public RectTransform SupportField;
[Tooltip("The Agriculture value display")]
public RectTransform AgricultureField;
[Tooltip("The Economy value display")]
public RectTransform EconomyField;
[Tooltip("The Infrastructure value display")]
public RectTransform InfrastructureField;
[Header("Heroes Panel")]
[Tooltip("The Resident Heroes panel")]
public RectTransform HeroesPanel;
[Header("Command Panel")]
[Tooltip("The command selector panel (parent of individual command selectors)")]
public RectTransform CommandPanel;
[Header("Command Buttons")]
[Tooltip("Container for command buttons")]
public RectTransform CommandButtonsPanel;
[Tooltip("The Commit button")]
public RectTransform CommitButton;
// Dynamic targets registered at runtime (e.g., command buttons from prefabs)
private Dictionary<string, RectTransform> _dynamicTargets =
new Dictionary<string, RectTransform>();
/// <summary>
/// Registers a target at runtime. Use for prefab-instantiated UI elements.
/// </summary>
public void RegisterTarget(string targetId, RectTransform target) {
if (string.IsNullOrEmpty(targetId) || target == null) return;
_dynamicTargets[targetId] = target;
}
/// <summary>
/// Unregisters a dynamically registered target.
/// </summary>
public void UnregisterTarget(string targetId) {
if (!string.IsNullOrEmpty(targetId)) { _dynamicTargets.Remove(targetId); }
}
/// <summary>
/// Gets a target by its string ID.
/// Checks static Inspector fields first, then dynamic registrations.
/// </summary>
public RectTransform GetTarget(string targetId) {
if (string.IsNullOrEmpty(targetId)) return null;
// Check static targets first
var staticTarget = targetId switch { "ProvinceInfoPanel" => ProvinceInfoPanel,
"SupportField" => SupportField,
"AgricultureField" => AgricultureField,
"EconomyField" => EconomyField,
"InfrastructureField" => InfrastructureField,
"HeroesPanel" => HeroesPanel,
"CommandPanel" => CommandPanel,
"CommandButtonsPanel" => CommandButtonsPanel,
"CommitButton" => CommitButton,
_ => null };
if (staticTarget != null) return staticTarget;
// Check dynamic targets
if (_dynamicTargets.TryGetValue(targetId, out var dynamicTarget)) {
return dynamicTarget;
}
return null;
}
/// <summary>
/// Gets a target, with fallback to GameObject.Find for unregistered paths.
/// </summary>
public Transform GetTargetOrFind(string targetId) {
var registered = GetTarget(targetId);
if (registered != null) return registered;
// Fallback to path-based lookup for unregistered targets
var found = GameObject.Find(targetId);
if (found != null) {
Debug.LogWarning(
$"TutorialTargetRegistry: '{targetId}' not registered, using GameObject.Find fallback");
return found.transform;
}
return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be44269baefd349e99d5ed5f6004234b
@@ -85,7 +85,7 @@ namespace Eagle0.Tutorial {
RectTransform panelRect = panel.AddComponent<RectTransform>();
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
panelRect.sizeDelta = new Vector2(800, 550);
panelRect.sizeDelta = new Vector2(540, 500);
panelRect.anchoredPosition = Vector2.zero;
// Panel Background
@@ -100,8 +100,8 @@ namespace Eagle0.Tutorial {
// Add vertical layout group
VerticalLayoutGroup layout = panel.AddComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(40, 40, 30, 30);
layout.spacing = 20;
layout.padding = new RectOffset(20, 20, 15, 15);
layout.spacing = 10;
layout.childAlignment = TextAnchor.UpperCenter;
layout.childControlHeight = false;
layout.childControlWidth = true;
@@ -134,18 +134,18 @@ namespace Eagle0.Tutorial {
titleObj.transform.SetParent(parent, false);
RectTransform rect = titleObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 60);
rect.sizeDelta = new Vector2(460, 36);
TextMeshProUGUI text = titleObj.AddComponent<TextMeshProUGUI>();
text.text = "Tutorial";
text.fontSize = 42;
text.fontSize = 28;
text.fontStyle = FontStyles.Bold;
text.color = TitleColor;
text.alignment = TextAlignmentOptions.Center;
if (font != null) text.font = font;
LayoutElement layoutElement = titleObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 60;
layoutElement.preferredHeight = 36;
}
private static void CreateIcon(Transform parent) {
@@ -172,11 +172,11 @@ namespace Eagle0.Tutorial {
descObj.transform.SetParent(parent, false);
RectTransform rect = descObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 200);
rect.sizeDelta = new Vector2(460, 180);
TextMeshProUGUI text = descObj.AddComponent<TextMeshProUGUI>();
text.text = "";
text.fontSize = 26;
text.fontSize = 18;
text.color = TextColor;
text.alignment = TextAlignmentOptions.TopLeft;
text.enableWordWrapping = true;
@@ -184,8 +184,8 @@ namespace Eagle0.Tutorial {
if (font != null) text.font = font;
LayoutElement layoutElement = descObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 200;
layoutElement.minHeight = 100;
layoutElement.preferredHeight = 180;
layoutElement.minHeight = 80;
}
private static void CreateSpacer(Transform parent, float height) {
@@ -233,7 +233,7 @@ namespace Eagle0.Tutorial {
TextMeshProUGUI progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
progressText.text = "Step 1 of 1";
progressText.fontSize = 20;
progressText.fontSize = 14;
progressText.color = TextColor;
progressText.alignment = TextAlignmentOptions.Center;
if (font != null) progressText.font = font;
@@ -308,10 +308,6 @@ namespace Eagle0.Tutorial {
panel.ContinueButton.onClick.RemoveAllListeners();
panel.ContinueButton.onClick.AddListener(panel.OnContinueClicked);
}
if (panel.SkipButton != null) {
panel.SkipButton.onClick.RemoveAllListeners();
panel.SkipButton.onClick.AddListener(panel.OnSkipClicked);
}
if (panel.SkipAllButton != null) {
panel.SkipAllButton.onClick.RemoveAllListeners();
panel.SkipAllButton.onClick.AddListener(panel.OnSkipAllClicked);
@@ -334,13 +330,10 @@ namespace Eagle0.Tutorial {
layout.childForceExpandWidth = false;
LayoutElement layoutElement = buttonRow.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 60;
layoutElement.preferredHeight = 44;
// Skip Button (left)
CreateButton(buttonRow.transform, "SkipButton", "Skip", 150, 50, font);
// Skip All Button
CreateButton(buttonRow.transform, "SkipAllButton", "Skip All", 150, 50, font);
// Skip Tutorial Button (left) - only shown during onboarding
CreateButton(buttonRow.transform, "SkipAllButton", "Skip Tutorial", 140, 36, font);
// Spacer
GameObject spacer = new GameObject("ButtonSpacer");
@@ -350,7 +343,7 @@ namespace Eagle0.Tutorial {
spacerLayout.flexibleWidth = 1;
// Continue Button (right)
CreateButton(buttonRow.transform, "ContinueButton", "Continue", 180, 50, font);
CreateButton(buttonRow.transform, "ContinueButton", "Continue", 120, 36, font);
}
private static GameObject CreateButton(
@@ -398,7 +391,7 @@ namespace Eagle0.Tutorial {
TextMeshProUGUI buttonText = textObj.AddComponent<TextMeshProUGUI>();
buttonText.text = text;
buttonText.fontSize = 24;
buttonText.fontSize = 16;
buttonText.color = ButtonTextColor;
buttonText.alignment = TextAlignmentOptions.Center;
if (font != null) buttonText.font = font;
@@ -450,11 +443,6 @@ namespace Eagle0.Tutorial {
// Button Row
Transform buttonRow = containerTransform.Find("ButtonRow");
if (buttonRow != null) {
Transform skipTransform = buttonRow.Find("SkipButton");
if (skipTransform != null) {
panel.SkipButton = skipTransform.GetComponent<Button>();
}
Transform skipAllTransform = buttonRow.Find("SkipAllButton");
if (skipAllTransform != null) {
panel.SkipAllButton = skipAllTransform.GetComponent<Button>();
@@ -26,10 +26,7 @@ namespace Eagle0.Tutorial {
[Tooltip("Text on continue button")]
public TextMeshProUGUI ContinueButtonText;
[Tooltip("Skip this step button")]
public Button SkipButton;
[Tooltip("Skip all tutorials button (onboarding only)")]
[Tooltip("Skip tutorial button (onboarding only)")]
public Button SkipAllButton;
[Header("Progress")]
@@ -52,12 +49,10 @@ namespace Eagle0.Tutorial {
// Callbacks
private Action _onContinue;
private Action _onSkip;
private void Awake() {
// Set up button listeners
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
if (SkipButton != null) { SkipButton.onClick.AddListener(OnSkipClicked); }
if (SkipAllButton != null) { SkipAllButton.onClick.AddListener(OnSkipAllClicked); }
// Start hidden
@@ -72,10 +67,8 @@ namespace Eagle0.Tutorial {
int currentIndex,
int totalSteps,
Action onContinue,
Action onSkip,
bool isOnboarding) {
_onContinue = onContinue;
_onSkip = onSkip;
// Set content
if (TitleText != null) { TitleText.text = step.Title ?? ""; }
@@ -96,8 +89,7 @@ namespace Eagle0.Tutorial {
ContinueButtonText.text = currentIndex >= totalSteps - 1 ? "Got it!" : "Continue";
}
// Show/hide skip buttons
if (SkipButton != null) { SkipButton.gameObject.SetActive(step.AllowSkip); }
// Show Skip Tutorial button only during onboarding (and when skipping is allowed)
if (SkipAllButton != null) {
SkipAllButton.gameObject.SetActive(isOnboarding && step.AllowSkip);
}
@@ -112,9 +104,19 @@ namespace Eagle0.Tutorial {
ProgressSlider.gameObject.SetActive(totalSteps > 1);
}
// Position panel - either adjacent to target or based on anchor setting
if (!string.IsNullOrEmpty(step.AdjacentTargetPath)) {
PositionAdjacentTo(step.AdjacentTargetPath, step.PanelAnchor);
} else {
PositionPanel(step.PanelAnchor);
}
// Show panel - ensure all parent containers are active first
ActivateParents();
if (ModalBlocker != null) { ModalBlocker.SetActive(true); }
// Only show modal blocker if step blocks interaction
if (ModalBlocker != null) { ModalBlocker.SetActive(step.BlocksInteraction); }
if (PanelContainer != null) { PanelContainer.SetActive(true); }
gameObject.SetActive(true);
@@ -122,6 +124,170 @@ namespace Eagle0.Tutorial {
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Show"); }
}
/// <summary>
/// Positions the panel based on the anchor setting.
/// </summary>
private void PositionPanel(TutorialPanelAnchor anchor) {
if (PanelContainer == null) return;
RectTransform rect = PanelContainer.GetComponent<RectTransform>();
if (rect == null) return;
// Store original size
Vector2 size = rect.sizeDelta;
switch (anchor) {
case TutorialPanelAnchor.Center:
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.anchoredPosition = Vector2.zero;
break;
case TutorialPanelAnchor.Left:
rect.anchorMin = new Vector2(0f, 0.5f);
rect.anchorMax = new Vector2(0f, 0.5f);
rect.pivot = new Vector2(0f, 0.5f);
rect.anchoredPosition = new Vector2(20f, 0f); // 20px padding from edge
break;
case TutorialPanelAnchor.Right:
rect.anchorMin = new Vector2(1f, 0.5f);
rect.anchorMax = new Vector2(1f, 0.5f);
rect.pivot = new Vector2(1f, 0.5f);
rect.anchoredPosition = new Vector2(-20f, 0f); // 20px padding from edge
break;
case TutorialPanelAnchor.Top:
rect.anchorMin = new Vector2(0.5f, 1f);
rect.anchorMax = new Vector2(0.5f, 1f);
rect.pivot = new Vector2(0.5f, 1f);
rect.anchoredPosition = new Vector2(0f, -20f); // 20px padding from edge
break;
case TutorialPanelAnchor.Bottom:
rect.anchorMin = new Vector2(0.5f, 0f);
rect.anchorMax = new Vector2(0.5f, 0f);
rect.pivot = new Vector2(0.5f, 0f);
rect.anchoredPosition = new Vector2(0f, 20f); // 20px padding from edge
break;
}
// Restore size after changing anchors
rect.sizeDelta = size;
}
/// <summary>
/// Positions the panel adjacent to a target GameObject.
/// </summary>
private void PositionAdjacentTo(string targetId, TutorialPanelAnchor side) {
if (PanelContainer == null) return;
// Use registry to find target
Transform target = GetTarget(targetId);
if (target == null) {
Debug.LogWarning($"TutorialModalPanel: Could not find target '{targetId}'");
PositionPanel(side);
return;
}
RectTransform targetRect = target.GetComponent<RectTransform>();
if (targetRect == null) {
PositionPanel(side);
return;
}
RectTransform panelRect = PanelContainer.GetComponent<RectTransform>();
if (panelRect == null) return;
Vector2 size = panelRect.sizeDelta;
// Get target bounds in screen space
Vector3[] targetCorners = new Vector3[4];
targetRect.GetWorldCorners(targetCorners);
// Get canvas for coordinate conversion
Canvas canvas = GetComponentInParent<Canvas>();
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
if (canvasRect == null) {
PositionPanel(side);
return;
}
// Convert target corners to canvas space
for (int i = 0; i < 4; i++) {
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect,
RectTransformUtility.WorldToScreenPoint(
canvas.worldCamera,
targetCorners[i]),
canvas.worldCamera,
out Vector2 localPoint);
targetCorners[i] = localPoint;
}
// Calculate target bounds
float targetLeft = Mathf.Min(targetCorners[0].x, targetCorners[1].x);
float targetRight = Mathf.Max(targetCorners[2].x, targetCorners[3].x);
float targetBottom = Mathf.Min(targetCorners[0].y, targetCorners[3].y);
float targetTop = Mathf.Max(targetCorners[1].y, targetCorners[2].y);
float targetCenterX = (targetLeft + targetRight) / 2f;
float targetCenterY = (targetTop + targetBottom) / 2f;
// Use center anchor for the panel
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
panelRect.pivot = new Vector2(0.5f, 0.5f);
float padding = 20f;
Vector2 position;
// Position based on which side
switch (side) {
case TutorialPanelAnchor.Right:
// Position panel to the right of target
position = new Vector2(targetRight + size.x / 2f + padding, targetCenterY);
break;
case TutorialPanelAnchor.Left:
// Position panel to the left of target
position = new Vector2(targetLeft - size.x / 2f - padding, targetCenterY);
break;
case TutorialPanelAnchor.Top:
// Position panel above target
position = new Vector2(targetCenterX, targetTop + size.y / 2f + padding);
break;
case TutorialPanelAnchor.Bottom:
// Position panel below target
position = new Vector2(targetCenterX, targetBottom - size.y / 2f - padding);
break;
default:
// For Center, fall back to standard positioning
PositionPanel(side);
return;
}
// Clamp to screen bounds (with padding)
float canvasHalfWidth = canvasRect.rect.width / 2f;
float canvasHalfHeight = canvasRect.rect.height / 2f;
float screenPadding = 10f;
float minX = -canvasHalfWidth + size.x / 2f + screenPadding;
float maxX = canvasHalfWidth - size.x / 2f - screenPadding;
float minY = -canvasHalfHeight + size.y / 2f + screenPadding;
float maxY = canvasHalfHeight - size.y / 2f - screenPadding;
position.x = Mathf.Clamp(position.x, minX, maxX);
position.y = Mathf.Clamp(position.y, minY, maxY);
panelRect.anchoredPosition = position;
panelRect.sizeDelta = size;
}
/// <summary>
/// Activates all parent GameObjects to ensure this object can be active in hierarchy.
/// </summary>
@@ -144,7 +310,6 @@ namespace Eagle0.Tutorial {
gameObject.SetActive(false);
_onContinue = null;
_onSkip = null;
}
/// <summary>
@@ -158,22 +323,28 @@ namespace Eagle0.Tutorial {
}
/// <summary>
/// Called when Skip button is clicked.
/// Public to allow external setup of button listeners.
/// </summary>
public void OnSkipClicked() {
var callback = _onSkip;
Hide();
callback?.Invoke();
}
/// <summary>
/// Called when Skip All button is clicked.
/// Public to allow external setup of button listeners.
/// Called when Skip Tutorial button is clicked.
/// Skips all remaining tutorial steps. User can restart from Settings.
/// </summary>
public void OnSkipAllClicked() {
Hide();
TutorialManager.Instance?.SkipAllOnboarding();
}
/// <summary>
/// Gets a target Transform by ID, using the registry if available.
/// Falls back to GameObject.Find for unregistered targets.
/// </summary>
private Transform GetTarget(string targetId) {
if (string.IsNullOrEmpty(targetId)) return null;
// Try registry first
var registry = TutorialManager.Instance?.TargetRegistry;
if (registry != null) { return registry.GetTargetOrFind(targetId); }
// Fallback to GameObject.Find
var targetObj = GameObject.Find(targetId);
return targetObj?.transform;
}
}
}
@@ -112,11 +112,15 @@ namespace Eagle0.Tutorial {
/// <summary>
/// Shows only the highlight without tooltip (for emphasis).
/// </summary>
public void HighlightOnly(Transform target) {
public void HighlightOnly(Transform target, bool boundsFromChildren = false) {
_currentTarget = target;
if (target != null) {
PositionHighlight(target);
if (boundsFromChildren) {
PositionHighlightFromChildren(target);
} else {
PositionHighlight(target);
}
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(true); }
}
@@ -130,6 +134,103 @@ namespace Eagle0.Tutorial {
_pulseCoroutine = StartCoroutine(PulseHighlight());
}
/// <summary>
/// Shows highlight around multiple targets with a combined bounding box.
/// </summary>
public void HighlightMultiple(Transform[] targets) {
if (targets == null || targets.Length == 0) {
ClearHighlight();
return;
}
// Use first target as "current" for tracking
_currentTarget = targets[0];
PositionHighlightMultiple(targets);
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(true); }
// Hide tooltip elements
if (TooltipContainer != null) { TooltipContainer.gameObject.SetActive(false); }
if (BackgroundDimmer != null) { BackgroundDimmer.gameObject.SetActive(false); }
// Ensure all parent containers are active first
ActivateParents();
gameObject.SetActive(true);
_pulseCoroutine = StartCoroutine(PulseHighlight());
}
private void PositionHighlightMultiple(Transform[] targets) {
if (HighlightFrame == null || targets == null || targets.Length == 0) return;
Canvas canvas = GetComponentInParent<Canvas>();
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
if (canvasRect == null) {
HighlightFrame.gameObject.SetActive(false);
return;
}
Camera cam = canvas.worldCamera;
// Initialize bounds with extreme values
float minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
foreach (var target in targets) {
if (target == null) continue;
RectTransform targetRect = target as RectTransform;
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
if (targetRect == null) continue;
// Get target bounds in world space
Vector3[] corners = new Vector3[4];
targetRect.GetWorldCorners(corners);
// Convert to canvas-local and expand bounds
for (int i = 0; i < 4; i++) {
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, corners[i]);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect,
screenPoint,
cam,
out Vector2 localPoint);
minX = Mathf.Min(minX, localPoint.x);
maxX = Mathf.Max(maxX, localPoint.x);
minY = Mathf.Min(minY, localPoint.y);
maxY = Mathf.Max(maxY, localPoint.y);
}
}
if (minX == float.MaxValue) {
HighlightFrame.gameObject.SetActive(false);
return;
}
// Clamp bounds to stay within canvas (accounting for padding that will be added)
float canvasHalfWidth = canvasRect.rect.width / 2f;
float canvasHalfHeight = canvasRect.rect.height / 2f;
float margin = 5f + HighlightPadding;
minX = Mathf.Max(minX, -canvasHalfWidth + margin);
maxX = Mathf.Min(maxX, canvasHalfWidth - margin);
minY = Mathf.Max(minY, -canvasHalfHeight + margin);
maxY = Mathf.Min(maxY, canvasHalfHeight - margin);
// Position and size highlight to encompass all targets
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
Vector2 size = new Vector2(
maxX - minX + HighlightPadding * 2,
maxY - minY + HighlightPadding * 2);
HighlightFrame.anchorMin = new Vector2(0.5f, 0.5f);
HighlightFrame.anchorMax = new Vector2(0.5f, 0.5f);
HighlightFrame.pivot = new Vector2(0.5f, 0.5f);
HighlightFrame.anchoredPosition = center;
HighlightFrame.sizeDelta = size;
HighlightFrame.gameObject.SetActive(true);
}
/// <summary>
/// Clears highlight without hiding overlay.
/// </summary>
@@ -171,35 +272,155 @@ namespace Eagle0.Tutorial {
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
if (targetRect != null) {
// Get target bounds in screen space
// Get target bounds in world space
Vector3[] corners = new Vector3[4];
targetRect.GetWorldCorners(corners);
// Convert to canvas space
// Get canvas for coordinate conversion
Canvas canvas = GetComponentInParent<Canvas>();
if (canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay) {
Camera cam = canvas.worldCamera ?? Camera.main;
for (int i = 0; i < 4; i++) { corners[i] = cam.WorldToScreenPoint(corners[i]); }
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
if (canvasRect == null) {
HighlightFrame.gameObject.SetActive(false);
return;
}
// Calculate bounds
float minX = Mathf.Min(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
float maxX = Mathf.Max(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
float minY = Mathf.Min(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
float maxY = Mathf.Max(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
// Convert world corners to screen space, then to canvas-local space
Vector2[] localCorners = new Vector2[4];
Camera cam = canvas.worldCamera;
// Position and size highlight
for (int i = 0; i < 4; i++) {
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, corners[i]);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect,
screenPoint,
cam,
out localCorners[i]);
}
// Calculate bounds in canvas-local space
float minX = Mathf.Min(
localCorners[0].x,
localCorners[1].x,
localCorners[2].x,
localCorners[3].x);
float maxX = Mathf.Max(
localCorners[0].x,
localCorners[1].x,
localCorners[2].x,
localCorners[3].x);
float minY = Mathf.Min(
localCorners[0].y,
localCorners[1].y,
localCorners[2].y,
localCorners[3].y);
float maxY = Mathf.Max(
localCorners[0].y,
localCorners[1].y,
localCorners[2].y,
localCorners[3].y);
// Clamp bounds to stay within canvas (accounting for padding that will be added)
float canvasHalfWidth = canvasRect.rect.width / 2f;
float canvasHalfHeight = canvasRect.rect.height / 2f;
float margin = 5f + HighlightPadding;
minX = Mathf.Max(minX, -canvasHalfWidth + margin);
maxX = Mathf.Min(maxX, canvasHalfWidth - margin);
minY = Mathf.Max(minY, -canvasHalfHeight + margin);
maxY = Mathf.Min(maxY, canvasHalfHeight - margin);
// Position and size highlight in canvas-local coordinates
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
Vector2 size = new Vector2(
maxX - minX + HighlightPadding * 2,
maxY - minY + HighlightPadding * 2);
HighlightFrame.position = center;
// Set highlight position and size using anchored position (canvas-local)
HighlightFrame.anchorMin = new Vector2(0.5f, 0.5f);
HighlightFrame.anchorMax = new Vector2(0.5f, 0.5f);
HighlightFrame.pivot = new Vector2(0.5f, 0.5f);
HighlightFrame.anchoredPosition = center;
HighlightFrame.sizeDelta = size;
HighlightFrame.gameObject.SetActive(true);
}
}
/// <summary>
/// Positions highlight based on active children of the target rather than target's own
/// bounds. Useful for containers where the RectTransform is larger than the visible
/// content.
/// </summary>
private void PositionHighlightFromChildren(Transform target) {
if (HighlightFrame == null || target == null) return;
Canvas canvas = GetComponentInParent<Canvas>();
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
if (canvasRect == null) {
HighlightFrame.gameObject.SetActive(false);
return;
}
Camera cam = canvas.worldCamera;
// Collect bounds from all active children with RectTransforms
float minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
foreach (Transform child in target) {
if (!child.gameObject.activeInHierarchy) continue;
RectTransform childRect = child.GetComponent<RectTransform>();
if (childRect == null) continue;
Vector3[] corners = new Vector3[4];
childRect.GetWorldCorners(corners);
for (int i = 0; i < 4; i++) {
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, corners[i]);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect,
screenPoint,
cam,
out Vector2 localPoint);
minX = Mathf.Min(minX, localPoint.x);
maxX = Mathf.Max(maxX, localPoint.x);
minY = Mathf.Min(minY, localPoint.y);
maxY = Mathf.Max(maxY, localPoint.y);
}
}
if (minX == float.MaxValue) {
// No active children found, fall back to target bounds
PositionHighlight(target);
return;
}
// Clamp bounds to stay within canvas (accounting for padding)
float canvasHalfWidth = canvasRect.rect.width / 2f;
float canvasHalfHeight = canvasRect.rect.height / 2f;
float margin = 5f + HighlightPadding;
minX = Mathf.Max(minX, -canvasHalfWidth + margin);
maxX = Mathf.Min(maxX, canvasHalfWidth - margin);
minY = Mathf.Max(minY, -canvasHalfHeight + margin);
maxY = Mathf.Min(maxY, canvasHalfHeight - margin);
// Position and size highlight
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
Vector2 size = new Vector2(
maxX - minX + HighlightPadding * 2,
maxY - minY + HighlightPadding * 2);
HighlightFrame.anchorMin = new Vector2(0.5f, 0.5f);
HighlightFrame.anchorMax = new Vector2(0.5f, 0.5f);
HighlightFrame.pivot = new Vector2(0.5f, 0.5f);
HighlightFrame.anchoredPosition = center;
HighlightFrame.sizeDelta = size;
HighlightFrame.gameObject.SetActive(true);
}
private void PositionTooltip(Transform target, Vector2 offset) {
if (TooltipContainer == null) return;
@@ -46,7 +46,6 @@ namespace Eagle0.Tutorial {
private bool _fallbackModalVisible;
private TutorialStep _fallbackStep;
private Action _fallbackOnContinue;
private Action _fallbackOnSkip;
private int _fallbackCurrentIndex;
private int _fallbackTotalSteps;
private bool _fallbackIsOnboarding;
@@ -106,16 +105,43 @@ namespace Eagle0.Tutorial {
private void OnGUI() {
if (!_fallbackModalVisible || _fallbackStep == null) return;
// Semi-transparent background
GUI.color = new Color(0, 0, 0, 0.7f);
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), Texture2D.whiteTexture);
GUI.color = Color.white;
// Only draw background blocker if step blocks interaction
if (_fallbackStep.BlocksInteraction) {
GUI.color = new Color(0, 0, 0, 0.7f);
GUI.DrawTexture(
new Rect(0, 0, Screen.width, Screen.height),
Texture2D.whiteTexture);
GUI.color = Color.white;
}
// Modal window
float windowWidth = Mathf.Min(1200, Screen.width - 40);
float windowHeight = 600;
float windowX = (Screen.width - windowWidth) / 2;
float windowY = (Screen.height - windowHeight) / 2;
// Calculate window position based on anchor
float windowWidth = Mathf.Min(800, Screen.width - 40);
float windowHeight = 500;
float windowX, windowY;
switch (_fallbackStep.PanelAnchor) {
case TutorialPanelAnchor.Left:
windowX = 20;
windowY = (Screen.height - windowHeight) / 2;
break;
case TutorialPanelAnchor.Right:
windowX = Screen.width - windowWidth - 20;
windowY = (Screen.height - windowHeight) / 2;
break;
case TutorialPanelAnchor.Top:
windowX = (Screen.width - windowWidth) / 2;
windowY = 20;
break;
case TutorialPanelAnchor.Bottom:
windowX = (Screen.width - windowWidth) / 2;
windowY = Screen.height - windowHeight - 20;
break;
case TutorialPanelAnchor.Center:
default:
windowX = (Screen.width - windowWidth) / 2;
windowY = (Screen.height - windowHeight) / 2;
break;
}
GUIStyle windowStyle = new GUIStyle(GUI.skin.window);
windowStyle.fontSize = 48;
@@ -165,26 +191,15 @@ namespace Eagle0.Tutorial {
// Buttons
GUILayout.BeginHorizontal();
if (_fallbackStep.AllowSkip) {
// Skip Tutorial button (only for onboarding, when step allows skipping)
if (_fallbackIsOnboarding && _fallbackStep.AllowSkip) {
if (GUILayout.Button(
"Skip",
"Skip Tutorial",
buttonStyle,
GUILayout.Width(200),
GUILayout.Width(280),
GUILayout.Height(80))) {
var callback = _fallbackOnSkip;
HideFallbackModal();
callback?.Invoke();
}
if (_fallbackIsOnboarding) {
if (GUILayout.Button(
"Skip All",
buttonStyle,
GUILayout.Width(200),
GUILayout.Height(80))) {
HideFallbackModal();
TutorialManager.Instance?.SkipAllOnboarding();
}
TutorialManager.Instance?.SkipAllOnboarding();
}
}
@@ -211,13 +226,11 @@ namespace Eagle0.Tutorial {
int currentIndex,
int totalSteps,
Action onContinue,
Action onSkip,
bool isOnboarding) {
_fallbackStep = step;
_fallbackCurrentIndex = currentIndex;
_fallbackTotalSteps = totalSteps;
_fallbackOnContinue = onContinue;
_fallbackOnSkip = onSkip;
_fallbackIsOnboarding = isOnboarding;
_fallbackModalVisible = true;
}
@@ -226,7 +239,6 @@ namespace Eagle0.Tutorial {
_fallbackModalVisible = false;
_fallbackStep = null;
_fallbackOnContinue = null;
_fallbackOnSkip = null;
}
/// <summary>
@@ -237,16 +249,56 @@ namespace Eagle0.Tutorial {
int currentIndex,
int totalSteps,
Action onComplete,
Action onSkip,
bool isOnboarding) {
// Clear any existing highlight before showing new one
OverlayController?.ClearHighlight();
// Show highlight on target element(s) if specified (works alongside modal)
if (!string.IsNullOrEmpty(step.TargetGameObjectPath)) {
if (OverlayController == null) {
Debug.LogWarning(
"TutorialUIManager: OverlayController is null, cannot highlight");
} else {
// Collect all targets (primary + additional)
var targets = new List<Transform>();
var primaryTarget = GetTarget(step.TargetGameObjectPath);
if (primaryTarget != null) { targets.Add(primaryTarget); }
if (step.AdditionalHighlightTargets != null) {
foreach (var additionalId in step.AdditionalHighlightTargets) {
var additionalTarget = GetTarget(additionalId);
if (additionalTarget != null) { targets.Add(additionalTarget); }
}
}
if (targets.Count > 1) {
OverlayController.HighlightMultiple(targets.ToArray());
} else if (targets.Count == 1) {
OverlayController.HighlightOnly(
targets[0],
step.HighlightBoundsFromChildren);
} else {
Debug.LogWarning(
$"TutorialUIManager: Target '{step.TargetGameObjectPath}' not found");
}
}
}
if (ModalPanel != null) {
// Ensure canvas is active
if (TutorialCanvas != null && !TutorialCanvas.gameObject.activeSelf) {
TutorialCanvas.gameObject.SetActive(true);
}
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
// Wrap callback to clear highlight when modal is dismissed
Action wrappedCallback = () => {
OverlayController?.ClearHighlight();
onComplete?.Invoke();
};
ModalPanel.Show(step, currentIndex, totalSteps, wrappedCallback, isOnboarding);
} else if (UseFallbackUI) {
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, isOnboarding);
} else {
Debug.LogWarning("TutorialUIManager: No ModalPanel assigned and fallback disabled");
onComplete?.Invoke();
@@ -263,16 +315,11 @@ namespace Eagle0.Tutorial {
return;
}
// Find target element
Transform target = null;
if (!string.IsNullOrEmpty(step.TargetGameObjectPath)) {
var targetObj = GameObject.Find(step.TargetGameObjectPath);
if (targetObj != null) {
target = targetObj.transform;
} else {
Debug.LogWarning(
$"TutorialUIManager: Could not find target '{step.TargetGameObjectPath}'");
}
// Find target element using registry
Transform target = GetTarget(step.TargetGameObjectPath);
if (!string.IsNullOrEmpty(step.TargetGameObjectPath) && target == null) {
Debug.LogWarning(
$"TutorialUIManager: Could not find target '{step.TargetGameObjectPath}'");
}
OverlayController.ShowOverlay(
@@ -305,13 +352,8 @@ namespace Eagle0.Tutorial {
// Don't show duplicate hints
if (_activeHints.ContainsKey(hintId)) return;
// Find target
Transform target = null;
if (!string.IsNullOrEmpty(targetPath)) {
var targetObj = GameObject.Find(targetPath);
if (targetObj != null) { target = targetObj.transform; }
}
// Find target using registry
Transform target = GetTarget(targetPath);
if (target == null) {
Debug.LogWarning($"TutorialUIManager: Could not find hint target '{targetPath}'");
return;
@@ -356,15 +398,10 @@ namespace Eagle0.Tutorial {
/// <summary>
/// Highlights a specific UI element (without showing tutorial content).
/// </summary>
public void HighlightElement(string gameObjectPath) {
public void HighlightElement(string targetId) {
if (OverlayController == null) return;
Transform target = null;
if (!string.IsNullOrEmpty(gameObjectPath)) {
var targetObj = GameObject.Find(gameObjectPath);
if (targetObj != null) { target = targetObj.transform; }
}
Transform target = GetTarget(targetId);
if (target != null) { OverlayController.HighlightOnly(target); }
}
@@ -372,5 +409,21 @@ namespace Eagle0.Tutorial {
/// Clears any active highlight.
/// </summary>
public void ClearHighlight() { OverlayController?.ClearHighlight(); }
/// <summary>
/// Gets a target Transform by ID, using the registry if available.
/// Falls back to GameObject.Find for unregistered targets.
/// </summary>
private Transform GetTarget(string targetId) {
if (string.IsNullOrEmpty(targetId)) return null;
// Try registry first
var registry = TutorialManager.Instance?.TargetRegistry;
if (registry != null) { return registry.GetTargetOrFind(targetId); }
// Fallback to GameObject.Find
var targetObj = GameObject.Find(targetId);
return targetObj?.transform;
}
}
}
@@ -223,6 +223,15 @@ namespace common {
return RowAt(index).GetComponent<T>();
}
/// <summary>
/// Gets the RectTransform of the row at the specified index.
/// Returns null if index is out of bounds.
/// </summary>
public RectTransform GetRowRectTransform(int index) {
if (index < 0 || index >= _rows.Count) return null;
return _rows[index].GetComponent<RectTransform>();
}
private void Awake() {
if (ShadeAlternateRows) {
_evenColor = rowPrefab.GetComponent<Image>().color;
@@ -0,0 +1,166 @@
using System;
using System.Runtime.InteropServices;
using UnityEngine;
namespace common {
/// <summary>
/// Manages window focus and minimize/restore operations across platforms.
/// Used primarily for OAuth flow to minimize game before opening browser,
/// then bring game back to foreground when auth completes.
/// </summary>
public static class WindowFocusManager {
private static bool _wasFullScreen;
private static FullScreenMode _previousFullScreenMode;
#if UNITY_STANDALONE_WIN
[DllImport("user32.dll")]
private static extern IntPtr GetActiveWindow();
[DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern bool IsIconic(IntPtr hWnd);
private const int SW_MINIMIZE = 6;
private const int SW_RESTORE = 9;
private static IntPtr _windowHandle = IntPtr.Zero;
private static IntPtr GetWindowHandle() {
if (_windowHandle == IntPtr.Zero) { _windowHandle = GetActiveWindow(); }
return _windowHandle;
}
#endif
#if UNITY_STANDALONE_OSX
[DllImport("WindowFocusPlugin")]
private static extern void MinimizeApplication();
[DllImport("WindowFocusPlugin")]
private static extern void ActivateApplication();
private static bool _hasNativePlugin = false;
private static bool _checkedForPlugin = false;
private static bool HasNativePlugin() {
if (!_checkedForPlugin) {
_checkedForPlugin = true;
try {
// Try to call the plugin - if it fails, we don't have it
MinimizeApplication();
_hasNativePlugin = true;
} catch (DllNotFoundException) {
Debug.Log("[WindowFocusManager] Native macOS plugin not found, using fallback");
_hasNativePlugin = false;
} catch (EntryPointNotFoundException) {
Debug.Log(
"[WindowFocusManager] Native macOS plugin entry point not found, using fallback");
_hasNativePlugin = false;
}
}
return _hasNativePlugin;
}
#endif
/// <summary>
/// Minimize the game window or exit fullscreen to allow browser to be visible.
/// Call this before opening an external browser.
/// </summary>
public static void MinimizeForExternalBrowser() {
Debug.Log("[WindowFocusManager] Minimizing for external browser");
// Store fullscreen state for later restoration
_wasFullScreen = Screen.fullScreen;
_previousFullScreenMode = Screen.fullScreenMode;
#if UNITY_STANDALONE_WIN
var handle = GetWindowHandle();
if (handle != IntPtr.Zero) {
ShowWindow(handle, SW_MINIMIZE);
Debug.Log("[WindowFocusManager] Windows: Minimized window");
}
#elif UNITY_STANDALONE_OSX
// On macOS, try native plugin first, fall back to exiting fullscreen
if (HasNativePlugin()) {
MinimizeApplication();
Debug.Log("[WindowFocusManager] macOS: Minimized via native plugin");
} else {
// Fallback: exit fullscreen so browser is visible
if (Screen.fullScreen) {
Screen.fullScreen = false;
Debug.Log("[WindowFocusManager] macOS: Exited fullscreen (fallback)");
}
}
#elif UNITY_STANDALONE_LINUX
// Linux: exit fullscreen as fallback (no standard minimize API)
if (Screen.fullScreen) {
Screen.fullScreen = false;
Debug.Log("[WindowFocusManager] Linux: Exited fullscreen");
}
#else
// Other platforms: just exit fullscreen if applicable
if (Screen.fullScreen) {
Screen.fullScreen = false;
Debug.Log("[WindowFocusManager] Exited fullscreen");
}
#endif
}
/// <summary>
/// Bring the game window back to foreground and optionally restore fullscreen.
/// Call this after external browser interaction completes.
/// </summary>
public static void BringToForeground() {
Debug.Log("[WindowFocusManager] Bringing to foreground");
#if UNITY_STANDALONE_WIN
var handle = GetWindowHandle();
if (handle != IntPtr.Zero) {
// Restore if minimized
if (IsIconic(handle)) { ShowWindow(handle, SW_RESTORE); }
// Bring to foreground
SetForegroundWindow(handle);
Debug.Log("[WindowFocusManager] Windows: Restored and focused window");
}
#elif UNITY_STANDALONE_OSX
if (HasNativePlugin()) {
ActivateApplication();
Debug.Log("[WindowFocusManager] macOS: Activated via native plugin");
} else {
// Fallback: Unity will regain focus when user clicks, but we can request attention
Debug.Log("[WindowFocusManager] macOS: Requesting user attention (fallback)");
}
#endif
// Restore fullscreen if it was enabled before
if (_wasFullScreen && !Screen.fullScreen) {
Screen.fullScreenMode = _previousFullScreenMode;
Screen.fullScreen = true;
Debug.Log(
$"[WindowFocusManager] Restored fullscreen mode: {_previousFullScreenMode}");
}
}
/// <summary>
/// Request user attention (flash taskbar/dock icon) without forcing focus.
/// Useful as a gentler notification when auth completes.
/// </summary>
public static void RequestUserAttention() {
#if UNITY_STANDALONE_WIN
// On Windows, SetForegroundWindow will flash if we can't take focus
var handle = GetWindowHandle();
if (handle != IntPtr.Zero) { SetForegroundWindow(handle); }
#endif
// Unity doesn't have a built-in API for this, but the above handles Windows
// On macOS, the dock icon bounces when the app is in background and receives events
}
}
}
@@ -4,6 +4,7 @@ go_library(
name = "authservice_lib",
srcs = [
"admin_handlers.go",
"apple_auth.go",
"handlers.go",
"invitation_handlers.go",
"invitations.go",
@@ -0,0 +1,314 @@
package main
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// AppleTokenResponse represents the response from Apple's token endpoint
type AppleTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
}
// AppleIDTokenClaims represents the claims in Apple's id_token
type AppleIDTokenClaims struct {
jwt.RegisteredClaims
Email string `json:"email,omitempty"`
EmailVerified string `json:"email_verified,omitempty"`
IsPrivateEmail string `json:"is_private_email,omitempty"`
RealUserStatus int `json:"real_user_status,omitempty"`
}
// GenerateAppleClientSecret generates a JWT client_secret for Apple Sign-In
func GenerateAppleClientSecret() (string, error) {
teamID := os.Getenv("APPLE_TEAM_ID")
clientID := os.Getenv("APPLE_SIGNIN_CLIENT_ID")
keyID := os.Getenv("APPLE_SIGNIN_KEY_ID")
privateKeyPEM := os.Getenv("APPLE_SIGNIN_PRIVATE_KEY")
if teamID == "" || clientID == "" || keyID == "" || privateKeyPEM == "" {
return "", fmt.Errorf("Apple Sign-In not fully configured")
}
// Parse the private key
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
// Try base64 decoding if not PEM format
keyBytes, err := base64.StdEncoding.DecodeString(privateKeyPEM)
if err != nil {
return "", fmt.Errorf("failed to decode private key: %v", err)
}
block = &pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}
}
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse private key: %v", err)
}
ecdsaKey, ok := privateKey.(*ecdsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not ECDSA")
}
now := time.Now()
claims := jwt.RegisteredClaims{
Issuer: teamID,
Subject: clientID,
Audience: jwt.ClaimStrings{"https://appleid.apple.com"},
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(6 * 30 * 24 * time.Hour)), // ~6 months
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = keyID
signedToken, err := token.SignedString(ecdsaKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %v", err)
}
return signedToken, nil
}
// ExchangeAppleCode exchanges an authorization code for tokens from Apple
func (s *OAuthService) ExchangeAppleCode(config OAuthProviderConfig, code string) (*AppleTokenResponse, error) {
clientSecret, err := GenerateAppleClientSecret()
if err != nil {
return nil, err
}
// Use the Apple-specific callback URL (must match authorization request exactly)
callbackURL := s.callbackURL
if config.CallbackPath != "" {
baseURL := s.callbackURL
if idx := strings.LastIndex(baseURL, "/oauth/callback"); idx > 0 {
baseURL = baseURL[:idx]
}
callbackURL = baseURL + config.CallbackPath
}
data := url.Values{}
data.Set("client_id", config.ClientID)
data.Set("client_secret", clientSecret)
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("redirect_uri", callbackURL)
log.Printf("Apple token exchange: client_id=%s redirect_uri=%s", config.ClientID, callbackURL)
req, err := http.NewRequest("POST", config.TokenEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("Apple token exchange failed: %d - %s", resp.StatusCode, string(body))
}
var tokenResp AppleTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, err
}
return &tokenResp, nil
}
// ParseAppleIDToken extracts user info from Apple's id_token JWT
func ParseAppleIDToken(idToken string) (*ProviderUserInfo, error) {
// Split the JWT into parts
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid id_token format")
}
// Decode the payload (middle part)
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode id_token payload: %v", err)
}
var claims AppleIDTokenClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("failed to parse id_token claims: %v", err)
}
// Apple user ID is in the 'sub' claim
sub := claims.Subject
// Use email prefix as username fallback since Apple doesn't provide username
username := claims.Email
if idx := strings.Index(username, "@"); idx > 0 {
username = username[:idx]
}
return &ProviderUserInfo{
ID: sub,
Email: claims.Email,
Username: username,
AvatarURL: "", // Apple doesn't provide avatar
}, nil
}
// HandleAppleCallback handles the Apple OAuth callback
// Note: Apple sends a POST request to the callback URL, not GET
func (s *OAuthService) HandleAppleCallback(w http.ResponseWriter, r *http.Request) {
log.Printf("Apple callback received: method=%s", r.Method)
// Apple POSTs the callback data
if r.Method != "POST" {
// Fallback to GET for the state parameter only
state := r.URL.Query().Get("state")
if state != "" {
// This might be an error redirect
errorParam := r.URL.Query().Get("error")
if errorParam != "" {
log.Printf("Apple callback error (GET): %s", errorParam)
s.completeWithError(state, fmt.Sprintf("Apple OAuth error: %s", errorParam))
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
return
}
}
log.Printf("Apple callback rejected: not POST")
http.Error(w, "Apple Sign-In requires POST callback", http.StatusMethodNotAllowed)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
log.Printf("Apple callback: failed to parse form: %v", err)
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
code := r.FormValue("code")
state := r.FormValue("state")
errorParam := r.FormValue("error")
log.Printf("Apple callback: state=%s hasCode=%v error=%s", state, code != "", errorParam)
if errorParam != "" {
log.Printf("Apple callback error (POST): %s", errorParam)
s.completeWithError(state, fmt.Sprintf("Apple OAuth error: %s", errorParam))
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
return
}
if code == "" || state == "" {
log.Printf("Apple callback: missing code or state")
http.Error(w, "Missing code or state", http.StatusBadRequest)
return
}
// Get pending OAuth info
stateData, ok := s.pendingOAuth.Load(state)
if !ok {
log.Printf("Apple callback: state not found: %s", state)
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
return
}
oauthState := stateData.(OAuthState)
if time.Since(oauthState.CreatedAt) >= stateExpiration {
s.pendingOAuth.Delete(state)
s.completeWithError(state, "OAuth session expired")
http.Error(w, "OAuth session expired", http.StatusBadRequest)
return
}
// Exchange code for tokens (Apple requires JWT client_secret)
config := s.configs[oauthState.Provider]
tokenResp, err := s.ExchangeAppleCode(config, code)
if err != nil {
log.Printf("Apple token exchange failed: %v", err)
s.completeWithError(state, err.Error())
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
}
// Parse user info from id_token
userInfo, err := ParseAppleIDToken(tokenResp.IDToken)
if err != nil {
s.completeWithError(state, err.Error())
http.Error(w, "Failed to parse user info", http.StatusInternalServerError)
return
}
// Apple may provide user info in the POST body on first auth
// This includes the user's name which isn't in the id_token
if userName := r.FormValue("user"); userName != "" {
var user struct {
Name struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
} `json:"name"`
}
if err := json.Unmarshal([]byte(userName), &user); err == nil {
if user.Name.FirstName != "" || user.Name.LastName != "" {
userInfo.Username = strings.TrimSpace(user.Name.FirstName + " " + user.Name.LastName)
}
}
}
// Mark as completed
s.pendingOAuth.Delete(state)
s.completedOAuth.Store(state, OAuthResult{
Success: true,
UserInfo: userInfo,
Provider: oauthState.Provider,
InvitationCode: oauthState.InvitationCode,
})
// Redirect or show close message (include state for invitation flow)
if oauthState.ReturnURL != "" {
redirectURL := oauthState.ReturnURL
if strings.Contains(redirectURL, "?") {
redirectURL += "&state=" + state
} else {
redirectURL += "?state=" + state
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head><title>Login Successful</title></head>
<body>
<h1>Login Successful!</h1>
<p>You can close this window and return to the game.</p>
<script>window.close();</script>
</body>
</html>`)
}
@@ -331,6 +331,10 @@ func providerToString(p auth.OAuthProvider) string {
return "discord"
case auth.OAuthProvider_OAUTH_PROVIDER_GOOGLE:
return "google"
case auth.OAuthProvider_OAUTH_PROVIDER_GITHUB:
return "github"
case auth.OAuthProvider_OAUTH_PROVIDER_APPLE:
return "apple"
default:
return "unknown"
}
@@ -342,6 +346,10 @@ func stringToProvider(s string) auth.OAuthProvider {
return auth.OAuthProvider_OAUTH_PROVIDER_DISCORD
case "google":
return auth.OAuthProvider_OAUTH_PROVIDER_GOOGLE
case "github":
return auth.OAuthProvider_OAUTH_PROVIDER_GITHUB
case "apple":
return auth.OAuthProvider_OAUTH_PROVIDER_APPLE
default:
return auth.OAuthProvider_OAUTH_PROVIDER_UNSPECIFIED
}
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -146,13 +146,14 @@ func main() {
// Start HTTP server for OAuth callbacks and invitation pages
http.HandleFunc("/oauth/callback", oauthSvc.HandleCallback)
http.HandleFunc("/oauth/apple/callback", oauthSvc.HandleAppleCallback) // Apple uses POST
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "OK")
})
// Register invitation landing page routes
inviteHandler := NewInvitationHTTPHandler(invitationService)
// Register invitation landing page routes (with OAuth support for web-based account creation)
inviteHandler := NewInvitationHTTPHandler(invitationService, userService, oauthSvc)
inviteHandler.RegisterRoutes()
log.Printf("HTTP server listening on port %d", actualHTTPPort)
@@ -180,5 +181,22 @@ func getOAuthConfigs() map[string]OAuthProviderConfig {
UserInfoEndpoint: "https://www.googleapis.com/oauth2/v2/userinfo",
Scopes: []string{"openid", "email", "profile"},
},
"github": {
ClientID: os.Getenv("GH_OAUTH_CLIENT_ID"),
ClientSecret: os.Getenv("GH_OAUTH_CLIENT_SECRET"),
AuthorizationEndpoint: "https://github.com/login/oauth/authorize",
TokenEndpoint: "https://github.com/login/oauth/access_token",
UserInfoEndpoint: "https://api.github.com/user",
Scopes: []string{"read:user", "user:email"},
},
"apple": {
ClientID: os.Getenv("APPLE_SIGNIN_CLIENT_ID"),
ClientSecret: "", // Generated dynamically from private key
AuthorizationEndpoint: "https://appleid.apple.com/auth/authorize",
TokenEndpoint: "https://appleid.apple.com/auth/token",
UserInfoEndpoint: "", // Not used - info comes from id_token
Scopes: []string{"name", "email"},
CallbackPath: "/oauth/apple/callback", // Apple uses POST callback
},
}
}
+116 -4
View File
@@ -23,6 +23,7 @@ type OAuthProviderConfig struct {
TokenEndpoint string
UserInfoEndpoint string
Scopes []string
CallbackPath string // Optional: custom callback path (e.g., "/oauth/apple/callback")
}
// OAuthState represents a pending OAuth session
@@ -101,6 +102,15 @@ func (s *OAuthService) GetAuthURL(provider string, returnURL string, invitationC
return "", "", fmt.Errorf("unsupported provider: %s", provider)
}
// Validate required credentials are configured
if config.ClientID == "" {
return "", "", fmt.Errorf("provider %s not configured (missing client ID)", provider)
}
// Apple generates client_secret dynamically, so we don't check it here
if provider != "apple" && config.ClientSecret == "" {
return "", "", fmt.Errorf("provider %s not configured (missing client secret)", provider)
}
state = uuid.New().String()
s.pendingOAuth.Store(state, OAuthState{
Provider: provider,
@@ -111,13 +121,29 @@ func (s *OAuthService) GetAuthURL(provider string, returnURL string, invitationC
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s hasInvitationCode=%v", state, provider, returnURL, invitationCode != "")
// Use custom callback path if configured, otherwise use default
callbackURL := s.callbackURL
if config.CallbackPath != "" {
// Extract base URL and append custom path
baseURL := s.callbackURL
if idx := strings.LastIndex(baseURL, "/oauth/callback"); idx > 0 {
baseURL = baseURL[:idx]
}
callbackURL = baseURL + config.CallbackPath
}
params := url.Values{}
params.Set("client_id", config.ClientID)
params.Set("redirect_uri", s.callbackURL)
params.Set("redirect_uri", callbackURL)
params.Set("response_type", "code")
params.Set("scope", strings.Join(config.Scopes, " "))
params.Set("state", state)
// Apple-specific parameters
if provider == "apple" {
params.Set("response_mode", "form_post") // Apple posts the callback
}
authURL = config.AuthorizationEndpoint + "?" + params.Encode()
return authURL, state, nil
}
@@ -218,9 +244,15 @@ func (s *OAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) {
log.Printf("OAuth callback: SUCCESS state=%s user=%s returnURL=%s", state, userInfo.Username, oauthState.ReturnURL)
// If return URL is set (web client), redirect there
// If return URL is set (web client), redirect there with state parameter
if oauthState.ReturnURL != "" {
http.Redirect(w, r, oauthState.ReturnURL, http.StatusFound)
redirectURL := oauthState.ReturnURL
if strings.Contains(redirectURL, "?") {
redirectURL += "&state=" + state
} else {
redirectURL += "?state=" + state
}
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
@@ -308,7 +340,70 @@ func (s *OAuthService) fetchUserInfo(provider string, config OAuthProviderConfig
return nil, fmt.Errorf("user info fetch failed: %d - %s", resp.StatusCode, string(body))
}
return s.parseUserInfo(provider, body)
userInfo, err := s.parseUserInfo(provider, body)
if err != nil {
return nil, err
}
// GitHub: fetch email from /user/emails if not in main response
// (GitHub only returns email in /user if user has made it public)
if provider == "github" && userInfo.Email == "" {
email, err := s.fetchGitHubEmail(accessToken)
if err != nil {
log.Printf("GitHub email fetch failed (non-fatal): %v", err)
} else {
userInfo.Email = email
}
}
return userInfo, nil
}
func (s *OAuthService) fetchGitHubEmail(accessToken string) (string, error) {
req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("email fetch failed: %d", resp.StatusCode)
}
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
if err := json.Unmarshal(body, &emails); err != nil {
return "", err
}
// Return primary verified email, or first verified email
for _, e := range emails {
if e.Primary && e.Verified {
return e.Email, nil
}
}
for _, e := range emails {
if e.Verified {
return e.Email, nil
}
}
return "", fmt.Errorf("no verified email found")
}
func (s *OAuthService) parseUserInfo(provider string, data []byte) (*ProviderUserInfo, error) {
@@ -349,6 +444,23 @@ func (s *OAuthService) parseUserInfo(provider string, data []byte) (*ProviderUse
AvatarURL: picture,
}, nil
case "github":
// GitHub returns id as a number
var id string
if idNum, ok := result["id"].(float64); ok {
id = fmt.Sprintf("%.0f", idNum)
}
login, _ := result["login"].(string)
email, _ := result["email"].(string)
avatarURL, _ := result["avatar_url"].(string)
return &ProviderUserInfo{
ID: id,
Username: login,
Email: email,
AvatarURL: avatarURL,
}, nil
default:
return nil, fmt.Errorf("unsupported provider: %s", provider)
}
@@ -65,8 +65,7 @@ func TestGetAuthURL_UnknownProvider(t *testing.T) {
}
func TestGetAuthURL_EmptyClientID(t *testing.T) {
// Note: The current implementation doesn't validate empty ClientID
// It will still generate a URL, just with an empty client_id parameter
// Empty ClientID should return an error - prevents broken auth URLs
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "",
@@ -76,16 +75,26 @@ func TestGetAuthURL_EmptyClientID(t *testing.T) {
}
svc := NewOAuthService(configs)
url, state, err := svc.GetAuthURL("discord", "", "")
// No error expected - the provider exists, it just has empty client ID
if err != nil {
t.Errorf("Unexpected error: %v", err)
_, _, err := svc.GetAuthURL("discord", "", "")
if err == nil {
t.Error("Expected error for empty client ID")
}
if url == "" {
t.Error("Expected URL to be generated")
}
func TestGetAuthURL_EmptyClientSecret(t *testing.T) {
// Empty ClientSecret should return an error (except for Apple which generates it dynamically)
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "test-client-id",
ClientSecret: "",
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
},
}
if state == "" {
t.Error("Expected state to be generated")
svc := NewOAuthService(configs)
_, _, err := svc.GetAuthURL("discord", "", "")
if err == nil {
t.Error("Expected error for empty client secret")
}
}
@@ -249,15 +249,18 @@ func uploadAppcast(bb aws.BucketBasics, appcast *Appcast) error {
}
func main() {
if len(os.Args) < 5 {
fmt.Println("Usage: mac_build_handler <app_path> <version> <build_number> <sparkle_private_key_path>")
if len(os.Args) < 4 {
fmt.Println("Usage: mac_build_handler <app_path> <version> <build_number> [sparkle_private_key_path]")
os.Exit(1)
}
appPath := os.Args[1]
version := os.Args[2]
buildNumber := os.Args[3]
privateKeyPath := os.Args[4]
privateKeyPath := ""
if len(os.Args) >= 5 {
privateKeyPath = os.Args[4]
}
if _, err := os.Stat(appPath); os.IsNotExist(err) {
log.Fatalf("App not found: %s", appPath)
@@ -284,13 +287,18 @@ func main() {
}
log.Printf("ZIP size: %d bytes", fileSize)
// Sign the ZIP with Sparkle EdDSA
log.Println("Signing ZIP with Sparkle...")
signature, err := signWithSparkle(zipPath, privateKeyPath)
if err != nil {
log.Fatalf("Failed to sign: %v", err)
// Sign the ZIP with Sparkle EdDSA (optional)
var signature string
if privateKeyPath != "" {
log.Println("Signing ZIP with Sparkle...")
signature, err = signWithSparkle(zipPath, privateKeyPath)
if err != nil {
log.Fatalf("Failed to sign: %v", err)
}
log.Printf("Signature: %s", signature)
} else {
log.Println("Skipping Sparkle signing (no private key provided)")
}
log.Printf("Signature: %s", signature)
// Upload ZIP to S3
remotePath := buildsRoot + zipFileName
@@ -306,39 +314,43 @@ func main() {
log.Fatalf("Failed to upload latest: %v", err)
}
// Update appcast.xml
log.Println("Updating appcast.xml...")
appcast, err := fetchAppcast(bb)
if err != nil {
log.Fatalf("Failed to fetch appcast: %v", err)
}
// Create new item
// Update appcast.xml (only if Sparkle signing was done)
downloadURL := fmt.Sprintf("https://assets.eagle0.net/%s%s", buildsRoot, zipFileName)
newItem := Item{
Title: fmt.Sprintf("Version %s", version),
PubDate: time.Now().Format(time.RFC1123Z),
SparkleVersion: buildNumber,
SparkleShortVersion: version,
Description: "",
Enclosure: Enclosure{
URL: downloadURL,
Length: fileSize,
Type: "application/octet-stream",
EdSig: signature,
},
}
if privateKeyPath != "" {
log.Println("Updating appcast.xml...")
appcast, err := fetchAppcast(bb)
if err != nil {
log.Fatalf("Failed to fetch appcast: %v", err)
}
// Prepend new item (most recent first)
appcast.Channel.Items = append([]Item{newItem}, appcast.Channel.Items...)
// Create new item
newItem := Item{
Title: fmt.Sprintf("Version %s", version),
PubDate: time.Now().Format(time.RFC1123Z),
SparkleVersion: buildNumber,
SparkleShortVersion: version,
Description: "",
Enclosure: Enclosure{
URL: downloadURL,
Length: fileSize,
Type: "application/octet-stream",
EdSig: signature,
},
}
// Keep only last 10 versions
if len(appcast.Channel.Items) > 10 {
appcast.Channel.Items = appcast.Channel.Items[:10]
}
// Prepend new item (most recent first)
appcast.Channel.Items = append([]Item{newItem}, appcast.Channel.Items...)
if err := uploadAppcast(bb, appcast); err != nil {
log.Fatalf("Failed to upload appcast: %v", err)
// Keep only last 10 versions
if len(appcast.Channel.Items) > 10 {
appcast.Channel.Items = appcast.Channel.Items[:10]
}
if err := uploadAppcast(bb, appcast); err != nil {
log.Fatalf("Failed to upload appcast: %v", err)
}
} else {
log.Println("Skipping appcast.xml update (no Sparkle signing)")
}
// Clean up local ZIP
@@ -39,6 +39,8 @@ enum OAuthProvider {
OAUTH_PROVIDER_UNSPECIFIED = 0;
OAUTH_PROVIDER_DISCORD = 1;
OAUTH_PROVIDER_GOOGLE = 2;
OAUTH_PROVIDER_GITHUB = 3;
OAUTH_PROVIDER_APPLE = 4;
}
// Request to initiate OAuth flow
@@ -24,6 +24,7 @@ message IncompleteText {
.net.eagle0.eagle.internal.GeneratedTextRequest llm_request = 3;
int32 requested_after_history_count = 4;
int64 requested_at_millis = 5;
int64 last_update_at_millis = 6;
}
message UnrequestedText {
@@ -4,17 +4,16 @@ import scala.collection.Map
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand as ProtoAvailableCommand
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands as ProtoOneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
AlmsCommandSelector,
AttackDecisionCommandChooser,
CommandChooser
}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.Engine
import net.eagle0.eagle.model.proto_converters.command.available.OneProvinceAvailableCommandsConverter
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.game_state.GameState
case class AIClientWithSelectedCommand(
@@ -27,14 +26,12 @@ case class AIClient(
factionId: FactionId,
focusProvinceId: Option[ProvinceId] = None
) {
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
val logger = new AIClientLogger(gameId = gameId, factionId = factionId)
val roundsBetweenRecruitAttempts = 5
def handleError(
selectedCommand: Option[SelectedCommand],
selectedCommand: Option[CommandSelection],
error: Throwable
): Unit = {
println(s"Received an error with AI selected command $error")
@@ -50,13 +47,8 @@ case class AIClient(
if scalaCommandsMap.isEmpty then RandomState(AIClientWithSelectedCommand(this, None), functionalRandom)
else {
// Convert to proto for command choosers (temporary until command choosers are migrated to Scala types)
val protoCommandsMap = scalaCommandsMap.map {
case (pid, scalaCmds) =>
pid -> OneProvinceAvailableCommandsConverter.toProto(scalaCmds, engine.currentState, factionId)
}
chooseFrom(
protoCommandsMap,
scalaCommandsMap,
engine.currentState,
functionalRandom
)
@@ -65,7 +57,7 @@ case class AIClient(
private def chooseMidGameCommandFrom(
gameState: GameState,
oneProvinceAvailableCommands: ProtoOneProvinceAvailableCommands,
oneProvinceAvailableCommands: OneProvinceAvailableCommands,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] =
MidGameAIClient.chosenMidGameCommand(
@@ -75,7 +67,7 @@ case class AIClient(
functionalRandom = functionalRandom
) match {
case RandomState(
CommandSelection(
cs @ CommandSelection(
actingFactionId,
actingProvinceId,
available,
@@ -91,7 +83,7 @@ case class AIClient(
s"$actingProvinceId (${gameState.provinces(actingProvinceId).name})"
)
logger.log(
s"${selected.asMessage.toProtoString}"
s"$selected"
)
logger.logLine(s" $reason")
logger.logLine("")
@@ -103,32 +95,23 @@ case class AIClient(
factionId = this.factionId,
focusProvinceId = focusProvinceId
),
Some(
CommandSelection(
actingFactionId,
actingProvinceId,
available,
selected,
reason
)
)
Some(cs)
),
fr
)
}
private def chooseFrom(
acs: Map[ProvinceId, ProtoOneProvinceAvailableCommands],
acs: Map[ProvinceId, OneProvinceAvailableCommands],
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] = {
val opac = acs.head._2
val oneProvinceAcs = acs.head._2.commands
val opac = acs.head._2
CommandChooser.choose(
actingFactionId = factionId,
gameState = gs,
availableCommands = oneProvinceAcs.toVector,
availableCommands = opac.commands,
rankedChoosers = Vector[CommandChooser](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
handleCapturedHeroesSelectedCommand(
@@ -181,7 +164,7 @@ case class AIClient(
),
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
resolvePleaseRecruitMeSelectedCommand(
resolvePleaseRecruitMeSelected(
actingFactionId,
gameState,
availableCommands
@@ -191,7 +174,7 @@ case class AIClient(
(
fid: FactionId,
gameState: GameState,
acs: Vector[ProtoAvailableCommand],
commandsVector: Vector[AvailableCommand],
functionalRandom
) =>
RandomState(
@@ -199,7 +182,7 @@ case class AIClient(
.chosenAlmsForSupportWithMinimumCommandForProvince(
actingFactionId = fid,
gameState = gameState,
availableCommands = acs,
availableCommands = commandsVector,
minimumFood = 0,
provinceId = opac.provinceId
),
@@ -225,7 +208,7 @@ case class AIClient(
.chooseEarlyGameCommand(
factionId,
gs,
oneProvinceAcs.toVector,
opac.commands,
fr
) match {
case RandomState(cs, fr2) =>
+19 -21
View File
@@ -12,9 +12,6 @@ scala_library(
":early_game_ai_client",
":mid_game_ai_client",
":resolve_diplomacy_command_selector",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:engine",
@@ -24,7 +21,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/available:one_province_available_commands_converter",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
],
)
@@ -70,14 +68,16 @@ scala_library(
],
deps = [
":resolve_diplomacy_command_selector",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:invite_gold_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:min_chance_for_a_i_invite",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/common",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -93,7 +93,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
@@ -134,13 +133,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -153,14 +152,14 @@ scala_library(
],
deps = [
":ai_client_utils",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -173,7 +172,6 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
@@ -184,9 +182,6 @@ scala_library(
":march_toward_province_command_chooser",
":move_leader_to_better_province_command_chooser",
":seek_more_leaders_command_chooser",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -210,6 +205,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/common",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
@@ -232,10 +230,10 @@ scala_library(
deps = [
":faction_leader_province_ranker",
":march_toward_province_command_chooser",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
@@ -251,8 +249,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
@@ -263,8 +259,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:ransom_offer_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/diplomacy_offer/status",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -279,8 +277,6 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
@@ -293,6 +289,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:sworn_brother_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -1,7 +1,6 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.{
chosenCommandWhileTraveling,
chosenDevelopCommand,
@@ -11,6 +10,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
@@ -1,12 +1,13 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.MarchAvailable
import net.eagle0.eagle.model.state.command.selected.CombatUnit
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.MarchSelected
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
@@ -19,7 +20,7 @@ object FixLeaderAloneCommandSelector {
): RandomState[Option[CommandSelection]] = {
val factions = gameState.factions.values.toVector
val provinces = gameState.provinces
randomSelectionForType[MarchAvailableCommand](
randomSelectionForType[MarchAvailable](
availableCommands,
functionalRandom
) {
@@ -40,7 +41,7 @@ object FixLeaderAloneCommandSelector {
.map { destination =>
fr.nextRandomElement(opmc.availableHeroIds)
.map { hid =>
CombatUnit(factionId = actingFactionId, heroId = hid)
CombatUnit(heroId = hid, battalionId = None)
}
.map { combatUnit =>
Some(
@@ -48,7 +49,7 @@ object FixLeaderAloneCommandSelector {
actingFactionId = actingFactionId,
actingProvinceId = ac.actingProvinceId,
available = ac,
selected = MarchSelectedCommand(
selected = MarchSelected(
gold = 0,
food = 0,
originProvince = opmc.originProvinceId,
@@ -1,18 +1,19 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
import net.eagle0.eagle.api.command.util.diplomacy_option.InvitationOption
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
import net.eagle0.eagle.library.settings.MinChanceForAIInvite
import net.eagle0.eagle.library.settings.{InviteGoldCost, MinChanceForAIInvite}
import net.eagle0.eagle.library.util.command_choice_helpers.{ProvinceGoldSurplusCalculator, TrustForDiplomacy}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{DiplomacyAvailable, DiplomacyOption}
import net.eagle0.eagle.model.state.command.common.DiplomacyOptionType
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.DiplomacySelected
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object InvitationCommandSelector {
private case class InvitationOptionWithChance(
invitationOption: InvitationOption,
targetFactionId: FactionId,
acceptanceChance: Int
)
@@ -21,38 +22,44 @@ object InvitationCommandSelector {
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) { diplomacyAvailableCommand =>
diplomacyAvailableCommand.options.collect { case io: InvitationOption => io }.filter { invitationOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = invitationOption.targetFactionId,
gameState = gameState
)
}.filter { invitationOption =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
gameState.provinces(diplomacyAvailableCommand.actingProvinceId)
) >= invitationOption.goldCost
}.map { invitationOption =>
InvitationOptionWithChance(
invitationOption = invitationOption,
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
invitationOption.targetFactionId,
gameState.factions.values.toVector,
gameState.provinces.values.toVector
flatSelectionForType[DiplomacyAvailable](acs = availableCommands) { diplomacyAvailable =>
// Find options that have Invitation as an option type
diplomacyAvailable.options
.filter(opt => opt.optionTypes.contains(DiplomacyOptionType.Invitation))
.filter { diplomacyOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = diplomacyOption.targetFactionId,
gameState = gameState
)
)
}.maxByOption(_.acceptanceChance)
}
.filter { _ =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
gameState.provinces(diplomacyAvailable.actingProvinceId)
) >= InviteGoldCost.intValue
}
.map { diplomacyOption =>
InvitationOptionWithChance(
targetFactionId = diplomacyOption.targetFactionId,
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
diplomacyOption.targetFactionId,
gameState.factions.values.toVector,
gameState.provinces.values.toVector
)
)
}
.maxByOption(_.acceptanceChance)
.filter(_.acceptanceChance >= MinChanceForAIInvite.intValue)
.map { iowc =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = diplomacyAvailableCommand.actingProvinceId,
available = diplomacyAvailableCommand,
selected = DiplomacySelectedCommand(
selectedOption = iowc.invitationOption,
targetFactionId = iowc.invitationOption.targetFactionId,
sentHeroId = diplomacyAvailableCommand.recommendedHeroId
actingProvinceId = diplomacyAvailable.actingProvinceId,
available = diplomacyAvailable,
selected = DiplomacySelected(
selectedOption = DiplomacyOptionType.Invitation,
targetFactionId = iowc.targetFactionId,
sentHeroId = diplomacyAvailable.recommendedHeroId
),
reason = "maybeInviteOtherFaction"
)
@@ -1,17 +1,17 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.library.util.{BattalionUtils, CommandSelection, ProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{MarchAvailable, MarchCommandFromOneProvince}
import net.eagle0.eagle.model.state.command.selected.CombatUnit
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.MarchSelected
import net.eagle0.eagle.model.state.game_state.GameState
object MarchTowardProvinceCommandChooser {
def marchTowardProvinceCommand(
ac: MarchAvailableCommand,
ac: MarchAvailable,
fid: FactionId,
opmc: MarchCommandFromOneProvince,
destinationProvinceId: ProvinceId,
@@ -49,7 +49,7 @@ object MarchTowardProvinceCommandChooser {
AIClientUtils.takenHeroIdsForMarchTowardFocus(
originProvince = originProvince,
destinationProvince = dest,
availableHeroIds = opmc.availableHeroIds.toVector,
availableHeroIds = opmc.availableHeroIds,
favorLeaders = favorLeaders,
factions = gs.factions.values.toVector,
heroes = gs.heroes
@@ -72,7 +72,7 @@ object MarchTowardProvinceCommandChooser {
)
.map {
case (hid, bid) =>
CombatUnit(factionId = fid, heroId = hid, battalionId = bid)
CombatUnit(heroId = hid, battalionId = bid)
}
val foodToTake = 2 * BattalionUtils.monthlyConsumedFood(
@@ -84,7 +84,7 @@ object MarchTowardProvinceCommandChooser {
actingFactionId = fid,
actingProvinceId = actingProvinceId,
available = ac,
selected = MarchSelectedCommand(
selected = MarchSelected(
marchingUnits = takenUnits, // Check the food cost here?
destinationProvinceId = dest.id,
originProvince = opmc.originProvinceId,
@@ -3,11 +3,6 @@ package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, MoreOption, RandomState}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.ai.AIClientUtils
import net.eagle0.eagle.api.available_command.*
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.{MarchSelectedCommand, ReconSelectedCommand}
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.improvement_type.ImprovementType
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, MonthsReconConsideredRecent}
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
@@ -30,6 +25,12 @@ import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.*
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.command.common.ImprovementType
import net.eagle0.eagle.model.state.command.selected.CombatUnit
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.{MarchSelected, ReconSelected}
import net.eagle0.eagle.model.state.date.Date.given
import net.eagle0.eagle.model.state.faction.FactionRelationship as FactionRelationshipC
import net.eagle0.eagle.model.state.game_state.GameState
@@ -66,7 +67,7 @@ object MidGameAIClient {
CommandChooser.choose(
actingFactionId = factionId,
gameState = gs,
availableCommands = opac.commands.toVector,
availableCommands = opac.commands,
rankedChoosers = Vector[CommandChooser](
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenUniversalCommand(fid, gs, acs, fr),
@@ -129,14 +130,13 @@ object MidGameAIClient {
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[SendSuppliesAvailableCommand](availableCommands) {
case available @ SendSuppliesAvailableCommand(
flatSelectionForType[SendSuppliesAvailable](availableCommands) {
case available @ SendSuppliesAvailable(
goldAvailable,
foodAvailable,
availableDestinationProvinceIds,
actingProvinceId,
_ /* availableHeroIds */,
_ /* unknownFieldSet */
_ /* availableHeroIds */
) =>
val actingProvince = gameState.provinces(actingProvinceId)
val foodMonthsToHoldBack =
@@ -546,7 +546,7 @@ object MidGameAIClient {
.choose(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = opac.commands.toVector,
availableCommands = opac.commands,
rankedChoosers = Vector[CommandChooser](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
@@ -683,7 +683,7 @@ object MidGameAIClient {
acs: Vector[AvailableCommand],
factionId: FactionId
): Option[CommandSelection] =
flatSelectionForType[ImproveAvailableCommand](acs) { availableCommand =>
flatSelectionForType[ImproveAvailable](acs) { availableCommand =>
val province = gs.provinces(availableCommand.actingProvinceId)
val existingBattalions = province.battalionIds
.map(gs.battalions)
@@ -706,7 +706,7 @@ object MidGameAIClient {
actingFactionId = factionId,
actingProvinceId = province.id,
availableCommands = acs,
improvementType = ImprovementType.DEVASTATION
improvementType = ImprovementType.Devastation
)
.orElse {
val neededAgriculture = notAvailable
@@ -729,7 +729,7 @@ object MidGameAIClient {
actingFactionId = factionId,
actingProvinceId = province.id,
availableCommands = acs,
improvementType = ImprovementType.AGRICULTURE
improvementType = ImprovementType.Agriculture
)
else
ImproveCommandSelector
@@ -737,7 +737,7 @@ object MidGameAIClient {
actingFactionId = factionId,
actingProvinceId = province.id,
availableCommands = acs,
improvementType = ImprovementType.ECONOMY
improvementType = ImprovementType.Economy
)
end if
}
@@ -856,7 +856,7 @@ object MidGameAIClient {
.filter(p => p.rulingFactionHeroIds.size < p.heroCap)
val cs = for {
(marchCommand, _) <- acs.zipWithIndex.collectFirst {
case (ac: MarchAvailableCommand, idx) => (ac, idx)
case (ac: MarchAvailable, idx) => (ac, idx)
}
} yield {
// Find the march command origin provinces that contain extra heroes
@@ -924,14 +924,14 @@ object MidGameAIClient {
private def selectedReconCommand(
actingFactionId: FactionId,
gameState: GameState,
ac: ReconAvailableCommand,
ac: ReconAvailable,
targetProvinceId: ProvinceId
): CommandSelection =
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ac.actingProvinceId,
available = ac,
selected = ReconSelectedCommand(
selected = ReconSelected(
actingHeroId = ac.availableHeroIds.maxBy(hid => gameState.heroes(hid).vigor),
targetProvinceId = targetProvinceId
),
@@ -943,7 +943,7 @@ object MidGameAIClient {
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[ReconAvailableCommand](acs) { ac =>
flatSelectionForType[ReconAvailable](acs) { ac =>
val targetProvincesNeighboringMe = ac.availableTargetProvinces
.map(gameState.provinces)
.filter(_.rulingFactionId.exists(_ != actingFactionId))
@@ -1013,8 +1013,8 @@ object MidGameAIClient {
): Option[CommandSelection] = {
val faction = gameState.factions(actingFactionId)
val factionLeaders = faction.leaderIds
flatSelectionForType[MarchAvailableCommand](acs) { marchAvailableCommand =>
marchAvailableCommand.oneProvinceCommands.flatMap { opmc =>
flatSelectionForType[MarchAvailable](acs) { marchAvailable =>
marchAvailable.oneProvinceCommands.flatMap { opmc =>
val originProvince = gameState.provinces(opmc.originProvinceId)
val leadersInOriginProvince =
originProvince.rulingFactionHeroIds.intersect(factionLeaders)
@@ -1042,16 +1042,15 @@ object MidGameAIClient {
validDestinations.minByOption(_.rulingFactionHeroIds.size).map { destinationProvince =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = marchAvailableCommand.actingProvinceId,
available = marchAvailableCommand,
selected = MarchSelectedCommand(
actingProvinceId = marchAvailable.actingProvinceId,
available = marchAvailable,
selected = MarchSelected(
gold = 0,
food = 0,
originProvince = originProvince.id,
destinationProvinceId = destinationProvince.id,
marchingUnits = Vector(
CombatUnit(
factionId = actingFactionId,
heroId = leaderIdToMove,
battalionId = None
)
@@ -1,10 +1,11 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{MarchAvailable, MarchCommandFromOneProvince}
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
@@ -31,11 +32,11 @@ object MoveLeaderToBetterProvinceCommandChooser {
)
private def candidatesForMarchCommand(
marchCommand: MarchAvailableCommand,
marchCommand: MarchAvailable,
gs: GameState,
faction: FactionT
): Vector[MarchCommandFromOneProvince] =
marchCommand.oneProvinceCommands.toVector.filter { opmc =>
marchCommand.oneProvinceCommands.filter { opmc =>
val originProvince = gs.provinces(opmc.originProvinceId)
if opmc.availableHeroIds.intersect(faction.leaderIds).isEmpty then false
else if originProvince.rulingFactionHeroIds.length < 2 then false
@@ -57,7 +58,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
ProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
mcfop.availableDestinationProvinces.map(_.provinceId),
actingFactionId,
gs.provinces
)
@@ -78,7 +79,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
): Option[CommandSelection] = {
val cs = for {
faction <- gs.factions.get(actingFactionId)
marchCommand <- acs.collectFirst { case ac: MarchAvailableCommand => ac }
marchCommand <- acs.collectFirst { case ac: MarchAvailable => ac }
} yield {
// Find the march command origin provinces that contain a faction leader
val candidatesWithDistances = candidateCommandsWithDistances(
@@ -1,42 +1,25 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.{
AvailableCommand,
ResolveAllianceOfferAvailableCommand,
ResolveBreakAllianceAvailableCommand,
ResolveInvitationAvailableCommand,
ResolveRansomOfferAvailableCommand,
ResolveTruceOfferAvailableCommand
}
import net.eagle0.eagle.api.selected_command.{
ResolveAllianceOfferSelectedCommand,
ResolveBreakAllianceSelectedCommand,
ResolveInvitationSelectedCommand,
ResolveRansomOfferSelectedCommand,
ResolveTruceOfferSelectedCommand,
SelectedCommand
}
import net.eagle0.eagle.common.diplomacy_offer.RansomOfferDetails
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
DIPLOMACY_OFFER_STATUS_ACCEPTED,
DIPLOMACY_OFFER_STATUS_IMPRISONED,
DIPLOMACY_OFFER_STATUS_REJECTED
}
import net.eagle0.eagle.library.settings.{
AiAllianceAcceptanceBaseChance,
AiTruceAcceptanceBaseChance,
MinimumPrestigeDifferenceToInvite
}
import net.eagle0.eagle.library.util.command_choice_helpers.{CommandChooser, RansomOfferHelpers}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.{
randomSelectionForType,
selectionForType
}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.*
import net.eagle0.eagle.model.state.command.selected.SelectedCommand
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.*
import net.eagle0.eagle.model.state.diplomacy_offer.status.{Accepted, Imprisoned, Rejected, Status}
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
@@ -71,7 +54,7 @@ object ResolveDiplomacyCommandSelector {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveInvitationAvailableCommand](
randomSelectionForType[ResolveInvitationAvailable](
availableCommands,
functionalRandom
) {
@@ -96,7 +79,7 @@ object ResolveDiplomacyCommandSelector {
private def selectionForResolveInvitationCommand(
actingFactionId: FactionId,
ac: ResolveInvitationAvailableCommand,
ac: ResolveInvitationAvailable,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
@@ -104,20 +87,20 @@ object ResolveDiplomacyCommandSelector {
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestInvitation = ac.invitations
.maxBy(inv => invitationAcceptanceChance(inv.originatingFactionId, invitedFid, factions, provinces))
.maxBy(inv => invitationAcceptanceChance(inv.offeringFactionId, invitedFid, factions, provinces))
internalRequire(
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
bestInvitation.eligibleStatuses.contains(Accepted),
"Cannot handle invitation without ACCEPTED option"
)
internalRequire(
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
bestInvitation.eligibleStatuses.contains(Rejected),
"Cannot handle invitation without REJECTED option"
)
val bestOriginatingFid = bestInvitation.originatingFactionId
val bestOriginatingFid = bestInvitation.offeringFactionId
ResolveInvitationSelectedCommand(
ResolveInvitationSelected(
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < invitationAcceptanceChance(
@@ -126,8 +109,8 @@ object ResolveDiplomacyCommandSelector {
factions,
provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
then Accepted
else Rejected
)
}
@@ -150,7 +133,7 @@ object ResolveDiplomacyCommandSelector {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveTruceOfferAvailableCommand](
randomSelectionForType[ResolveTruceOfferAvailable](
availableCommands,
functionalRandom
) {
@@ -175,7 +158,7 @@ object ResolveDiplomacyCommandSelector {
private def selectionForResolveTruceOfferSelectedCommand(
actingFactionId: FactionId,
ac: ResolveTruceOfferAvailableCommand,
ac: ResolveTruceOfferAvailable,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
@@ -183,20 +166,20 @@ object ResolveDiplomacyCommandSelector {
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestOffer = ac.offers
.maxBy(inv => truceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
.maxBy(inv => truceOfferAcceptanceChance(inv.offeringFactionId, actingFid, factions, provinces))
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
bestOffer.eligibleStatuses.contains(Accepted),
"Cannot handle truce offer without ACCEPTED option"
)
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
bestOffer.eligibleStatuses.contains(Rejected),
"Cannot handle truce offer without REJECTED option"
)
val bestOriginatingFid = bestOffer.originatingFactionId
val bestOriginatingFid = bestOffer.offeringFactionId
ResolveTruceOfferSelectedCommand(
ResolveTruceOfferSelected(
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < truceOfferAcceptanceChance(
@@ -205,8 +188,8 @@ object ResolveDiplomacyCommandSelector {
factions = factions,
provinces = provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
then Accepted
else Rejected
)
}
@@ -271,7 +254,7 @@ object ResolveDiplomacyCommandSelector {
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[ResolveRansomOfferAvailableCommand](availableCommands) { resolveRansomAc =>
selectionForType[ResolveRansomOfferAvailable](availableCommands) { resolveRansomAc =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
@@ -284,17 +267,18 @@ object ResolveDiplomacyCommandSelector {
}
private def selectionForResolveRansomOfferSelectedCommand(
ac: ResolveRansomOfferAvailableCommand
): SelectedCommand =
ac.offers.head.offerDetails match {
case rod: RansomOfferDetails =>
ResolveRansomOfferSelectedCommand(
offeringFactionId = ac.offers.head.originatingFactionId,
ransomOffer = Some(ac.offers.head),
resolution = RansomOfferHelpers.chosenResolution(rod)
)
case _ => throw new EagleInternalException("Not a ransom offer")
}
ac: ResolveRansomOfferAvailable
): SelectedCommand = {
val offer = ac.offers.head
// TODO: Proper scoring requires ransom details not currently in DiplomacyOfferInfo
// For now, accept all ransom offers
val resolution = if offer.eligibleStatuses.contains(Accepted) then Accepted else Rejected
ResolveRansomOfferSelected(
offeringFactionId = offer.offeringFactionId,
prisonerHeroId = offer.heroId.get,
resolution = resolution
)
}
private def resolveAllianceOfferSelectedCommand(
actingFactionId: FactionId,
@@ -302,7 +286,7 @@ object ResolveDiplomacyCommandSelector {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveAllianceOfferAvailableCommand](
randomSelectionForType[ResolveAllianceOfferAvailable](
availableCommands,
functionalRandom
) {
@@ -331,7 +315,7 @@ object ResolveDiplomacyCommandSelector {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveBreakAllianceAvailableCommand](
randomSelectionForType[ResolveBreakAllianceAvailable](
availableCommands,
functionalRandom
) {
@@ -354,7 +338,7 @@ object ResolveDiplomacyCommandSelector {
private def selectionForResolveAllianceOfferSelectedCommand(
actingFactionId: FactionId,
ac: ResolveAllianceOfferAvailableCommand,
ac: ResolveAllianceOfferAvailable,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
@@ -362,20 +346,20 @@ object ResolveDiplomacyCommandSelector {
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestOffer = ac.offers
.maxBy(inv => allianceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
.maxBy(inv => allianceOfferAcceptanceChance(inv.offeringFactionId, actingFid, factions, provinces))
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
bestOffer.eligibleStatuses.contains(Accepted),
"Cannot handle alliance offer without ACCEPTED option"
)
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
bestOffer.eligibleStatuses.contains(Rejected),
"Cannot handle alliance offer without REJECTED option"
)
val bestOriginatingFid = bestOffer.originatingFactionId
val bestOriginatingFid = bestOffer.offeringFactionId
ResolveAllianceOfferSelectedCommand(
ResolveAllianceOfferSelected(
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < allianceOfferAcceptanceChance(
@@ -384,26 +368,26 @@ object ResolveDiplomacyCommandSelector {
factions = factions,
provinces = provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
then Accepted
else Rejected
)
}
// FIXME: always imprison ambassadors for now
// Note: doesn't use gameState or functionalRandom roll
private def selectionForResolveBreakAllianceSelectedCommand(
ac: ResolveBreakAllianceAvailableCommand,
ac: ResolveBreakAllianceAvailable,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { _ =>
val offer = ac.offers.head
ResolveBreakAllianceSelectedCommand(
originatingFactionId = ac.offers.head.originatingFactionId,
ResolveBreakAllianceSelected(
originatingFactionId = offer.offeringFactionId,
resolution =
if offer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_IMPRISONED)
then DIPLOMACY_OFFER_STATUS_IMPRISONED
else if offer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
if offer.eligibleStatuses.contains(Imprisoned)
then Imprisoned
else if offer.eligibleStatuses.contains(Accepted)
then Accepted
else
throw new EagleInternalException(
"Unable to handle a break alliance command without IMPRISONED or ACCEPTED options"

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