Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 81838065ae Add delete button to games list in admin console
- Add delete button next to each game in the main games list
- Include confirmation modal with option to delete save files
- Reuses existing /games/{id}/delete endpoint

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:39:08 -08:00
0354fefdca Add Unity meta file for TutorialOverlayBuilder.cs (#5252)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:34:41 -08:00
6fb14ef9af Fix deployment downtime and config sync issues (#5253)
Problems fixed:
1. CI was recreating admin BEFORE blue-green, causing stale .env
2. CI was restarting nginx AFTER blue-green (double restart)
3. Deploy script used slow nginx restart instead of reload
4. Cleanup was blocking the critical path

Changes:
- CI: Only restart shardok before blue-green, let script handle rest
- CI: Remove duplicate nginx restart and fallback path
- Deploy: Use nginx reload (faster) with restart fallback
- Deploy: Update .env before restarting services
- Deploy: Run container cleanup in background

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:33:53 -08:00
eaf00de74d Delete LegacyFactionUtils - complete deproto of faction utilities (#5251)
* Begin deleting LegacyFactionUtils - convert first batch of callers

Converts the following callers to use FactionUtils with FactionConverter:
- ShardokInterfaceGrpcClient: isFactionLeader
- BattalionNameFilter: provinces (inlined as filter)
- IncomingArmyUtils: factionsAreMutuallyAllied, factionsAreHostile, hostilityStatus

Remaining files to convert:
- ProvinceViewFilter (hasAlliance, isFactionLeader)
- FactionViewFilter (prestige)
- BattleFilter (hostilityStatus)
- Visibility (hasAlliance)
- ActionResultFilter (hasAlliance)
- EligibleDiplomacyStatuses (hasProvinces)
- AvailableResolveInvitationCommandFactory (provinceCount)

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

* Delete LegacyFactionUtils - complete deproto of faction utilities

Convert all callers of LegacyFactionUtils to use FactionUtils + FactionConverter:
- BattleFilter: Use FactionConverter to get factions vector for hostilityStatus
- FactionViewFilter: Convert faction and provinces for prestige calculation
- ProvinceViewFilter: Convert factions for hasAlliance and isFactionLeader
- Visibility: Convert factions for hasAlliance
- ActionResultFilter: Convert factions for hasAlliance check
- EligibleDiplomacyStatuses: Convert provinces for hasProvinces check
- AvailableResolveInvitationCommandFactory: Convert provinces for provinceCount

Delete LegacyFactionUtils.scala and LegacyFactionUtilsTest.scala.
Update BUILD.bazel files to remove legacy_faction_utils dependencies.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:26:31 -08:00
f2743fe9cc Increase nginx client_max_body_size for game uploads (#5250)
Default is 1MB which is too small for game save uploads.
Set to 50MB to allow large game files.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:05:37 -08:00
0bdc4d46ce Add environment dropdown to connection panel (#5248)
* Add environment dropdown to connection panel

Allows switching environments before connecting, useful when selected
environment (e.g. QA) is down and user can't reach the lobby to switch.

- Add connectionEnvironmentDropdown field
- SetupConnectionEnvironmentDropdown() initializes on Start
- OnConnectionEnvironmentChanged() saves preference for next connection
- ShowAuthPanel() syncs dropdown when returning to connection screen

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

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

* Wire up connection panel environment dropdown in Unity

🤖 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-12 19:05:08 -08:00
0e9dc4121f Log and return errors for failed command Futures (#5249)
Previously, when command processing threw an exception (e.g., invalid
diplomacy resolution status), the Future would fail silently - no error
was logged, sent to Sentry, or returned to the client. The client would
just wait forever for a response that never came.

Changes:
- Add ERROR status and error_message field to PostCommandResponse proto
- Add .recover handler to postCommand Future in streaming handler
- Log errors to console with SimpleTimedLogger
- Print stack trace for debugging
- Report errors to Sentry for monitoring
- Return PostCommandResponse with ERROR status to client

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:14:48 -08:00
e9281f66fe Add concurrency control to prevent parallel deployments (#5247)
Multiple deployments were running simultaneously, causing:
- Container name conflicts ("shardok-server is already in use")
- Corrupted image downloads (short read errors)
- Race conditions with docker compose

This adds a concurrency group so deployments run one at a time.
New deployments queue (cancel-in-progress: false) rather than
canceling running ones to avoid leaving production in a bad state.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:07:09 -08:00
336d008f26 Fix Imprison status not being handled in SelectedCommandConverter (#5246)
The statusFromProto function was throwing IllegalArgumentException for
DIPLOMACY_OFFER_STATUS_IMPRISONED instead of returning the Imprisoned
status. This caused the game to fail when players selected the Imprison
option for diplomacy offers.

The bug was introduced in #5106 when SelectedCommandConverter was added.
The function was only designed to handle Accept/Reject but the Imprison
option was later added as an eligible status.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:06:38 -08:00
5a56f71c4c Require invitation codes for new user registration (#5244)
Add REQUIRE_INVITATION_CODE=true to auth service in production.
Without this, anyone could create an account without an invitation.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:45:45 -08:00
6e66b88929 Delete LegacyHeroUtils and use HeroUtils with converters (#5245)
Updates callers to use HeroConverter + HeroUtils instead of LegacyHeroUtils:
- HeroViewFilter: Updated proto overload to convert heroes and use HeroUtils
- ProvinceViewFilter: Added heroIdSortOrderer helper using HeroConverter
- ShardokInterfaceGrpcClient: Updated archeryCapable/startFireCapable calls
- GameStateViewFilterTest: Updated test to use HeroConverter + HeroUtils

Also added new overload for HeroUtils.loyaltyAsStatWithCondition that takes
factionLeaderIds directly for cases where proto code has leader IDs but not
full faction objects.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:45:13 -08:00
eee10dc209 Fix CI jfr-sidecar startup when eagle-green is active (#5241)
The CI workflow was unconditionally starting jfr-sidecar (which shares
PID namespace with eagle-blue). When eagle-green is active after a
blue-green deployment, eagle-blue doesn't exist and jfr-sidecar fails.

Fix: Remove the redundant jfr-sidecar startup from CI. The
deploy-blue-green.sh script already handles starting the appropriate
jfr-sidecar (either jfr-sidecar or jfr-sidecar-green) based on which
eagle instance is active.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:44:35 -08:00
caa843f763 Fix archived/deleted games reappearing in games.e0es (#5243)
With lazy loading, save() merges in-memory games with unloaded games
from storage. When a game was archived or deleted:
1. It was removed from gameControllerInfos
2. save() read games.e0es and found the game
3. Since it wasn't in loadedGameIds, it was treated as "unloaded"
4. The game was written back to games.e0es

This caused FileNotFoundException spam in Sentry when the server tried
to load these archived games (their files no longer exist).

Fix: Track explicitly removed games in removedGameIds and exclude them
from the merge in save().

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:42:04 -08:00
bb6de75ad0 Add tutorial overlay system with runtime UI construction (#5214)
* Add tutorial overlay system with runtime UI construction

Implements TutorialOverlayBuilder to construct overlay UI at runtime,
similar to TutorialCanvasBuilder for modals.

Features:
- TutorialOverlayBuilder creates complete overlay UI hierarchy:
  - Background dimmer (semi-transparent)
  - Highlight frame with gold border and corner decorations
  - Tooltip container with title, description, continue button
  - Arrow pointer for visual connection
- TutorialUIManager auto-builds overlay if not assigned
- TutorialOverlayController.OnContinueClicked made public for button wiring
- Test tutorial now includes overlay step for End Turn button
- Updated TUTORIAL_PLAN.md with overlay system completion

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

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

* Fix HideOverlay coroutine error on inactive GameObject

Check if gameObject is active before starting FadeOut coroutine.
HideAll() may be called when overlay is already hidden.

🤖 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-12 17:32:51 -08:00
ad418c8848 Make Mac installer double-clickable (#5242)
Change Mac installer from .sh to .command extension:
- .command files open Terminal and execute when double-clicked on macOS
- No more asking users to open Terminal and run bash commands
- Updated instructions to reflect simpler flow
- Added "Press Enter to exit" so users can see completion message

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:32:30 -08:00
093316b1c6 Delete LegacyBattalionUtils and use BattalionUtils with converters (#5240)
Updated LegacyProvinceUtils.monthlyFoodConsumption to use
BattalionUtils with BattalionConverter and BattalionTypeConverter
to convert proto types to Scala types.

Also added export for model/state/battalion from battalion_converter.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:16:54 -08:00
55ad8a8278 Admin console shows all games, auto-install s3cmd, restart nginx properly (#5238)
* Fix: Move S3 backup to BEFORE stopping old server

Critical fix: The old server may wipe games.e0es on shutdown if it
doesn't have the save() merge fix. The backup must happen BEFORE
stopping to capture valid data.

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

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

* Admin console shows all games, auto-install s3cmd, restart nginx properly

1. Admin console now shows all games from games.e0es, not just loaded ones:
   - Added getAllRunningGamesSummary() to GamesManager
   - Updated getRunningGames() to include unloaded games with "[Not loaded]" status
   - Clicking into a game triggers lazy loading via getGameHistory()

2. Deploy script improvements:
   - Auto-install s3cmd if not present (via pip or apt)
   - Auto-configure s3cmd for DigitalOcean Spaces from .env
   - Use nginx restart instead of reload to ensure all workers pick up new config

🤖 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-12 17:04:33 -08:00
0361464db1 Delete LegacyBattalionViewFilter and update callers to use Scala types (#5239)
Updated callers to use BattalionConverter + BattalionViewFilter instead:
- AvailableCommandConverter: proto Battalion → Scala → BattalionView → proto
- ExpandedCombatUnitUtils: proto Battalion → Scala → BattalionView
- ProvinceViewFilter: Scala BattalionT → BattalionView (direct)

Also added required exports and visibility for battalion_view_filter.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:02:02 -08:00
31649c73a7 Make manifest signature verification blocking (#5235)
* Make manifest signature verification blocking

When a public key is configured, the installer now rejects:
- Unsigned manifests (signature required)
- Manifests with invalid signatures

This closes the security gap where a compromised manifest could
point to a malicious installer. The SHA check on the installer
was already blocking, but an attacker could modify the manifest
to include the SHA of their malicious installer.

Behavior:
- No public key configured: allows any manifest (backwards compatible)
- Public key configured + valid signature: proceeds
- Public key configured + missing/invalid signature: BLOCKS update

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

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

* Always require valid manifest signature

Remove backwards-compatibility fallback - any installer with this
code will have been built with the public key injected by CI.

Now requires:
- Public key must be configured (fails if missing)
- Manifest must be signed (fails if unsigned)
- Signature must be valid (fails if invalid)

🤖 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-12 16:41:10 -08:00
c24509e0b2 Fix: Move S3 backup to BEFORE stopping old server (#5237)
* Fix critical bug: save() now merges with existing games.e0es

CRITICAL BUG FIX: With lazy loading, gameControllerInfos only contains
games that have been loaded into memory. The old save() would overwrite
games.e0es with only the loaded games, losing all unloaded games.

This caused complete data loss of user-to-game mappings when:
1. New server started (empty gameControllerInfos)
2. Any operation triggered save()
3. games.e0es was overwritten with empty data

Fix: save() now reads existing games.e0es first and merges:
- Unloaded games are preserved from disk
- Loaded games use fresh in-memory state

Also adds logging to diagnose games.e0es read failures.

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

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

* Add S3 backup of games.e0es during blue-green deployment

After stopping the old container (which flushes state to storage),
create a timestamped backup in S3 before switching nginx traffic.
This provides a recovery point if something goes wrong during deployment.

Backups are stored at: s3://eagle0/eagle/save/backups/games.e0es.<timestamp>

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

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

* Fix blue-green deployment dependencies and auth routing

1. docker-compose.prod.yml:
   - Remove eagle-blue from nginx depends_on (use EAGLE_ADDR variable instead)
   - Remove eagle-blue from admin depends_on (use EAGLE_ADDR variable instead)
   - Add TODO note about jfr-sidecar limitation during green deployments

2. nginx/nginx.conf:
   - Fix auth.Auth location on port 443 to route to auth:40033 instead of
     eagle_backend. This was causing auth failures when clients connected
     via the main HTTPS port.

These changes allow blue-green deployments to work without hard
dependencies that cause docker-compose to recreate stopped containers.

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

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

* Add jfr-sidecar-green for blue-green JFR profiling support

- Add jfr-sidecar-green service that shares PID namespace with eagle-green
- Make admin's jfr-sidecar address configurable via JFR_SIDECAR_ADDR env var
- Update deploy script to:
  - Start appropriate jfr-sidecar with each eagle instance
  - Update .env with EAGLE_ADDR and JFR_SIDECAR_ADDR after switching
  - Restart admin service to pick up new addresses
  - Clean up old jfr-sidecar when removing old eagle instance

This ensures the JFR button in admin console works regardless of
whether blue or green is the active instance.

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

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

* Sync config files from GitHub at start of deployment

Downloads docker-compose.prod.yml and nginx.conf from the main branch
before starting deployment. This ensures new services (like jfr-sidecar-green)
are available when the deploy script runs.

- Preserves the current active instance in nginx.conf
- Creates .bak backups before overwriting
- Continues with existing files if GitHub fetch fails

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

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

* Fix: Move S3 backup to BEFORE stopping old server

Critical fix: The old server may wipe games.e0es on shutdown if it
doesn't have the save() merge fix. The backup must happen BEFORE
stopping to capture valid data.

🤖 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-12 16:40:31 -08:00
ca2a13e17b Delete LegacyRansomValidity and convert test to Scala types (#5236)
Remove the proto wrapper LegacyRansomValidity and its only caller (the
proto overload of AvailableResolveRansomOfferCommandFactory). Convert
the test from proto types to Scala types, replacing ScalaPB's .update()
lens syntax with helper functions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:35:21 -08:00
65a9cf1f97 CRITICAL: Fix save() to merge with existing games.e0es (#5233)
* Fix critical bug: save() now merges with existing games.e0es

CRITICAL BUG FIX: With lazy loading, gameControllerInfos only contains
games that have been loaded into memory. The old save() would overwrite
games.e0es with only the loaded games, losing all unloaded games.

This caused complete data loss of user-to-game mappings when:
1. New server started (empty gameControllerInfos)
2. Any operation triggered save()
3. games.e0es was overwritten with empty data

Fix: save() now reads existing games.e0es first and merges:
- Unloaded games are preserved from disk
- Loaded games use fresh in-memory state

Also adds logging to diagnose games.e0es read failures.

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

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

* Add S3 backup of games.e0es during blue-green deployment

After stopping the old container (which flushes state to storage),
create a timestamped backup in S3 before switching nginx traffic.
This provides a recovery point if something goes wrong during deployment.

Backups are stored at: s3://eagle0/eagle/save/backups/games.e0es.<timestamp>

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

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

* Fix blue-green deployment dependencies and auth routing

1. docker-compose.prod.yml:
   - Remove eagle-blue from nginx depends_on (use EAGLE_ADDR variable instead)
   - Remove eagle-blue from admin depends_on (use EAGLE_ADDR variable instead)
   - Add TODO note about jfr-sidecar limitation during green deployments

2. nginx/nginx.conf:
   - Fix auth.Auth location on port 443 to route to auth:40033 instead of
     eagle_backend. This was causing auth failures when clients connected
     via the main HTTPS port.

These changes allow blue-green deployments to work without hard
dependencies that cause docker-compose to recreate stopped containers.

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

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

* Add jfr-sidecar-green for blue-green JFR profiling support

- Add jfr-sidecar-green service that shares PID namespace with eagle-green
- Make admin's jfr-sidecar address configurable via JFR_SIDECAR_ADDR env var
- Update deploy script to:
  - Start appropriate jfr-sidecar with each eagle instance
  - Update .env with EAGLE_ADDR and JFR_SIDECAR_ADDR after switching
  - Restart admin service to pick up new addresses
  - Clean up old jfr-sidecar when removing old eagle instance

This ensures the JFR button in admin console works regardless of
whether blue or green is the active instance.

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

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

* Sync config files from GitHub at start of deployment

Downloads docker-compose.prod.yml and nginx.conf from the main branch
before starting deployment. This ensures new services (like jfr-sidecar-green)
are available when the deploy script runs.

- Preserves the current active instance in nginx.conf
- Creates .bak backups before overwriting
- Continues with existing files if GitHub fetch fails

🤖 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-12 16:28:56 -08:00
dd9d6d046d Add Ed25519 signature verification for manifest (#5227)
* Add Ed25519 signature verification for manifest

When a manifest has a signature line (# signature=...), the installer
now verifies it using the embedded public key. If verification fails,
a warning is logged but the update proceeds to allow graceful degradation.

Changes:
- Add NSec.Cryptography NuGet package for Ed25519
- Add VerifyManifestSignature() to parse and verify signature
- Update ReadConfiguration() to support comments in config file
- Call verification when fetching remote manifest

Behavior:
- If no signature: proceeds normally (backwards compatible)
- If no public key configured: logs info, proceeds
- If signature valid: logs success, proceeds
- If signature invalid: logs WARNING, proceeds (graceful degradation)

To enable verification:
1. Generate key pair: go run scripts/generate_manifest_keys.go
2. Add public key to configuration.txt as manifest_public_key
3. Add private key as GitHub secret MANIFEST_SIGNING_KEY (from PR 3)

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

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

* Fix: NSec PublicKey is not IDisposable

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:22:30 -08:00
738dd12c7c Delete unused Legacy utility classes (#5234)
- Remove LegacyRecruitmentOdds (callers already use Scala RecruitmentOdds)
- Remove LegacyBattalionTypeFinder (inline simple lookup in RuntimeValidator)

Part of ongoing deproto effort to remove proto wrapper classes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:06:46 -08:00
1d98df6cbc Remove obsolete ReloadGames RPC call from deploy script (#5232)
With lazy game loading (merged in #5223), games are loaded on-demand
when users reconnect after nginx switches traffic. No explicit reload
call is needed - the new server reads fresh state from storage.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:58:22 -08:00
f74f61c18e Implement lazy game loading for zero-downtime blue-green deployments (#5223)
Games are now loaded on-demand when a user subscribes or lists their games,
rather than all at startup. This enables true zero-downtime deployments:

1. New server starts with no games loaded (fast startup)
2. Old server stops, flushes all game state to disk
3. nginx switches traffic to new server
4. Users reconnect, triggering fresh game loads from disk

Key changes:
- GamesManager.apply() no longer loads games at startup
- New ensureGameLoaded() method loads a single game from disk on demand
- readRunningGamesFromDisk() reads games.e0es fresh each time to handle
  race conditions (e.g., game created just before deployment)
- streamUpdates() calls ensureGameLoaded() before accessing game
- gamesFor() reads games.e0es to find user's games, then loads them
  (handles "lost game ID after disconnect" scenario)
- dropGame() tries lazy loading before returning "not found"
- begin() simplified to just connect to Shardok
- Removed ReloadGames RPC (no longer needed with lazy loading)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:57:40 -08:00
88bd7ade50 Add workflow_dispatch trigger to Unity Build (#5231)
Enables manual triggering of the Unity build workflow.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:51:41 -08:00
99721d963a Upgrade FlatBuffers to version 25.9.23 (#5230)
FlatBuffers 25.9.23 changed GetMutableObject() on vectors of structs
to return const T* instead of T*. This is a const-correctness
improvement in the library.

Updated all C++ code to handle this change:
- Added const_cast<T*>() wrappers where mutation is needed on owned buffers
- Added helper function GetMutableTerrain() in HexMapUtils for terrain access
- All const_casts are safe because the code owns the underlying mutable buffers

All 112 C++ tests and 209 Scala tests pass.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:44:23 -08:00
adminandGitHub 4296d5046a Add workflow_dispatch and inject MANIFEST_PUBLIC_KEY in installer build (#5229) 2026-01-12 12:41:03 -08:00
353eb3907c Sign manifest with Ed25519 at build time (#5226)
Add optional Ed25519 signing to the manifest_manager. When a signing key
is provided, the manifest is signed and the signature is prepended as
a header comment that clients can verify.

Changes:
- manifest_manager: Accept optional private key file as 3rd argument
- manifest_manager: Sign manifest content and prepend signature line
- installer_build.yml: Pass MANIFEST_SIGNING_KEY to manifest_manager
- unity_build.yml: Pass MANIFEST_SIGNING_KEY to manifest_manager
- Add generate_manifest_keys.go script to create key pairs

The signature line format is: # signature=<base64-encoded-ed25519-signature>

To enable signing:
1. Run: go run scripts/generate_manifest_keys.go
2. Add the private key as GitHub secret MANIFEST_SIGNING_KEY
3. Embed the public key in the installer for verification (PR 4)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:08:54 -08:00
ded06ba815 Verify installer SHA256 after download (#5224)
* Verify installer SHA256 after download

The Windows installer was downloading and launching new installer updates
without verifying the SHA256 hash, which could allow a corrupted or
tampered installer to run. This adds SHA256 verification after download
and before launching the new installer.

- Add expectedSha parameter to DownloadAndLaunchNewInstaller
- Compute SHA256 of downloaded file and compare to manifest value
- Delete the file and fail if SHA doesn't match
- Log verification success on match

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

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

* Remove accidentally committed node_modules cache files

* Add node_modules to .gitignore

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:58:03 -08:00
70392b109a Stream downloads to disk with incremental SHA256 hashing (#5225)
Previously, FetchAndWriteOne() would download entire files into memory,
then compute the SHA256, then write to disk. This doubled memory usage
for each concurrent download.

Now the function streams directly to a temp file while computing SHA256
incrementally using TransformBlock. The temp file is renamed to the
final location only after SHA verification passes.

Benefits:
- Eliminates memory buffering of entire files
- Safer atomic writes using temp file + rename pattern
- Temp files cleaned up on failure

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:57:01 -08:00
821e3f1a4a Add retry loop for stapler after notarization (#5222)
Apple's CloudKit can have a brief delay after notarization completes
before the ticket is available for stapling. This adds a retry loop
with 10-second delays, up to 5 attempts.

Error was:
  CloudKit query for eagle0.app failed due to "Record not found".
  The staple and validate action failed! Error 65.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:13:43 -08:00
ebe819a5a1 Update Bazel dependencies to latest compatible versions (#5218)
Updates the following dependencies:
- bazel_skylib: 1.8.1 → 1.9.0
- rules_pkg: 1.1.0 → 1.2.0
- rules_go: 0.56.1 → 0.59.0
- gazelle: 0.45.0 → 0.47.0
- rules_oci: 2.2.6 → 2.2.7
- aspect_bazel_lib: 2.16.0 → 2.22.4
- rules_jvm_external: 6.3 → 6.9

Not updated (compatibility issues):
- googletest 1.17.0.bcr.2: pulls abseil-cpp incompatible with protobuf 29.2
- rules_scala 7.1.6: protobuf gencode/runtime version mismatch
- flatbuffers 25.9.23: C++ API changes break existing code
- grpc/grpc-java: requires rules_swift 3.x which conflicts with rules_apple

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:54:35 -08:00
3e868d0305 Fix nginx failing to reload during blue-green deployment (#5219)
The previous configuration used an upstream block with a static hostname:
  upstream eagle_grpc { server eagle-blue:40032; }

This caused nginx -s reload to fail when eagle-blue was stopped because
nginx tries to resolve all upstream hostnames at config load time.

Changed to use a map directive with a variable:
  map $host $eagle_backend { default "eagle-blue:40032"; }
  grpc_pass grpc://$eagle_backend;

This pattern (already used for auth backend) resolves the hostname at
request time, allowing nginx to reload even when the backend is down.
Requests to a stopped backend will get 502 errors instead of failing
to reload nginx entirely.

Tradeoff: Loses keepalive 100; setting, but deployment reliability
is more important than connection pooling optimization.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:53:38 -08:00
e46867333c Add mac_build_handler to Mac Build workflow triggers (#5220)
Changes to the Go upload tool should trigger the Mac build workflow.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:51:54 -08:00
2d59d4ff51 Fix rsync exit code 23 in persist_library.sh (#5221)
Unity's temporary .traceevents files can vanish during rsync, causing
exit code 23 ("partial transfer due to error"). This is acceptable for
the Library cache, so treat exit code 23 as success.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:51:19 -08:00
d493182184 Fix Sparkle sign_update flag: use -f for file path (#5217)
The -s flag expects the private key as a string argument, not a file
path. Changed to -f which correctly reads the key from a file.

Error was: "Failed to decode base64 encoded key data from: /tmp/sparkle_private_key"

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:05:36 -08:00
8ce4e4c4a9 Add rule: Claude must never merge PRs (#5216)
User explicitly stated: "never ever ever ever merge a PR for me.
You create PRs. I merge them."

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:41:55 -08:00
9729110ea8 Consolidate Docker Build into single job for better runner utilization (#5215)
Previously, docker_build.yml had 4 separate jobs (build-eagle, build-shardok,
build-admin, build-jfr-sidecar) that competed for runner slots. With 3 runners
and 6+ workflows triggering on main push, these jobs serialized rather than
running in parallel.

Now consolidated into a single `build-all` job that:
- Builds all 4 images with one `bazel build` command (Bazel parallelizes internally)
- Uses 1 runner slot instead of 4, freeing runners for other workflows
- Shares Bazel cache warming across all builds
- Pushes all images sequentially (fast, network-bound)

Expected improvement: Docker Build workflow goes from ~8.5m (4 serialized jobs)
to ~3-4m (1 consolidated job with internal parallelism).

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:37:07 -08:00
adminandGitHub 2224e78a36 Improve Sparkle sign_update error reporting (#5209)
Improves Sparkle sign_update error reporting by capturing stderr, and allows full signing/notarization/deploy pipeline on feature branches via workflow_dispatch.
2026-01-12 08:36:41 -08:00
007a57eea2 Convert CommandSelection and AI clients to use Scala types (#5160)
Add toScala() method to CommandSelection that converts proto-based
command selection to ScalaCommandSelection. This simplifies the action
files that were previously doing manual conversion from proto to Scala
types.

Updated files:
- CommandSelection.scala: Added toScala() method
- EndHandleRiotsPhaseAction.scala: Use toScala() instead of manual conversion
- PerformVassalCommandsPhaseAction.scala: Use toScala() instead of manual conversion
- PerformVassalDefenseDecisionsAction.scala: Use toScala() instead of manual conversion
- Removed unused AvailableCommandTypeMap and SelectedCommandConverter imports

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:31:35 -08:00
3febf2a6cd Use DigitalOcean Spaces for busybox binary (private repo fix) (#5213)
GitHub release URLs don't work for private repos without authentication.
Bazel's http_file can't use GitHub auth, so we need a public URL.

Uploaded the busybox binary to DigitalOcean Spaces alongside the sysroots.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:43:30 -08:00
26204cc879 Add runtime Canvas UI builder for tutorial modal (#5208)
* Add runtime Canvas UI builder for tutorial modal

Implements TutorialCanvasBuilder to construct Canvas-based tutorial modal
UI at runtime, replacing the IMGUI fallback when no prefab is assigned.

- TutorialCanvasBuilder creates complete Canvas UI hierarchy:
  - Modal blocker (dark overlay)
  - Panel with title, description, icon, progress bar
  - Continue, Skip, and Skip All buttons
  - Fantasy RPG color scheme matching game style
- TutorialUIManager auto-builds Canvas UI if ModalPanel not assigned
- TutorialModalPanel click handlers made public for external setup
- Updated TUTORIAL_PLAN.md to reflect Canvas UI completion

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

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

* Fix tutorial Canvas UI issues

- Auto-load Stoke font if not assigned (searches Resources and loaded assets)
- Increase panel height (550px) and use flexible spacer for layout
- Fix text truncation by using Overflow mode instead of Ellipsis
- Replace "Province Selected" tutorial with proper welcome intro
- Intro tutorial triggers immediately on game start
- Skip buttons hidden when AllowSkip=false (intro is non-skippable)

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

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

* Remove font search logic - use CanvasFont field instead

Font should be assigned in TutorialUIManager inspector (CanvasFont field).
Removed unnecessary auto-search logic from TutorialCanvasBuilder.

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

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

* Fix TutorialTestSetup compile errors

- Use HasCompletedTutorial instead of HasSeenTutorial
- Use OnGameEvent instead of TriggerEvent

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

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

* Trigger intro tutorial when entering game, not lobby

- Remove immediate trigger from TutorialTestSetup.Start()
- Trigger "game_started" event from TutorialManager.Initialize()
  when EagleGameController is passed (actual game entry)

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

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

* Update TUTORIAL_PLAN.md with current status and future work

- Document completed phases (foundation, Canvas UI, triggers, test setup)
- Add Unity setup instructions (font assignments)
- Add future work: lobby tutorial helper, overlay system, hints
- Add lobby tutorial section to planned contextual tutorials
- Update testing instructions

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

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

* Assign Stoke-Regular-SDF font to TutorialUIManager

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

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

* Mark font assignment complete in TUTORIAL_PLAN.md

🤖 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-12 07:36:29 -08:00
7032260a31 Make Engine.postCommand use Scala SelectedCommand (#5211)
Updates Engine.postCommand to accept Scala SelectedCommand instead of
the proto version. The conversion from proto to Scala now happens at
the GameController layer, keeping the Engine interface proto-free.

Changes:
- Engine.scala: Import Scala SelectedCommand instead of proto
- EngineImpl.scala: Remove proto import and converter, use Scala directly
- GameController.scala: Convert proto→Scala before calling engine.postCommand
- Update test files to use Scala SelectedCommand for mock expectations

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:26:51 -08:00
23ab715ffc Add GitHub mirror for busybox binary to fix CI download failures (#5210)
The busybox.net server is frequently unavailable or slow, causing CI
builds to fail with download timeouts. This adds a GitHub release
mirror as the primary download source with busybox.net as fallback.

The binary is hosted at:
https://github.com/nolen777/eagle0/releases/tag/busybox-1.35.0

SHA256 verified: 6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:17:08 -08:00
73f42d5b51 Add Mac download support to invitation landing page (#5207)
* Allow Mac signing/notarization/deploy on manual workflow triggers

The signing, notarization, and deploy steps were only running on
push events. Now they also run on workflow_dispatch (manual triggers)
when targeting main branch.

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

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

* Add Mac download support to invitation landing page

- Detect Mac vs Windows from User-Agent header
- Add /invite/{code}/install.sh endpoint for Mac shell installer
- Shell script creates invitation.json, downloads app ZIP, extracts to /Applications
- Update HTML template with platform-specific instructions
- Add MAC_INSTALLER_URL environment variable (default: assets.eagle0.net/mac/builds/eagle0-latest.zip)
- Show link to other platform at bottom of page

🤖 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-12 07:03:28 -08:00
3cb61f69f4 Make CommandFactory use Scala types instead of proto types (#5205)
Update TCommandFactory and CommandFactory to accept Scala AvailableCommand
and SelectedCommand types directly, eliminating proto dependencies from
the command execution path.

Key changes:
- TCommandFactory.makeTCommand now takes Scala command types
- CommandFactory pattern matching updated for all ~40 command types
- Helper methods updated (attackDecision, improvementTypeMap, etc.)
- Removed proto converter imports from CommandFactory
- Updated vassal/riot phase actions to convert proto→Scala at call sites
- Added exports for Scala command types from t_command_factory

All 175 library tests pass.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:58:05 -08:00
62fff3c0ea Fix warmup authentication for Docker bridge network connections (#5206)
The warmup tool sends X-Warmup-User header for authentication during
blue/green deployments. This only worked when connecting from true
localhost (127.0.0.1), but when running warmup from the host machine
to a Docker container, the connection appears to come from the Docker
bridge network (172.17.x.x), causing the warmup header to be ignored.

The CreateGameRequest then fails because the user is unauthenticated
(null username), causing the warmup to timeout.

Fix: Expand the localhost check to also accept Docker bridge network
addresses (10.x.x.x, 172.16-31.x.x, 192.168.x.x) using Java's
isSiteLocalAddress(). These are all private network addresses that
can only come from the same machine or local network.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:47:23 -08:00
ca86926a25 Fix AI to chase scattering defenders instead of holding empty castles (#5203)
When defenders scatter (flee from castles), the attacker AI was still
using HOLD_CASTLES strategy. This caused the AI to park units on
empty castles instead of chasing fleeing defenders, even though
eliminating all defenders wins via LAST_PLAYER_STANDING.

The fix adds a new condition to the strategy selector: if defenders
exist but none are on castles, use ATTACK_UNITS strategy to chase
them down.

New strategy selection flow:
1. Consider fleeing (if combat odds are bad)
2. Consider crossing rivers (if needed)
3. If attacker can't hold all castles → ATTACK_UNITS
4. If any defender is on a castle → ATTACK_CASTLES
5. NEW: If defenders exist but none on castles → ATTACK_UNITS
6. If no defenders remain → HOLD_CASTLES

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:34:47 -08:00
1d334c91ae Allow Mac signing/notarization/deploy on manual workflow triggers (#5204)
The signing, notarization, and deploy steps were only running on
push events. Now they also run on workflow_dispatch (manual triggers)
when targeting main branch.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 06:32:24 -08:00
64e1e46e0a Fix crane installation and use crane in blue-green deploys (#5202)
1. Fix crane installation - don't try to mv crane to itself
   (tar extracts to cwd which is already /opt/eagle0)
2. Keep crane binary after deploy for blue-green script to use
3. Update blue-green deploy to use crane instead of docker pull
   (fixes OCI/Docker digest mismatch issue)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:09:04 -08:00
4722a40384 Fix crane binary disappearing during Docker deploy (#5201)
Use absolute path for crane binary and add verification that it
was installed correctly. Also add ls -la output for debugging.

The crane binary was mysteriously disappearing between the Eagle
and Shardok image pulls.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 23:02:20 -08:00
6653a14660 Use Scala types for command matching in EngineImpl (#5200)
Refactors postCommand to:
- Get Scala commands directly from AvailableCommandsFactory
- Convert proto SelectedCommand to Scala for matching
- Use CommandType-based matching (no more proto dependency in AvailableCommandTypeMap)
- Convert back to proto for CommandFactory (temporary until full migration)

AvailableCommandTypeMap now uses only Scala types - matching is simply:
  availableCommands.find(_.commandType == selectedCommand.commandType)

Also adds:
- ScalaCommandSelection case class for future migration of command selectors
- Improved visibility for command converter targets

This is an incremental step toward eliminating proto commands from library/.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:59:01 -08:00
056571651e Upgrade rules_apple to 4.3.3 and rules_swift to 2.4.0 (#5198)
* Upgrade rules_apple to 4.3.3 and rules_swift to 2.4.0

These are the latest compatible versions (rules_apple 4.3.3 depends
on rules_swift 2.4.0 with compatibility level 2).

Note: rules_swift 3.x uses compatibility level 3 and is not
compatible with current rules_apple versions.

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

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

* Fix zipApp to handle symlinks in Sparkle.framework

Sparkle.framework contains symlinks like Headers -> Versions/Current/Headers.
The previous code used filepath.Walk which follows symlinks, causing it to
try to read a directory as a file.

Now uses filepath.WalkDir with os.Lstat to detect symlinks and store them
properly in the zip archive.

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

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

* Revert "Fix zipApp to handle symlinks in Sparkle.framework"

This reverts commit ad2f2e40d4562ced1b4001e13abc95d8901c8f0e.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:48:57 -08:00
d8224e7886 Fix zipApp to handle symlinks in Sparkle.framework (#5199)
Sparkle.framework contains symlinks like Headers -> Versions/Current/Headers.
The previous code used filepath.Walk which follows symlinks, causing it to
try to read a directory as a file.

Now uses filepath.WalkDir with os.Lstat to detect symlinks and store them
properly in the zip archive.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:48:29 -08:00
9cc9a65e4a Fix codesign script to sign all Sparkle framework components (#5195)
* Fix codesign script to sign all Sparkle framework components

Sign XPC services, nested apps (Updater.app), and standalone
executables (Autoupdate) before signing the framework itself.
Apple notarization requires all nested binaries to be signed
with Developer ID certificate and secure timestamp.

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

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

* Add missing path triggers for pull_request in Mac build workflow

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

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

* Re-add rules_swift for Mac GoDice plugin build

The DarwinGodiceBundle requires rules_swift to build.
This was inadvertently removed in #5194.

🤖 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-11 22:24:55 -08:00
025563b607 Add retry logic to docker pull in blue-green deploy (#5197)
Handles intermittent Docker registry digest mismatch errors like:
"failed commit on ref: unexpected commit digest"

This is a known Docker/containerd issue that can occur due to:
- Registry caching
- Network/proxy issues
- Race conditions during push

Now retries up to 3 times with 5 second delays.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 22:23:18 -08:00
030f6b2c2e Fix warmup tool and PostCommandResponse status (#5196)
Warmup tool fixes:
- Process GameUpdates while waiting for PostCommandResponse (action
  results arrive BEFORE PostCommandResponse, not after)
- Don't wait for SubscriptionAck before ActionResultResponse (they
  arrive in reverse order)
- Use recommended_hero_id from ImproveAvailableCommand instead of
  hardcoding 0 (which doesn't exist)
- Verify we receive new AvailableCommands after posting command
- Add detailed logging for debugging

Server fix:
- Return PostCommandResponse.Status.SUCCESS instead of default UNKNOWN

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:54:41 -08:00
1d5dd7b971 Remove swift_proto_library targets and rules_swift dependency (#5194)
The Mac history editor that used these targets was previously removed.
Cleaning up the unused Swift proto infrastructure.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:24:54 -08:00
649e80c4ac Add Mac build pipeline with Sparkle auto-updates (#5193)
- Unity Mac build scripts (build_mac.sh, build_unity_mac.sh)
- Code signing with Developer ID certificate
- Apple notarization for Gatekeeper compliance
- Sparkle framework injection for delta auto-updates
- Go build handler for S3 upload and appcast.xml generation
- Update InvitationCodeManager.cs for Mac platform paths
- GitHub Actions workflow triggered on main branch pushes

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 14:22:52 -08:00
a1b4e41553 Add CommandType enum to replace SelectedCommand in ActionResult and Province (#5191)
* Add CommandType enum to replace SelectedCommand in ActionResult and Province

Create a compile-time safe CommandType enum that provides exhaustive matching
when adding new command types. This replaces SelectedCommand in:
- ActionResult.lastCommandTypeForActingProvince
- Province.lastCommand

Key changes:
- New CommandType.scala enum with exhaustive converters from SelectedCommand
  and AvailableCommand
- New command_type.proto with 40 command types + UNKNOWN
- CommandTypeConverter for proto<->Scala conversion
- Simplified shouldFollow in AvailableCommandsFactory to compare CommandType
  values directly
- Updated RandomStateSequencer.withTCommandAndLastCommand to use CommandType

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

* Add backwards compatibility for CommandType in saved games

Preserve the deprecated SelectedCommand field (field 28) alongside the
new CommandType field (field 43) in action_result.proto. When loading
saved games, first check the new CommandType field; if not set, fall
back to the deprecated SelectedCommand and convert it.

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

* Use parameterized enums for SelectedCommand/AvailableCommand to CommandType mapping

Refactors the dependency direction: instead of CommandType.from(SelectedCommand),
each enum case now has a built-in `val commandType: CommandType` parameter.

Benefits:
- Compile-time safety: can't add a new command without specifying its CommandType
- Simpler access: just call .commandType instead of a converter method
- Better dependency direction: richer types depend on simpler types, not vice versa

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:58:34 -08:00
1c24474a0b Add critical git rules to top of CLAUDE.md (#5192)
Prevent future accidental pushes directly to main by putting
explicit rules at the very top of the file.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:31:36 -08:00
adminandClaude Opus 4.5 26eec6cabc Add tutorial system plan document
Documents current implementation status, architecture, and remaining work.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:15:27 -08:00
d134f40438 Add tutorial test setup with IMGUI fallback UI (#5173)
* Add tutorial test setup with IMGUI fallback UI

Create TutorialTestSetup component that:
- Registers test tutorials programmatically on startup
- Triggers on first province selection, battle entry, and command issued
- Allows testing without Unity Editor asset creation

Add fallback IMGUI modal in TutorialUIManager:
- Renders when no ModalPanel prefab is assigned
- Shows title, description, progress, and action buttons
- Enables end-to-end testing without UI prefab setup

To test:
1. Add TutorialTestSetup component to TutorialManager GameObject
2. Ensure TutorialManager has UIManager reference
3. Play game and select a province to see test tutorial

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

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

* Silence missing onboarding sequence warning

Change LogWarning to debug log when no onboarding sequence is assigned.
This is a valid configuration state, not an error.

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

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

* Improve IMGUI fallback modal styling

- Add FallbackFont field (assign Stoke-Regular.ttf in Unity)
- Increase font sizes: title 24, description 20, buttons 18
- Make modal window larger (600x300)
- Make buttons taller (40px height)

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

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

* Double IMGUI modal size and font sizes

- Window: 1200x600 (was 600x300)
- Title: 48pt, Description: 40pt, Progress: 32pt, Buttons: 36pt
- Buttons: 200-240x80 (was 100-120x40)
- Add TutorialManager GameObject to Gameplay scene

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

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

* Add Reset Tutorials button to Settings panel

- Add resetTutorialsButton field to SettingsPanelController
- Add OnResetTutorialsClick() handler that calls TutorialManager.ResetAllProgress()

To wire up in Unity:
1. Add a Button to the Settings panel
2. Assign it to resetTutorialsButton field
3. Set OnClick to SettingsPanelController.OnResetTutorialsClick

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

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

* Fix ambiguous Debug reference

Use UnityEngine.Debug.Log to resolve conflict with System.Diagnostics.Debug

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

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

* Apply window style to IMGUI modal title

Pass windowStyle to GUI.Window so title uses 48pt font

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

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

* Add more spacing between title and description

Increase top spacing from 30 to 60 pixels

🤖 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-11 11:38:21 -08:00
a638dd26ca Fix deploy: remove warmup before scp, safer container cleanup (#5190)
Three fixes:
1. Remove existing warmup binary before scp - Bazel outputs it with
   read-only permissions (r-xr-xr-x), causing scp to fail on overwrite.

2. Remove the `docker compose up -d --remove-orphans` step which was
   causing "container name already in use by service {}" errors due to
   Docker Compose state conflicts.

3. Add `docker container prune -f` as a safer alternative - removes
   stopped containers without trying to reconcile compose state.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:46:29 -08:00
5e93b0eaa0 Add null safety to RunningGamePlayerInfo in admin console (#5189)
Prevent NPE when listing games with corrupt faction/leader data.
This defensive fix handles three cases:
- Hero might not exist for the faction head ID
- Faction name might be null
- Leader nameTextId might be null

The warmup tool created games with unexpected null values, causing
the admin console to crash when listing running games.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:22:12 -08:00
6940312d3f Add /invite/ route to nginx config (#5188)
The invitation landing page is served by the auth service on port 8080,
but nginx wasn't configured to proxy requests to it, resulting in 404.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 07:18:26 -08:00
243f0d43e7 Fix scp to preserve directory structure for deploy scripts (#5187)
scp flattens paths - it was copying scripts/bin/warmup to /opt/eagle0/warmup
instead of /opt/eagle0/scripts/bin/warmup. Fixed by:
1. Creating directory structure on remote first
2. Copying files to their correct locations

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:44:25 -08:00
1ddd015449 Unify .env management across workflows with shared update script (#5186)
Both docker_build.yml and auth_build.yml now use a shared update-env.sh
script that updates only the variables each workflow is responsible for,
without overwriting values set by other workflows.

Changes:
- Add deploy/env.template with all environment variables
- Add deploy/update-env.sh to safely update individual env vars
- Update docker_build.yml to use update-env.sh instead of rm/recreate
- Update auth_build.yml to use update-env.sh instead of grep/sed chain
- Add FASTMAIL_* vars to docker_build.yml deploy job

This fixes the bug where docker_build.yml was overwriting FASTMAIL env
vars set by auth_build.yml because it recreated .env from scratch.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:42:08 -08:00
da85d0983a Fix deploy order: start jfr-sidecar after eagle-blue (#5185)
jfr-sidecar has `pid: "service:eagle-blue"` to share PID namespace for
JFR profiling. It must be started after eagle-blue exists, not before.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:31:12 -08:00
ae574e6ff5 Add delete user and delete invitation functionality to admin panel (#5181)
- Add DeleteUser and DeleteInvitation RPCs to admin.proto
- Add Delete methods to UserService and InvitationService
- Add delete handlers in admin_handlers.go and admin_server.go
- Add delete buttons to users and invitations admin UI templates
- Users can be deleted permanently (with self-deletion prevention)
- Only non-pending invitations (revoked, expired, redeemed) can be deleted

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:23:03 -08:00
c1ac57b929 Build warmup tool in deploy job before scp (#5183)
The warmup binary was being built in build-eagle but each job has its
own checkout, so the binary wasn't available in the deploy job when
we tried to scp it to the droplet.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:22:21 -08:00
be51daa148 Remove EagleGameHistoryViewer Mac app (#5182)
This Swift/SwiftUI app for viewing game history is no longer needed.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:21:25 -08:00
97605d0a63 Remove mac history editor build workflow (#5180)
The mac history editor is no longer needed now that we have the Admin
console for game management.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:17:48 -08:00
9cca922b70 Add blue-green deployment infrastructure for Eagle server (#5162)
* Add blue-green deployment infrastructure for Eagle server

- Add ReloadGames RPC to eagle.proto for reloading game state from disk
- Implement reloadAllGames() and flushToDisk() in GamesManager.scala
- Add reloadGames() handler to EagleServiceImpl.scala
- Update docker-compose.prod.yml with eagle-blue/green services
- Update nginx.conf for switchable upstream
- Create scripts/deploy-blue-green.sh for zero-downtime deployment
- Create Go warmup tool (src/main/go/net/eagle0/warmup) that:
  - Uses bidirectional streaming to create test games
  - Posts Improve command and verifies ActionResults
  - Cleans up test game after warmup
- Create scripts/warmup-eagle.sh wrapper that uses Go tool or falls back to grpcurl
- Update docker_build.yml to build and deploy warmup binary

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

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

* Add X-Warmup-User header support with localhost restriction

- AuthorizationInterceptor: Accept X-Warmup-User header for warmup authentication
- Only allow X-Warmup-User from localhost connections (security)
- Go warmup tool: Send x-warmup-user metadata header
- Add grpc/metadata dependency to warmup BUILD

🤖 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-10 22:13:15 -08:00
6eab9ffc3c Add invitation landing page with one-click installer (#5165)
Instead of asking users to copy/paste a PowerShell command, emails now
link to a landing page at /invite/{code} that:
- Shows a branded "Accept Invitation" page
- Offers a "Download & Install" button that downloads a .bat file
- The .bat file downloads the installer and runs it with --code=XXX
- Shows clear instructions for running the .bat file
- Displays error messages for invalid/expired/redeemed codes
- Falls back to showing the invitation code for manual entry

Changes:
- Add invitation_handlers.go with landing page and .bat download routes
- Update main.go to register the new HTTP routes
- Simplify email template to just link to the landing page

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:10:09 -08:00
08eaffb927 Remove debug logging from combat animators (#5172)
Remove Debug.Log calls from ArrowVolleyAnimator and MeleeAnimator.
Keep Debug.LogWarning calls that indicate configuration issues.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:51:28 -08:00
a2bc55e0f5 Remove debug logging from MeleeAnimator (#5179)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:51:05 -08:00
19861377a6 Use hex center for animation positions (#5178)
Changed all animators to use GetCellCenterPosition instead of
GetCellLocalPosition so animations start and end at the actual
hex center rather than offset towards the top.

- MoveAnimator: footprints centered
- MeleeAnimator: weapon animations centered
- CatapultAnimator: projectile source now also centered (target already was)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:21:14 -08:00
cc70087efc Fix captured hero visibility in AvailableCommandConverter (#5177)
The converter was using factionId = Some(viewingFactionId) which
applied visibility restrictions to captured heroes. The old proto-based
factory used factionId = None (with comment "can see unaffiliated
heroes") which always showed full hero info.

When handling captured heroes, the player needs to see all hero stats
to make informed decisions about recruiting, imprisoning, etc.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 21:14:33 -08:00
b2383c8384 Fix Move animation direction for observed battles (#5176)
When observing battles, Move animations were playing backwards because
the model is fully updated before animations play. The animation code
was using the unit's current location (post-move) as the source, but
for multi-step moves this caused animations to go from the final
position back to intermediate positions.

Fix: Track source coordinates in ShardokGameModel.MoveSourceCoords
before applying each diff, then use these stored coordinates for
animation source positions.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:33:58 -08:00
03958b6914 Fix ArmyStats missing hostility and originProvinceId fields (#5175)
Add hostility and originProvinceId fields to Scala ArmyStats that were
present in the proto definition but missing from the Scala model. This
fixes the AttackDecisionCommandChooser which uses hostility to determine
friendly vs enemy armies.

Unlike #5174, this uses required fields instead of defaults, ensuring
all call sites explicitly provide the values rather than relying on
potentially incorrect defaults.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 20:03:49 -08:00
4cbf3ea39f Integrate tutorial system hooks into game controllers (#5171)
Add tutorial trigger hooks to both strategic (EagleGameController) and
tactical (ShardokGameController) layers to enable the tutorial system
to respond to game events.

Strategic layer hooks:
- TutorialManager initialization with auto-start onboarding
- OnModelUpdated for game state changes
- OnProvinceSelected for province selection
- OnCommandIssued for command submission

Tactical layer hooks:
- TutorialManager initialization on battle entry
- OnBattleEntered when entering combat
- OnBattleAction for each action result
- OnUnitSelected for tile selection
- OnTurnEnded for turn completion

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:16:59 -08:00
3dbbe74830 Use self-hosted runner for deploy job (#5170)
Replace Docker-based appleboy/scp-action and appleboy/ssh-action with
native scp and ssh commands. This eliminates the Docker container build
overhead that was causing ~3 minute delays during each deploy.

Changes:
- deploy job now runs on self-hosted runner
- Use native scp to copy files to droplet
- Use native ssh with heredoc for deployment script
- Add DO_DROPLET_IP and DO_REGISTRY_TOKEN to env block

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 19:14:15 -08:00
dab4365f16 Fix race condition in ShardokGameController setup (#5169)
The UpdateAction callback was being set before hexGrid.SetUp() was called.
Since SetUp() is queued via MainQueue, game updates arriving in between
would call ModelUpdated() before cells were initialized.

Fixed by:
1. Moving Model.UpdateAction assignment inside the queued block, after SetUp()
2. Added defensive null checks in HexGrid.SetProfessionImage/SetUnitTypeImage

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:57:21 -08:00
0b3cd4f1c6 Fix melee animation weapons persisting after animation ends (#5167)
When a melee animation was cancelled (either by starting a new animation
or calling CancelAnimation), the weapon GameObjects were not destroyed
because they were local variables in the coroutine.

Fixed by storing weapons in class-level fields and adding a CleanupWeapons()
method that is called when animations are cancelled or complete normally.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:52:27 -08:00
49d42a599a Fix missing eligibleStatuses in diplomacy resolve commands (#5168)
The Scala DiplomacyOfferInfo was missing the eligibleStatuses field,
causing the client to not know what actions are available when
resolving diplomacy offers (alliance, truce, ransom, invitation,
break alliance).

Changes:
- Add eligibleStatuses: Vector[Status] to DiplomacyOfferInfo
- Update all resolve command factories to populate eligibleStatuses
- Update AvailableCommandConverter to apply eligibleStatuses to proto

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:44:18 -08:00
5ae7d8a9cc Fix NullReferenceException in StopAll when ModelUpdater is null (#5166)
StopAll() could be called before a game was set up (ModelUpdater not
initialized) or called multiple times (second call after ModelUpdater
was set to null). Added null check to prevent the crash.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 18:29:46 -08:00
ee47f7d6bc Switch from SMTP to Fastmail JMAP API for invitation emails (#5164)
DigitalOcean blocks SMTP ports (587, 465) by default. Switch to
Fastmail's JMAP API which uses HTTPS and is not blocked.

Changes:
- Rewrite sendgrid.go to use JMAP API (Email/set + EmailSubmission/set)
- Update docker-compose.prod.yml with FASTMAIL_* env vars
- Update auth_build.yml workflow with new secrets

Required GitHub secrets:
- FASTMAIL_API_TOKEN: API token with email submission scope
- FASTMAIL_FROM_EMAIL: Sender email (optional, uses identity default)
- FASTMAIL_FROM_NAME: Sender name (optional, defaults to "Eagle0 Game")

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:54:10 -08:00
fe3c2d3e79 Add SMTP env vars to auth service in docker-compose (#5163)
The workflow writes SMTP credentials to .env, but docker-compose only
passes explicitly listed environment variables to containers.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:38:49 -08:00
de21646a37 Pass SMTP credentials to auth service container (#5161)
Add SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM_EMAIL, and SMTP_FROM_NAME
to the deploy job so the auth service can send invitation emails.

The credentials are now:
1. Mapped from GitHub secrets in the env section
2. Passed via SSH in the envs parameter
3. Written to .env file on the production server

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 17:34:08 -08:00
040f2d55b9 Replace SendGrid with standard SMTP for email sending (#5159)
Use Go's net/smtp package with STARTTLS instead of SendGrid API.
This allows using Fastmail (or any SMTP provider) without DNS changes.

Environment variables:
- SMTP_HOST (default: smtp.fastmail.com)
- SMTP_PORT (default: 587)
- SMTP_USERNAME
- SMTP_PASSWORD (app password)
- SMTP_FROM_EMAIL
- SMTP_FROM_NAME

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 16:40:00 -08:00
5b32712b0f Add invitation code support to installer and Unity client (#5158)
* Add invitation code capture to Windows installer

Support --code=XXXX command line argument to pass invitation codes.
The code is saved to invitation.json in the install directory for
the Unity client to read during OAuth.

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

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

* Add invitation code support to Unity client OAuth flow

- Add InvitationCodeManager to read codes from installer file or PlayerPrefs
- Pass invitation code in GetOAuthUrlRequest
- Handle OAUTH_STATUS_INVITATION_REQUIRED response
- Clear invitation code after successful new account creation
- Add OnInvitationRequired event for UI handling

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

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

* Add PowerShell install option and manual code entry UI

Email template:
- Add Option 1: PowerShell one-liner to download and run with code
- Add Option 2: Manual download with code entry instructions

Unity client:
- Add invitation code entry panel UI references
- Handle OnInvitationRequired event to show code entry
- OnSubmitInvitationCodeClicked saves code and returns to login

🤖 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-10 15:54:11 -08:00
bdec4a0015 Tutorial system foundation (#5155)
* Add tutorial system foundation

Create the core architecture for a comprehensive tutorial system:

- TutorialState: PlayerPrefs-based persistence for tutorial progress
- TutorialStep/TutorialSequence: Data structures for tutorial content
- TutorialManager: Singleton coordinating state, triggers, and UI
- TutorialTriggerRegistry: Event-based trigger system for contextual tutorials
- TutorialUIManager: Coordinates modal, overlay, and hint UI components
- TutorialModalPanel: Full-screen modal dialogs for important tutorials
- TutorialOverlayController: Highlighting UI elements with tooltips
- TutorialHintIndicator: Subtle pulsing hints on UI elements

Supports:
- Guided onboarding sequences for new players
- Contextual tutorials triggered on first encounter/attempt
- Mixed UI: modals, overlays, and hint indicators
- Skip/dismiss functionality
- Progress persistence via PlayerPrefs

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

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

* Fix missing namespace imports in tutorial system

Add using statements for eagle and Shardok namespaces to resolve
compiler errors referencing EagleGameController, ShardokGameController,
and IGameModel.

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

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

* Add Unity meta files for tutorial system

Unity requires .meta files for all assets including scripts and
directories. These are needed for the Unity build to succeed.

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

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

* Rename namespace to Eagle0.Tutorial to avoid conflict

The Eagle namespace is used by generated protobuf code (Eagle.EagleClient).
Using Eagle.Tutorial was shadowing this, causing compilation errors.
Renamed to Eagle0.Tutorial to avoid the conflict.

🤖 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-10 15:52:58 -08:00
c2c7b85da5 Make AvailableCommandsFactory return Scala types instead of proto (#5157)
* Make AvailableCommandsFactory return Scala types instead of proto

Convert AvailableCommandsFactory to work entirely with Scala types internally
and return Scala OneProvinceAvailableCommands. Proto conversion now happens at
API boundaries (EngineImpl) rather than inside the factory. This continues the
protoless migration by pushing proto dependencies to the edges of the system.

- Add Scala OneProvinceAvailableCommands case class
- Add OneProvinceAvailableCommandsConverter for proto conversion
- Update AvailableCommandsFactory to return Scala types
- Update callers (EngineImpl, RoundPhaseAdvancer, action classes)
- Remove proto overloads from diplomacy resolution factories
- Update tests to work with new Scala types

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

* Remove dead proto code from AvailablePleaseRecruitMeCommandFactory

Delete unused proto overload and ExpandedUnaffiliatedHeroUtils which
was only used by the proto code path.

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

* Fix missing DEVASTATION case in SelectedCommandConverter

Add missing case for ImprovementTypeProto.DEVASTATION in the
improvementTypeFromProto match expression.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 15:34:50 -08:00
9e287ba3bb Add invitation-based account creation system (#5156)
* Add invitation-based account creation system (Phase 1-2)

Implement invitation system to restrict new account creation to
invited users only. Existing users are grandfathered.

Proto definitions:
- Add Invitation, InvitationStatus, InvitationDatabase to user.proto
- Add invitation management RPCs to admin.proto (CreateInvitation,
  ListInvitations, RevokeInvitation, ResendInvitation)
- Add invitation_code field to GetOAuthUrlRequest
- Add OAUTH_STATUS_INVITATION_REQUIRED status

Go auth service:
- Add InvitationService for managing invitations with persistence
- Add EmailService for SendGrid integration (disabled if API key not set)
- Update OAuth flow to pass invitation code through state
- Validate invitation code for new users in CheckOAuthStatus
- Add admin handlers for invitation management

Environment variables:
- SENDGRID_API_KEY: Required for email sending
- SENDGRID_FROM_EMAIL: Sender email (default: noreply@eagle0.net)
- SENDGRID_FROM_NAME: Sender name (default: Eagle0 Game)
- INSTALLER_DOWNLOAD_URL: URL for installer download link

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

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

* Add invitation management UI to admin panel (Phase 3)

- Add "Invitations" link to navigation
- Create invitations.html and invitations_rows.html templates
- Add invitation management handlers:
  - handleInvitationsPage: List all invitations with filtering
  - handleInvitationsSearch: Search/filter invitations (htmx)
  - handleCreateInvitation: Create and send invitation
  - handleResendInvitation: Resend invitation email
  - handleRevokeInvitation: Revoke a pending invitation

Features:
- Status filtering (All/Pending/Redeemed/Expired/Revoked)
- Email search
- Create invitation modal with expiration days
- Resend and Revoke actions for pending invitations
- Copy invitation code to clipboard

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

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

* Add feature flag for invitation code requirement

Add REQUIRE_INVITATION_CODE environment variable to control whether
new users must provide invitation codes. Defaults to false, allowing
the invitation system to be deployed without immediately blocking
new signups.

- Add isInvitationRequired() function that checks env var
- Only validate invitation codes when feature flag is enabled
- Log feature flag status at startup

🤖 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-10 15:19:19 -08:00
1a0cfe8578 Remove assets/ prefix from installer paths (#5154)
The old server had an assets/ prefix in its routing, but DO Spaces
CDN serves files directly from the bucket root.

- Old: https://eagle0.net/assets/installer/...
- New: https://assets.eagle0.net/installer/...

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 11:10:01 -08:00
a841720c75 Remove factory adapter infrastructure (#5152)
Now that all Available*CommandFactory classes use ScalaAvailableCommandsFactory,
remove the adapter infrastructure:
- Delete UnifiedCommandFactory trait
- Delete LegacyFactoryAdapter and ScalaFactoryAdapter
- Delete AvailableCommandsFactoryForType trait
- Update AvailableCommandsFactory to use ScalaAvailableCommandsFactory directly
- Update tests to mock ScalaAvailableCommandsFactory

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 11:02:07 -08:00
064a606d13 Hardcode CDN URL to fix auto-update from old installers (#5153)
Old installers saved eagle0.net to the registry. The new installer
(without auth) was reading that saved URL and failing with 401.

Now the URL is hardcoded to assets.eagle0.net with no registry storage.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 10:58:45 -08:00
b84af5798b Migrate Windows installer to public CDN at assets.eagle0.net (#5145)
* Migrate Windows installer to public CDN at assets.eagle0.net

- Change default URL from eagle0.net to assets.eagle0.net
- Make Basic Auth optional (public CDN doesn't require credentials)
- Allow users to proceed without credentials for public CDN
- Retain credential validation for custom authenticated servers

This prepares the installer for the migration from the local Go
asset server to DigitalOcean Spaces CDN with public access.

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

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

* Add public ACL support for S3 uploads

Update Go AWS utilities and build handlers to upload files with
public-read ACL, enabling direct CDN access without presigned URLs.

- Add UploadFilePublic and UploadBytesPublic functions to s3.go
- Update unity3d_windows_build_handler to use public uploads
- Update manifest_manager to use public uploads
- Update installer_build_handler to use public uploads

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

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

* Remove HTTP Basic Auth and credentials UI from installer

With public CDN access at assets.eagle0.net, authentication is no longer
needed. This significantly simplifies the installer:

- Remove LoginDialog.cs entirely
- Simplify CredentialManager to only store server URL
- Remove auth headers and credential handling from EagleUpdater
- Remove login panel, credentials button from MainForm
- Remove credential-related CLI flags from Program.cs

The installer now just downloads from the public CDN without any
authentication prompts or credential storage.

🤖 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-10 10:48:14 -08:00
440af18210 Convert AvailableHandleCapturedHeroCommandFactory to Scala types (#5150)
This is the final factory conversion. All Available*CommandFactory
classes now extend ScalaAvailableCommandsFactory instead of the
legacy AvailableCommandsFactoryForType.

Changes:
- Update CapturedHeroOption enum to add Exile and Return (previously
  only had Release which mapped to Exile)
- Update AvailableCommandConverter for new CapturedHeroOption cases
- Convert factory to use Scala GameState and return Scala AvailableCommand
- Update AvailableCommandsFactory to call the converted factory with
  Scala GameState and convert result to proto
- Rewrite test to construct Scala GameState directly without proto

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 09:43:45 -08:00
722e107d53 Migrate sysroot to dedicated eagle0-sysroot bucket (#5151)
- Update build_sysroot.yml to upload to eagle0-sysroot bucket
- Update MODULE.bazel sysroot URLs to new bucket location

Note: Before merging, copy existing sysroot files to new bucket:
  aws s3 cp s3://eagle0-windows/sysroot/v3/ s3://eagle0-sysroot/v3/ --recursive
  aws s3 cp s3://eagle0-windows/sysroot/v4/ s3://eagle0-sysroot/v4/ --recursive

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 09:41:20 -08:00
9cc83fa2f5 Make meteor animation more dramatic (#5149)
* Make meteor animation more dramatic

- Slower fall duration (0.4s -> 0.8s) with visible rock rotation
- Continuous fiery trail that follows behind the meteor with hot-to-cool
  color gradient
- Bigger explosion (endScale 50 -> 80) with initial flash effect
- Rock debris particles that fly outward with gravity arc and spin
- Trail particles wobble perpendicular to fall direction for more dynamic look

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

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

* Configure MeteorAnimator sprite references in scene

🤖 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-10 09:18:34 -08:00
cd9db60b80 Convert AvailableAttackDecisionCommandFactory to Scala types (#5143)
* Convert AvailableAttackDecisionCommandFactory to Scala types

- Convert factory to extend ScalaAvailableCommandsFactory
- Use Scala GameState, Army, MovingArmy, HostileArmyGroup
- Use Scala AttackDecisionType, ArmyStats, ExpandedCombatUnit
- Use Scala FactionUtils.provinces instead of LegacyFactionUtils
- Update AvailableCommandsFactory to use ScalaFactoryAdapter

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

* Fix test to use Scala GameState directly instead of proto

Convert AvailableAttackDecisionCommandFactoryTest to construct Scala
GameState directly using concrete types (HeroC, ProvinceC, FactionC,
BattalionC, MovingArmy, HostileArmyGroup, FactionRelationship) instead
of proto types.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 09:10:54 -08:00
5c0be5e3a3 Convert AvailableFreeForAllDecisionCommandFactory to Scala types (#5142)
* Convert AvailableFreeForAllDecisionCommandFactory to Scala types

- Convert factory to extend ScalaAvailableCommandsFactory
- Use Scala GameState, Army, MovingArmy, HostileArmyGroupStatus
- Use Scala AttackDecisionType, ArmyStats, ExpandedCombatUnit
- Use Scala RoundPhase.FreeForAllDecision
- Update AvailableCommandsFactory to use ScalaFactoryAdapter

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

* Fix test to use Scala GameState directly instead of proto

Convert AvailableFreeForAllDecisionCommandFactoryTest to construct Scala
GameState directly using concrete types (HeroC, ProvinceC, FactionC,
BattalionC, MovingArmy, HostileArmyGroup) instead of proto types.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 09:00:04 -08:00
fdc6c7f8cf Convert AvailableManagePrisonersCommandFactory to Scala types (#5141)
* Convert AvailableManagePrisonersCommandFactory to Scala types

- Convert factory to extend ScalaAvailableCommandsFactory
- Use Scala GameState, UnaffiliatedHeroType, PrisonerManagementOption
- Expand PrisonerManagementOption enum with Exile, Move, Return cases
- Update AvailableCommandConverter and SelectedCommandConverter
- Update AvailableCommandsFactory to use ScalaFactoryAdapter
- Fix tests to use new enum cases

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

* Fix test to use Scala GameState directly instead of proto

Convert AvailableManagePrisonerCommandsFactoryTest to construct Scala
GameState directly using concrete types (HeroC, ProvinceC, FactionC,
UnaffiliatedHeroC) instead of converting from proto.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:49:36 -08:00
37ac0dd19f Convert AvailableDefendCommandsFactory to Scala types (#5144)
Convert AvailableDefendCommandsFactory from proto types to Scala types:
- Extend ScalaAvailableCommandsFactory trait
- Use Scala GameState, ProvinceT, HeroT, BattalionT types
- Use Scala Profession enum with scalaProfessionOrdering
- Use FactionUtils and BattalionUtils (not Legacy versions)
- Convert SuitableBattalions from util to AvailableCommand type
- Update BUILD.bazel deps for both factory and test
- Convert test to use Scala types (BattalionType, Neighbor, etc.)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:48:24 -08:00
13037aec56 Convert AvailableMarchCommandFactory to Scala types (#5148)
- Extend ScalaAvailableCommandsFactory trait instead of
  AvailableCommandsFactoryForType
- Take Scala GameState as input, return Scala AvailableCommand
- Use ProvinceUtils, HeroUtils, BattalionUtils instead of Legacy versions
- Convert proto CombatUnit to Scala RecommendedCombatUnit
- Convert BattalionSuitability.SuitableBattalions to AvailableCommand.SuitableBattalions
- Update AvailableCommandsFactory to use ScalaFactoryAdapter
- Update tests to use GameStateConverter.fromProto for proto test setup

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:36:36 -08:00
2acfec76a9 Fix registry cleanup workflow parsing headers as data (#5146)
The doctl --no-header flag wasn't working reliably, causing the script to
treat column headers ("Name", "Manifest", "Digest") as actual repository
names and digests.

Fixes:
- Filter out "Name" from repository list
- Filter out "Digest" from manifest list
- Validate digests start with "sha256:" before attempting delete

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:20:23 -08:00
ad105d60d9 Convert AvailableDiplomacyCommandsFactory to Scala types (#5147)
- Extend ScalaAvailableCommandsFactory trait instead of
  AvailableCommandsFactoryForType
- Take Scala GameState as input, return Scala AvailableCommand
- Use FactionUtils, HeroUtils, ProvinceUtils instead of Legacy versions
- Group diplomacy options by targetFactionId using
  DiplomacyOption(targetFactionId, optionTypes: Vector[DiplomacyOptionType])
- Update AvailableCommandsFactory to use ScalaFactoryAdapter
- Update tests to use GameStateConverter.fromProto for proto test setup

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:19:36 -08:00
a8048fbef3 Convert AvailableResolveTributeCommandsFactory to Scala types (#5137)
* Convert AvailableResolveTributeCommandsFactory to Scala types

Updates AvailableResolveTributeCommandsFactory to:
- Extend ScalaAvailableCommandsFactory
- Take Scala GameState and ProvinceT input
- Return Scala ResolveTributeAvailable
- Use Scala HostileArmyGroup and HostileArmyGroupStatus types
- Inline hero/troop counting to avoid proto dependencies

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

* Convert ResolveTribute test to use Scala types

Update AvailableResolveTributeCommandsFactoryTest to use Scala GameState
and related types instead of proto types, matching the factory conversion.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 07:37:11 -08:00
ad72f11e29 Convert AvailableDivineCommandsFactory to Scala types (#5140)
- Change factory to extend ScalaAvailableCommandsFactory
- Use Scala GameState, ProvinceT, UnaffiliatedHeroT, RecruitmentInfo types
- Use Scala ProvinceUtils instead of LegacyProvinceUtils
- Return Scala DivineAvailable with ExpandedUnaffiliatedHero
- Simplify test to use makeGameState helper pattern

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 07:36:33 -08:00
14c03f34ef Convert AvailableRecruitHeroesCommandFactory to Scala types (#5138)
* Convert AvailableRecruitHeroesCommandFactory to Scala types

- Change factory to extend ScalaAvailableCommandsFactory
- Use Scala GameState, ProvinceT, UnaffiliatedHeroT, HeroT types
- Use Scala FactionUtils, ProvinceUtils, RecruitmentOdds instead of Legacy versions
- Return Scala RecruitHeroesAvailable with ExpandedUnaffiliatedHero
- Update BUILD.bazel with Scala dependencies

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

* Fix test to use Scala types instead of proto types

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 07:05:56 -08:00
eb63977ca2 Convert AvailableDeclineQuestCommandsFactory to Scala types (#5139)
- Change factory to extend ScalaAvailableCommandsFactory
- Use Scala GameState, ProvinceT, UnaffiliatedHeroT, RecruitmentInfo types
- Use Scala ProvinceUtils instead of LegacyProvinceUtils
- Return Scala DeclineQuestAvailable with ExpandedUnaffiliatedHero
- Update test to use Scala types with makeGameState helper

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 06:56:46 -08:00
1cd23b566b Convert AvailableHandleRiotCrackDownCommandFactory to Scala types (#5131)
Convert the HandleRiotCrackDown factory to use Scala GameState and return
Scala AvailableCommand instead of proto types. Updates:
- Factory extends ScalaAvailableCommandsFactory
- Uses ProvinceUtils.hasImminentRiot instead of LegacyProvinceUtils
- Uses ProvinceT instead of proto Province
- Returns AvailableCommand.HandleRiotCrackDownAvailable
- Updated BUILD.bazel with Scala dependencies
- Updated AvailableCommandsFactory to use ScalaFactoryAdapter
- Converted test to use Scala types with makeGameState() pattern

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 06:55:47 -08:00
779e7eceec Convert AvailableIssueOrdersCommandFactory to Scala types (#5135)
Updates AvailableIssueOrdersCommandFactory to:
- Extend ScalaAvailableCommandsFactory
- Take Scala GameState input
- Return Scala IssueOrdersAvailable
- Use FactionUtils.provinceCount instead of LegacyFactionUtils
- Add toCommandOrderType converter between province and command ProvinceOrderType

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 06:54:21 -08:00
1ed5252d44 Convert AvailableHandleRiotCrackDownCommandFactory to Scala types (#5136)
Updates AvailableHandleRiotCrackDownCommandFactory to:
- Extend ScalaAvailableCommandsFactory
- Take Scala GameState and ProvinceT input
- Return Scala HandleRiotCrackDownAvailable
- Use ProvinceUtils.hasImminentRiot instead of LegacyProvinceUtils

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 05:54:53 -08:00
836c09c357 Convert AvailableOrganizeTroopsCommandsFactory to Scala types (#5134)
Changes factory to use Scala GameState and return Scala AvailableCommand.
Uses ProvinceUtils.effectiveAgriculture and effectiveEconomy for battalion
type availability checks. Returns OrganizeTroopsAvailable with proper
BattalionTypeId.value conversions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 05:54:07 -08:00
6f73ac71ab Convert AvailableArmTroopsCommandFactory to Scala types (#5133)
Changes factory to use Scala GameState and return Scala AvailableCommand.
Uses ProvinceUtils.effectiveInfrastructure instead of LegacyProvinceUtils,
BattalionTypeFinder for battalion type lookups, and proper BattalionTypeId
enum values with .value conversion for ArmamentCost integer fields.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 05:53:26 -08:00
5e68fc344d Convert AvailableHandleRiotGiveCommandFactory to Scala types (#5132)
Updates AvailableHandleRiotGiveCommandFactory to:
- Extend ScalaAvailableCommandsFactory
- Take Scala GameState input
- Return Scala HandleRiotGiveAvailable
- Use ProvinceUtils.hasImminentRiot instead of LegacyProvinceUtils

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 05:52:51 -08:00
2690332b10 Animation improvements: Charge, Melee, and Meteor phases (#5130)
* Add separate animations for meteor phases

MeteorAnimator now supports distinct animations for each meteor phase:
- MeteorStart: Charging effect with growing glow and spiraling particles
- MeteorTarget: Pulsing target indicator with contracting ring
- MeteorCast: Falling meteor with trail and explosion (existing)
- MeteorCancel: Fizzle effect with dispersing particles

Update ShardokGameController to map each ActionType/CommandType to
the appropriate animation instead of using MeteorCast for all phases.

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

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

* Add meteor phase animation settings to scene

Configure MeteorAnimator with sprites and settings for:
- Charge phase (orange glow)
- Target phase (red indicator)
- Cancel phase (grey fizzle)

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

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

* Simplify Charge animation and add weapon orientation to Melee

ChargeAnimator:
- Remove defender weapon, show only attacker thrusting
- Accelerating thrust motion (slow start, fast finish)
- Add impact flash on each thrust
- Cleaner, less chaotic animation

MeleeAnimator:
- Add per-weapon rotation offset and flip settings
- Settings for sword, mace, small spear, large spear, dagger, bone
- Each weapon type can be independently oriented
- Follows same pattern as ToolAnimator

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

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

* Center Charge animation on hex centers

Use GetCellCenterPosition instead of GetCellLocalPosition to
position the weapon at the center of the hex.

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

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

* Make Melee animation slower with deliberate swings

- Increase clashDuration from 0.12s to 0.3s (slower, more visible)
- Reduce clashCount from 3 to 2 (fewer but deliberate)
- Increase swingArc from 60 to 90 degrees (more pronounced)
- Remove shake during swing motion (only at impact)
- Reduce shakeIntensity from 8 to 3
- Clean, smooth swing interpolation

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

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

* Change cavalry weapons from spears to swords in Melee

Light cavalry now uses falchion (was small spear)
Heavy cavalry now uses two-handed sword (was large spear)
Both cavalry types now swing instead of thrust.

Renamed sprite and orientation fields:
- smallSpearSprite -> falchionSprite
- largeSpearSprite -> twoHandedSwordSprite
- smallSpearRotationOffset -> falchionRotationOffset
- largeSpearRotationOffset -> twoHandedSwordRotationOffset
- (and corresponding flip settings)

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

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

* Fix remaining spear references in MeleeAnimator

Update sprite null check to use new weapon names.

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

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

* Update animator settings in Unity scene

MeleeAnimator:
- Rename spear sprites to falchion/twoHandedSword
- Add per-weapon rotation/flip settings

ChargeAnimator:
- Add flash settings
- Update thrust/pullback settings

🤖 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-09 22:45:05 -08:00
99109e7e60 Convert AvailableHandleRiotDoNothingCommandFactory to Scala types (#5129)
Convert the HandleRiotDoNothing factory to use Scala GameState and return
Scala AvailableCommand instead of proto types. Updates:
- Factory extends ScalaAvailableCommandsFactory
- Uses ProvinceUtils.hasImminentRiot instead of LegacyProvinceUtils
- Returns AvailableCommand.HandleRiotDoNothingAvailable
- Updated BUILD.bazel with Scala dependencies
- Updated AvailableCommandsFactory to use ScalaFactoryAdapter
- Converted test to use Scala types with makeGameState() pattern

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:40:59 -08:00
bd9b850d45 Convert AvailableTradeCommandFactory to Scala types (#5128)
Convert the Trade factory to use Scala GameState and return
Scala AvailableCommand instead of proto types. Updates:
- Factory extends ScalaAvailableCommandsFactory
- Uses Scala ProvinceT for province access
- Returns AvailableCommand.TradeAvailable
- Updated BUILD.bazel with Scala dependencies
- Updated AvailableCommandsFactory to use ScalaFactoryAdapter
- Converted test to use Scala types with makeGameState() pattern

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:40:35 -08:00
867b4ac66e Convert AvailableSwearBrotherhoodCommandFactory to Scala types (#5127)
Update the SwearBrotherhood command factory to use Scala GameState and
return Scala AvailableCommand types instead of proto.

Key changes:
- Factory extends ScalaAvailableCommandsFactory
- Uses Scala types: GameState, FactionT, HeroT
- Returns SwearBrotherhoodAvailable (Scala enum case)
- Rewrote test to use Scala types with makeGameState() pattern

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:28:22 -08:00
752f078c74 Convert AvailableStartEpidemicCommandFactory to Scala types (#5126)
* Convert AvailableSuppressBeastsCommandFactory to Scala types

Update the SuppressBeasts command factory to use Scala GameState and
return Scala AvailableCommand types instead of proto.

Key changes:
- Factory extends ScalaAvailableCommandsFactory
- Uses Scala types: GameState, ProvinceT
- Uses ProvinceUtils.hasBeasts instead of LegacyProvinceUtils
- Returns SuppressBeastsAvailable (Scala enum case)

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

* Convert AvailableStartEpidemicCommandFactory to Scala types

Update the StartEpidemic command factory to use Scala GameState and
return Scala AvailableCommand types instead of proto.

Key changes:
- Factory extends ScalaAvailableCommandsFactory
- Uses Scala types: GameState, ProvinceT, Profession
- Uses ProvinceUtils.hasEpidemic instead of LegacyProvinceUtils
- Returns StartEpidemicAvailable (Scala enum case)
- Rewrote test to use Scala types with makeGameState() pattern

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:14:43 -08:00
4348579000 Convert AvailableImproveCommandsFactory to Scala types (#5124)
Update the Improve command factory to use Scala GameState and return
Scala AvailableCommand types instead of proto.

Key changes:
- Factory extends ScalaAvailableCommandsFactory
- Uses Scala types: GameState, ProvinceT, HeroT, Profession
- Returns ImproveAvailable (Scala enum case)
- Added Devastation to ImprovementType enum in command/common
- Added conversion function between ImprovementType types
- Updated AvailableCommandConverter for Devastation handling
- Rewrote test to use Scala types with makeGameState() pattern

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 22:10:03 -08:00
a3dfaf6445 Convert HeroGift factory to Scala types (#5123)
Convert AvailableHeroGiftCommandFactory from proto types to Scala:
- Extends ScalaAvailableCommandsFactory instead of AvailableCommandsFactoryForType
- Uses Scala GameState, ProvinceT, FactionT instead of proto types
- Uses FactionUtils and HeroUtils instead of Legacy versions
- Returns HeroGiftAvailable with EligibleGift instead of proto types
- Update AvailableCommandsFactory to use ScalaFactoryAdapter
- Rewrite test to use Scala types with inside() pattern

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:54:26 -08:00
aadb517771 Convert ExileVassal factory to Scala types (#5122)
Convert AvailableExileVassalCommandFactory from proto types to Scala:
- Extends ScalaAvailableCommandsFactory instead of AvailableCommandsFactoryForType
- Uses Scala GameState, ProvinceT, FactionT instead of proto types
- Uses FactionUtils and ProvinceUtils instead of Legacy versions
- Returns ExileVassalAvailable instead of ExileVassalAvailableCommand proto
- Update AvailableCommandsFactory to use ScalaFactoryAdapter

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:53:49 -08:00
ff74504a26 Convert AvailableControlWeatherCommandsFactory to Scala types (#5121)
- Extend ScalaAvailableCommandsFactory instead of AvailableCommandsFactoryForType
- Use Scala GameState, ProvinceT types
- Use Scala ControlWeatherAvailable, TargetProvinceOptions, ControlWeatherType
- Use ProvinceUtils.hasBlizzard/hasDrought instead of LegacyProvinceUtils
- Use Profession.Mage instead of profession.isMage
- Update test to use HeroC, ProvinceC, Neighbor, makeGameState pattern
- Use inside() pattern for type matching in tests

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 21:53:18 -08:00
1e72e53f0e Convert AvailableApprehendOutlawCommandFactory to Scala types (#5120)
- Extend ScalaAvailableCommandsFactory instead of AvailableCommandsFactoryForType
- Use Scala GameState, ProvinceT, HeroT, UnaffiliatedHeroT types
- Use Scala AvailableCommand.ApprehendOutlawAvailable and ResidentOutlaw
- Update test to use HeroC, ProvinceC, UnaffiliatedHeroC, makeGameState pattern
- Use inside() pattern for type matching in tests
- Update BUILD.bazel deps to use Scala state types

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:45:15 -08:00
66419dee0b Convert AvailableTravelCommandsFactory to Scala types (#5118)
Update the factory to extend ScalaAvailableCommandsFactory and use
Scala GameState and ProvinceUtils instead of proto types.
Return Scala TravelAvailable instead of proto.

Update test to use HeroC, ProvinceC, and Scala GameState.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:44:45 -08:00
0726c41609 Convert AvailableTrainCommandsFactory to Scala types (#5117)
Update the factory to extend ScalaAvailableCommandsFactory and use
Scala GameState, HeroT, and Profession instead of proto types.
Return Scala TrainAvailable instead of proto.

Update test to use HeroC, ProvinceC, BattalionC, and Scala GameState.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:44:17 -08:00
472b33e8de Convert AvailableReconCommandFactory to Scala types (#5115)
- Change AvailableReconCommandFactory to extend ScalaAvailableCommandsFactory
- Use Scala GameState, ProvinceT, HeroT, Profession, IncomingRecon
- Returns Scala AvailableCommand.ReconAvailable instead of proto
- Wrap with ScalaFactoryAdapter in AvailableCommandsFactory
- Update test to use HeroC, ProvinceC, FactionC, and Scala GameState

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:43:55 -08:00
908716c8af Update ScoutAnimator settings in scene for redesigned animation (#5119)
Update serialized fields to match new Scout animation design:
- Add coneRotationOffset (90°)
- Replace scan fields with glow settings (glowSprite, glowColor, etc.)
- Configure extend/hold/fade durations

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 19:28:56 -08:00
1b52c48e5b Convert AvailableSendSuppliesCommandFactory to Scala types (#5116)
Update the factory to extend ScalaAvailableCommandsFactory and use
Scala GameState instead of proto. Return Scala SendSuppliesAvailable
instead of proto.

Update test to use HeroC, ProvinceC, and Scala GameState.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 19:27:54 -08:00
f6e5235382 Convert AvailableAlmsCommandFactory to Scala types (#5114)
- Change AvailableAlmsCommandFactory to extend ScalaAvailableCommandsFactory
- Use Scala GameState, ProvinceT, HeroT, Profession instead of proto types
- Returns Scala AvailableCommand.AlmsAvailable instead of proto
- Wrap with ScalaFactoryAdapter in AvailableCommandsFactory
- Update test to use HeroC, ProvinceC, FactionC, and Scala GameState

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 19:26:35 -08:00
138a8a76cd Convert AvailableReturnCommandsFactory to Scala types (#5113)
Update the factory to extend ScalaAvailableCommandsFactory and use
Scala GameState instead of proto. Return Scala ReturnAvailable
instead of proto.

Update test to use HeroC, ProvinceC, and Scala GameState.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 19:26:02 -08:00
ffd2b63397 Convert AvailableTravelCommandsFactory to Scala types (#5112)
- Change AvailableTravelCommandsFactory to extend ScalaAvailableCommandsFactory
- Use Scala GameState, ProvinceT, and return AvailableCommand.TravelAvailable
- Wrap with ScalaFactoryAdapter in AvailableCommandsFactory
- Update test to use HeroC, ProvinceC, and Scala GameState

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 19:25:29 -08:00
8400b44acd Animation fixes: centering, orientation, and Scout redesign (#5111)
* Adjust animation sprite settings in scene

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

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

* Fix Reduce animation to land in hex center

Use GetCellCenterPosition for the target position so the catapult
projectile lands in the center of the hex, consistent with RaiseDead.

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

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

* Fix hammer orientation in Repair animation

Add baseRotation offset (default 180 degrees) to flip the hammer
so the head strikes the target instead of the handle.

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

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

* Flip hammer horizontally to show striking face

Add flipHorizontal option (default true) to flip the hammer so the
striking face is forward instead of the claw.

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

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

* Separate rotation/flip settings for hammer vs alternate tool

- hammerBaseRotation, hammerFlipHorizontal for repair
- alternateBaseRotation, alternateFlipHorizontal for bridge building
- Allows axe to be oriented differently from hammer

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

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

* Center arrow volley on hex centers

Use GetCellCenterPosition for source and target so arrows start
and end centered on the hex cells, with spread applied around
the center points.

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

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

* Make Extinguish water effect larger and more chaotic

- Increase droplet count (12 → 25) and steam count (5 → 8)
- Increase fall height (60 → 100) and spread (25 → 40)
- Add variable droplet sizes (5-15 scale range)
- Add staggered launch spread for chaotic timing
- Add horizontal chaos movement during fall (sine wave pattern)
- Longer fall duration for more dramatic effect

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

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

* Fix Scout vision cone to extend outward from eye

Position the cone offset from source by half its length so the
base of the triangle stays at the eye while the tip extends
outward toward the target during the sweep.

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

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

* Redesign Scout animation with traveling glow effect

Replace sweep-based cone animation with straight extension:
- Eye appears at source hex with scale-up animation
- Cone extends directly toward target (no sweep rotation)
- Glow effect travels with cone tip, growing from small to 7-hex coverage
- Glow size calculated from hex radius for consistent area coverage
- Hold phase with pulsing glow at target before fade out

Add triangle sprite for vision cone effect.

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

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

* Add cone rotation offset for Scout animation

Triangle sprite with apex pointing up needs -90° offset to point
toward target. Default coneRotationOffset=-90 handles this case.

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

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

* Fix Scout cone scaling axis after rotation

After +90° rotation, the sprite's Y axis is the length direction.
Swap scale values so Y controls length and X controls width.
This keeps the cone base anchored at the eye while extending toward target.

🤖 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-09 18:40:30 -08:00
06db8a9059 Convert AvailableRestCommandsFactory to Scala types (#5110)
- Takes Scala GameState instead of proto
- Returns Scala AvailableCommand.RestAvailable instead of proto
- Wrapped with ScalaFactoryAdapter in AvailableCommandsFactory

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 18:35:53 -08:00
0262aa5a87 Convert AvailableFeastCommandFactory to Scala types (#5109)
First factory to use the new ScalaAvailableCommandsFactory trait:
- Takes Scala GameState instead of proto
- Uses Scala ProvinceT and HeroUtils instead of proto/LegacyHeroUtils
- Returns Scala AvailableCommand.FeastAvailable instead of proto

Wrapped with ScalaFactoryAdapter in AvailableCommandsFactory, demonstrating
the incremental migration pattern where new Scala factories get zero
conversion overhead while legacy factories remain unchanged.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:46:44 -08:00
60e0c8fc96 Add UnifiedCommandFactory adapter pattern for incremental Scala migration (#5108)
Introduces adapter pattern to allow incremental migration of Available*CommandFactory
classes from proto to Scala types:

- ScalaAvailableCommandsFactory: trait for new factories using Scala GameState
- UnifiedCommandFactory: unified interface taking both Scala and Proto GameState
- LegacyFactoryAdapter: wraps existing proto-based factories
- ScalaFactoryAdapter: wraps new Scala factories, converts output to proto

AvailableCommandsFactory now takes Scala GameState as input and converts to proto
internally, passing both states to factories. All existing factories wrapped with
LegacyFactoryAdapter. New Scala factories can be added using ScalaFactoryAdapter
with zero conversion overhead.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 16:30:49 -08:00
d5a836db79 Implement footprint trail animation for unit movement (#5107)
* Implement footprint trail animation for unit movement

Replace single-sprite slide animation with footprint/hoofprint trail:
- Boot prints for infantry (alternating left/right with flip)
- Horseshoe prints for cavalry
- Prints appear sequentially along path
- FIFO fade out (first prints fade first)
- Configurable print count, timing, colors, and scale

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

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

* Test both boot and horseshoe animations in TestMove

Shows infantry boot prints first, waits 1.5s, then shows cavalry
horseshoe prints so both can be seen in sequence.

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

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

* Add missing System.Collections using for IEnumerator

* Improve footprint animation: smaller prints, more prints, lateral offset for hooves

- Reduce print scale from 3.0 to 1.5
- Increase prints per hex from 3 to 5
- Faster print interval (0.06s vs 0.08s)
- Add lateral offset to horse prints (front/rear hoof distinction)
- Make lateral offset configurable

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

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

* Increase lateral offset from 3 to 6 for more visible zigzag

* Add footprint sprites and wire up MoveAnimator in scene

Add sprites:
- leather_boot.png, armored_boot.png (infantry prints)
- light_horse.png, heavy_horse.png (cavalry prints)

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

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

* Update MoveAnimator settings in scene

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 15:43:59 -08:00
20f3d14721 Add SelectedCommandConverter for Proto-to-Scala conversion (#5106)
Creates SelectedCommandConverter.fromProto() to convert proto
SelectedCommand messages to their Scala equivalents. This is the
inverse of AvailableCommandConverter which converts Scala to proto.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:16:51 -08:00
4d7824656b Add AvailableCommandConverter for Scala-to-Proto conversion (#5104)
* Add AvailableCommandConverter for Scala-to-Proto conversion

Implements toProto conversion for AvailableCommand types, enabling
conversion from the Scala domain types to proto format. Key changes:

- Add AvailableCommandConverter with toProto method taking GameState context
- Fix SelectedCommand.scala enum types to match proto structure:
  - AttackDecisionType: Advance/Withdraw/DemandTribute/SafePassage
  - ControlWeatherType: StartBlizzard/EndBlizzard/StartDrought/EndDrought
  - ImprovementType: Agriculture/Economy/Infrastructure
  - ProvinceOrderType: Develop/Mobilize/Expand/Entrust
- Handle ScalaPB oneof patterns (use case classes directly, not SealedValue)
- Look up full data from GameState for simplified Scala types
- Add visibility for legacy_battalion_view_filter and hero_view_filter

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

* Add tests for AvailableCommandConverter

Comprehensive unit tests covering:
- Simple commands (AlmsAvailable, FeastAvailable, RestAvailable, etc.)
- Commands with enum conversions (ControlWeatherType, ImprovementType, ProvinceOrderType)
- Commands that expand multiple proto options (DiplomacyAvailable)
- Commands that require GameState lookups (ApprehendOutlawAvailable, DefendAvailable)
- Error cases for unsupported conversions (DemandTribute, SafePassage, Ransom)
- Complex nested structures (MarchAvailable, OrganizeTroopsAvailable)

Also adds test visibility to command/available BUILD.bazel.

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

* Refactor tests to use inside() pattern instead of asInstanceOf

Replace shouldBe a[] and asInstanceOf with ScalaTest's inside()
pattern for better error messages and idiomatic type matching.

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

* Add type annotations to pattern match destructuring

Adds explicit type annotations to all destructured case class parameters
in pattern matches for improved readability and type safety.

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

* Add data to AttackDecisionType and DiplomacyOptionType for full conversion

Changes AttackDecisionType and DiplomacyOptionType from simple enums to
sealed traits with case classes that carry the data needed for proper
proto conversion:

- AttackDecisionType.DemandTribute now takes gold and food amounts
- AttackDecisionType.SafePassage now takes destination province ID
- DiplomacyOptionType.Ransom now takes RansomOfferDetails

Also adds supporting case classes for ransom data:
- RansomOfferDetails
- PrisonerToBeRansomed
- PrisonerOfferedInExchange
- HostageOfferedInExchange

This removes the UnsupportedOperationException cases in the converter.

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

* Update converter to import from common package

- Changed imports from selected to common package for shared types
- Updated BUILD.bazel deps from selected to common

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:57:13 -08:00
5b18e275af Extract shared command types to common package (#5105)
Moves shared enums and supporting types from SelectedCommand to a new
common package, breaking the dependency between AvailableCommand and
SelectedCommand.

Types moved to common:
- AttackDecisionType (with DemandTribute(gold, food) and SafePassage(provinceId))
- ControlWeatherType
- CapturedHeroOption
- DiplomacyOptionType (with Ransom(details))
- ImprovementType
- ProvinceOrderType
- PrisonerManagementOption
- RansomOfferDetails and related case classes

Uses Scala 3 enum syntax with parameterized cases where data is needed.
Enum values now match the proto definitions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 12:40:12 -08:00
ca39d16710 Reject Eagle connections from users without display name (#5103)
Users who haven't set a display name should not be able to interact
with the Eagle game server. This adds validation in AuthorizationInterceptor
to reject JWT-authenticated requests where displayName is null or empty.

Returns FAILED_PRECONDITION with message:
"Display name not set. Please set a display name before playing."

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 10:48:07 -08:00
6e47e836c2 Show display name panel if user has empty display name (#5102)
If a user completes OAuth but closes the app before setting their
display name, subsequent session restores or stored account connections
would bypass the display name panel. Now we check for empty display
names after validating the session and show the display name panel
if needed.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 09:31:44 -08:00
75668cdc58 Add Scala SelectedCommand and AvailableCommand enum types (#5100)
Creates Scala 3 types for command deproto work:
- SelectedCommand enum with parameterized cases for all selected command variants
- AvailableCommand enum with parameterized cases for all available command variants
- Supporting case classes in AvailableCommand companion object to avoid namespace confusion
- Supporting enums shared between both (AttackDecisionType, ControlWeatherType, etc.)
  defined in SelectedCommand and imported by AvailableCommand

These types mirror the proto structure but use native Scala types.
Converters will be added in a follow-up PR.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 09:15:12 -08:00
fad349bf35 Add environment dropdown to lobby for seamless switching (#5098)
* Add environment dropdown to lobby for seamless switching

Replace static lobbyEnvironmentText with lobbyEnvironmentDropdown.
Users can now switch between prod/qa environments while in the lobby
without logging out - the connection is automatically re-established.

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

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

* Fix lobby environment dropdown initialization

- Move dropdown setup to SetupLobbyUI() so it's ready at start
- Clear default Unity options before adding environment options
- Set initial value from PlayerPrefs
- UpdateLobbyStatusDisplays now only updates the selection

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

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

* Wire up lobby environment dropdown in Unity scene

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

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

* Remove environment dropdown from connection panel

Use PlayerPrefs (set by lobby dropdown) for environment selection.
The lobby dropdown now handles all environment switching, so the
connection panel dropdown is no longer needed.

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

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

* Remove environment dropdown reference from ConnectionHandler

Wire up the lobby environment dropdown and remove the old connection
panel dropdown reference in Unity.

🤖 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-09 09:13:24 -08:00
956728670a Improve exception logging and Sentry reporting (#5101)
- Add global uncaught exception handler in Main.scala to catch and log
  exceptions from any thread, plus report to Sentry
- Add logging and Sentry reporting to LlmResolver.scala recover block
  so LLM processing failures are visible in logs
- Fix exception swallowing in GamesManager.scala game loading - was using
  Try().toOption which silently dropped exceptions. Now logs and reports
  to Sentry before returning None

This ensures exceptions during startup, game loading, and LLM processing
are properly logged to stdout and sent to Sentry for alerting.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 08:57:33 -08:00
d6bb7a23f2 Fix type mismatch in UpgradeBattalionQuest causing NoSuchElementException (#5099)
* Fix type mismatch in UpgradeBattalionQuest causing NoSuchElementException

The UpgradeBattalionQuest pattern match was extracting battalionTypeId as
the proto enum type (net.eagle0.eagle.common.battalion_type.BattalionTypeId)
but comparing it against gameState.battalionTypes which uses the Scala
sealed class type (net.eagle0.eagle.model.state.BattalionTypeId).

This type mismatch caused the .find() to always return None, leading to
NoSuchElementException on .get when generating LLM prompts for quest
completion/failure.

Fix: Convert proto BattalionTypeId to Scala type using BattalionTypeIdConverter
before comparing.

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

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

* Convert BattalionTypeId to Scala 3 enum with CanEqual

Modernize BattalionTypeId to use Scala 3 enum syntax and add a CanEqual
instance. This enables type-safe equality checking when files opt in with
`import scala.language.strictEquality`, which would catch proto/model type
mismatches at compile time.

🤖 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-09 08:33:38 -08:00
241426c0fa Multi-account OAuth token persistence (#5096)
* Multi-account OAuth token persistence

- TokenStorage now stores multiple accounts keyed by provider:userId
- Tokens are preserved on logout for quick re-login
- ConnectionHandler displays stored account buttons
- Clicking a stored account button connects (refreshing token if needed)
- Removed legacy/classic auth toggle - OAuth only
- Legacy single-account data is automatically migrated

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

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

* Add provider icon support to stored account buttons

- Added discordProviderIcon and googleProviderIcon sprite references
- Button prefab should have an Image child for the provider icon
- Icon is set based on account.Provider when creating buttons

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

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

* Fix provider icon to look for child named ProviderIcon

GetComponentInChildren<Image>() was finding the Button's own Image.
Now uses transform.Find("ProviderIcon") to find the specific child.

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

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

* Add Unity assets for stored account buttons

- Add Discord Blurple symbol sprite for provider icons
- Add StoredAccountButton prefab with ProviderIcon child
- Wire up stored accounts container and prefab in Gameplay scene
- Update OAuth button sprite import settings

🤖 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-09 07:35:35 -08:00
69923e12b9 Remove classic login flow - require OAuth authentication (#5095)
Remove Basic Auth (username in header) support from the Eagle server.
Users must now authenticate via OAuth (Discord or Google) to play.

Changes:
- Remove parseBasicAuth method from AuthorizationInterceptor
- Remove Basic Auth fallback in interceptCall (now goes straight to unauthenticated)
- Remove contextWithUserName helper from AuthorizationUtils
- Update documentation and comments to reflect JWT-only auth

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 07:33:43 -08:00
3f921c663f Update deproto plan: add DiplomacyOfferStatus and next candidates (#5097)
- Document completed DiplomacyOfferStatus enum migration (PR #5093)
- Add Enum Type Migrations section tracking proto enum conversions
- Add Next Candidates section with priority items for future work

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 06:31:05 -08:00
adminandGitHub d49b402988 Remove classic login mode and add a setting for animation testing (#5094)
* remove classic login and add a show animation tests toggle

* fix
2026-01-09 05:31:48 -08:00
898af3b858 Fix Hetzner SSH deployment (#5092)
* Fix Hetzner SSH: remove invalid protocol param, add explicit port

The appleboy/ssh-action doesn't support the 'protocol' parameter.
Adding explicit port: 22 helps with IPv6 address parsing.

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

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

* Use self-hosted runner for Hetzner deploy (IPv6 connectivity)

* Use native SSH instead of container action for macOS runner

* Fix: Stop containers by port/name filter before starting new one

* Set SHARDOK_RESOURCES_PATH and SHARDOK_MAPS_PATH env vars for Docker

* Set SHARDOK_EAGLE_INTERFACE_ADDRESS=0.0.0.0:40042 to listen on all interfaces

* Mount TLS certs and config file into Shardok container

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 22:18:02 -08:00
043302ed6a Deproto: Use Scala Status in EligibleDiplomacyStatuses (#5093)
Convert EligibleDiplomacyStatuses to use Scala Status types internally,
with conversion to proto at the call sites where needed for building
AvailableCommand proto messages.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:42:27 -08:00
c010206094 Add animations for all command types to hide latency (#5091)
* Add animations for all command types to hide latency

Adds 12 new animator classes to provide visual feedback for all
command types, reducing perceived latency when issuing commands:

- ChargeAnimator: Cavalry charge with lance sprites
- ToolAnimator: Hammer strikes for Repair and BuildBridge
- FearAnimator: Dark wave effect from source to target
- ControlAnimator: Mind control beam with spiraling particles
- FireEffectAnimator: Rising flames for StartFire and FireDamage
- ExtinguishAnimator: Water spray with steam for fire extinguishing
- FreezeAnimator: Ice crystal formation for FreezeWater
- WaterEffectAnimator: Splash and ripples for BraveWater/WaterDamage
- ScoutAnimator: Scanning eye with vision cone sweep
- DismissAnimator: Dissolve particles drifting away
- FleeAnimator: Motion blur trail and dust clouds
- DuelAnimator: Crossed swords clashing with sparks

All animators auto-discover HexGrid and use GetCellCenterPosition
for proper hex centering. Integrated into ShardokGameController
with switch cases in PlayAnimationAndSound and PlayAnimation.

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

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

* Update ChargeAnimator to support all battalion types

- Add separate sprite fields for each battalion type:
  - swordSprite for light infantry
  - maceSprite for heavy infantry
  - smallSpearSprite for light cavalry
  - largeLanceSprite for heavy cavalry
  - daggerSprite for longbowmen
  - boneSprite for undead
- Rename internal types from Lance* to Weapon* for clarity
- Keep charge-specific animation behavior (more dramatic motion)

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

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

* Update LightningAnimator with multiple bolts and hex centering

- Use GetCellCenterPosition for proper hex center alignment
- Add boltCount field to control number of lightning bolts
- All bolts start from same source hex center
- Each bolt ends at a random point within the target hex
- Add targetSpread field to control endpoint spread within target hex
- Reduce default thicknessMultiplier to 1f for thinner bolts

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

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

* Add test methods for all new animators to AnimationTestController

Adds test buttons for Charge, Repair, BuildBridge, Fear, Control,
Fire, Extinguish, Freeze, WaterSplash, Scout, Dismiss, Flee, and Duel
animations. Updates TestAllSequence to include all new animations.

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

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

* Fix test method calls to match animator signatures

- AnimateFire instead of AnimateStartFire
- AnimateScout takes source and target cell indices

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

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

* Wire up all animator components in Unity scene

Connects all new animator references in the Gameplay scene so
test buttons can trigger animations.

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

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

* Unity scene adjustments

🤖 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-08 21:30:04 -08:00
ecbca95051 Add CD step to deploy Shardok ARM64 to Hetzner (#5090)
* Add CD step to deploy Shardok ARM64 to Hetzner

After building and pushing the ARM64 image, automatically deploy it to
the Hetzner server by SSHing in, pulling the new image, and restarting
the container.

Requires secrets: HETZNER_IP, HETZNER_SSH_KEY

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

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

* Use tcp6 protocol for IPv6 Hetzner connection

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:53:24 -08:00
ac92f4c112 Use STANDARD scoring calculator instead of MCTS_OPTIMIZED (#5089)
Switch back to the standard AI scoring calculator for iterative deepening.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:38:52 -08:00
0a8f43f548 Fix SetUserAdmin to parse multipart form data (#5088)
The SetUserAdmin endpoint was using ParseForm() which doesn't handle
multipart/form-data (what JavaScript FormData sends). This caused the
is_admin field to be empty, defaulting to false even when checked.

Applied the same fix as SetDisplayName - try ParseMultipartForm first,
fall back to ParseForm for compatibility.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:30:02 -08:00
3722c120a7 Remove unused MANAGE_PRISONER command type from Shardok (#5087)
This command type was only used by Eagle (which uses oneof, not this enum)
and had dead code in AIHeuristicWeighting.cpp.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:26:49 -08:00
841ebd8ae4 Add test for applyResolvedBattle in ActionResultApplierImpl (#5086)
* Add test for applyResolvedBattle in ActionResultApplierImpl

This adds test coverage for the resolvedBattle functionality that was
fixed in #5075. The test verifies that when an ActionResult contains a
resolvedBattle field, the corresponding battle is removed from the
game state's outstandingBattles vector.

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

* Remove dead applyAccumulatedDetails and add notification tests

- Remove unused applyAccumulatedDetails from GameStateMiscExtensions
  (replaced by applyNewNotifications which correctly filters by .deferred)
- Add tests for notification filtering behavior:
  - Deferred notifications are added to deferredNotifications
  - Non-deferred notifications are NOT added to deferredNotifications
  - Mixed notifications are correctly filtered

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:26:19 -08:00
be83fc882e Add Holy Wave animation and fix animator hex centering (#5081)
* Add Holy Wave animation for InspiredTroops action

Implements an expanding white glow animation that spreads from the
acting unit's hex outward. When the wave reaches hexes containing
undead units, it triggers a violent damage effect with flashing
and burst animations.

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

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

* Fix hex distance calculation to use Row/Column coordinates

The Coords struct uses Row and Column properties, not Q and R.
Added HexDistance helper that converts offset coordinates to cube
coordinates for accurate hex distance calculation.

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

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

* Make HexGrid auto-discovered in animators, fix HolyWave bugs

- Changed all 8 animators to auto-find HexGrid at runtime instead of
  requiring manual inspector hookup
- Fixed MissingReferenceException in HolyWaveAnimator by having damage
  effects manage their own cleanup lifecycle
- Added glowVerticalOffset parameter to adjust holy wave positioning

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

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

* Use true hex center and scale glow based on hex size

- Added GetCellCenterPosition() to HexGrid for true hex center
  (GetCellLocalPosition returns terrain image position which is offset)
- Added GetHexInnerRadius() to HexGrid for hex size reference
- HolyWaveAnimator now uses true hex center for positioning
- Glow scale now computed from hex radius (glowEndRadii=2.5 means
  the glow extends 2.5 hex radii from center, into neighbors)

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

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

* Fix naming convention for hexGrid field, add gradient circle sprite

Renamed _hexGrid to __hexGrid per linter naming rules.
Added Gradient Circle sprite for holy wave animation.

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

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

* Center Meteor explosion and RaiseDead glow on hex

Use GetCellCenterPosition instead of GetCellLocalPosition for
proper hex centering.

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

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

* Update Unity scene with HolyWaveAnimator configuration

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

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

* Fix: Remove non-existent DuelChallenged and DuelDeclined ActionTypes

Only ActionType.DuelAccepted exists in the proto definition.

🤖 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-08 20:22:05 -08:00
425fa082ab Add debug logging for admin console edit form issue (#5085)
The admin console edit form is sending empty values even when the user
enters data. This adds debugging to identify the root cause:

- Add type="button" to modal buttons to prevent default submit behavior
- Add console.log in JavaScript to show what values are being read
- Add server-side logging to show Content-Type and parsed form values

This is temporary debugging to diagnose why displayName="" and
isAdmin=false are being received when the user enters values.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:11:41 -08:00
268f28e755 Remove unused DUEL_CHALLENGED action type (#5084)
The DUEL_CHALLENGED action type was defined but never used in any code.
The ChallengeDuelCommand implementation skips directly to emitting
DUEL_ACCEPTED or DUEL_DECLINED without an intermediate challenge step.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:10:09 -08:00
562 changed files with 36777 additions and 66277 deletions
+26 -24
View File
@@ -105,10 +105,23 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy update-env script to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "deploy/update-env.sh,deploy/env.template"
target: /opt/eagle0/
strip_components: 1
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
with:
@@ -116,34 +129,23 @@ 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
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
script: |
set -x
cd /opt/eagle0
# Update AUTH_IMAGE in .env file (preserve other vars)
if [ -f .env ]; then
# Remove old AUTH_IMAGE line and add new one
grep -v '^AUTH_IMAGE=' .env > .env.tmp || true
echo "AUTH_IMAGE=${AUTH_IMAGE}" >> .env.tmp
# Also update OAuth credentials
grep -v '^DISCORD_CLIENT_ID=' .env.tmp > .env.tmp2 || true
echo "DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}" >> .env.tmp2
grep -v '^DISCORD_CLIENT_SECRET=' .env.tmp2 > .env.tmp3 || true
echo "DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}" >> .env.tmp3
grep -v '^GOOGLE_CLIENT_ID=' .env.tmp3 > .env.tmp4 || true
echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}" >> .env.tmp4
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
# Update JWT private key (JWK format for auth service bootstrap)
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env || true
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5
chmod 600 .env
else
echo "ERROR: .env file not found. Run main deploy first."
exit 1
fi
# 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}"
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
+8 -8
View File
@@ -53,26 +53,26 @@ jobs:
run: |
# Upload sysroot tarball to DO Spaces
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== AMD64 Sysroot uploaded ==="
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
build-sysroot-arm64:
@@ -114,24 +114,24 @@ jobs:
run: |
# Upload sysroot tarball to DO Spaces
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.tar.xz \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== ARM64 Sysroot uploaded ==="
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot_arm64\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
echo ")"
+220 -446
View File
@@ -26,196 +26,71 @@ permissions:
contents: read
jobs:
build-eagle:
# Single consolidated build job - builds all images with one bazel invocation
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
build-all:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
shardok_image_tag: ${{ steps.push-images.outputs.shardok_image_tag }}
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Eagle Docker image
id: build-eagle
run: |
set -ex
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Eagle image to DO registry
id: push-eagle
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
- name: Build all Docker images
id: build-all
run: |
set -ex
# Use cross-compiled image path from build step
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
echo "Using Eagle image: $EAGLE_IMAGE"
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
exit 1
fi
# Debug: show OCI layout contents
echo "=== OCI Layout Contents ==="
cat "$EAGLE_IMAGE/index.json"
echo ""
echo "=== Blobs ==="
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
# Verify OCI layout consistency before pushing
echo "=== Verifying OCI layout consistency ==="
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
if [ ! -f "$blob_path" ]; then
echo "ERROR: Blob not found: $blob_path"
exit 1
fi
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
if [ "$digest" != "$actual_digest" ]; then
echo "ERROR: Digest mismatch for $blob_path"
echo " Index says: $digest"
echo " Actual: $actual_digest"
exit 1
fi
echo "✓ Verified: $digest"
done
# Build the push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Use crane directly for push (avoids OCI->Docker digest mismatch)
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing eagle image: $IMAGE_TAG"
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
# Verify push by checking what's in the registry
echo "=== Verifying push ==="
$CRANE manifest "$IMAGE_TAG" | head -50
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
echo "Registry reports digest: $PUSHED_DIGEST"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
build-shardok:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok binary (cross-compile for Linux)
run: |
set -ex
# Step 1: Build JUST the binary with cross-compilation
# We need --extra_toolchains to force the Linux toolchain to be used
# because toolchains_llvm registers with dev_dependency=True
echo "=== Building shardok-server binary for linux-x86_64 ==="
# Build ALL images in a single bazel command - Bazel parallelizes internally
# This is much more efficient than 4 separate GitHub Actions jobs
echo "=== Building all Docker images ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
//ci:eagle_server_image \
//ci:shardok_server_image \
//ci:admin_server_image \
//ci:jfr_sidecar_image \
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Step 2: Check the binary directly from bazel-bin
# bazel-bin is a symlink that points to the correct output directory
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
echo "=== Checking binary at: $LINUX_BIN ==="
# Copy warmup binary to scripts/ for deployment
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 1
fi
# Save all image paths before any other bazel command changes bazel-bin symlink
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
SHARDOK_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
ADMIN_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
JFR_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
# Debug: show what bazel-bin points to
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
echo "shardok_path=$SHARDOK_PATH" >> $GITHUB_OUTPUT
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
echo "=== Verifying binary format ==="
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
echo "Binary magic bytes: $MAGIC"
echo "=== Image paths ==="
echo "Eagle: $EAGLE_PATH"
echo "Shardok: $SHARDOK_PATH"
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
echo ""
echo "Debug info:"
echo "- bazel-bin points to: $(readlink bazel-bin)"
file "$LINUX_BIN" || true
exit 1
else
echo "WARNING: Unknown binary format: $MAGIC"
file "$LINUX_BIN" || true
fi
- name: Build Shardok Docker image
id: build-shardok
run: |
set -ex
# Build the OCI image with cross-compilation flags
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:shardok_server_image
# The image is output to bazel-bin which is a symlink.
# Resolve it now before any other bazel commands change where it points.
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Verify the binary inside the tar layer is ELF
echo "=== Verifying binary in image tar ==="
# Verify Shardok binary is ELF (Linux) format
echo "=== Verifying Shardok binary format ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
if [ -f "$BINARY_TAR" ]; then
echo "Checking binary in $BINARY_TAR"
# Extract just the first 4 bytes of the binary from the tar
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
echo "Binary magic in tar: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary in tar is ELF format (Linux)"
echo "SUCCESS: Binary is ELF format (Linux)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
echo "This means pkg_tar is packaging the wrong binary."
echo "ERROR: Binary is NOT ELF format!"
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- name: Login to DigitalOcean Container Registry
@@ -226,210 +101,79 @@ jobs:
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Shardok image to DO registry
id: push-shardok
- name: Push all images to DO registry
id: push-images
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
echo "Using cross-compiled image: $CROSS_IMAGE"
GIT_SHA=$(git rev-parse --short=8 HEAD)
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
exit 1
fi
# Get crane from Eagle push target (which doesn't need cross-compilation)
# This gives us a macOS crane binary we can actually run.
# We can't build shardok_server_push with platform flags because it would
# download a Linux crane that can't run on macOS.
# Get crane from push target runfiles
bazel build //ci:eagle_server_push
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
# Find the Darwin crane binary (may be a symlink)
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
# Fallback to any crane
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
if [ ! -e "$CRANE" ]; then
# Fallback: find any Darwin crane
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found. Listing runfiles:"
find "$RUNFILES" -name crane 2>/dev/null || true
echo "ERROR: crane not found"
exit 1
fi
echo "Using crane: $CRANE"
# Push the cross-compiled image with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing shardok image: $IMAGE_TAG"
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
# Push Eagle image
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing Eagle: $EAGLE_TAG"
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG"
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Push Shardok image
SHARDOK_IMAGE="${{ steps.build-all.outputs.shardok_path }}"
SHARDOK_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing Shardok: $SHARDOK_TAG"
$CRANE push "$SHARDOK_IMAGE" "$SHARDOK_TAG"
$CRANE copy "$SHARDOK_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
echo "shardok_image_tag=$SHARDOK_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
# Push Admin image
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing Admin: $ADMIN_TAG"
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG"
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
build-admin:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-admin.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
# Push JFR Sidecar image
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR Sidecar: $JFR_TAG"
$CRANE push "$JFR_IMAGE" "$JFR_TAG"
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
- name: Build Admin Server Docker image
id: build-admin
run: |
set -ex
# Build admin server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:admin_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Admin image to DO registry
id: push-admin
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
echo "Using Admin image: $ADMIN_IMAGE"
if [ -z "$ADMIN_IMAGE" ] || [ ! -d "$ADMIN_IMAGE" ]; then
echo "ERROR: Admin image not found at: $ADMIN_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:admin_server_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_admin_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing admin image: $IMAGE_TAG"
$CRANE push "$ADMIN_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
build-jfr-sidecar:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-jfr-sidecar.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build JFR Sidecar Docker image
id: build-jfr-sidecar
run: |
set -ex
# Build JFR sidecar image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:jfr_sidecar_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push JFR Sidecar image to DO registry
id: push-jfr-sidecar
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
JFR_IMAGE="${{ steps.build-jfr-sidecar.outputs.image_path }}"
echo "Using JFR Sidecar image: $JFR_IMAGE"
if [ -z "$JFR_IMAGE" ] || [ ! -d "$JFR_IMAGE" ]; then
echo "ERROR: JFR Sidecar image not found at: $JFR_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:jfr_sidecar_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_jfr_sidecar_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR sidecar image: $IMAGE_TAG"
$CRANE push "$JFR_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
echo "=== All images pushed successfully ==="
deploy:
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
runs-on: self-hosted
needs: [build-all]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
JFR_SIDECAR_IMAGE: ${{ needs.build-jfr-sidecar.outputs.image_tag }}
EAGLE_IMAGE: ${{ needs.build-all.outputs.eagle_image_tag }}
SHARDOK_IMAGE: ${{ needs.build-all.outputs.shardok_image_tag }}
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
@@ -443,135 +187,165 @@ jobs:
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Build warmup tool
run: |
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
- name: Copy config files to droplet
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "docker-compose.prod.yml,nginx/nginx.conf"
target: "/opt/eagle0"
run: |
# 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
rm -f /opt/eagle0/scripts/bin/warmup
SETUP_DIRS
# Copy files
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh 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
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,SHARDOK_ADDRESS,SHARDOK_AUTH_TOKEN,SENTRY_DSN
script: |
set -x
cd /opt/eagle0
run: |
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
set -ex
cd /opt/eagle0
# Check Docker has IPv6 support for connecting to Hetzner Shardok
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
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."
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
fi
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
SHARDOK_IMAGE="${SHARDOK_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}"
# Write env vars to .env file for docker-compose
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
EXISTING_AUTH_IMAGE=""
if [ -f .env ]; then
EXISTING_AUTH_IMAGE=$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
fi
# 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
rm -f .env 2>/dev/null || true
cat > .env << EOF
EAGLE_IMAGE=${EAGLE_IMAGE}
SHARDOK_IMAGE=${SHARDOK_IMAGE}
ADMIN_IMAGE=${ADMIN_IMAGE}
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
AUTH_IMAGE=${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
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:-}
EOF
chmod 600 .env
# Update env vars
chmod +x deploy/update-env.sh
cd deploy && ./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"SHARDOK_IMAGE=\${SHARDOK_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 "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
# Use exact image tags passed from build jobs (no :latest fallback)
# Note: AUTH_IMAGE is managed separately by auth_build.yml
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
echo "Using images: \$EAGLE_IMAGE, \$SHARDOK_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
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
# Install crane for pulling OCI images
echo "Installing crane..."
rm -f 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
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
# Pull and load all images
echo "Pulling Eagle image..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Shardok image..."
./crane pull "\${SHARDOK_IMAGE}" shardok.tar && docker load -i shardok.tar && rm shardok.tar
echo "Pulling Admin image with crane..."
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
echo "Pulling Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
echo "Pulling JFR Sidecar image with crane..."
./crane pull "${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
echo "Loading JFR Sidecar image into Docker..."
docker load -i jfr-sidecar.tar
rm jfr-sidecar.tar
echo "Pulling JFR Sidecar image..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
rm ./crane
# Pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
echo "All images pulled successfully"
# Recreate non-Eagle services
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok admin
# Recreate only the services we're updating (not auth - managed by auth_build.yml)
# This prevents restarting auth service during every Eagle deploy
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle shardok admin jfr-sidecar
# Deploy Eagle with blue-green
if [ -x "/opt/eagle0/scripts/deploy-blue-green.sh" ]; then
echo "Using blue-green deployment for Eagle..."
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}" || {
echo "Blue-green failed, falling back to direct restart"
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
}
else
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
fi
# Ensure auth is running (but don't force-recreate it)
docker compose -f docker-compose.prod.yml up -d auth
# Note: jfr-sidecar is started by deploy-blue-green.sh (either jfr-sidecar or jfr-sidecar-green
# depending on which eagle instance is active)
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
# Ensure auth is running
docker compose -f docker-compose.prod.yml up -d auth
# Cleanup orphaned containers
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Restart nginx
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Verify
sleep 10
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml images
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Cleanup old images
docker image prune -f
# Cleanup
docker container prune -f
docker image prune -f
DEPLOY_SCRIPT
+34 -5
View File
@@ -10,6 +10,7 @@ on:
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
workflow_dispatch:
permissions:
contents: read
@@ -29,6 +30,19 @@ jobs:
with:
dotnet-version: '8.0.x'
- name: Inject manifest public key
env:
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
run: |
if [ -n "$MANIFEST_PUBLIC_KEY" ]; then
CONFIG_FILE="src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/configuration.txt"
echo "manifest_public_key = $MANIFEST_PUBLIC_KEY" >> "$CONFIG_FILE"
echo "Injected manifest public key into configuration.txt"
cat "$CONFIG_FILE"
else
echo "MANIFEST_PUBLIC_KEY not set, skipping injection"
fi
- name: Restore dependencies
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
@@ -68,15 +82,30 @@ jobs:
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
# Create installer manifest content
# Create installer manifest content
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
echo "=== Installer manifest content ==="
cat /tmp/installer_manifest.txt
echo "=================================="
# Update the unified manifest
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
# Write signing key to temp file (if available)
SIGNING_ARGS=""
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
chmod 600 /tmp/manifest_signing_key
SIGNING_ARGS="/tmp/manifest_signing_key"
echo "Manifest signing key available"
else
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
+154
View File
@@ -0,0 +1,154 @@
name: Mac Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_protos.sh"
- "scripts/build_mac_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_mac_app.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
pull_request:
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_mac_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_mac_app.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
workflow_dispatch:
inputs:
skip_signing:
description: 'Skip code signing, notarization, and deploy (build only)'
required: false
default: 'false'
type: boolean
permissions:
contents: read
jobs:
mac-unity:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: true
clean: false
fetch-depth: 0 # For version numbering from git history
- name: Pull LFS files
run: git lfs pull
- name: Restore Library/
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/
run: ./ci/github_actions/persist_library.sh
- name: Inject Sparkle Framework
if: success()
env:
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
run: |
chmod +x ./scripts/inject_sparkle.sh
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Import Code Signing Certificate
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
run: |
# Generate random keychain password (only used within this workflow run)
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
# Decode certificate
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain build.keychain 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import certificate
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
# Allow codesign to access keychain
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Clean up
rm certificate.p12
- name: Code Sign App
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
chmod +x ./scripts/codesign_mac_app.sh
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
- name: Notarize App
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_mac_app.sh
./scripts/notarize_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Deploy Mac Build
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
run: |
# Write private key to temp file for signing
SPARKLE_PRIVATE_KEY_PATH="/tmp/sparkle_private_key"
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
VERSION=$(git describe --tags --always)
BUILD_NUMBER=$(git rev-list --count HEAD)
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
"/tmp/eagle0/eagle0MAC/eagle0.app" \
"$VERSION" \
"$BUILD_NUMBER" \
"$SPARKLE_PRIVATE_KEY_PATH"
rm "$SPARKLE_PRIVATE_KEY_PATH"
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
-29
View File
@@ -1,29 +0,0 @@
name: Mac History Editor Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
pull_request:
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
permissions:
contents: read
jobs:
mac-history-build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Build the mac history
run: ./ci/github_actions/build_mac_history.sh
+6 -4
View File
@@ -40,13 +40,15 @@ jobs:
echo ""
# List of repositories to clean
REPOS=$(doctl registry repository list-v2 --format Name --no-header)
# Use tail to skip header row in case --no-header doesn't work
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
for REPO in $REPOS; do
echo "=== Processing repository: ${REPO} ==="
# Get all manifests with their tags and dates
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null || echo "")
# 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 "")
if [ -z "$MANIFESTS" ]; then
echo " No manifests found"
@@ -54,8 +56,8 @@ jobs:
fi
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
# Skip if no digest
if [ -z "$DIGEST" ]; then
# 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
+62
View File
@@ -157,3 +157,65 @@ jobs:
echo "=== Push complete ==="
echo "Image: $IMAGE_TAG"
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
deploy-hetzner:
runs-on: self-hosted
needs: [build-shardok-arm64]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
SHARDOK_IMAGE: ${{ needs.build-shardok-arm64.outputs.image_tag }}
steps:
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.HETZNER_SSH_KEY }}" > ~/.ssh/hetzner_deploy
chmod 600 ~/.ssh/hetzner_deploy
# Add host key to known_hosts to avoid prompt
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Deploy to Hetzner
run: |
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
set -ex
cd /opt/eagle0
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Pull the new image
docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Stop and remove any container using port 40042 or named shardok*
docker ps -q --filter "publish=40042" | xargs -r docker stop
docker ps -aq --filter "name=shardok" | xargs -r docker rm -f
docker ps -aq --filter "publish=40042" | xargs -r docker rm -f
# Run new container
docker run -d \
--name shardok-ai \
--restart unless-stopped \
-p 40042:40042 \
-v /opt/eagle0/data:/data \
-v /etc/shardok:/etc/shardok:ro \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
-e SHARDOK_RESOURCES_PATH=/app/resources \
-e SHARDOK_MAPS_PATH=/app/resources/maps \
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Wait and verify
sleep 5
docker ps | grep shardok-ai
# Cleanup old images
docker image prune -f
echo "=== Hetzner deployment complete ==="
ENDSSH
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/hetzner_deploy
+19 -1
View File
@@ -16,6 +16,7 @@ on:
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/unity_build.yml"
@@ -63,7 +64,24 @@ jobs:
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
# Write signing key to temp file (if available)
SIGNING_ARGS=""
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
chmod 600 /tmp/manifest_signing_key
SIGNING_ARGS="/tmp/manifest_signing_key"
echo "Manifest signing key available"
else
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
+1
View File
@@ -38,3 +38,4 @@ scripts/refresh_name_layers/refresh_name_layers.zip
api_keys.txt
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
node_modules/
+18
View File
@@ -1,5 +1,23 @@
# CLAUDE.md
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
**ALWAYS use this workflow:**
1. Create a feature branch from origin/main
2. Commit to that branch
3. Create a PR with `gh pr create`
4. Wait for user to merge (DO NOT run `gh pr merge`)
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
---
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
+22 -15
View File
@@ -13,8 +13,8 @@ AWS_SDK_VERSION = "2.28.1"
# Core Build Tools
#
bazel_dep(name = "bazel_skylib", version = "1.8.1")
bazel_dep(name = "rules_pkg", version = "1.1.0")
bazel_dep(name = "bazel_skylib", version = "1.9.0")
bazel_dep(name = "rules_pkg", version = "1.2.0")
#
# Language Support - Scala
@@ -88,22 +88,22 @@ sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
sysroot(
name = "linux_sysroot",
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
)
# ARM64 sysroot
sysroot(
name = "linux_sysroot_arm64",
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
)
#
# Language Support - Go
#
bazel_dep(name = "rules_go", version = "0.56.1", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.45.0", repo_name = "bazel_gazelle")
bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
@@ -127,8 +127,8 @@ use_repo(
#
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
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")
#
# Protocol Buffers & RPC
@@ -137,7 +137,7 @@ bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rule
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.2.10")
bazel_dep(name = "flatbuffers", version = "25.9.23")
#
# Testing
@@ -149,8 +149,8 @@ bazel_dep(name = "googletest", version = "1.17.0")
# Container Images (OCI)
#
bazel_dep(name = "rules_oci", version = "2.2.6")
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
bazel_dep(name = "rules_oci", version = "2.2.7")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.4")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
@@ -186,7 +186,7 @@ use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17",
# Java/Scala Dependencies
#
bazel_dep(name = "rules_jvm_external", version = "6.3")
bazel_dep(name = "rules_jvm_external", version = "6.9")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
@@ -294,11 +294,15 @@ http_archive(
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# https://busybox.net/downloads/binaries/
# Primary: DigitalOcean Spaces (public, reliable)
# Fallback: busybox.net (can be unreliable/slow)
http_file(
name = "busybox_x86_64",
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
urls = [
"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/busybox/busybox-1.35.0-x86_64-linux-musl",
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
],
downloaded_file_path = "busybox",
executable = True,
)
@@ -306,7 +310,10 @@ http_file(
http_file(
name = "busybox_aarch64",
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
urls = ["https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox"],
urls = [
# TODO: Upload aarch64 binary to GitHub release when needed
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
],
downloaded_file_path = "busybox",
executable = True,
)
+119 -30
View File
@@ -27,9 +27,9 @@
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.16.0/MODULE.bazel": "852f9ebbda017572a7c113a2434592dd3b2f55cd9a0faea3d4be5a09a59e4900",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/MODULE.bazel": "a05cbd9bc16712a58dc27ffe0dceaefd0da59a9bd87a227379b2a934b26a39ab",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/source.json": "9780bc57f521968ee82b7c3e85b7d0c71518fb7ce83ed7a9e5077ce20923207b",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
@@ -39,11 +39,11 @@
"https://bcr.bazel.build/modules/aspect_rules_js/1.33.1/MODULE.bazel": "db3e7f16e471cf6827059d03af7c21859e7a0d2bc65429a3a11f005d46fc501b",
"https://bcr.bazel.build/modules/aspect_rules_js/1.39.0/MODULE.bazel": "aece421d479e3c31dc3e5f6d49a12acc2700457c03c556650ec7a0ff23fc0d95",
"https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/MODULE.bazel": "47cc48eec374d69dced3cf9b9e5926beac2f927441acfb1a3568bbb709b25666",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/source.json": "6b0fe67780c101430be087381b7a79d75eeebe1a1eae6a2cee937713603634ac",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/MODULE.bazel": "74bf20a7a6bd5f2be09607fdb4196cfd6f203422ea271752ec2b1afe95426101",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/source.json": "411ec9d79d6f5fe8a083359588c21d01a5b48d88a2cbd334a4c90365015b7836",
"https://bcr.bazel.build/modules/aspect_rules_lint/0.12.0/MODULE.bazel": "e767c5dbfeb254ec03275a7701b5cfde2c4d2873676804bc7cb27ddff3728fed",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/MODULE.bazel": "5b554d5de90d96ee14117527c0519037713dd33884f3212eae391beccb2e94ff",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/source.json": "9ada3722b716853b6dccdb7b650d8e776a23bc8a190de0c59bd15f21afea6f8a",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/MODULE.bazel": "d0045b5eabb012be550a609589b3e5e47eba682344b19cfd9365d4d896ed07df",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/source.json": "5593e3f1cd0dd5147f7748e163307fd5c2e1077913d6945b58739ad8d770a290",
"https://bcr.bazel.build/modules/bazel_features/0.1.0/MODULE.bazel": "47011d645b0f949f42ee67f2e8775188a9cf4a0a1528aa2fa4952f2fd00906fd",
"https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
@@ -57,12 +57,15 @@
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
@@ -77,7 +80,8 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
@@ -104,8 +108,8 @@
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20241214-918efc9/MODULE.bazel": "24e05f6f52f37be63a795192848555a2c8c855e7814dbc1ed419fb04a7005464",
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/MODULE.bazel": "1fe72489212c530086e3ffb0e018b2bfef4663200ca03571570f9f006bef1d75",
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/MODULE.bazel": "32753ba60bf3bacfe7737c0f3e8e3e55624b19af5d398c485580d57492d145d8",
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/source.json": "a2116f0017f6896353fd3abf65ef2b89b0a257e8a87f395c5000f53934829f31",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
@@ -115,8 +119,8 @@
"https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a",
"https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0",
"https://bcr.bazel.build/modules/gazelle/0.37.0/MODULE.bazel": "d1327ba0907d0275ed5103bfbbb13518f6c04955b402213319d0d6c0ce9839d4",
"https://bcr.bazel.build/modules/gazelle/0.45.0/MODULE.bazel": "ecd19ebe9f8e024e1ccffb6d997cc893a974bcc581f1ae08f386bdd448b10687",
"https://bcr.bazel.build/modules/gazelle/0.45.0/source.json": "111d182facc5f5e80f0b823d5f077b74128f40c3fd2eccc89a06f34191bd3392",
"https://bcr.bazel.build/modules/gazelle/0.47.0/MODULE.bazel": "b61bb007c4efad134aa30ee7f4a8e2a39b22aa5685f005edaa022fbd1de43ebc",
"https://bcr.bazel.build/modules/gazelle/0.47.0/source.json": "aeb2e5df14b7fb298625d75d08b9c65bdb0b56014c5eb89da9e5dd0572280ae6",
"https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
"https://bcr.bazel.build/modules/google_benchmark/1.8.4/MODULE.bazel": "c6d54a11dcf64ee63545f42561eda3fd94c1b5f5ebe1357011de63ae33739d5e",
"https://bcr.bazel.build/modules/google_benchmark/1.8.5/MODULE.bazel": "9ba9b31b984022828a950e3300410977eda2e35df35584c6b0b2d0c2e52766b7",
@@ -176,6 +180,7 @@
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
"https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
@@ -229,9 +234,9 @@
"https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
"https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e",
"https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a",
"https://bcr.bazel.build/modules/rules_apple/3.16.1/MODULE.bazel": "8294474defa70af2534a558ab905c083d69203344145e6f7d544d5098611ec7d",
"https://bcr.bazel.build/modules/rules_apple/3.16.1/source.json": "9190fd9d34a5d048bfbba8a530a57f2c2bf3f61e5634a9ab0b6ab005458857f9",
"https://bcr.bazel.build/modules/rules_apple/3.5.1/MODULE.bazel": "3d1bbf65ad3692003d36d8a29eff54d4e5c1c5f4bfb60f79e28646a924d9101c",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/MODULE.bazel": "c5c2c4adeeac5f3f2f9b7f16abfa8be7ffefa596171d0d92bed4cae9ade0a498",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/source.json": "3cb1d69c8243ffcc42ecbf84ae8b9cccd7b1e2f091b0aee5a3e9c9a45267f312",
"https://bcr.bazel.build/modules/rules_buf/0.1.1/MODULE.bazel": "6189aec18a4f7caff599ad41b851ab7645d4f1e114aa6431acf9b0666eb92162",
"https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
"https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
@@ -246,6 +251,8 @@
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
"https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
@@ -263,8 +270,8 @@
"https://bcr.bazel.build/modules/rules_go/0.48.0/MODULE.bazel": "d00ebcae0908ee3f5e6d53f68677a303d6d59a77beef879598700049c3980a03",
"https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0",
"https://bcr.bazel.build/modules/rules_go/0.53.0/MODULE.bazel": "a4ed760d3ac0dbc0d7b967631a9a3fd9100d28f7d9fcf214b4df87d4bfff5f9a",
"https://bcr.bazel.build/modules/rules_go/0.56.1/MODULE.bazel": "d5b835c548ac917345f1780cd2da52edc1130a908fe091c92096895303ae78a0",
"https://bcr.bazel.build/modules/rules_go/0.56.1/source.json": "0c902f7272e8d4e47e459af97be472bc19dadbbe6023a0719d1adce8483ac75a",
"https://bcr.bazel.build/modules/rules_go/0.59.0/MODULE.bazel": "b7e43e7414a3139a7547d1b4909b29085fbe5182b6c58cbe1ed4c6272815aeae",
"https://bcr.bazel.build/modules/rules_go/0.59.0/source.json": "1df17bb7865cfc029492c30163cee891d0dd8658ea0d5bfdf252c4b6db5c1ef6",
"https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
"https://bcr.bazel.build/modules/rules_java/5.1.0/MODULE.bazel": "324b6478b0343a3ce7a9add8586ad75d24076d6d43d2f622990b9c1cfd8a1b15",
"https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
@@ -292,7 +299,8 @@
"https://bcr.bazel.build/modules/rules_jvm_external/6.0/MODULE.bazel": "37c93a5a78d32e895d52f86a8d0416176e915daabd029ccb5594db422e87c495",
"https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197",
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/MODULE.bazel": "07c5db05527db7744a54fcffd653e1550d40e0540207a7f7e6d0a4de5bef8274",
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/source.json": "b12970214f3cc144b26610caeb101fa622d910f1ab3d98f0bae1058edbd00bd4",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5",
@@ -306,12 +314,12 @@
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
"https://bcr.bazel.build/modules/rules_oci/2.2.7/MODULE.bazel": "f6150e4b224d459f7f6523ef65967464ca4efdd266c7fbf2f5a2a51011957e0c",
"https://bcr.bazel.build/modules/rules_oci/2.2.7/source.json": "b099f02af330f47f19dc67fc9300ef6e1937a8c86882690db0e7a2fcea8c7f6b",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/source.json": "fef768df13a92ce6067e1cd0cdc47560dace01354f1d921cfb1d632511f7d608",
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/MODULE.bazel": "c7db3c2b407e673c7a39e3625dc05dc9f12d6682cbd82a3a5924a13b491eda7e",
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/source.json": "9062e00845bf91a4247465d371baa837adf9b6ff44c542f73ba084f07667e1dc",
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
"https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
@@ -334,7 +342,8 @@
"https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
"https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7",
"https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43",
"https://bcr.bazel.build/modules/rules_python/1.0.0/source.json": "b0162a65c6312e45e7912e39abd1a7f8856c2c7e41ecc9b6dc688a6f6400a917",
"https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
"https://bcr.bazel.build/modules/rules_python/1.3.0/source.json": "25932f917cd279c7baefa6cb1d3fa8750a7a29de522024449b19af6eab51f4a0",
"https://bcr.bazel.build/modules/rules_rust/0.45.1/MODULE.bazel": "a69d0db3a958fab2c6520961e1b2287afcc8b36690fd31bbc4f6f7391397150d",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/MODULE.bazel": "b1f80c52ae49b27d41b9291d8b328b69247de2b7596d35d09afe6147b82cf562",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
@@ -345,8 +354,8 @@
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
"https://bcr.bazel.build/modules/rules_swift/2.3.1/MODULE.bazel": "0b42093600d9226bcbdb31fb86d25d4204293d716fdbb2e50a1852547032a660",
"https://bcr.bazel.build/modules/rules_swift/2.3.1/source.json": "87d28609c37d2061db2f6fc3aae8ab7fbda9adf556cd88fbd0c7d520b8d81391",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/source.json": "a6577f57f9febbdc015a01f2a8f3487422032f134d6c61d18ed8e8ca3b9acc7c",
"https://bcr.bazel.build/modules/stardoc/0.5.0/MODULE.bazel": "f9f1f46ba8d9c3362648eea571c6f9100680efc44913618811b58cc9c02cd678",
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
"https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
@@ -360,6 +369,7 @@
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
"https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
@@ -386,7 +396,7 @@
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
"general": {
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
"usagesDigest": "TOb4CUri5UsTKxgIDTNzR0ddIc21eYLCRIm+jqQmjlg=",
"usagesDigest": "tl3VVeQX3Hzh7FhM2gjnkCwEJpRMlY5S6a850WY/xvc=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -413,7 +423,7 @@
},
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
"general": {
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
"bzlTransitiveDigest": "8L5Llfl6uxIWXd5GR+Qmmm04/jxp6TuJH5LFhIZIUCA=",
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -492,6 +502,7 @@
"extra_build_content": "",
"generate_bzl_library_targets": false,
"extract_full_archive": false,
"exclude_package_contents": [],
"system_tar": "auto"
}
},
@@ -516,11 +527,17 @@
"package_visibility": [
"//visibility:public"
],
"replace_package": ""
"replace_package": "",
"exclude_package_contents": []
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_lib",
"bazel_lib~"
],
[
"aspect_bazel_lib~",
"bazel_skylib",
@@ -531,6 +548,11 @@
"bazel_tools",
"bazel_tools"
],
[
"aspect_bazel_lib~",
"tar.bzl",
"tar.bzl~"
],
[
"aspect_rules_esbuild~",
"aspect_rules_js",
@@ -546,6 +568,11 @@
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"aspect_rules_js~",
"aspect_rules_js",
"aspect_rules_js~"
],
[
"aspect_rules_js~",
"bazel_skylib",
@@ -555,6 +582,31 @@
"aspect_rules_js~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"tar.bzl~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"tar.bzl~",
"bazel_skylib",
"bazel_skylib~"
],
[
"tar.bzl~",
"tar.bzl",
"tar.bzl~"
]
]
}
@@ -1162,7 +1214,7 @@
"@@rules_nodejs~//nodejs:extensions.bzl%node": {
"general": {
"bzlTransitiveDigest": "q44Ox2Nwogn6OsO0Xw5lhjkd/xmxkvvpwVOn5P4pmHQ=",
"usagesDigest": "WQpLKLujnBfrx9sMWCJgyaK9P04binseT6CGBy3vP4E=",
"usagesDigest": "Py5Wgc5kr5fTMe1FKrlFK276B6SodesXp6nw2Fq5XA8=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1292,8 +1344,8 @@
},
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "39yHQmifPoGf+JSYpnQSnJGugxbrFVge/+ENmUiqZ6M=",
"bzlTransitiveDigest": "AOLP47LtVHSKSDiukosQymx543OwcgeoQP666wwuj3o=",
"usagesDigest": "3Xsv1/UEV8MOARW4BZScPw3Gxtx19OQ5EXOqcL1p9bI=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1580,6 +1632,43 @@
]
}
},
"@@rules_python~//python/uv:uv.bzl%uv": {
"general": {
"bzlTransitiveDigest": "Xpqjnjzy6zZ90Es9Wa888ZLHhn7IsNGbph/e6qoxzw8=",
"usagesDigest": "qI5PVlIum/YAnGJg5oXGHzDkMFWt2aNSUZY4G8PBbic=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"uv": {
"bzlFile": "@@rules_python~//python/uv/private:uv_toolchains_repo.bzl",
"ruleClassName": "uv_toolchains_repo",
"attributes": {
"toolchain_type": "'@@rules_python~//python/uv:uv_toolchain_type'",
"toolchain_names": [
"none"
],
"toolchain_implementations": {
"none": "'@@rules_python~//python:none'"
},
"toolchain_compatible_with": {
"none": [
"@platforms//:incompatible"
]
},
"toolchain_target_settings": {}
}
}
},
"recordedRepoMappingEntries": [
[
"rules_python~",
"platforms",
"platforms"
]
]
}
},
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
"general": {
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
@@ -5056,7 +5145,7 @@
"@@rules_swift~//swift:extensions.bzl%non_module_deps": {
"general": {
"bzlTransitiveDigest": "PAIMhc1bVKfcyoHeg0xO8LMS9KN5yzbsMGwa5O2ifJM=",
"usagesDigest": "A3fzk5iHsrLdI3PokT1bHIdeJ2j9tc09H3/3Old6IfU=",
"usagesDigest": "l2vIL7SL4tJqHIVLWd78Y/ym+r0II64lmvoX8o+0Bb0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euxo pipefail
. ./ci/unity_version.sh
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
BUILD_DIR=$1
LOG_PATH=$2
echo "Building Mac in $BUILD_DIR"
echo "Cleaning up $BUILD_DIR"
/bin/rm -rf "$BUILD_DIR"
/bin/mkdir -p "$BUILD_DIR"
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-buildOSXUniversalPlayer "$BUILD_DIR/eagle0.app" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
set -euxo pipefail
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
git log -3
/bin/echo "build Mac"
LOG_PATH="/tmp/eagle0/editor_mac.log"
BUILD_DIR=$1
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
+16 -2
View File
@@ -1,6 +1,20 @@
#!/bin/bash
set -euxo pipefail
set -uxo pipefail
/bin/echo "persist Library/"
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
# 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/
rsync_exit=$?
if [ $rsync_exit -eq 0 ]; then
exit 0
elif [ $rsync_exit -eq 23 ]; then
echo "Warning: rsync exited with 23 (some files vanished during copy). This is expected for Unity temp files."
exit 0
else
echo "Error: rsync failed with exit code $rsync_exit"
exit $rsync_exit
fi
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow JIT compilation (required for Mono/IL2CPP) -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Allow unsigned executable memory (required for Unity) -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Disable library validation (required for plugins) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Allow outgoing network connections -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
+40
View File
@@ -0,0 +1,40 @@
# 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)
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
SHARDOK_IMAGE=registry.digitalocean.com/eagle0/shardok-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
SHARDOK_ADDRESS=shardok:40042
SHARDOK_AUTH_TOKEN=
# Monitoring
SENTRY_DSN=
# Email (Fastmail JMAP)
FASTMAIL_API_TOKEN=
FASTMAIL_FROM_EMAIL=
FASTMAIL_FROM_NAME=
+59
View File
@@ -0,0 +1,59 @@
#!/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"
+97 -10
View File
@@ -8,9 +8,13 @@
# Run: docker compose -f docker-compose.prod.yml up -d
services:
eagle:
# Blue-green deployment: eagle-blue is the primary (production) instance
# eagle-green is the staging instance for zero-downtime deployments
# See scripts/deploy-blue-green.sh for deployment workflow
eagle-blue:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-server
container_name: eagle-blue
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
@@ -39,7 +43,7 @@ services:
volumes:
- ./saves:/app/saves # Game saves and user database
- ./archived:/app/archived # Archived completed games
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- ./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
depends_on:
@@ -58,6 +62,58 @@ services:
retries: 3
start_period: 30s
eagle-green:
image: ${EAGLE_IMAGE_NEW:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-green
profiles: ["blue-green"] # Only started during blue-green deployment
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "--auth-service-url"
- "auth:40033"
ports:
- "40034:40032" # Different host port for staging
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
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:-}"
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
EAGLE_SAVE_DIR: "/app/saves"
EAGLE_ARCHIVE_DIR: "/app/archived"
SENTRY_DSN: "${SENTRY_DSN:-}"
SENTRY_ENVIRONMENT: "production"
volumes:
- ./saves:/app/saves # Same save directory as blue
- ./archived:/app/archived # Same archive directory as blue
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: "no" # Don't auto-restart during deployment
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
interval: 10s
timeout: 5s
retries: 6
start_period: 60s
# Backward compatibility alias - for scripts that reference 'eagle' service
eagle:
extends:
service: eagle-blue
auth:
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
container_name: auth-server
@@ -80,6 +136,12 @@ services:
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
JWT_KEYS_PATH: "/etc/eagle0/keys"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
# Fastmail JMAP API for sending invitation emails
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
# Require invitation codes for new user registration
REQUIRE_INVITATION_CODE: "true"
# Note: port 40033 is exposed via nginx, not directly
volumes:
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
@@ -136,8 +198,9 @@ services:
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle
- admin
# Note: nginx connects to eagle via EAGLE_ADDR (default: eagle-blue:40032)
# For blue-green deployments, update EAGLE_ADDR in .env before switching
restart: unless-stopped
logging:
driver: "json-file"
@@ -150,18 +213,18 @@ services:
container_name: admin-server
command:
- "--eagle-addr"
- "eagle:40032"
- "${EAGLE_ADDR:-eagle-blue:40032}" # Can be switched for blue-green
- "--auth-addr"
- "auth:40033"
- "--jfr-sidecar-addr"
- "jfr-sidecar:8081"
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
- "--http-port"
- "8080"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- eagle
- auth
- jfr-sidecar
# Note: admin connects to eagle via EAGLE_ADDR and jfr-sidecar via JFR_SIDECAR_ADDR
# For blue-green deployments, set both in .env before switching
restart: unless-stopped
logging:
driver: "json-file"
@@ -179,11 +242,12 @@ services:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar
# Share PID namespace with Eagle to access its JVM via jcmd
pid: "service:eagle"
# For blue-green: use JFR_SIDECAR_ADDR=jfr-sidecar-green:8081 when green is active
pid: "service:eagle-blue"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle
- eagle-blue
restart: unless-stopped
logging:
driver: "json-file"
@@ -197,6 +261,29 @@ services:
retries: 3
start_period: 10s
jfr-sidecar-green:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar-green
profiles: ["blue-green"] # Only started during blue-green deployment
# Share PID namespace with Eagle green instance
pid: "service:eagle-green"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle-green
restart: "no" # Don't auto-restart during deployment
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "2"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
certbot:
image: certbot/certbot
container_name: certbot
+32 -1
View File
@@ -231,6 +231,21 @@ All action files have been migrated to use Scala types:
-`CommandChoiceHelpers` migrated to Scala types
-`ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
### Enum Type Migrations
Proto enums are being converted to Scala sealed traits with converters at boundaries:
| Enum | Scala Type | Status | Notes |
|------|------------|--------|-------|
| `DiplomacyOfferStatus` | `Status` sealed trait | ✅ **Complete** | PR #5093 - `EligibleDiplomacyStatuses` uses Scala types internally |
| `RoundPhase` | `RoundPhase` sealed trait | Partial | Some usages converted |
| `BattalionType` | `BattalionType` sealed trait | Partial | Some usages converted |
**DiplomacyOfferStatus Migration (PR #5093):**
- `EligibleDiplomacyStatuses.scala` now returns `Vector[Status]` instead of `Vector[DiplomacyOfferStatus]`
- Call sites in `AvailableResolve*CommandFactory` files convert to proto via `StatusConverter.toProto` at the boundary
- This pattern should be applied to other proto enums
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
@@ -257,7 +272,23 @@ Several command selectors have already been converted to use Scala types:
|--------|-------|
| Action files fully protoless | 52 / 52 (100%) ✅ |
| Proto usages in remaining actions | 0 |
| Next target | History APIs (InMemoryHistory, PersistedHistory) |
| 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
+20 -9
View File
@@ -24,10 +24,13 @@ http {
# This prevents stale IP caching when containers restart
resolver 127.0.0.11 valid=10s ipv6=off;
# Upstream for Eagle gRPC server
upstream eagle_grpc {
server eagle:40032;
keepalive 100;
# Eagle backend address - configured via variable for blue-green deployments
# Using a variable makes nginx resolve the hostname at request time, not config load time
# This allows nginx to reload even when the backend is temporarily down
# For blue-green deployments, this is switched between eagle-blue:40032 and eagle-green:40032
# by scripts/deploy-blue-green.sh, then nginx is reloaded with 'nginx -s reload'
map $host $eagle_backend {
default "eagle-blue:40032";
}
# HTTP server for Let's Encrypt challenge and redirect
@@ -71,8 +74,8 @@ http {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# gRPC proxy - uses variable for blue-green deployment support
grpc_pass grpc://$eagle_backend;
# Timeouts for long-running streams
grpc_read_timeout 1200s;
@@ -83,13 +86,14 @@ http {
error_page 502 = /error502grpc;
}
# gRPC proxy for Auth service
# gRPC proxy for Auth service (routes to Go auth service, not Eagle)
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Route to auth service directly (not through Eagle)
set $auth_backend "auth:40033";
grpc_pass grpc://$auth_backend;
# Timeouts
grpc_read_timeout 30s;
@@ -106,6 +110,13 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Invitation landing page (proxied to Go auth service)
location /invite/ {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Health check endpoint
location /health {
access_log off;
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# Code sign a macOS .app bundle for distribution
# Usage: codesign_mac_app.sh <app_path> [entitlements_path]
#
# Environment variables:
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
set -euxo pipefail
APP_PATH="$1"
ENTITLEMENTS_PATH="${2:-}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
# Unlock keychain if password provided
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
fi
echo "=== Signing nested components first ==="
# Sign all dylibs
find "$APP_PATH" -name "*.dylib" -print0 | while IFS= read -r -d '' item; do
echo "Signing dylib: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all bundles (plugins)
find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
echo "Signing bundle: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign XPC services (inside Sparkle framework)
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
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)
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
echo "Signing nested app: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign standalone executables inside frameworks (like Autoupdate)
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
echo "Signing executable: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all frameworks (after their contents are signed)
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"
done
echo "=== Signing main app bundle ==="
if [ -n "$ENTITLEMENTS_PATH" ] && [ -f "$ENTITLEMENTS_PATH" ]; then
echo "Using entitlements: $ENTITLEMENTS_PATH"
codesign --force --verify --verbose --timestamp --options runtime \
--entitlements "$ENTITLEMENTS_PATH" \
--sign "$SIGNING_IDENTITY" "$APP_PATH"
else
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$APP_PATH"
fi
echo "=== Verifying signature ==="
codesign --verify --verbose=4 "$APP_PATH"
echo "=== Checking Gatekeeper assessment ==="
spctl --assess --type exec -v "$APP_PATH" || echo "Note: Gatekeeper may reject until notarized"
echo "Code signing complete: $APP_PATH"
+360
View File
@@ -0,0 +1,360 @@
#!/bin/bash
#
# Blue-Green Deployment Script for Eagle Server
#
# This script performs a zero-downtime deployment by:
# 1. Starting the new version on a staging port (green)
# 2. Waiting for it to become healthy
# 3. Running warmup traffic to pre-heat the JIT
# 4. Stopping the old version (blue) - which flushes state to storage
# 5. Switching nginx to route traffic to green
# 6. Users reconnect, triggering lazy game loading from fresh storage
# 7. Cleaning up
#
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
#
# Example:
# ./deploy-blue-green.sh latest
# ./deploy-blue-green.sh sha-abc123
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="${APP_DIR:-/opt/eagle0}"
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# GitHub raw URL base for fetching config files
GITHUB_RAW_BASE="https://raw.githubusercontent.com/nolen777/eagle0/main"
# Sync config files from GitHub to ensure we have the latest versions
sync_config_files() {
log_info "Syncing config files from GitHub..."
# Backup and update docker-compose.prod.yml
if curl -fsSL "${GITHUB_RAW_BASE}/docker-compose.prod.yml" -o "${COMPOSE_FILE}.new"; then
cp "${COMPOSE_FILE}" "${COMPOSE_FILE}.bak"
mv "${COMPOSE_FILE}.new" "${COMPOSE_FILE}"
log_info "Updated docker-compose.prod.yml"
else
log_warn "Failed to fetch docker-compose.prod.yml, using existing"
fi
# Backup and update nginx.conf, preserving current active instance
if curl -fsSL "${GITHUB_RAW_BASE}/nginx/nginx.conf" -o "${NGINX_CONF}.new"; then
# Preserve the current eagle_backend setting (which instance is active)
local current_backend
current_backend=$(grep -o 'default "eagle-[a-z]*:40032"' "${NGINX_CONF}" | head -1 || echo 'default "eagle-blue:40032"')
cp "${NGINX_CONF}" "${NGINX_CONF}.bak"
# Update the new config with the current active instance
sed -i "s/default \"eagle-blue:40032\"/${current_backend}/g" "${NGINX_CONF}.new"
mv "${NGINX_CONF}.new" "${NGINX_CONF}"
log_info "Updated nginx.conf (preserved active: ${current_backend})"
else
log_warn "Failed to fetch nginx.conf, using existing"
fi
}
# Determine which instance is currently active
get_active_instance() {
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
echo "blue"
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
echo "green"
else
log_error "Cannot determine active instance from nginx config"
exit 1
fi
}
# Pull image with retry using crane (handles OCI/Docker digest mismatch)
pull_with_retry() {
local image=$1
local max_attempts=${2:-3}
local attempt=1
# Use crane if available (handles OCI format correctly)
if [ -x "${APP_DIR}/crane" ]; then
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image with crane (attempt ${attempt}/${max_attempts})..."
if "${APP_DIR}/crane" pull "${image}" /tmp/image.tar && docker load -i /tmp/image.tar; then
rm -f /tmp/image.tar
log_info "Image pulled and loaded successfully"
return 0
fi
rm -f /tmp/image.tar
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
else
# Fallback to docker pull if crane not available
log_warn "crane not found at ${APP_DIR}/crane, falling back to docker pull"
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image (attempt ${attempt}/${max_attempts})..."
if docker pull "${image}"; then
log_info "Image pulled successfully"
return 0
fi
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
fi
log_error "Failed to pull image after ${max_attempts} attempts"
return 1
}
# Wait for a container to be healthy
wait_for_healthy() {
local container=$1
local max_attempts=${2:-60}
local attempt=1
log_info "Waiting for ${container} to become healthy..."
while [ $attempt -le $max_attempts ]; do
health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown")
if [ "$health" = "healthy" ]; then
log_info "${container} is healthy"
return 0
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
echo ""
log_error "${container} did not become healthy after $((max_attempts * 2)) seconds"
return 1
}
# Main deployment logic
main() {
local new_tag="${1:-latest}"
local registry="registry.digitalocean.com/eagle0/eagle-server"
local new_image="${registry}:${new_tag}"
log_info "Starting blue-green deployment"
log_info "New image: ${new_image}"
cd "${APP_DIR}"
# Sync config files from GitHub before deployment
sync_config_files
# Determine current active instance
local active=$(get_active_instance)
local staging
if [ "$active" = "blue" ]; then
staging="green"
else
staging="blue"
fi
log_info "Active instance: eagle-${active}"
log_info "Staging instance: eagle-${staging}"
# Pull the new image (with retry for intermittent registry issues)
if ! pull_with_retry "${new_image}" 3; then
log_error "Failed to pull new image, aborting deployment"
exit 1
fi
# Start staging instance with new image (and its jfr-sidecar)
log_info "Starting eagle-${staging} with new image..."
if [ "$staging" = "green" ]; then
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green jfr-sidecar-green
else
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue jfr-sidecar
fi
# Wait for staging to be healthy
if ! wait_for_healthy "eagle-${staging}" 90; then
log_error "Staging instance failed health check, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
# Run warmup/smoke test
local staging_port
if [ "$staging" = "green" ]; then
staging_port=40034
else
staging_port=40032
fi
log_info "Running warmup against eagle-${staging}..."
if [ -x "${WARMUP_SCRIPT}" ]; then
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
log_error "Warmup/smoke test failed, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
else
log_warn "Warmup script not found at ${WARMUP_SCRIPT}, skipping warmup"
log_warn "JIT will be cold on first requests"
fi
# Backup games.e0es BEFORE stopping the active instance
# Critical: The old server may wipe games.e0es on shutdown if it doesn't have the merge fix
local backup_timestamp
backup_timestamp=$(date +%Y%m%d_%H%M%S)
log_info "Creating S3 backup of games.e0es (backup_${backup_timestamp})..."
if command -v s3cmd &> /dev/null; then
if s3cmd put "${APP_DIR}/saves/games.e0es" "s3://eagle0/eagle/save/backups/games.e0es.${backup_timestamp}" 2>/dev/null; then
log_info "Backup created: s3://eagle0/eagle/save/backups/games.e0es.${backup_timestamp}"
else
log_warn "S3 backup failed (s3cmd put), continuing deployment"
fi
else
log_warn "s3cmd not found, skipping S3 backup"
fi
# Stop the active instance (this flushes state to storage)
log_info "Stopping eagle-${active} (flushing state to storage)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
# Note: No need to explicitly reload games - Eagle uses lazy loading.
# Games are loaded on-demand when users reconnect after nginx switches traffic.
# This ensures games are always loaded from the freshest storage state.
# Switch nginx upstream
log_info "Switching nginx upstream to eagle-${staging}..."
if [ "$staging" = "green" ]; then
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
else
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
fi
# Restart nginx to ensure fresh config is loaded
# Note: We use restart instead of reload to ensure all workers pick up the new config
log_info "Restarting nginx..."
docker compose -f "${COMPOSE_FILE}" restart nginx
# Clean up old instance and its jfr-sidecar
log_info "Removing old eagle-${active} container..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}"
if [ "$active" = "green" ]; then
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar-green" 2>/dev/null || true
else
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar" 2>/dev/null || true
fi
# Update .env with new EAGLE_ADDR and JFR_SIDECAR_ADDR for admin service
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
log_info "Updating .env for green instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-green:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar-green:8081" >> "${env_file}"
else
log_info "Updating .env for blue instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-blue:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
fi
# Restart admin to pick up new EAGLE_ADDR and JFR_SIDECAR_ADDR
log_info "Restarting admin service..."
docker compose -f "${COMPOSE_FILE}" up -d admin
log_info "Deployment complete!"
log_info "Active instance: eagle-${staging}"
}
# Ensure s3cmd is installed and configured for S3 backups
ensure_s3cmd() {
# Install s3cmd if not present
if ! command -v s3cmd &> /dev/null; then
log_info "Installing s3cmd for S3 backups..."
if command -v pip3 &> /dev/null; then
pip3 install --quiet s3cmd
elif command -v pip &> /dev/null; then
pip install --quiet s3cmd
elif command -v apt-get &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq s3cmd
else
log_warn "Cannot install s3cmd (no pip or apt-get), S3 backups will be skipped"
return 1
fi
if ! command -v s3cmd &> /dev/null; then
log_warn "s3cmd installation failed, S3 backups will be skipped"
return 1
fi
log_info "s3cmd installed successfully"
fi
# Configure s3cmd for DigitalOcean Spaces if not already configured
local s3cfg="${HOME}/.s3cfg"
if [ ! -f "${s3cfg}" ]; then
# Load credentials from .env
local env_file="${APP_DIR}/.env"
if [ -f "${env_file}" ]; then
# shellcheck source=/dev/null
source "${env_file}"
fi
if [ -z "${DO_SPACES_ACCESS_KEY:-}" ] || [ -z "${DO_SPACES_SECRET_KEY:-}" ]; then
log_warn "DO_SPACES_ACCESS_KEY or DO_SPACES_SECRET_KEY not set, S3 backups will be skipped"
return 1
fi
log_info "Configuring s3cmd for DigitalOcean Spaces..."
cat > "${s3cfg}" << EOF
[default]
access_key = ${DO_SPACES_ACCESS_KEY}
secret_key = ${DO_SPACES_SECRET_KEY}
host_base = sfo3.digitaloceanspaces.com
host_bucket = %(bucket)s.sfo3.digitaloceanspaces.com
use_https = True
EOF
log_info "s3cmd configured successfully"
fi
return 0
}
# Check for required tools
check_requirements() {
if ! command -v docker &> /dev/null; then
log_error "docker is required but not installed"
exit 1
fi
if ! command -v sed &> /dev/null; then
log_error "sed is required but not installed"
exit 1
fi
if [ ! -f "${NGINX_CONF}" ]; then
log_error "nginx config not found at ${NGINX_CONF}"
exit 1
fi
if [ ! -f "${COMPOSE_FILE}" ]; then
log_error "docker-compose file not found at ${COMPOSE_FILE}"
exit 1
fi
# Ensure s3cmd is available for backups (non-fatal if it fails)
ensure_s3cmd || true
}
# Run
check_requirements
main "$@"
+41
View File
@@ -0,0 +1,41 @@
// +build ignore
// Script to generate Ed25519 key pair for manifest signing.
// Run with: go run scripts/generate_manifest_keys.go
//
// This will output:
// - Private key (base64): Store as MANIFEST_SIGNING_KEY GitHub secret
// - Public key (base64): Embed in EagleInstaller for verification
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
"log"
)
func main() {
// Generate Ed25519 key pair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Fatalf("Failed to generate key pair: %v", err)
}
// Encode keys as base64
privateKeyB64 := base64.StdEncoding.EncodeToString(privateKey)
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
fmt.Println("=== Ed25519 Key Pair for Manifest Signing ===")
fmt.Println()
fmt.Println("PRIVATE KEY (store as GitHub secret MANIFEST_SIGNING_KEY):")
fmt.Println(privateKeyB64)
fmt.Println()
fmt.Println("PUBLIC KEY (embed in EagleInstaller.cs for verification):")
fmt.Println(publicKeyB64)
fmt.Println()
fmt.Printf("Private key size: %d bytes\n", len(privateKey))
fmt.Printf("Public key size: %d bytes\n", len(publicKey))
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
#
# Inject Sparkle framework into a macOS .app bundle for auto-updates
# Usage: inject_sparkle.sh <app_path>
#
# Environment variables (required):
# SPARKLE_EDDSA_PUBLIC_KEY - EdDSA public key for verifying updates
#
# Optional environment variables:
# SPARKLE_FEED_URL - Appcast URL (default: https://assets.eagle0.net/mac/appcast.xml)
# SPARKLE_VERSION - Sparkle version to use (default: 2.6.4)
set -euxo pipefail
APP_PATH="$1"
SPARKLE_VERSION="${SPARKLE_VERSION:-2.6.4}"
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://assets.eagle0.net/mac/appcast.xml}"
SPARKLE_CACHE_DIR="/tmp/sparkle-cache"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
if [ -z "${SPARKLE_EDDSA_PUBLIC_KEY:-}" ]; then
echo "ERROR: SPARKLE_EDDSA_PUBLIC_KEY environment variable not set"
exit 1
fi
# Download Sparkle if not cached
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
fi
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/"
# Also copy the XPC services if present
if [ -d "$SPARKLE_DIR/Sparkle.framework/Versions/B/XPCServices" ]; then
echo "Sparkle XPC services present"
fi
echo "=== Updating Info.plist ==="
PLIST_PATH="$APP_PATH/Contents/Info.plist"
# Add Sparkle configuration to Info.plist
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string '$SPARKLE_FEED_URL'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string '$SPARKLE_EDDSA_PUBLIC_KEY'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Delete :SUEnableAutomaticChecks" "$PLIST_PATH" 2>/dev/null || true
/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")
BUILD_NUMBER=$(git rev-list --count HEAD 2>/dev/null || echo "1")
echo "Setting version: $VERSION (build $BUILD_NUMBER)"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string '$VERSION'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string '$BUILD_NUMBER'" "$PLIST_PATH"
# Add URL scheme for invitation codes (eagle0://invite?code=XXXX)
echo "=== Adding URL scheme for invitation codes ==="
/usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string 'com.Shardok-Games.eagle0'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string 'eagle0'" "$PLIST_PATH"
echo "=== Sparkle injection complete ==="
echo "App: $APP_PATH"
echo "Feed URL: $SPARKLE_FEED_URL"
echo "Version: $VERSION (build $BUILD_NUMBER)"
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
#
# Notarize a macOS .app bundle with Apple
# Usage: notarize_mac_app.sh <app_path>
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euxo pipefail
APP_PATH="$1"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set"
echo " APPLE_ID: ${APPLE_ID:-<not set>}"
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}"
echo " TEAM_ID: ${TEAM_ID:-<not set>}"
exit 1
fi
# Create ZIP for notarization submission
ZIP_PATH="${APP_PATH%.app}.zip"
echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "=== Submitting to Apple for notarization ==="
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" \
--wait 2>&1) || true
echo "$SUBMIT_OUTPUT"
# Extract submission ID and status (look for " status:" to avoid matching "Current status:")
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
STATUS=$(echo "$SUBMIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
echo "Submission ID: $SUBMISSION_ID"
echo "Status: $STATUS"
# Clean up the zip
rm "$ZIP_PATH"
if [ "$STATUS" != "Accepted" ]; then
echo "=== Notarization failed! Fetching log for details ==="
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID"
exit 1
fi
echo "=== Stapling notarization ticket to app ==="
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
MAX_STAPLE_ATTEMPTS=5
STAPLE_ATTEMPT=1
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
if xcrun stapler staple "$APP_PATH"; then
echo "Stapling successful"
break
fi
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
exit 1
fi
echo "Stapling failed, waiting 10 seconds before retry..."
sleep 10
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
done
echo "=== Verifying notarization ==="
xcrun stapler validate "$APP_PATH"
spctl --assess --type exec -v "$APP_PATH"
echo "Notarization complete: $APP_PATH"
+142
View File
@@ -0,0 +1,142 @@
#!/bin/bash
#
# Warmup Script for Eagle Server
#
# This script warms up the JIT compiler before switching traffic to a new instance.
# It uses the Go warmup tool which:
# 1. Creates a test game via bidirectional streaming
# 2. Posts an Improve command
# 3. Verifies action results and new commands
# 4. Cleans up the test game
#
# Usage: ./warmup-eagle.sh HOST:PORT
#
# Example:
# ./warmup-eagle.sh localhost:40032
# ./warmup-eagle.sh localhost:40034
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
HOST="${1:-localhost:40032}"
log_info "Warming up Eagle server at ${HOST}..."
# Try to find the Go warmup tool
WARMUP_TOOL=""
# Check if we're in the project directory with bazel
if [ -f "${PROJECT_ROOT}/WORKSPACE" ] || [ -f "${PROJECT_ROOT}/WORKSPACE.bazel" ]; then
# Try to find the pre-built binary
BAZEL_BIN="${PROJECT_ROOT}/bazel-bin/src/main/go/net/eagle0/warmup/warmup_/warmup"
if [ -x "${BAZEL_BIN}" ]; then
WARMUP_TOOL="${BAZEL_BIN}"
fi
fi
# Check for the warmup tool in common locations (for deployed environments)
if [ -z "${WARMUP_TOOL}" ]; then
for path in \
"${SCRIPT_DIR}/bin/warmup" \
"/opt/eagle0/scripts/bin/warmup" \
"/opt/eagle0/bin/warmup" \
"/usr/local/bin/eagle-warmup" \
"${SCRIPT_DIR}/warmup"; do
if [ -x "${path}" ]; then
WARMUP_TOOL="${path}"
break
fi
done
fi
# If we found the Go tool, use it
if [ -n "${WARMUP_TOOL}" ]; then
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=60s; then
log_info "Warmup complete!"
exit 0
else
log_error "Go warmup tool failed"
exit 1
fi
fi
# Fallback to grpcurl-based warmup
log_warn "Go warmup tool not found, falling back to grpcurl"
# Check for grpcurl
if ! command -v grpcurl &> /dev/null; then
log_error "Neither Go warmup tool nor grpcurl is available"
log_error "Build the warmup tool with: bazel build //src/main/go/net/eagle0/warmup"
log_error "Or install grpcurl: brew install grpcurl (macOS)"
exit 1
fi
# Warmup iterations
WARMUP_ITERATIONS=3
# 1. Call GetRunningGames multiple times - this exercises the gRPC layer and basic game access
log_info "Warming up GetRunningGames..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
RESULT=$(grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames 2>&1) || true
if echo "$RESULT" | grep -q "games\|{}"; then
echo -n "."
else
log_error "GetRunningGames failed on iteration $i"
exit 1
fi
done
echo " done"
# 2. Call GetSettings - exercises settings loading
log_info "Warming up GetSettings..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetSettings > /dev/null 2>&1; then
echo -n "."
else
log_warn "GetSettings failed on iteration $i (non-fatal)"
fi
done
echo " done"
# 3. Call AddSettings with empty list - exercises settings path
log_info "Warming up AddSettings..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
if grpcurl -plaintext -d '{"settings": []}' "${HOST}" net.eagle0.eagle.api.Eagle/AddSettings > /dev/null 2>&1; then
echo -n "."
else
log_warn "AddSettings failed on iteration $i (non-fatal)"
fi
done
echo " done"
# Final health check
log_info "Verifying server health..."
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames > /dev/null 2>&1; then
log_info "Health check passed"
else
log_error "Health check failed"
exit 1
fi
log_info ""
log_info "Warmup complete (basic mode - bidirectional streaming warmup not available)!"
log_info "The JIT should be warmed for:"
log_info " - gRPC layer and protobuf parsing"
log_info " - Settings loading and management"
log_info ""
log_warn "Note: For full warmup including game creation and command processing,"
log_warn " build and use the Go warmup tool: bazel build //src/main/go/net/eagle0/warmup"
@@ -107,6 +107,23 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
alCache,
battalionTypeGetter,
braveWaterCost));
} else if (!defenderPositions.empty()) {
// Defenders exist but none are on castles - they're scattering/fleeing.
// Chase them down rather than holding empty castles, since eliminating
// all defenders also wins the battle via LAST_PLAYER_STANDING.
chosenStrategy = AttackUnitsStrategy(GenerateTargetPriorities(
Occupants(
*gameState->units(),
gameState->hex_map()->row_count(),
gameState->hex_map()->column_count()),
gameState->hex_map(),
defenderPositions,
attackerPid,
attackerUnits,
apdCache,
alCache,
battalionTypeGetter,
braveWaterCost));
} else {
chosenStrategy = HoldCastlesStrategy;
}
@@ -222,7 +222,6 @@ double AIHeuristicWeighting::GetCommandWeight(
case CommandType::RELEASE_UNIT_COMMAND: return 0.0;
case CommandType::REINFORCE_COMMAND: return 10.0;
case CommandType::MANAGE_PRISONER: return 1.0;
// === ZERO WEIGHT - NEVER SELECT (0.0) ===
// Explicitly bad actions
@@ -20,8 +20,9 @@ auto UnitIdsRequiringWaterCrossing(
fb::HexMapW mapCopy = fb::CopyHexMap(gameState->hex_map());
for (uint32_t index = 0; index < mapCopy->terrain()->size(); index++) {
if (IsWater(mapCopy->terrain()->Get(index)->type())) continue;
mapCopy->mutable_terrain()
->GetMutableObject(index)
// const_cast is safe because we own the mutable buffer (mapCopy)
const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index))
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
@@ -164,16 +165,11 @@ auto WaterCrossingTiles(
if (modifier.ice().present() && !modifier.fire().present()) continue;
// Now try adding a bridge to the tile to see if it helps
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(true);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
// const_cast is safe because we own the mutable buffer (mapCopy)
auto *terr = const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index));
terr->mutable_modifier().mutable_bridge().mutate_present(true);
terr->mutable_modifier().mutable_fire().mutate_present(false);
auto hash = ActionPointDistancesCache::GetMapId(mapCopy);
@@ -183,11 +179,7 @@ auto WaterCrossingTiles(
}
// Undo the new bridge for the next iteration of the loop
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(false);
terr->mutable_modifier().mutable_bridge().mutate_present(false);
}
return returnCoords;
@@ -85,7 +85,7 @@ vector<shared_ptr<ShardokAIClient>> ShardokGameController::MakeAIClients(
e->GetCurrentGameState()->hex_map(),
e->GetGameSettings()->GetGetter(),
AIAlgorithmType::ITERATIVE_DEEPENING,
ScoringCalculatorType::MCTS_OPTIMIZED,
ScoringCalculatorType::STANDARD,
mctsConfig);
// MCTS config is dynamically adjusted in ShardokAIClient based on proximity:
@@ -76,11 +76,13 @@ static auto CreateIceClearedMap(const HexMap* map) -> fb::HexMapW {
// Now modify the ice on the mutable copy
auto* mutableMap = mapCopy.Get();
const auto* terrainVec = mutableMap->mutable_terrain();
auto* terrainVec = mutableMap->mutable_terrain();
for (size_t i = 0; i < terrainVec->size(); i++) {
// Only process tiles with ice
if (auto* terrain = terrainVec->GetMutableObject(i); terrain->modifier().ice().present()) {
// const_cast is safe here because we own the mutable buffer (mapCopy)
if (auto* terrain = const_cast<Terrain*>(terrainVec->GetMutableObject(i));
terrain->modifier().ice().present()) {
terrain->mutable_modifier().mutable_ice().mutate_present(false);
terrain->mutable_modifier().mutable_ice().mutate_integrity(0.0f);
}
@@ -22,6 +22,13 @@ using std::unique_ptr;
using ResolvedUnitProto = net::eagle0::shardok::storage::ResolvedUnit;
using GameState = net::eagle0::shardok::storage::fb::GameState;
using GameStateT = net::eagle0::shardok::storage::fb::GameStateT;
using Unit = net::eagle0::shardok::storage::fb::Unit;
// Helper to get a mutable unit from the units vector.
// const_cast is safe because we're accessing through a mutable GameState pointer.
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
}
using net::eagle0::shardok::storage::fb::DrawType;
using net::eagle0::shardok::storage::fb::VictoryCondition;
using net::eagle0::shardok::storage::fb::VictoryType;
@@ -37,7 +44,7 @@ void ApplyResolvedUnit(
if (unit.has_attached_hero() &&
unit.attached_hero().control_info().controlled_unit_id() != -1) {
const UnitId controlledUnitId = unit.attached_hero().control_info().controlled_unit_id();
auto *controlledUnit = inoutState->mutable_units()->GetMutableObject(controlledUnitId);
auto *controlledUnit = GetMutableUnit(inoutState, controlledUnitId);
internalAssert(controlledUnit->unit_id() == controlledUnitId);
internalAssert(controlledUnit->commanding_unit_id() == unitId);
controlledUnit->mutate_commanding_unit_id(-1);
@@ -47,7 +54,7 @@ void ApplyResolvedUnit(
if (unit.commanding_unit_id() != -1) {
const UnitId commandingUnitId = unit.commanding_unit_id();
auto *commandingUnit = inoutState->mutable_units()->GetMutableObject(commandingUnitId);
auto *commandingUnit = GetMutableUnit(inoutState, commandingUnitId);
internalAssert(commandingUnit->unit_id() == commandingUnitId);
internalAssert(
commandingUnit->attached_hero().control_info().controlled_unit_id() == unitId);
@@ -58,7 +65,7 @@ void ApplyResolvedUnit(
if (status == net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT &&
unit.has_attached_hero() && unit.attached_hero().is_vip()) {
for (uint32_t i = 0; i < inoutState->units()->size(); i++) {
auto *playerUnit = inoutState->mutable_units()->GetMutableObject(i);
auto *playerUnit = GetMutableUnit(inoutState, i);
if (playerUnit->player_id() != unit.player_id()) continue;
if (playerUnit->unit_id() == unit.unit_id()) continue;
@@ -70,7 +77,7 @@ void ApplyResolvedUnit(
}
}
inoutState->mutable_units()->GetMutableObject(unitId)->mutate_status(status);
GetMutableUnit(inoutState, unitId)->mutate_status(status);
}
void ApplyResolvedUnit(
@@ -189,7 +196,7 @@ void MutatingAddUnits(GameStateW &mutatingState, const ActionResultProto &result
// We only need to process the units that are being changed
for (const auto &unitBytes : result.changed_units_fb()) {
const auto *unit = (Unit *)unitBytes.data();
auto *mutableUnit = mutatingState->mutable_units()->GetMutableObject(unit->unit_id());
auto *mutableUnit = GetMutableUnit(mutatingState.Get(), unit->unit_id());
if (mutableUnit->status() ==
net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT) {
// Convert this reserved slot to a real unit
@@ -340,7 +347,7 @@ void MutatingApplyResult(
}
// Capture old position before applying changes
auto *mutableUnit = mutatingGameState->units()->GetMutableObject(changedUnit->unit_id());
auto *mutableUnit = GetMutableUnit(mutatingGameState.Get(), changedUnit->unit_id());
const auto oldLocation = mutableUnit->location();
fb::ApplyUnit(mutableUnit, changedUnit, status);
@@ -64,8 +64,16 @@ auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain * {
return map->terrain()->Get(coords.row() * map->column_count() + coords.column());
}
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain * {
// const_cast is safe because we're accessing through a mutable HexMap pointer
return const_cast<Terrain *>(map->mutable_terrain()->GetMutableObject(
coords.row() * map->column_count() + coords.column()));
}
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain * {
return map->terrain()->GetMutableObject(coords.row() * map->column_count() + coords.column());
// const_cast is safe when the underlying buffer is known to be mutable
return const_cast<Terrain *>(
map->terrain()->Get(coords.row() * map->column_count() + coords.column()));
}
auto HasForestAccess(
@@ -597,7 +605,9 @@ void MutatingSetTileModifier(
const int row,
const int column,
const TileModifierProto &TileModifierProto) {
auto *terr = hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column);
// const_cast is safe because we're accessing through a mutable HexMap pointer
auto *terr = const_cast<Terrain *>(
hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column));
if (TileModifierProto.has_bridge()) {
terr->mutable_modifier().mutable_bridge().mutate_present(true);
@@ -82,6 +82,9 @@ auto HasForestAccess(
PlayerId player) -> bool;
auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain *;
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain *;
// Overload for const HexMap - uses const_cast internally. Safe when the underlying buffer is
// mutable.
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain *;
auto CoordsAreValid(const HexMap *map, const Coords &coords) -> bool;
@@ -29,6 +29,13 @@ using UnitViewProto = net::eagle0::shardok::api::UnitView;
using Coords = net::eagle0::shardok::storage::fb::Coords;
using Unit = net::eagle0::shardok::storage::fb::Unit;
using GameState = net::eagle0::shardok::storage::fb::GameState;
// Helper to get a mutable unit from the units vector.
// const_cast is safe because we're accessing through a mutable GameState pointer.
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
}
constexpr int8_t kGuessedHeroStat = 75;
constexpr int8_t kGuessedBattalionStat = 0;
@@ -412,16 +419,13 @@ auto GameStateGuesser::GuessedState(
if (unit->has_attached_hero()) {
UnitId controlledUnitId = unit->attached_hero().control_info().controlled_unit_id();
if (controlledUnitId != -1) {
gsw->mutable_units()
->GetMutableObject(controlledUnitId)
->mutate_commanding_unit_id(unitId);
GetMutableUnit(gsw.Get(), controlledUnitId)->mutate_commanding_unit_id(unitId);
}
}
UnitId commandingUnitId = unit->commanding_unit_id();
if (unit->commanding_unit_id() != -1) {
gsw->mutable_units()
->GetMutableObject(commandingUnitId)
GetMutableUnit(gsw.Get(), commandingUnitId)
->mutable_attached_hero()
.mutable_control_info()
.mutate_controlled_unit_id(unitId);
@@ -93,6 +93,7 @@
<Compile Include="Assets/Eagle/ConnectionStatusUI.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerIconEditor.cs" />
<Compile Include="Assets/Eagle/NotificationPanel.cs" />
<Compile Include="Assets/Shardok/FreezeAnimator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Demo/DemoListShadow.cs" />
<Compile Include="Assets/HoveringTooltipTextProvider.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/util/KeyModifiedAmount.cs" />
@@ -107,6 +108,7 @@
<Compile Include="Assets/Eagle/CommandSelectors/FreeForAllDecisionCommandSelector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerButtonEditor.cs" />
<Compile Include="Assets/Eagle/CommandWarningPanelController.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialModalPanel.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Context Menu/ContextMenuManager.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoopEditor.cs" />
<Compile Include="Assets/Eagle/BattalionUtils.cs" />
@@ -124,6 +126,7 @@
<Compile Include="Assets/Modern UI Pack/Scripts/Notification/NotificationManagerEditor.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicEditor.cs" />
<Compile Include="Assets/ConnectionHandler/RunningGameItem.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialCanvasBuilder.cs" />
<Compile Include="Assets/Shardok/Table Rows/ArmyRowController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/PrisonerExchangeDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/Notification.cs" />
@@ -134,7 +137,9 @@
<Compile Include="Assets/Eagle/TextureList.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowManager.cs" />
<Compile Include="Assets/Eagle/Table Rows/BattalionRowController.cs" />
<Compile Include="Assets/Shardok/DismissAnimator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Toggle/ToggleAnim.cs" />
<Compile Include="Assets/Shardok/HolyWaveAnimator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerInputField.cs" />
<Compile Include="Assets/Shardok/ActionResultTypeManager.cs" />
<Compile Include="Assets/MainQueue.cs" />
@@ -151,6 +156,7 @@
<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" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
@@ -197,10 +203,12 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceAcceptedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Progress Bar/ProgressBarEditor.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Dropdown/CustomDropdownEditor.cs" />
<Compile Include="Assets/Shardok/WaterEffectAnimator.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/RansomRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/FactionsTableController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeSlider.cs" />
<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" />
@@ -218,8 +226,10 @@
<Compile Include="Assets/GUI Pro Kit Fantasy RPG/Extensions/UIParticle/UIParticleSystem.cs" />
<Compile Include="Assets/Shardok/SoundManager.cs" />
<Compile Include="Assets/Shardok/HexMesh.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialSequence.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/DiplomacyCommandSelector.cs" />
<Compile Include="Assets/Tutorial/Triggers/TutorialTriggerRegistry.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveBreakAllianceCommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveAllianceCommandSelector.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/WithdrewForTruceDetailsNotificationGenerator.cs" />
@@ -228,6 +238,7 @@
<Compile Include="Assets/Eagle/CustomFileLogger.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationAcceptedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/ProvinceEventsNotificationGenerator.cs" />
<Compile Include="Assets/Tutorial/TutorialManager.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/FeastCommandSelector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Animated Icon/AnimatedIconHandler.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/GenericNotificationGenerator.cs" />
@@ -236,6 +247,7 @@
<Compile Include="Assets/ConnectionKiller.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RadialSliderEditor.cs" />
<Compile Include="Assets/Eagle/Table Rows/MovingArmyPopupRowController.cs" />
<Compile Include="Assets/Shardok/DuelAnimator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Dropdown/CustomDropdown.cs" />
<Compile Include="Assets/Eagle/Notifications/NotificationDispatcher.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Demo/DemoTopButton.cs" />
@@ -256,6 +268,7 @@
<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" />
<Compile Include="Assets/Modern UI Pack/Scripts/Progress Bar/ProgressBar.cs" />
@@ -277,6 +290,8 @@
<Compile Include="Assets/Modern UI Pack/Scripts/Fixes/LayoutGroupPositionFix.cs" />
<Compile Include="Assets/common/Logger.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/AllianceAcceptedNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/ToolAnimator.cs" />
<Compile Include="Assets/Shardok/ScoutAnimator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerModalWindow.cs" />
<Compile Include="Assets/Eagle/HeroDetailsController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerEditor.cs" />
@@ -284,6 +299,7 @@
<Compile Include="Assets/Eagle/Table Rows/UnitSelectorHeroRowController.cs" />
<Compile Include="Assets/ConnectionHandler/CustomBattleHandler.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/RansomPaidDetailsNotificationGenerator.cs" />
<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/Modern UI Pack/Scripts/Slider/SliderManagerEditor.cs" />
@@ -299,14 +315,17 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/CapturedHeroReturnedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/DivineCommandSelector.cs" />
<Compile Include="Assets/Shardok/Unit.cs" />
<Compile Include="Assets/Shardok/FearAnimator.cs" />
<Compile Include="Assets/Eagle/ProvinceInfoPanelController.cs" />
<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/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" />
<Compile Include="Assets/Eagle/Notifications/NotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/DynamicTextNotification.cs" />
@@ -342,6 +361,7 @@
<Compile Include="Assets/Eagle/IClientConnectionSubscriber.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/AlmsCommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveDiplomacyCommandSelector.cs" />
<Compile Include="Assets/Shardok/FleeAnimator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerWithIcon.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Horizontal Selector/HorizontalSelectorEditor.cs" />
<Compile Include="Assets/Eagle/PopupPanelController.cs" />
@@ -351,7 +371,9 @@
<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/Tutorial/UI/TutorialUIManager.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" />
@@ -364,12 +386,15 @@
<Compile Include="Assets/Eagle/Table Rows/IncomingArmyTableRow.cs" />
<Compile Include="Assets/ConnectionHandler/CreateGameItem.cs" />
<Compile Include="Assets/Eagle/EagleGameController.cs" />
<Compile Include="Assets/Tutorial/TutorialTestSetup.cs" />
<Compile Include="Assets/Eagle/Notifications/FailedSwearBrotherhoodNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/FireEffectAnimator.cs" />
<Compile Include="Assets/common/GUIUtils/EventBasedTable.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Window/WindowManagerEditor.cs" />
<Compile Include="Assets/Eagle/Notifications/FactionDestroyedNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerNotification.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/HandleCapturedHeroesCommandSelector.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialOverlayController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ARNNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/AttackDecisionCommandSelector.cs" />
<Compile Include="Assets/Eagle/Table Rows/AvailableHeroTableRow.cs" />
@@ -58,10 +58,19 @@ namespace Auth {
/// Routed to Go auth service.
/// </summary>
/// <param name="provider">Discord or Google</param>
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
/// <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) {
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}");
@@ -94,6 +103,10 @@ 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) {
@@ -170,18 +183,16 @@ namespace Auth {
/// <summary>
/// Logout - invalidates refresh token on server.
/// Does NOT clear local tokens (OAuthManager handles that decision).
/// Routed to Eagle.
/// </summary>
public async Task LogoutAsync() {
try {
await _eagleClient.LogoutAsync(new LogoutRequest());
Debug.Log("[AuthClient] Server logout successful");
} catch (Exception ex) {
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
}
// Clear local tokens regardless of server response
TokenStorage.Clear();
Debug.Log("[AuthClient] Logged out, tokens cleared");
}
public void Dispose() {
@@ -0,0 +1,141 @@
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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a2a19f11d1f82412e8e2811b366113ac
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
@@ -9,6 +10,7 @@ namespace Auth {
/// - Opens system browser for OAuth consent
/// - Polls server for OAuth completion (no deep links needed)
/// - Token storage and refresh
/// - Multi-account support
/// </summary>
public class OAuthManager : MonoBehaviour {
public static OAuthManager Instance { get; private set; }
@@ -16,12 +18,14 @@ namespace Auth {
private AuthClient _authClient;
private string _currentAuthServiceUrl;
private string _currentEagleUrl;
private OAuthProvider _currentLoginProvider; // Track provider during login
// Events for UI updates
public event Action<UserInfo> OnLoginSuccess;
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;
@@ -80,9 +84,14 @@ namespace Auth {
/// </summary>
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
_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);
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider, invitationCode);
// Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
@@ -91,16 +100,26 @@ namespace Auth {
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// Store tokens
// 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");
}
// Store tokens with provider info
var providerName = provider.ToString().ToLowerInvariant();
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName ?? "");
response.User.DisplayName ?? "",
providerName);
// Notify listeners
// Clear invitation code after successful new account creation
if (response.IsNewUser) {
InvitationCodeManager.ClearInvitationCode();
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
OnLoginSuccess?.Invoke(response.User);
@@ -156,6 +175,15 @@ namespace Auth {
// Validate session with server
try {
var response = await _authClient.GetCurrentUserAsync();
// Check if user still needs to set display name (e.g., closed app before setting
// it)
if (string.IsNullOrEmpty(response.User.DisplayName)) {
Debug.Log("[OAuthManager] Session restored but user needs display name");
OnNewUserNeedsDisplayName?.Invoke(false); // false = not a brand new user
return true;
}
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
return true;
@@ -178,16 +206,89 @@ namespace Auth {
}
/// <summary>
/// Logout and clear stored tokens.
/// Logout - clears current session but preserves stored tokens for quick re-login.
/// </summary>
public async Task LogoutAsync() {
// LogoutAsync can work even without server connection - just clear local tokens
// Notify server of logout if connected
if (_authClient != null) {
await _authClient.LogoutAsync();
} else {
TokenStorage.Clear();
try {
await _authClient.LogoutAsync();
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Server logout failed: {ex.Message}");
}
}
// Clear current account selection but keep tokens stored
TokenStorage.ClearCurrentAccount();
OnLogout?.Invoke();
}
/// <summary>
/// Get all stored accounts for display in UI.
/// </summary>
public IReadOnlyList<StoredAccount> GetStoredAccounts() {
return TokenStorage.GetAllAccounts();
}
/// <summary>
/// Select and connect with a stored account.
/// If token is expired or about to expire, refreshes it first.
/// </summary>
public async Task<bool> ConnectWithStoredAccountAsync(StoredAccount account) {
EnsureConfigured();
// Select this account as current
TokenStorage.SelectAccount(account.AccountKey);
// Check if token needs refresh
if (!account.HasValidToken || account.NeedsRefresh) {
if (account.HasRefreshToken) {
try {
await _authClient.RefreshTokenAsync();
Debug.Log($"[OAuthManager] Refreshed token for {account.DisplayName}");
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Token refresh failed: {ex.Message}");
// Token refresh failed - need to re-authenticate
// Parse provider from account key
var provider = account.Provider.ToLowerInvariant() == "google"
? OAuthProvider.Google
: OAuthProvider.Discord;
OnLoginFailed?.Invoke("Session expired. Please sign in again.");
return false;
}
} else {
OnLoginFailed?.Invoke("Session expired. Please sign in again.");
return false;
}
}
// Validate session with server
try {
var response = await _authClient.GetCurrentUserAsync();
// Check if user still needs to set display name
if (string.IsNullOrEmpty(response.User.DisplayName)) {
Debug.Log("[OAuthManager] Stored account needs display name");
OnNewUserNeedsDisplayName?.Invoke(false);
return true;
}
Debug.Log(
$"[OAuthManager] Connected with stored account: {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
return true;
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
OnLoginFailed?.Invoke("Session invalid. Please sign in again.");
return false;
}
}
/// <summary>
/// Remove a stored account permanently.
/// </summary>
public void RemoveStoredAccount(StoredAccount account) {
TokenStorage.RemoveAccount(account.AccountKey);
}
}
}
@@ -1,27 +1,63 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Auth {
/// <summary>
/// Represents a stored OAuth account with tokens.
/// </summary>
[Serializable]
public class StoredAccount {
public string Provider; // "discord" or "google"
public string UserId; // OAuth provider user ID
public string DisplayName; // User's display name
public string AccessToken;
public string RefreshToken;
public long ExpiresAt; // Unix timestamp
public string AccountKey => $"{Provider}:{UserId}";
public bool HasValidToken => !string.IsNullOrEmpty(AccessToken) &&
ExpiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
public bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
public bool NeedsRefresh {
get {
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return ExpiresAt > 0 && ExpiresAt - now < 300; // 5 minutes
}
}
}
/// <summary>
/// Container for all stored accounts, serialized to JSON.
/// </summary>
[Serializable]
public class StoredAccountsData {
public List<StoredAccount> Accounts = new();
public string CurrentAccountKey; // Provider:UserId of active account
}
/// <summary>
/// Secure storage for OAuth tokens using Unity's PlayerPrefs.
/// Supports multiple accounts - tokens are preserved on logout.
/// Values are cached in memory to allow access from background threads.
/// In production, consider using platform-specific secure storage
/// (Keychain on iOS, Keystore on Android).
/// </summary>
public static class TokenStorage {
private const string AccessTokenKey = "eagle0_access_token";
private const string RefreshTokenKey = "eagle0_refresh_token";
private const string ExpiresAtKey = "eagle0_expires_at";
private const string UserIdKey = "eagle0_user_id";
private const string DisplayNameKey = "eagle0_display_name";
private const string AccountsDataKey = "eagle0_accounts_data";
// In-memory cache for thread-safe access from background threads
// PlayerPrefs can only be accessed from the main thread
private static string _cachedAccessToken;
private static string _cachedRefreshToken;
private static long _cachedExpiresAt;
private static string _cachedUserId;
private static string _cachedDisplayName;
// Legacy keys for migration
private const string LegacyAccessTokenKey = "eagle0_access_token";
private const string LegacyRefreshTokenKey = "eagle0_refresh_token";
private const string LegacyExpiresAtKey = "eagle0_expires_at";
private const string LegacyUserIdKey = "eagle0_user_id";
private const string LegacyDisplayNameKey = "eagle0_display_name";
// In-memory cache
private static StoredAccountsData _accountsData;
private static bool _cacheInitialized = false;
/// <summary>
@@ -29,147 +65,245 @@ namespace Auth {
/// Call this early in app startup (e.g., in Awake or Start).
/// </summary>
public static void InitializeCache() {
_cachedAccessToken = PlayerPrefs.GetString(AccessTokenKey, null);
_cachedRefreshToken = PlayerPrefs.GetString(RefreshTokenKey, null);
_cachedExpiresAt =
long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
_cachedUserId = PlayerPrefs.GetString(UserIdKey, null);
_cachedDisplayName = PlayerPrefs.GetString(DisplayNameKey, null);
_cacheInitialized = true;
var json = PlayerPrefs.GetString(AccountsDataKey, "");
Debug.Log(
$"[TokenStorage] Cache initialized, hasToken={!string.IsNullOrEmpty(_cachedAccessToken)}");
}
public static bool HasValidToken {
get {
var token = AccessToken;
var expiresAt = ExpiresAt;
return !string.IsNullOrEmpty(token) &&
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
}
public static bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
public static string AccessToken {
get {
if (!_cacheInitialized) {
Debug.LogWarning(
"[TokenStorage] Cache not initialized, returning cached value anyway");
if (!string.IsNullOrEmpty(json)) {
try {
_accountsData = JsonUtility.FromJson<StoredAccountsData>(json);
} catch (Exception ex) {
Debug.LogWarning($"[TokenStorage] Failed to parse accounts data: {ex.Message}");
_accountsData = new StoredAccountsData();
}
return _cachedAccessToken;
} else {
_accountsData = new StoredAccountsData();
// Try to migrate legacy single-account data
MigrateLegacyData();
}
private
set {
_cachedAccessToken = value;
PlayerPrefs.SetString(AccessTokenKey, value ?? "");
_cacheInitialized = true;
Debug.Log(
$"[TokenStorage] Cache initialized, {_accountsData.Accounts.Count} accounts stored");
}
private static void MigrateLegacyData() {
var legacyAccessToken = PlayerPrefs.GetString(LegacyAccessTokenKey, "");
if (string.IsNullOrEmpty(legacyAccessToken)) return;
var legacyRefreshToken = PlayerPrefs.GetString(LegacyRefreshTokenKey, "");
var legacyExpiresAt =
long.TryParse(PlayerPrefs.GetString(LegacyExpiresAtKey, "0"), out var val) ? val
: 0;
var legacyUserId = PlayerPrefs.GetString(LegacyUserIdKey, "");
var legacyDisplayName = PlayerPrefs.GetString(LegacyDisplayNameKey, "");
if (!string.IsNullOrEmpty(legacyUserId)) {
// Assume discord for legacy data (most common)
var account = new StoredAccount {
Provider = "discord",
UserId = legacyUserId,
DisplayName = legacyDisplayName,
AccessToken = legacyAccessToken,
RefreshToken = legacyRefreshToken,
ExpiresAt = legacyExpiresAt
};
_accountsData.Accounts.Add(account);
_accountsData.CurrentAccountKey = account.AccountKey;
SaveAccountsData();
// Clear legacy keys
PlayerPrefs.DeleteKey(LegacyAccessTokenKey);
PlayerPrefs.DeleteKey(LegacyRefreshTokenKey);
PlayerPrefs.DeleteKey(LegacyExpiresAtKey);
PlayerPrefs.DeleteKey(LegacyUserIdKey);
PlayerPrefs.DeleteKey(LegacyDisplayNameKey);
PlayerPrefs.Save();
Debug.Log($"[TokenStorage] Migrated legacy account: {account.DisplayName}");
}
}
public static string RefreshToken {
get => _cachedRefreshToken;
private
set {
_cachedRefreshToken = value;
PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
}
private static void SaveAccountsData() {
var json = JsonUtility.ToJson(_accountsData);
PlayerPrefs.SetString(AccountsDataKey, json);
PlayerPrefs.Save();
}
public static long ExpiresAt {
get => _cachedExpiresAt;
private
set {
_cachedExpiresAt = value;
PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
}
/// <summary>
/// Get all stored accounts.
/// </summary>
public static IReadOnlyList<StoredAccount> GetAllAccounts() {
EnsureInitialized();
return _accountsData.Accounts.AsReadOnly();
}
public static string UserId {
get => _cachedUserId;
private
set {
_cachedUserId = value;
PlayerPrefs.SetString(UserIdKey, value ?? "");
}
}
public static string DisplayName {
get => _cachedDisplayName;
private
set {
_cachedDisplayName = value;
PlayerPrefs.SetString(DisplayNameKey, value ?? "");
/// <summary>
/// Get the currently active account, or null if none selected.
/// </summary>
public static StoredAccount CurrentAccount {
get {
EnsureInitialized();
if (string.IsNullOrEmpty(_accountsData.CurrentAccountKey)) return null;
return _accountsData.Accounts.Find(
a => a.AccountKey == _accountsData.CurrentAccountKey);
}
}
/// <summary>
/// Store tokens received from OAuth exchange.
/// Select an account as the current active account.
/// </summary>
public static void SelectAccount(string accountKey) {
EnsureInitialized();
var account = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
if (account != null) {
_accountsData.CurrentAccountKey = accountKey;
SaveAccountsData();
Debug.Log($"[TokenStorage] Selected account: {account.DisplayName}");
}
}
/// <summary>
/// Clear the current account selection (logout without deleting tokens).
/// </summary>
public static void ClearCurrentAccount() {
EnsureInitialized();
_accountsData.CurrentAccountKey = null;
SaveAccountsData();
Debug.Log("[TokenStorage] Cleared current account selection");
}
/// <summary>
/// Remove a specific account and its tokens.
/// </summary>
public static void RemoveAccount(string accountKey) {
EnsureInitialized();
var removed = _accountsData.Accounts.RemoveAll(a => a.AccountKey == accountKey);
if (_accountsData.CurrentAccountKey == accountKey) {
_accountsData.CurrentAccountKey = null;
}
if (removed > 0) {
SaveAccountsData();
Debug.Log($"[TokenStorage] Removed account: {accountKey}");
}
}
// Compatibility properties that reference the current account
public static bool HasValidToken => CurrentAccount?.HasValidToken ?? false;
public static bool HasRefreshToken => CurrentAccount?.HasRefreshToken ?? false;
public static string AccessToken => CurrentAccount?.AccessToken;
public static string RefreshToken => CurrentAccount?.RefreshToken;
public static long ExpiresAt => CurrentAccount?.ExpiresAt ?? 0;
public static string UserId => CurrentAccount?.UserId;
public static string DisplayName => CurrentAccount?.DisplayName;
public static bool NeedsRefresh => CurrentAccount?.NeedsRefresh ?? false;
/// <summary>
/// Store tokens for an account. Creates new or updates existing.
/// </summary>
public static void StoreTokens(
string accessToken,
string refreshToken,
long expiresAt,
string userId,
string displayName) {
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresAt = expiresAt;
UserId = userId;
DisplayName = displayName;
PlayerPrefs.Save();
string displayName,
string provider = "discord") {
EnsureInitialized();
Debug.Log(
$"[TokenStorage] Stored tokens for user {displayName} (expires at {expiresAt})");
var accountKey = $"{provider}:{userId}";
var existingAccount = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
if (existingAccount != null) {
existingAccount.AccessToken = accessToken;
existingAccount.RefreshToken = refreshToken;
existingAccount.ExpiresAt = expiresAt;
existingAccount.DisplayName = displayName;
} else {
var newAccount = new StoredAccount {
Provider = provider,
UserId = userId,
DisplayName = displayName,
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresAt = expiresAt
};
_accountsData.Accounts.Add(newAccount);
}
// Set as current account
_accountsData.CurrentAccountKey = accountKey;
SaveAccountsData();
Debug.Log($"[TokenStorage] Stored tokens for {displayName} ({provider})");
}
/// <summary>
/// Update access token after refresh.
/// Update access token after refresh for the current account.
/// </summary>
public static void UpdateAccessToken(string accessToken, long expiresAt) {
AccessToken = accessToken;
ExpiresAt = expiresAt;
PlayerPrefs.Save();
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
EnsureInitialized();
var account = CurrentAccount;
if (account != null) {
account.AccessToken = accessToken;
account.ExpiresAt = expiresAt;
SaveAccountsData();
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
}
}
/// <summary>
/// Update display name after user sets it.
/// Update access token for a specific account (by key).
/// </summary>
public static void
UpdateAccessTokenForAccount(string accountKey, string accessToken, long expiresAt) {
EnsureInitialized();
var account = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
if (account != null) {
account.AccessToken = accessToken;
account.ExpiresAt = expiresAt;
SaveAccountsData();
Debug.Log(
$"[TokenStorage] Updated access token for {account.DisplayName} (expires at {expiresAt})");
}
}
/// <summary>
/// Update display name for the current account.
/// </summary>
public static void UpdateDisplayName(string displayName) {
DisplayName = displayName;
PlayerPrefs.Save();
EnsureInitialized();
var account = CurrentAccount;
if (account != null) {
account.DisplayName = displayName;
SaveAccountsData();
}
}
/// <summary>
/// Clear all stored tokens (logout).
/// Clear all stored accounts (full reset).
/// </summary>
public static void Clear() {
_cachedAccessToken = null;
_cachedRefreshToken = null;
_cachedExpiresAt = 0;
_cachedUserId = null;
_cachedDisplayName = null;
PlayerPrefs.DeleteKey(AccessTokenKey);
PlayerPrefs.DeleteKey(RefreshTokenKey);
PlayerPrefs.DeleteKey(ExpiresAtKey);
PlayerPrefs.DeleteKey(UserIdKey);
PlayerPrefs.DeleteKey(DisplayNameKey);
public static void ClearAll() {
_accountsData = new StoredAccountsData();
PlayerPrefs.DeleteKey(AccountsDataKey);
PlayerPrefs.Save();
Debug.Log("[TokenStorage] Cleared all tokens");
Debug.Log("[TokenStorage] Cleared all accounts");
}
/// <summary>
/// Check if token needs refresh (expires within 5 minutes).
/// Legacy Clear method - now just clears current selection, not tokens.
/// </summary>
public static bool NeedsRefresh {
get {
var expiresAt = ExpiresAt;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return expiresAt > 0 && expiresAt - now < 300; // 5 minutes
public static void Clear() { ClearCurrentAccount(); }
private static void EnsureInitialized() {
if (!_cacheInitialized) {
Debug.LogWarning("[TokenStorage] Cache not initialized, initializing now");
InitializeCache();
}
}
}
@@ -80,9 +80,6 @@ public class AuthInterceptor : Interceptor {
};
public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TMP_Dropdown environmentDropdown;
public TMP_InputField nameField;
public TMP_InputField passwordField;
public TMP_Dropdown resolutionDropdown;
[Header("OAuth UI")]
@@ -91,16 +88,23 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button googleLoginButton;
public TextMeshProUGUI oauthStatusText;
[Header("Stored Accounts")]
public GameObject storedAccountsContainer;
public GameObject 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("Legacy Auth (for testing)")]
public GameObject legacyAuthPanel;
public Button useLegacyAuthButton;
public TextMeshProUGUI useLegacyAuthButtonText;
[Header("Invitation Code Entry")]
public GameObject invitationCodePanel;
public TMP_InputField invitationCodeField;
public Button submitInvitationCodeButton;
public TextMeshProUGUI invitationCodeErrorText;
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
@@ -114,7 +118,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button logoutButton;
public Button customBattleButton;
public Button cancelCustomBattleButton;
public TextMeshProUGUI lobbyEnvironmentText;
public TMP_Dropdown lobbyEnvironmentDropdown;
public TextMeshProUGUI lobbyUserText;
public GameObject runningGamesListArea;
@@ -142,7 +146,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private CancellationTokenSource _cancellationTokenSource;
private readonly Object pendingReplyLock = new Object();
private int _connectedEnvironmentIndex = -1; // -1 means not connected
private bool _useOAuth = false; // Default to legacy until server-side OAuth is ready
private List<GameObject> _storedAccountButtons = new List<GameObject>();
public ClientPregeneratedText clientPregeneratedText;
@@ -166,7 +170,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
: null;
private string GetUrlFromEnvironment() {
int envIndex = environmentDropdown.value;
int envIndex = PlayerPrefs.GetInt(EnvironmentKey, 0);
string prefix = EnvironmentOptions[envIndex];
return prefix + BaseDomain;
}
@@ -199,14 +203,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
resolutionDropdown.AddOptions(resolutions.Select(r => $"{r.width} x {r.height}").ToList());
resolutionDropdown.value = resolutions.ToList().IndexOf(currentResolution);
// Set up environment dropdown
environmentDropdown.ClearOptions();
environmentDropdown.AddOptions(EnvironmentDisplayNames);
environmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
nameField.text = PlayerPrefs.GetString(NameKey, "sample_name");
passwordField.text = PlayerPrefs.GetString(PasswordKey, "sample_password");
connectionCanvas.gameObject.SetActive(true);
eagleCanvas.gameObject.SetActive(false);
shardokCanvas.gameObject.SetActive(false);
@@ -232,7 +228,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void SetupOAuthUI() {
// Set up OAuth button click handlers
// Set up OAuth button click handlers for new sign-ins
if (discordLoginButton != null) {
discordLoginButton.onClick.AddListener(
() => OnOAuthLoginClicked(OAuthProvider.Discord));
@@ -243,8 +239,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (setDisplayNameButton != null) {
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
}
if (useLegacyAuthButton != null) {
useLegacyAuthButton.onClick.AddListener(OnUseLegacyAuthClicked);
if (submitInvitationCodeButton != null) {
submitInvitationCodeButton.onClick.AddListener(OnSubmitInvitationCodeClicked);
}
// Subscribe to OAuthManager events
@@ -253,26 +249,83 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
OAuthManager.Instance.OnLogout += OnOAuthLogout;
OAuthManager.Instance.OnInvitationRequired += OnInvitationRequired;
}
// Show appropriate panel based on auth state
// Show auth panel with stored accounts
ShowAuthPanel();
}
private void ShowAuthPanel() {
// Hide all auth panels first
if (oauthPanel != null) oauthPanel.SetActive(false);
// Hide other panels
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
// Show appropriate panel based on _useOAuth
if (_useOAuth) {
if (oauthPanel != null) oauthPanel.SetActive(true);
if (useLegacyAuthButtonText != null)
useLegacyAuthButtonText.text = "Use Classic Sign-in";
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
// Refresh stored account buttons
RefreshStoredAccountButtons();
}
private void RefreshStoredAccountButtons() {
// Clear existing buttons
foreach (var btn in _storedAccountButtons) {
if (btn != null) Destroy(btn);
}
_storedAccountButtons.Clear();
if (storedAccountsContainer == null || storedAccountButtonPrefab == null) return;
// Get stored accounts
var accounts = OAuthManager.Instance?.GetStoredAccounts();
if (accounts == null || accounts.Count == 0) return;
// Create button for each stored account
foreach (var account in accounts) {
var buttonObj =
Instantiate(storedAccountButtonPrefab, storedAccountsContainer.transform);
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; }
// 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;
}
}
// Set click handler
var button = buttonObj.GetComponent<Button>();
if (button != null) {
var accountCopy = account; // Capture for lambda
button.onClick.AddListener(() => OnStoredAccountClicked(accountCopy));
}
_storedAccountButtons.Add(buttonObj);
}
}
private async void OnStoredAccountClicked(StoredAccount account) {
// Configure OAuthManager with URLs
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
if (oauthStatusText != null) {
oauthStatusText.text = $"Connecting as {account.DisplayName}...";
}
var success = await OAuthManager.Instance.ConnectWithStoredAccountAsync(account);
if (success) {
// OnLoginSuccess will handle connection to lobby
} else {
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
// Failed - might need re-auth, refresh the buttons
RefreshStoredAccountButtons();
}
}
@@ -282,22 +335,56 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (cancelCustomBattleButton != null) {
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
}
// Set up environment dropdown
if (lobbyEnvironmentDropdown != null) {
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
}
private void UpdateLobbyStatusDisplays() {
// Show environment (prod/qa)
if (lobbyEnvironmentText != null) {
lobbyEnvironmentText.text = ConnectedEnvironmentName ?? "";
// Update environment dropdown selection
if (lobbyEnvironmentDropdown != null && _connectedEnvironmentIndex >= 0) {
// Temporarily remove listener to avoid triggering reconnect
lobbyEnvironmentDropdown.onValueChanged.RemoveListener(OnLobbyEnvironmentChanged);
lobbyEnvironmentDropdown.value = _connectedEnvironmentIndex;
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
// Show current user - prefer OAuth display name, fall back to classic login name
if (lobbyUserText != null) {
if (_useOAuth && !string.IsNullOrEmpty(TokenStorage.DisplayName)) {
lobbyUserText.text = TokenStorage.DisplayName;
} else {
lobbyUserText.text = nameField.text;
}
}
// Show current user from OAuth
if (lobbyUserText != null) { lobbyUserText.text = TokenStorage.DisplayName ?? ""; }
}
private void OnLobbyEnvironmentChanged(int newEnvironmentIndex) {
if (newEnvironmentIndex == _connectedEnvironmentIndex) return;
Debug.Log(
$"[ConnectionHandler] Switching environment from {ConnectedEnvironmentName} to {EnvironmentDisplayNames[newEnvironmentIndex]}");
// Disconnect from current environment
_persistentClientConnection?.Dispose();
eagleConnection?.Dispose();
_httpClient?.Dispose();
_persistentClientConnection = null;
eagleConnection = null;
_httpClient = null;
// Update environment index and reconnect
_connectedEnvironmentIndex = newEnvironmentIndex;
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
// Clear current game lists while reconnecting
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
foreach (Transform row in availableGamesListArea.transform) { Destroy(row.gameObject); }
// Reconnect to new environment
_createConnection();
RequestMaps();
StartListeningForLobbyUpdates();
}
private async void OnLogoutClicked() {
@@ -378,11 +465,53 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
MainQueue.Q.Enqueue(() => {
// Show display name panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(true);
if (displayNameErrorText != null) displayNameErrorText.text = "";
});
}
private void OnInvitationRequired() {
MainQueue.Q.Enqueue(() => {
// Show invitation code entry panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(true);
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text =
"An invitation code is required to create a new account.";
}
if (invitationCodeField != null) invitationCodeField.text = "";
});
}
private void OnSubmitInvitationCodeClicked() {
if (invitationCodeField == null) return;
var code = invitationCodeField.text.Trim();
if (string.IsNullOrEmpty(code)) {
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text = "Please enter an invitation code";
}
return;
}
// Save the code and return to login screen to retry
InvitationCodeManager.SetInvitationCode(code);
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text = "Code saved. Please sign in again.";
}
// Return to auth panel after a short delay
MainQueue.Q.Enqueue(() => {
if (oauthStatusText != null) {
oauthStatusText.text = "Invitation code saved. Please sign in to continue.";
}
ShowAuthPanel();
});
}
private async void OnSetDisplayNameClicked() {
if (displayNameField == null || OAuthManager.Instance == null) return;
@@ -410,27 +539,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
});
}
private void OnUseLegacyAuthClicked() {
_useOAuth = !_useOAuth; // Toggle instead of just setting false
if (_useOAuth) {
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
if (useLegacyAuthButtonText != null)
useLegacyAuthButtonText.text = "Use Classic Sign-in";
} else {
// Show legacy panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
}
}
private void ConnectWithOAuth() {
_useOAuth = true;
_internalConnectEagle();
}
private void ConnectWithOAuth() { _internalConnectEagle(); }
private float _statusUpdateTimer = 0f;
private const float StatusUpdateInterval = 0.5f;
@@ -634,10 +743,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void _createConnection() {
_connectedEnvironmentIndex = environmentDropdown.value;
PlayerPrefs.SetInt(EnvironmentKey, _connectedEnvironmentIndex);
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
_connectedEnvironmentIndex = PlayerPrefs.GetInt(EnvironmentKey, 0);
// Dispose existing connections before creating new ones
_httpClient?.Dispose();
@@ -650,25 +756,18 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
string url = GetUrlFromEnvironment();
if (_useOAuth && TokenStorage.HasValidToken) {
// Use JWT-based connection
eagleConnection = EagleConnection.CreateWithJwt(url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
} else {
// Legacy Basic Auth connection
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
// OAuth JWT-based connection
if (!TokenStorage.HasValidToken) {
Debug.LogError("[ConnectionHandler] No valid token available for connection");
return;
}
eagleConnection = EagleConnection.CreateWithJwt(url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
_persistentClientConnection = new PersistentClientConnection(
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: af9f637b3a1f6433f819a0bcdb326453
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,427 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &1262787222422545696
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6242087783640990751}
- component: {fileID: 5444015614922696687}
- component: {fileID: 8871991993173924918}
- component: {fileID: 6965305925514130928}
m_Layer: 5
m_Name: ProviderIcon
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6242087783640990751
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1262787222422545696}
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!222 &5444015614922696687
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
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
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: 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: 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: 21300000, guid: b6b68c8c37d5cf1419267a80c426ac83, 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 &5166012670666351313
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 157686408557551303}
- component: {fileID: 3634401029247236684}
- component: {fileID: 7790188191322585408}
- component: {fileID: 646268409010340088}
- component: {fileID: 3097176747511135857}
- component: {fileID: 7691398338434978342}
m_Layer: 5
m_Name: StoredAccountButton
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &157686408557551303
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5166012670666351313}
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: 7974514965529143181}
- {fileID: 6242087783640990751}
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_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &3634401029247236684
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5166012670666351313}
m_CullTransparentMesh: 1
--- !u!114 &7790188191322585408
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: 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: 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: 10905, guid: 0000000000000000f000000000000000, type: 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 &646268409010340088
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: 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: 7790188191322585408}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!114 &3097176747511135857
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: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 400
m_PreferredHeight: 60
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &7691398338434978342
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: 30649d3a9faa99c48a7b1166b86bf2a0, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.HorizontalLayoutGroup
m_Padding:
m_Left: 0
m_Right: 0
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 5
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!1 &5617793748733146591
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7974514965529143181}
- component: {fileID: 4513536456760004536}
- component: {fileID: 6210669843299902677}
- component: {fileID: 572895541873054588}
m_Layer: 5
m_Name: Label
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7974514965529143181
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5617793748733146591}
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!222 &4513536456760004536
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5617793748733146591}
m_CullTransparentMesh: 1
--- !u!114 &6210669843299902677
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5617793748733146591}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
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: nolen (dan@danielcrosby.net)
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: 4281479730
m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, 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_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 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 &572895541873054588
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5617793748733146591}
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: -1
m_PreferredHeight: 60
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6440904d10a244010b3a91e0aa247749
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using common;
using common.GUIUtils;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
@@ -158,9 +159,11 @@ namespace eagle {
public void StopAll() {
_newModel = null;
ModelUpdater.StopListeningForUpdates();
ModelUpdater.UpdateAction = null;
ModelUpdater = null;
if (ModelUpdater != null) {
ModelUpdater.StopListeningForUpdates();
ModelUpdater.UpdateAction = null;
ModelUpdater = null;
}
SwapModel();
Model = null;
chronicleCanvasController.Entries = new List<ChronicleEntry>();
@@ -281,6 +284,13 @@ namespace eagle {
MainQueue.Q.EnqueueForNextUpdate(
() => { _ = ModelUpdater.StartListeningForUpdates(); });
// Initialize tutorial system
TutorialManager.Instance?.Initialize(this, null);
if (TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
}
#if UNITY_EDITOR
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
#endif
@@ -362,6 +372,12 @@ namespace eagle {
SetNextActiveProvinceButton();
_dominionPanelController.ForceUpdate();
SetMusic();
// Notify tutorial system of province selection
if (pid.HasValue) {
TutorialManager.Instance?.TriggerRegistry?.OnProvinceSelected(
Model.Provinces[pid.Value]);
}
}
private void PrefetchHeadshotForHeroes(List<HeroView> heroViews) {
@@ -477,6 +493,9 @@ namespace eagle {
var oldModel = Model;
Model = _newModel;
// Notify tutorial system of model change
TutorialManager.Instance?.TriggerRegistry?.OnModelUpdated(Model, oldModel);
provinceInfoPanelController.Model = _newModel;
freeHeroesTableController.Model = _newModel;
movingArmiesTableController.Model = _newModel;
@@ -625,6 +644,9 @@ namespace eagle {
}
private void PostCommittedCommand(ProvinceId provinceId, SelectedCommand selectedCommand) {
// Notify tutorial system of command
TutorialManager.Instance?.TriggerRegistry?.OnCommandIssued(selectedCommand);
ModelUpdater.PostCommand(provinceId: provinceId, command: selectedCommand)
.ContinueWith(response => {
if (response.IsFaulted) { errorHandler.Add(response.Exception); }
@@ -6,7 +6,7 @@ TextureImporter:
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:
@@ -6,7 +6,7 @@ TextureImporter:
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:
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,9 @@ using System.Diagnostics;
using System.IO;
using common;
using eagle;
using Eagle0.Tutorial;
using Net.Eagle0.Shardok.Api;
using Shardok;
using TMPro;
using UnityEditor;
using UnityEngine;
@@ -28,6 +30,9 @@ public class SettingsPanelController : MonoBehaviour {
public Toggle tooltipHugsCursorToggle;
public HoveringTooltip hoveringTooltip;
public Toggle showAnimationTestsToggle;
public AnimationTestController animationTestController;
public Slider autoScrollSpeedSlider;
public TMP_Text autoScrollSpeedLabel;
@@ -39,11 +44,14 @@ public class SettingsPanelController : MonoBehaviour {
private const string MusicVolumeKey = "musicVolume";
public const string UseGoDiceKey = "useGoDice";
private const string TooltipHugsCursorKey = "tooltipHugsCursor";
private const string AnimationToggleKey = "animationToggle";
public EagleGameController eagleGameController;
public GameObject ShardokCanvas;
public ConnectionHandler connectionHandler;
public Button resetTutorialsButton;
void Start() {
_active = false;
panel.SetActive(false);
@@ -59,6 +67,9 @@ public class SettingsPanelController : MonoBehaviour {
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
showAnimationTestsToggle.isOn = PlayerPrefs.GetInt(AnimationToggleKey, 0) == 1;
animationTestController.gameObject.SetActive(showAnimationTestsToggle.isOn);
if (autoScrollSpeedSlider != null) {
autoScrollSpeedSlider.value = AutoScrollingText.GlobalScrollSpeedMultiplier;
UpdateAutoScrollSpeedLabel(autoScrollSpeedSlider.value);
@@ -116,6 +127,11 @@ public class SettingsPanelController : MonoBehaviour {
hoveringTooltip.HugsCursor = val;
}
public void OnAnimationTestToggleChange(bool val) {
PlayerPrefs.SetInt(AnimationToggleKey, val ? 1 : 0);
animationTestController.gameObject.SetActive(val);
}
public void OnAutoScrollSpeedSliderChange(float val) {
AutoScrollingText.GlobalScrollSpeedMultiplier = val;
UpdateAutoScrollSpeedLabel(val);
@@ -135,6 +151,11 @@ public class SettingsPanelController : MonoBehaviour {
Process.Start(Path.Combine(Application.persistentDataPath, "eagle0", "Resources"));
}
public void OnResetTutorialsClick() {
TutorialManager.Instance?.ResetAllProgress();
UnityEngine.Debug.Log("Tutorial progress reset");
}
private void HandleLobby() {
eagleGameController.StopAll();
eagleGameController.gameObject.SetActive(false);
@@ -0,0 +1,233 @@
fileFormatVersion: 2
guid: 00630274bf61c4899aa743c3fc3d7ccc
TextureImporter:
internalIDToNameTable:
- first:
213: -2413806693520163455
second: Circle
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: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 256
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: 0
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 1
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
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
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:
- serializedVersion: 2
name: Circle
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 0
pivot: {x: 0.5, y: 0.5}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline:
- - {x: 33, y: 128}
- {x: -33, y: 128}
- {x: -95, y: 95}
- {x: -128, y: 33}
- {x: -128, y: -33}
- {x: -95, y: -95}
- {x: -33, y: -128}
- {x: 33, y: -128}
- {x: 95, y: -95}
- {x: 128, y: -33}
- {x: 128, y: 33}
- {x: 95, y: 95}
physicsShape:
- - {x: 0, y: 128}
- {x: -39, y: 121}
- {x: -75, y: 103}
- {x: -103, y: 75}
- {x: -121, y: 39}
- {x: -128, y: 0}
- {x: -121, y: -39}
- {x: -103, y: -75}
- {x: -75, y: -103}
- {x: -39, y: -121}
- {x: 0, y: -128}
- {x: 39, y: -121}
- {x: 75, y: -103}
- {x: 103, y: -75}
- {x: 121, y: -39}
- {x: 128, y: 0}
- {x: 121, y: 39}
- {x: 103, y: 75}
- {x: 75, y: 103}
- {x: 39, y: 121}
tessellationDetail: 0
bones: []
spriteID: 18d3544e99f608ed0800000000000000
internalID: -2413806693520163455
vertices: []
indices:
edges: []
weights: []
outline:
- - {x: 33, y: 128}
- {x: -33, y: 128}
- {x: -95, y: 95}
- {x: -128, y: 33}
- {x: -128, y: -33}
- {x: -95, y: -95}
- {x: -33, y: -128}
- {x: 33, y: -128}
- {x: 95, y: -95}
- {x: 128, y: -33}
- {x: 128, y: 33}
- {x: 95, y: 95}
customData:
physicsShape:
- - {x: 0, y: 128}
- {x: -39, y: 121}
- {x: -75, y: 103}
- {x: -103, y: 75}
- {x: -121, y: 39}
- {x: -128, y: 0}
- {x: -121, y: -39}
- {x: -103, y: -75}
- {x: -75, y: -103}
- {x: -39, y: -121}
- {x: 0, y: -128}
- {x: 39, y: -121}
- {x: 75, y: -103}
- {x: 103, y: -75}
- {x: 121, y: -39}
- {x: 128, y: 0}
- {x: 121, y: 39}
- {x: 103, y: 75}
- {x: 75, y: 103}
- {x: 39, y: 121}
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable:
Circle: -2413806693520163455
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,169 @@
fileFormatVersion: 2
guid: 32f7f3d380ec1413590676a13c22324c
TextureImporter:
internalIDToNameTable:
- first:
213: 7482667652216324306
second: Square
externalObjects: {}
serializedVersion: 11
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
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 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: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 256
spriteBorder: {x: 4, y: 4, z: 4, w: 4}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: 0
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: iPhone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: Triangle
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 9
pivot: {x: 0.5, y: 0.28866667}
border: {x: 0, y: 0, z: 0, w: 0}
outline:
- - {x: 0, y: 93.7025033688}
- {x: -128, y: -128}
- {x: 128, y: -128}
physicsShape:
- - {x: 0, y: 93.7025033688}
- {x: -128, y: -128}
- {x: 128, y: -128}
tessellationDetail: 0
bones: []
spriteID: 2d009a6b596c7d760800000000000000
internalID: 7482667652216324306
vertices: []
indices:
edges: []
weights: []
outline: []
physicsShape:
- - {x: -128, y: 128}
- {x: -128, y: -128}
- {x: 128, y: -128}
- {x: 128, y: 128}
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable:
Triangle: 7482667652216324306
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 7fee21f21c4f146f69dd2eae14a94b4b
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: bfa8cdc5e73d54ed1876c17a47ff7346
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: bd23b09c6f27b4754a769858a2020668
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,117 @@
fileFormatVersion: 2
guid: 3fe839b58f5fd46d695ece8ccf2412a5
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:
@@ -1,3 +1,4 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
@@ -24,6 +25,19 @@ namespace Shardok {
private CatapultAnimator _catapultAnimator;
private RaiseDeadAnimator _raiseDeadAnimator;
private MeteorAnimator _meteorAnimator;
private HolyWaveAnimator _holyWaveAnimator;
private ChargeAnimator _chargeAnimator;
private ToolAnimator _toolAnimator;
private FearAnimator _fearAnimator;
private ControlAnimator _controlAnimator;
private FireEffectAnimator _fireEffectAnimator;
private ExtinguishAnimator _extinguishAnimator;
private FreezeAnimator _freezeAnimator;
private WaterEffectAnimator _waterEffectAnimator;
private ScoutAnimator _scoutAnimator;
private DismissAnimator _dismissAnimator;
private FleeAnimator _fleeAnimator;
private DuelAnimator _duelAnimator;
private AudioSource _audioSource;
private SoundManager _soundManager;
@@ -40,6 +54,19 @@ namespace Shardok {
_catapultAnimator = gameController.catapultAnimator;
_raiseDeadAnimator = gameController.raiseDeadAnimator;
_meteorAnimator = gameController.meteorAnimator;
_holyWaveAnimator = gameController.holyWaveAnimator;
_chargeAnimator = gameController.chargeAnimator;
_toolAnimator = gameController.toolAnimator;
_fearAnimator = gameController.fearAnimator;
_controlAnimator = gameController.controlAnimator;
_fireEffectAnimator = gameController.fireEffectAnimator;
_extinguishAnimator = gameController.extinguishAnimator;
_freezeAnimator = gameController.freezeAnimator;
_waterEffectAnimator = gameController.waterEffectAnimator;
_scoutAnimator = gameController.scoutAnimator;
_dismissAnimator = gameController.dismissAnimator;
_fleeAnimator = gameController.fleeAnimator;
_duelAnimator = gameController.duelAnimator;
_audioSource = gameController.audioClipSource;
_soundManager = gameController.soundManager;
} else {
@@ -99,13 +126,30 @@ namespace Shardok {
Debug.Log("Melee sequence complete!");
}
public void TestMove() {
Debug.Log($"Testing Move: {sourceCell} -> {targetCell}");
public void TestMove() { StartCoroutine(TestMoveSequence()); }
private IEnumerator TestMoveSequence() {
Debug.Log($"Testing Move (Infantry): {sourceCell} -> {targetCell}");
if (_moveAnimator != null) {
_moveAnimator.AnimateMove(sourceCell, targetCell);
_moveAnimator.AnimateMove(
sourceCell,
targetCell,
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.LightInfantry);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Move);
} else {
Debug.LogWarning("MoveAnimator not available");
yield break;
}
yield return new WaitForSeconds(1.5f);
Debug.Log($"Testing Move (Cavalry): {sourceCell} -> {targetCell}");
if (_moveAnimator != null) {
_moveAnimator.AnimateMove(
sourceCell,
targetCell,
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.LightCavalry);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Move);
}
}
@@ -149,6 +193,152 @@ namespace Shardok {
}
}
public void TestHolyWave() {
Debug.Log($"Testing Holy Wave from: {sourceCell}");
if (_holyWaveAnimator != null) {
// Test with some simulated undead positions nearby
var undeadCells = new System.Collections.Generic.List<int> { targetCell };
_holyWaveAnimator.AnimateHolyWave(sourceCell, undeadCells);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.InspiredTroops);
} else {
Debug.LogWarning("HolyWaveAnimator not available");
}
}
public void TestCharge() {
Debug.Log($"Testing Charge: {sourceCell} -> {targetCell}");
if (_chargeAnimator != null) {
_chargeAnimator.AnimateCharge(
sourceCell,
targetCell,
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.HeavyCavalry,
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.HeavyInfantry);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.ChargeAttack);
} else {
Debug.LogWarning("ChargeAnimator not available");
}
}
public void TestRepair() {
Debug.Log($"Testing Repair at: {targetCell}");
if (_toolAnimator != null) {
_toolAnimator.AnimateRepair(targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Repair);
} else {
Debug.LogWarning("ToolAnimator not available");
}
}
public void TestBuildBridge() {
Debug.Log($"Testing BuildBridge at: {targetCell}");
if (_toolAnimator != null) {
_toolAnimator.AnimateBuildBridge(targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.BuildBridge);
} else {
Debug.LogWarning("ToolAnimator not available");
}
}
public void TestFear() {
Debug.Log($"Testing Fear: {sourceCell} -> {targetCell}");
if (_fearAnimator != null) {
_fearAnimator.AnimateFear(sourceCell, targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.FearAttack);
} else {
Debug.LogWarning("FearAnimator not available");
}
}
public void TestControl() {
Debug.Log($"Testing Control: {sourceCell} -> {targetCell}");
if (_controlAnimator != null) {
_controlAnimator.AnimateControl(sourceCell, targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Controlled);
} else {
Debug.LogWarning("ControlAnimator not available");
}
}
public void TestFire() {
Debug.Log($"Testing Fire at: {targetCell}");
if (_fireEffectAnimator != null) {
_fireEffectAnimator.AnimateFire(targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.StartFire);
} else {
Debug.LogWarning("FireEffectAnimator not available");
}
}
public void TestExtinguish() {
Debug.Log($"Testing Extinguish at: {targetCell}");
if (_extinguishAnimator != null) {
_extinguishAnimator.AnimateExtinguish(targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.ExtinguishFire);
} else {
Debug.LogWarning("ExtinguishAnimator not available");
}
}
public void TestFreeze() {
Debug.Log($"Testing Freeze at: {targetCell}");
if (_freezeAnimator != null) {
_freezeAnimator.AnimateFreeze(targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.FreezeWater);
} else {
Debug.LogWarning("FreezeAnimator not available");
}
}
public void TestWaterSplash() {
Debug.Log($"Testing Water Splash at: {targetCell}");
if (_waterEffectAnimator != null) {
_waterEffectAnimator.AnimateWaterSplash(targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.CrossedWater);
} else {
Debug.LogWarning("WaterEffectAnimator not available");
}
}
public void TestScout() {
Debug.Log($"Testing Scout: {sourceCell} -> {targetCell}");
if (_scoutAnimator != null) {
_scoutAnimator.AnimateScout(sourceCell, targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Scouted);
} else {
Debug.LogWarning("ScoutAnimator not available");
}
}
public void TestDismiss() {
Debug.Log($"Testing Dismiss at: {targetCell}");
if (_dismissAnimator != null) {
_dismissAnimator.AnimateDismiss(targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.DismissUnit);
} else {
Debug.LogWarning("DismissAnimator not available");
}
}
public void TestFlee() {
Debug.Log($"Testing Flee: {sourceCell} -> {targetCell}");
if (_fleeAnimator != null) {
_fleeAnimator.AnimateFlee(sourceCell, targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Move);
} else {
Debug.LogWarning("FleeAnimator not available");
}
}
public void TestDuel() {
Debug.Log($"Testing Duel: {sourceCell} -> {targetCell}");
if (_duelAnimator != null) {
_duelAnimator.AnimateDuel(sourceCell, targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.MeleeAttack);
} else {
Debug.LogWarning("DuelAnimator not available");
}
}
public void TestAll() {
Debug.Log("Testing all animations in sequence...");
StartCoroutine(TestAllSequence());
@@ -174,6 +364,48 @@ namespace Shardok {
yield return new WaitForSeconds(2.0f);
TestMeteor();
yield return new WaitForSeconds(1.5f);
TestHolyWave();
yield return new WaitForSeconds(1.5f);
TestCharge();
yield return new WaitForSeconds(1.0f);
TestRepair();
yield return new WaitForSeconds(1.0f);
TestBuildBridge();
yield return new WaitForSeconds(1.0f);
TestFear();
yield return new WaitForSeconds(1.0f);
TestControl();
yield return new WaitForSeconds(1.5f);
TestFire();
yield return new WaitForSeconds(1.5f);
TestExtinguish();
yield return new WaitForSeconds(1.0f);
TestFreeze();
yield return new WaitForSeconds(1.0f);
TestWaterSplash();
yield return new WaitForSeconds(1.0f);
TestScout();
yield return new WaitForSeconds(1.5f);
TestDismiss();
yield return new WaitForSeconds(1.5f);
TestFlee();
yield return new WaitForSeconds(1.0f);
TestDuel();
Debug.Log("All animations tested!");
}
@@ -38,15 +38,10 @@ namespace Shardok {
[Tooltip("Time spread for staggered launch")]
public float launchSpread = 0.1f;
[Header("References")]
[Tooltip("HexGrid to get canvas reference from (auto-found if not set)")]
public HexGrid hexGrid;
private HexGrid __hexGrid;
private readonly List<Coroutine> _activeCoroutines = new List<Coroutine>();
private void Start() {
if (hexGrid == null) { hexGrid = FindObjectOfType<HexGrid>(); }
}
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts an arrow volley animation between two hex cells.
@@ -59,13 +54,13 @@ namespace Shardok {
return;
}
if (hexGrid == null || hexGrid.gridCanvas == null) {
Debug.LogWarning("ArrowVolleyAnimator: No hexGrid or gridCanvas available");
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("ArrowVolleyAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = hexGrid.GetCellLocalPosition(sourceCellIndex);
Vector2? targetPos = hexGrid.GetCellLocalPosition(targetCellIndex);
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
@@ -73,14 +68,10 @@ namespace Shardok {
return;
}
Debug.Log(
$"ArrowVolleyAnimator: Starting volley from cell {sourceCellIndex} to {targetCellIndex}");
StartCoroutine(SpawnArrowVolley(sourcePos.Value, targetPos.Value));
}
private IEnumerator SpawnArrowVolley(Vector2 sourcePosition, Vector2 targetPosition) {
Debug.Log(
$"ArrowVolleyAnimator: Spawning {arrowCount} arrows from {sourcePosition} to {targetPosition}");
var arrows = new List<GameObject>();
for (int i = 0; i < arrowCount; i++) {
@@ -122,7 +113,7 @@ namespace Shardok {
// Create arrow as child of gridCanvas, using SpriteRenderer for world-space rendering
// This matches how particle effects work on the rotated canvas
GameObject arrow = new GameObject("Arrow");
arrow.transform.SetParent(hexGrid.gridCanvas.transform, false);
arrow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
// Use SpriteRenderer instead of UI Image
SpriteRenderer renderer = arrow.AddComponent<SpriteRenderer>();
@@ -40,13 +40,9 @@ namespace Shardok {
[Tooltip("Duration of impact explosion in seconds")]
public float impactDuration = 0.3f;
[Header("References")]
[Tooltip("HexGrid to get canvas reference from (auto-found if not set)")]
public HexGrid hexGrid;
private HexGrid __hexGrid;
private void Start() {
if (hexGrid == null) { hexGrid = FindObjectOfType<HexGrid>(); }
}
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a catapult animation between two hex cells.
@@ -58,13 +54,13 @@ namespace Shardok {
return;
}
if (hexGrid == null || hexGrid.gridCanvas == null) {
Debug.LogWarning("CatapultAnimator: No hexGrid or gridCanvas available");
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("CatapultAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = hexGrid.GetCellLocalPosition(sourceCellIndex);
Vector2? targetPos = hexGrid.GetCellLocalPosition(targetCellIndex);
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
@@ -78,7 +74,7 @@ namespace Shardok {
private IEnumerator AnimateProjectile(Vector2 source, Vector2 target) {
// Create rock projectile
GameObject rock = new GameObject("CatapultRock");
rock.transform.SetParent(hexGrid.gridCanvas.transform, false);
rock.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer rockRenderer = rock.AddComponent<SpriteRenderer>();
rockRenderer.sprite = rockSprite;
@@ -115,7 +111,7 @@ namespace Shardok {
// Impact explosion
if (explosionSprite != null) {
GameObject explosion = new GameObject("CatapultExplosion");
explosion.transform.SetParent(hexGrid.gridCanvas.transform, false);
explosion.transform.SetParent(__hexGrid.gridCanvas.transform, false);
explosion.transform.localPosition = new Vector3(target.x, target.y, 5f);
SpriteRenderer explosionRenderer = explosion.AddComponent<SpriteRenderer>();
@@ -0,0 +1,257 @@
using System.Collections;
using Net.Eagle0.Shardok.Api;
using UnityEngine;
using static Net.Eagle0.Shardok.Api.BattalionView.Types;
namespace Shardok {
/// <summary>
/// Animates charge attacks between two hexes.
/// Shows attacker's weapon thrusting toward defender with accelerating motion
/// and a violent flash on impact.
/// </summary>
public class ChargeAnimator : MonoBehaviour {
[Header("Weapon Sprites")]
[Tooltip("Sword sprite for light infantry charge")]
public Sprite swordSprite;
[Tooltip("Mace/hammer sprite for heavy infantry charge")]
public Sprite maceSprite;
[Tooltip("Small spear sprite for light cavalry charge")]
public Sprite smallSpearSprite;
[Tooltip("Large lance sprite for heavy cavalry charge")]
public Sprite largeLanceSprite;
[Tooltip("Dagger sprite for longbowmen charge")]
public Sprite daggerSprite;
[Tooltip("Bone/claw sprite for undead charge")]
public Sprite boneSprite;
[Header("Impact Flash Settings")]
[Tooltip("Sprite for impact flash")]
public Sprite flashSprite;
[Tooltip("Color of impact flash")]
public Color flashColor = new Color(1f, 1f, 0.8f, 0.9f);
[Tooltip("Scale of impact flash")]
public float flashScale = 35f;
[Tooltip("Duration of flash")]
public float flashDuration = 0.1f;
[Header("Animation Settings")]
[Tooltip("Base scale of weapon sprites")]
public float weaponScale = 25.0f;
[Tooltip("Number of thrusts")]
public int thrustCount = 2;
[Tooltip("Duration of each thrust (slow to fast)")]
public float thrustDuration = 0.25f;
[Tooltip("Duration of pullback between thrusts")]
public float pullbackDuration = 0.15f;
[Tooltip("Starting position offset from midpoint (in local units)")]
public float startOffset = 60f;
[Tooltip("End position offset from midpoint (in local units)")]
public float endOffset = 15f;
[Tooltip("Base rotation offset for diagonal sprites (degrees)")]
public float baseRotationOffset = -45f;
private HexGrid __hexGrid;
private Coroutine _activeAnimation;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a charge animation between two hex cells.
/// </summary>
public void AnimateCharge(
int attackerCellIndex,
int defenderCellIndex,
BattalionTypeId attackerType,
BattalionTypeId defenderType) {
if (swordSprite == null && maceSprite == null && smallSpearSprite == null &&
largeLanceSprite == null && daggerSprite == null && boneSprite == null) {
Debug.LogWarning("ChargeAnimator: No weapon sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("ChargeAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
if (attackerPos == null || defenderPos == null) {
Debug.LogWarning(
$"ChargeAnimator: Invalid cell indices {attackerCellIndex} or {defenderCellIndex}");
return;
}
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
_activeAnimation = StartCoroutine(
AnimateChargeThrusts(attackerPos.Value, defenderPos.Value, attackerType));
}
private Sprite GetWeaponSprite(BattalionTypeId type) {
switch (type) {
case BattalionTypeId.LightInfantry: return swordSprite;
case BattalionTypeId.HeavyInfantry: return maceSprite ?? swordSprite;
case BattalionTypeId.LightCavalry:
return smallSpearSprite ?? largeLanceSprite ?? swordSprite;
case BattalionTypeId.HeavyCavalry:
return largeLanceSprite ?? smallSpearSprite ?? swordSprite;
case BattalionTypeId.Longbowmen: return daggerSprite ?? swordSprite;
case BattalionTypeId.Undead: return boneSprite ?? swordSprite;
default: return swordSprite;
}
}
private float GetScaleMultiplier(BattalionTypeId type) {
switch (type) {
case BattalionTypeId.HeavyInfantry: return 1.2f;
case BattalionTypeId.LightCavalry: return 1.1f;
case BattalionTypeId.HeavyCavalry: return 1.4f;
case BattalionTypeId.Longbowmen: return 0.8f;
default: return 1.0f;
}
}
private IEnumerator AnimateChargeThrusts(
Vector2 attackerPos,
Vector2 defenderPos,
BattalionTypeId attackerType) {
// Calculate direction toward defender
Vector2 midpoint = (attackerPos + defenderPos) / 2f;
Vector2 direction = (defenderPos - attackerPos).normalized;
float baseAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
// Create attacker weapon
Sprite weaponSprite = GetWeaponSprite(attackerType);
float scale = weaponScale * GetScaleMultiplier(attackerType);
GameObject weapon = new GameObject("ChargeWeapon");
weapon.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = weapon.AddComponent<SpriteRenderer>();
renderer.sprite = weaponSprite;
renderer.sortingLayerName = "Effects";
weapon.transform.localRotation = Quaternion.Euler(0, 0, baseAngle + baseRotationOffset);
weapon.transform.localScale = new Vector3(scale, scale, scale);
// Calculate positions
Vector3 startPos = new Vector3(
midpoint.x - direction.x * startOffset,
midpoint.y - direction.y * startOffset,
10f);
Vector3 endPos = new Vector3(
midpoint.x + direction.x * endOffset,
midpoint.y + direction.y * endOffset,
10f);
weapon.transform.localPosition = startPos;
// Animate thrusts
for (int i = 0; i < thrustCount; i++) {
// Thrust forward - starts slow, accelerates
float elapsed = 0f;
while (elapsed < thrustDuration) {
if (weapon == null) yield break;
float t = elapsed / thrustDuration;
// Cubic ease-in for accelerating thrust
float easeT = t * t * t;
weapon.transform.localPosition = Vector3.Lerp(startPos, endPos, easeT);
elapsed += Time.deltaTime;
yield return null;
}
weapon.transform.localPosition = endPos;
// Impact flash
if (flashSprite != null) {
yield return StartCoroutine(ShowImpactFlash(endPos, direction));
}
// Pullback - smooth ease out
elapsed = 0f;
while (elapsed < pullbackDuration) {
if (weapon == null) yield break;
float t = elapsed / pullbackDuration;
// Ease out for smooth pullback
float easeT = t * (2f - t);
weapon.transform.localPosition = Vector3.Lerp(endPos, startPos, easeT);
elapsed += Time.deltaTime;
yield return null;
}
weapon.transform.localPosition = startPos;
// Brief pause between thrusts
if (i < thrustCount - 1) { yield return new WaitForSeconds(0.05f); }
}
// Cleanup
if (weapon != null) { Destroy(weapon); }
_activeAnimation = null;
}
private IEnumerator ShowImpactFlash(Vector3 position, Vector2 direction) {
GameObject flash = new GameObject("ChargeFlash");
flash.transform.SetParent(__hexGrid.gridCanvas.transform, false);
flash.transform.localPosition = position;
SpriteRenderer renderer = flash.AddComponent<SpriteRenderer>();
renderer.sprite = flashSprite;
renderer.sortingLayerName = "Effects";
renderer.color = flashColor;
float elapsed = 0f;
while (elapsed < flashDuration) {
float t = elapsed / flashDuration;
// Quick expand then fade
float expandT = t < 0.3f ? t / 0.3f : 1f;
float scale = flashScale * (0.5f + 0.5f * expandT);
flash.transform.localScale = Vector3.one * scale;
// Fade out
Color c = flashColor;
c.a = flashColor.a * (1f - t);
renderer.color = c;
elapsed += Time.deltaTime;
yield return null;
}
Destroy(flash);
}
/// <summary>
/// Cancels any active charge animation.
/// </summary>
public void CancelAnimation() {
if (_activeAnimation != null) {
StopCoroutine(_activeAnimation);
_activeAnimation = null;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 52d8c0b9135074d4eaa11cb4457185e3
@@ -0,0 +1,274 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates mind control with a pulsing beam from source to target.
/// Creates a mystical connection effect with spiraling particles.
/// </summary>
public class ControlAnimator : MonoBehaviour {
[Header("Beam Settings")]
[Tooltip("Sprite for the beam (elongated gradient works well)")]
public Sprite beamSprite;
[Tooltip("Color of the control beam")]
public Color beamColor = new Color(0.8f, 0.2f, 0.9f, 0.7f);
[Tooltip("Width of the beam")]
public float beamWidth = 8f;
[Header("Particle Settings")]
[Tooltip("Sprite for spiraling particles")]
public Sprite particleSprite;
[Tooltip("Number of particles along the beam")]
public int particleCount = 6;
[Tooltip("Color of particles")]
public Color particleColor = new Color(1f, 0.5f, 1f, 0.9f);
[Header("Impact Settings")]
[Tooltip("Sprite for impact ring at target")]
public Sprite impactSprite;
[Tooltip("Color of impact")]
public Color impactColor = new Color(0.9f, 0.3f, 1f, 0.8f);
[Header("Animation Settings")]
[Tooltip("Duration of the beam formation")]
public float beamDuration = 0.4f;
[Tooltip("Duration of the pulse effect")]
public float pulseDuration = 0.3f;
[Tooltip("Number of pulse waves")]
public int pulseCount = 2;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a mind control animation from source to target hex.
/// </summary>
public void AnimateControl(int sourceCellIndex, int targetCellIndex) {
if (beamSprite == null && particleSprite == null) {
Debug.LogWarning("ControlAnimator: No sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("ControlAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
$"ControlAnimator: Invalid cell indices {sourceCellIndex} or {targetCellIndex}");
return;
}
StartCoroutine(AnimateControlBeam(sourcePos.Value, targetPos.Value));
}
private IEnumerator AnimateControlBeam(Vector2 source, Vector2 target) {
Vector2 direction = (target - source).normalized;
float distance = Vector2.Distance(source, target);
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
// Create beam
GameObject beam = null;
SpriteRenderer beamRenderer = null;
if (beamSprite != null) {
beam = new GameObject("ControlBeam");
beam.transform.SetParent(__hexGrid.gridCanvas.transform, false);
beamRenderer = beam.AddComponent<SpriteRenderer>();
beamRenderer.sprite = beamSprite;
beamRenderer.sortingLayerName = "Effects";
beamRenderer.color = beamColor;
// Position at midpoint, rotate toward target
Vector2 midpoint = (source + target) / 2f;
beam.transform.localPosition = new Vector3(midpoint.x, midpoint.y, 6f);
beam.transform.localRotation = Quaternion.Euler(0, 0, angle);
beam.transform.localScale = new Vector3(0f, beamWidth, 1f);
}
// Create spiraling particles
GameObject[] particles = new GameObject[particleCount];
float[] particlePhases = new float[particleCount];
for (int i = 0; i < particleCount; i++) {
if (particleSprite == null) break;
GameObject particle = new GameObject($"ControlParticle_{i}");
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = particle.AddComponent<SpriteRenderer>();
renderer.sprite = particleSprite;
renderer.sortingLayerName = "Effects";
renderer.color = particleColor;
particle.transform.localScale = Vector3.one * 6f;
particlePhases[i] = (float)i / particleCount * Mathf.PI * 2f;
particles[i] = particle;
}
// Beam formation phase
float elapsed = 0f;
while (elapsed < beamDuration) {
float t = elapsed / beamDuration;
float easeT = t * t * (3f - 2f * t);
// Extend beam
if (beam != null) {
float beamLength = distance * easeT;
beam.transform.localScale = new Vector3(beamLength, beamWidth, 1f);
// Slight pulse in width
float pulse = 1f + Mathf.Sin(t * Mathf.PI * 4) * 0.1f;
beam.transform.localScale = new Vector3(beamLength, beamWidth * pulse, 1f);
}
// Animate particles spiraling along beam
for (int i = 0; i < particles.Length; i++) {
if (particles[i] == null) continue;
float particleT = (float)i / particleCount * easeT;
Vector2 pos = Vector2.Lerp(source, target, particleT);
// Spiral offset
float spiralAngle = particlePhases[i] + elapsed * 15f;
float spiralRadius = 10f * (1f - Mathf.Abs(particleT - 0.5f) * 2f);
pos += new Vector2(
Mathf.Cos(spiralAngle) * spiralRadius * -direction.y +
Mathf.Sin(spiralAngle) * spiralRadius * direction.x,
Mathf.Cos(spiralAngle) * spiralRadius * direction.x +
Mathf.Sin(spiralAngle) * spiralRadius * direction.y);
particles[i].transform.localPosition = new Vector3(pos.x, pos.y, 5f);
// Fade based on position
var renderer = particles[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = particleColor;
c.a = particleColor.a * easeT;
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Create impact effect at target
GameObject impact = null;
SpriteRenderer impactRenderer = null;
if (impactSprite != null) {
impact = new GameObject("ControlImpact");
impact.transform.SetParent(__hexGrid.gridCanvas.transform, false);
impact.transform.localPosition = new Vector3(target.x, target.y, 5f);
impactRenderer = impact.AddComponent<SpriteRenderer>();
impactRenderer.sprite = impactSprite;
impactRenderer.sortingLayerName = "Effects";
impactRenderer.color = impactColor;
impact.transform.localScale = Vector3.one * 10f;
}
// Pulse phase
for (int p = 0; p < pulseCount; p++) {
elapsed = 0f;
while (elapsed < pulseDuration) {
float t = elapsed / pulseDuration;
// Beam pulsing
if (beam != null) {
float pulse = 1f + Mathf.Sin(t * Mathf.PI * 2) * 0.3f;
beam.transform.localScale = new Vector3(distance, beamWidth * pulse, 1f);
Color c = beamColor;
c.a = beamColor.a * (0.7f + Mathf.Sin(t * Mathf.PI * 2) * 0.3f);
beamRenderer.color = c;
}
// Impact pulsing
if (impact != null) {
float impactPulse = 10f + Mathf.Sin(t * Mathf.PI * 2) * 5f;
impact.transform.localScale = Vector3.one * impactPulse;
}
// Continue particle spiral
for (int i = 0; i < particles.Length; i++) {
if (particles[i] == null) continue;
float particleT = (float)i / particleCount;
Vector2 pos = Vector2.Lerp(source, target, particleT);
float spiralAngle = particlePhases[i] +
(beamDuration + p * pulseDuration + elapsed) * 15f;
float spiralRadius = 10f * (1f - Mathf.Abs(particleT - 0.5f) * 2f);
pos += new Vector2(
Mathf.Cos(spiralAngle) * spiralRadius * -direction.y +
Mathf.Sin(spiralAngle) * spiralRadius * direction.x,
Mathf.Cos(spiralAngle) * spiralRadius * direction.x +
Mathf.Sin(spiralAngle) * spiralRadius * direction.y);
particles[i].transform.localPosition = new Vector3(pos.x, pos.y, 5f);
}
elapsed += Time.deltaTime;
yield return null;
}
}
// Fade out
float fadeDuration = 0.2f;
elapsed = 0f;
while (elapsed < fadeDuration) {
float t = elapsed / fadeDuration;
if (beam != null) {
Color c = beamColor;
c.a = beamColor.a * (1f - t);
beamRenderer.color = c;
}
if (impact != null) {
Color c = impactColor;
c.a = impactColor.a * (1f - t);
impactRenderer.color = c;
}
for (int i = 0; i < particles.Length; i++) {
if (particles[i] == null) continue;
var renderer = particles[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = particleColor;
c.a = particleColor.a * (1f - t);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
if (beam != null) { Destroy(beam); }
if (impact != null) { Destroy(impact); }
foreach (var particle in particles) {
if (particle != null) { Destroy(particle); }
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 25a8c1a83921d4859b6bd40fd9a8b6e4
@@ -0,0 +1,166 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates unit dismissal with a fade-out and dissolve effect.
/// Creates particles that drift away as the unit disappears.
/// </summary>
public class DismissAnimator : MonoBehaviour {
[Header("Dissolve Settings")]
[Tooltip("Sprite for dissolve particles")]
public Sprite dissolveSprite;
[Tooltip("Number of dissolve particles")]
public int particleCount = 12;
[Tooltip("Color of dissolve particles")]
public Color dissolveColor = new Color(0.8f, 0.8f, 0.8f, 0.7f);
[Header("Glow Settings")]
[Tooltip("Sprite for fading glow")]
public Sprite glowSprite;
[Tooltip("Color of the glow")]
public Color glowColor = new Color(1f, 1f, 0.9f, 0.5f);
[Tooltip("Scale of the glow")]
public float glowScale = 30f;
[Header("Animation Settings")]
[Tooltip("Duration of the dissolve")]
public float dissolveDuration = 0.6f;
[Tooltip("Spread distance of particles")]
public float dissolveSpread = 40f;
[Tooltip("Rise height of particles")]
public float riseHeight = 30f;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a dismiss animation at the target hex.
/// </summary>
public void AnimateDismiss(int targetCellIndex) {
if (dissolveSprite == null && glowSprite == null) {
Debug.LogWarning("DismissAnimator: No sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("DismissAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (targetPos == null) {
Debug.LogWarning($"DismissAnimator: Invalid cell index {targetCellIndex}");
return;
}
StartCoroutine(AnimateDismissEffect(targetPos.Value));
}
private IEnumerator AnimateDismissEffect(Vector2 target) {
// Create glow
GameObject glow = null;
SpriteRenderer glowRenderer = null;
if (glowSprite != null) {
glow = new GameObject("DismissGlow");
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
glow.transform.localPosition = new Vector3(target.x, target.y, 6f);
glow.transform.localScale = Vector3.one * glowScale;
glowRenderer = glow.AddComponent<SpriteRenderer>();
glowRenderer.sprite = glowSprite;
glowRenderer.sortingLayerName = "Effects";
glowRenderer.color = glowColor;
}
// Create dissolve particles
GameObject[] particles = new GameObject[particleCount];
Vector2[] particleDirections = new Vector2[particleCount];
float[] particleSpeeds = new float[particleCount];
float[] particleDelays = new float[particleCount];
for (int i = 0; i < particleCount; i++) {
if (dissolveSprite == null) break;
GameObject particle = new GameObject($"Dissolve_{i}");
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
particle.transform.localPosition = new Vector3(target.x, target.y, 5f);
particle.transform.localScale = Vector3.one * Random.Range(4f, 8f);
SpriteRenderer renderer = particle.AddComponent<SpriteRenderer>();
renderer.sprite = dissolveSprite;
renderer.sortingLayerName = "Effects";
renderer.color = dissolveColor;
float angle = (float)i / particleCount * Mathf.PI * 2f + Random.Range(-0.3f, 0.3f);
particleDirections[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
particleSpeeds[i] = dissolveSpread * Random.Range(0.5f, 1.2f);
particleDelays[i] = Random.Range(0f, dissolveDuration * 0.3f);
particles[i] = particle;
}
// Animate dissolve
float elapsed = 0f;
while (elapsed < dissolveDuration) {
float t = elapsed / dissolveDuration;
// Pulse and fade glow
if (glow != null) {
float pulse = 1f + Mathf.Sin(t * Mathf.PI * 3) * 0.2f;
glow.transform.localScale = Vector3.one * glowScale * pulse * (1f - t * 0.5f);
Color c = glowColor;
c.a = glowColor.a * (1f - t);
glowRenderer.color = c;
}
// Animate particles drifting outward and upward
for (int i = 0; i < particles.Length; i++) {
if (particles[i] == null) continue;
float particleT = Mathf.Clamp01(
(elapsed - particleDelays[i]) / (dissolveDuration - particleDelays[i]));
if (particleT <= 0) continue;
float easeT = particleT * (2f - particleT); // Ease out
Vector2 pos = target + particleDirections[i] * particleSpeeds[i] * easeT;
pos.y += riseHeight * particleT;
particles[i].transform.localPosition = new Vector3(pos.x, pos.y, 5f);
// Fade and shrink
var renderer = particles[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = dissolveColor;
c.a = dissolveColor.a * (1f - particleT);
renderer.color = c;
}
float scale = Mathf.Lerp(Random.Range(4f, 8f), 0f, particleT);
particles[i].transform.localScale = Vector3.one * scale;
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
if (glow != null) { Destroy(glow); }
foreach (var particle in particles) {
if (particle != null) { Destroy(particle); }
}
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 153258e70f7a4f468e2ddbb00c8eb625
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,289 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates a duel with crossed weapons clashing dramatically.
/// Creates weapon sprites that meet in the middle with impact effects.
/// </summary>
public class DuelAnimator : MonoBehaviour {
[Header("Weapon Sprites")]
[Tooltip("Sword sprite for the duel")]
public Sprite swordSprite;
[Tooltip("Alternate weapon sprite")]
public Sprite alternateWeaponSprite;
[Header("Impact Settings")]
[Tooltip("Sprite for clash impact")]
public Sprite impactSprite;
[Tooltip("Color of impact flash")]
public Color impactColor = new Color(1f, 1f, 0.8f, 0.9f);
[Tooltip("Sprite for sparks")]
public Sprite sparkSprite;
[Tooltip("Number of sparks on clash")]
public int sparkCount = 4;
[Header("Animation Settings")]
[Tooltip("Scale of weapon sprites")]
public float weaponScale = 22f;
[Tooltip("Distance weapons start from clash point")]
public float startDistance = 45f;
[Tooltip("Duration of weapon swing")]
public float swingDuration = 0.2f;
[Tooltip("Number of clashes")]
public int clashCount = 3;
[Tooltip("Pause between clashes")]
public float clashPause = 0.1f;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a duel animation between two hex cells.
/// </summary>
public void AnimateDuel(int attackerCellIndex, int defenderCellIndex) {
if (swordSprite == null && alternateWeaponSprite == null) {
Debug.LogWarning("DuelAnimator: No weapon sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("DuelAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
if (attackerPos == null || defenderPos == null) {
Debug.LogWarning(
$"DuelAnimator: Invalid cell indices {attackerCellIndex} or {defenderCellIndex}");
return;
}
StartCoroutine(AnimateDuelEffect(attackerPos.Value, defenderPos.Value));
}
private IEnumerator AnimateDuelEffect(Vector2 attacker, Vector2 defender) {
Vector2 midpoint = (attacker + defender) / 2f;
Vector2 direction = (defender - attacker).normalized;
float baseAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
Sprite weapon1Sprite = swordSprite ?? alternateWeaponSprite;
Sprite weapon2Sprite = alternateWeaponSprite ?? swordSprite;
// Create weapons
GameObject weapon1 = new GameObject("DuelWeapon1");
weapon1.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer weapon1Renderer = weapon1.AddComponent<SpriteRenderer>();
weapon1Renderer.sprite = weapon1Sprite;
weapon1Renderer.sortingLayerName = "Effects";
weapon1.transform.localScale = Vector3.one * weaponScale;
GameObject weapon2 = new GameObject("DuelWeapon2");
weapon2.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer weapon2Renderer = weapon2.AddComponent<SpriteRenderer>();
weapon2Renderer.sprite = weapon2Sprite;
weapon2Renderer.sortingLayerName = "Effects";
weapon2.transform.localScale = Vector3.one * weaponScale;
// Starting positions
Vector3 weapon1Start = new Vector3(
midpoint.x - direction.x * startDistance,
midpoint.y - direction.y * startDistance,
5f);
Vector3 weapon2Start = new Vector3(
midpoint.x + direction.x * startDistance,
midpoint.y + direction.y * startDistance,
5f);
Vector3 clashPoint = new Vector3(midpoint.x, midpoint.y, 5f);
// Duel clashes
for (int clash = 0; clash < clashCount; clash++) {
// Alternate swing direction
float swingOffset = (clash % 2 == 0) ? 1f : -1f;
// Swing in toward clash
weapon1.transform.localPosition = weapon1Start;
weapon2.transform.localPosition = weapon2Start;
// Crossed swords angle
float weapon1Angle = baseAngle + 45f * swingOffset;
float weapon2Angle = baseAngle + 180f - 45f * swingOffset;
weapon1.transform.localRotation = Quaternion.Euler(0, 0, weapon1Angle);
weapon2.transform.localRotation = Quaternion.Euler(0, 0, weapon2Angle);
float elapsed = 0f;
// Swing toward clash point
while (elapsed < swingDuration) {
float t = elapsed / swingDuration;
float easeT = t * t * (3f - 2f * t);
weapon1.transform.localPosition = Vector3.Lerp(weapon1Start, clashPoint, easeT);
weapon2.transform.localPosition = Vector3.Lerp(weapon2Start, clashPoint, easeT);
// Rotate during swing
float rot1 = Mathf.Lerp(weapon1Angle + 30f * swingOffset, weapon1Angle, easeT);
float rot2 = Mathf.Lerp(weapon2Angle - 30f * swingOffset, weapon2Angle, easeT);
weapon1.transform.localRotation = Quaternion.Euler(0, 0, rot1);
weapon2.transform.localRotation = Quaternion.Euler(0, 0, rot2);
elapsed += Time.deltaTime;
yield return null;
}
// Impact effect
yield return StartCoroutine(CreateClashImpact(midpoint));
// Brief shake
float shakeDuration = 0.05f;
elapsed = 0f;
while (elapsed < shakeDuration) {
Vector3 shake = new Vector3(Random.Range(-5f, 5f), Random.Range(-5f, 5f), 0f);
weapon1.transform.localPosition = clashPoint + shake;
weapon2.transform.localPosition = clashPoint - shake;
elapsed += Time.deltaTime;
yield return null;
}
// Pull back
elapsed = 0f;
while (elapsed < swingDuration * 0.5f) {
float t = elapsed / (swingDuration * 0.5f);
float easeT = t * (2f - t);
weapon1.transform.localPosition = Vector3.Lerp(clashPoint, weapon1Start, easeT);
weapon2.transform.localPosition = Vector3.Lerp(clashPoint, weapon2Start, easeT);
elapsed += Time.deltaTime;
yield return null;
}
if (clash < clashCount - 1) { yield return new WaitForSeconds(clashPause); }
}
// Final fade out
float fadeDuration = 0.15f;
float fadeElapsed = 0f;
while (fadeElapsed < fadeDuration) {
float t = fadeElapsed / fadeDuration;
Color c1 = weapon1Renderer.color;
c1.a = 1f - t;
weapon1Renderer.color = c1;
Color c2 = weapon2Renderer.color;
c2.a = 1f - t;
weapon2Renderer.color = c2;
fadeElapsed += Time.deltaTime;
yield return null;
}
// Cleanup
Destroy(weapon1);
Destroy(weapon2);
}
private IEnumerator CreateClashImpact(Vector2 position) {
// Create impact flash
GameObject impact = null;
SpriteRenderer impactRenderer = null;
if (impactSprite != null) {
impact = new GameObject("DuelImpact");
impact.transform.SetParent(__hexGrid.gridCanvas.transform, false);
impact.transform.localPosition = new Vector3(position.x, position.y, 4f);
impact.transform.localScale = Vector3.one * 8f;
impactRenderer = impact.AddComponent<SpriteRenderer>();
impactRenderer.sprite = impactSprite;
impactRenderer.sortingLayerName = "Effects";
impactRenderer.color = impactColor;
}
// Create sparks
GameObject[] sparks = new GameObject[sparkCount];
Vector2[] sparkDirections = new Vector2[sparkCount];
float[] sparkSpeeds = new float[sparkCount];
for (int i = 0; i < sparkCount; i++) {
if (sparkSprite == null) break;
GameObject spark = new GameObject($"DuelSpark_{i}");
spark.transform.SetParent(__hexGrid.gridCanvas.transform, false);
spark.transform.localPosition = new Vector3(position.x, position.y, 3f);
spark.transform.localScale = Vector3.one * 4f;
SpriteRenderer renderer = spark.AddComponent<SpriteRenderer>();
renderer.sprite = sparkSprite;
renderer.sortingLayerName = "Effects";
renderer.color = impactColor;
float angle = (float)i / sparkCount * Mathf.PI * 2f + Random.Range(-0.5f, 0.5f);
sparkDirections[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
sparkSpeeds[i] = Random.Range(20f, 40f);
sparks[i] = spark;
}
// Animate impact and sparks
float impactDuration = 0.15f;
float elapsed = 0f;
while (elapsed < impactDuration) {
float t = elapsed / impactDuration;
// Flash expand and fade
if (impact != null) {
float scale = 8f + t * 12f;
impact.transform.localScale = Vector3.one * scale;
Color c = impactColor;
c.a = impactColor.a * (1f - t);
impactRenderer.color = c;
}
// Sparks fly out
for (int i = 0; i < sparks.Length; i++) {
if (sparks[i] == null) continue;
Vector2 pos = position + sparkDirections[i] * sparkSpeeds[i] * t;
sparks[i].transform.localPosition = new Vector3(pos.x, pos.y, 3f);
var renderer = sparks[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = impactColor;
c.a = impactColor.a * (1f - t);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
if (impact != null) { Destroy(impact); }
foreach (var spark in sparks) {
if (spark != null) { Destroy(spark); }
}
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: eb7274736d0c47e697b1dbf7153f3433
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,244 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates extinguishing fire with water spray/splash effect.
/// Creates a burst of water droplets descending on the target.
/// </summary>
public class ExtinguishAnimator : MonoBehaviour {
[Header("Water Settings")]
[Tooltip("Sprite for water droplets")]
public Sprite dropletSprite;
[Tooltip("Number of droplets")]
public int dropletCount = 25;
[Tooltip("Minimum droplet scale")]
public float dropletScaleMin = 5f;
[Tooltip("Maximum droplet scale")]
public float dropletScaleMax = 15f;
[Tooltip("Color of water")]
public Color waterColor = new Color(0.3f, 0.6f, 0.9f, 0.8f);
[Header("Steam Settings")]
[Tooltip("Sprite for steam puffs")]
public Sprite steamSprite;
[Tooltip("Number of steam puffs")]
public int steamCount = 8;
[Tooltip("Color of steam")]
public Color steamColor = new Color(0.9f, 0.9f, 0.9f, 0.6f);
[Header("Animation Settings")]
[Tooltip("Height water falls from")]
public float fallHeight = 100f;
[Tooltip("Spread radius of the water")]
public float waterSpread = 40f;
[Tooltip("Duration of the water fall")]
public float fallDuration = 0.5f;
[Tooltip("Time spread for staggered droplet launch")]
public float launchSpread = 0.25f;
[Tooltip("Horizontal chaos during fall")]
public float fallChaos = 15f;
[Tooltip("Duration of the steam effect")]
public float steamDuration = 0.5f;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts an extinguish animation at the target hex.
/// </summary>
public void AnimateExtinguish(int targetCellIndex) {
if (dropletSprite == null && steamSprite == null) {
Debug.LogWarning("ExtinguishAnimator: No sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("ExtinguishAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (targetPos == null) {
Debug.LogWarning($"ExtinguishAnimator: Invalid cell index {targetCellIndex}");
return;
}
StartCoroutine(AnimateExtinguishEffect(targetPos.Value));
}
private IEnumerator AnimateExtinguishEffect(Vector2 target) {
// Create droplets
GameObject[] droplets = new GameObject[dropletCount];
Vector2[] dropletOffsets = new Vector2[dropletCount];
Vector2[] dropletChaos = new Vector2[dropletCount];
float[] dropletDelays = new float[dropletCount];
for (int i = 0; i < dropletCount; i++) {
if (dropletSprite == null) break;
GameObject droplet = new GameObject($"Droplet_{i}");
droplet.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = droplet.AddComponent<SpriteRenderer>();
renderer.sprite = dropletSprite;
renderer.sortingLayerName = "Effects";
renderer.color = waterColor;
droplet.transform.localScale =
Vector3.one * Random.Range(dropletScaleMin, dropletScaleMax);
float angle = Random.Range(0f, Mathf.PI * 2f);
float radius = waterSpread * Random.Range(0f, 1f);
dropletOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
dropletDelays[i] = Random.Range(0f, launchSpread);
// Random horizontal chaos direction for each droplet
float chaosAngle = Random.Range(0f, Mathf.PI * 2f);
dropletChaos[i] =
new Vector2(Mathf.Cos(chaosAngle), Mathf.Sin(chaosAngle)) * fallChaos;
// Start above with some horizontal spread
droplet.transform.localPosition = new Vector3(
target.x + dropletOffsets[i].x * 0.3f,
target.y + fallHeight + Random.Range(-10f, 10f),
5f);
droplets[i] = droplet;
}
// Animate falling water
float elapsed = 0f;
float totalDuration = fallDuration + launchSpread;
while (elapsed < totalDuration) {
for (int i = 0; i < droplets.Length; i++) {
if (droplets[i] == null) continue;
float dropletT = Mathf.Clamp01(
(elapsed - dropletDelays[i]) /
(fallDuration - dropletDelays[i] * 0.5f));
if (dropletT <= 0) continue;
// Accelerating fall
float fallT = dropletT * dropletT;
// Add chaotic horizontal movement (sine wave pattern)
float chaosT = Mathf.Sin(dropletT * Mathf.PI * 2f) * (1f - dropletT);
Vector2 chaos = dropletChaos[i] * chaosT;
float x = target.x + dropletOffsets[i].x + chaos.x;
float y = target.y + fallHeight * (1f - fallT);
droplets[i].transform.localPosition = new Vector3(x, y, 5f);
}
elapsed += Time.deltaTime;
yield return null;
}
// Splash effect - droplets spread out
float splashDuration = 0.15f;
elapsed = 0f;
while (elapsed < splashDuration) {
float t = elapsed / splashDuration;
for (int i = 0; i < droplets.Length; i++) {
if (droplets[i] == null) continue;
Vector2 splashOffset = dropletOffsets[i] * (1f + t * 0.5f);
droplets[i].transform.localPosition =
new Vector3(target.x + splashOffset.x, target.y + t * 5f, 5f);
var renderer = droplets[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = waterColor;
c.a = waterColor.a * (1f - t);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup droplets
foreach (var droplet in droplets) {
if (droplet != null) { Destroy(droplet); }
}
// Create steam
GameObject[] steams = new GameObject[steamCount];
Vector2[] steamOffsets = new Vector2[steamCount];
for (int i = 0; i < steamCount; i++) {
if (steamSprite == null) break;
GameObject steam = new GameObject($"Steam_{i}");
steam.transform.SetParent(__hexGrid.gridCanvas.transform, false);
steam.transform.localPosition = new Vector3(target.x, target.y, 4f);
SpriteRenderer renderer = steam.AddComponent<SpriteRenderer>();
renderer.sprite = steamSprite;
renderer.sortingLayerName = "Effects";
renderer.color = steamColor;
steam.transform.localScale = Vector3.one * 10f;
float angle = (float)i / steamCount * Mathf.PI * 2f;
steamOffsets[i] =
new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * waterSpread * 0.5f;
steams[i] = steam;
}
// Animate rising steam
elapsed = 0f;
while (elapsed < steamDuration) {
float t = elapsed / steamDuration;
for (int i = 0; i < steams.Length; i++) {
if (steams[i] == null) continue;
float riseT = t * (2f - t); // Ease out
Vector2 pos = target + steamOffsets[i] * (1f + t * 0.3f);
pos.y += fallHeight * 0.8f * riseT;
steams[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
// Expand and fade
float scale = 10f * (1f + t * 2f);
steams[i].transform.localScale = Vector3.one * scale;
var renderer = steams[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = steamColor;
c.a = steamColor.a * (1f - t);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup steam
foreach (var steam in steams) {
if (steam != null) { Destroy(steam); }
}
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: a4b21a8ead0c49478d937a08fe1fd31b
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,217 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates a fear attack with a dark wave emanating from source toward target.
/// Creates an ominous expanding dark pulse effect.
/// </summary>
public class FearAnimator : MonoBehaviour {
[Header("Wave Settings")]
[Tooltip("Sprite for the fear wave (circular gradient works well)")]
public Sprite waveSprite;
[Tooltip("Color of the fear wave")]
public Color waveColor = new Color(0.3f, 0.1f, 0.4f, 0.8f);
[Tooltip("Starting scale of the wave")]
public float waveStartScale = 5f;
[Tooltip("Ending scale of the wave")]
public float waveEndScale = 40f;
[Header("Tendril Settings")]
[Tooltip("Sprite for dark tendrils")]
public Sprite tendrilSprite;
[Tooltip("Number of tendrils in the wave")]
public int tendrilCount = 5;
[Tooltip("Color of tendrils")]
public Color tendrilColor = new Color(0.2f, 0f, 0.3f, 0.9f);
[Header("Animation Settings")]
[Tooltip("Duration of the fear wave animation")]
public float waveDuration = 0.5f;
[Tooltip("Duration of the impact effect on target")]
public float impactDuration = 0.3f;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a fear animation from source to target hex.
/// </summary>
public void AnimateFear(int sourceCellIndex, int targetCellIndex) {
if (waveSprite == null && tendrilSprite == null) {
Debug.LogWarning("FearAnimator: No sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("FearAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
$"FearAnimator: Invalid cell indices {sourceCellIndex} or {targetCellIndex}");
return;
}
StartCoroutine(AnimateFearWave(sourcePos.Value, targetPos.Value));
}
private IEnumerator AnimateFearWave(Vector2 source, Vector2 target) {
Vector2 direction = (target - source).normalized;
float distance = Vector2.Distance(source, target);
// Create main wave
GameObject wave = null;
SpriteRenderer waveRenderer = null;
if (waveSprite != null) {
wave = new GameObject("FearWave");
wave.transform.SetParent(__hexGrid.gridCanvas.transform, false);
wave.transform.localPosition = new Vector3(source.x, source.y, 6f);
wave.transform.localScale = Vector3.one * waveStartScale;
waveRenderer = wave.AddComponent<SpriteRenderer>();
waveRenderer.sprite = waveSprite;
waveRenderer.sortingLayerName = "Effects";
waveRenderer.color = waveColor;
}
// Create tendrils
GameObject[] tendrils = new GameObject[tendrilCount];
float[] tendrilAngles = new float[tendrilCount];
float[] tendrilSpeeds = new float[tendrilCount];
float baseAngle = Mathf.Atan2(direction.y, direction.x);
for (int i = 0; i < tendrilCount; i++) {
if (tendrilSprite == null) break;
GameObject tendril = new GameObject($"FearTendril_{i}");
tendril.transform.SetParent(__hexGrid.gridCanvas.transform, false);
tendril.transform.localPosition = new Vector3(source.x, source.y, 5f);
SpriteRenderer renderer = tendril.AddComponent<SpriteRenderer>();
renderer.sprite = tendrilSprite;
renderer.sortingLayerName = "Effects";
renderer.color = tendrilColor;
tendril.transform.localScale = Vector3.one * 8f;
// Fan out from center toward target direction
float spreadAngle = (i - (tendrilCount - 1) / 2f) * 0.3f;
tendrilAngles[i] = baseAngle + spreadAngle;
tendrilSpeeds[i] = 1f + Random.Range(-0.2f, 0.2f);
tendrils[i] = tendril;
}
// Animate wave traveling toward target
float elapsed = 0f;
while (elapsed < waveDuration) {
float t = elapsed / waveDuration;
float easeT = t * t * (3f - 2f * t); // Smooth step
// Move and expand wave
if (wave != null) {
Vector2 wavePos = Vector2.Lerp(source, target, easeT);
wave.transform.localPosition = new Vector3(wavePos.x, wavePos.y, 6f);
float scale = Mathf.Lerp(waveStartScale, waveEndScale, easeT);
wave.transform.localScale = Vector3.one * scale;
// Fade as it expands
Color c = waveColor;
c.a = waveColor.a * (1f - t * 0.5f);
waveRenderer.color = c;
}
// Animate tendrils
for (int i = 0; i < tendrils.Length; i++) {
if (tendrils[i] == null) continue;
float tendrilT = Mathf.Clamp01(t * tendrilSpeeds[i]);
float tendrilDist = distance * tendrilT;
Vector2 tendrilPos =
source +
new Vector2(Mathf.Cos(tendrilAngles[i]), Mathf.Sin(tendrilAngles[i])) *
tendrilDist;
// Add waviness
float wave_offset = Mathf.Sin(t * 10f + i) * 5f;
tendrilPos += new Vector2(-direction.y, direction.x) * wave_offset;
tendrils[i].transform.localPosition =
new Vector3(tendrilPos.x, tendrilPos.y, 5f);
// Rotate to face direction
tendrils[i].transform.localRotation =
Quaternion.Euler(0, 0, tendrilAngles[i] * Mathf.Rad2Deg - 90f);
// Scale and fade
var renderer = tendrils[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = tendrilColor;
c.a = tendrilColor.a * (1f - tendrilT * 0.7f);
renderer.color = c;
float tendrilScale = 8f * (1f + tendrilT * 0.5f);
tendrils[i].transform.localScale = Vector3.one * tendrilScale;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Impact effect at target
GameObject impact = null;
if (waveSprite != null) {
impact = new GameObject("FearImpact");
impact.transform.SetParent(__hexGrid.gridCanvas.transform, false);
impact.transform.localPosition = new Vector3(target.x, target.y, 6f);
SpriteRenderer impactRenderer = impact.AddComponent<SpriteRenderer>();
impactRenderer.sprite = waveSprite;
impactRenderer.sortingLayerName = "Effects";
impactRenderer.color = waveColor;
impact.transform.localScale = Vector3.one * waveEndScale;
// Pulse and fade impact
elapsed = 0f;
while (elapsed < impactDuration) {
float t = elapsed / impactDuration;
float pulseScale = waveEndScale * (1f + Mathf.Sin(t * Mathf.PI * 3) * 0.1f);
impact.transform.localScale = Vector3.one * pulseScale;
Color c = waveColor;
c.a = waveColor.a * (1f - t);
impactRenderer.color = c;
elapsed += Time.deltaTime;
yield return null;
}
Destroy(impact);
}
// Cleanup
if (wave != null) { Destroy(wave); }
foreach (var tendril in tendrils) {
if (tendril != null) { Destroy(tendril); }
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3af84325bf53c44c48b14a508d38c14e
@@ -0,0 +1,191 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates fire effects for StartFire and FireDamage actions.
/// Creates rising flames with flickering particles.
/// </summary>
public class FireEffectAnimator : MonoBehaviour {
[Header("Flame Settings")]
[Tooltip("Sprite for flame particles")]
public Sprite flameSprite;
[Tooltip("Number of flame particles")]
public int flameCount = 5;
[Tooltip("Base color of flames")]
public Color flameColor = new Color(1f, 0.5f, 0.1f, 0.9f);
[Tooltip("Hot color for inner flames")]
public Color hotColor = new Color(1f, 0.9f, 0.3f, 1f);
[Header("Ember Settings")]
[Tooltip("Sprite for rising embers")]
public Sprite emberSprite;
[Tooltip("Number of embers")]
public int emberCount = 8;
[Tooltip("Color of embers")]
public Color emberColor = new Color(1f, 0.6f, 0.2f, 0.8f);
[Header("Animation Settings")]
[Tooltip("Scale of flame sprites")]
public float flameScale = 15f;
[Tooltip("Height flames rise")]
public float flameHeight = 40f;
[Tooltip("Duration of the fire effect")]
public float fireDuration = 0.8f;
[Tooltip("Spread radius of the fire")]
public float fireSpread = 20f;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a fire animation at the target hex.
/// </summary>
public void AnimateFire(int targetCellIndex) {
if (flameSprite == null && emberSprite == null) {
Debug.LogWarning("FireEffectAnimator: No sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("FireEffectAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (targetPos == null) {
Debug.LogWarning($"FireEffectAnimator: Invalid cell index {targetCellIndex}");
return;
}
StartCoroutine(AnimateFireEffect(targetPos.Value));
}
private IEnumerator AnimateFireEffect(Vector2 target) {
// Create flames
GameObject[] flames = new GameObject[flameCount];
Vector2[] flameOffsets = new Vector2[flameCount];
float[] flamePhases = new float[flameCount];
for (int i = 0; i < flameCount; i++) {
if (flameSprite == null) break;
GameObject flame = new GameObject($"Flame_{i}");
flame.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = flame.AddComponent<SpriteRenderer>();
renderer.sprite = flameSprite;
renderer.sortingLayerName = "Effects";
renderer.color = i < flameCount / 2 ? hotColor : flameColor;
flame.transform.localScale = Vector3.one * flameScale * Random.Range(0.8f, 1.2f);
float angle = (float)i / flameCount * Mathf.PI * 2f;
float radius = fireSpread * Random.Range(0.3f, 0.8f);
flameOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
flamePhases[i] = Random.Range(0f, Mathf.PI * 2f);
flames[i] = flame;
}
// Create embers
GameObject[] embers = new GameObject[emberCount];
Vector2[] emberOffsets = new Vector2[emberCount];
float[] emberSpeeds = new float[emberCount];
float[] emberPhases = new float[emberCount];
for (int i = 0; i < emberCount; i++) {
if (emberSprite == null) break;
GameObject ember = new GameObject($"Ember_{i}");
ember.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = ember.AddComponent<SpriteRenderer>();
renderer.sprite = emberSprite;
renderer.sortingLayerName = "Effects";
renderer.color = emberColor;
ember.transform.localScale = Vector3.one * 4f;
float angle = Random.Range(0f, Mathf.PI * 2f);
emberOffsets[i] =
new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * fireSpread * 0.5f;
emberSpeeds[i] = Random.Range(0.5f, 1.5f);
emberPhases[i] = Random.Range(0f, Mathf.PI * 2f);
embers[i] = ember;
}
// Animate
float elapsed = 0f;
while (elapsed < fireDuration) {
float t = elapsed / fireDuration;
// Animate flames (rise and flicker)
for (int i = 0; i < flames.Length; i++) {
if (flames[i] == null) continue;
float flicker = Mathf.Sin(elapsed * 20f + flamePhases[i]) * 0.3f + 0.7f;
float rise = Mathf.Sin(elapsed * 8f + flamePhases[i]) * 5f;
Vector2 pos = target + flameOffsets[i];
pos.y += rise + flameHeight * Mathf.Sin(t * Mathf.PI);
flames[i].transform.localPosition = new Vector3(pos.x, pos.y, 5f);
flames[i].transform.localScale =
Vector3.one * flameScale * flicker * (1f - t * 0.3f);
var renderer = flames[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color baseColor = i < flameCount / 2 ? hotColor : flameColor;
Color c = baseColor;
c.a = baseColor.a * flicker * (1f - t * 0.5f);
renderer.color = c;
}
}
// Animate embers (rise and drift)
for (int i = 0; i < embers.Length; i++) {
if (embers[i] == null) continue;
float emberT = t * emberSpeeds[i];
float drift = Mathf.Sin(elapsed * 5f + emberPhases[i]) * 10f;
Vector2 pos = target + emberOffsets[i];
pos.x += drift;
pos.y += flameHeight * 1.5f * emberT;
embers[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
var renderer = embers[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = emberColor;
c.a = emberColor.a * (1f - emberT);
renderer.color = c;
embers[i].transform.localScale = Vector3.one * 4f * (1f - emberT * 0.5f);
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
foreach (var flame in flames) {
if (flame != null) { Destroy(flame); }
}
foreach (var ember in embers) {
if (ember != null) { Destroy(ember); }
}
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 16506a5d765243e9badade12973e848d
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,198 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates unit fleeing with a quick dash away effect.
/// Creates dust clouds and motion blur trailing behind.
/// </summary>
public class FleeAnimator : MonoBehaviour {
[Header("Dust Settings")]
[Tooltip("Sprite for dust cloud")]
public Sprite dustSprite;
[Tooltip("Number of dust puffs")]
public int dustCount = 4;
[Tooltip("Color of dust")]
public Color dustColor = new Color(0.8f, 0.7f, 0.5f, 0.6f);
[Header("Motion Blur Settings")]
[Tooltip("Sprite for motion blur/afterimage")]
public Sprite blurSprite;
[Tooltip("Number of blur images")]
public int blurCount = 3;
[Tooltip("Color of blur (typically unit-colored)")]
public Color blurColor = new Color(0.5f, 0.5f, 0.5f, 0.5f);
[Header("Animation Settings")]
[Tooltip("Distance of the flee movement")]
public float fleeDistance = 50f;
[Tooltip("Duration of the flee animation")]
public float fleeDuration = 0.3f;
[Tooltip("Duration of dust settling")]
public float dustDuration = 0.4f;
[Tooltip("Scale of dust sprites")]
public float dustScale = 12f;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a flee animation from source away from threat.
/// </summary>
public void AnimateFlee(int sourceCellIndex, int threatCellIndex) {
if (dustSprite == null && blurSprite == null) {
Debug.LogWarning("FleeAnimator: No sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("FleeAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? threatPos = __hexGrid.GetCellCenterPosition(threatCellIndex);
if (sourcePos == null || threatPos == null) {
Debug.LogWarning(
$"FleeAnimator: Invalid cell indices {sourceCellIndex} or {threatCellIndex}");
return;
}
StartCoroutine(AnimateFleeEffect(sourcePos.Value, threatPos.Value));
}
private IEnumerator AnimateFleeEffect(Vector2 source, Vector2 threat) {
// Direction is AWAY from threat
Vector2 fleeDirection = (source - threat).normalized;
Vector2 fleeTarget = source + fleeDirection * fleeDistance;
// Create motion blur afterimages
GameObject[] blurs = new GameObject[blurCount];
for (int i = 0; i < blurCount; i++) {
if (blurSprite == null) break;
GameObject blur = new GameObject($"FleeBlur_{i}");
blur.transform.SetParent(__hexGrid.gridCanvas.transform, false);
blur.transform.localPosition = new Vector3(source.x, source.y, 5f);
blur.transform.localScale = Vector3.one * 15f;
SpriteRenderer renderer = blur.AddComponent<SpriteRenderer>();
renderer.sprite = blurSprite;
renderer.sortingLayerName = "Effects";
Color c = blurColor;
c.a = blurColor.a * (1f - (float)i / blurCount * 0.5f);
renderer.color = c;
blurs[i] = blur;
}
// Animate flee movement with blur trail
float elapsed = 0f;
while (elapsed < fleeDuration) {
float t = elapsed / fleeDuration;
float easeT = 1f - (1f - t) * (1f - t); // Ease out
Vector2 currentPos = Vector2.Lerp(source, fleeTarget, easeT);
// Update blur trail positions (staggered behind)
for (int i = 0; i < blurs.Length; i++) {
if (blurs[i] == null) continue;
float blurT = Mathf.Max(0f, easeT - (float)(i + 1) / blurCount * 0.3f);
Vector2 blurPos = Vector2.Lerp(source, fleeTarget, blurT);
blurs[i].transform.localPosition =
new Vector3(blurPos.x, blurPos.y, 5f + i * 0.1f);
// Fade as they get further behind
var renderer = blurs[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = blurColor;
c.a = blurColor.a * (1f - t) * (1f - (float)i / blurCount * 0.5f);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup blurs
foreach (var blur in blurs) {
if (blur != null) { Destroy(blur); }
}
// Create dust clouds at flee destination
GameObject[] dusts = new GameObject[dustCount];
Vector2[] dustOffsets = new Vector2[dustCount];
for (int i = 0; i < dustCount; i++) {
if (dustSprite == null) break;
GameObject dust = new GameObject($"FleeDust_{i}");
dust.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = dust.AddComponent<SpriteRenderer>();
renderer.sprite = dustSprite;
renderer.sortingLayerName = "Effects";
renderer.color = dustColor;
dust.transform.localScale = Vector3.one * dustScale * 0.5f;
// Position dust behind the flee path
float offset = (float)i / dustCount * fleeDistance * 0.8f;
Vector2 dustPos = fleeTarget - fleeDirection * offset;
dustPos += new Vector2(Random.Range(-10f, 10f), Random.Range(-5f, 5f));
dustOffsets[i] = new Vector2(Random.Range(-5f, 5f), Random.Range(5f, 15f));
dust.transform.localPosition = new Vector3(dustPos.x, dustPos.y, 6f);
dusts[i] = dust;
}
// Animate dust settling
elapsed = 0f;
while (elapsed < dustDuration) {
float t = elapsed / dustDuration;
for (int i = 0; i < dusts.Length; i++) {
if (dusts[i] == null) continue;
// Expand and rise
float scale = dustScale * (0.5f + t * 1.5f);
dusts[i].transform.localScale = Vector3.one * scale;
Vector3 pos = dusts[i].transform.localPosition;
pos.x += dustOffsets[i].x * Time.deltaTime;
pos.y += dustOffsets[i].y * Time.deltaTime * (1f - t);
dusts[i].transform.localPosition = pos;
// Fade
var renderer = dusts[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = dustColor;
c.a = dustColor.a * (1f - t);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup dust
foreach (var dust in dusts) {
if (dust != null) { Destroy(dust); }
}
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: 692443d7e72c42c99ddec3400c3d4d75
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,221 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates freezing water with ice crystal formation effect.
/// Creates expanding ice crystals with frost particles.
/// </summary>
public class FreezeAnimator : MonoBehaviour {
[Header("Ice Crystal Settings")]
[Tooltip("Sprite for ice crystals")]
public Sprite crystalSprite;
[Tooltip("Number of crystals")]
public int crystalCount = 6;
[Tooltip("Color of ice")]
public Color iceColor = new Color(0.7f, 0.9f, 1f, 0.9f);
[Header("Frost Settings")]
[Tooltip("Sprite for frost particles")]
public Sprite frostSprite;
[Tooltip("Number of frost particles")]
public int frostCount = 10;
[Tooltip("Color of frost")]
public Color frostColor = new Color(0.9f, 0.95f, 1f, 0.7f);
[Header("Animation Settings")]
[Tooltip("Scale of crystal sprites")]
public float crystalScale = 12f;
[Tooltip("Spread radius of ice effect")]
public float iceSpread = 25f;
[Tooltip("Duration of crystal formation")]
public float formDuration = 0.4f;
[Tooltip("Duration of shimmer effect")]
public float shimmerDuration = 0.3f;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a freeze animation at the target hex.
/// </summary>
public void AnimateFreeze(int targetCellIndex) {
if (crystalSprite == null && frostSprite == null) {
Debug.LogWarning("FreezeAnimator: No sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("FreezeAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (targetPos == null) {
Debug.LogWarning($"FreezeAnimator: Invalid cell index {targetCellIndex}");
return;
}
StartCoroutine(AnimateFreezeEffect(targetPos.Value));
}
private IEnumerator AnimateFreezeEffect(Vector2 target) {
// Create crystals
GameObject[] crystals = new GameObject[crystalCount];
Vector2[] crystalOffsets = new Vector2[crystalCount];
float[] crystalAngles = new float[crystalCount];
for (int i = 0; i < crystalCount; i++) {
if (crystalSprite == null) break;
GameObject crystal = new GameObject($"IceCrystal_{i}");
crystal.transform.SetParent(__hexGrid.gridCanvas.transform, false);
crystal.transform.localPosition = new Vector3(target.x, target.y, 5f);
crystal.transform.localScale = Vector3.zero;
SpriteRenderer renderer = crystal.AddComponent<SpriteRenderer>();
renderer.sprite = crystalSprite;
renderer.sortingLayerName = "Effects";
renderer.color = iceColor;
float angle = (float)i / crystalCount * Mathf.PI * 2f;
crystalAngles[i] = angle * Mathf.Rad2Deg;
float radius = iceSpread * Random.Range(0.5f, 1f);
crystalOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
crystal.transform.localRotation = Quaternion.Euler(0, 0, crystalAngles[i] + 90f);
crystals[i] = crystal;
}
// Create frost particles
GameObject[] frosts = new GameObject[frostCount];
Vector2[] frostOffsets = new Vector2[frostCount];
for (int i = 0; i < frostCount; i++) {
if (frostSprite == null) break;
GameObject frost = new GameObject($"Frost_{i}");
frost.transform.SetParent(__hexGrid.gridCanvas.transform, false);
frost.transform.localPosition = new Vector3(target.x, target.y, 4f);
frost.transform.localScale = Vector3.one * 3f;
SpriteRenderer renderer = frost.AddComponent<SpriteRenderer>();
renderer.sprite = frostSprite;
renderer.sortingLayerName = "Effects";
renderer.color = frostColor;
float angle = Random.Range(0f, Mathf.PI * 2f);
float radius = iceSpread * Random.Range(0.2f, 1.2f);
frostOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
frosts[i] = frost;
}
// Crystal formation phase
float elapsed = 0f;
while (elapsed < formDuration) {
float t = elapsed / formDuration;
float easeT = t * t * (3f - 2f * t);
// Grow crystals outward
for (int i = 0; i < crystals.Length; i++) {
if (crystals[i] == null) continue;
Vector2 pos = target + crystalOffsets[i] * easeT;
crystals[i].transform.localPosition = new Vector3(pos.x, pos.y, 5f);
crystals[i].transform.localScale = Vector3.one * crystalScale * easeT;
var renderer = crystals[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = iceColor;
c.a = iceColor.a * easeT;
renderer.color = c;
}
}
// Spread frost
for (int i = 0; i < frosts.Length; i++) {
if (frosts[i] == null) continue;
float frostT = Mathf.Clamp01(t * 1.5f);
Vector2 pos = target + frostOffsets[i] * frostT;
frosts[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
var renderer = frosts[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = frostColor;
c.a = frostColor.a * Mathf.Sin(frostT * Mathf.PI);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Shimmer phase
elapsed = 0f;
while (elapsed < shimmerDuration) {
float t = elapsed / shimmerDuration;
// Crystal shimmer
for (int i = 0; i < crystals.Length; i++) {
if (crystals[i] == null) continue;
var renderer = crystals[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
float shimmer = 0.8f + Mathf.Sin(t * Mathf.PI * 4f + i) * 0.2f;
Color c = iceColor;
c.a = iceColor.a * shimmer * (1f - t * 0.3f);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Fade out
float fadeDuration = 0.2f;
elapsed = 0f;
while (elapsed < fadeDuration) {
float t = elapsed / fadeDuration;
for (int i = 0; i < crystals.Length; i++) {
if (crystals[i] == null) continue;
var renderer = crystals[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = iceColor;
c.a = iceColor.a * (1f - t);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
foreach (var crystal in crystals) {
if (crystal != null) { Destroy(crystal); }
}
foreach (var frost in frosts) {
if (frost != null) { Destroy(frost); }
}
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: d7b7ff29594e436dab2ff82a41d6a4b7
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -357,12 +357,29 @@ public class HexGrid : MonoBehaviour {
/// <summary>
/// Gets the local position of a cell for use with effects (arrows, particles).
/// Returns the anchoredPosition which maps to localPosition in the rotated canvas.
/// Note: This returns the terrain image position, which is offset from the true hex center.
/// For the actual hex center, use GetCellCenterPosition.
/// </summary>
public Vector2? GetCellLocalPosition(int cellIndex) {
if (cells == null || cellIndex < 0 || cellIndex >= cells.Length) { return null; }
return cells[cellIndex].TerrainImage.rectTransform.anchoredPosition;
}
/// <summary>
/// Gets the true center position of a hex cell.
/// Unlike GetCellLocalPosition, this returns the actual geometric center of the hex.
/// </summary>
public Vector2? GetCellCenterPosition(int cellIndex) {
if (cells == null || cellIndex < 0 || cellIndex >= cells.Length) { return null; }
return cells[cellIndex].Geometry.AnchoredPosition;
}
/// <summary>
/// Gets the inner radius of a hex (distance from center to edge midpoint).
/// Useful for scaling effects relative to hex size.
/// </summary>
public float GetHexInnerRadius() { return metrics?.InnerRadius ?? 0f; }
public void OverlayCell(
int cellIndex,
Color mainColor,
@@ -445,11 +462,14 @@ public class HexGrid : MonoBehaviour {
}
public void SetProfessionImage(int cellIndex, Texture image) {
var professionImage = cells[cellIndex].ProfessionImage;
if (professionImage == null) return;
if (image == null) {
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: false);
professionImage.gameObject.SetActive(value: false);
} else {
cells[cellIndex].ProfessionImage.texture = image;
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: true);
professionImage.texture = image;
professionImage.gameObject.SetActive(value: true);
}
}
@@ -458,11 +478,14 @@ public class HexGrid : MonoBehaviour {
}
public void SetUnitTypeImage(int cellIndex, Texture image) {
var unitTypeImage = cells[cellIndex].UnitTypeImage;
if (unitTypeImage == null) return;
if (image == null) {
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: false);
unitTypeImage.gameObject.SetActive(value: false);
} else {
cells[cellIndex].UnitTypeImage.texture = image;
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: true);
unitTypeImage.texture = image;
unitTypeImage.gameObject.SetActive(value: true);
}
}
@@ -0,0 +1,224 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates a holy wave expanding from the source hex outward.
/// Creates a white glow that spreads to surrounding hexes, with violent
/// damage effects on hexes containing undead units.
/// </summary>
public class HolyWaveAnimator : MonoBehaviour {
[Header("Glow Settings")]
[Tooltip("Sprite for the expanding glow")]
public Sprite glowSprite;
[Tooltip("Color of the holy glow")]
public Color glowColor = new Color(1f, 1f, 0.9f, 0.7f);
[Tooltip("Initial scale as fraction of hex radius")]
public float glowStartRadii = 0.1f;
[Tooltip("Final scale as number of hex radii (2.5 = extends well into neighbors)")]
public float glowEndRadii = 2.5f;
[Header("Damage Effect Settings")]
[Tooltip("Sprite for damage effect on undead")]
public Sprite damageSprite;
[Tooltip("Color of the damage effect")]
public Color damageColor = new Color(1f, 1f, 0.5f, 1f);
[Tooltip("Scale of damage effect")]
public float damageScale = 25f;
[Tooltip("Number of damage flashes")]
public int damageFlashCount = 3;
[Header("Animation Settings")]
[Tooltip("Duration of the wave expansion")]
public float expansionDuration = 0.8f;
[Tooltip("Duration the wave holds at full size")]
public float holdDuration = 0.3f;
[Tooltip("Duration of fade out")]
public float fadeOutDuration = 0.3f;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a holy wave animation from the source hex.
/// </summary>
/// <param name="sourceCellIndex">The hex where the wave originates</param>
/// <param name="undeadCellIndices">Cells containing undead that will show damage
/// effects</param>
public void AnimateHolyWave(int sourceCellIndex, List<int> undeadCellIndices = null) {
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("HolyWaveAnimator: No _hexGrid or gridCanvas available");
return;
}
// Use GetCellCenterPosition for the true hex center
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
if (sourcePos == null) {
Debug.LogWarning($"HolyWaveAnimator: Invalid cell index {sourceCellIndex}");
return;
}
// Get positions of undead cells (use center positions)
List<Vector2> undeadPositions = new List<Vector2>();
if (undeadCellIndices != null) {
foreach (int cellIndex in undeadCellIndices) {
Vector2? pos = __hexGrid.GetCellCenterPosition(cellIndex);
if (pos != null) { undeadPositions.Add(pos.Value); }
}
}
// Compute scale based on hex size
float hexRadius = __hexGrid.GetHexInnerRadius();
float startScale = hexRadius * glowStartRadii;
float endScale = hexRadius * glowEndRadii * 2f; // diameter = 2 * radius
StartCoroutine(AnimateWave(sourcePos.Value, undeadPositions, startScale, endScale));
}
private IEnumerator AnimateWave(
Vector2 source,
List<Vector2> undeadPositions,
float startScale,
float endScale) {
List<GameObject> objects = new List<GameObject>();
// Create main glow
GameObject glow = null;
SpriteRenderer glowRenderer = null;
if (glowSprite != null) {
glow = new GameObject("HolyWaveGlow");
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
glow.transform.localPosition = new Vector3(source.x, source.y, 6f);
glow.transform.localScale = Vector3.one * startScale;
glowRenderer = glow.AddComponent<SpriteRenderer>();
glowRenderer.sprite = glowSprite;
glowRenderer.sortingLayerName = "Effects";
glowRenderer.color = glowColor;
objects.Add(glow);
}
// Track which undead have been hit (for triggering damage effects)
HashSet<int> hitUndead = new HashSet<int>();
// Expansion phase
float elapsed = 0f;
while (elapsed < expansionDuration) {
float t = elapsed / expansionDuration;
float easeT = t * (2f - t); // Ease out
if (glow != null) {
float scale = Mathf.Lerp(startScale, endScale, easeT);
glow.transform.localScale = Vector3.one * scale;
// Pulse the alpha slightly for ethereal effect
Color c = glowColor;
c.a = glowColor.a * (0.8f + 0.2f * Mathf.Sin(elapsed * 10f));
glowRenderer.color = c;
}
// Check if wave has reached any undead positions
float currentRadius = Mathf.Lerp(startScale, endScale, easeT) * 0.5f;
for (int i = 0; i < undeadPositions.Count; i++) {
if (hitUndead.Contains(i)) continue;
float distance = Vector2.Distance(source, undeadPositions[i]);
if (distance <= currentRadius) {
hitUndead.Add(i);
// Start damage effect coroutine (manages its own cleanup)
StartCoroutine(PlayDamageEffect(undeadPositions[i]));
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Ensure all undead are hit
for (int i = 0; i < undeadPositions.Count; i++) {
if (!hitUndead.Contains(i)) {
StartCoroutine(PlayDamageEffect(undeadPositions[i]));
}
}
// Hold at full size
if (glow != null) { glow.transform.localScale = Vector3.one * endScale; }
yield return new WaitForSeconds(holdDuration);
// Fade out
elapsed = 0f;
while (elapsed < fadeOutDuration) {
float t = elapsed / fadeOutDuration;
if (glowRenderer != null) {
Color c = glowColor;
c.a = glowColor.a * (1f - t);
glowRenderer.color = c;
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
foreach (var obj in objects) {
if (obj != null) { Destroy(obj); }
}
}
private IEnumerator PlayDamageEffect(Vector2 position) {
if (damageSprite == null) yield break;
GameObject damageEffect = new GameObject("HolyWaveDamage");
damageEffect.transform.SetParent(__hexGrid.gridCanvas.transform, false);
damageEffect.transform.localPosition = new Vector3(position.x, position.y, 5f);
SpriteRenderer renderer = damageEffect.AddComponent<SpriteRenderer>();
renderer.sprite = damageSprite;
renderer.sortingLayerName = "Effects";
// Flash effect
float flashDuration = 0.15f;
for (int i = 0; i < damageFlashCount; i++) {
// Flash on
renderer.color = damageColor;
damageEffect.transform.localScale = Vector3.one * damageScale;
yield return new WaitForSeconds(flashDuration * 0.6f);
// Flash off
renderer.color = new Color(damageColor.r, damageColor.g, damageColor.b, 0.3f);
damageEffect.transform.localScale = Vector3.one * damageScale * 0.8f;
yield return new WaitForSeconds(flashDuration * 0.4f);
}
// Final burst
float burstDuration = 0.2f;
float burstElapsed = 0f;
while (burstElapsed < burstDuration) {
float t = burstElapsed / burstDuration;
damageEffect.transform.localScale = Vector3.one * damageScale * (1f + t * 0.5f);
Color c = damageColor;
c.a = 1f - t;
renderer.color = c;
burstElapsed += Time.deltaTime;
yield return null;
}
Destroy(damageEffect);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d7925cd01f3b44fb1a3048f9fb5bfa6c
@@ -4,15 +4,19 @@ using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates a lightning bolt striking from source to target hex.
/// Creates a jagged electric arc with multiple segments and flicker effect.
/// Animates lightning bolts striking from source to target hex.
/// Creates multiple jagged electric arcs that all start at source center
/// but end at random points within the target hex.
/// </summary>
public class LightningAnimator : MonoBehaviour {
[Header("Lightning Settings")]
[Tooltip("Sprite for lightning bolt segment")]
public Sprite boltSegmentSprite;
[Tooltip("Number of segments in the bolt")]
[Tooltip("Number of lightning bolts")]
public int boltCount = 3;
[Tooltip("Number of segments per bolt")]
public int segmentCount = 8;
[Tooltip("Maximum perpendicular offset for jags")]
@@ -22,29 +26,28 @@ namespace Shardok {
public float segmentScale = 3f;
[Tooltip("Bolt segment thickness multiplier")]
public float thicknessMultiplier = 2f;
public float thicknessMultiplier = 1f;
[Tooltip("How far endpoints spread within target hex (0-1, fraction of hex radius)")]
public float targetSpread = 0.6f;
[Header("Animation Settings")]
[Tooltip("Total duration of lightning effect")]
public float duration = 0.25f;
[Tooltip("Number of times the bolt flickers")]
[Tooltip("Number of times the bolts flicker")]
public int flickerCount = 3;
[Tooltip("Color of the lightning")]
public Color boltColor = new Color(0.8f, 0.9f, 1f, 1f);
[Header("References")]
[Tooltip("HexGrid to get canvas reference from (auto-found if not set)")]
public HexGrid hexGrid;
private HexGrid __hexGrid;
private void Start() {
if (hexGrid == null) { hexGrid = FindObjectOfType<HexGrid>(); }
}
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a lightning animation between two hex cells.
/// Multiple animations can run simultaneously.
/// Multiple bolts originate from source center and strike random points in target hex.
/// </summary>
public void AnimateLightning(int sourceCellIndex, int targetCellIndex) {
if (boltSegmentSprite == null) {
@@ -52,13 +55,13 @@ namespace Shardok {
return;
}
if (hexGrid == null || hexGrid.gridCanvas == null) {
Debug.LogWarning("LightningAnimator: No hexGrid or gridCanvas available");
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("LightningAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = hexGrid.GetCellLocalPosition(sourceCellIndex);
Vector2? targetPos = hexGrid.GetCellLocalPosition(targetCellIndex);
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
@@ -66,40 +69,52 @@ namespace Shardok {
return;
}
StartCoroutine(AnimateBolt(sourcePos.Value, targetPos.Value));
float hexRadius = __hexGrid.GetHexInnerRadius();
StartCoroutine(AnimateBolts(sourcePos.Value, targetPos.Value, hexRadius));
}
private IEnumerator AnimateBolt(Vector2 source, Vector2 target) {
List<GameObject> segments = new List<GameObject>();
private IEnumerator AnimateBolts(Vector2 source, Vector2 targetCenter, float hexRadius) {
List<GameObject> allSegments = new List<GameObject>();
// Generate random endpoints for each bolt within the target hex
Vector2[] boltEndpoints = new Vector2[boltCount];
for (int b = 0; b < boltCount; b++) {
float angle = Random.Range(0f, Mathf.PI * 2f);
float radius = Random.Range(0f, hexRadius * targetSpread);
boltEndpoints[b] =
targetCenter + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
}
for (int flicker = 0; flicker < flickerCount; flicker++) {
// Clear previous flicker segments
foreach (var seg in segments) {
foreach (var seg in allSegments) {
if (seg != null) { Destroy(seg); }
}
segments.Clear();
allSegments.Clear();
// Generate jagged path
List<Vector2> points = GenerateBoltPath(source, target);
// Generate and create all bolts
for (int b = 0; b < boltCount; b++) {
List<Vector2> points = GenerateBoltPath(source, boltEndpoints[b]);
// Create segment objects
for (int i = 0; i < points.Count - 1; i++) {
var segment = CreateBoltSegment(points[i], points[i + 1]);
segments.Add(segment);
// Create segment objects for this bolt
for (int i = 0; i < points.Count - 1; i++) {
var segment = CreateBoltSegment(points[i], points[i + 1]);
allSegments.Add(segment);
}
}
// Show briefly
yield return new WaitForSeconds(duration / flickerCount * 0.6f);
// Hide briefly (flicker effect)
foreach (var seg in segments) {
foreach (var seg in allSegments) {
if (seg != null) { seg.SetActive(false); }
}
yield return new WaitForSeconds(duration / flickerCount * 0.4f);
}
// Cleanup
foreach (var seg in segments) {
foreach (var seg in allSegments) {
if (seg != null) { Destroy(seg); }
}
}
@@ -128,7 +143,7 @@ namespace Shardok {
private GameObject CreateBoltSegment(Vector2 start, Vector2 end) {
GameObject segment = new GameObject("LightningSegment");
segment.transform.SetParent(hexGrid.gridCanvas.transform, false);
segment.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = segment.AddComponent<SpriteRenderer>();
renderer.sprite = boltSegmentSprite;
@@ -18,11 +18,11 @@ namespace Shardok {
[Tooltip("Mace/hammer sprite for heavy infantry")]
public Sprite maceSprite;
[Tooltip("Small spear sprite for light cavalry")]
public Sprite smallSpearSprite;
[Tooltip("Falchion sprite for light cavalry")]
public Sprite falchionSprite;
[Tooltip("Large spear/lance sprite for heavy cavalry")]
public Sprite largeSpearSprite;
[Tooltip("Two-handed sword sprite for heavy cavalry")]
public Sprite twoHandedSwordSprite;
[Tooltip("Dagger sprite for longbowmen")]
public Sprite daggerSprite;
@@ -30,15 +30,57 @@ namespace Shardok {
[Tooltip("Bone/fist sprite for undead")]
public Sprite boneSprite;
[Header("Weapon Orientation - Sword")]
[Tooltip("Base rotation offset for sword (degrees)")]
public float swordRotationOffset = -45f;
[Tooltip("Flip sword horizontally")]
public bool swordFlipHorizontal = false;
[Header("Weapon Orientation - Mace")]
[Tooltip("Base rotation offset for mace (degrees)")]
public float maceRotationOffset = -45f;
[Tooltip("Flip mace horizontally")]
public bool maceFlipHorizontal = false;
[Header("Weapon Orientation - Falchion")]
[Tooltip("Base rotation offset for falchion (degrees)")]
public float falchionRotationOffset = -45f;
[Tooltip("Flip falchion horizontally")]
public bool falchionFlipHorizontal = false;
[Header("Weapon Orientation - Two-Handed Sword")]
[Tooltip("Base rotation offset for two-handed sword (degrees)")]
public float twoHandedSwordRotationOffset = -45f;
[Tooltip("Flip two-handed sword horizontally")]
public bool twoHandedSwordFlipHorizontal = false;
[Header("Weapon Orientation - Dagger")]
[Tooltip("Base rotation offset for dagger (degrees)")]
public float daggerRotationOffset = -45f;
[Tooltip("Flip dagger horizontally")]
public bool daggerFlipHorizontal = false;
[Header("Weapon Orientation - Bone")]
[Tooltip("Base rotation offset for bone (degrees)")]
public float boneRotationOffset = -45f;
[Tooltip("Flip bone horizontally")]
public bool boneFlipHorizontal = false;
[Header("Animation Settings")]
[Tooltip("Base scale of weapon sprites")]
public float weaponScale = 20.0f;
[Tooltip("Duration of a single clash cycle in seconds")]
public float clashDuration = 0.12f;
[Tooltip("Duration of a single swing cycle in seconds")]
public float clashDuration = 0.3f;
[Tooltip("Number of clash cycles")]
public int clashCount = 3;
[Tooltip("Number of swing cycles")]
public int clashCount = 2;
[Tooltip("Distance weapons travel toward each other (in local units)")]
public float thrustDistance = 25f;
@@ -46,23 +88,28 @@ namespace Shardok {
[Tooltip("Offset from center point along attack axis (in local units)")]
public float weaponOffset = 40f;
[Tooltip("Base rotation offset for diagonal sprites (degrees)")]
public float baseRotationOffset = -45f;
[Tooltip("Swing arc angle for swinging weapons (degrees)")]
public float swingArc = 60f;
public float swingArc = 90f;
[Tooltip("Amount of random shake (in local units)")]
public float shakeIntensity = 8f;
[Header("References")]
[Tooltip("HexGrid to get canvas reference from (auto-found if not set)")]
public HexGrid hexGrid;
[Tooltip("Amount of shake at impact only (in local units)")]
public float shakeIntensity = 3f;
private HexGrid __hexGrid;
private Coroutine _activeAnimation;
private GameObject _attackerWeapon;
private GameObject _defenderWeapon;
private void Start() {
if (hexGrid == null) { hexGrid = FindObjectOfType<HexGrid>(); }
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
private void CleanupWeapons() {
if (_attackerWeapon != null) {
Destroy(_attackerWeapon);
_attackerWeapon = null;
}
if (_defenderWeapon != null) {
Destroy(_defenderWeapon);
_defenderWeapon = null;
}
}
/// <summary>
@@ -77,19 +124,19 @@ namespace Shardok {
int defenderCellIndex,
BattalionTypeId attackerType,
BattalionTypeId defenderType) {
if (swordSprite == null && maceSprite == null && smallSpearSprite == null &&
largeSpearSprite == null && daggerSprite == null && boneSprite == null) {
if (swordSprite == null && maceSprite == null && falchionSprite == null &&
twoHandedSwordSprite == null && daggerSprite == null && boneSprite == null) {
Debug.LogWarning("MeleeAnimator: No weapon sprites assigned");
return;
}
if (hexGrid == null || hexGrid.gridCanvas == null) {
Debug.LogWarning("MeleeAnimator: No hexGrid or gridCanvas available");
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("MeleeAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? attackerPos = hexGrid.GetCellLocalPosition(attackerCellIndex);
Vector2? defenderPos = hexGrid.GetCellLocalPosition(defenderCellIndex);
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
if (attackerPos == null || defenderPos == null) {
Debug.LogWarning(
@@ -97,10 +144,11 @@ namespace Shardok {
return;
}
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
if (_activeAnimation != null) {
StopCoroutine(_activeAnimation);
CleanupWeapons();
}
Debug.Log(
$"MeleeAnimator: Starting melee from cell {attackerCellIndex} ({attackerType}) to {defenderCellIndex} ({defenderType})");
_activeAnimation = StartCoroutine(
AnimateClash(attackerPos.Value, defenderPos.Value, attackerType, defenderType));
}
@@ -109,7 +157,8 @@ namespace Shardok {
public Sprite sprite;
public bool swings; // true = swing arc, false = thrust
public float scaleMultiplier;
public float violenceMultiplier; // shake intensity multiplier
public float rotationOffset;
public bool flipHorizontal;
}
private WeaponConfig GetWeaponConfig(BattalionTypeId type) {
@@ -119,49 +168,66 @@ namespace Shardok {
sprite = swordSprite,
swings = true,
scaleMultiplier = 1.0f,
violenceMultiplier = 0.3f
rotationOffset = swordRotationOffset,
flipHorizontal = swordFlipHorizontal
};
case BattalionTypeId.HeavyInfantry:
return new WeaponConfig {
sprite = maceSprite ?? swordSprite,
swings = true,
scaleMultiplier = 1.2f,
violenceMultiplier = 0.4f
rotationOffset =
maceSprite != null ? maceRotationOffset : swordRotationOffset,
flipHorizontal =
maceSprite != null ? maceFlipHorizontal : swordFlipHorizontal
};
case BattalionTypeId.LightCavalry:
return new WeaponConfig {
sprite = smallSpearSprite ?? largeSpearSprite ?? swordSprite,
swings = false,
scaleMultiplier = 0.9f,
violenceMultiplier = 1.0f
sprite = falchionSprite ?? swordSprite,
swings = true,
scaleMultiplier = 1.0f,
rotationOffset = falchionSprite != null ? falchionRotationOffset
: swordRotationOffset,
flipHorizontal = falchionSprite != null ? falchionFlipHorizontal
: swordFlipHorizontal
};
case BattalionTypeId.HeavyCavalry:
return new WeaponConfig {
sprite = largeSpearSprite ?? smallSpearSprite ?? swordSprite,
swings = false,
sprite = twoHandedSwordSprite ?? swordSprite,
swings = true,
scaleMultiplier = 1.4f,
violenceMultiplier = 1.8f
rotationOffset = twoHandedSwordSprite != null ? twoHandedSwordRotationOffset
: swordRotationOffset,
flipHorizontal = twoHandedSwordSprite != null ? twoHandedSwordFlipHorizontal
: swordFlipHorizontal
};
case BattalionTypeId.Longbowmen:
return new WeaponConfig {
sprite = daggerSprite ?? swordSprite,
swings = false,
scaleMultiplier = 0.7f,
violenceMultiplier = 0.8f
rotationOffset =
daggerSprite != null ? daggerRotationOffset : swordRotationOffset,
flipHorizontal =
daggerSprite != null ? daggerFlipHorizontal : swordFlipHorizontal
};
case BattalionTypeId.Undead:
return new WeaponConfig {
sprite = boneSprite ?? swordSprite,
swings = true,
scaleMultiplier = 1.0f,
violenceMultiplier = 0.5f
rotationOffset =
boneSprite != null ? boneRotationOffset : swordRotationOffset,
flipHorizontal =
boneSprite != null ? boneFlipHorizontal : swordFlipHorizontal
};
default:
return new WeaponConfig {
sprite = swordSprite,
swings = true,
scaleMultiplier = 1.0f,
violenceMultiplier = 0.3f
rotationOffset = swordRotationOffset,
flipHorizontal = swordFlipHorizontal
};
}
}
@@ -180,14 +246,12 @@ namespace Shardok {
WeaponConfig attackerConfig = GetWeaponConfig(attackerType);
WeaponConfig defenderConfig = GetWeaponConfig(defenderType);
// Create weapons with type-specific scaling
GameObject attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
GameObject defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
// Create weapons with type-specific scaling (stored in class fields for cleanup)
_attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
_defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
bool attackerSwings = attackerConfig.swings;
bool defenderSwings = defenderConfig.swings;
float attackerViolence = shakeIntensity * attackerConfig.violenceMultiplier;
float defenderViolence = shakeIntensity * defenderConfig.violenceMultiplier;
// Starting positions offset from midpoint
Vector3 attackerStart = new Vector3(
@@ -209,91 +273,86 @@ namespace Shardok {
midpoint.y + direction.y * (weaponOffset - thrustDistance),
10f);
attackerWeapon.transform.localPosition = attackerStart;
defenderWeapon.transform.localPosition = defenderStart;
_attackerWeapon.transform.localPosition = attackerStart;
_defenderWeapon.transform.localPosition = defenderStart;
float attackerBaseAngle = baseAngle + baseRotationOffset;
float defenderBaseAngle = baseAngle + 180f + baseRotationOffset;
float attackerBaseAngle = baseAngle + attackerConfig.rotationOffset;
float defenderBaseAngle = baseAngle + 180f + defenderConfig.rotationOffset;
// Animate clash cycles with alternating swing directions
// Animate swing cycles with alternating swing directions
for (int i = 0; i < clashCount; i++) {
// Alternate swing direction each cycle
float swingDirection = (i % 2 == 0) ? 1f : -1f;
// Swing/thrust toward clash point
// Swing toward clash point
yield return StartCoroutine(AnimateWeaponSwing(
attackerWeapon,
_attackerWeapon,
attackerStart,
attackerClash,
attackerBaseAngle,
attackerSwings ? -swingArc * swingDirection : 0f,
attackerSwings ? 0f : 0f,
clashDuration / 2f,
true,
attackerViolence));
true));
yield return StartCoroutine(AnimateWeaponSwing(
defenderWeapon,
_defenderWeapon,
defenderStart,
defenderClash,
defenderBaseAngle,
defenderSwings ? swingArc * swingDirection : 0f,
defenderSwings ? 0f : 0f,
clashDuration / 2f,
true,
defenderViolence));
true));
// Brief violent shake at impact (use max violence of both)
float impactViolence = Mathf.Max(attackerViolence, defenderViolence);
// Brief shake at impact
yield return StartCoroutine(ImpactShake(
attackerWeapon,
defenderWeapon,
_attackerWeapon,
_defenderWeapon,
attackerClash,
defenderClash,
0.05f,
impactViolence));
shakeIntensity));
// Swing/pull back
// Swing back
yield return StartCoroutine(AnimateWeaponSwing(
attackerWeapon,
_attackerWeapon,
attackerClash,
attackerStart,
attackerBaseAngle,
0f,
attackerSwings ? -swingArc * swingDirection : 0f,
clashDuration / 2f,
false,
attackerViolence));
false));
yield return StartCoroutine(AnimateWeaponSwing(
defenderWeapon,
_defenderWeapon,
defenderClash,
defenderStart,
defenderBaseAngle,
0f,
defenderSwings ? swingArc * swingDirection : 0f,
clashDuration / 2f,
false,
defenderViolence));
false));
}
// Cleanup
if (attackerWeapon != null) { Destroy(attackerWeapon); }
if (defenderWeapon != null) { Destroy(defenderWeapon); }
CleanupWeapons();
_activeAnimation = null;
}
private GameObject CreateWeapon(WeaponConfig config, float angle) {
GameObject weapon = new GameObject("MeleeWeapon");
weapon.transform.SetParent(hexGrid.gridCanvas.transform, false);
weapon.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = weapon.AddComponent<SpriteRenderer>();
renderer.sprite = config.sprite;
renderer.sortingLayerName = "Effects";
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + baseRotationOffset);
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + config.rotationOffset);
float scale = weaponScale * config.scaleMultiplier;
weapon.transform.localScale = new Vector3(scale, scale, scale);
float xScale = config.flipHorizontal ? -scale : scale;
weapon.transform.localScale = new Vector3(xScale, scale, scale);
return weapon;
}
@@ -306,25 +365,20 @@ namespace Shardok {
float fromAngleOffset,
float toAngleOffset,
float duration,
bool isAttacking,
float violence) {
bool isAttacking) {
float elapsed = 0f;
while (elapsed < duration) {
if (weapon == null) { yield break; }
float t = elapsed / duration;
// Aggressive ease for attack, smoother for retreat
// Smooth ease for deliberate swing motion
float smoothT = isAttacking ? t * t * (3f - 2f * t) : t * (2f - t);
// Position with shake (scaled by violence)
Vector3 shake = new Vector3(
Random.Range(-violence, violence),
Random.Range(-violence, violence),
0f);
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT) + shake;
// Clean position interpolation - no shake during swing
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT);
// Rotation (for swinging weapons)
// Rotation arc for swinging weapons
float currentAngle =
Mathf.Lerp(baseAngle + fromAngleOffset, baseAngle + toAngleOffset, smoothT);
weapon.transform.localRotation = Quaternion.Euler(0, 0, currentAngle);
@@ -377,6 +431,7 @@ namespace Shardok {
StopCoroutine(_activeAnimation);
_activeAnimation = null;
}
CleanupWeapons();
}
}
}
@@ -28,60 +28,118 @@ namespace Shardok {
[Tooltip("Sprite for trail particles")]
public Sprite trailSprite;
[Tooltip("Number of trail particles")]
public int trailParticleCount = 8;
[Tooltip("Number of trail particles in continuous trail")]
public int trailParticleCount = 20;
[Tooltip("Scale of trail particles")]
public float trailScale = 8f;
public float trailScale = 50f;
[Tooltip("Color of trail")]
public Color trailColor = new Color(1f, 0.7f, 0.3f, 0.8f);
[Tooltip("Color of trail at front (hottest)")]
public Color trailColorHot = new Color(1f, 0.9f, 0.6f, 0.9f);
[Tooltip("Color of trail at back (cooler)")]
public Color trailColorCool = new Color(1f, 0.4f, 0.1f, 0.6f);
[Tooltip("Trail spread width")]
public float trailSpread = 12f;
[Header("Explosion Settings")]
[Tooltip("Sprite for explosion")]
public Sprite explosionSprite;
[Tooltip("Initial scale of explosion")]
public float explosionStartScale = 15f;
public float explosionStartScale = 20f;
[Tooltip("Final scale of explosion")]
public float explosionEndScale = 50f;
public float explosionEndScale = 250f;
[Tooltip("Color of explosion")]
public Color explosionColor = new Color(1f, 0.4f, 0.1f, 1f);
[Tooltip("Color of explosion core")]
public Color explosionColor = new Color(1f, 0.6f, 0.2f, 1f);
[Tooltip("Number of sparks")]
public int sparkCount = 6;
[Tooltip("Number of fire sparks")]
public int sparkCount = 8;
[Tooltip("Spark spread distance")]
public float sparkSpread = 40f;
public float sparkSpread = 50f;
[Header("Debris Settings")]
[Tooltip("Sprite for rock debris")]
public Sprite debrisSprite;
[Tooltip("Number of debris rocks")]
public int debrisCount = 6;
[Tooltip("Scale of debris rocks")]
public float debrisScale = 6f;
[Tooltip("Debris spread distance")]
public float debrisSpread = 60f;
[Tooltip("Color of debris")]
public Color debrisColor = new Color(0.4f, 0.3f, 0.2f, 1f);
[Header("Meteor Rotation")]
[Tooltip("Rotation speed in degrees per second")]
public float rotationSpeed = 360f;
[Header("Charging Settings (MeteorStart)")]
[Tooltip("Sprite for charging glow")]
public Sprite chargeSprite;
[Tooltip("Color of charging effect")]
public Color chargeColor = new Color(1f, 0.6f, 0.2f, 0.7f);
[Tooltip("Starting scale of charge glow")]
public float chargeStartScale = 5f;
[Tooltip("Ending scale of charge glow")]
public float chargeEndScale = 25f;
[Tooltip("Duration of charge animation")]
public float chargeDuration = 0.5f;
[Header("Targeting Settings (MeteorTarget)")]
[Tooltip("Sprite for target indicator")]
public Sprite targetSprite;
[Tooltip("Color of target indicator")]
public Color targetColor = new Color(1f, 0.3f, 0.1f, 0.6f);
[Tooltip("Scale of target indicator")]
public float targetScale = 30f;
[Tooltip("Duration of target animation")]
public float targetDuration = 0.4f;
[Header("Cancel Settings (MeteorCancel)")]
[Tooltip("Color of fizzle effect")]
public Color cancelColor = new Color(0.5f, 0.5f, 0.5f, 0.8f);
[Tooltip("Duration of cancel animation")]
public float cancelDuration = 0.3f;
[Header("Animation Settings")]
[Tooltip("Duration of meteor fall")]
public float fallDuration = 0.4f;
public float fallDuration = 0.8f;
[Tooltip("Duration of explosion")]
public float explosionDuration = 0.5f;
public float explosionDuration = 0.6f;
[Header("References")]
[Tooltip("HexGrid to get canvas reference from (auto-found if not set)")]
public HexGrid hexGrid;
private HexGrid __hexGrid;
private void Start() {
if (hexGrid == null) { hexGrid = FindObjectOfType<HexGrid>(); }
}
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a meteor animation at the target hex cell.
/// Multiple animations can run simultaneously.
/// </summary>
public void AnimateMeteorCast(int targetCellIndex) {
if (hexGrid == null || hexGrid.gridCanvas == null) {
Debug.LogWarning("MeteorAnimator: No hexGrid or gridCanvas available");
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? targetPos = hexGrid.GetCellLocalPosition(targetCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (targetPos == null) {
Debug.LogWarning($"MeteorAnimator: Invalid cell index {targetCellIndex}");
@@ -91,6 +149,315 @@ namespace Shardok {
StartCoroutine(AnimateMeteor(targetPos.Value));
}
/// <summary>
/// Animates meteor charging effect at the caster's position.
/// Shows a growing fiery glow gathering energy.
/// </summary>
public void AnimateMeteorStart(int sourceCellIndex) {
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
if (sourcePos == null) {
Debug.LogWarning($"MeteorAnimator: Invalid cell index {sourceCellIndex}");
return;
}
StartCoroutine(AnimateCharge(sourcePos.Value));
}
/// <summary>
/// Animates meteor targeting indicator at destination.
/// Shows a pulsing circle/crosshairs marking the target.
/// </summary>
public void AnimateMeteorTarget(int targetCellIndex) {
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (targetPos == null) {
Debug.LogWarning($"MeteorAnimator: Invalid cell index {targetCellIndex}");
return;
}
StartCoroutine(AnimateTarget(targetPos.Value));
}
/// <summary>
/// Animates meteor cancel/fizzle effect at the caster's position.
/// Shows the charging energy dissipating.
/// </summary>
public void AnimateMeteorCancel(int sourceCellIndex) {
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
if (sourcePos == null) {
Debug.LogWarning($"MeteorAnimator: Invalid cell index {sourceCellIndex}");
return;
}
StartCoroutine(AnimateCancel(sourcePos.Value));
}
private IEnumerator AnimateCharge(Vector2 source) {
Sprite sprite = chargeSprite ?? explosionSprite;
if (sprite == null) yield break;
// Create charging glow
GameObject glow = new GameObject("MeteorCharge");
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
glow.transform.localPosition = new Vector3(source.x, source.y, 5f);
SpriteRenderer renderer = glow.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = "Effects";
renderer.color = chargeColor;
// Create rising particles
int particleCount = 5;
GameObject[] particles = new GameObject[particleCount];
Vector2[] particleOffsets = new Vector2[particleCount];
for (int i = 0; i < particleCount; i++) {
if (trailSprite == null) break;
GameObject particle = new GameObject($"ChargeParticle_{i}");
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer pRenderer = particle.AddComponent<SpriteRenderer>();
pRenderer.sprite = trailSprite;
pRenderer.sortingLayerName = "Effects";
pRenderer.color = chargeColor;
particle.transform.localScale = Vector3.one * trailScale;
float angle = (float)i / particleCount * Mathf.PI * 2f;
particleOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * 20f;
particles[i] = particle;
}
// Animation
float elapsed = 0f;
while (elapsed < chargeDuration) {
float t = elapsed / chargeDuration;
float easeT = t * (2f - t); // Ease out
// Grow glow
float scale = Mathf.Lerp(chargeStartScale, chargeEndScale, easeT);
glow.transform.localScale = Vector3.one * scale;
// Pulse alpha
Color c = chargeColor;
c.a = chargeColor.a * (0.7f + 0.3f * Mathf.Sin(elapsed * 12f));
renderer.color = c;
// Particles spiral inward and upward
for (int i = 0; i < particles.Length; i++) {
if (particles[i] == null) continue;
float particleT = (t + (float)i / particleCount) % 1f;
float radius = 20f * (1f - particleT);
float angle =
particleT * Mathf.PI * 4f + (float)i / particleCount * Mathf.PI * 2f;
Vector2 offset = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
particles[i].transform.localPosition = new Vector3(
source.x + offset.x,
source.y + offset.y + particleT * 15f,
4f);
var pRenderer = particles[i].GetComponent<SpriteRenderer>();
if (pRenderer != null) {
Color pc = chargeColor;
pc.a = chargeColor.a * (1f - particleT * 0.5f);
pRenderer.color = pc;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Brief flash at end
glow.transform.localScale = Vector3.one * chargeEndScale * 1.2f;
Color flashColor = chargeColor;
flashColor.a = 1f;
renderer.color = flashColor;
yield return new WaitForSeconds(0.05f);
// Cleanup
Destroy(glow);
foreach (var p in particles) {
if (p != null) Destroy(p);
}
}
private IEnumerator AnimateTarget(Vector2 target) {
Sprite sprite = targetSprite ?? explosionSprite;
if (sprite == null) yield break;
// Create target indicator
GameObject indicator = new GameObject("MeteorTarget");
indicator.transform.SetParent(__hexGrid.gridCanvas.transform, false);
indicator.transform.localPosition = new Vector3(target.x, target.y, 5f);
SpriteRenderer renderer = indicator.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = "Effects";
renderer.color = targetColor;
// Create outer ring that contracts
GameObject ring = null;
SpriteRenderer ringRenderer = null;
if (sprite != null) {
ring = new GameObject("TargetRing");
ring.transform.SetParent(__hexGrid.gridCanvas.transform, false);
ring.transform.localPosition = new Vector3(target.x, target.y, 6f);
ringRenderer = ring.AddComponent<SpriteRenderer>();
ringRenderer.sprite = sprite;
ringRenderer.sortingLayerName = "Effects";
Color ringColor = targetColor;
ringColor.a = targetColor.a * 0.5f;
ringRenderer.color = ringColor;
}
// Animation - indicator appears, ring contracts to it
float elapsed = 0f;
while (elapsed < targetDuration) {
float t = elapsed / targetDuration;
// Main indicator pulses
float pulse = 0.8f + 0.2f * Mathf.Sin(elapsed * 15f);
indicator.transform.localScale = Vector3.one * targetScale * pulse;
Color c = targetColor;
c.a = targetColor.a * pulse;
renderer.color = c;
// Ring contracts inward
if (ring != null) {
float ringScale = targetScale * (2f - t);
ring.transform.localScale = Vector3.one * ringScale;
Color rc = targetColor;
rc.a = targetColor.a * 0.5f * (1f - t);
ringRenderer.color = rc;
}
elapsed += Time.deltaTime;
yield return null;
}
// Final pulse and fade
float fadeDuration = 0.15f;
elapsed = 0f;
while (elapsed < fadeDuration) {
float t = elapsed / fadeDuration;
indicator.transform.localScale = Vector3.one * targetScale * (1f + t * 0.3f);
Color c = targetColor;
c.a = targetColor.a * (1f - t);
renderer.color = c;
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
Destroy(indicator);
if (ring != null) Destroy(ring);
}
private IEnumerator AnimateCancel(Vector2 source) {
Sprite sprite = chargeSprite ?? explosionSprite;
if (sprite == null) yield break;
// Create fizzling glow (starts at charge end size)
GameObject glow = new GameObject("MeteorCancel");
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
glow.transform.localPosition = new Vector3(source.x, source.y, 5f);
glow.transform.localScale = Vector3.one * chargeEndScale;
SpriteRenderer renderer = glow.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = "Effects";
renderer.color = cancelColor;
// Create dispersing particles
int particleCount = 6;
GameObject[] particles = new GameObject[particleCount];
Vector2[] directions = new Vector2[particleCount];
for (int i = 0; i < particleCount; i++) {
if (trailSprite == null) break;
GameObject particle = new GameObject($"CancelParticle_{i}");
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
particle.transform.localPosition = new Vector3(source.x, source.y, 4f);
SpriteRenderer pRenderer = particle.AddComponent<SpriteRenderer>();
pRenderer.sprite = trailSprite;
pRenderer.sortingLayerName = "Effects";
pRenderer.color = cancelColor;
particle.transform.localScale = Vector3.one * trailScale;
float angle = (float)i / particleCount * Mathf.PI * 2f + Random.Range(-0.2f, 0.2f);
directions[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
particles[i] = particle;
}
// Animation - shrink and fade while particles disperse
float elapsed = 0f;
while (elapsed < cancelDuration) {
float t = elapsed / cancelDuration;
// Shrink and fade glow
float scale = chargeEndScale * (1f - t * 0.7f);
glow.transform.localScale = Vector3.one * scale;
Color c = cancelColor;
c.a = cancelColor.a * (1f - t);
renderer.color = c;
// Particles drift outward and fade
for (int i = 0; i < particles.Length; i++) {
if (particles[i] == null) continue;
Vector2 pos = source + directions[i] * 30f * t;
particles[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
var pRenderer = particles[i].GetComponent<SpriteRenderer>();
if (pRenderer != null) {
Color pc = cancelColor;
pc.a = cancelColor.a * (1f - t);
pRenderer.color = pc;
particles[i].transform.localScale =
Vector3.one * trailScale * (1f - t * 0.5f);
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
Destroy(glow);
foreach (var p in particles) {
if (p != null) Destroy(p);
}
}
private IEnumerator AnimateMeteor(Vector2 target) {
List<GameObject> objects = new List<GameObject>();
@@ -99,87 +466,103 @@ namespace Shardok {
Vector2 fallDirection = new Vector2(Mathf.Sin(angleRad), Mathf.Cos(angleRad));
Vector2 startPos = target + fallDirection * fallDistance;
// Create meteor
// Create meteor rock
GameObject meteor = null;
SpriteRenderer meteorRenderer = null;
float currentRotation = 0f;
if (meteorSprite != null) {
meteor = new GameObject("Meteor");
meteor.transform.SetParent(hexGrid.gridCanvas.transform, false);
meteor.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer meteorRenderer = meteor.AddComponent<SpriteRenderer>();
meteorRenderer = meteor.AddComponent<SpriteRenderer>();
meteorRenderer.sprite = meteorSprite;
meteorRenderer.sortingLayerName = "Effects";
meteorRenderer.color = meteorColor;
meteor.transform.localScale = Vector3.one * meteorScale;
// Rotate meteor to face direction of travel
float rotAngle = Mathf.Atan2(-fallDirection.x, -fallDirection.y) * Mathf.Rad2Deg;
meteor.transform.localRotation = Quaternion.Euler(0, 0, rotAngle);
meteor.transform.localPosition = new Vector3(startPos.x, startPos.y, 3f);
objects.Add(meteor);
}
// Trail particles
// Pre-create trail particles (continuous trail behind meteor)
List<TrailParticle> trailParticles = new List<TrailParticle>();
for (int i = 0; i < trailParticleCount; i++) {
if (trailSprite == null) break;
GameObject particle = new GameObject($"TrailParticle_{i}");
particle.transform.SetParent(hexGrid.gridCanvas.transform, false);
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = particle.AddComponent<SpriteRenderer>();
renderer.sprite = trailSprite;
renderer.sortingLayerName = "Effects";
renderer.color = trailColor;
particle.transform.localScale = Vector3.one * trailScale;
particle.SetActive(false);
particle.transform.localPosition = new Vector3(startPos.x, startPos.y, 4f);
// Particles further back are smaller and cooler colored
float trailT = (float)i / trailParticleCount;
float particleScale = trailScale * (1f - trailT * 0.6f);
particle.transform.localScale = Vector3.one * particleScale;
Color particleColor = Color.Lerp(trailColorHot, trailColorCool, trailT);
renderer.color = particleColor;
// Random offset perpendicular to fall direction
float perpOffset = Random.Range(-trailSpread, trailSpread) * trailT;
trailParticles.Add(new TrailParticle {
gameObject = particle,
delay = (float)i / trailParticleCount * 0.15f,
offset = Random.Range(-5f, 5f)
delay = trailT * 0.3f, // How far behind the meteor this particle trails
offset = perpOffset
});
objects.Add(particle);
}
// Fall phase
// Fall phase with rotation and continuous trail
float elapsed = 0f;
int nextTrailIndex = 0;
while (elapsed < fallDuration) {
float t = elapsed / fallDuration;
float easeT = t * t; // Accelerating fall
// Ease-in for accelerating fall (starts slow, speeds up)
float easeT = t * t;
Vector2 currentPos = Vector2.Lerp(startPos, target, easeT);
// Update meteor position and rotation
if (meteor != null) {
meteor.transform.localPosition = new Vector3(currentPos.x, currentPos.y, 3f);
// Continuous rotation
currentRotation += rotationSpeed * Time.deltaTime;
meteor.transform.localRotation = Quaternion.Euler(0, 0, currentRotation);
// Slight glow intensification as it approaches
Color c = meteorColor;
c.r = Mathf.Min(1f, meteorColor.r + t * 0.2f);
c.g = Mathf.Min(1f, meteorColor.g + t * 0.1f);
meteorRenderer.color = c;
}
// Spawn trail particles
while (nextTrailIndex < trailParticles.Count &&
elapsed >= trailParticles[nextTrailIndex].delay) {
var tp = trailParticles[nextTrailIndex];
tp.gameObject.SetActive(true);
tp.spawnPosition =
currentPos + new Vector2(tp.offset, tp.offset * 0.5f) * fallDirection.x;
tp.gameObject.transform.localPosition =
new Vector3(tp.spawnPosition.x, tp.spawnPosition.y, 4f);
nextTrailIndex++;
}
// Update trail particles to follow behind meteor
Vector2 perpDir = new Vector2(-fallDirection.y, fallDirection.x);
for (int i = 0; i < trailParticles.Count; i++) {
var tp = trailParticles[i];
if (tp.gameObject == null) continue;
// Fade trail particles
foreach (var tp in trailParticles) {
if (!tp.gameObject.activeSelf) continue;
float age = elapsed - tp.delay;
if (age > 0) {
float fadeT = Mathf.Clamp01(age / 0.2f);
var renderer = tp.gameObject.GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = trailColor;
c.a = trailColor.a * (1f - fadeT);
renderer.color = c;
tp.gameObject.transform.localScale =
Vector3.one * trailScale * (1f - fadeT * 0.5f);
}
// Calculate position along the trail (behind the meteor)
float trailT = (float)i / trailParticleCount;
float trailDistance = trailT * 50f; // How far behind
// Position is behind meteor along fall direction
Vector2 trailPos = currentPos + fallDirection * trailDistance;
// Add perpendicular wobble
trailPos += perpDir * tp.offset * Mathf.Sin(elapsed * 8f + i);
tp.gameObject.transform.localPosition = new Vector3(trailPos.x, trailPos.y, 4f);
// Flicker effect
var renderer = tp.gameObject.GetComponent<SpriteRenderer>();
if (renderer != null) {
Color baseColor = Color.Lerp(trailColorHot, trailColorCool, trailT);
baseColor.a *= 0.8f + 0.2f * Mathf.Sin(elapsed * 15f + i * 0.5f);
renderer.color = baseColor;
}
}
@@ -194,29 +577,30 @@ namespace Shardok {
}
objects.Clear();
// Explosion phase
// Explosion phase - bigger and with debris
GameObject explosion = null;
SpriteRenderer explosionRenderer = null;
if (explosionSprite != null) {
explosion = new GameObject("MeteorExplosion");
explosion.transform.SetParent(hexGrid.gridCanvas.transform, false);
explosion.transform.SetParent(__hexGrid.gridCanvas.transform, false);
explosion.transform.localPosition = new Vector3(target.x, target.y, 5f);
explosionRenderer = explosion.AddComponent<SpriteRenderer>();
explosionRenderer.sprite = explosionSprite;
explosionRenderer.sortingLayerName = "Effects";
explosionRenderer.color = explosionColor;
explosion.transform.localScale = Vector3.one * explosionStartScale;
objects.Add(explosion);
}
// Sparks
// Fire sparks (fast moving, fiery)
List<SparkParticle> sparks = new List<SparkParticle>();
for (int i = 0; i < sparkCount; i++) {
if (trailSprite == null) break;
GameObject spark = new GameObject($"Spark_{i}");
spark.transform.SetParent(hexGrid.gridCanvas.transform, false);
spark.transform.SetParent(__hexGrid.gridCanvas.transform, false);
spark.transform.localPosition = new Vector3(target.x, target.y, 4f);
SpriteRenderer renderer = spark.AddComponent<SpriteRenderer>();
@@ -229,27 +613,61 @@ namespace Shardok {
sparks.Add(new SparkParticle {
gameObject = spark,
direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)),
speed = sparkSpread* Random.Range(0.7f, 1.3f)
speed = sparkSpread* Random.Range(0.8f, 1.4f)
});
objects.Add(spark);
}
// Rock debris (slower, with gravity arc, rotating)
List<DebrisParticle> debris = new List<DebrisParticle>();
Sprite actualDebrisSprite = debrisSprite ?? meteorSprite;
if (actualDebrisSprite != null) {
for (int i = 0; i < debrisCount; i++) {
GameObject rock = new GameObject($"Debris_{i}");
rock.transform.SetParent(__hexGrid.gridCanvas.transform, false);
rock.transform.localPosition = new Vector3(target.x, target.y, 3f);
SpriteRenderer renderer = rock.AddComponent<SpriteRenderer>();
renderer.sprite = actualDebrisSprite;
renderer.sortingLayerName = "Effects";
renderer.color = debrisColor;
float randomScale = debrisScale * Random.Range(0.6f, 1.4f);
rock.transform.localScale = Vector3.one * randomScale;
// Debris flies outward in all directions
float angle =
(float)i / debrisCount * Mathf.PI * 2f + Random.Range(-0.4f, 0.4f);
Vector2 dir = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
debris.Add(new DebrisParticle {
gameObject = rock,
direction = dir,
speed = debrisSpread * Random.Range(0.6f, 1.2f),
rotationSpeed = Random.Range(-400f, 400f),
currentRotation = Random.Range(0f, 360f)
});
objects.Add(rock);
}
}
// Explosion animation
elapsed = 0f;
while (elapsed < explosionDuration) {
float t = elapsed / explosionDuration;
// Expand and fade explosion
// Expand and fade explosion with initial flash
if (explosion != null) {
float scale = Mathf.Lerp(explosionStartScale, explosionEndScale, t);
float flashT = t < 0.1f ? 1f + (0.1f - t) * 5f : 1f;
float scale = Mathf.Lerp(explosionStartScale, explosionEndScale, t) * flashT;
explosion.transform.localScale = Vector3.one * scale;
Color c = explosionColor;
c.a = 1f - t;
c.a = (1f - t * t); // Slower fade at start
explosionRenderer.color = c;
}
// Animate sparks
// Animate fire sparks (fast, straight lines)
foreach (var spark in sparks) {
if (spark.gameObject == null) continue;
@@ -262,7 +680,31 @@ namespace Shardok {
c.a = 1f - t;
renderer.color = c;
spark.gameObject.transform.localScale =
Vector3.one * trailScale * 1.5f * (1f - t * 0.5f);
Vector3.one * trailScale * 1.5f * (1f - t * 0.6f);
}
}
// Animate debris (flies outward in all directions, rotating)
for (int di = 0; di < debris.Count; di++) {
var d = debris[di];
if (d.gameObject == null) continue;
// Debris flies outward, slowing down over time
float easeT = t * (2f - t); // Ease out - fast start, slow end
Vector2 pos = target + d.direction * d.speed * easeT;
d.gameObject.transform.localPosition = new Vector3(pos.x, pos.y, 3f);
// Spin the debris
d.currentRotation += d.rotationSpeed * Time.deltaTime;
d.gameObject.transform.localRotation =
Quaternion.Euler(0, 0, d.currentRotation);
debris[di] = d; // Write back modified struct
var renderer = d.gameObject.GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = debrisColor;
c.a = 1f - t * 0.8f; // Debris fades slower
renderer.color = c;
}
}
@@ -288,5 +730,13 @@ namespace Shardok {
public Vector2 direction;
public float speed;
}
private struct DebrisParticle {
public GameObject gameObject;
public Vector2 direction;
public float speed;
public float rotationSpeed;
public float currentRotation;
}
}
}
@@ -1,45 +1,72 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Net.Eagle0.Shardok.Api.BattalionView.Types;
namespace Shardok {
/// <summary>
/// Animates unit movement from source to target hex with smooth interpolation.
/// Creates a temporary indicator that slides across the grid.
/// Animates unit movement between hex cells using a trail of footprints/hoofprints.
/// Boot prints for infantry, horseshoe prints for cavalry.
/// Prints appear sequentially then fade out FIFO.
/// </summary>
public class MoveAnimator : MonoBehaviour {
[Header("Print Sprites")]
[Tooltip("Boot print sprite (will be flipped for left/right)")]
public Sprite bootPrintSprite;
[Tooltip("Paired horseshoe prints sprite")]
public Sprite hoofPrintSprite;
[Header("Animation Settings")]
[Tooltip("Duration of the movement in seconds")]
public float moveDuration = 0.3f;
[Tooltip("Number of prints between hex centers")]
public int printsPerHex = 5;
[Tooltip("Optional sprite to show during movement")]
public Sprite moveIndicatorSprite;
[Tooltip("Time between each print appearing (seconds)")]
public float printInterval = 0.06f;
[Tooltip("Scale of the movement indicator")]
public float indicatorScale = 15f;
[Tooltip("How long each print stays fully visible before fading (seconds)")]
public float printLingerTime = 0.3f;
[Tooltip("Height offset for arc movement (0 = straight line)")]
public float arcHeight = 0.05f;
[Tooltip("Duration of each print's fade out (seconds)")]
public float fadeDuration = 0.4f;
[Header("References")]
[Tooltip("HexGrid to get canvas reference from (auto-found if not set)")]
public HexGrid hexGrid;
[Tooltip("Scale of print sprites")]
public float printScale = 1.5f;
private void Start() {
if (hexGrid == null) { hexGrid = FindObjectOfType<HexGrid>(); }
}
[Tooltip("Lateral offset for alternating prints")]
public float lateralOffset = 6f;
[Tooltip("Color tint for boot prints")]
public Color bootPrintColor = new Color(0.3f, 0.2f, 0.1f, 0.8f);
[Tooltip("Color tint for hoof prints")]
public Color hoofPrintColor = new Color(0.25f, 0.15f, 0.05f, 0.8f);
private HexGrid _hexGrid;
private readonly List<Coroutine> _activeCoroutines = new List<Coroutine>();
private void Start() { _hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a move animation between two hex cells.
/// Multiple animations can run simultaneously.
/// Animate movement from source to target hex.
/// </summary>
public void AnimateMove(int sourceCellIndex, int targetCellIndex) {
if (hexGrid == null || hexGrid.gridCanvas == null) {
Debug.LogWarning("MoveAnimator: No hexGrid or gridCanvas available");
/// <param name="sourceCellIndex">Starting hex cell index</param>
/// <param name="targetCellIndex">Destination hex cell index</param>
/// <param name="unitType">Type of battalion (determines boot vs hoof prints)</param>
public void
AnimateMove(int sourceCellIndex, int targetCellIndex, BattalionTypeId unitType) {
if (bootPrintSprite == null && hoofPrintSprite == null) {
Debug.LogWarning("MoveAnimator: No print sprites assigned");
return;
}
Vector2? sourcePos = hexGrid.GetCellLocalPosition(sourceCellIndex);
Vector2? targetPos = hexGrid.GetCellLocalPosition(targetCellIndex);
if (_hexGrid == null || _hexGrid.gridCanvas == null) {
Debug.LogWarning("MoveAnimator: No HexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = _hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = _hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
@@ -47,45 +74,122 @@ namespace Shardok {
return;
}
StartCoroutine(AnimateMovement(sourcePos.Value, targetPos.Value));
bool isMounted = unitType == BattalionTypeId.LightCavalry ||
unitType == BattalionTypeId.HeavyCavalry;
StartCoroutine(SpawnPrintTrail(sourcePos.Value, targetPos.Value, isMounted));
}
private IEnumerator AnimateMovement(Vector2 source, Vector2 target) {
// Create movement indicator if sprite assigned
GameObject indicator = null;
if (moveIndicatorSprite != null) {
indicator = new GameObject("MoveIndicator");
indicator.transform.SetParent(hexGrid.gridCanvas.transform, false);
/// <summary>
/// Overload for when unit type is not available - defaults to boot prints.
/// </summary>
public void AnimateMove(int sourceCellIndex, int targetCellIndex) {
AnimateMove(sourceCellIndex, targetCellIndex, BattalionTypeId.LightInfantry);
}
var renderer = indicator.AddComponent<SpriteRenderer>();
renderer.sprite = moveIndicatorSprite;
renderer.sortingLayerName = "Effects";
indicator.transform.localScale = Vector3.one * indicatorScale;
private IEnumerator SpawnPrintTrail(Vector2 source, Vector2 target, bool isMounted) {
var prints = new List<GameObject>();
Vector2 direction = (target - source).normalized;
float totalDistance = Vector2.Distance(source, target);
// Calculate rotation angle for prints to face direction of travel
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
Sprite printSprite = isMounted ? (hoofPrintSprite ?? bootPrintSprite)
: (bootPrintSprite ?? hoofPrintSprite);
Color printColor = isMounted ? hoofPrintColor : bootPrintColor;
// Spawn prints along the path
for (int i = 0; i < printsPerHex; i++) {
float t = (i + 1f) / (printsPerHex + 1f);
Vector2 position = Vector2.Lerp(source, target, t);
// Alternate left/right for boot prints (flip horizontally)
bool flipX = !isMounted && (i % 2 == 1);
// Lateral offset for alternating prints (feet or front/rear hooves)
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
float offset = (i % 2 == 0) ? -lateralOffset : lateralOffset;
position += perpendicular * offset;
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
prints.Add(print);
// Start FIFO fade coroutine for this print
var fadeCoroutine =
StartCoroutine(FadePrintFIFO(print, printLingerTime + i * printInterval));
_activeCoroutines.Add(fadeCoroutine);
yield return new WaitForSeconds(printInterval);
}
// Wait for all fades to complete
float totalWaitTime = printLingerTime + fadeDuration + 0.1f;
yield return new WaitForSeconds(totalWaitTime);
// Cleanup any remaining prints
foreach (var print in prints) {
if (print != null) { Destroy(print); }
}
_activeCoroutines.Clear();
}
private GameObject
CreatePrint(Vector2 position, float angle, Sprite sprite, Color color, bool flipX) {
GameObject print = new GameObject("Footprint");
print.transform.SetParent(_hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = print.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = "Effects";
renderer.sortingOrder = -1; // Below other effects
renderer.color = color;
print.transform.localPosition = new Vector3(position.x, position.y, 0);
print.transform.localRotation = Quaternion.Euler(0, 0, angle);
Vector3 scale = new Vector3(flipX ? -printScale : printScale, printScale, printScale);
print.transform.localScale = scale;
return print;
}
private IEnumerator FadePrintFIFO(GameObject print, float delay) {
// Wait before starting fade (FIFO - earlier prints fade first)
yield return new WaitForSeconds(delay);
if (print == null) yield break;
SpriteRenderer renderer = print.GetComponent<SpriteRenderer>();
if (renderer == null) yield break;
Color startColor = renderer.color;
float elapsed = 0f;
float distance = Vector2.Distance(source, target);
while (elapsed < moveDuration) {
float t = elapsed / moveDuration;
float smoothT = t * t * (3f - 2f * t); // Smooth step
while (elapsed < fadeDuration) {
if (print == null || renderer == null) yield break;
Vector3 pos = Vector3.Lerp(
new Vector3(source.x, source.y, 5f),
new Vector3(target.x, target.y, 5f),
smoothT);
// Add slight arc on Y axis for visible height in top-down view
float arc = 4f * arcHeight * distance * t * (1f - t);
pos.y += arc;
if (indicator != null) { indicator.transform.localPosition = pos; }
float t = elapsed / fadeDuration;
Color newColor = startColor;
newColor.a = Mathf.Lerp(startColor.a, 0f, t);
renderer.color = newColor;
elapsed += Time.deltaTime;
yield return null;
}
if (indicator != null) { Destroy(indicator); }
if (print != null) { Destroy(print); }
}
/// <summary>
/// Cancels all active print animations.
/// </summary>
public void CancelAllAnimations() {
foreach (var coroutine in _activeCoroutines) {
if (coroutine != null) { StopCoroutine(coroutine); }
}
_activeCoroutines.Clear();
}
}
}
@@ -44,25 +44,21 @@ namespace Shardok {
[Tooltip("Duration of rising phase")]
public float riseDuration = 0.8f;
[Header("References")]
[Tooltip("HexGrid to get canvas reference from (auto-found if not set)")]
public HexGrid hexGrid;
private HexGrid __hexGrid;
private void Start() {
if (hexGrid == null) { hexGrid = FindObjectOfType<HexGrid>(); }
}
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a raise dead animation at the target hex cell.
/// Multiple animations can run simultaneously.
/// </summary>
public void AnimateRaiseDead(int targetCellIndex) {
if (hexGrid == null || hexGrid.gridCanvas == null) {
Debug.LogWarning("RaiseDeadAnimator: No hexGrid or gridCanvas available");
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("RaiseDeadAnimator: No __hexGrid or gridCanvas available");
return;
}
Vector2? targetPos = hexGrid.GetCellLocalPosition(targetCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (targetPos == null) {
Debug.LogWarning($"RaiseDeadAnimator: Invalid cell index {targetCellIndex}");
@@ -81,7 +77,7 @@ namespace Shardok {
if (glowSprite != null) {
glow = new GameObject("RaiseDeadGlow");
glow.transform.SetParent(hexGrid.gridCanvas.transform, false);
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
glow.transform.localPosition = new Vector3(target.x, target.y, 8f);
glow.transform.localScale = Vector3.zero;
@@ -112,7 +108,7 @@ namespace Shardok {
if (figureSprite == null) break;
GameObject figure = new GameObject($"RisingUndead_{i}");
figure.transform.SetParent(hexGrid.gridCanvas.transform, false);
figure.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer figureRenderer = figure.AddComponent<SpriteRenderer>();
figureRenderer.sprite = figureSprite;
@@ -0,0 +1,251 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates scouting action with an eye at source, a vision cone extending
/// toward target, and a glow that travels with the cone tip and grows to
/// cover the target hex and its neighbors.
/// </summary>
public class ScoutAnimator : MonoBehaviour {
[Header("Eye Settings")]
[Tooltip("Sprite for the eye/binoculars icon")]
public Sprite eyeSprite;
[Tooltip("Color of the eye")]
public Color eyeColor = new Color(0.9f, 0.9f, 0.3f, 0.9f);
[Tooltip("Scale of the eye sprite")]
public float eyeScale = 15f;
[Header("Vision Cone Settings")]
[Tooltip("Sprite for vision cone/beam (triangle pointing right)")]
public Sprite coneSprite;
[Tooltip("Color of the vision cone")]
public Color coneColor = new Color(1f, 1f, 0.5f, 0.3f);
[Tooltip("Width of the vision cone at base")]
public float coneWidth = 30f;
[Tooltip("Rotation offset for cone sprite (degrees). +90 for upward-pointing triangle")]
public float coneRotationOffset = 90f;
[Header("Glow Settings")]
[Tooltip("Sprite for the traveling glow (circle)")]
public Sprite glowSprite;
[Tooltip("Color of the glow")]
public Color glowColor = new Color(1f, 1f, 0.7f, 0.5f);
[Tooltip("Starting scale of glow (at source)")]
public float glowStartScale = 10f;
[Tooltip("Final scale of glow in hex radii (2.5 = covers 7-hex area)")]
public float glowEndRadii = 2.5f;
[Header("Animation Settings")]
[Tooltip("Duration of cone extension")]
public float extendDuration = 0.5f;
[Tooltip("Duration the glow holds at target")]
public float holdDuration = 0.4f;
[Tooltip("Duration of fade out")]
public float fadeDuration = 0.3f;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a scout animation from source toward target hex.
/// </summary>
public void AnimateScout(int sourceCellIndex, int targetCellIndex) {
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("ScoutAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
$"ScoutAnimator: Invalid cell indices {sourceCellIndex} or {targetCellIndex}");
return;
}
float hexRadius = __hexGrid.GetHexInnerRadius();
float glowEndScale = hexRadius * glowEndRadii * 2f;
StartCoroutine(AnimateScoutEffect(sourcePos.Value, targetPos.Value, glowEndScale));
}
private IEnumerator AnimateScoutEffect(Vector2 source, Vector2 target, float glowEndScale) {
Vector2 direction = (target - source).normalized;
float baseAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
float distance = Vector2.Distance(source, target);
// Create eye at source
GameObject eye = null;
SpriteRenderer eyeRenderer = null;
if (eyeSprite != null) {
eye = new GameObject("ScoutEye");
eye.transform.SetParent(__hexGrid.gridCanvas.transform, false);
eye.transform.localPosition = new Vector3(source.x, source.y, 5f);
eye.transform.localScale = Vector3.zero;
eyeRenderer = eye.AddComponent<SpriteRenderer>();
eyeRenderer.sprite = eyeSprite;
eyeRenderer.sortingLayerName = "Effects";
eyeRenderer.color = eyeColor;
}
// Create vision cone
GameObject cone = null;
SpriteRenderer coneRenderer = null;
if (coneSprite != null) {
cone = new GameObject("VisionCone");
cone.transform.SetParent(__hexGrid.gridCanvas.transform, false);
cone.transform.localPosition = new Vector3(source.x, source.y, 6f);
cone.transform.localRotation =
Quaternion.Euler(0, 0, baseAngle + coneRotationOffset);
cone.transform.localScale = new Vector3(coneWidth, 0f, 1f);
coneRenderer = cone.AddComponent<SpriteRenderer>();
coneRenderer.sprite = coneSprite;
coneRenderer.sortingLayerName = "Effects";
coneRenderer.color = coneColor;
}
// Create traveling glow
GameObject glow = null;
SpriteRenderer glowRenderer = null;
if (glowSprite != null) {
glow = new GameObject("ScoutGlow");
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
glow.transform.localPosition = new Vector3(source.x, source.y, 7f);
glow.transform.localScale = Vector3.one * glowStartScale;
glowRenderer = glow.AddComponent<SpriteRenderer>();
glowRenderer.sprite = glowSprite;
glowRenderer.sortingLayerName = "Effects";
glowRenderer.color = glowColor;
}
// Eye appearance phase
float appearDuration = 0.15f;
float elapsed = 0f;
while (elapsed < appearDuration) {
float t = elapsed / appearDuration;
if (eye != null) {
float scale = eyeScale * t * (2f - t);
eye.transform.localScale = Vector3.one * scale;
}
elapsed += Time.deltaTime;
yield return null;
}
if (eye != null) { eye.transform.localScale = Vector3.one * eyeScale; }
// Cone extension phase - cone extends, glow travels and grows
elapsed = 0f;
while (elapsed < extendDuration) {
float t = elapsed / extendDuration;
float easeT = t * (2f - t); // Ease out
// Extend cone - Y axis is length after rotation, X is width
if (cone != null) {
float length = distance * easeT;
Vector2 conePos = source + direction * (length * 0.5f);
cone.transform.localPosition = new Vector3(conePos.x, conePos.y, 6f);
cone.transform.localScale =
new Vector3(coneWidth * (1f - easeT * 0.3f), length, 1f);
Color c = coneColor;
c.a = coneColor.a * (0.8f + 0.2f * Mathf.Sin(elapsed * 15f));
coneRenderer.color = c;
}
// Move and grow glow at cone tip
if (glow != null) {
Vector2 glowPos = source + direction * distance * easeT;
glow.transform.localPosition = new Vector3(glowPos.x, glowPos.y, 7f);
float glowScale = Mathf.Lerp(glowStartScale, glowEndScale, easeT);
glow.transform.localScale = Vector3.one * glowScale;
Color c = glowColor;
c.a = glowColor.a * (0.7f + 0.3f * Mathf.Sin(elapsed * 12f));
glowRenderer.color = c;
}
elapsed += Time.deltaTime;
yield return null;
}
// Ensure final positions
if (cone != null) {
Vector2 conePos = source + direction * (distance * 0.5f);
cone.transform.localPosition = new Vector3(conePos.x, conePos.y, 6f);
cone.transform.localScale = new Vector3(coneWidth * 0.7f, distance, 1f);
}
if (glow != null) {
glow.transform.localPosition = new Vector3(target.x, target.y, 7f);
glow.transform.localScale = Vector3.one * glowEndScale;
}
// Hold phase - glow pulses at target
elapsed = 0f;
while (elapsed < holdDuration) {
float t = elapsed / holdDuration;
if (glowRenderer != null) {
Color c = glowColor;
c.a = glowColor.a * (0.8f + 0.2f * Mathf.Sin(elapsed * 10f));
glowRenderer.color = c;
}
elapsed += Time.deltaTime;
yield return null;
}
// Fade out phase
elapsed = 0f;
while (elapsed < fadeDuration) {
float t = elapsed / fadeDuration;
if (eyeRenderer != null) {
Color c = eyeColor;
c.a = eyeColor.a * (1f - t);
eyeRenderer.color = c;
}
if (coneRenderer != null) {
Color c = coneColor;
c.a = coneColor.a * (1f - t);
coneRenderer.color = c;
}
if (glowRenderer != null) {
Color c = glowColor;
c.a = glowColor.a * (1f - t);
glowRenderer.color = c;
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
if (eye != null) { Destroy(eye); }
if (cone != null) { Destroy(cone); }
if (glow != null) { Destroy(glow); }
}
}
}
@@ -0,0 +1,10 @@
fileFormatVersion: 2
guid: daed3cae2aa74ac2a50c1b70232beb33
MonoImporter:
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -5,6 +5,7 @@ using System.Globalization;
using System.Linq;
using common;
using eagle0;
using Eagle0.Tutorial;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using TMPro;
@@ -35,7 +36,10 @@ namespace Shardok {
Fear,
// Magic
LightningBolt,
MeteorStart,
MeteorTarget,
MeteorCast,
MeteorCancel,
HolyWave,
RaiseDead,
Control,
@@ -115,6 +119,19 @@ namespace Shardok {
public CatapultAnimator catapultAnimator;
public RaiseDeadAnimator raiseDeadAnimator;
public MeteorAnimator meteorAnimator;
public HolyWaveAnimator holyWaveAnimator;
public ChargeAnimator chargeAnimator;
public ToolAnimator toolAnimator;
public FearAnimator fearAnimator;
public ControlAnimator controlAnimator;
public FireEffectAnimator fireEffectAnimator;
public ExtinguishAnimator extinguishAnimator;
public FreezeAnimator freezeAnimator;
public WaterEffectAnimator waterEffectAnimator;
public ScoutAnimator scoutAnimator;
public DismissAnimator dismissAnimator;
public FleeAnimator fleeAnimator;
public DuelAnimator duelAnimator;
public CommandTypeUIManager commandTypeUIManager;
public ActionResultTypeManager actionResultTypeManager;
@@ -228,6 +245,8 @@ namespace Shardok {
turnHistoryPanel.Clear();
this.gameObject.SetActive(false);
} else {
// Notify tutorial system of turn end
TutorialManager.Instance?.TriggerRegistry?.OnTurnEnded();
Model.EndTurn();
}
}
@@ -378,8 +397,6 @@ namespace Shardok {
Model = shardokGameModel;
Model.UpdateAction = ModelUpdated;
HexMap map = Model.Map;
Texture[] textures = new Texture[map.RowCount * map.ColumnCount];
Coords coords = new Coords();
@@ -405,10 +422,17 @@ namespace Shardok {
hexGrid.SetUp();
hexGrid.gameObject.SetActive(true);
// Set UpdateAction after hexGrid is ready to avoid race condition
Model.UpdateAction = ModelUpdated;
SetHeroLabels();
MainQueue.Q.EnqueueForNextUpdate(() => { ModelUpdated(); });
// Initialize tutorial system for tactical combat
TutorialManager.Instance?.Initialize(null, this);
TutorialManager.Instance?.TriggerRegistry?.OnBattleEntered(Model);
#if UNITY_EDITOR
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
#endif
@@ -528,6 +552,9 @@ namespace Shardok {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
// Notify tutorial system of battle action
TutorialManager.Instance?.TriggerRegistry?.OnBattleAction(historyEntry);
ActionType type = historyEntry.Type;
AnimationType animationType = AnimationTypeForAction(type);
@@ -552,7 +579,12 @@ namespace Shardok {
if (Model.UnitsById.TryGetValue(
historyEntry.Actor.Value,
out var actorUnit)) {
int sourceIndex = MapCoordsToGridIndex(actorUnit.Location);
// For Move actions, use stored source coords (unit's pre-move location)
// For other actions, use current unit location
int sourceIndex =
Model.MoveSourceCoords.TryGetValue(i, out var sourceCoords)
? MapCoordsToGridIndex(sourceCoords)
: MapCoordsToGridIndex(actorUnit.Location);
// Get target - either coords (archery) or unit location (melee)
int targetIndex = -1;
@@ -1165,6 +1197,9 @@ namespace Shardok {
}
_selectedGridIndex = selectedIndex;
RedrawCommandOverlays(selectedIndex, selectedIndex);
// Notify tutorial system of unit selection
TutorialManager.Instance?.TriggerRegistry?.OnUnitSelected(selectedIndex);
}
public void HandleActionClick(int clickedIndex) {
@@ -1330,8 +1365,8 @@ namespace Shardok {
case CommandType.MoveCommand: return AnimationType.Move;
// Magic
case CommandType.LightningBoltCommand: return AnimationType.LightningBolt;
case CommandType.MeteorStartCommand:
case CommandType.MeteorTargetCommand: return AnimationType.MeteorCast;
case CommandType.MeteorStartCommand: return AnimationType.MeteorStart;
case CommandType.MeteorTargetCommand: return AnimationType.MeteorTarget;
case CommandType.HolyWaveCommand: return AnimationType.HolyWave;
case CommandType.RaiseDeadCommand: return AnimationType.RaiseDead;
case CommandType.ControlCommand: return AnimationType.Control;
@@ -1370,9 +1405,10 @@ namespace Shardok {
case ActionType.FearAttack: return AnimationType.Fear;
// Magic
case ActionType.LightningBolt: return AnimationType.LightningBolt;
case ActionType.MeteorStart:
case ActionType.MeteorTarget:
case ActionType.MeteorStart: return AnimationType.MeteorStart;
case ActionType.MeteorTarget: return AnimationType.MeteorTarget;
case ActionType.MeteorCast: return AnimationType.MeteorCast;
case ActionType.MeteorCancel: return AnimationType.MeteorCancel;
case ActionType.HolyWave:
case ActionType.HolyWaveDamage: return AnimationType.HolyWave;
case ActionType.RaisedUndead: return AnimationType.RaiseDead;
@@ -1394,9 +1430,7 @@ namespace Shardok {
case ActionType.Reduce: return AnimationType.Reduce;
case ActionType.Fled: return AnimationType.Flee;
// Other
case ActionType.DuelChallenged:
case ActionType.DuelAccepted:
case ActionType.DuelDeclined: return AnimationType.Duel;
case ActionType.DuelAccepted: return AnimationType.Duel;
default: return AnimationType.None;
}
}
@@ -1414,7 +1448,10 @@ namespace Shardok {
case AnimationType.Fear: return ActionType.FearAttack;
// Magic
case AnimationType.LightningBolt: return ActionType.LightningBolt;
case AnimationType.MeteorStart: return ActionType.MeteorStart;
case AnimationType.MeteorTarget: return ActionType.MeteorTarget;
case AnimationType.MeteorCast: return ActionType.MeteorCast;
case AnimationType.MeteorCancel: return ActionType.MeteorCancel;
case AnimationType.HolyWave: return ActionType.HolyWave;
case AnimationType.RaiseDead: return ActionType.RaisedUndead;
case AnimationType.Control: return ActionType.Controlled;
@@ -1435,7 +1472,7 @@ namespace Shardok {
case AnimationType.Reduce: return ActionType.Reduce;
case AnimationType.Flee: return ActionType.Fled;
// Other
case AnimationType.Duel: return ActionType.DuelChallenged;
case AnimationType.Duel: return ActionType.DuelAccepted;
default: return ActionType.UnknownAction;
}
}
@@ -1538,8 +1575,11 @@ namespace Shardok {
}
break;
case AnimationType.Move:
if (moveAnimator != null) {
moveAnimator.AnimateMove(sourceGridIndex, targetGridIndex);
if (moveAnimator != null && attackerUnit != null) {
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
}
break;
case AnimationType.LightningBolt:
@@ -1557,11 +1597,97 @@ namespace Shardok {
raiseDeadAnimator.AnimateRaiseDead(targetGridIndex);
}
break;
case AnimationType.MeteorStart:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorStart(sourceGridIndex);
}
break;
case AnimationType.MeteorTarget:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorTarget(targetGridIndex);
}
break;
case AnimationType.MeteorCast:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorCast(targetGridIndex);
}
break;
case AnimationType.MeteorCancel:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorCancel(sourceGridIndex);
}
break;
case AnimationType.HolyWave:
if (holyWaveAnimator != null) {
var undeadCells = FindNearbyUndeadCells(sourceGridIndex, 2);
holyWaveAnimator.AnimateHolyWave(sourceGridIndex, undeadCells);
}
break;
case AnimationType.Charge:
if (chargeAnimator != null && attackerUnit != null && defenderUnit != null) {
chargeAnimator.AnimateCharge(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type,
defenderUnit.Battalion.Type);
}
break;
case AnimationType.Fear:
if (fearAnimator != null) {
fearAnimator.AnimateFear(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.Control:
if (controlAnimator != null) {
controlAnimator.AnimateControl(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.StartFire:
case AnimationType.FireDamage:
if (fireEffectAnimator != null) {
fireEffectAnimator.AnimateFire(targetGridIndex);
}
break;
case AnimationType.ExtinguishFire:
if (extinguishAnimator != null) {
extinguishAnimator.AnimateExtinguish(targetGridIndex);
}
break;
case AnimationType.FreezeWater:
if (freezeAnimator != null) { freezeAnimator.AnimateFreeze(targetGridIndex); }
break;
case AnimationType.BuildBridge:
if (toolAnimator != null) { toolAnimator.AnimateBuildBridge(targetGridIndex); }
break;
case AnimationType.Repair:
if (toolAnimator != null) { toolAnimator.AnimateRepair(targetGridIndex); }
break;
case AnimationType.BraveWater:
case AnimationType.WaterDamage:
if (waterEffectAnimator != null) {
waterEffectAnimator.AnimateWaterSplash(targetGridIndex);
}
break;
case AnimationType.Scout:
if (scoutAnimator != null) {
scoutAnimator.AnimateScout(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.DismissUnit:
if (dismissAnimator != null) {
dismissAnimator.AnimateDismiss(targetGridIndex);
}
break;
case AnimationType.Flee:
if (fleeAnimator != null) {
fleeAnimator.AnimateFlee(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.Duel:
if (duelAnimator != null) {
duelAnimator.AnimateDuel(sourceGridIndex, targetGridIndex);
}
break;
}
}
@@ -1612,8 +1738,11 @@ namespace Shardok {
}
break;
case AnimationType.Move:
if (moveAnimator != null) {
moveAnimator.AnimateMove(sourceGridIndex, targetGridIndex);
if (moveAnimator != null && attackerUnit != null) {
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
}
break;
case AnimationType.LightningBolt:
@@ -1631,14 +1760,138 @@ namespace Shardok {
raiseDeadAnimator.AnimateRaiseDead(targetGridIndex);
}
break;
case AnimationType.MeteorStart:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorStart(sourceGridIndex);
}
break;
case AnimationType.MeteorTarget:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorTarget(targetGridIndex);
}
break;
case AnimationType.MeteorCast:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorCast(targetGridIndex);
}
break;
case AnimationType.MeteorCancel:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorCancel(sourceGridIndex);
}
break;
case AnimationType.HolyWave:
if (holyWaveAnimator != null) {
var undeadCells = FindNearbyUndeadCells(sourceGridIndex, 2);
holyWaveAnimator.AnimateHolyWave(sourceGridIndex, undeadCells);
}
break;
case AnimationType.Charge:
if (chargeAnimator != null && attackerUnit != null && defenderUnit != null) {
chargeAnimator.AnimateCharge(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type,
defenderUnit.Battalion.Type);
}
break;
case AnimationType.Fear:
if (fearAnimator != null) {
fearAnimator.AnimateFear(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.Control:
if (controlAnimator != null) {
controlAnimator.AnimateControl(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.StartFire:
case AnimationType.FireDamage:
if (fireEffectAnimator != null) {
fireEffectAnimator.AnimateFire(targetGridIndex);
}
break;
case AnimationType.ExtinguishFire:
if (extinguishAnimator != null) {
extinguishAnimator.AnimateExtinguish(targetGridIndex);
}
break;
case AnimationType.FreezeWater:
if (freezeAnimator != null) { freezeAnimator.AnimateFreeze(targetGridIndex); }
break;
case AnimationType.BuildBridge:
if (toolAnimator != null) { toolAnimator.AnimateBuildBridge(targetGridIndex); }
break;
case AnimationType.Repair:
if (toolAnimator != null) { toolAnimator.AnimateRepair(targetGridIndex); }
break;
case AnimationType.BraveWater:
case AnimationType.WaterDamage:
if (waterEffectAnimator != null) {
waterEffectAnimator.AnimateWaterSplash(targetGridIndex);
}
break;
case AnimationType.Scout:
if (scoutAnimator != null) {
scoutAnimator.AnimateScout(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.DismissUnit:
if (dismissAnimator != null) {
dismissAnimator.AnimateDismiss(targetGridIndex);
}
break;
case AnimationType.Flee:
if (fleeAnimator != null) {
fleeAnimator.AnimateFlee(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.Duel:
if (duelAnimator != null) {
duelAnimator.AnimateDuel(sourceGridIndex, targetGridIndex);
}
break;
}
}
/// <summary>
/// Finds grid indices of cells containing undead units within a hex radius of the source.
/// </summary>
List<int> FindNearbyUndeadCells(int sourceCellIndex, int maxDistance) {
var result = new List<int>();
Coords sourceCoords = GridIndexToMapCoords(sourceCellIndex);
foreach (var unit in Model.Units) {
if (unit.Battalion.Type != BattalionTypeId.Undead) continue;
int distance = HexDistance(sourceCoords, unit.Location);
if (distance > 0 && distance <= maxDistance) {
result.Add(MapCoordsToGridIndex(unit.Location));
}
}
return result;
}
/// <summary>
/// Calculates hex distance between two offset coordinates.
/// Converts to cube coordinates for accurate hex distance calculation.
/// </summary>
static int HexDistance(Coords a, Coords b) {
// Convert offset coordinates to cube coordinates (odd-q vertical layout)
int ax = a.Column;
int az = a.Row - (a.Column - (a.Column & 1)) / 2;
int ay = -ax - az;
int bx = b.Column;
int bz = b.Row - (b.Column - (b.Column & 1)) / 2;
int by = -bx - bz;
// Cube distance
return (Math.Abs(ax - bx) + Math.Abs(ay - by) + Math.Abs(az - bz)) / 2;
}
void PerformAction(int startGridIndex, int finishGridIndex) {
Coords startCoords = GridIndexToMapCoords(startGridIndex);
Coords finishCoords = GridIndexToMapCoords(finishGridIndex);
@@ -75,6 +75,13 @@ public class ShardokGameModel {
ReserveUnitsById.Values.FirstOrDefault(u => u.Location.Equals(location));
public List<ActionResultView> History { get; private set; }
/// <summary>
/// Tracks the source coordinates for Move actions, keyed by history index.
/// Used for animations since the model is fully updated before animations play.
/// </summary>
public Dictionary<int, Coords> MoveSourceCoords { get; } = new();
public PlayerId PlayerId { get; private set; }
public GameStatus GameStatus { get; private set; }
public RoundId CurrentRound { get; private set; }
@@ -402,6 +409,14 @@ public class ShardokGameModel {
}
private void HandleNewHistoryEntry(ActionResultView entry) {
// For Move actions, record the source coordinates BEFORE applying the diff
// This is needed for animations since all diffs are applied before animations play
if (entry.Type == ActionType.Move && entry.Actor != null) {
if (UnitsById.TryGetValue(entry.Actor.Value, out var actorUnit)) {
MoveSourceCoords[History.Count] = actorUnit.Location;
}
}
History.Add(entry);
var gsvDiff = entry.GameStateViewDiff;
if (gsvDiff == null) {
@@ -0,0 +1,229 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates construction/repair actions with a hammer/tool striking effect.
/// Used for Repair and BuildBridge commands.
/// </summary>
public class ToolAnimator : MonoBehaviour {
[Header("Tool Sprites")]
[Tooltip("Hammer sprite for repair actions")]
public Sprite hammerSprite;
[Tooltip("Alternate tool sprite (wrench, etc)")]
public Sprite alternateToolSprite;
[Header("Spark Settings")]
[Tooltip("Sprite for sparks on impact")]
public Sprite sparkSprite;
[Tooltip("Number of sparks per strike")]
public int sparkCount = 3;
[Tooltip("Spark spread distance")]
public float sparkSpread = 15f;
[Tooltip("Color of sparks")]
public Color sparkColor = new Color(1f, 0.9f, 0.3f, 1f);
[Header("Animation Settings")]
[Tooltip("Scale of the tool sprite")]
public float toolScale = 20f;
[Tooltip("Number of hammer strikes")]
public int strikeCount = 3;
[Tooltip("Duration of each strike cycle")]
public float strikeDuration = 0.2f;
[Tooltip("Height of tool swing arc")]
public float swingHeight = 30f;
[Tooltip("Rotation arc of the swing (degrees)")]
public float swingArc = 45f;
[Tooltip("Base rotation offset for hammer (degrees)")]
public float hammerBaseRotation = 180f;
[Tooltip("Flip hammer horizontally")]
public bool hammerFlipHorizontal = true;
[Tooltip("Base rotation offset for alternate tool (degrees)")]
public float alternateBaseRotation = 0f;
[Tooltip("Flip alternate tool horizontally")]
public bool alternateFlipHorizontal = false;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Starts a repair/construction animation at the target hex.
/// </summary>
public void AnimateRepair(int targetCellIndex) { AnimateTool(targetCellIndex, false); }
/// <summary>
/// Starts a bridge building animation at the target hex.
/// </summary>
public void AnimateBuildBridge(int targetCellIndex) { AnimateTool(targetCellIndex, true); }
private void AnimateTool(int targetCellIndex, bool useBridgeStyle) {
if (hammerSprite == null && alternateToolSprite == null) {
Debug.LogWarning("ToolAnimator: No tool sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("ToolAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (targetPos == null) {
Debug.LogWarning($"ToolAnimator: Invalid cell index {targetCellIndex}");
return;
}
StartCoroutine(AnimateStrikes(targetPos.Value, useBridgeStyle));
}
private IEnumerator AnimateStrikes(Vector2 target, bool useBridgeStyle) {
bool useAlternate = useBridgeStyle && alternateToolSprite != null;
Sprite toolSprite =
useAlternate ? alternateToolSprite : hammerSprite ?? alternateToolSprite;
float baseRotation = useAlternate ? alternateBaseRotation : hammerBaseRotation;
bool flipHorizontal = useAlternate ? alternateFlipHorizontal : hammerFlipHorizontal;
// Create tool
GameObject tool = new GameObject("Tool");
tool.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer toolRenderer = tool.AddComponent<SpriteRenderer>();
toolRenderer.sprite = toolSprite;
toolRenderer.sortingLayerName = "Effects";
float xScale = flipHorizontal ? -toolScale : toolScale;
tool.transform.localScale = new Vector3(xScale, toolScale, toolScale);
// Starting position (raised above target)
Vector3 strikePoint = new Vector3(target.x, target.y, 5f);
Vector3 raisePoint = new Vector3(target.x, target.y + swingHeight, 5f);
// Animate strike cycles
for (int i = 0; i < strikeCount; i++) {
// Raise tool
tool.transform.localPosition = raisePoint;
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation - swingArc);
float elapsed = 0f;
float downDuration = strikeDuration * 0.4f;
// Swing down
while (elapsed < downDuration) {
float t = elapsed / downDuration;
float easeT = t * t; // Accelerating
tool.transform.localPosition = Vector3.Lerp(raisePoint, strikePoint, easeT);
float angle = Mathf.Lerp(-swingArc, 0f, easeT);
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation + angle);
elapsed += Time.deltaTime;
yield return null;
}
tool.transform.localPosition = strikePoint;
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation);
// Spawn sparks on impact
yield return StartCoroutine(SpawnSparks(target));
// Brief pause at impact
yield return new WaitForSeconds(strikeDuration * 0.1f);
// Raise back up
elapsed = 0f;
float upDuration = strikeDuration * 0.5f;
while (elapsed < upDuration) {
float t = elapsed / upDuration;
float easeT = t * (2f - t); // Decelerating
tool.transform.localPosition = Vector3.Lerp(strikePoint, raisePoint, easeT);
float angle = Mathf.Lerp(0f, -swingArc, easeT);
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation + angle);
elapsed += Time.deltaTime;
yield return null;
}
}
// Cleanup
if (tool != null) { Destroy(tool); }
}
private IEnumerator SpawnSparks(Vector2 target) {
if (sparkSprite == null) { yield break; }
GameObject[] sparks = new GameObject[sparkCount];
for (int i = 0; i < sparkCount; i++) {
GameObject spark = new GameObject($"Spark_{i}");
spark.transform.SetParent(__hexGrid.gridCanvas.transform, false);
spark.transform.localPosition = new Vector3(target.x, target.y, 4f);
SpriteRenderer renderer = spark.AddComponent<SpriteRenderer>();
renderer.sprite = sparkSprite;
renderer.sortingLayerName = "Effects";
renderer.color = sparkColor;
spark.transform.localScale = Vector3.one * 5f;
sparks[i] = spark;
}
// Animate sparks flying outward
float sparkDuration = 0.15f;
float elapsed = 0f;
Vector2[] directions = new Vector2[sparkCount];
float[] speeds = new float[sparkCount];
for (int i = 0; i < sparkCount; i++) {
float angle = Random.Range(0f, Mathf.PI * 2f);
directions[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
speeds[i] = sparkSpread * Random.Range(0.7f, 1.3f);
}
while (elapsed < sparkDuration) {
float t = elapsed / sparkDuration;
for (int i = 0; i < sparks.Length; i++) {
if (sparks[i] == null) continue;
Vector2 pos = target + directions[i] * speeds[i] * t;
// Sparks rise slightly as they spread
pos.y += t * 10f;
sparks[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
// Fade out
var renderer = sparks[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = sparkColor;
c.a = 1f - t;
renderer.color = c;
sparks[i].transform.localScale = Vector3.one * 5f * (1f - t * 0.5f);
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup sparks
foreach (var spark in sparks) {
if (spark != null) { Destroy(spark); }
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b131f94a99ad146afa8636a4f80c23b1

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