Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 5cb23464e5 Fix nginx deploy: use SCP to copy config files
The production server doesn't have a git repo, so we can't use git
commands to update files. Instead, use appleboy/scp-action to copy
nginx.conf and docker-compose.prod.yml from the runner to the server,
then restart nginx.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:02:15 -08:00
3e1919a715 Update auth workflow to deploy nginx config changes (#5412)
- Add nginx/nginx.conf and docker-compose.prod.yml to trigger paths
- Pull latest nginx config and restart nginx during deployment

This ensures nginx config changes (like OAuth callback routes) are
automatically deployed when merged to main.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:56:25 -08:00
699bcc684a Store Scala types in ClientTextStore instead of proto (#5407)
* Store Scala types in ClientTextStore instead of proto

This change updates the ClientTextStore layer to store the native Scala
type `GeneratedTextRequestT` in memory instead of the proto type
`GeneratedTextRequest`. Proto conversion now only happens at persistence
boundaries (when saving to/loading from disk).

Key changes:
- ClientText.scala: Changed llmRequest field to use GeneratedTextRequestT
- ClientTextStore.scala: Updated interface to accept Scala types
- ClientTextStoreImpl.scala: Added toProto/fromProto conversion at persistence
- GameController.scala: Removed proto conversion in allNewLlmRequests
- UnrequestedTextHandler.scala: Uses Scala types internally, converts to proto
  only when passing to LlmResolver
- GamesManager.scala: Uses Scala types directly when creating UnrequestedClientText
- Updated BUILD files for new dependencies and visibility

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

* Fix tests for ClientTextStore Scala types migration

- Update UnrequestedTextHandlerTest to use Scala types instead of proto
- Add GeneratedTextRequestConverter import for proto conversions in test mocks
- Change test requests from FixedHeroName to LlmRequestT.DivineMessage to properly test LLM resolution path
- Add visibility for test packages in BUILD files
- Remove unused proto dependency from generator_utilities_test

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:54:50 -08:00
42fd9c5853 Fix flush marker wait: skip for old instance being replaced (#5413)
During blue-green deployment, the old (active) instance should not wait
for the flush marker - it would be waiting for itself to stop, which
causes a 30s timeout warning.

Now compares deployment timestamp to instance start time:
- If deployment started AFTER instance: skip wait (we're the old instance)
- If deployment started BEFORE instance: wait (we're the new instance)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:53:59 -08:00
01aa3dbace Add /oauth/steam/callback to nginx config (#5410)
Steam OpenID 2.0 redirects back to /oauth/steam/callback after
authentication, but nginx was not configured to proxy this path to the
auth service, resulting in 404 errors.

Added the location block alongside the existing /oauth/callback and
/oauth/apple/callback blocks.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:49:34 -08:00
a7595e81a2 Add all OAuth providers to admin server login page (#5409)
- Add GitHub, Apple, Steam, and Twitch to handleLoginStart switch
- Add sign-in buttons with icons for all providers to login.html
- Add CSS styles for new provider buttons

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:40:33 -08:00
6a61634f37 Add Steam and Twitch sign-in buttons to Unity client (#5404)
* Add Steam and Twitch sign-in buttons to Unity client

- Add steamLoginButton and twitchLoginButton fields
- Add steamProviderIcon and twitchProviderIcon for stored accounts
- Wire up button click handlers in SetupOAuthUI()
- Add icon mapping for stored account display

Note: Unity scene needs Steam/Twitch button GameObjects and icons wired up.

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

* Add Steam and Twitch OAuth button assets

- Add Steam and Twitch (glitch) logo icons for OAuth buttons
- Consolidate Discord icon to OAuth Buttons folder
- Remove unused Google OAuth button variants
- Update Gameplay.unity with new button references

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:34:11 -08:00
cfa552270a Add Twitch secrets to auth service deployment workflow (#5408)
The TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET were added to
docker-compose.prod.yml but not to the GitHub Actions workflow
that deploys the auth service.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:32:03 -08:00
09d560208e Add game state guidance tutorials (#5405)
* Add game state guidance tutorials

Add four contextual tutorials that trigger based on game state analysis:

1. guidance_loyalty_danger - November warning when heroes have low loyalty
   Suggests using Feast or Give Gift to boost loyalty before year end

2. guidance_neighbor_danger - Alert when province borders hostile faction
   Suggests Organizing Troops or seeking Diplomacy

3. guidance_recruit_heroes - When stable province has recruitable heroes
   Suggests using Travel, Divine, and Recruit commands

4. guidance_expand - When player has stable province but only one territory
   Suggests using March to claim new provinces

These provide strategic guidance for new players based on their game state.

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

* Fix compilation errors in guidance trigger checks

- MarchAvailableCommand.OneProvinceCommands to access destination provinces
- FactionRelationshipView.TargetFactionId instead of FactionId
- province.FullInfo.Gold instead of model.CurrentGold
- Net.Eagle0.Eagle.Common.RecruitmentStatus instead of Views namespace

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

* Use Any() instead of ToList().Count for efficiency

Any() short-circuits on first match rather than materializing entire list.

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

* Simplify recruit heroes gold check

Just check for 100 gold - January was when this would naturally be true,
not an alternative condition.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:18:17 -08:00
2188834ddf Fix auth deploy: remove SHARDOK_ADDRESS validation from docker-compose (#5406)
docker-compose validates the entire file even when deploying just auth.
Remove the :? validation since docker_build.yml already validates
SHARDOK_ADDRESS explicitly before deploying Eagle.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 12:45:18 -08:00
eafe5d73ee Add comment about dynamically registered hero row targets (#5402)
Document that WarlordRow, VassalRow1, VassalRow2 are registered dynamically
by HeroesAndBattalionsPanelController when hero rows are created from prefabs.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 12:27:54 -08:00
f16257591b Add Steam and Twitch OAuth support (#5403)
- Steam: OpenID 2.0 authentication (no API key required)
- Twitch: Standard OAuth 2.0 with user:read:email scope
- Add TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET to deployment config
- Add Steam/Twitch buttons to invitation landing page

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 12:26:53 -08:00
d259282213 Add fromProto converters for GeneratedTextRequest types (#5401)
Add fromProto methods to LlmRequestConverter and GeneratedTextRequestConverter
to enable converting proto types back to Scala types. This is preparation for
storing Scala types in-memory in ClientTextStore, with proto conversion only
happening at persistence boundaries.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 11:22:27 -08:00
de5e6cc65e Fix duplicate stored account buttons and add delete button (#5400)
- Clear all children of storedAccountsContainer and disable them
  immediately before destroying (fixes visual duplication from
  deferred Destroy)
- Add delete button to StoredAccountButton to remove saved accounts
- Wire up delete button to OAuthManager.RemoveStoredAccount

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 11:21:30 -08:00
7da2fb7913 Fix Divine tutorial text to describe actual functionality (#5399)
Divine reveals what you must do to impress a hero and recruit them,
not their stats/abilities.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 10:00:53 -08:00
a485772e66 Consolidate secrets handling: Eagle uses public key only for JWT validation (#5398)
Security improvement: Eagle no longer has access to JWT private keys.

- JwtService.scala: Simplified to validation-only (removed signing methods)
- Loads public key from /etc/eagle0/keys/public.pem (shared volume from auth)
- Falls back to extracting public key from JWT_PRIVATE_KEY for backward compat
- AuthServiceImpl.scala: Local fallbacks throw UNIMPLEMENTED (signing in Go auth)
- docker-compose.prod.yml: Removed JWT_PRIVATE_KEY from Eagle, volume now read-only

Auth service unchanged - still bootstraps PEM files from JWT_PRIVATE_KEY env var.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:44:03 -08:00
e0212fa7a2 Fix unit placement deselection during setup phase (#5397)
Clicking a selected unit during setup would trigger a placement action
instead of deselecting the unit, because HasPlacementAction returned
true for the unit's own tile. Now the placement check excludes the
currently selected tile, allowing the deselection logic to run.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:32:23 -08:00
6e76ff0fbc Use commit-count based versioning for Mac builds (#5392)
Change version scheme from git describe (which picked up unrelated tags
like busybox-1.35.0) to commit-count based versions like 1.0.9548.

This gives automatic, always-incrementing version numbers that Sparkle
can properly compare for updates.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:17:59 -08:00
a37e0af028 Make DiplomacyResolutionLlmRequestGenerator protoless (#5396) (#5396)
* Update DEPROTO_PLAN.md with recent completions

- Mark Phase 9 (Utilities) as complete - MapGenerator now protoless
- Mark Phase 10 (History APIs) as complete - PersistedHistory accepts Scala types
- Add PRs #5373, #5378, #5381, #5390 to recent completions
- Update proto import inventory with current counts
- Update success criteria checklist

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

* Make DiplomacyResolutionLlmRequestGenerator protoless

Replace proto types with Scala equivalents:
- DiplomacyOfferStatus -> Status
- GeneratedTextRequest -> LlmRequestT
- Proto message types -> LlmRequestT enum cases

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:05:37 -08:00
d6704413d3 Fix Sparkle codesigning for XPC services (#5393)
- Use ditto instead of cp -R to copy Sparkle.framework (preserves bundle structure)
- Skip individual signing of Sparkle's internal XPC services, apps, and executables
- Use --deep flag when signing Sparkle.framework to handle its internal components
- Verify cached Sparkle.framework has proper symlink structure, re-download if corrupted

Fixes "bundle format unrecognized" error during codesigning.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:04:39 -08:00
be59f9775f Add deployment validation for env vars and Shardok connectivity (#5394)
After investigating a bad deployment where Eagle connected to "shardok"
instead of the actual Hetzner address, found the root cause: GitHub
Actions secrets can occasionally fail to load, causing the heredoc to
expand with default values instead of the actual secrets.

Changes:
- Add validation at deploy start to check critical env vars are present
- Add network connectivity check to Shardok before blue-green deployment
  (blocks deployment if Shardok is unreachable)
- Remove the misleading "shardok:40042" default - now SHARDOK_ADDRESS
  must be explicitly set, making configuration errors immediately obvious
- docker-compose.prod.yml now uses :? syntax to require SHARDOK_ADDRESS

The env var validation will abort deployment early with a clear error
message if secrets failed to load, rather than deploying with broken
config.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:00:13 -08:00
bebe050352 Add Scala ActionResultT overload to PersistedHistory.apply (#5390)
Add a new apply overload that takes Vector[ActionResultT] directly,
avoiding proto conversion at the call site. The conversion to proto
for storage still happens internally, but callers like NewGameCreation
can now pass Scala types directly.

Removed the unused proto-based apply overload since all callers that
need proto types use the case class constructor directly.

This makes NewGameCreation fully protoless.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 08:36:52 -08:00
77d6fc571b Fix logout button to return to connection panel instead of quitting (#5391)
The logout button was wired to QuitButtonClicked in the Unity scene,
causing the app to exit instead of returning to the connection panel.

- Change scene to wire logout button to OnLogoutClicked
- Make OnLogoutClicked public so it's visible in Unity inspector
- Remove redundant programmatic listener setup

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 08:32:46 -08:00
adminandGitHub 65e0ad93cc center the text in the rows in the lobby (#5389) 2026-01-16 17:05:35 -08:00
adminandGitHub 275cb0c563 Enable the Sign In With Apple button and fix the button layouts (#5388)
* add sign in with apple button (disabled)

* and the github icon

* add the apple button

* better stored account button support
2026-01-16 15:44:17 -08:00
d64f35b18a Add loading indicator when switching environments in lobby (#5365)
* Add loading indicator when switching environments in lobby

Shows "Switching environment..." text while waiting for the server
to respond after changing the environment dropdown. Text is cleared
when the lobby data arrives and populates the game lists.

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

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

* add lobby connection status and fix 16:10 aspect ratio

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:04:07 -08:00
adminandGitHub ac0c03af9b bad cast (#5387) 2026-01-16 08:40:28 -08:00
adminandGitHub 6b0d58a9e7 map and continue (#5386) 2026-01-16 08:25:01 -08:00
adminandGitHub fb56cf0029 wrong match (#5385) 2026-01-16 07:49:07 -08:00
adminandGitHub 70a5a517af more debugging (#5384) 2026-01-16 07:38:06 -08:00
adminandGitHub 6de07a70dc full stack trace in lockedSendLobbyUpdate (#5383) 2026-01-16 07:30:02 -08:00
8e64af7233 Make NewGameCreation protoless (#5381)
* Make NewGameCreation protoless

Convert NewGameCreation to build Scala ActionResultC internally instead of
protobuf ActionResult. Proto conversion now only happens at the boundary
when storing to PersistedHistory.

Changes:
- NewGameCreation.scala: Use ActionResultC, Scala RoundPhase, ChronicleEntry,
  FactionC, FactionRelationship instead of proto equivalents
- StartGameActionResultUtils.scala: Change from proto ActionResult to
  ActionResultC throughout
- GameParametersUtils.scala: Add provinceWithOverrideScala method
- Add withId methods to HeroT/HeroC and BattalionT/BattalionC traits
- Update BUILD.bazel files with visibility for new_game_creation package
- Update test to use Scala types

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

* empty check

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 07:20:41 -08:00
ca91d3193e Fix missing newProvinces in ActionResultProtoConverter.toProto (#5382)
The toProto method was not serializing newProvinces, causing provinces
to be lost when persisting game state. This led to stack overflow during
game creation as the phase advancer would loop infinitely with no provinces.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 07:18:52 -08:00
1de12597d7 Export proto types from converters for caller visibility (#5380)
* Export proto types from converters for caller visibility

ActionResultProtoConverter and GameStateConverter return proto types,
so those proto dependencies need to be exported for callers to use
the return values.

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

* Fix missing proto exports and ServerSetupHelpers warnings

- Export shardok_battle_scala_proto from ShardokBattleConverter
- Export game_state_scala_proto and shardok_battle_scala_proto from GamesManager
- Remove unused ManagedChannelBuilder import from ServerSetupHelpers
- Remove unused default parameter from private newShardokInterface method

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:17:08 -08:00
36c09cae77 Fix unused parameter warnings in CustomBattleManager (#5379)
- Add @unused annotation to parameters that are intentionally unused
  (userName and name parameters that need future verification)
- Add missing game_state_scala_proto dependency to BUILD.bazel
- Add CustomBattleManagerTest with tests for CustomBattleGameController

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 13:54:53 -08:00
83d159823a Remove proto ActionResult from Engine layer (#5378)
- Change EngineAndResults.results to return Vector[ActionResultT] (Scala) instead of Vector[ActionResult] (proto)
- Update PostResults to use Scala ActionResultT and ShardokBattle types
- Update GameController methods to use Scala types internally
- Add GeneratedTextRequestConverter for Scala-to-proto conversion when needed for LLM requests
- Add recipientFactionIds field to ClientTextVisibilityExtensionT trait

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 11:32:51 -08:00
5ce88f04d2 Remove Apple Sign-In debug logging (#5377)
Remove verbose debug logs that were added during Apple Sign-In debugging:
- Token exchange client_id/redirect_uri
- id_token length
- Parsed user info (id/email)

Error logging is retained for troubleshooting.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 11:15:52 -08:00
1bfcb343d9 Fix Apple id_token claims to handle both string and bool types (#5376)
Apple inconsistently returns email_verified and is_private_email as
either boolean (true/false) or string ("true"/"false"). Added custom
AppleBool type that unmarshals both formats.

Also added missing claims from Apple's documentation:
- nonce, nonce_supported, c_hash, auth_time

See: https://developer.apple.com/forums/thread/121411

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:36:46 -08:00
88b628b73f Add logging for Apple id_token parsing (#5375)
Log the id_token length and any parse errors to help debug
"Failed to parse user info" errors.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:29:51 -08:00
9004e6887a Fix Apple private key parsing for base64-encoded PEM (#5374)
The key is stored as base64-encoded PEM. The previous code base64
decoded it but then tried to use the result directly as key bytes.
The fix: after base64 decoding, PEM decode to extract the actual
key bytes.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:23:39 -08:00
c62b3d3166 Make MapGenerator return Scala types (#5373)
MapGenerator now returns Scala ProvinceT instead of proto Province.
GameParametersUtils works with Scala types internally, converting to
proto only in NewGameCreation when needed for ActionResult.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:08:35 -08:00
6d54312aec Fix auth deployment using wrong image tag (#5372)
docker-compose ignores AUTH_IMAGE env var for unknown reasons.
Fix with multiple approaches:

1. Tag pulled image as :latest locally (belt)
2. Pass AUTH_IMAGE explicitly on command line (suspenders)
3. Add debug output to diagnose .env file issues
4. Verify by comparing image digests, not tags

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:00:54 -08:00
c886ddc769 Make BattalionTypeLoader return Scala types (#5371)
BattalionTypeLoader now returns Scala BattalionType instead of proto.
GamesManager and NewGameCreation updated to use Scala types internally,
with conversion to proto only when needed for GameState updates.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:57:28 -08:00
f6a7545533 Fully separate auth and eagle deployments (#5370)
- Exclude auth paths from docker_build.yml triggers so eagle builds
  don't trigger when only auth code changes
- Remove `docker compose up -d auth` from docker_build.yml - auth is
  deployed exclusively by auth_build.yml now
- Delete unused deploy/update-env.sh and deploy/env.template

This prevents docker_build.yml from reverting auth to stale images
when it runs after auth_build.yml.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:28:46 -08:00
8d3bc914ed Make GameHistory APIs return Scala types (#5367)
* Make GameHistory APIs return Scala types

The GameHistory trait now returns ActionResultWithResultingState (pure
Scala) instead of ActionWithResultingState (proto-based). Internal
storage in InMemoryHistory and PersistedHistory still uses proto types
for file persistence, but the public API is now protoless.

Changes:
- GameHistory.all, since, sinceDate, last, recentResultsForRound now
  return Vector[ActionResultWithResultingState]
- withNewResults accepts Scala types and converts to proto for storage
- Added toScala() helper in history implementations
- Updated callers: EngineImpl, EagleServiceImpl, ChronicleEventGenerator,
  ActionResultFilter, GamesManager
- SavedGameUtils uses internal ActionWithProtoState for proto reflection
- Fixed Option[Date] comparisons with .forall() pattern

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

* Delete unused SavedGameUtils

This file was a standalone debugging utility that was never integrated
into the build (BUILD target was commented out). Removing it as cleanup.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:45:09 -08:00
844d7d8e50 Fix auth service deployment not updating container (#5368)
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 08:28:47 -08:00
cae2d47af0 Add Apple OAuth debugging and GitHub email fetch (#5364)
- 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:34:46 -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
353 changed files with 14125 additions and 14876 deletions
+71 -32
View File
@@ -11,6 +11,8 @@ on:
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'ci/BUILD.bazel'
- '.github/workflows/auth_build.yml'
- 'nginx/nginx.conf'
- 'docker-compose.prod.yml'
workflow_dispatch:
inputs:
push_images:
@@ -104,6 +106,14 @@ 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 }}
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
@@ -112,15 +122,15 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy update-env script to server
- name: Copy config files 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
source: "nginx/nginx.conf,docker-compose.prod.yml"
target: "/opt/eagle0"
strip_components: 0
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
@@ -129,51 +139,80 @@ 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,TWITCH_CLIENT_ID,TWITCH_CLIENT_SECRET,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 TWITCH_CLIENT_ID="${TWITCH_CLIENT_ID}"
export TWITCH_CLIENT_SECRET="${TWITCH_CLIENT_SECRET}"
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
# 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; }
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
# Tag as :latest locally so any fallback uses correct image
docker tag "${AUTH_IMAGE}" registry.digitalocean.com/eagle0/auth-server:latest
# Only recreate the auth container (not eagle, shardok, etc.)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
# Debug: check environment and .env file
echo "DEBUG: AUTH_IMAGE=$AUTH_IMAGE"
env | grep AUTH || echo "AUTH_IMAGE not in env output"
if [ -f .env ]; then
echo "DEBUG: .env file contents related to AUTH:"
grep AUTH .env || echo "No AUTH in .env"
fi
# Recreate auth container - pass AUTH_IMAGE explicitly on command line
AUTH_IMAGE="${AUTH_IMAGE}" docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
# Wait for health check
sleep 5
# Verify container is using the correct image
# Note: docker-compose may use :latest tag (which we tagged to the correct image)
echo "=== Verifying auth container image ==="
RUNNING_IMAGE=$(docker inspect auth-server --format '{{.Config.Image}}')
RUNNING_DIGEST=$(docker inspect auth-server --format '{{.Image}}')
EXPECTED_DIGEST=$(docker inspect "${AUTH_IMAGE}" --format '{{.Id}}')
echo "Expected image: ${AUTH_IMAGE}"
echo "Running image: ${RUNNING_IMAGE}"
echo "Expected digest: ${EXPECTED_DIGEST}"
echo "Running digest: ${RUNNING_DIGEST}"
if [ "$RUNNING_DIGEST" != "$EXPECTED_DIGEST" ]; then
echo "ERROR: Container is running wrong image!"
exit 1
fi
echo "Image digests match - correct image is running"
# Show container status
docker compose -f docker-compose.prod.yml ps auth
# Verify container is using correct image
echo "=== Verifying auth container image ==="
docker compose -f docker-compose.prod.yml images auth
# Restart nginx with updated config (copied via SCP step)
echo "=== Restarting nginx ==="
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate nginx
echo "Nginx restarted"
# Cleanup old images
docker image prune -f
+116 -50
View File
@@ -5,9 +5,15 @@ on:
branches: [ "main" ]
paths:
# Note: C++ changes trigger shardok_arm64_build.yml instead
# Note: Auth changes trigger auth_build.yml instead
- 'src/main/go/**'
- '!src/main/go/net/eagle0/authservice/**'
- '!src/main/go/net/eagle0/authcli/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- '!src/main/protobuf/net/eagle0/eagle/api/auth.proto'
- '!src/main/protobuf/net/eagle0/eagle/api/admin/**'
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
@@ -162,6 +168,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 }}
@@ -192,7 +204,7 @@ jobs:
# Create directory structure on remote
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
set -e
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx /opt/eagle0/deploy
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx
rm -f /opt/eagle0/scripts/bin/warmup
SETUP_DIRS
@@ -201,7 +213,6 @@ jobs:
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
scp -i ~/.ssh/deploy_key deploy/env.template deploy/update-env.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/deploy/
- name: Deploy to production droplet
run: |
@@ -209,57 +220,89 @@ 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}"
# =================================================================
# CRITICAL: Validate environment variables before proceeding
# This catches GitHub Actions secret store hiccups early
# =================================================================
validate_env() {
local var_name="\$1"
local var_value="\$2"
local default_value="\${3:-}"
if [ -z "\${var_value}" ]; then
echo "ERROR: \${var_name} is empty. GitHub Actions secrets may have failed to load."
echo "Please retry the workflow."
return 1
fi
# Check if we got a default value instead of the real secret
if [ -n "\${default_value}" ] && [ "\${var_value}" = "\${default_value}" ]; then
echo "ERROR: \${var_name} has default value '\${default_value}' instead of the actual secret."
echo "This indicates GitHub Actions secrets failed to load. Please retry the workflow."
return 1
fi
return 0
}
echo "Validating critical environment variables..."
# These are the raw values from GitHub Actions (before export)
# We check them before exporting to catch issues early
VALIDATION_FAILED=0
validate_env "SHARDOK_ADDRESS" "${SHARDOK_ADDRESS}" "" || VALIDATION_FAILED=1
validate_env "EAGLE_IMAGE" "${EAGLE_IMAGE}" "" || VALIDATION_FAILED=1
validate_env "JWT_PRIVATE_KEY" "${JWT_PRIVATE_KEY}" "" || VALIDATION_FAILED=1
validate_env "DO_REGISTRY_TOKEN" "${DO_REGISTRY_TOKEN}" "" || VALIDATION_FAILED=1
if [ "\${VALIDATION_FAILED}" -eq 1 ]; then
echo ""
echo "========================================="
echo "DEPLOYMENT ABORTED: Missing critical secrets"
echo "This is likely a transient GitHub Actions issue."
echo "Please retry the workflow."
echo "========================================="
exit 1
fi
echo "All critical environment variables validated successfully."
# =================================================================
# 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}"
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
@@ -287,20 +330,43 @@ jobs:
echo "All images pulled successfully"
# =================================================================
# Verify Shardok connectivity before proceeding with deployment
# This catches network/firewall issues early
# =================================================================
echo "Verifying Shardok connectivity..."
SHARDOK_HOST=\$(echo "\${SHARDOK_ADDRESS}" | cut -d: -f1)
SHARDOK_PORT=\$(echo "\${SHARDOK_ADDRESS}" | cut -d: -f2)
# Try to connect to Shardok (timeout after 10 seconds)
if nc -z -w 10 "\${SHARDOK_HOST}" "\${SHARDOK_PORT}" 2>/dev/null; then
echo "Shardok connectivity verified: \${SHARDOK_ADDRESS} is reachable"
else
echo ""
echo "========================================="
echo "ERROR: Cannot reach Shardok at \${SHARDOK_ADDRESS}"
echo "This may indicate:"
echo " - Shardok server is not running on Hetzner"
echo " - Network/firewall issues between DigitalOcean and Hetzner"
echo " - Incorrect SHARDOK_ADDRESS configuration"
echo ""
echo "DEPLOYMENT ABORTED: Shardok must be reachable for battles to work."
echo "========================================="
exit 1
fi
# Stop local shardok container if running (now runs on Hetzner)
docker stop shardok-server 2>/dev/null || true
docker rm shardok-server 2>/dev/null || true
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
# Note: Auth is deployed separately via auth_build.yml - do NOT touch auth here
chmod +x /opt/eagle0/scripts/*.sh
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
# Ensure auth is running
docker compose -f docker-compose.prod.yml up -d auth
# Verify
sleep 10
docker compose -f docker-compose.prod.yml ps
+8 -2
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
@@ -256,8 +261,9 @@ jobs:
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
VERSION=$(git describe --tags --always)
# Use commit count for automatic incrementing versions (e.g., 1.0.9548)
BUILD_NUMBER=$(git rev-list --count HEAD)
VERSION="1.0.${BUILD_NUMBER}"
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
"/tmp/eagle0/eagle0MAC/eagle0.app" \
+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
+18
View File
@@ -130,6 +130,13 @@ bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_a
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
# Register Apple CC toolchain for Objective-C compilation
apple_cc_configure = use_extension(
"@build_bazel_apple_support//crosstool:setup.bzl",
"apple_cc_configure_extension",
)
use_repo(apple_cc_configure, "local_config_apple_cc")
#
# Protocol Buffers & RPC
#
@@ -293,6 +300,17 @@ http_archive(
],
)
# Sparkle framework for macOS auto-updates
SPARKLE_VERSION = "2.6.4"
http_archive(
name = "sparkle",
build_file = "@//external:BUILD.sparkle",
sha256 = "50612a06038abc931f16011d7903b8326a362c1074dabccb718404ce8e585f0b",
strip_prefix = "",
url = "https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz" % (SPARKLE_VERSION, SPARKLE_VERSION),
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# Primary: DigitalOcean Spaces (public, reliable)
# Fallback: busybox.net (can be unreliable/slow)
+1 -1
View File
@@ -396,7 +396,7 @@
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
"general": {
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
"usagesDigest": "tl3VVeQX3Hzh7FhM2gjnkCwEJpRMlY5S6a850WY/xvc=",
"usagesDigest": "DqQsfZN5lA8z+nLEEY+EpKGzQ8M73mDm/A8lofDSyus=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
-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/
-40
View File
@@ -1,40 +0,0 @@
# Environment template for production deployment
# This file defines all env vars used by docker-compose.prod.yml
# Workflows should update their specific vars without overwriting others
# Container images (managed by respective build workflows)
# Note: Shardok runs on Hetzner, deployed via shardok_arm64_build.yml
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
# OpenAI / LLM
OPENAI_API_KEY=
GPT_MODEL_NAME=gpt-4o
# DigitalOcean Spaces (S3-compatible storage)
EAGLE_ENABLE_S3=false
DO_SPACES_ACCESS_KEY=
DO_SPACES_SECRET_KEY=
# JWT authentication
JWT_PRIVATE_KEY=
# OAuth providers
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Shardok connection (Hetzner ARM64 server)
SHARDOK_ADDRESS=
SHARDOK_AUTH_TOKEN=
# Monitoring
SENTRY_DSN=
# Email (Fastmail JMAP)
FASTMAIL_API_TOKEN=
FASTMAIL_FROM_EMAIL=
FASTMAIL_FROM_NAME=
-59
View File
@@ -1,59 +0,0 @@
#!/bin/bash
# Update .env file without losing other variables
# Usage: ./update-env.sh KEY1=value1 KEY2=value2 ...
#
# This script:
# 1. Creates .env from template if it doesn't exist
# 2. Updates only the specified KEY=value pairs
# 3. Preserves all other existing values
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${ENV_FILE:-/opt/eagle0/.env}"
TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/env.template}"
# Create .env from template if it doesn't exist
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$TEMPLATE_FILE" ]; then
echo "Creating .env from template..."
grep -v '^#' "$TEMPLATE_FILE" | grep -v '^$' > "$ENV_FILE"
else
echo "Creating empty .env..."
touch "$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
fi
# Process each KEY=VALUE argument
for arg in "$@"; do
# Skip empty args
[ -z "$arg" ] && continue
# Parse KEY=VALUE
KEY="${arg%%=*}"
VALUE="${arg#*=}"
# Skip if no key
[ -z "$KEY" ] && continue
# Skip setting empty values (keeps existing value)
if [ -z "$VALUE" ]; then
echo "Skipping $KEY (empty value)"
continue
fi
# Remove existing line for this key and add new one
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
# Key exists, update it
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
echo "Updated $KEY"
else
# Key doesn't exist, add it
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
echo "Added $KEY"
fi
done
chmod 600 "$ENV_FILE"
echo "Done updating $ENV_FILE"
+18 -10
View File
@@ -21,7 +21,7 @@ services:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "${SHARDOK_ADDRESS}"
- "--auth-service-url"
- "auth:40033"
ports:
@@ -32,9 +32,8 @@ services:
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
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:-}"
# JWT public key for token validation (auth service handles signing)
# Reads from /etc/eagle0/keys/public.pem via shared volume
# Auth token for Shardok on Hetzner (required)
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
# Use persistent volume for save data (users, games, etc.)
@@ -47,7 +46,7 @@ services:
- ./archived:/app/archived # Archived completed games
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
- jwt-keys:/etc/eagle0/keys:ro # JWT public key from auth service (read-only)
depends_on:
- auth
restart: unless-stopped
@@ -71,7 +70,7 @@ services:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "${SHARDOK_ADDRESS}"
- "--auth-service-url"
- "auth:40033"
ports:
@@ -82,9 +81,8 @@ services:
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
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:-}"
# JWT public key for token validation (auth service handles signing)
# Reads from /etc/eagle0/keys/public.pem via shared volume
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
EAGLE_SAVE_DIR: "/app/saves"
EAGLE_ARCHIVE_DIR: "/app/archived"
@@ -95,7 +93,7 @@ services:
- ./archived:/app/archived # Same archive directory as blue
- ./jfr:/app/jfr # JFR recordings (same as blue)
- jvm-tmp:/tmp # Shared with jfr-sidecar-green for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
- jwt-keys:/etc/eagle0/keys:ro # JWT public key from auth service (read-only)
depends_on:
- auth
restart: "no" # Don't auto-restart during deployment
@@ -133,6 +131,16 @@ 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:-}"
# Twitch OAuth credentials
TWITCH_CLIENT_ID: "${TWITCH_CLIENT_ID:-}"
TWITCH_CLIENT_SECRET: "${TWITCH_CLIENT_SECRET:-}"
# 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
+218 -302
View File
@@ -33,7 +33,25 @@
---
## 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) |
| Other Utilities (`/library/util/`) | **0** | ✅ **Complete** |
| NewGameCreation (`/service/new_game_creation/`) | **0** | ✅ **Complete** (except input GameParameters proto) |
| LLM Prompt Generators (`/library/actions/llm_prompt_generators/`) | **56** | ⏳ Remaining work (34 files) |
| LLM Request Generators (`/library/actions/llm_request_generators/`) | **2** | ⏳ Remaining work (1 file) |
| Root Library (`/library/`) | **7** | Boundary code (4 files) |
**Total: ~65 proto imports remaining** (down from 149)
### Completed Phases
@@ -43,346 +61,244 @@
| 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-#5390)
`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
14. **MapGenerator protoless** (PR #5373) - Returns Scala types instead of proto
15. **Remove proto ActionResult from Engine layer** (PR #5378) - Engine uses Scala `ActionResultT`
16. **NewGameCreation protoless** (PR #5381) - Uses Scala types throughout, no proto conversion
17. **PersistedHistory Scala API** (PR #5390) - New `apply` overload accepts `Vector[ActionResultT]` directly
---
## 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 ✅ Complete
**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` | ✅ **0** | Returns Scala types (PR #5373) |
| `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
**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
**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:**
```
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** | | |
### Phase 10: History APIs ✅ Complete
**Completed:**
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
- `CommandChoiceHelpers` migrated to Scala types
- `ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
- `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
- `PersistedHistory.apply` now accepts `Vector[ActionResultT]` directly (PR #5390)
- `NewGameCreation` is fully protoless (PR #5381)
### 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
**Note:** History internals still use proto for persistence (expected - proto is good for disk serialization).
---
## 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
- LLM request generators (2 imports in 1 file) - Medium priority
- BattleFilter (1 import) - Keep (boundary)
- Root library boundary code (7 imports in 4 files) - Keep (boundary)
---
## 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 |
| Utility files | 0 | 0 | ✅ Complete |
| History APIs | 0 | 0 | ✅ Complete |
| LLM Prompt Generators | 34 | 56 | Medium |
| LLM Request Generators | 1 | 2 | Medium |
| Root Library (boundary) | 4 | 7 | Keep |
| **Total** | **~35** | **~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)
- [x] Zero proto imports in `/library/util/` (except view filters)
- [x] Zero proto imports in `/service/new_game_creation/` (except input GameParameters)
- [ ] Zero proto imports in `/library/actions/llm_prompt_generators/`
- [ ] Zero proto imports in `/library/actions/llm_request_generators/`
### 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
- [x] NewGameCreation uses Scala types
- [x] PersistedHistory accepts Scala types for new games
- [ ] 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 ~35 files** (as of 2026-01-17)
### 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 |
| `actions/llm_request_generators/` | 1 | 2 | LLM request types |
| `util/command_choice_helpers/` | 0 | 0 | ✅ Clean |
| `util/view_filters/` | 0 | 0 | ✅ Clean (except boundary) |
| `util/` (other) | 0 | 0 | ✅ Clean |
| Root (`library/`) | 4 | 7 | 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 ✅ Complete
| File | Imports | Notes |
|------|---------|-------|
| `MapGenerator.scala` | ✅ **0** | Returns Scala types (PR #5373) |
### Root library/ (7 imports, 4 files)
| File | Imports | Notes |
|------|---------|-------|
| `ActionResultFilter.scala` | 3 | Action result filtering (boundary, uses Scala types internally) |
| `Engine.scala` | 1 | Trait interface (returns `ActionResultView` proto for client) |
| `EngineImpl.scala` | 1 | Returns proto for persistence (boundary) |
| `ActionWithResultingState.scala` | 2 | Stores both proto and Scala (boundary for persistence) |
### 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.
### actions/llm_request_generators/ (2 imports, 1 file)
| File | Imports | Notes |
|------|---------|-------|
| `DiplomacyResolutionLlmRequestGenerator.scala` | 2 | Uses `DiplomacyOfferStatus` enum and `GeneratedTextRequest` |
**Migration strategy**: Create Scala `DiplomacyOfferStatus` enum and `GeneratedTextRequest` types.
---
## 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`
+8
View File
@@ -0,0 +1,8 @@
load("@build_bazel_rules_apple//apple:apple.bzl", "apple_dynamic_framework_import")
# Import pre-built Sparkle framework
apple_dynamic_framework_import(
name = "Sparkle",
framework_imports = glob(["Sparkle.framework/**"]),
visibility = ["//visibility:public"],
)
+14
View File
@@ -112,6 +112,20 @@ 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;
}
# Steam OAuth callback (Steam uses OpenID 2.0)
location /oauth/steam/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
@@ -8,3 +8,6 @@ ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_g
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
/bin/echo "building sparkle plugin"
./scripts/build_sparkle_plugin.sh
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
#
# Build the SparklePlugin native library for Unity using Bazel
#
# Usage: build_sparkle_plugin.sh [output_dir]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUTPUT_DIR="${1:-$PROJECT_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/macOS}"
echo "=== Building SparklePlugin with Bazel ==="
bazel build --config=mactools //src/main/objc/net/eagle0/sparkle:SparklePlugin
# Get the zip path from bazel
ZIP_PATH=$(bazel cquery --config=mactools --output=files //src/main/objc/net/eagle0/sparkle:SparklePlugin 2>/dev/null)
echo "=== Extracting SparklePlugin.bundle ==="
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/"
+25 -6
View File
@@ -39,32 +39,51 @@ find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign XPC services (inside Sparkle framework)
# Sign XPC services (but skip ones inside Sparkle.framework - they're already signed)
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
if [[ "$item" == *"Sparkle.framework"* ]]; then
echo "Skipping Sparkle XPC service (pre-signed): $item"
continue
fi
echo "Signing XPC service: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign nested apps (like Sparkle's Updater.app)
# Sign nested apps (but skip ones inside Sparkle.framework - they're already signed)
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
if [[ "$item" == *"Sparkle.framework"* ]]; then
echo "Skipping Sparkle nested app (pre-signed): $item"
continue
fi
echo "Signing nested app: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign standalone executables inside frameworks (like Autoupdate)
# Sign standalone executables inside frameworks (but skip Sparkle.framework internals)
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
if [[ "$item" == *"Sparkle.framework"* ]]; then
echo "Skipping Sparkle executable (pre-signed): $item"
continue
fi
echo "Signing executable: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all frameworks (after their contents are signed)
# Use --deep for Sparkle.framework to handle its XPC services
find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do
echo "Signing framework: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
if [[ "$item" == *"Sparkle.framework" ]]; then
echo "Signing Sparkle framework with --deep: $item"
codesign --deep --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
else
echo "Signing framework: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
fi
done
echo "=== Signing main app bundle ==="
+46 -19
View File
@@ -27,33 +27,59 @@ if [ -z "${SPARKLE_EDDSA_PUBLIC_KEY:-}" ]; then
exit 1
fi
# Download Sparkle if not cached
# Always use a fresh Sparkle download to avoid cache corruption issues
SPARKLE_DIR="$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION"
if [ ! -d "$SPARKLE_DIR/Sparkle.framework" ]; then
echo "=== Downloading Sparkle $SPARKLE_VERSION ==="
mkdir -p "$SPARKLE_CACHE_DIR"
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
curl -L "$SPARKLE_URL" | tar -xJ -C "$SPARKLE_CACHE_DIR"
mv "$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION" "$SPARKLE_DIR" 2>/dev/null || true
# If the extracted directory doesn't match version pattern, it may just be "Sparkle"
if [ ! -d "$SPARKLE_DIR" ]; then
mkdir -p "$SPARKLE_DIR"
mv "$SPARKLE_CACHE_DIR/Sparkle.framework" "$SPARKLE_DIR/" 2>/dev/null || true
mv "$SPARKLE_CACHE_DIR/bin" "$SPARKLE_DIR/" 2>/dev/null || true
fi
echo "=== Clearing Sparkle cache and downloading fresh copy ==="
rm -rf "$SPARKLE_DIR"
mkdir -p "$SPARKLE_DIR"
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
echo "Downloading from: $SPARKLE_URL"
curl -L "$SPARKLE_URL" -o /tmp/sparkle.tar.xz
tar -xJf /tmp/sparkle.tar.xz -C "$SPARKLE_DIR"
rm /tmp/sparkle.tar.xz
# Show what was extracted
echo "=== Extracted contents ==="
ls -la "$SPARKLE_DIR/"
# The tarball extracts files directly, not into a subdirectory
# Verify the framework has proper symlink structure
echo "=== Verifying Sparkle.framework structure ==="
ls -la "$SPARKLE_DIR/Sparkle.framework/"
if [ ! -L "$SPARKLE_DIR/Sparkle.framework/Sparkle" ]; then
echo "ERROR: Sparkle.framework/Sparkle is not a symlink"
file "$SPARKLE_DIR/Sparkle.framework/Sparkle"
exit 1
fi
if [ ! -L "$SPARKLE_DIR/Sparkle.framework/Versions/Current" ]; then
echo "ERROR: Sparkle.framework/Versions/Current is not a symlink"
ls -la "$SPARKLE_DIR/Sparkle.framework/Versions/"
exit 1
fi
echo "Sparkle framework structure verified OK"
echo "=== Injecting Sparkle framework ==="
FRAMEWORKS_DIR="$APP_PATH/Contents/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
# Copy Sparkle framework
cp -R "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/"
# Remove any existing Sparkle.framework in the app
rm -rf "$FRAMEWORKS_DIR/Sparkle.framework"
# Also copy the XPC services if present
if [ -d "$SPARKLE_DIR/Sparkle.framework/Versions/B/XPCServices" ]; then
echo "Sparkle XPC services present"
# Copy Sparkle framework (use ditto to preserve symlinks and bundle structure)
ditto "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/Sparkle.framework"
# Verify the copied framework still has proper structure
echo "=== Verifying copied Sparkle.framework structure ==="
ls -la "$FRAMEWORKS_DIR/Sparkle.framework/"
if [ ! -L "$FRAMEWORKS_DIR/Sparkle.framework/Sparkle" ]; then
echo "ERROR: Copied framework lost symlink structure"
exit 1
fi
echo "Copied framework structure OK"
echo "=== Updating Info.plist ==="
PLIST_PATH="$APP_PATH/Contents/Info.plist"
@@ -69,8 +95,9 @@ PLIST_PATH="$APP_PATH/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool true" "$PLIST_PATH"
# Set bundle version from git for Sparkle version comparison
VERSION=$(git describe --tags --always 2>/dev/null || echo "1.0.0")
# Use commit count for automatic incrementing versions (e.g., 1.0.9548)
BUILD_NUMBER=$(git rev-list --count HEAD 2>/dev/null || echo "1")
VERSION="1.0.${BUILD_NUMBER}"
echo "Setting version: $VERSION (build $BUILD_NUMBER)"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" 2>/dev/null || \
@@ -87,7 +87,6 @@
<Compile Include="Assets/Modern UI Pack/Scripts/Demo/LaunchURL.cs" />
<Compile Include="Assets/Shardok/HexCoordinates.cs" />
<Compile Include="Assets/Eagle/Table Rows/OrganizeTroopsTableRow.cs" />
<Compile Include="Assets/Bluetooth/PickerRowController.cs" />
<Compile Include="Assets/TouchHandler.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/TravelCommandSelector.cs" />
<Compile Include="Assets/Eagle/ConnectionStatusUI.cs" />
@@ -98,7 +97,6 @@
<Compile Include="Assets/HoveringTooltipTextProvider.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/util/KeyModifiedAmount.cs" />
<Compile Include="Assets/HoveringTooltip.cs" />
<Compile Include="Assets/Bluetooth/DiceInterface.cs" />
<Compile Include="Assets/ButtonColors.cs" />
<Compile Include="Assets/Eagle/MapController.cs" />
<Compile Include="Assets/common/DisclosureTriangle.cs" />
@@ -151,10 +149,8 @@
<Compile Include="Assets/Eagle/GeneratedTextUpdater.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/OutlawApprehendedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/HexMetrics.cs" />
<Compile Include="Assets/Bluetooth/OneDiceRollController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Switch/SwitchManager.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
<Compile Include="Assets/common/ResourceFetcher.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialStep.cs" />
<Compile Include="Assets/Auth/AuthClient.cs" />
@@ -162,12 +158,11 @@
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowManagerEditor.cs" />
<Compile Include="Assets/common/GUIUtils/AutoScrollingText.cs" />
<Compile Include="Assets/Bluetooth/NativeDiceInterfaceImports.cs" />
<Compile Include="Assets/Eagle/Notifications/ProvinceConqueredNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
<Compile Include="Assets/Auth/JwtAuthInterceptor.cs" />
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialContentDefinitions.cs" />
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ShatteredArmyDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/TurnHistoryPanelController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/HeroDepartureDetailsNotificationGenerator.cs" />
@@ -212,7 +207,6 @@
<Compile Include="Assets/Eagle/CommandSelectors/ExileVassalCommandSelector.cs" />
<Compile Include="Assets/Tutorial/TutorialState.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIconEditor.cs" />
<Compile Include="Assets/Bluetooth/DiceVectors.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoop.cs" />
<Compile Include="Assets/Eagle/MovingArmiesTableController.cs" />
<Compile Include="Assets/ConnectionHandler/ConnectionHandler.cs" />
@@ -233,9 +227,11 @@
<Compile Include="Assets/Eagle/CommandSelectors/DiplomacyCommandSelector.cs" />
<Compile Include="Assets/Tutorial/Triggers/TutorialTriggerRegistry.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveBreakAllianceCommandSelector.cs" />
<Compile Include="Assets/common/WindowFocusManager.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveAllianceCommandSelector.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/WithdrewForTruceDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/MarchCommandSelector.cs" />
<Compile Include="Assets/Tutorial/TutorialTargetRegistry.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/TrainCommandSelector.cs" />
<Compile Include="Assets/Eagle/CustomFileLogger.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationAcceptedDetailsNotificationGenerator.cs" />
@@ -269,7 +265,6 @@
<Compile Include="Assets/Shardok/AnimationTestController.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/TradeCommandSelector.cs" />
<Compile Include="Assets/Eagle/Table Rows/DominionTableRowController.cs" />
<Compile Include="Assets/Bluetooth/RollPanelController.cs" />
<Compile Include="Assets/Shardok/ChargeAnimator.cs" />
<Compile Include="Assets/Eagle/ClientTextProvider.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManager.cs" />
@@ -304,6 +299,7 @@
<Compile Include="Assets/Shardok/ControlAnimator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Horizontal Selector/HorizontalSelector.cs" />
<Compile Include="Assets/Eagle/Table Rows/OrganizeTroopsResultRow.cs" />
<Compile Include="Assets/Eagle/SparkleUpdater.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/SliderManagerEditor.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/SwearBrotherhoodDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/PersistentClientConnection.cs" />
@@ -322,10 +318,10 @@
<Compile Include="Assets/Shardok/ShardokGameModel.cs" />
<Compile Include="Assets/common/GUIUtils/GeneralClickDetector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerContextMenu.cs" />
<Compile Include="Assets/Eagle/SparkleInitializer.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceAmbassadorImprisonedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIcon.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialHintIndicator.cs" />
<Compile Include="Assets/Bluetooth/UnityDieColors.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/OrganizeTroopsCommandSelector.cs" />
<Compile Include="Assets/Shardok/ExtinguishAnimator.cs" />
<Compile Include="Assets/Eagle/PanelPositions.cs" />
@@ -338,7 +334,6 @@
<Compile Include="Assets/EagleConnection.cs" />
<Compile Include="Assets/GUI Pro Kit Fantasy RPG/Scripts/CtrPanel.cs" />
<Compile Include="Assets/UI/Scripts/SceneLoadTester.cs" />
<Compile Include="Assets/Bluetooth/RollFetcher.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/HandleRiotGiveCommandSelector.cs" />
<Compile Include="Assets/Eagle/ProvinceUtils.cs" />
<Compile Include="Assets/common/GUIUtils/ProvinceStatUtils.cs" />
@@ -372,16 +367,15 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/SuppressBeastsFailedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Terrain Hexes/Example Scene/BasicHexArranger.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ManagePrisonersCommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/SendSuppliesCommandSelector.cs" />
<Compile Include="Assets/ConnectionHandler/StoredAccountButton.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialUIManager.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/SendSuppliesCommandSelector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Window/WindowManager.cs" />
<Compile Include="Assets/Auth/InvitationCodeManager.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/CapturedHeroExiledDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/SuppressBeastsSucceededNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/ReservesTableController.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/CommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/RestCommandSelector.cs" />
<Compile Include="Assets/Bluetooth/DiceConfigurationPanelController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerDropdown.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerButton.cs" />
<Compile Include="Assets/common/GUIUtils/GUIUtils.cs" />
@@ -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,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,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,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,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,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,25 +86,21 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject oauthPanel;
public Button discordLoginButton;
public Button googleLoginButton;
public Button githubLoginButton;
public Button appleLoginButton;
public Button steamLoginButton;
public Button twitchLoginButton;
public TextMeshProUGUI oauthStatusText;
[Header("Stored Accounts")]
public GameObject storedAccountsContainer;
public GameObject storedAccountButtonPrefab;
public StoredAccountButton storedAccountButtonPrefab;
public Sprite discordProviderIcon;
public Sprite googleProviderIcon;
[Header("Display Name Setup")]
public GameObject displayNamePanel;
public TMP_InputField displayNameField;
public Button setDisplayNameButton;
public TextMeshProUGUI displayNameErrorText;
[Header("Invitation Code Entry")]
public GameObject invitationCodePanel;
public TMP_InputField invitationCodeField;
public Button submitInvitationCodeButton;
public TextMeshProUGUI invitationCodeErrorText;
public Sprite githubProviderIcon;
public Sprite appleProviderIcon;
public Sprite steamProviderIcon;
public Sprite twitchProviderIcon;
[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;
@@ -124,6 +121,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button cancelCustomBattleButton;
public TMP_Dropdown lobbyEnvironmentDropdown;
public TextMeshProUGUI lobbyUserText;
public TextMeshProUGUI lobbyStatusText;
public GameObject runningGamesListArea;
public GameObject runningGamesListItemPrefab;
@@ -150,14 +148,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private CancellationTokenSource _cancellationTokenSource;
private readonly Object pendingReplyLock = new Object();
private int _connectedEnvironmentIndex = -1; // -1 means not connected
private List<GameObject> _storedAccountButtons = new List<GameObject>();
private List<StoredAccountButton> _storedAccountButtons = new();
public ClientPregeneratedText clientPregeneratedText;
const String BaseDomain = "eagle0.net";
const String EnvironmentKey = "environmentKey";
const String NameKey = "nameKey";
const String PasswordKey = "passwordKey";
// Environment options: index 0 = prod., index 1 = qa., index 2 = (none)
private static readonly List<string> EnvironmentOptions = new() { "prod.", "qa.", "" };
@@ -213,6 +209,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,20 +256,24 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (googleLoginButton != null) {
googleLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Google));
}
if (setDisplayNameButton != null) {
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
if (githubLoginButton != null) {
githubLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Github));
}
if (submitInvitationCodeButton != null) {
submitInvitationCodeButton.onClick.AddListener(OnSubmitInvitationCodeClicked);
if (appleLoginButton != null) {
appleLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Apple));
}
if (steamLoginButton != null) {
steamLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Steam));
}
if (twitchLoginButton != null) {
twitchLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Twitch));
}
// Subscribe to OAuthManager events
if (OAuthManager.Instance != null) {
OAuthManager.Instance.OnLoginSuccess += OnOAuthLoginSuccess;
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
OAuthManager.Instance.OnLogout += OnOAuthLogout;
OAuthManager.Instance.OnInvitationRequired += OnInvitationRequired;
}
// Show auth panel with stored accounts
@@ -280,10 +281,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);
@@ -301,9 +298,13 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void RefreshStoredAccountButtons() {
// Clear existing buttons
foreach (var btn in _storedAccountButtons) {
if (btn != null) Destroy(btn);
// Clear ALL children of the container
// Disable immediately (Destroy is deferred to end of frame)
if (storedAccountsContainer != null) {
foreach (Transform child in storedAccountsContainer.transform) {
child.gameObject.SetActive(false);
Destroy(child.gameObject);
}
}
_storedAccountButtons.Clear();
@@ -320,26 +321,33 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
buttonObj.transform.localScale = Vector3.one;
// Set button text (just display name, icon shows provider)
var buttonText = buttonObj.GetComponentInChildren<TextMeshProUGUI>();
if (buttonText != null) { buttonText.text = account.DisplayName; }
buttonObj.label.text = account.DisplayName;
// Set provider icon (look for child named "ProviderIcon")
var providerIconTransform = buttonObj.transform.Find("ProviderIcon");
if (providerIconTransform != null) {
var providerImage = providerIconTransform.GetComponent<Image>();
if (providerImage != null) {
var isGoogle = account.Provider.ToLowerInvariant() == "google";
providerImage.sprite = isGoogle ? googleProviderIcon : discordProviderIcon;
}
var providerImage = buttonObj.icon;
if (providerImage != null) {
var providerLower = account.Provider.ToLowerInvariant();
providerImage.sprite = providerLower switch { "google" => googleProviderIcon,
"github" => githubProviderIcon,
"apple" => appleProviderIcon,
"steam" => steamProviderIcon,
"twitch" => twitchProviderIcon,
_ => discordProviderIcon };
}
// Set click handler
// Set click handler for sign-in
var button = buttonObj.GetComponent<Button>();
if (button != null) {
var accountCopy = account; // Capture for lambda
button.onClick.AddListener(() => OnStoredAccountClicked(accountCopy));
}
// Set click handler for delete button
if (buttonObj.deleteButton != null) {
var accountCopy = account; // Capture for lambda
buttonObj.deleteButton.onClick.AddListener(
() => OnDeleteStoredAccount(accountCopy));
}
_storedAccountButtons.Add(buttonObj);
}
}
@@ -361,8 +369,13 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
}
private void OnDeleteStoredAccount(StoredAccount account) {
Debug.Log($"[ConnectionHandler] Deleting stored account: {account.DisplayName}");
OAuthManager.Instance?.RemoveStoredAccount(account);
RefreshStoredAccountButtons();
}
private void SetupLobbyUI() {
if (logoutButton != null) { logoutButton.onClick.AddListener(OnLogoutClicked); }
if (customBattleButton != null) { customBattleButton.onClick.AddListener(CustomBattle); }
if (cancelCustomBattleButton != null) {
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
@@ -413,13 +426,16 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
foreach (Transform row in availableGamesListArea.transform) { Destroy(row.gameObject); }
// Show loading indicator
if (lobbyStatusText != null) { lobbyStatusText.text = "Switching environment..."; }
// Reconnect to new environment
_createConnection();
RequestMaps();
StartListeningForLobbyUpdates();
}
private async void OnLogoutClicked() {
public async void OnLogoutClicked() {
Debug.Log("[ConnectionHandler] Logout button clicked");
// Clear OAuth tokens
@@ -440,6 +456,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();
@@ -493,77 +510,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
});
}
private void OnNewUserNeedsDisplayName(bool isNewUser) {
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;
var displayName = displayNameField.text.Trim();
if (string.IsNullOrEmpty(displayName)) {
if (displayNameErrorText != null) {
displayNameErrorText.text = "Please enter a display name";
}
return;
}
if (displayNameErrorText != null) { displayNameErrorText.text = "Setting display name..."; }
var success = await OAuthManager.Instance.SetDisplayNameAsync(displayName);
if (!success && displayNameErrorText != null) {
displayNameErrorText.text = "Name already taken or invalid. Try another.";
}
// OnLoginSuccess will be called if successful
}
private void OnOAuthLogout() {
MainQueue.Q.Enqueue(() => {
ShowAuthPanel();
@@ -700,6 +646,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
@@ -766,6 +713,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
availableGameItem.JoinCallback = this.JoinGame;
availableGameItem.DropCallback = this.DropGame;
}
// Clear loading indicator now that lobby is populated
if (lobbyStatusText != null) { lobbyStatusText.text = ""; }
});
}
@@ -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);
@@ -602,7 +602,7 @@ MonoBehaviour:
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 0.42745098, b: 0.42745098, a: 1}
m_NormalColor: {r: 1, g: 1, b: 1, a: 0}
m_HighlightedColor: {r: 0.84313726, g: 0.84313726, b: 0.84313726, a: 1}
m_PressedColor: {r: 0, g: 0, b: 0, a: 1}
m_SelectedColor: {r: 0.84313726, g: 0.84313726, b: 0.84313726, a: 1}
@@ -1151,7 +1151,7 @@ MonoBehaviour:
m_Right: 0
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 0
m_ChildAlignment: 3
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
@@ -1256,14 +1256,14 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.RawImage
m_Material: {fileID: 0}
m_Color: {r: 0, g: 0, b: 0, a: 1}
m_Color: {r: 1, g: 0, b: 0, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Texture: {fileID: 2800000, guid: 8c998213d53883d4b8fdf7a3eb3c2909, type: 3}
m_Texture: {fileID: 2800000, guid: 88dca061d041a2a4596563e6a38f66c8, type: 3}
m_UVRect:
serializedVersion: 2
x: 0
@@ -0,0 +1,8 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
public class StoredAccountButton : MonoBehaviour {
public TMP_Text label;
public Image icon;
public Button deleteButton;
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c2ab265dfad44a1aac98c4eaba3969bc
timeCreated: 1768606277
@@ -1,5 +1,62 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &602258008028479859
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 8558900591190483184}
- component: {fileID: 5225486432737178588}
m_Layer: 5
m_Name: ProviderIconContainer
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &8558900591190483184
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 602258008028479859}
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: 6242087783640990751}
m_Father: {fileID: 157686408557551303}
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!114 &5225486432737178588
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 602258008028479859}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 80
m_PreferredHeight: 50
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &1262787222422545696
GameObject:
m_ObjectHideFlags: 0
@@ -10,7 +67,6 @@ GameObject:
m_Component:
- component: {fileID: 6242087783640990751}
- component: {fileID: 5444015614922696687}
- component: {fileID: 8871991993173924918}
- component: {fileID: 6965305925514130928}
m_Layer: 5
m_Name: ProviderIcon
@@ -26,17 +82,17 @@ RectTransform:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1262787222422545696}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 157686408557551303}
m_Father: {fileID: 8558900591190483184}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_SizeDelta: {x: -10, y: -10}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &5444015614922696687
CanvasRenderer:
@@ -46,26 +102,6 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1262787222422545696}
m_CullTransparentMesh: 1
--- !u!114 &8871991993173924918
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1262787222422545696}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 80
m_PreferredHeight: 50
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &6965305925514130928
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -86,7 +122,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 21300000, guid: b6b68c8c37d5cf1419267a80c426ac83, type: 3}
m_Sprite: {fileID: 21300000, guid: af9f637b3a1f6433f819a0bcdb326453, type: 3}
m_Type: 0
m_PreserveAspect: 1
m_FillCenter: 1
@@ -96,6 +132,62 @@ MonoBehaviour:
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &4773346950019690658
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9042872047974783334}
- component: {fileID: 1446804990396579382}
m_Layer: 5
m_Name: Spacer
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9042872047974783334
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4773346950019690658}
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: 157686408557551303}
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!114 &1446804990396579382
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4773346950019690658}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 5
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &5166012670666351313
GameObject:
m_ObjectHideFlags: 0
@@ -110,6 +202,7 @@ GameObject:
- component: {fileID: 646268409010340088}
- component: {fileID: 3097176747511135857}
- component: {fileID: 7691398338434978342}
- component: {fileID: 369281190448318618}
m_Layer: 5
m_Name: StoredAccountButton
m_TagString: Untagged
@@ -130,13 +223,15 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 7974514965529143181}
- {fileID: 6242087783640990751}
- {fileID: 8558900591190483184}
- {fileID: 1544713289774970384}
- {fileID: 9042872047974783334}
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: 0, y: 0}
m_SizeDelta: {x: 400, y: 60}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &3634401029247236684
CanvasRenderer:
@@ -258,7 +353,7 @@ MonoBehaviour:
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 5
m_Spacing: 0
m_Spacing: 5
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 1
@@ -266,6 +361,21 @@ MonoBehaviour:
m_ChildScaleWidth: 0
m_ChildScaleHeight: 0
m_ReverseArrangement: 0
--- !u!114 &369281190448318618
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5166012670666351313}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c2ab265dfad44a1aac98c4eaba3969bc, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::StoredAccountButton
label: {fileID: 6210669843299902677}
icon: {fileID: 6965305925514130928}
deleteButton: {fileID: 5102831562105438405}
--- !u!1 &5617793748733146591
GameObject:
m_ObjectHideFlags: 0
@@ -425,3 +535,232 @@ MonoBehaviour:
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &5969634697112825658
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3505386829136548087}
- component: {fileID: 4500436679700087771}
- component: {fileID: 5081311949443699076}
m_Layer: 5
m_Name: Image
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &3505386829136548087
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5969634697112825658}
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: 1544713289774970384}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4500436679700087771
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5969634697112825658}
m_CullTransparentMesh: 1
--- !u!114 &5081311949443699076
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5969634697112825658}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 0, b: 0, 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: 21300002, guid: 88dca061d041a2a4596563e6a38f66c8, type: 3}
m_Type: 0
m_PreserveAspect: 1
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &7624557558758837467
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1544713289774970384}
- component: {fileID: 1437169994306891023}
- component: {fileID: 4243156207255156076}
- component: {fileID: 5102831562105438405}
- component: {fileID: 7366748879155754635}
m_Layer: 5
m_Name: Delete
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1544713289774970384
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7624557558758837467}
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: 3505386829136548087}
m_Father: {fileID: 157686408557551303}
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 &1437169994306891023
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7624557558758837467}
m_CullTransparentMesh: 1
--- !u!114 &4243156207255156076
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7624557558758837467}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 0}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &5102831562105438405
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7624557558758837467}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 4243156207255156076}
m_OnClick:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 5166012670666351313}
m_TargetAssemblyTypeName:
m_MethodName:
m_Mode: 1
m_Arguments:
m_ObjectArgument: {fileID: 0}
m_ObjectArgumentAssemblyTypeName:
m_IntArgument: 0
m_FloatArgument: 0
m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
--- !u!114 &7366748879155754635
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7624557558758837467}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 40
m_PreferredHeight: 40
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -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() {
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 8d524cd6fa08d47c99b2462c2686b8d0
guid: 2bbeb692cb0204e0c965a4f08dfeffce
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 1e69ea88b87d34a48bbb45a1fbad81fc
guid: 68f43a341139b445688f2326a98d5567
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
@@ -1,12 +1,12 @@
fileFormatVersion: 2
guid: 1fb1f6deb68f9475281c9f7c427a7024
guid: 498227fe7963d42e0b240ce5c37f3665
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
@@ -37,13 +37,13 @@ TextureImporter:
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -52,9 +52,9 @@ TextureImporter:
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 0
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
@@ -100,7 +100,7 @@ TextureImporter:
customData:
physicsShape: []
bones: []
spriteID:
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
@@ -1,12 +1,12 @@
fileFormatVersion: 2
guid: 1850a7332b6e84e1a9b69f5f5b4cf482
guid: da40d75c7a6fb481d9d2cf483c71ae3a
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
@@ -37,13 +37,13 @@ TextureImporter:
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -52,9 +52,9 @@ TextureImporter:
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 0
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
@@ -100,7 +100,7 @@ TextureImporter:
customData:
physicsShape: []
bones: []
spriteID:
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
@@ -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:
@@ -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;
}
}
@@ -0,0 +1,13 @@
using UnityEngine;
/// <summary>
/// Initializes Sparkle auto-updater at app startup.
/// Uses RuntimeInitializeOnLoadMethod to ensure early initialization.
/// </summary>
public static class SparkleInitializer {
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Initialize() {
Debug.Log("SparkleInitializer: Initializing Sparkle at startup");
SparkleUpdater.Initialize();
}
}
@@ -1,11 +1,11 @@
fileFormatVersion: 2
guid: e694fb18ea1df4034b392352bf3aa04a
guid: 2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
executionOrder: -100
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,120 @@
using System.Runtime.InteropServices;
using UnityEngine;
/// <summary>
/// C# wrapper for the native SparklePlugin on macOS.
/// Provides auto-update functionality via the Sparkle framework.
/// On non-macOS platforms, all methods are no-ops.
/// </summary>
public static class SparkleUpdater {
#if UNITY_STANDALONE_OSX && !UNITY_EDITOR
private const string PluginName = "SparklePlugin";
[DllImport(PluginName)]
private static extern void SparklePlugin_Initialize();
[DllImport(PluginName)]
private static extern void SparklePlugin_CheckForUpdates();
[DllImport(PluginName)]
private static extern void SparklePlugin_CheckForUpdatesInBackground();
[DllImport(PluginName)]
private static extern int SparklePlugin_IsCheckingForUpdates();
[DllImport(PluginName)]
private static extern int SparklePlugin_GetAutomaticallyChecksForUpdates();
[DllImport(PluginName)]
private static extern void SparklePlugin_SetAutomaticallyChecksForUpdates(int enabled);
private static bool _initialized = false;
/// <summary>
/// Initialize the Sparkle updater. Should be called once at app startup.
/// This starts automatic background update checks based on Info.plist settings.
/// </summary>
public static void Initialize() {
if (_initialized) {
Debug.Log("SparkleUpdater: Already initialized");
return;
}
Debug.Log("SparkleUpdater: Initializing Sparkle");
try {
SparklePlugin_Initialize();
_initialized = true;
Debug.Log("SparkleUpdater: Initialization complete");
} catch (System.Exception e) {
Debug.LogError($"SparkleUpdater: Failed to initialize: {e.Message}");
}
}
/// <summary>
/// Manually check for updates. Shows the update UI to the user.
/// </summary>
public static void CheckForUpdates() {
if (!_initialized) {
Debug.LogWarning("SparkleUpdater: Not initialized");
return;
}
SparklePlugin_CheckForUpdates();
}
/// <summary>
/// Check for updates silently in the background.
/// Only shows UI if an update is found.
/// </summary>
public static void CheckForUpdatesInBackground() {
if (!_initialized) {
Debug.LogWarning("SparkleUpdater: Not initialized");
return;
}
SparklePlugin_CheckForUpdatesInBackground();
}
/// <summary>
/// Returns true if an update check is currently in progress.
/// </summary>
public static bool IsCheckingForUpdates {
get {
if (!_initialized) return false;
return SparklePlugin_IsCheckingForUpdates() != 0;
}
}
/// <summary>
/// Gets or sets whether automatic update checks are enabled.
/// </summary>
public static bool AutomaticallyChecksForUpdates {
get {
if (!_initialized) return false;
return SparklePlugin_GetAutomaticallyChecksForUpdates() != 0;
}
set {
if (!_initialized) return;
SparklePlugin_SetAutomaticallyChecksForUpdates(value? 1: 0);
}
}
#else
// Stub implementations for non-macOS platforms and Editor
public static void Initialize() { Debug.Log("SparkleUpdater: Not available on this platform"); }
public static void CheckForUpdates() {
Debug.Log("SparkleUpdater: Not available on this platform");
}
public static void CheckForUpdatesInBackground() {
Debug.Log("SparkleUpdater: Not available on this platform");
}
public static bool IsCheckingForUpdates => false;
public static bool AutomaticallyChecksForUpdates {
get => false;
set {}
}
#endif
}
@@ -1,11 +1,11 @@
fileFormatVersion: 2
guid: 680b7d1179eb34a0fa338286236db2e9
guid: 8a3b5c7d9e1f2a3b4c5d6e7f8a9b0c1d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: b6b68c8c37d5cf1419267a80c426ac83
TextureImporter:
fileIDToRecycleName: {}
externalObjects: {}
serializedVersion: 5
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
isReadable: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: -1
aniso: -1
mipBias: -1
wrapU: 1
wrapV: 1
wrapW: 1
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
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
platformSettings:
- serializedVersion: 2
buildTarget: DefaultTexturePlatform
maxTextureSize: 128
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
- serializedVersion: 2
buildTarget: Standalone
maxTextureSize: 128
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
- serializedVersion: 2
buildTarget: Android
maxTextureSize: 128
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
- serializedVersion: 2
buildTarget: WebGL
maxTextureSize: 128
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID: e8701ed65f69e7b4385fc7a2dcf1f924
vertices: []
indices:
edges: []
weights: []
spritePackingTag:
userData:
assetBundleName:
assetBundleVariant:
@@ -1,3 +1,6 @@
System.Runtime.CompilerServices.Unsafe/**
!**/*.psd
**/*.dll
# Native plugins built at CI time
DarwinGodiceBundle.bundle/
macOS/SparklePlugin.bundle/
@@ -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:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3c5d7e9f1a2b4c6d8e0f2a4b6c8d0e2f
folderAsset: yes
DefaultImporter:
externalObjects: {}
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;
@@ -1,6 +1,6 @@
fileFormatVersion: 2
guid: 9acf3ba64a8c04e6083aae699248b0c4
PrefabImporter:
guid: e082b4a1a25a044a99b7798107149f77
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
@@ -1160,7 +1160,7 @@ namespace Shardok {
int currentMouseGridIndex) {}
public void HandlePrimaryClick(int clickedIndex) {
if (HasPlacementAction(clickedIndex)) {
if (_selectedGridIndex != clickedIndex && HasPlacementAction(clickedIndex)) {
PerformPlacementAction(clickedIndex);
_selectedGridIndex = null;
} else if (HasReinforceAction(clickedIndex)) {
@@ -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() {
@@ -2057,6 +2057,21 @@ MonoBehaviour:
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
- m_Index: 130
m_Metrics:
m_Width: 0
m_Height: 0
m_HorizontalBearingX: 0
m_HorizontalBearingY: 0
m_HorizontalAdvance: 54.015625
m_GlyphRect:
m_X: 0
m_Y: 0
m_Width: 0
m_Height: 0
m_Scale: 1
m_AtlasIndex: 0
m_ClassDefinitionType: 0
m_CharacterTable:
- m_ElementType: 1
m_Unicode: 2
@@ -2582,6 +2597,10 @@ MonoBehaviour:
m_Unicode: 8230
m_GlyphIndex: 389
m_Scale: 1
- m_ElementType: 1
m_Unicode: 173
m_GlyphIndex: 130
m_Scale: 1
m_AtlasTextures:
- {fileID: -953588492498898259}
m_AtlasTextureIndex: 0
@@ -16,14 +16,20 @@ namespace Eagle0.Tutorial {
var onboarding = CreateOnboardingSequence();
manager.OnboardingSequence = onboarding;
// Register command panel tutorials (shown after onboarding)
RegisterCommandPanelTutorials(registry);
// Register strategic contextual tutorials
RegisterStrategicTutorials(registry);
// Register tactical contextual tutorials
RegisterTacticalTutorials(registry);
// Register game state guidance tutorials
RegisterGuidanceTutorials(registry);
}
// ========== ONBOARDING SEQUENCE (13 steps) ==========
// ========== ONBOARDING SEQUENCE ==========
private static TutorialSequence CreateOnboardingSequence() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
@@ -37,60 +43,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 +193,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",
"Pay a soothsayer to divine a hero's hidden desires.\n\n" +
"Reveals the intentions and aspirations of a free hero, showing you what you must do to win them to your cause.");
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",
@@ -283,6 +685,47 @@ namespace Eagle0.Tutorial {
registry.RegisterTutorial(charge, "ability_charge_available");
}
// ========== GAME STATE GUIDANCE TUTORIALS ==========
// These trigger based on complex game state analysis, not simple first-encounters.
private static void RegisterGuidanceTutorials(TutorialTriggerRegistry registry) {
// Loyalty danger warning (November)
var loyaltyDanger = CreateSingleStepTutorial(
"guidance_loyalty_danger",
"Loyalty Warning!",
"Some of your heroes are unhappy. At year's end, disloyal heroes may leave your service—or worse, betray you.\n\n" +
"Use <b>Feast</b> to boost loyalty and vigor for all heroes in a province, or <b>Give Gift</b> to greatly increase one hero's loyalty.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(loyaltyDanger, "guidance_loyalty_danger");
// Neighbor danger warning
var neighborDanger = CreateSingleStepTutorial(
"guidance_neighbor_danger",
"Border Alert!",
"Your province borders hostile territory. An enemy army could march in at any time.\n\n" +
"Consider <b>Organizing Troops</b> for defense, or use <b>Diplomacy</b> to seek a Truce or Alliance with your neighbor.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(neighborDanger, "guidance_neighbor_danger");
// Recruit heroes guidance
var recruitHeroes = CreateSingleStepTutorial(
"guidance_recruit_heroes",
"Grow Your Court",
"Free heroes are nearby, seeking a lord to serve. More heroes means more armies and more provinces you can manage.\n\n" +
"Use <b>Travel</b> to visit town, then <b>Divine</b> to learn what a hero desires, or <b>Recruit</b> those already willing to join.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(recruitHeroes, "guidance_recruit_heroes");
// Expand guidance
var expand = CreateSingleStepTutorial(
"guidance_expand",
"Time to Expand",
"Your province is stable. To grow your realm, you'll need to claim new territory.\n\n" +
"Use <b>March</b> to send your Warlord and another hero to a neighboring province. Once there, develop it just like your first province.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(expand, "guidance_expand");
}
// ========== HELPER METHODS ==========
private static TutorialSequence CreateSingleStepTutorial(
@@ -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>
@@ -104,6 +134,9 @@ namespace Eagle0.Tutorial {
// Check for free heroes (recruitment opportunity)
CheckHeroRecruitmentAvailable(model, previousModel);
// Check game state guidance triggers
CheckGuidanceTriggers(model);
}
private void CheckStrategicCommandsAvailable(IGameModel model, IGameModel previousModel) {
@@ -143,6 +176,143 @@ namespace Eagle0.Tutorial {
}
}
// ========== Game State Guidance Triggers ==========
private void CheckGuidanceTriggers(IGameModel model) {
CheckLoyaltyDanger(model);
CheckNeighborDanger(model);
CheckRecruitHeroesGuidance(model);
CheckExpandGuidance(model);
}
/// <summary>
/// Check if it's November and any heroes have low loyalty.
/// </summary>
private void CheckLoyaltyDanger(IGameModel model) {
if (_manager.State.HasCompletedTutorial("guidance_loyalty_danger")) return;
// Only warn in November (month 11)
if (model.CurrentDate?.Month != 11) return;
// Get the player's faction ID
var playerId = model.PlayerId;
if (playerId == null) return;
// Check all heroes belonging to the player's faction
var hasLowLoyaltyHero = model.Heroes?.Values.Any(
h => h.FactionId == playerId && h.Loyalty != null && h.Loyalty.Stat < 70);
if (hasLowLoyaltyHero == true) { OnGameEvent("guidance_loyalty_danger"); }
}
/// <summary>
/// Check if player has provinces adjacent to hostile factions.
/// </summary>
private void CheckNeighborDanger(IGameModel model) {
if (_manager.State.HasCompletedTutorial("guidance_neighbor_danger")) return;
var playerId = model.PlayerId;
if (playerId == null) return;
// Get player's faction relationships
if (!model.ActiveFactions.TryGetValue(playerId.Value, out var playerFaction)) return;
// Check each province the player owns
foreach (var province in model.Provinces.Values) {
if (province.RulingFactionId != playerId) continue;
// Check available march destinations for hostile neighbors
if (model.AvailableCommandsByProvince == null) continue;
if (!model.AvailableCommandsByProvince.TryGetValue(province.Id, out var commands))
continue;
var marchCommand = commands.Commands.FirstOrDefault(c => c.MarchCommand != null);
if (marchCommand?.MarchCommand == null) continue;
// Check all one-province march commands for adjacent hostile provinces
foreach (var oneProvinceCmd in marchCommand.MarchCommand.OneProvinceCommands) {
foreach (var dest in oneProvinceCmd.AvailableDestinationProvinces) {
var destProvince = model.Provinces[dest.ProvinceId];
if (destProvince.RulingFactionId == null ||
destProvince.RulingFactionId == playerId)
continue;
// Check if we have a truce or alliance with this faction
var relationship = playerFaction.FactionRelationships.FirstOrDefault(
fr => fr.TargetFactionId == destProvince.RulingFactionId);
if (relationship == null ||
relationship.RelationshipLevel ==
Net.Eagle0.Eagle.Views.FactionRelationshipView.Types
.RelationshipLevel.Hostile) {
// Found a hostile neighbor!
OnGameEvent("guidance_neighbor_danger");
return;
}
}
}
}
}
/// <summary>
/// Check if player should be recruiting heroes.
/// </summary>
private void CheckRecruitHeroesGuidance(IGameModel model) {
if (_manager.State.HasCompletedTutorial("guidance_recruit_heroes")) return;
var playerId = model.PlayerId;
if (playerId == null) return;
// Check for a province with support >= 40 that has free heroes
foreach (var province in model.Provinces.Values) {
if (province.RulingFactionId != playerId) continue;
if (province.FullInfo?.Support?.Stat < 40) continue;
// Need at least 100 gold in the province to divine
if (province.FullInfo?.Gold < 100) continue;
// Check for free heroes in this province
var freeHeroes = province.FullInfo?.UnaffiliatedHeroes;
if (freeHeroes == null || freeHeroes.Count == 0) continue;
// Check if any are divineable or ready to join
var recruitableHeroes = freeHeroes.Where(
uh => uh.RecruitmentInfo?.Status ==
Net.Eagle0.Eagle.Common.RecruitmentStatus.WouldJoin ||
uh.RecruitmentInfo?.Status ==
Net.Eagle0.Eagle.Common.RecruitmentStatus.NotDivined);
if (recruitableHeroes.Any()) {
OnGameEvent("guidance_recruit_heroes");
return;
}
}
}
/// <summary>
/// Check if player should consider expanding to new provinces.
/// </summary>
private void CheckExpandGuidance(IGameModel model) {
if (_manager.State.HasCompletedTutorial("guidance_expand")) return;
var playerId = model.PlayerId;
if (playerId == null) return;
// Check if player has any province with support >= 40
var stableProvince = model.Provinces.Values.FirstOrDefault(
p => p.RulingFactionId == playerId && p.FullInfo?.Support?.Stat >= 40);
if (stableProvince == null) return;
// Check if player only has one province (hasn't expanded yet)
var playerProvinceCount =
model.Provinces.Values.Count(p => p.RulingFactionId == playerId);
if (playerProvinceCount > 1) return;
// Player has one stable province but hasn't expanded - suggest expansion
OnGameEvent("guidance_expand");
}
/// <summary>
/// Called when a province is selected.
/// </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,109 @@
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;
// Note: WarlordRow, VassalRow1, VassalRow2 are registered dynamically
// by HeroesAndBattalionsPanelController when hero rows are created
[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>();

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