Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 9f51aba513 Add debug logging to Mac Build artifact download steps
Investigating why artifact download succeeds but file is not found.
Adding ls -la after download to see actual directory contents.

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

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

This inventory makes it easier to track deproto progress.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:57:00 -08:00
239d626ac1 Fix Mac build artifact handling to preserve .app bundle structure (#5305)
The issue was that upload-artifact uploads directory *contents*, not
the directory itself. So uploading eagle0.app resulted in an artifact
containing Contents/... without the eagle0.app wrapper. When downloaded,
this corrupted the .app bundle structure.

Fix by:
- Zip the .app bundle with ditto before uploading (preserves structure
  and macOS extended attributes)
- Unzip after downloading to restore the proper .app bundle
- Clean download directories before extracting to avoid stale state

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:53:18 -08:00
bb1f3929c3 Fix proto workflow triggers and remove redundant workflow (#5303)
- Fix unity_build.yml and mac_build.yml to trigger on actual client
  proto directories instead of non-existent src/main/proto/** path
- Trigger on: common/**, shardok/**, eagle/api/**, eagle/common/**,
  eagle/views/** (excludes eagle/internal/** which is server-only)
- Remove build_protos_test.yml as redundant (unity/mac builds run
  build_protos.sh)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:47:46 -08:00
8fb218d8ee Remove proto overloads from diplomacy resolution factories (#5302)
Delete proto GameState overloads from AvailableResolve*CommandFactory
classes since they now use only Scala types. Move package.scala with
proto helper functions to test directory. Convert break alliance test
to use Scala types.

Reduces proto imports in library/ from 192 to 168.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:43:09 -08:00
c85973edda Remove unused client presigner service (#5301)
The client presigner was intended for generating presigned S3 URLs,
but assets.eagle0.net now points directly to the DigitalOcean CDN
(eagle0-windows bucket is public). The presigner was never deployed.

Removed:
- .github/workflows/client_presigner.yml
- src/main/go/net/eagle0/client_download/
- Presigning code from util/aws/s3.go (GetPresignedURL, NewPresigner, Presigner)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:42:38 -08:00
ae970a6693 Remove unused internal proto from client build (#5304)
The internal/unaffiliated_hero.proto was mistakenly included in the
client proto build. Internal protos should only be used server-side.
No C# code references types from Net.Eagle0.Eagle.Internal namespace.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:42:09 -08:00
6df165cc5a Tutorial bug fixes: prevent multiple popups and remove placeholder paths (#5300)
* Prevent multiple tutorials from showing simultaneously

- Don't trigger contextual tutorials while another is already active
- Fix HideOverlay to work even when parent container is inactive
  (was silently returning without hiding, causing UI pile-up)

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

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

* Remove placeholder TargetGameObjectPath values from tutorials

The placeholder values like "ProvinceUI", "BattleButton", "EndTurnButton"
don't match actual GameObjects in the scene, causing warnings.

Overlays now show centered without targets. TODO comments mark where
to add proper targeting once the actual UI hierarchy is known.

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

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

* Add tutorial content documentation for editing

Creates docs/TUTORIAL_CONTENT.md with:
- Full onboarding sequence (13 steps) with titles, descriptions, triggers
- Strategic contextual tutorials (diplomacy, heroes, weather, prisoners)
- Tactical contextual tutorials (spells, terrain, abilities)
- Display mode reference
- Content guidelines

Edit this doc to refine content, then update TutorialContentDefinitions.cs.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:19:43 -08:00
c26b531792 Fix last played time not persisting across server restarts (#5299)
The lastPlayedByUser timestamp was updated in postCommand and
postShardokCommand but save() was not called, so the data stayed
in memory until something else triggered a save.

Add save() calls after updating lastPlayedByUser to ensure the
timestamp is persisted to disk immediately.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:14:46 -08:00
4682784737 Consolidate validators by removing proto versions (#5298)
Remove redundant proto-based Validator and RuntimeValidator that used
proto types. The Scala versions (ScalaValidator/ScalaRuntimeValidator)
have identical validation logic and are already used by ActionResultApplierImpl.

- Delete proto Validator.scala and RuntimeValidator.scala
- Rename ScalaValidator -> Validator
- Rename ScalaRuntimeValidator -> RuntimeValidator
- Update all imports across library/ and test/ code
- Delete unused TestingNoopValidator

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:07:29 -08:00
139eb89edb Fix command loss during blue-green deployment reconnect (#5297)
Bug: When a deploy happened while a user had a command in-flight,
the command could be lost without the user knowing:

1. User posts command with token T
2. PostRequest adds to _pendingCommands, WriteAsync completes locally
3. PostRequest removes from _pendingCommands (TOO EARLY!)
4. Connection dies before server receives command
5. Reconnect - _pendingCommands is empty, command never retried
6. User sees "Processing..." forever

Root cause: WriteAsync completing only means data was written to local
TCP buffers, not that the server received and processed it. The command
was removed from _pendingCommands prematurely.

Fix:
- Don't remove from _pendingCommands after WriteAsync success
- Remove only when server confirms: PostCommandResponse SUCCESS or BAD_TOKEN
- TryPendingCommands already handles stale commands (token advanced)

This ensures commands are retried on reconnect if they weren't confirmed.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:51:01 -08:00
c6c31cd28e Remove redundant S3 backup from deploy script (#5296)
Eagle server already persists to S3 on every game save via CompoundPersister
when S3Credentials.isEnabled. The deploy script's s3cmd backup was redundant
and caused warnings when s3cmd wasn't installed.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:36:47 -08:00
d5665c9570 Add bazel label to workflows for consistent Bazel cache (#5295)
Docker builds were failing because jobs could run on any self-hosted
runner, but each runner has a different Bazel output base. When a job
ran on a different runner than previous builds, Bazel's remote cache
reported "cached" but local output files didn't exist.

Fix: Add `bazel` label requirement to all generic Bazel-based workflows.
The specialized Unity/notarization runners don't have this label, so
they won't pick up these jobs.

Workflows updated:
- auth_build.yml
- bazel_test.yml
- build_protos_test.yml
- client_presigner.yml
- docker_build.yml
- installer_build.yml
- shardok_arm64_build.yml
- shardok_build.yml

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 14:59:10 -08:00
0719d01819 Tutorial Phase 4: Content definitions (#5289)
* Add tutorial content definitions (Phase 4)

Create TutorialContentDefinitions.cs with all tutorial content:

Onboarding sequence (13 steps):
- Welcome, map overview, province selection
- Command panel, march command, turn cycle
- Battle intro, enter battle, tactical overview
- Move units, attack enemies, end turn, completion

Strategic contextual tutorials:
- Diplomacy introduction
- Hero recruitment
- Weather control
- Prisoner management

Tactical contextual tutorials:
- Spells: Lightning, Meteor, Holy Wave, Raise Dead
- Terrain: Fire hazards, water crossing
- Abilities: Cavalry charge

Content is defined in code for easy version control and review.
TutorialManager now auto-registers all content on initialization.

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

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

* Fix tutorial UI not showing when parent container inactive

Add ActivateParents() to TutorialOverlayController and TutorialModalPanel
to ensure all parent GameObjects are active before showing. This fixes
the error "Coroutine couldn't be started because the game object is inactive".

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 14:53:11 -08:00
a4d7ac4283 Delete unused appliedResults and rename appliedResultsScala (#5294)
The proto-based appliedResults method was never called - all code paths
use the Scala-based appliedResultsScala. This removes the dead code and
renames appliedResultsScala to appliedResults for clarity.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 14:47:59 -08:00
cf16c594a2 Delete unused ActionResultTApplier and ActionResultTApplierImpl (#5293)
These files wrapped proto→Scala→proto conversions but were never used
anywhere in the codebase. Removing them as dead code cleanup.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 14:35:10 -08:00
1073a208de Make AvailableCommandConverter use Scala GameState directly (#5292)
Previously, AvailableCommandConverter.toProto() took a proto GameState and
internally called GameStateConverter.fromProto() to get the Scala GameState
needed for lookups. This caused unnecessary Scala→Proto→Scala round-trips.

This change:
- Updates AvailableCommandConverter.toProto() to take Scala GameState directly
- Updates OneProvinceAvailableCommandsConverter.toProto() similarly
- Updates all callers (GameController, AIClient, action files) to pass
  Scala GameState directly instead of converting to proto first
- Updates tests to use Scala GameState

This eliminates proto conversion overhead in the command availability path.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:57:57 -08:00
e96c0704ed Split Unity builds across dedicated runners with async notarization (#5291)
Add dedicated self-hosted runners for parallel Unity builds:
- unity-mac: Mac Unity builds and deployment
- unity-windows: Windows Unity builds (cross-compiled on Mac)
- notarize: Notarization waiting (lightweight, doesn't block builds)

Split mac_build.yml into 3 jobs:
1. build-and-sign (unity-mac): Build, sign, submit to Apple
2. wait-notarization (notarize): Wait for Apple, staple ticket
3. deploy (unity-mac): Deploy notarized app

This allows:
- Mac and Windows Unity builds to run in parallel
- Notarization waiting doesn't block other builds
- All runners share the same Mac Mini hardware

New scripts:
- notarize_submit.sh: Submit without waiting, output submission ID
- notarize_wait.sh: Wait for submission ID, staple ticket

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:29:27 -08:00
ef1a447431 Make ActionResultFilter use Scala GameState directly (#5290)
Eliminates unnecessary Scala→Proto→Scala round-trip conversions in the
action result filtering path:

- ActionResultFilter now takes Scala GameState instead of proto
- Uses Scala RoundPhase enum instead of proto RoundPhase
- Removed GameStateConverter.fromProto calls in filteredGameStateDiff
- Updated callers (EngineImpl, HumanPlayerClientConnectionState) to pass
  Scala state directly instead of converting to proto first

JFR profiling showed proto conversion taking ~16% of eagle0 time. This
change reduces that overhead in the filtering path by eliminating:
- 1x Scala→Proto conversion per filter call in callers
- 2x Proto→Scala conversions per action result (before/after states)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 12:58:33 -08:00
f874231e04 Fix notarize script cleanup to be idempotent (#5288)
Use rm -f instead of rm when cleaning up the zip file after notarization.
The zip may already be deleted if a previous step failed and was retried,
causing the script to fail even when notarization actually succeeded.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:46:18 -08:00
5acb4c4f14 Add download confirmation dialog when stopping JFR (#5287)
When clicking "Stop" on JFR recording, a dialog now appears with options:
- Download & Stop: Downloads the recording then stops
- Stop Only: Stops without downloading
- Cancel: Keeps recording

This prevents accidentally losing recordings by clicking Stop without
remembering to download first.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:38:45 -08:00
2dc6f4d0bb Make Engine.getAvailablePlayerCommands return Scala types only (#5283)
* Add getScalaAvailablePlayerCommands to Engine interface

Expose a method that returns available commands using Scala types directly,
avoiding proto conversion overhead. This enables AI clients and other internal
callers to work with native Scala types without round-tripping through proto.

The existing getAvailablePlayerCommands method continues to return proto types
for gRPC client compatibility.

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

* Make Engine.getAvailablePlayerCommands return Scala types only

Callers that need proto types for gRPC (like GameController) now
convert using OneProvinceAvailableCommandsConverter. AIClient
converts to proto temporarily until command choosers are migrated.

Changes:
- Engine.getAvailablePlayerCommands returns SortedMap[ProvinceId, ScalaOneProvinceAvailableCommands]
- Removed separate getScalaAvailablePlayerCommands method
- Added toProtoAvailableCommands helper to GameController for gRPC conversion
- Updated AIClient to convert Scala to proto for command choosers
- Updated tests to use SortedMap.empty for mocked commands
- Removed unused proto deps from EngineImpl

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

* Remove unnecessary Scala prefix from OneProvinceAvailableCommands imports

No longer need to disambiguate since proto types are only used where needed.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:25:00 -08:00
bbef0c1430 Display last played time in lobby game list (client-side) (#5285)
* Display last played time in lobby game list (client-side)

- Add lastPlayedField to RunningGameItem for displaying time
- Format time as relative (e.g., "Just now", "5m ago", "2h ago", "3d ago")
- Fall back to date format ("Jan 5") for older times
- Display in user's local timezone

Requires server-side changes from PR #5281 (now merged).

Note: The lastPlayedField TextMeshProUGUI reference needs to be added
to the RunningGameItem prefab in Unity.

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

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

* Wire up lastPlayedField in RunningGameItem prefab

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:20:40 -08:00
f929672f83 Refresh game state when pending command is dropped as stale (#5284)
When a pending command is dropped because the server's token has
advanced (indicating the command was already processed), the client
now triggers a re-subscription to ensure it has the current game state.

This fixes a race condition during deployment reconnects where:
1. User posts command, UI clears available commands
2. Connection drops during deployment
3. Server processes command, token advances
4. Client reconnects, pending command dropped as "stale"
5. UI was stuck with no commands visible

Changes:
- Add game_id to PostCommandResponse proto for targeted refresh
- Server echoes game_id in SUCCESS and ERROR responses
- Client refreshes specific game subscription when:
  - Pending command dropped as stale (token mismatch)
  - Server returns BAD_TOKEN response

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:04:21 -08:00
7eb362a09b Add timing logs for first-connect performance profiling (#5279)
Adds [TIMING] logs to identify slow operations during client subscription:

- GamesManager.streamUpdates: logs ensureGameLoaded and filtering time
- HumanPlayerClientConnectionState.streamUpdates: logs shardok filtering,
  action result filtering, and game state view filtering
- filteredResultsFrom: logs GameStateConverter.toProto and
  ActionResultFilter.filterForOptionalPlayer separately

Logs only appear when operations exceed 10-50ms thresholds to avoid
noise during normal operation.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:03:55 -08:00
e4b7d8e8a8 Fix: Remove shardok from eagle-green depends_on (#5286)
Missed this in #5282 - eagle-green still had depends_on: shardok
which caused the deployment to fail.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:02:06 -08:00
a56bea82f1 Track last played time for games (server-side) (#5281)
- Add last_played_by_user map to RunningGame proto for persistence
- Track last played time in ControllerInfo when processing commands
- Update postCommand and postShardokCommand to record timestamps
- Persist and load last played times across server restarts
- Include lastPlayedTimestampMillis in GameInfo lobby response

The client-side display will be added in a follow-up PR.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 10:55:59 -08:00
8bc22baa7c Remove DigitalOcean Shardok deployment (use Hetzner only) (#5282)
Shardok now runs exclusively on the Hetzner ARM64 server, deployed via
the shardok_arm64_build.yml workflow. This removes:

- shardok service from docker-compose.prod.yml
- Shardok x86 build/push from docker_build.yml
- SHARDOK_IMAGE from env.template and deploy script
- C++ path trigger from docker_build.yml (handled by ARM64 workflow)

The next deployment will stop and remove any existing shardok-server
container on the DigitalOcean droplet.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 10:54:07 -08:00
2f31d94313 Increase warmup timeout from 90s to 180s for cold JVM (#5280)
CreateGame on a cold JVM took >90s in production, causing warmup to
fail and abort the deployment. Increase per-operation timeout to 180s.

The overall warmup timeout (--timeout flag) is already 300s, but the
internal per-operation timeout was only 90s.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 09:53:57 -08:00
7b46295e17 Optimize PersistedHistory to reduce serialization overhead (#5278)
Change incremental saves from O(n²) to O(n) by saving individual ActionResults
to separate .e0r files immediately, then consolidating into chunks and deleting
the individual files. This eliminates redundant re-serialization of the same
results when building up a chunk incrementally.

Also adds crash recovery support: orphaned .e0r files are loaded and merged
with chunk data on startup, preserving any results written before a crash.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 09:45:06 -08:00
6c154e827c FilterContext optimization for filteredGameState (#5277)
* Add FilterContext optimization for filteredGameState

Introduces a FilterContext class that pre-computes expensive data once
at the start of filteredGameState, avoiding repeated O(n) and O(n*m)
lookups when filtering game state for player views.

Key optimizations:
- Pre-compute ally pairs as Set for O(1) alliance checks (was O(n))
- Pre-compute prisoner hero IDs as Set for O(1) lookups (was O(heroes*provinces))
- Pre-compute hero-to-province mapping for O(1) lookups (was O(provinces))
- Cache factionLeaderIds to avoid repeated flatMap allocations

Complexity improvements:
- HeroViewFilter: O(heroes * provinces) -> O(heroes + provinces)
- BattalionNameFilter: O(factions²) -> O(factions)
- Overall filteredGameState: eliminates ~148+ repeated Vector allocations

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

* Remove unused filter overloads and update tests

- Remove old FactionViewFilter.filteredFactionView 3-param overload
- Remove old ProvinceViewFilter.filteredProvinceView 3-param overload
- Remove old BattalionNameFilter.filteredBattalionNames 2-param overload
- Remove old helper methods no longer needed
- Update FactionViewFilterTest to use FilterContext
- Update ProvinceViewFilterTest to use FilterContext

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:23:10 -08:00
040b0cf580 Fix user wait window metric to measure actual user impact (#5275)
* Auto-invalidate game cache when flush marker is updated

During warmup, the staging server may cache stale game data that was
loaded before the active server flushed. Instead of exposing an RPC
for cache invalidation (which leaks internal state), the server now
automatically detects when the flush marker is updated and invalidates
any cached games.

Changes:
- GamesManager: Added `invalidateCacheIfFlushMarkerUpdated()` that
  checks the flush marker's modification time and clears the cache
  if it's been updated since the last check
- Called before checking if a game is in cache, so stale data is
  cleared before any attempt to use it
- Removed the InvalidateGameCache RPC (no longer needed)

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

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

* Fix user wait window metric to measure actual user impact

The max user wait window was measuring from when nginx switching
started (before recreation) instead of when it completed. This
inflated the metric by ~12s because nginx recreation time was included.

Users can only experience a wait AFTER nginx starts routing to the
staging server, so we now measure from nginx_switch_end (after
recreation completes) to flush_end.

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:17:24 -08:00
3ae3f6ba16 Auto-invalidate game cache when flush marker is updated (#5273)
During warmup, the staging server may cache stale game data that was
loaded before the active server flushed. Instead of exposing an RPC
for cache invalidation (which leaks internal state), the server now
automatically detects when the flush marker is updated and invalidates
any cached games.

Changes:
- GamesManager: Added `invalidateCacheIfFlushMarkerUpdated()` that
  checks the flush marker's modification time and clears the cache
  if it's been updated since the last check
- Called before checking if a game is in cache, so stale data is
  cleared before any attempt to use it
- Removed the InvalidateGameCache RPC (no longer needed)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:04:56 -08:00
6da0636731 Handle PostCommandResponse ERROR status from server (#5274)
When the server returns a PostCommandResponse with ERROR status,
disconnect and reconnect using the normal flow. This ensures the
client recovers gracefully from server-side errors during command
processing.

Also logs BAD_TOKEN responses for debugging purposes.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:04:29 -08:00
53ab903568 Make view_filters completely protoless (#5271)
* Make view_filters completely protoless

Remove all proto type overloads from the view_filters package, forcing
callers to use Scala types exclusively. This is part of the ongoing
effort to reduce proto dependencies in the codebase.

Changes:
- Visibility: Remove proto GameState overloads
- BattalionNameFilter: Remove proto overload (~60 lines)
- HeroViewFilter: Remove proto overloads, use Scala RoundPhase
- ProvinceViewFilter: Remove proto overloads (~280 lines)
- FactionViewFilter: Remove proto overload
- BattleFilter: Remove proto overloads
- ArmyFilter: Remove proto overloads

Also:
- Delete unused ExpandedCombatUnitUtils.scala
- Update AvailableCommandConverter to convert types before calling filters
- Remove outdated view_filter tests that used proto types

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

* Add protoless tests for view_filters

Restore FactionViewFilterTest, HeroViewFilterTest, and ProvinceViewFilterTest
using Scala types (FactionC, ProvinceC, HeroC, GameState) instead of protos.

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

* Add comprehensive protoless ProvinceViewFilterTest

Rewrites ProvinceViewFilterTest with full coverage of all original test
cases, using Scala types instead of proto types. Tests cover:
- Devastation and economy values
- Ruler traveling status
- Incoming armies (own/hostile/neutral provinces)
- Unaffiliated heroes
- Incoming supplies visibility
- Reconned views from self/allies
- Most recent reconned view selection
- Killed heroes filtering from reconned views

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 07:26:52 -08:00
e7745cc9ec Fix deployment script permission denied on marker files (#5272)
The saves directory is owned by root (created by Docker) but the deploy
script runs as the deploy user. Use docker exec to create marker files
from inside a running container that has the saves directory mounted.

- create_deployment_marker: uses active container (running before staging starts)
- create_flush_marker: uses staging container (running after active stops)
- cleanup_markers_on_failure: uses active container (still running on failure)
- remove_stale_deployment_marker: finds any running eagle container

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 07:02:05 -08:00
a3f185b6f6 Enhance tutorial trigger detection for strategic and tactical events (#5269)
* Enhance tutorial trigger detection for strategic and tactical events

Expands TutorialTriggerRegistry with specific condition detection:

Strategic triggers:
- Diplomacy command availability
- Weather control availability
- Province riots (new)
- Hero recruitment opportunities

Tactical triggers:
- Spell cast detection (lightning, meteor, holy wave, raise dead)
- Ability usage (charge, flanking)
- Terrain encounters (fire, water)
- Spell/ability availability when commands update

Also adds OnTacticalCommandsAvailable hook to ShardokGameController
to detect when special abilities become available.

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

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

* Fix tutorial trigger compilation errors

- Use correct field name ControlWeatherSelectedCommand (not ControlWeatherCommand)
- Remove CheckBattleMapFeatures - HexMap doesn't have FireCoords/WaterCoords

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

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

* Fix remaining compilation errors in TutorialTriggerRegistry

- Use ControlWeatherAvailableCommand (not ControlWeatherCommand) for AvailableCommand
- Use FactionId (not Faction) for HeroView

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

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

* Fix all proto field name mismatches in TutorialTriggerRegistry

- Use opac.Commands (not AvailableCommands) for OneProvinceAvailableCommands
- Remove province riot detection (riot status not exposed in ProvinceView)
- Remove FlankAttack (doesn't exist in ActionType)
- Use CrossedWater (not CrossWater) for ActionType
- Use LightningBoltCommand (not LightningCommand) for CommandType

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 07:00:58 -08:00
8f3bee853f Add flush marker coordination for zero-downtime blue-green deploys (#5270)
* Fix nginx not picking up config changes during blue-green deploy

Root cause: `docker compose restart nginx` doesn't refresh bind-mounted
volume files. The nginx container keeps using its cached copy of
nginx.conf even after we update the host file with sed.

This caused nginx to keep trying to connect to the old (now deleted)
eagle instance, resulting in 502 errors after deployment.

Fix:
- Use `docker compose up -d --force-recreate nginx` instead of `restart`
  This recreates the container, forcing it to read the updated config
- Add verification that nginx picked up the correct backend
- Remove the useless pre-validation (it validated old config in old container)

The progression of failed fixes:
1. `nginx -s reload` - doesn't re-resolve Docker DNS
2. `docker compose restart` - doesn't refresh bind-mounted files
3. `docker compose up -d --force-recreate` - THIS WORKS

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

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

* Reorder deploy: switch nginx BEFORE stopping blue to avoid 502s

Previous order (caused 502 errors):
1. Stop blue → nginx still points to blue → 502!
2. Update nginx config
3. Recreate nginx → traffic finally works

New order (eliminates 502 window):
1. Update nginx config
2. Recreate nginx → traffic goes to green (blue still running)
3. Stop blue → flushes state to disk
4. 3-second pause for flush to complete

The stale data race condition is minimized by stopping blue immediately
after the nginx switch. Users reconnecting to green will lazy-load
fresh game data from disk (after blue has flushed).

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

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

* Add flush marker coordination for zero-downtime blue-green deploys

This ensures green never serves stale game data during deployments:

1. Deploy script creates .deployment_in_progress marker at start
2. Green's lazy-load waits for flush marker if deployment in progress
3. nginx switches to green BEFORE stopping blue (zero 502 downtime)
4. Blue stops, flushes state to disk
5. Deploy script creates .flush_complete marker
6. Green's waiting lazy-loads proceed with fresh disk data

Key changes:
- GamesManager.scala: Add waitForFlushMarker() that blocks lazy-load
  during deployment until flush marker appears (30s timeout)
- GamesManager.scala: Auto-clean stale markers >5 minutes old
- GamesManager.scala: Add deployment ID correlation in logs [DEPLOY:xxx]
- GamesManager.scala: Report flush marker timeouts to Sentry
- deploy-blue-green.sh: Reorder to switch nginx BEFORE stopping blue
- deploy-blue-green.sh: Add marker file coordination with deployment ID
- deploy-blue-green.sh: Add timing metrics (flush duration, user wait window)
- nginx.conf: Keep variable-based routing (Docker DNS only resolves
  running containers, so upstream+backup doesn't work)

Monitoring and observability:
- All deployment-related logs tagged with [DEPLOY:timestamp] for correlation
- Wait duration logged for each lazy-load during deployment
- Flush marker timeouts reported to Sentry for alerting
- Deploy script logs total duration, flush duration, max user wait window

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 06:42:59 -08:00
b4234debce Fix O(N^2) performance issue in HeroViewFilter proto path (#5268)
Convert GameState once upfront in GameStateViewFilter proto overload,
then use the already-converted Scala heroes directly instead of
converting each hero individually.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 05:36:41 -08:00
7f562de2a8 Handle missing recruitmentInfo in UnaffiliatedHeroConverter (#5267)
Use fold with RecruitmentInfo.Unknown as fallback instead of .get
to handle proto UnaffiliatedHero objects where recruitmentInfo
is None.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:31:52 -08:00
bc5d86fb1e Consolidate Visibility proto overloads to delegate to Scala (#5266)
The proto overloads now convert factions via FactionConverter
and delegate to the Scala implementations, eliminating duplicate
logic.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:17:31 -08:00
10a747114a Consolidate FactionViewFilter proto overload to delegate to Scala (#5265)
Changes:
- Proto filteredFactionView now converts types and delegates to Scala version
- Removed ~45 lines of duplicate private methods (filteredRelationshipLevel,
  filteredFactionRelationshipView)

This continues the deproto work of consolidating proto and Scala code paths.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:12:46 -08:00
a94a48a4bf Add batch delete for games in admin console (#5264)
Add checkboxes next to each game in the admin console Games list,
allowing multiple games to be selected and deleted at once. When
games are selected, a batch actions bar appears with "Delete Selected"
and "Clear" buttons.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:07:21 -08:00
945511153a Consolidate HeroViewFilter proto overload to delegate to Scala (#5262)
Changes:
- Proto filteredHeroView now converts types and delegates to Scala version
- Removed duplicate private methods (heroIsPrisoner, heroUnaffiliatedInProvince,
  heroIsOfferedInRansom, includeFullHeroInfo)
- Added Gender.Unknown to Scala enum to preserve GENDER_UNKNOWN on round-trip
- Updated GenderConverter to handle Unknown case
- Fixed test data in AvailableCommandConverterTest to include valid currentPhase

This reduces code duplication while maintaining backward compatibility.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:05:00 -08:00
d131aa50f0 Use move command path for animation instead of straight line (#5261)
When clicking to move a unit, the animation now follows the actual
path of hexes from the CommandDescriptor's path field instead of
drawing a straight line from origin to destination. This prevents
the animation from showing units moving "over water" when the
actual path goes around it.

Changes:
- MoveAnimator: Add path-based animation method that draws prints
  along each segment of the path
- ShardokGameModel: Return full CommandDescriptor from
  PerformTargetedCommand instead of just CommandType
- ShardokGameController: Extract path from executed command and
  pass to animation system

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:58:03 -08:00
d0d84e98e0 Use nginx restart instead of reload for blue-green switch (#5260)
nginx reload doesn't always force DNS re-resolution in Docker. During
blue-green deployment, after updating nginx.conf to point to the new
instance (e.g., eagle-green:40032), nginx -s reload would sometimes
keep trying to connect to the old (now removed) container, causing
502 errors.

A full restart ensures nginx picks up the new upstream correctly.
The ~1-2 second restart time is acceptable for deployment reliability.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:52:56 -08:00
0e3f1bcb87 Increase warmup timeouts for cold JVM startup (#5259)
The warmup tool was timing out during CreateGame because the per-step
timeout was only 30 seconds. On a cold JVM (freshly started Eagle
instance during blue-green deployment), CreateGame can take longer
than 30 seconds due to:
- JIT compilation not yet warmed up
- First-time class loading
- Game initialization including Shardok communication

Changes:
- Increase per-operation timeout from 30s to 90s
- Increase overall warmup timeout from 60s to 300s (5 minutes)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:51:03 -08:00
37fe57be71 Add path field to CommandDescriptor for move animations (#5258)
CommandDescriptor now includes a repeated Coords path field that
contains all hexes traversed during a move command. This allows
clients to animate the actual path taken rather than a straight
line from origin to destination (which sometimes showed units
moving over water).

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:50:29 -08:00
1a147b0a19 Delete LegacyProvinceUtils - complete deproto of province utilities (#5257)
This removes the last of the Legacy*Utils wrapper classes, completing the
deproto migration. ProvinceViewFilter now uses ProvinceUtils directly,
with a new resourceCap helper method to calculate gold/food caps from
pre-computed effective development values.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:47:20 -08:00
37a9a29eaf Add delete button to games list in admin console (#5256)
- 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:42:41 -08:00
0ff7d51f2c Fix deployment issues: missing volumes, redundant operations, validation (#5254)
1. Add missing jfr and jvm-tmp volumes to eagle-green
   - Without these, JFR sidecar can't attach to the JVM when green is active
   - JFR recordings wouldn't work either

2. Remove redundant sync_config_files from deploy script
   - CI already copies config files via scp before running deploy
   - Fetching from GitHub main could cause version mismatches
   - Eliminates unnecessary network calls during deployment

3. Skip image pull if already present locally
   - CI already pulls images before running deploy script
   - Saves time during CI deployments
   - Manual deployments still pull if needed

4. Add nginx config validation before reload
   - Run nginx -t before nginx -s reload
   - Rollback to backup config if validation fails
   - Prevents broken config from taking down nginx

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:42:02 -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
0c6d2abd22 Simplify ActionResultType from generated files to Scala enum (#5080)
This replaces the complex code generation system for ActionResultType
(Go generators + bazel rules creating individual files per enum value)
with a simple Scala 3 enum.

Key changes:
- Delete Go generators and bazel action_result_type_rule.bzl
- Replace per-file generated ResultTypes with single ActionResultType enum
- Add ActionResultType.fromValue() for O(1) lookup by int value
- Add two Scala-only types: HeroStatGained and ProfessionGained
- Remove "ResultType" suffix from all enum values throughout codebase
- Resolve name collisions with qualified imports
- Add parity test ensuring proto and Scala enums stay in sync

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:33:20 -08:00
0fb80e5e7a Fix ignored save() errors in auth service user persistence (#5083)
Several functions in users.go were ignoring errors from save(), which
could cause user edits to appear to succeed but not persist to disk.
If save() failed (disk permissions, disk full, etc.), the changes would
be lost on service restart.

Fixed functions:
- SetDisplayName: now returns error if save fails
- SetDisplayNameAdmin: now returns error if save fails (2 places)
- FindOrCreateUser: now logs warning if save fails (can't change signature)

This fixes admin console "Edit" not persisting display name or admin
status changes.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:26:16 -08:00
4f39e16b80 Add missing action result types to proto and remove generator hook (#5082)
- Add HERO_STAT_GAINED (151) and PROFESSION_GAINED (152) to
  action_result_type.proto to maintain parity with the Scala enum
- Remove update-action-result-types pre-commit hook and script
  (no longer needed with simplified Scala enum approach)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 19:14:16 -08:00
b825cdb140 Implement two-stage sounds and new combat animations (#5072)
* Add plan doc for two-stage sound system and new animations

Documents the implementation plan for:
- Two-stage sound system for conditional actions (attempt + result sounds)
- Five new animators: Move, Lightning, Catapult, RaiseDead, Meteor

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

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

* Implement two-stage sounds and new combat animations

Two-stage sound system:
- Add attempt sounds for conditional actions (StartFire, Repair, etc.)
- Play attempt sound immediately when command issued
- Play result sound (success/failure) when server responds
- Handle both own commands and other players' actions

New animators:
- MoveAnimator: Smooth hex movement with arc
- LightningAnimator: Jagged electric arc with flicker
- CatapultAnimator: Arcing rock with impact explosion (Reduce)
- RaiseDeadAnimator: Ground glow with rising figures
- MeteorAnimator: Falling meteor with trail and explosion

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

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

* Wire up animator sprites in Unity scene

- Add Animation Sprites folder with basic shapes
- Assign sprites to animator components in Gameplay scene

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

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

* Add AnimationTestController for testing animations

Debug controller with public methods for each animation type:
- TestArrowVolley, TestMelee, TestMove, TestLightning
- TestCatapult, TestRaiseDead, TestMeteor
- TestAll (runs all in sequence)

Wire methods to UI buttons to test animations without game setup.

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

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

* Simplify AnimationTestController to use ShardokGameController reference

Instead of duplicating animator references, pull them from
ShardokGameController at Start(). Auto-finds controller if not assigned.

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

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

* Wire up AnimationTestController in Unity scene

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

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

* Fix arc animations and sound playback for non-animated actions

Arc fixes:
- Change arc offset from Z to Y axis so arcs are visible in top-down view
- Affects ArrowVolleyAnimator, CatapultAnimator, and MoveAnimator

Sound fix:
- Add missing sound playback for current player's non-animated actions
- Previously only actions WITH animations played sounds in PerformAction

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

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

* Allow animations to run simultaneously without cancellation

Remove animation cancellation logic so multiple animations can play
at once. Each animation manages its own lifecycle and cleans up
when complete, preventing orphaned objects.

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

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

* Update TestMelee to cycle through all battalion types

Runs 3 sequential melee animations covering all 6 battalion types:
- LightInfantry vs HeavyInfantry
- LightCavalry vs HeavyCavalry
- Longbowmen vs Undead

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

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

* Remove HolyWave from two-stage sound system

HolyWave always succeeds - it's deterministic. HolyWaveDamage is a
separate action that fires when undead are damaged, but the wave
itself cannot fail.

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

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

* Remove FailedInspireTroops reference after proto update

FailedInspireTroops was removed from the ActionType enum.

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

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

* Link additional attempt sounds in Unity 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-08 18:53:06 -08:00
b7ecc07468 Enable HTTPS for admin subdomain (#5079)
- Uncomment HTTPS server block for admin.eagle0.net
- HTTP now redirects to HTTPS

Requires SSL certs to be in place before merge.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:56:23 -08:00
134dbdb30c Remove unused FAILED_INSPIRE_TROOPS action type (#5078)
The FAILED_INSPIRE_TROOPS action type was defined in action_type.proto
but never used in production code. The HolyWaveCommand always succeeds
when inspiring troops - there is no failure path.

The only reference was an unused import in HolyWaveCommand_test.cpp.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:54:29 -08:00
da0e059b01 Add admin subdomain support with IPv6 (#5077)
- Add nginx server blocks for admin.eagle0.net and admin.prod.eagle0.net
- Enable IPv6 listeners on all nginx server blocks
- Remove direct port exposure for admin (now accessed via nginx)
- Admin console will be available at https://admin.eagle0.net

Requires SSL certificate setup after DNS propagation:
  certbot certonly --webroot -w /var/www/certbot \
    -d admin.eagle0.net -d admin.prod.eagle0.net

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:52:12 -08:00
fbede62140 Add admin subdomain support with IPv6 (#5076)
- Add nginx server blocks for admin.eagle0.net and admin.prod.eagle0.net
- Enable IPv6 listeners on all nginx server blocks
- Remove direct port exposure for admin (now accessed via nginx)
- Admin console will be available at https://admin.eagle0.net

Requires SSL certificate setup after DNS propagation:
  certbot certonly --webroot -w /var/www/certbot \
    -d admin.eagle0.net -d admin.prod.eagle0.net

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 16:36:29 -08:00
adminandGitHub cf5962f4e3 missing the applyResolvedBattle call (#5075) 2026-01-07 20:56:58 -08:00
6e4ccf7189 Add auth configuration logging to admin server (#5062)
- Log configuration at startup: eagle-addr, auth-addr, auth-tls
- Improve admin check failure logging to include auth server details

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 06:27:47 -08:00
ed4a9174de Fix new game creation crashes: RoundPhase and BattalionTypes (#5073)
Two issues prevented CreateGame from succeeding:

1. UNKNOWN_PHASE error: GameStateProto was created without currentPhase,
   defaulting to UNKNOWN_PHASE (0) which caused ProtoConversionException

2. None.get in BattalionSuitability: newBattalionTypes was in the proto
   but commented out in the Scala model. When creating games, battalion
   types were set in ActionResult but lost during proto-to-Scala conversion,
   so they never got applied to GameState

Fixed by:
- Setting currentPhase = NEW_ROUND in PersistedHistory initial states
- Adding newBattalionTypes field to ActionResultT, ActionResultC
- Adding conversion in ActionResultProtoConverter
- Adding applyNewBattalionTypes extension method
- Calling extension in ActionResultApplierImpl

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 21:24:36 -08:00
d7afc48202 Add JWT service initialization logging (#5071)
- Log whether JWT_PRIVATE_KEY environment variable is set/empty
- Log the key ID and size when successfully loaded
- Log parse errors instead of silently swallowing them
- Helps diagnose OAuth token validation issues across environments

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:22:29 -08:00
54de9fe655 Refactor: unified AnimationType for animations and sounds (#5070)
- Add AnimationType enum to unify CommandType and ActionType for animations
- Change Model.PerformTargetedCommand to return CommandType? (null = failure)
- Add PlayAnimationAndSound helper that handles both animation and sound
- Add conversion functions: AnimationTypeForCommand, AnimationTypeForAction
- Update PerformAction to play sound immediately with animation
- Update ModelUpdated to use helper for other players' actions
- Skip duplicate sound for current player (already played in PerformAction)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 21:15:16 -08:00
68254d011b Hardcode auth service URL to prod for all environments (#5067)
Unity client now always connects to prod.eagle0.net:40033 for OAuth,
regardless of which Eagle server is selected for gameplay. This allows
QA testing with prod authentication.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:40:08 -08:00
37f099be96 Add unit-specific weapons for melee animation (#5069)
Each battalion type now has its own weapon and animation style:
- Light Infantry: Swords (swung)
- Heavy Infantry: Maces/Hammers (swung, larger, more violent)
- Light Cavalry: Small spears (thrust)
- Heavy Cavalry: Large lances (thrust, largest, most violent)
- Longbowmen: Daggers (thrust, smaller, less violent)
- Undead: Bones (thrust, violent)

Weapon configs include scale multiplier and violence multiplier for
differentiated visual feedback per unit type.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:37:04 -08:00
9f47a9d01c Add arrow volley animation for archery attacks (#5068)
Uses SpriteRenderer for world-space rendering (like particle effects)
instead of UI Image, which has issues with the rotated Grid Canvas.
This approach follows the proven pattern used by SetCellModifierEffect.

Key changes:
- ArrowVolleyAnimator: Creates arrows as SpriteRenderer GameObjects
  parented to gridCanvas, positioned with transform.localPosition
- HexGrid: Added GetCellLocalPosition() to expose cell positions
- ShardokGameController: Triggers animation in PerformAction() for
  player's archery commands (latency hiding) and in ModelUpdated()
  for other players' archery actions

The Grid Canvas uses Screen Space - Camera with 90° X rotation
(lies flat like a tabletop). UI Image components have rendering
issues in this configuration, but SpriteRenderers (3D objects)
render correctly - matching how particle effects already work.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 17:33:41 -08:00
9014874f0b Fix game upload to check for conflicts before overwriting (#5066)
Previously, uploading a game would extract files to disk before checking
if the game already existed, potentially losing data if the import failed.

Changes:
- Add CheckGameExists RPC to check if game exists in memory or on disk
- Implement checkGameExists in GamesManager and EagleServiceImpl
- Update admin server upload handler to check before extracting files
- When conflict detected, show options: "Use New ID" or "Replace Existing"
- "Use New ID" generates a random new game ID for the upload
- "Replace Existing" removes existing game from memory before overwriting
- Add JavaScript in games.html to handle conflict resolution flow

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:47:46 -08:00
93339c8fc3 Add delete game feature to admin console (#5065)
- Add DeleteGame RPC to proto with request/response messages
- Implement deleteGame in GamesManager to remove game from memory
  and optionally delete save files from disk
- Implement deleteGame override in EagleServiceImpl
- Add handleGameDelete handler in Go admin server
- Add delete button and confirmation modal to game detail page
- Modal includes checkbox to also delete save files from disk

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 16:17:23 -08:00
b10f936dfd Fix duplicate battalion IDs in destroyedBattalionIds (#5064)
A battalion could be added twice if:
1. Its size was 0, AND
2. Its unit was Captured or Outlawed

This caused "key not found" errors when the applier tried to remove
the same battalion twice.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:52:22 -08:00
db5446ea37 Add Admin service route to nginx port 40033 (#5063)
The QA admin server connecting to prod auth was getting 404 because
nginx only routed the Auth service, not the Admin service which
handles ListUsers RPC for admin privilege checks.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 21:27:33 -08:00
3119b0c63c Add --auth-tls flag for admin server remote auth connections (#5061)
When connecting to a remote auth server (e.g., prod.eagle0.net:40033),
TLS is required. This adds an --auth-tls flag that switches from
insecure credentials to TLS with system root CAs.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 20:49:02 -08:00
4b36870cc5 Allow disabling JFR sidecar in admin server (#5059)
When --jfr-sidecar-addr is set to empty string (""), JFR functionality
is disabled instead of failing with connection errors. This is useful
for QA environments that don't have the JFR sidecar container.

- Add jfrDisabled() helper function
- Return "JFR sidecar not configured" for /jfr/status (as JSON)
- Return 503 Service Unavailable for /jfr/start, /jfr/stop, /jfr/download
- Update startup log to indicate JFR status

Usage: --jfr-sidecar-addr ""

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:10:49 -08:00
b3d0c1ce29 Delete ActionResultProtoApplierImpl (no longer needed) (#5058)
After PR #5056, History classes now use the Scala ActionResultApplierImpl
instead of the proto-based applier. This removes the now-unused proto applier:

- Delete ActionResultProtoApplier.scala and ActionResultProtoApplierImpl.scala
- Delete ActionResultProtoApplierImplTest.scala
- Remove unused proto applier dependencies from BUILD files
- Update comments in MarchCommand.scala and SendSuppliesCommand.scala to
  reference ActionResultApplierImpl instead

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 18:07:32 -08:00
577c94f092 Remove unused images from Assets/Images, update references and asset audit (#5057)
- Delete all unused images from Assets/Images/ (keeping only startFire.png)
- Delete duplicate Assets/Images/bridge.png (identical to Shardok/commandImages/bridge.png)
- Update Gameplay.unity and Map Editor.unity to reference the Shardok bridge
- Update ASSET_AUDIT.md:
  - Mark previously deleted stock images as done
  - Add clip art section for bridge.png and startFire.png that need replacement
  - Update action items and recommendations

Deleted images (unused):
- Steel-Hauberk.png, bow00.png, brotherhood.png, burglar.png
- chicken-leg.png, clinking-beer-mugs.png, crystal-ball.png, faker.png
- flail.png, gift.png, jail.png, longbow.png, orders.png
- peace-dove.png, spearshield.png, supplies.png, travel.png, bridge.png

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:08:42 -08:00
0c271aacea Use Scala applier in History classes instead of proto applier (#5056)
- Add fromProto method to ActionResultProtoConverter to convert proto
  ActionResult to Scala ActionResultT
- Add fromProto method to ChangedProvinceConverter to convert proto
  ChangedProvince to Scala ChangedProvinceT
- Update InMemoryHistory and PersistedHistory to use ActionResultApplierImpl
  (Scala) instead of ActionResultProtoApplierImpl
- Store precomputed Scala state in ActionWithResultingState to avoid
  redundant proto-to-Scala conversions

This eliminates the need to maintain two separate applier implementations
that must stay in sync. The proto applier is no longer used by production
code and can be deleted in a follow-up.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 17:02:50 -08:00
5a82353744 Remove unused actionResultProtoApplier parameter from EngineImpl (#5055)
* Delete dead Action trait and TRandomSequentialResultsAction

- Delete Action.scala - trait with execute method, nothing implements it
- Delete TRandomSequentialResultsAction.scala - nothing extends it
- Remove :action dependency from all BUILD files
- Remove dead WaitingAction implicit class from test package.scala
- Remove unused WaitingAction imports from test files

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

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

* Remove unused actionResultProtoApplier parameter from EngineImpl

- Remove unused actionResultProtoApplier constructor parameter (declared but never used)
- Remove unused ActionResultProtoApplier/Impl imports from EngineImpl
- Remove unused RuntimeValidator import from EngineImpl
- Remove action_result_proto_applier_impl dependency from engine_impl and round_phase_advancer
- Remove unused runtime_validator dependency from engine_impl

🤖 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-04 14:52:32 -08:00
c9d6944b95 Delete dead Action trait and TRandomSequentialResultsAction (#5054)
- Delete Action.scala - trait with execute method, nothing implements it
- Delete TRandomSequentialResultsAction.scala - nothing extends it
- Remove :action dependency from all BUILD files
- Remove dead WaitingAction implicit class from test package.scala
- Remove unused WaitingAction imports from test files

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 14:51:48 -08:00
66efefc8d6 Replace Hetzner plan doc with latency hiding strategies (#5053)
The Hetzner on-demand compute implementation is complete:
- ARM64 builds working in CI
- Shardok running on Hetzner CAX41 in Helsinki
- Production Eagle connected via TLS + token auth
- IPv6/NAT64 networking configured

Delete the completed planning doc and add a new doc covering
future latency optimization strategies:
1. Client-side animation masking (recommended first step)
2. Split Shardok architecture (future if needed)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:37:53 -08:00
811de67c40 Move Custom Battle to lobby with OAuth authentication (#5051)
* Move Custom Battle to lobby, use OAuth authentication

- Remove _createConnection() from _internalCustomBattle() since connection
  already exists when called from lobby
- Hide gameSelectionPanel instead of connectionPanel when entering custom battle
- Add customBattleButton field for lobby UI button
- Add cancelCustomBattleButton and CancelCustomBattle() to return to lobby
- Wire up button click handlers in SetupLobbyUI()

The Custom Battle button should now be placed in the gameSelectionPanel (lobby)
in Unity and assigned to the customBattleButton field. A cancel button in the
customBattlePanel should be assigned to cancelCustomBattleButton.

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

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

* Wire up Custom Battle and Cancel buttons in Unity scene

- Assign customBattleButton in lobby panel
- Assign cancelCustomBattleButton in custom battle panel

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

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

* Fix CancelCustomBattle to properly reset canvas states

Hide shardokCanvas and ensure connectionCanvas is visible when
returning to lobby from custom battle.

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

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

* Fix Back button onClick handler in custom battle panel

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:32:34 -08:00
f4aa19119b Remove dead execute method from ProtolessRandomSequentialResultsAction (#5052)
* Update DEPROTO_PLAN.md: 100% action files now protoless

- ResolveBattleAction migrated to Scala types (PR #5048)
- All 52 action files are now fully protoless
- CommandChoiceHelpers fully migrated to Scala types
- Update progress summary and validation checklist

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

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

* Remove dead execute method from ProtolessRandomSequentialResultsAction

The execute(startingState: GameStateProto, applier: ActionResultProtoApplier) method
was never called - all actions using this base class now call .results() directly
and apply results via RandomStateSequencer or ActionResultApplier.

This removes the dead method and its unused dependencies (SeededRandom,
ActionResultProtoApplier, VigorXPApplier, ActionResultProtoConverter).

Note: The proto GameState export is retained because downstream actions use
ProvinceViewFilter which has overloaded methods taking both proto and Scala
GameState - overload resolution requires both types to be visible.

🤖 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-04 13:21:08 -08:00
80023d39a0 Update DEPROTO_PLAN.md: 100% action files now protoless (#5050)
- ResolveBattleAction migrated to Scala types (PR #5048)
- All 52 action files are now fully protoless
- CommandChoiceHelpers fully migrated to Scala types
- Update progress summary and validation checklist

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:20:46 -08:00
ecffeb0d2c Fix Docker IPv6 check: don't require sudo (#5049)
The deploy user doesn't have passwordless sudo. Change from trying
to configure Docker (which fails) to just checking and warning.

Docker IPv6 is a one-time server setup done manually.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:03:28 -08:00
1936d65bea Migrate ResolveBattleAction to Scala types (#5048)
- Change ResolveBattleAction to take Scala GameState and use ActionResultApplier
- Add resolvedBattle field to ActionResultT, ActionResultC, and proto converter
- Update EngineImpl.resolveBattle to use Scala-based applier
- Update test to convert proto GameState to Scala and handle Scala results

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:02:28 -08:00
ea46bb4a90 Enable Docker IPv6 for connecting to Hetzner Shardok (#5047)
The Hetzner Shardok server is IPv6-only. Docker containers need
IPv6 support to reach it.

Changes:
- docker-compose.prod.yml: Add IPv6-enabled network
- docker_build.yml: Configure Docker daemon for IPv6 on deploy
  (with ip6tables for NAT)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 12:09:58 -08:00
480ae0d0c6 Fix auth service overwriting external user database changes (#5046)
The auth service loads users.pb into memory at startup. When authcli
modifies the file on disk (e.g., to grant admin), the service still has
the old in-memory copy. On next login, it would overwrite the file with
stale data, losing the admin grant.

Fix: Check file modification time before each FindOrCreateUser call.
If the file was modified externally, reload it before proceeding.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:06:44 -08:00
14def8899d Migrate ChronicleEventGenerator to use Scala NotificationDetails (#5045)
- Replace proto notification detail imports with Scala NotificationDetails
- Use NotificationConverter.fromProto to convert proto -> Scala
- Remove dependency on action_result_notification_details_scala_proto
- Add dependencies on notification_trait and notification_converter

The internal pattern matching now uses Scala sealed trait NotificationDetails
instead of proto case classes, reducing proto coupling in the action layer.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:05:43 -08:00
adminandClaude Opus 4.5 581ffb735e Make Shardok address configurable via SHARDOK_ADDRESS env var
Allows switching between local Shardok (default: shardok:40042) and
remote Hetzner Shardok (shardok.prod.eagle0.net:40042) via GitHub
Actions secrets.

To use Hetzner Shardok in production:
1. Add secret SHARDOK_ADDRESS=shardok.prod.eagle0.net:40042
2. Add secret SHARDOK_AUTH_TOKEN=<your-token>

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:51:52 -08:00
74383ae5d9 Fix admin console OAuth flow and route protection (#5044)
* Fix admin console OAuth flow and route protection

Changes:
- Gate all admin routes with requireAuth (games, settings, JFR, APIs)
  Only login, logout, health, and static files are public now
- Add return_url to OAuth flow so auth service redirects back to admin
  console after callback, instead of showing "close this window"
- handleLoginComplete now immediately completes login on redirect
- Update authcli help text with production usage examples

The OAuth flow now works properly for web clients:
1. User clicks Sign in -> admin console redirects to OAuth provider
2. OAuth provider calls auth service callback
3. Auth service redirects back to admin console's /login/complete
4. Admin console sets JWT cookie and redirects to /games

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

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

* Fix oauth_test.go for GetAuthURL signature change

Update test calls to pass empty string for the new returnURL parameter.

🤖 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-04 10:51:23 -08:00
ba9c92222c Add asset licensing audit document (#5042)
Document all media assets in Unity project for licensing review before
potentially opening public access to game downloads. Identifies:
- Asset Store purchases (properly licensed)
- CC-licensed music with attribution
- Items needing verification (Shardok sounds, StrategyGameIcons, etc.)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:47:28 -08:00
f24c1209e0 Convert ProvinceEvent from sealed trait to Scala 3 enum (#5043)
- Replace sealed trait + case class pattern with Scala 3 enum
- Update all import sites to use `ProvinceEvent.{BeastsEvent, ...}` pattern
- More idiomatic Scala 3 with cleaner syntax

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 10:23:55 -08:00
e7c8df8b33 Add admin user management to admin console (#5041)
* Add admin user management to admin console

Adds functionality to view and manage user identity→display name links:

Backend (auth service):
- New admin.proto with AdminService gRPC (ListUsers, SetUserDisplayName, SetUserAdmin)
- AdminHandler validates JWT is_admin claim for authorization
- UserService methods for listing users and admin operations

Frontend (admin console):
- OAuth login flow with Discord/Google (JWT stored in HTTP-only cookie)
- Auth middleware requiring is_admin claim for /users routes
- Users page with search, display name editing, and admin toggle
- HTMX-powered table with infinite scroll pagination

Deployment:
- admin container now connects to auth service for user management

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

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

* Add authcli tool for user management

CLI tool for managing auth service users directly via the users.pb file.
Useful for bootstrapping the first admin user or emergency access.

Commands:
- list: List all users with their admin status
- find <email>: Find user by email (partial match)
- set-admin <id> true|false: Set admin status by user ID
- grant-admin <email>: Grant admin to user by exact email match

Usage:
  authcli --data-dir=/app/data list
  authcli --data-dir=/app/data grant-admin your@email.com

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

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

* Add authcli to auth server Docker image

- Include authcli_linux_amd64 in auth_binary_layer
- Update auth_build.yml paths to trigger on authcli and admin proto changes

Usage on VM:
  docker exec auth-server /app/authcli_linux_amd64 --data-dir=/app/data list
  docker exec auth-server /app/authcli_linux_amd64 --data-dir=/app/data grant-admin user@email.com

🤖 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-04 07:55:23 -08:00
c753c4995a Add Scala overloads to ProvinceEventUtils (#5040)
* Add Scala overloads to ProvinceEventUtils

- Add Scala overloads for isBlizzardEvent, isBeastsEvent, isEpidemicEvent,
  isFestivalEvent, isDroughtEvent, isFloodEvent, isImminentRiotEvent
- Add Scala overloads for beastsCount and beastInfo
- Update BUILD.bazel with required Scala model dependencies
- Add province event dependency to LegacyProvinceUtils for overload resolution

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

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

* Replace isInstanceOf with pattern matching in ProvinceEventUtils

- Use match expressions instead of isInstanceOf for type checks
- More idiomatic Scala style

🤖 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-04 07:42:02 -08:00
51e9e9b14a Add Scala overload to ExpandedCombatUnitUtils (#5039)
- Add protoless overload that takes ScalaGameState and CombatUnitC
- Uses BattalionViewFilter (protoless) and BattalionViewConverter for output
- Exports CombatUnitC and ScalaGameState types for downstream callers

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 06:00:24 -08:00
a2feaa6546 Add protoless Scala overloads to diplomacy Resolve command factories (#5038)
- EligibleDiplomacyStatuses: Add Scala overload for maybeImprisonStatus
  that takes provinces Iterable instead of proto GameState
- AvailableResolveTruceOfferCommandFactory: Add Scala overload that
  filters Scala TruceOffer instances and converts to proto output
- AvailableResolveAllianceOfferCommandFactory: Add Scala overload for
  AllianceOffer filtering
- AvailableResolveBreakAllianceCommandFactory: Add Scala overload for
  BreakAlliance filtering
- AvailableResolveInvitationCommandFactory: Add Scala overload with
  protoless invitation validation using FactionUtils
- AvailableResolveRansomOfferCommandFactory: Add Scala overload with
  protoless RansomValidity and conflict checking

All Scala overloads take ScalaGameState and output proto commands,
allowing callers with Scala model types to avoid GameStateConverter.toProto().

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:49:44 -08:00
4d83978951 Fix OAuth reconnection failure due to PlayerPrefs threading (#5037)
TokenStorage was calling PlayerPrefs.GetString() which can only be
called from Unity's main thread. When PersistentClientConnection
attempts to reconnect from a background thread, JwtAuthInterceptor
tries to get the access token, causing a UnityException.

This caused an infinite loop: DeadlineExceeded → reconnect attempt →
PlayerPrefs exception → reconnect fails → idle timeout → retry...

Changes:
- TokenStorage: Add in-memory cache for thread-safe token access
- OAuthManager: Initialize cache on main thread in Awake()
- PersistentClientConnection: Schedule reconnect when stream ends
  normally (was missing, causing silent connection death)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:38:03 -08:00
11de6872d4 Add TLS support to ExternalAuthClient for remote auth connections (#5036)
Enable QA Eagle to connect to production auth service over TLS.

- Use TLS for external connections (non-localhost hosts)
- Keep plaintext for local container-to-container connections
  (localhost, auth, 127.x.x.x)

This allows running QA Eagle with --auth-service-url prod.eagle0.net:40033
to share user accounts with production.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:25:50 -08:00
ecd089de59 Add Sentry integration for exception alerting (#5011)
- Add io.sentry:sentry dependency
- Initialize Sentry from SENTRY_DSN environment variable
- Report uncaught exceptions via ExceptionInterceptor
- Add SENTRY_DSN to docker-compose and CI workflow

When SENTRY_DSN is configured, uncaught exceptions in gRPC handlers
will be reported to Sentry for email/Slack alerts.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:20:05 -08:00
c1756cb36b Add protoless ProvinceViewFilter overload with faction filtering (#5034)
- Add filteredProvinceView(ProvinceT, ScalaGameState, FactionId) overload
- Use protoless FactionUtils.hasAlliance, Visibility.hasFullVisibility, and ProvinceUtils.incomingOthers
- Add helper methods: fullProvinceInfoScala, maybeIncomingAttackersScala, unaffiliatedHeroInfoScala
- Handle reconned provinces directly from Scala FactionT.reconnedProvinces

GameStateViewFilter improvements:
- Eliminate GameStateConverter.toProto() call in Scala overload
- Use new ProvinceViewFilter Scala overload for faction filtering
- Convert battalionTypes and chronicleEntries to proto only at output boundary

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 22:16:44 -08:00
78533a9ff0 Remove Eagle user management - forward to Go auth service (#5033)
Phase 2 of moving user management to Go auth service:
- Eagle now forwards setDisplayName, getCurrentUser, logout to Go auth
- Removed InternalUserServiceImpl and internal gRPC server (port 40034)
- UserService is now optional (only created when not using external auth)
- Updated docker-compose to remove port 40034 exposure

This makes Eagle stateless for user data when configured with
--auth-service-url, enabling QA to point to prod auth service.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 21:39:24 -08:00
8172a4e117 Migrate CheckForFulfilledQuestsAction to protoless BattalionTypeFinder (#5032)
* Migrate CheckForFulfilledQuestsAction to protoless BattalionTypeFinder

- Change battalionTypes parameter from proto Vector[BattalionType] to Scala Vector[BattalionType]
- Update callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
- Eliminate wasteful BattalionTypeConverter.toProto() conversions
- Update test to use Scala BattalionType

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

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

* Add Scala overload to ExpandedUnaffiliatedHeroUtils, eliminate proto conversions

- Add ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)
- Add UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto() helper for efficient enum conversion
- Use reverse map instead of inefficient .find() in UnaffiliatedHeroConverter.toProto()
- Update AvailablePleaseRecruitMeCommandFactory to use Scala overload directly
- Eliminate wasteful GameStateConverter.toProto() and UnaffiliatedHeroConverter.toProto() calls

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

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

* Add exports to expanded_unaffiliated_hero_utils for transitive type visibility

Callers that import ExpandedUnaffiliatedHeroUtils now see both overloads in
method signatures, which exposes ScalaGameState and UnaffiliatedHeroT types.
Add these as exports so callers can compile without needing explicit deps.

🤖 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-03 21:33:32 -08:00
fbabe9cea4 Move user management to Go auth service (Phase 1) (#5030)
* Move user management to Go auth service (Phase 1)

Auth service now manages users locally instead of calling Eagle:
- Add users.go with UserService for protobuf-based user persistence
- Implement SetDisplayName, GetCurrentUser, Logout handlers
- Extract JWT from gRPC metadata (authorization header)
- Add ValidateAccessToken function to jwt.go
- Add user_go_proto target for user.proto
- Add AUTH_DATA_DIR env var and /app/data volume mount

Auth service no longer depends on Eagle's InternalUserService.
Eagle's user management code remains for now (Phase 2 cleanup).

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

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

* Add unit tests and migration path for user service

- Add comprehensive unit tests for UserService (users_test.go)
- Add automatic migration from Eagle's users.pb location
- Add atomic write pattern for crash safety (write .tmp, then rename)
- Add AUTH_LEGACY_DATA_DIR config for migration path
- Mount Eagle's saves volume read-only for migration access

Migration strategy:
1. Auth service checks /app/data/users.pb first
2. If not found, reads from /app/saves/auth/users.pb (Eagle's location)
3. Saves migrated data to new location
4. Subsequent reads/writes use new location only

🤖 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-03 19:21:45 -08:00
7df24149f7 Deproto Phase 5: Document Legacy utility migration status (#5031)
* Add sortKey to FactionUtils to match LegacyFactionUtils API

- Add sortKey method and sortIgnoredChars constant to protoless FactionUtils
- Update LegacyFactionUtils to use direct proto field access (avoid converter
  failures on incomplete test data)
- Both implementations now have matching APIs for the deproto migration
- Update DEPROTO_PLAN.md with progress

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

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

* Document Legacy* utility migration status in DEPROTO_PLAN.md

All Legacy* utilities now have protoless equivalents with matching APIs:

**Parallel Implementations (proto mirrors protoless):**
- FactionUtils / LegacyFactionUtils - 24+ boundary callers
- HeroUtils / LegacyHeroUtils - 10 boundary callers
- ProvinceUtils / LegacyProvinceUtils - 20 boundary callers

**Awaiting migration of callers:**
- BattalionUtils / LegacyBattalionUtils - 4 callers
- BattalionViewFilter / LegacyBattalionViewFilter - 3 callers
- BattalionTypeFinder / LegacyBattalionTypeFinder - 2 callers

Legacy versions are appropriately used by boundary code (availability
factories, view filters, action appliers) that works with proto GameState.
The AI layer already uses the protoless versions.

🤖 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-03 18:41:44 -08:00
7de74539f0 Fix LocalFilePersister to create parent directories (#5029)
When saving to paths like "auth/users.pb", the parent directory
may not exist. FileOutputStream doesn't create parent directories,
so the save would silently fail. Now we ensure parent directories
exist before writing.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:31:54 -08:00
e9dd53fdf8 Hetzner setup: docs + Step 7 implementation (#5018)
* Update Hetzner docs with actual deployment details

- Use shardok.prod.eagle0.net as the domain name
- Step 4: Specify GitHub Actions secrets location
- Step 5: Recommend Hillsboro, OR (hil) + IPv6 floating IP
- Update all code examples and cloud-init scripts

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

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

* Wire SHARDOK_AUTH_TOKEN through Eagle's Shardok connection

Implements Step 7 of Hetzner setup guide:
- Main.scala reads SHARDOK_AUTH_TOKEN env var and creates ShardokSecurityConfig
- TLS is auto-enabled when Shardok address contains ".eagle0.net"
- Security config passed to both newGamesManager and newCustomBattleManager
- docker-compose.prod.yml passes SHARDOK_AUTH_TOKEN to Eagle container
- docker_build.yml passes secret during deployment

This is backward compatible: local Docker Shardok (shardok:40042) continues
to work without TLS/auth since the address doesn't contain ".eagle0.net".

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

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

* Update ShardokInstanceManager for DigitalOcean registry and TLS

- Update cloud-init script to use DigitalOcean Container Registry instead of ghcr.io
- Add Let's Encrypt/Certbot setup for TLS certificates
- Configure automatic certificate renewal via cron
- Pass TLS cert paths and auth token file to Shardok container
- Default to shardok.prod.eagle0.net domain

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

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

* Fix cloud-init to use eagle0.conf instead of environment variables

The Shardok C++ server reads configuration from /usr/local/share/eagle0/eagle0.conf,
not environment variables. Updated cloud-init script to:
- Create eagle0.conf with TLS and auth paths
- Mount the config directory into the container
- Remove unused -e environment variable flags

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

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

* Simplify spin-up strategy: trigger on human turns with 10-min idle timeout

Instead of trying to predict battles, we now:
- Spin up Shardok when any human player takes a turn
- Shut down after 10 minutes of no human turns

This is simpler and more reliable. Battles happen regularly during active
play, so Shardok will be ready when needed. The 10-minute timeout is long
enough to cover thinking time but short enough to minimize idle costs.

Updated:
- SHARDOK_ON_DEMAND_COMPUTE.md with new strategy and state machine
- ShardokInstanceManager: renamed onPlayerActivity -> onHumanTurn,
  changed default idle timeout from 60 to 10 minutes

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

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

* Document Hetzner testing progress and ARM64 blocker

Testing status:
- Hetzner CAX41 ARM64 server created in Helsinki
- Floating IPv6 configured with netplan persistence
- DNS and Let's Encrypt certificates working
- NAT64 configured for IPv6→IPv4 registry access

BLOCKER: ARM64 container crashes with runfiles error:
"cannot find runfiles (argv0="/app/shardok-server")"
Needs BUILD.bazel investigation for ARM64 image packaging.

Also documented known issues:
- IPv6-only servers need NAT64 DNS (nat64.net)
- Floating IP requires manual netplan config

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

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

* Fix ARM64 container crash: add missing environment variables

The ARM64 Shardok container was crashing with "cannot find runfiles"
because SHARDOK_RESOURCES_PATH and SHARDOK_MAPS_PATH weren't set.
Without these, the binary tries to use Bazel runfiles which don't
exist in the container.

Added the required environment variables to the docker run command
in the cloud-init script, matching docker-compose.prod.yml.

🤖 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-03 17:07:10 -08:00
35e8f85627 Fix duplicate UnaffiliatedHero entries when only hero departs (#5028)
When the only ruling faction hero departed from a province, clearRulingFaction
would create duplicate UnaffiliatedHeroC entries - one from the departure logic
and another from clearRulingFaction iterating over all rulingFactionHeroIds.

Fix: Filter out heroIds that already have entries in newUnaffiliatedHeroes
before creating new ones.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 17:04:41 -08:00
35bc242582 Fix upload section styling in admin panel (#5027)
* Fix upload section styling in admin panel

- Add explicit positioning to prevent overlap with other elements
- Style the details/summary for clearer expand/collapse indicators
- Add border and background when expanded for visual clarity
- Prevent button from shrinking in flex layout

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

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

* Fix upload section contrast and button sizing

- Use card background color for summary with proper text contrast
- Change Upload button to btn-small class for appropriate sizing
- Add proper border and styling for expanded form area
- Remove inline styles in favor of CSS classes

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

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

* Fix Upload button to not be full-width

- Add width: auto !important to override Pico CSS defaults
- Add flex-grow: 0 to prevent stretching in flex container
- Add position: static to prevent any fixed/absolute positioning
- Add z-index: auto to upload section to prevent stacking issues

🤖 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-03 16:52:45 -08:00
c5829b1aff Remove unused Cloudflare OAuth relay worker (#5026)
This was never successfully deployed. OAuth callbacks are handled
directly by the Go auth service via nginx routing.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 16:44:48 -08:00
6c78d9f733 Remove dead code from LegacyHeroUtils and LegacyFactionUtils (#5025)
* Remove dead code from LegacyHeroUtils

- Remove unused `seniorityOrder` method (protoless version in HeroUtils is used)
- Remove unused `sortOrdering` method (protoless version in HeroUtils is used)
- Remove unused `discordance` method (protoless version in HeroUtils is used)
- Remove unused `faction` method (protoless version in HeroUtils is used)
- Remove unused `Faction` import and proto dependency
- Remove corresponding tests for deleted methods

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

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

* Remove dead code from LegacyFactionUtils

- Remove unused `trust` method (protoless version in FactionUtils is used)
- Remove unused `factionHead` method
- Remove unused `ownedNeighbors` method (protoless version in FactionUtils is used)
- Remove unused `hostileNeighbors` methods (protoless version in FactionUtils is used)
- Remove unused `neutralNeighbors` method (protoless version in FactionUtils is used)
- Remove unused `truceExpirationDate` method
- Remove unused `alliedFactions` method (protoless version in FactionUtils is used)
- Remove unused `provincesWithHostileNeighbors` method
- Remove unused `ProvinceWithHostileNeighbors` case class
- Remove unused `Hero` import and `hero_scala_proto` dependency
- Remove corresponding tests for deleted methods

🤖 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-03 15:29:57 -08:00
1966dfbc24 Update DEPROTO_PLAN.md and remove dead code from LegacyBattalionUtils (#5022)
* Update DEPROTO_PLAN.md with Phase 5 progress

- Add section for thin wrapper refactorings (LegacyRansomValidity, LegacyRecruitmentOdds)
- Note LegacyHeroUtils dependency on LegacyFactionUtils
- Note LegacyBattalionUtils has intentionally different power multipliers

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

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

* Remove dead code from LegacyBattalionUtils

- Remove unused `power` method (superseded by BattalionPower.power)
- Remove unused `estimatedPower` method (superseded by BattalionPower.estimatedPower)
- Remove unused `powerMultiplier` map (only used by removed methods)
- Remove unused `BattalionView` import and proto dependency

All callers already use BattalionPower for power calculations.

🤖 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-03 12:33:03 -08:00
7cd7edf9a0 Fix null reference race in lobby subscriber callbacks (#5023)
The code checked _lobbySubscriber != null before enqueueing a lambda,
but the lambda captured the field reference and executed later on the
main thread when _lobbySubscriber could have become null (e.g., during
Dispose).

Fix by capturing the subscriber in a local variable before the null
check. This ensures the lambda uses the subscriber that was active
when the message arrived, even if _lobbySubscriber changes later.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 12:32:40 -08:00
104ee5429d Add scheduled workflow to cleanup old container images (#5024)
Runs daily at 3am UTC to delete container images older than 5 days from
DigitalOcean Container Registry. Protected tags (latest, arm64-latest)
are never deleted.

Also runs garbage collection after cleanup to reclaim storage.

Includes manual trigger with dry-run option for testing.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 12:31:31 -08:00
57fd73571f Refactor LegacyRecruitmentOdds to delegate to protoless RecruitmentOdds (#5021)
- Convert LegacyRecruitmentOdds from duplicated logic to thin wrapper
- Uses FactionConverter, HeroConverter, UnaffiliatedHeroConverter
- Delete LegacyRecruitmentOddsTest (protoless version already tested)
- Reduces duplicate code, centralizes recruitment odds calculation

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 11:56:50 -08:00
9ea6e29752 Delete unused Legacy* utilities: LegacyProvinceDistances, LegacyBattalionSuitability, LegacyFoodConsumptionUtils, LegacyHandleRiotUtils (#5020)
Phase 5 of deproto cleanup - remove Legacy* utilities that have no production callers:

- LegacyProvinceDistances: no callers besides its own test
- LegacyBattalionSuitability: no callers besides its own test
- LegacyFoodConsumptionUtils: no callers besides its own test
- LegacyHandleRiotUtils: no callers besides its own test

Updated DEPROTO_PLAN.md to track Phase 5 progress.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 09:49:51 -08:00
8581232f70 Add Scala overloads to BattalionNameFilter and BattleFilter (#5019)
* Eliminate GameState toProto() conversion in LLM pipeline

Phase 1 of deproto optimization: Make LlmRequestWithGameState use Scala
GameState instead of proto GameState, eliminating wasteful round-trip
conversion in UnrequestedTextHandler (the heaviest proto conversion in profiles).

Changes:
- LlmResolver: Change LlmRequestWithGameState.gameState to Scala GameState
- UnrequestedTextHandler: Remove GameStateConverter.toProto() calls, pass
  gameHistory.stateAfter() directly
- Update all prompt generators (~38 files) to use Scala model types:
  - FactionT instead of proto Faction
  - HeroT instead of proto Hero
  - ProvinceT instead of proto Province
  - Scala enums (Gender.Male, Profession.Mage) instead of proto enums
- DivineMessagePromptGenerator: Rewrite quest pattern matching to use
  Scala QuestC types instead of proto quest types
- ChronicleUpdatePromptGenerator: Inline province lookup, remove
  LegacyFactionUtils dependency
- BattalionDescriptions: Add Scala BattalionT and BattalionTypeId overloads
- Update test files to use concrete Scala types (HeroC, FactionC, ProvinceC)

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

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

* Make AttackCommandChooser fully protoless

- Add `estimatedPower(BattalionViewC)` to `BattalionPower` for handling recon
  data with optional training/armament (defaults to 50.0 when unknown)
- Update `AttackCommandChooser.chosenAttackCommand` to use Scala `BattalionViewC`
  instead of proto `BattalionView`
- Update `MidGameAIClient` to pass battalions directly without proto conversion
- Remove `LegacyBattalionUtils` and proto `battalion_view_scala_proto` dependencies
- Update DEPROTO_PLAN.md: Phase 1 complete, CommandChoiceHelpers fully protoless

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

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

* Add Scala overloads to BattalionNameFilter and BattleFilter

- Add Scala GameState overload to BattalionNameFilter using FactionUtils.provinces
- Add Scala overload to BattleFilter using FactionUtils.hostilityStatus
- Update GameStateViewFilter Scala overload to use the new Scala sub-filters
- Add shardok_battle visibility for view_filters package
- Update DEPROTO_PLAN.md to reflect completed work

🤖 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-03 09:26:25 -08:00
053ac4a622 Add Scala overloads to view filters (Phase 4 deproto) (#5016)
- Add Scala overloads to Visibility.scala for hasFullVisibility using
  Vector[FactionT] instead of proto GameState
- Add Scala overload to HeroViewFilter.filteredHeroView taking HeroT
  and ScalaGameState with proper type conversions
- Add Scala overload to FactionViewFilter.filteredFactionView taking
  FactionT and ScalaGameState
- Update GameStateViewFilter Scala overload to use Scala sub-filters
  where available (HeroViewFilter, FactionViewFilter)
- Update BUILD.bazel files for visibility and exports

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 09:04:17 -08:00
09041887c8 Fix ARM64 image push: use same repo with arm64- tag prefix (#5012)
DigitalOcean free tier limits to 5 repositories. Use the existing
shardok-server repository with arm64-prefixed tags instead of a
separate shardok-server-arm64 repository.

- shardok-server:latest (x86)
- shardok-server:arm64-latest (ARM64)

Also adds HETZNER_SETUP.md with infrastructure setup instructions.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 08:27:02 -08:00
58fd3dc112 Fix user database persistence in Docker (#5017)
The user database was being written to ~/eagle0/eagle/save/ but Docker
only mounted ./saves to /app/saves. This caused user data (including
displayName) to be lost on every container restart.

Add EAGLE_SAVE_DIR and EAGLE_ARCHIVE_DIR environment variables to
SaveDirectory, defaulting to the existing paths for local development.
Docker compose now sets these to /app/saves and /app/archived which
are properly mounted to persistent volumes.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 08:26:15 -08:00
d62bb747dd Optimize GetGameHistory to not load entries when only count is needed (#5015)
When the admin panel requests game history with limit=0, it only needs
the total count for pagination. Previously this would load all entries
and serialize each one to JSON, causing timeouts on games with many
actions.

Now when limit <= 0, we return just the count with empty entries,
making the count request fast regardless of history size.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 07:04:16 -08:00
f8646c7b50 Fix game ID parsing in download handler (#5014)
The download handler was using ParseInt which fails for game IDs that
have the high bit set (appear as negative when formatted as signed).
The gameID is already parsed and validated in handleGameRoutes using
ParseUint, so just pass it through instead of re-parsing.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-03 07:03:52 -08:00
affd0a8180 Add Scala GameState overload to GameStateViewFilter (#5010)
- Add `filteredGameState(gs: ScalaGameState, factionId: Option[FactionId])`
  overload that converts internally for now (sub-filters still need proto)
- Update HumanPlayerClientConnectionState to pass Scala GameState directly,
  removing 3 wasteful toProto conversions at call sites
- Add visibility for view_filters to access GameStateConverter
- Export Scala GameState from game_state_view_filter target
- Update DEPROTO_PLAN.md to reflect Phase 4 progress

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 22:32:23 -08:00
a92a878465 Switch ARM64 Shardok image to DigitalOcean Container Registry (#5009)
Use DigitalOcean registry for ARM64 images instead of GitHub Container
Registry for consistency with all other images (Eagle, Shardok x86,
admin, auth, jfr-sidecar).

- Update ci/BUILD.bazel: shardok_server_push_arm64 → registry.digitalocean.com
- Update shardok_arm64_build.yml: use DO_REGISTRY_TOKEN auth
- Update docs to reflect the registry change

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 22:25:17 -08:00
10696325e9 Return new JWT from SetDisplayName with updated displayName claim (#5008)
When a user sets their display name, the current JWT still has the old
(blank) displayName. This caused games to be created with blank usernames.

Fix: SetDisplayName now returns a new access token with the updated
displayName claim. The client stores this new token so subsequent requests
(like joining games) use the correct displayName.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 22:20:00 -08:00
88273937da Add TLS and token authentication to Shardok server (#5001)
* Add TLS and token authentication support to Shardok server

Enables secure communication between Eagle and remote Shardok instances:

- TLS support: Read SSL cert/key from config paths, create SslServerCredentials
- Token auth: Validate "Authorization: Bearer <token>" header on all RPCs
- Config: Added authTokenPath to ServerConfiguration
- TokenValidator class reads token from file and validates requests
- Graceful fallback: If TLS not configured, uses insecure credentials

Configuration options (via eagle0.conf or environment):
- sslCertPath: Path to TLS certificate (e.g., Let's Encrypt fullchain.pem)
- sslPrivateKeyPath: Path to TLS private key
- authTokenPath: Path to file containing the auth token

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

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

* Update implementation plan to mark completed tasks

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

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

* Add TLS and token authentication support to Eagle's Shardok client

Add ShardokSecurityConfig to configure TLS and auth token for connecting
to remote Shardok instances. When useTls is enabled, uses system trust
store for Let's Encrypt certificates. When authToken is set, adds Bearer
token to all gRPC requests via BearerTokenInterceptor.

This enables Eagle to connect securely to Shardok instances running on
Hetzner Cloud with Let's Encrypt TLS and token-based authentication.

🤖 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-02 22:12:31 -08:00
e472ab4df2 Add ShardokInstanceManager for on-demand compute lifecycle (#4998)
Manages the lifecycle of Shardok instances on Hetzner Cloud:
- Activity-based spin-up when players connect
- Automatic shutdown after idle timeout (default 60 minutes)
- State machine tracking: Stopped -> Starting -> Ready -> InUse
- Cloud-init script generation for Docker container setup
- Health checking during startup

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 22:10:05 -08:00
11110a5f88 Client connects directly to Go auth service for OAuth (#4982)
Phase 2 of OAuth extraction: Unity client now routes OAuth RPCs
(GetOAuthUrl, CheckOAuthStatus, RefreshToken) directly to the Go
auth service on port 40033, while user RPCs (SetDisplayName,
GetCurrentUser, Logout) continue to go to Eagle.

Changes:
- AuthClient: Use separate gRPC channels for auth service and Eagle
- OAuthManager: Accept both authServiceUrl and eagleUrl parameters
- ConnectionHandler: Pass both URLs when configuring OAuthManager

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 22:06:42 -08:00
d125394028 Fix action history pagination to avoid loading all results (#5005)
Previously, getGameHistory loaded all action results with history.all
then paginated in-memory. For games with many actions, this caused
timeouts.

Now uses history.since(startIndex).take(limit) which efficiently loads
only the save files containing the requested range. The since() method
in PersistedHistory already calculates which partial game files to read.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 22:06:06 -08:00
99037c7c3a Make AttackCommandChooser fully protoless (#5007)
* Eliminate GameState toProto() conversion in LLM pipeline

Phase 1 of deproto optimization: Make LlmRequestWithGameState use Scala
GameState instead of proto GameState, eliminating wasteful round-trip
conversion in UnrequestedTextHandler (the heaviest proto conversion in profiles).

Changes:
- LlmResolver: Change LlmRequestWithGameState.gameState to Scala GameState
- UnrequestedTextHandler: Remove GameStateConverter.toProto() calls, pass
  gameHistory.stateAfter() directly
- Update all prompt generators (~38 files) to use Scala model types:
  - FactionT instead of proto Faction
  - HeroT instead of proto Hero
  - ProvinceT instead of proto Province
  - Scala enums (Gender.Male, Profession.Mage) instead of proto enums
- DivineMessagePromptGenerator: Rewrite quest pattern matching to use
  Scala QuestC types instead of proto quest types
- ChronicleUpdatePromptGenerator: Inline province lookup, remove
  LegacyFactionUtils dependency
- BattalionDescriptions: Add Scala BattalionT and BattalionTypeId overloads
- Update test files to use concrete Scala types (HeroC, FactionC, ProvinceC)

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

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

* Make AttackCommandChooser fully protoless

- Add `estimatedPower(BattalionViewC)` to `BattalionPower` for handling recon
  data with optional training/armament (defaults to 50.0 when unknown)
- Update `AttackCommandChooser.chosenAttackCommand` to use Scala `BattalionViewC`
  instead of proto `BattalionView`
- Update `MidGameAIClient` to pass battalions directly without proto conversion
- Remove `LegacyBattalionUtils` and proto `battalion_view_scala_proto` dependencies
- Update DEPROTO_PLAN.md: Phase 1 complete, CommandChoiceHelpers fully protoless

🤖 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-02 22:05:22 -08:00
c60d4b80f2 Proxy game save downloads through gRPC for container environments (#5006)
Admin server and Eagle run in separate containers in production, so the
admin server cannot directly access Eagle's save directory. Added a streaming
gRPC endpoint DownloadGameSave that zips and streams the save directory,
with the admin server proxying the download through this endpoint.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:50:52 -08:00
041514bd02 Fix JWT claims to match Eagle's expected format (#5002)
* Fix JWT claims to match Eagle's expected format

Go auth service was using custom claim names (userId, displayName, isAdmin)
but Eagle's JwtServiceImpl expects standard claims:
- sub (Subject) for userId
- name for displayName
- admin for isAdmin

This fixes "User not found" error when client calls SetDisplayName after
OAuth login, because Eagle couldn't extract the userId from the JWT.

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

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

* Fix jwt_test.go to use updated claim field names

Update tests to use Subject instead of UserID and Name instead of
DisplayName, matching the changes to EagleClaims and RefreshClaims.

🤖 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-02 20:19:52 -08:00
c410f790ff Add game download/upload feature to admin panel (#5004)
- Add ImportGame RPC to eagle.proto for registering uploaded games
- Implement importGame in GamesManager to load saves from disk
- Add download handler: zips game save folder and serves as download
- Add upload handler: accepts zip, extracts, calls gRPC to register
- Add "Download Save" button to game detail page
- Add upload form (collapsible) to games list page

Uploaded games start with all AI players; admin can assign humans
using existing player management features.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:18:52 -08:00
b4d19e7b65 Remove MainQueue backlog debug logging (#5003)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 20:14:31 -08:00
9e8169df3e Remove dead proto code from CommandChoiceHelpers and related files (#4995)
Production code:
- Remove LegacyCommandChooser trait and all legacy methods from CommandChooser.scala
- Remove all proto overloads from CommandChoiceHelpers.scala (~1100 lines removed)
- Remove proto imports and converter dependencies
- Rename chosenFulfillEasyQuestsCommandProtoless to chosenFulfillEasyQuestsCommand
- Update MidGameAIClient to use renamed method
- Remove unused CommandChooserImplicits import from AIClient

Test code:
- Update CommandChoiceHelpersTest to use Scala model types
- Update FulfillQuestsCommandSelectorTest to use Scala model types
- Update PerformVassalCommandsPhaseActionTest to use Scala model types

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:20:05 -08:00
f92f400f28 Route OAuth callback to Go auth service (#5000)
The /oauth/callback endpoint should proxy to auth:8080 (Go auth service)
not eagle:8080. This fixes 502 errors during OAuth flow.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:18:38 -08:00
28d7da9634 Use SERVER_BASE_URL for OAuth callback URL (#4999)
The Go auth service now constructs the OAuth callback URL from
SERVER_BASE_URL env var (e.g., https://prod.eagle0.net/oauth/callback),
matching the Scala implementation. This fixes "Invalid OAuth2 redirect_uri"
errors from Discord.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:08:47 -08:00
79aac7c9ce Add Hetzner Cloud API client for on-demand Shardok instances (#4996)
Implements a Scala client for the Hetzner Cloud API to manage
on-demand Shardok instances for tactical combat. Features:

- Create/delete servers with cloud-init user data
- Get server status and list servers by label
- Power on/off and graceful shutdown operations
- Async execution with Futures
- Proper error handling for API errors

Also updates the plan doc to track ARM64 build completion.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:01:58 -08:00
a530a219f1 Deploy JWT_PRIVATE_KEY via auth CI/CD pipeline (#4997)
Add JWT_PRIVATE_KEY to auth_build.yml deploy job so the auth service
can bootstrap its RSA keys from the JWK secret. This ensures the
JWT key is properly deployed without manual server intervention.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 15:59:36 -08:00
335baf96dc Fix JWK base64 decoding to handle multiple formats (#4994)
The nimbus-jose-jwt library may output standard base64 (with +/)
instead of base64url (with -_). Try multiple decode strategies:
1. base64url without padding (JWK spec)
2. base64url with padding
3. standard base64 without padding
4. standard base64 with padding

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 15:14:41 -08:00
584ccbadcb Add ARM64 Linux build infrastructure for Shardok (#4990)
* Add ARM64 Linux build infrastructure for Shardok

Infrastructure to support cross-compiling Shardok for ARM64 Linux,
enabling deployment to Hetzner ARM instances (CAX41) for on-demand
compute.

Changes:
- Add linux_arm64 platform definition
- Add LLVM toolchain for ARM64 cross-compilation
- Add ARM64 sysroot placeholder (needs workflow run to populate)
- Add ARM64 busybox for container health checks
- Add Ubuntu 24.04 ARM64 base image
- Add Shardok ARM64 container image targets
- Update sysroot build workflow to support ARM64

Next steps:
1. Run "Build Linux Sysroot" workflow with architecture=arm64
2. Update MODULE.bazel with generated sysroot SHA
3. Build and push ARM64 container

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

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

* Update ARM64 sysroot SHA from workflow build

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

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

* Fix ARM64 container build issues

- Use linux/arm64/v8 platform string (rules_oci requires variant suffix)
- Remove busybox_layer_arm64 due to busybox.net SSL certificate issues
- Health checks can be added later using an alternative busybox source

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

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

* Add GitHub Actions workflow for ARM64 Shardok build

Builds and pushes ARM64 container image to GitHub Container Registry
for deployment on Hetzner ARM instances (CAX41).

Triggered on:
- Push to main (when Shardok-related files change)
- Manual workflow_dispatch

🤖 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-02 15:08:51 -08:00
06f6ba8340 Fix auth port conflict with nginx (#4993)
Remove direct port 40033 binding from auth service since nginx
now proxies this port (PR #4987). Both can't bind to the same
host port.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:57:02 -08:00
bfe5befff2 Auth service bootstraps JWT keys from JWK format (#4992)
- Add bootstrapKeysFromJWK() to convert JWK to PEM files on first run
- Uses only Go stdlib (no third-party JWK libraries)
- Pass JWT_PRIVATE_KEY env var to auth service in docker-compose
- PEM files persist in jwt-keys volume after first bootstrap

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:47:56 -08:00
521dde9503 Deproto: migrate AI files to protoless-only (#4991)
- Remove proto GameState overloads from 8 AI files, keeping only Scala model versions
- Update all AI tests to use Scala model types (GameState, FactionC, ProvinceC, HeroC)
- Clean up BUILD.bazel deps to remove proto dependencies
- Net reduction: -489 lines of code

Files converted:
- AIClientUtils.scala
- EarlyGameAIClient.scala
- FactionLeaderProvinceRanker.scala
- FixLeaderAloneCommandSelector.scala
- InvitationCommandSelector.scala
- MoveLeaderToBetterProvinceCommandChooser.scala
- ResolveDiplomacyCommandSelector.scala
- All corresponding test files

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:37:36 -08:00
85eccb97f0 Add Shardok on-demand compute infrastructure documentation (#4989)
Documents the architecture for running Shardok on Hetzner ARM instances
with on-demand spin-up based on player activity:

- Hetzner CAX41 (16 ARM cores) in Ashburn at ~$0.04/hr
- Activity-based spin-up: start when players connect, stop after 1hr idle
- TLS + token authentication (Let's Encrypt for certs, auto-renewed)
- Estimated cost: $2-7/month vs $20-40/month for equivalent always-on

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:12:07 -08:00
e270d27a4a Deproto MidGameAIClient: complete protoless migration including test (#4981)
* Deproto MidGameAIClient: flip main entry point to protoless

Completes the deproto migration of MidGameAIClient by:
- Adding protoless chosenMidGameStrategicCommand
- Flipping the main entry point so proto delegates to protoless
- Adding protoless maybeMoveToRecruitCommand to CommandChoiceHelpers
- Adding protoless chosenFulfillEasyQuestsCommandProtoless
- Adding FactionUtils.neutralNeighbors helper
- Fixing test GameState constructors to include currentPhase

The proto versions now delegate to protoless versions via
GameStateConverter.fromProto(), eliminating proto usage in the
main command selection logic.

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

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

* Remove proto deps from MidGameAIClient and make test protoless

- Remove all protobuf dependencies from MidGameAIClient BUILD.bazel
- Delete legacy proto-based methods from MidGameAIClient.scala (now fully protoless)
- Convert MidGameAIClientTest to construct Scala GameState directly
- Add helper methods for creating test fixtures (makeGameState, makeFaction, etc.)
- Add visibility for battalion/concrete and state packages for test access

🤖 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-02 10:31:52 -08:00
d9893edcb1 Expose port 40033 for Go auth service in nginx (v2) (#4987)
Uses dynamic DNS resolution to avoid startup dependency on auth container.
Previous version failed because static upstreams resolve at nginx startup.

Changes:
- nginx.conf: Add server block on port 40033 with variable-based grpc_pass
- nginx.conf: Add /health endpoint on port 40033
- docker-compose: Expose 40033 on nginx container

Key fix: Using `set $auth_backend "auth:40033"; grpc_pass grpc://$auth_backend;`
instead of static upstream, so DNS resolution happens at request time
(cached by resolver for 10s) rather than at nginx startup.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 10:25:22 -08:00
adminandGitHub 5d4719ea19 Revert "Expose port 40033 for Go auth service in nginx (#4983)" (#4986)
This reverts commit e63a8489b6.
2026-01-02 08:43:41 -08:00
e63a8489b6 Expose port 40033 for Go auth service in nginx (#4983)
Adds nginx server block to listen on port 40033 and route gRPC
traffic to the Go auth service. This enables Phase 2 clients
to connect directly to the auth service.

Changes:
- nginx.conf: Add auth_grpc upstream and server block on port 40033
- docker-compose: Expose 40033 on nginx, add auth dependency

This is safe to deploy before Phase 2 clients - old clients still
use the Eagle proxy through port 443.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:24:59 -08:00
9b190254a4 Separate auth service into its own CI/CD workflow (#4985)
Auth service now has independent deployment lifecycle:
- Only triggers on authservice/** or auth proto changes
- Deploys only the auth container (no Eagle restart)
- Preserves in-memory OAuth state during Eagle deploys

docker_build.yml changes:
- Remove build-auth job
- Preserve existing AUTH_IMAGE in .env
- Only force-recreate eagle, shardok, admin, jfr-sidecar
- Auth container starts but isn't force-recreated

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:18:22 -08:00
eb2677bd84 Add auth service to CI/CD pipeline (#4984)
The deployment was failing because AUTH_IMAGE wasn't being built or
passed to the deploy script. This adds:

- build-auth job to build and push the Go auth service image
- AUTH_IMAGE to deploy job dependencies and env vars
- Auth image pulling in deploy script
- GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET env vars

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 08:11:50 -08:00
23d2cb3968 Extract OAuth to Go service (Phase 1) (#4980)
* Extract OAuth to Go service (Phase 1)

Move OAuth authentication handling from Eagle Scala server into a separate Go
service running in its own container. This simplifies Eagle, enables independent
deployment, and sets up for future JWT validation extraction.

Architecture:
- Go auth service handles OAuth flows, JWT creation, state management
- Eagle proxies Auth gRPC calls to Go service when configured
- Go service calls Eagle's InternalUserService for user persistence
- Shared JWT keys via volume mount

New files:
- src/main/go/net/eagle0/authservice/ - Go auth service
- auth_internal.proto - Internal gRPC for Go→Eagle communication
- InternalUserServiceImpl.scala - User service wrapper for internal gRPC
- ExternalAuthClient.scala - Client for forwarding to Go service

Backward compatible: Eagle runs in standalone mode without --auth-service-url

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

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

* Add unit tests for Go auth service

- jwt_test.go: Tests for JWT token creation, validation, and refresh
- oauth_test.go: Tests for OAuth state management and status checking

🤖 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-02 07:32:02 -08:00
c66263cd56 Add protoless versions to 5 MidGameAIClient internal methods (#4979)
- Add protoless validateNoFactionLeaderAlone using FactionUtils
- Add protoless provincesWithFactionLeader using FactionUtils and ProvinceUtils
- Add protoless foodSurplus using ProvinceUtils.monthlyFoodSurplus
- Add protoless maybeChosenRaiseSupportCommand (removes fromProto calls)
- Add protoless chosenAttackCommandWithReconInfo using Scala BattalionView
- Proto versions now delegate to protoless versions via fromProto conversion
- Update BUILD.bazel visibility for BattalionViewConverter and BattalionView

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 06:42:27 -08:00
0ba3e0e5ae Fix missing dependency for SimpleTimedLogger in oauth_service (#4978)
Add simple_timed_logger dependency that was missing after adding
diagnostic logging in PR #4974.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 05:37:12 -08:00
860d0f3c5f Add protoless versions to MidGameAIClient relay supplies and recon commands (#4977)
- Add protoless maybeChosenRelaySuppliesCommand using FoodConsumptionUtils
  and ProvinceUtils instead of Legacy versions
- Add protoless selectedReconCommand and maybeChosenReconCommand using
  Scala Date extensions and FactionRelationshipC
- Update chosenMidGameCommand to use protoless versions via fromProto conversion
- Add required imports and BUILD.bazel dependencies

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 05:30:09 -08:00
13f79c37bc Add lobby status display and update OAuth documentation (#4976)
* Update OAUTH_NEXT_STEPS.md with current status and remaining work

- Mark completed items (headshots, logout, lobby display, etc.)
- Add new issues discovered (intermittent expired errors, token expiry bug)
- Reorganize implementation plan into Phase 2 (remaining) and Phase 3 (nice-to-haves)
- Update status of all known issues

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

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

* Wire up lobby UI elements in Unity scene

- Connect logout button
- Connect lobbyEnvironmentText and lobbyUserText fields

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

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

* Add lobby environment and user display fields to ConnectionHandler

- Add lobbyEnvironmentText and lobbyUserText fields
- Add UpdateLobbyStatusDisplays() to populate them when entering lobby
- Shows OAuth DisplayName or classic login username

🤖 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-01 21:59:03 -08:00
87cd58f7a4 Add protoless maybeChosenTravelToRecruitCommand (#4975)
- Add protoless helper methods in CommandChoiceHelpers:
  - isInterestingUHC: uses Scala UnaffiliatedHeroT and RecruitmentInfo
  - hasInterestingUHC: uses Scala GameStateC
  - maybeChosenTravelToRecruitCommand: protoless overload
- Update MidGameAIClient to use protoless version via fromProto conversion
- Import RecruitmentInfo and UnaffiliatedHeroT for protoless checks

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:51:50 -08:00
e739e18d77 Add diagnostic logging to OAuth flow (#4974)
* Add diagnostic logging to OAuth flow for debugging expired errors

Adds detailed logging to trace OAuth state through the flow:
- getAuthUrl: logs state creation and map sizes
- checkStatus: logs non-pending results with map state
- handleCallback: logs entry, success, and error cases

This will help diagnose why clients sometimes get 'expired' errors
even when the OAuth callback succeeds on the server.

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

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

* Fix expiresAt to return access token expiry instead of refresh token expiry

The CheckOAuthStatusResponse.expiresAt field was returning the refresh
token expiry (30 days) but clients interpret this as the access token
expiry (7 days). This fix calculates the correct access token expiry.

🤖 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-01 21:48:39 -08:00
c03daf100c Add protoless version to MidGameAIClient.chosenMidGameCommand (#4973)
- Add protoless overload that takes native GameState
- Rename proto GameState import to GameStateProto for clarity
- Update AIClient to use protoless version directly
- Remove unused GameStateConverter import from AIClient
- Update BUILD.bazel dependencies

This continues the deproto migration by moving the toProto() conversion
from AIClient into MidGameAIClient, making the public API protoless.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:38:25 -08:00
6519850ec3 Add logout button to lobby for OAuth users (#4967)
Add a logoutButton field that can be wired in Unity, along with
OnLogoutClicked handler that:
- Clears OAuth tokens via OAuthManager.LogoutAsync()
- Disposes gRPC connection and HTTP client
- Returns to connection screen
- Re-shows appropriate auth panel

This allows OAuth users who are auto-logged in to return to the
connection screen to use a different account or auth method.

Note: The button still needs to be added in Unity and wired to
the logoutButton field.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:37:01 -08:00
df785f557b Eliminate toProto() call in AIClient.chooseCommand (#4972)
- Change chooseFrom to accept Scala GameState instead of proto
- Use protoless CommandChooser with protoless helper methods
- Update chooseMidGameCommandFrom to accept Scala GameState
- Move toProto() conversion to MidGameAIClient call only (mid-game path)
- Use protoless EarlyGameAIClient methods (early game path)

This eliminates the unconditional toProto() conversion in chooseCommand,
now only converting when needed for MidGameAIClient which still uses proto.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:22:29 -08:00
224dc3bc13 Add protoless overloads to ResolveDiplomacyCommandSelector (#4970)
- Add protoless version of resolveDiplomacySelectedCommand using
  CommandChooser instead of LegacyCommandChooser
- Add protoless versions of all private helper methods that use
  Scala GameState and extract factions/provinces from it
- Rename proto GameState import to GameStateProto to disambiguate
- For methods that don't use gameState (ransom, break alliance),
  delegate to Core implementations to avoid code duplication

This enables callers to use Scala GameState directly without
conversion, preparing for elimination of toProto() call in
AIClient.chooseFrom.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:12:38 -08:00
5f3095dbd3 Fix NPE in SetUnitInfoLabels when grid not initialized (#4971)
Add null check for cells array in SetUnitInfoLabels, matching
the pattern used in other methods like ClearCellLabels.

This fixes a race condition where model updates arrive before
the hex grid is fully initialized on the first battle.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:11:59 -08:00
fb12810105 Use public CDN for headshot fetching (#4966)
Switch from server-mediated headshot fetching (eagle0.net with auth)
to direct CDN access (eagle0-headshots.sfo3.cdn.digitaloceanspaces.com).

- No auth required (bucket is public)
- No server-side changes needed
- Works identically for QA, prod, and local testing
- Removes coupling between headshot fetching and game server

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 21:11:33 -08:00
fe2a5b0869 Add protoless overloads for AI command selection helpers (#4969)
- Add protoless overloads for handleCapturedHeroesSelectedCommand,
  resolvePleaseRecruitMeSelectedCommand, and freeForAllDecisionSelectedCommand
- These methods don't actually use gameState, so the proto versions now
  delegate to core implementations that don't take gameState
- Simplify freeForAllDecisionSelectedCommand to not use CommandChooser
  since the inner methods don't use gameState

This prepares the groundwork for eliminating toProto() calls in AIClient.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:45:29 -08:00
3f00ba4a03 Eliminate toProto() calls in CommandChoiceHelpers protoless overloads (#4968)
- Add protoless overloads for helper methods:
  - maybeChosenFeastCommand, maybeChosenGiftCommand
  - maybeSpreadIntoOwnedProvince, maybeSpreadIntoEmptyProvince
  - maybeChosenGetUnderHeroCapCommand
  - maybeChosenAttackToSaveLeaderCommand
  - maybeRansomLeaderCommand, chosenRescueLeaderCommand

- Update protoless overloads to use native implementations:
  - chosenLoyaltyManagementCommand now uses CommandChooser directly
  - chosenRescueLeaderIfAllPrisonersCommand uses leaderIds and
    UnaffiliatedHeroType.Prisoner instead of proto equivalents

- Add quest dependency to BUILD.bazel (required for UnaffiliatedHeroT)

This eliminates all GameStateConverter.toProto() calls from
CommandChoiceHelpers.scala protoless overloads.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 18:32:21 -08:00
3dd06c0aba Fix JWT auth to set legacy userName for backwards compatibility (#4964)
* Fix JWT auth to set legacy userName for backwards compatibility

When using JWT authentication, contextWithJwtClaims was not setting
the legacy userNameCtxKey, causing AuthorizationUtils.userName to
return null. This broke game management code that relies on userName
for mapping users to factions.

Fix by also setting userNameCtxKey to displayName when using JWT auth,
ensuring backwards compatibility with existing game management code.

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

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

* Add OAuth implementation next steps and design doc

Comprehensive design document covering:
- Known issues (identity fragility, headshots, logout, uniqueness)
- Proposed user identity model with userId as stable key
- Multi-provider account linking strategy
- Avatar/headshot strategy
- Phased implementation plan
- Technical debt and open questions

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

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

* Update OAuth design doc with headshot investigation

- Clarify that headshots are AI-generated character portraits, not user avatars
- Document headshot architecture: client → eagle0.net (home Mac) → S3 signed URL
- Explain why OAuth breaks headshots: eagle0.net nginx only validates Basic Auth
- Clarify PR #4964 is required now (fixes admin server NPE crash)
- Phase 2 migration to userId-based identity deferred

🤖 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-01 17:45:12 -08:00
d95f65d9b4 Add protoless overloads to CommandChoiceHelpers travel commands (#4965)
- Add protoless chosenBuyFoodCommand using protoless foodAmountToBuy
- Add protoless maybeChosenDivineCommand using HeroUtils.power
- Add protoless maybeChosenArmTroopsCommand using ProvinceUtils
- Add protoless maybeChosenRecruitCommand and chosenReturnCommand (impl helpers)
- Convert chosenCommandWhileTraveling to native protoless implementation
  using CommandChooser with protoless choosers

This eliminates wasteful GameStateConverter.toProto() conversions when
EarlyGameAIClient calls chosenCommandWhileTraveling with Scala GameState.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:24:08 -08:00
a7c1146c41 Add protoless overloads to EarlyGameAIClient (#4963)
- Add protoless isEarlyGame(GameState, FactionId) using FactionUtils and ProvinceUtils
- Add protoless chooseEarlyGameCommand using CommandChooser with protoless choosers
- Update BUILD.bazel with required deps (FactionUtils, ProvinceUtils, GameState)
- Add exports for GameState to support downstream callers

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:05:11 -08:00
994 changed files with 61035 additions and 83047 deletions
+179
View File
@@ -0,0 +1,179 @@
name: Auth Service Build and Deploy
on:
push:
branches: [ "main" ]
paths:
- 'src/main/go/net/eagle0/authservice/**'
- 'src/main/go/net/eagle0/authcli/**'
- 'src/main/protobuf/net/eagle0/eagle/api/auth.proto'
- 'src/main/protobuf/net/eagle0/eagle/api/admin/**'
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'ci/BUILD.bazel'
- '.github/workflows/auth_build.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to container registry'
required: true
default: 'false'
type: boolean
permissions:
contents: read
jobs:
build-auth:
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Auth Server Docker image
id: build-auth
run: |
set -ex
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:auth_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/auth_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 Auth image to DO registry
id: push-auth
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
AUTH_IMAGE="${{ steps.build-auth.outputs.image_path }}"
echo "Using Auth image: $AUTH_IMAGE"
if [ -z "$AUTH_IMAGE" ] || [ ! -d "$AUTH_IMAGE" ]; then
echo "ERROR: Auth image not found at: $AUTH_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:auth_server_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_auth_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/auth-server:${GIT_SHA}"
echo "Pushing auth image: $IMAGE_TAG"
$CRANE push "$AUTH_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/auth-server:latest"
deploy:
runs-on: ubuntu-latest
needs: [build-auth]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
AUTH_IMAGE: ${{ needs.build-auth.outputs.image_tag }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
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:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -x
cd /opt/eagle0
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"AUTH_IMAGE=${AUTH_IMAGE}" \
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
echo "Deploying auth service: $AUTH_IMAGE"
# Use crane to pull image
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
echo "Pulling Auth image with crane..."
./crane pull "${AUTH_IMAGE}" auth.tar || { echo "ERROR: Failed to pull auth image"; exit 1; }
echo "Loading Auth image into Docker..."
docker load -i auth.tar
rm auth.tar
rm ./crane
# Only recreate the auth container (not eagle, shardok, etc.)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
# Wait for health check
sleep 5
docker compose -f docker-compose.prod.yml ps auth
# Verify container is using correct image
echo "=== Verifying auth container image ==="
docker compose -f docker-compose.prod.yml images auth
# Cleanup old images
docker image prune -f
+1 -1
View File
@@ -26,7 +26,7 @@ permissions:
jobs:
test:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
-21
View File
@@ -1,21 +0,0 @@
name: Build Protos
on:
pull_request:
paths:
- "src/main/protobuf/**"
permissions:
contents: read
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Run tests
run: ./scripts/build_protos.sh
+79 -8
View File
@@ -8,12 +8,22 @@ on:
required: true
default: 'v2'
type: string
architecture:
description: 'Target architecture'
required: true
default: 'amd64'
type: choice
options:
- amd64
- arm64
- both
permissions:
contents: read
jobs:
build-sysroot:
build-sysroot-amd64:
if: ${{ inputs.architecture == 'amd64' || inputs.architecture == 'both' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
@@ -25,7 +35,7 @@ jobs:
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot
name: ubuntu-noble-sysroot-amd64
path: tools/sysroot/output/
- name: Install AWS CLI
@@ -41,26 +51,87 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces (using eagle0-windows bucket, same as other workflows)
# 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 "=== Sysroot uploaded ==="
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "=== AMD64 Sysroot uploaded ==="
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:
if: ${{ inputs.architecture == 'arm64' || inputs.architecture == 'both' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU for ARM64 emulation
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build ARM64 sysroot
run: ./tools/sysroot/build_sysroot_arm64.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot-arm64
path: tools/sysroot/output/
- name: Install AWS CLI
run: |
if ! command -v aws &> /dev/null; then
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install
fi
- name: Upload to DigitalOcean Spaces
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces
aws s3 cp tools/sysroot/output/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-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-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-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
echo ")"
-35
View File
@@ -1,35 +0,0 @@
name: Client Presigner
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
pull_request:
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
permissions:
contents: read
jobs:
client-presigner:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Build Client Presigner
run: bazel build --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //src/main/go/net/eagle0/client_download
- name: Archive presigner binary
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: client_download
path: bazel-bin/src/main/go/net/eagle0/client_download/client_download_/client_download
@@ -1,24 +0,0 @@
name: Deploy OAuth Relay Worker
on:
push:
branches:
- main
paths:
- 'cloudflare/oauth-relay/**'
workflow_dispatch: # Allow manual trigger
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Workers
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy Worker
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
workingDirectory: cloudflare/oauth-relay
+207 -440
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
# Note: C++ changes trigger shardok_arm64_build.yml instead
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
@@ -22,30 +22,63 @@ on:
default: 'false'
type: boolean
# Only allow one deployment at a time to prevent race conditions
concurrency:
group: docker-build-deploy
cancel-in-progress: false # Don't cancel running deployments, queue new ones
permissions:
contents: read
jobs:
build-eagle:
runs-on: self-hosted
# Single consolidated build job - builds all images with one bazel invocation
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
build-all:
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
eagle_image_tag: ${{ steps.push-images.outputs.eagle_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
- name: Build all Docker images
id: build-all
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
# Build ALL images in a single bazel command - Bazel parallelizes internally
# Note: Shardok is built separately for ARM64 and deployed to Hetzner
echo "=== Building Docker images ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:eagle_server_image \
//ci:admin_server_image \
//ci:jfr_sidecar_image \
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
# 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
# Save all image paths before any other bazel command changes bazel-bin symlink
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
ADMIN_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
JFR_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
echo "=== Image paths ==="
echo "Eagle: $EAGLE_PATH"
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
@@ -55,381 +88,70 @@ 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 Eagle image to DO registry
id: push-eagle
- 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
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
echo "Using Eagle image: $EAGLE_IMAGE"
GIT_SHA=$(git rev-parse --short=8 HEAD)
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
# Get crane from push target 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 ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
# 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 ==="
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 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
# Debug: show what bazel-bin points to
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
# 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"
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 ==="
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)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
echo "This means pkg_tar is packaging the wrong binary."
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- 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 Shardok image to DO registry
id: push-shardok
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"
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.
bazel build //ci:eagle_server_push
# 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)
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 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
# 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 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
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
- 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, bazel]
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 }}
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 }}
@@ -438,108 +160,153 @@ jobs:
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
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
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
# Write env vars to .env file for docker-compose
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}
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:-}
EOF
chmod 600 .env
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
ADMIN_IMAGE="${ADMIN_IMAGE}"
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
OPENAI_API_KEY="${OPENAI_API_KEY}"
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
SENTRY_DSN="${SENTRY_DSN}"
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# 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
# Use exact image tags passed from build jobs (no :latest fallback)
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
# Update env vars
chmod +x deploy/update-env.sh
cd deploy && ./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
"SENTRY_DSN=\${SENTRY_DSN}" \
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
cd ..
# 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
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
# 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
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
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
# 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
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
# 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 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 Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
rm ./crane
echo "Pulling JFR Sidecar image..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
# Pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
echo "All images pulled successfully"
# Force recreate containers to ensure new image is used
docker compose -f docker-compose.prod.yml up -d --force-recreate --remove-orphans
# Stop local shardok container if running (now runs on Hetzner)
docker stop shardok-server 2>/dev/null || true
docker rm shardok-server 2>/dev/null || true
# 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 restart nginx
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
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}"
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Ensure auth is running
docker compose -f docker-compose.prod.yml up -d auth
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Verify
sleep 10
docker compose -f docker-compose.prod.yml ps
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
+35 -6
View File
@@ -10,13 +10,14 @@ on:
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
workflow_dispatch:
permissions:
contents: read
jobs:
build-installer:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
steps:
- uses: actions/checkout@v4
@@ -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
+280
View File
@@ -0,0 +1,280 @@
name: Mac Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "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_submit.sh"
- "scripts/notarize_wait.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/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "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_submit.sh"
- "scripts/notarize_wait.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:
build-and-sign:
runs-on: [self-hosted, macOS, unity-mac]
outputs:
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
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: Check if should deploy
id: check-deploy
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.skip_signing }}" == "true" ]]; then
echo "should_deploy=false" >> $GITHUB_OUTPUT
elif [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "should_deploy=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "should_deploy=true" >> $GITHUB_OUTPUT
else
echo "should_deploy=false" >> $GITHUB_OUTPUT
fi
- name: Import Code Signing Certificate
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
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() && steps.check-deploy.outputs.should_deploy == 'true'
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: Submit for Notarization
id: notarize-submit
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_submit.sh
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
- name: Zip signed app for artifact
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
run: |
cd /tmp/eagle0/eagle0MAC
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload signed app
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
uses: actions/upload-artifact@v4
with:
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
wait-notarization:
needs: build-and-sign
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, notarize]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
with:
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Debug - List download directory
run: |
echo "=== Contents of /tmp/eagle0/eagle0MAC ==="
ls -la /tmp/eagle0/eagle0MAC || echo "Directory does not exist"
echo "=== Find any zip files ==="
find /tmp/eagle0 -name "*.zip" 2>/dev/null || echo "No zip files found"
- name: Unzip signed app
run: |
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
- name: Wait for Notarization and Staple
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_wait.sh
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Zip notarized app for artifact
run: |
cd /tmp/eagle0/eagle0MAC
rm -f eagle0.app.zip
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload notarized app
uses: actions/upload-artifact@v4
with:
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
deploy:
needs: [build-and-sign, wait-notarization]
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # For version numbering
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
with:
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Debug - List download directory
run: |
echo "=== Contents of /tmp/eagle0/eagle0MAC ==="
ls -la /tmp/eagle0/eagle0MAC || echo "Directory does not exist"
- name: Unzip notarized app
run: |
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
- name: Deploy Mac Build
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"
-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
+92
View File
@@ -0,0 +1,92 @@
name: Cleanup Old Container Images
on:
schedule:
# Run daily at 3am UTC
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (show what would be deleted without deleting)'
required: true
default: 'true'
type: boolean
permissions:
contents: read
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DO_REGISTRY_TOKEN }}
- name: Cleanup old images
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }}
run: |
set -e
RETENTION_DAYS=5
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%s)
REGISTRY="eagle0"
echo "Cleaning up images older than ${RETENTION_DAYS} days"
echo "Cutoff date: $(date -d "@${CUTOFF_DATE}" -Iseconds)"
echo "Dry run: ${DRY_RUN}"
echo ""
# List of repositories to clean
# 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
# 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"
continue
fi
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
continue
fi
# Parse the date
MANIFEST_DATE=$(date -d "$UPDATED_AT" +%s 2>/dev/null || echo "0")
# Skip protected tags (latest, arm64-latest)
if echo "$TAGS" | grep -qE '(^|,)(latest|arm64-latest)(,|$)'; then
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
continue
fi
# Check if older than cutoff
if [ "$MANIFEST_DATE" -lt "$CUTOFF_DATE" ]; then
echo " DELETE: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
if [ "$DRY_RUN" != "true" ]; then
doctl registry repository delete-manifest "${REPO}" "$DIGEST" --force
fi
else
echo " KEEP: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
fi
done
echo ""
done
- name: Run garbage collection
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
run: |
echo "Starting garbage collection..."
doctl registry garbage-collection start --force
echo "Garbage collection started. It may take a few minutes to complete."
+221
View File
@@ -0,0 +1,221 @@
name: Shardok ARM64 Build and Push
on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/protobuf/net/eagle0/shardok/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'src/main/resources/net/eagle0/shardok/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- '.github/workflows/shardok_arm64_build.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to container registry'
required: true
default: 'true'
type: boolean
permissions:
contents: read
jobs:
build-shardok-arm64:
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
run: |
set -ex
echo "=== Building shardok-server binary for linux-aarch64 ==="
bazel build \
--platforms=//:linux_arm64 \
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
echo "=== Checking binary at: $LINUX_BIN ==="
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 1
fi
# 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"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
# Check if it's ARM64 (e_machine = 0xB7 = 183 for aarch64)
E_MACHINE=$(od -An -j18 -N2 -tx2 "$LINUX_BIN" | tr -d ' ')
echo "ELF e_machine: $E_MACHINE"
if [ "$E_MACHINE" = "b700" ]; then
echo "SUCCESS: Binary is ARM64 (aarch64)"
else
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
fi
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
exit 1
else
echo "WARNING: Unknown binary format: $MAGIC"
file "$LINUX_BIN" || true
fi
- name: Build Shardok ARM64 Docker image
id: build-shardok
run: |
set -ex
bazel build \
--platforms=//:linux_arm64 \
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
//ci:shardok_server_image_arm64
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image_arm64)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Verify the binary inside the tar layer is ARM64 ELF
echo "=== Verifying binary in image tar ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer_arm64.tar"
if [ -f "$BINARY_TAR" ]; then
echo "Checking binary in $BINARY_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)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
- name: Push Shardok ARM64 image to DigitalOcean
id: push-shardok
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
run: |
set -ex
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
echo "Using cross-compiled image: $CROSS_IMAGE"
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
exit 1
fi
# Build a push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Find the Darwin crane binary
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
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found"
exit 1
fi
echo "Using crane: $CRANE"
# Push with arm64-prefixed SHA tag (same repo as x86, different tag)
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:arm64-${GIT_SHA}"
echo "Pushing shardok ARM64 image: $IMAGE_TAG"
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :arm64-latest tag for convenience
echo "Copying to :arm64-latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
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, bazel]
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
+1 -1
View File
@@ -28,7 +28,7 @@ permissions:
jobs:
build:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
+30 -6
View File
@@ -6,7 +6,11 @@ on:
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
@@ -15,12 +19,16 @@ on:
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
@@ -29,14 +37,13 @@ on:
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/**/BUILD.bazel"
permissions:
contents: read
jobs:
windows-unity:
runs-on: self-hosted
runs-on: [self-hosted, macOS, unity-windows]
steps:
- uses: actions/checkout@v4
@@ -63,7 +70,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/
-7
View File
@@ -35,10 +35,3 @@ repos:
entry: ./scripts/pre-commit-gazelle.sh
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
pass_filenames: false
- repo: local
hooks:
- id: update-action-result-types
name: update-action-result-types
language: system
entry: ./scripts/updateActionResultTypes.sh
files: 'src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto'
+9
View File
@@ -12,6 +12,15 @@ platform(
],
)
# Platform for cross-compiling to Linux ARM64
platform(
name = "linux_arm64",
constraint_values = [
"@platforms//os:linux",
"@platforms//cpu:aarch64",
],
)
gazelle(name = "gazelle")
# gazelle:proto file
+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
+463 -25
View File
@@ -70,43 +70,126 @@ When migrating a file:
### Fully Protoless
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
### Blocked (still uses proto GameState)
### AI Layer ✅ COMPLETE
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
- Depends on many Legacy* utils
- Central hub called by many command selectors
- [ ] `AttackDecisionCommandChooser` - uses proto GameState, converts internally
- [ ] `CommandChooser` - trait uses proto GameState in signature
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
- Called by `MidGameAIClient` which uses proto GameState
All AI and command chooser code is now fully protoless:
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
- [x] `CommandChooser` - trait uses Scala GameState
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
- [x] `MidGameAIClient` - uses Scala GameState internally
### Still Using Proto GameState (Boundary Code)
These files use proto GameState because they're at system boundaries:
**View Filters (client projection):**
- `view_filters/GameStateViewFilter` - has Scala overload, uses Scala sub-filters
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
- `view_filters/FactionViewFilter` - has Scala overload
- `view_filters/HeroViewFilter` - has Scala overload
- `view_filters/BattalionNameFilter` - has Scala overload
- `view_filters/BattleFilter` - has Scala overload
**Legacy Utilities (to be deprecated):**
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
- Used by code that still needs proto GameState
**Persistence/Action System:**
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
- `ActionWithResultingState` - caches both proto and Scala state
**Shardok Interface (gRPC boundary):**
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
## Next Steps
### Phase 1: CommandChoiceHelpers Migration
### Phase 1-3: AI Layer ✅ COMPLETE
The main blocker is `CommandChoiceHelpers.scala` which uses proto `GameState` extensively. Strategy:
The entire AI decision-making layer is now protoless.
1. **Add Scala overloads** to `CommandChoiceHelpers` methods that currently take proto GameState
2. **Update internal helpers** to use Scala types where possible
3. **Migrate callers incrementally** - command selectors that are already protoless can switch to Scala overloads
### Phase 4: View Filters ✅ COMPLETE
### Phase 2: CommandChooser Trait
The view_filters package migration is complete:
Once CommandChoiceHelpers is protoless:
**Completed:**
- [x] `GameStateViewFilter` - added Scala GameState overload (uses Scala sub-filters)
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
- [x] `HeroViewFilter` - added Scala overload
- [x] `FactionViewFilter` - added Scala overload
- [x] `Visibility` - added Scala overloads
1. Add Scala `GameState` overload to `CommandChooser.choose()` method
2. Update implementations (`AttackDecisionCommandChooser`, etc.) to use Scala internally
3. Eventually deprecate proto overloads
**Still Using Proto:**
- [x] `BattalionNameFilter` - has Scala overload
- [x] `BattleFilter` - has Scala overload
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
### Phase 3: MidGameAIClient
**Strategy:**
1. Add Scala GameState overloads to view filter methods
2. Update callers to pass Scala GameState where available
3. Eventually deprecate proto versions
The top-level AI client still uses proto GameState. Once lower layers are protoless:
### Phase 5: Legacy Utility Cleanup (IN PROGRESS)
1. Convert `MidGameAIClient` to use Scala GameState internally
2. Only convert at the boundary when receiving from/sending to gRPC
Remove Legacy* utilities by migrating remaining callers:
1. Identify callers of each Legacy* util
2. Update callers to use protoless versions
3. Delete Legacy* files when no longer needed
**Deleted (no production callers):**
- [x] `LegacyProvinceDistances` - deleted (no callers)
- [x] `LegacyBattalionSuitability` - deleted (no callers)
- [x] `LegacyFoodConsumptionUtils` - deleted (no callers)
- [x] `LegacyHandleRiotUtils` - deleted (no callers)
**Refactored to Thin Wrappers (delegating to protoless versions):**
- [x] `LegacyRansomValidity` - already a thin wrapper delegating to `RansomValidity`
- [x] `LegacyRecruitmentOdds` - refactored to delegate to `RecruitmentOdds`
**Parallel Implementations (proto mirrors protoless):**
- [x] `FactionUtils` / `LegacyFactionUtils` - both have matching APIs; LegacyFactionUtils used by boundary code (24+ callers)
- [x] `HeroUtils` / `LegacyHeroUtils` - both have matching APIs; LegacyHeroUtils used by boundary code (10 callers)
- [x] `ProvinceUtils` / `LegacyProvinceUtils` - both have matching APIs; LegacyProvinceUtils used by boundary code (20 callers: availability factories, view filters)
**Parallel Implementations (awaiting migration of callers):**
- [x] `BattalionUtils` / `LegacyBattalionUtils` - both have matching core methods; LegacyBattalionUtils used by boundary code (4 callers)
- [x] `BattalionViewFilter` / `LegacyBattalionViewFilter` - protoless version exists; Legacy used by view filters, action appliers (3 callers)
- [x] `BattalionTypeFinder` / `LegacyBattalionTypeFinder` - protoless version exists; Legacy used by validators (1 caller: RuntimeValidator)
### Recent Caller Migration
**CheckForFulfilledQuestsAction** - migrated to use protoless `BattalionTypeFinder`:
- Changed `battalionTypes` parameter from proto `Vector[BattalionType]` to Scala `Vector[BattalionType]`
- Updated callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
- Eliminated wasteful `BattalionTypeConverter.toProto()` conversions
**ExpandedUnaffiliatedHeroUtils** - added Scala overload:
- New overload takes Scala `GameState` and `UnaffiliatedHeroT` directly
- Added `UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto()` helper for efficient enum conversion
- Proto overload retained for backward compatibility
**AvailablePleaseRecruitMeCommandFactory** - eliminated wasteful proto conversions:
- Now uses `ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)` directly
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
- Factory is now fully protoless internally (still returns proto types for API boundary)
**ProvinceViewFilter** - added Scala overload with faction filtering:
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
- Scala overload now fully protoless internally
- Uses the new ProvinceViewFilter Scala overload with faction filtering
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
## Key Files
@@ -123,6 +206,361 @@ The top-level AI client still uses proto GameState. Once lower layers are protol
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) currently uses proto types extensively
- `PerformUnaffiliatedHeroesAction` already uses protoless `GameState`
- Migration should proceed incrementally: utilities first, then higher-level selectors/choosers
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
---
## Proto Import Inventory (library/)
**Total: 149 proto imports across 51 files** (as of 2026-01-13)
### Proto Dependencies by BUILD.bazel (unique deps)
#### library/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/api:action_result_view_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/storage:action_result_scala_proto`
#### library/actions/impl/action/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_order_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
#### library/actions/impl/common/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
#### library/actions/llm_prompt_generators/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:event_for_chronicle_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto`
#### library/actions/llm_request_generators/diplomacy_llm_helpers/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
#### library/settings/loaders/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto`
#### library/util/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:army_stats_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:date_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_event_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:hero_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:stat_with_condition_scala_proto`
#### library/util/command_choice_helpers/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:battalion_with_food_cost_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:combat_unit_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_scala_proto`
#### library/util/command_choice_helpers/quest_command_selectors/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:improvement_type_scala_proto`
#### library/util/faction_utils/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
#### library/util/quest_creation/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
#### library/util/quest_fulfillment/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_scala_proto`
#### library/util/view_filters/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_event_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:army_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_relationship_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:hero_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:incoming_army_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:shardok_battle_view_scala_proto`
---
### Summary by Directory
| Directory | Files | Imports | Status |
|-----------|-------|---------|--------|
| `actions/availability/` | 0 | 0 | ✅ Clean |
| `actions/impl/action/` | 2 | 3 | Boundary code |
| `actions/impl/common/` | 1 | 2 | Boundary code |
| `actions/llm_prompt_generators/` | 24 | 46 | LLM request types |
| `actions/llm_request_generators/` | 1 | 2 | LLM request types |
| `settings/loaders/` | 1 | 1 | Loader (expected) |
| `util/` | 9 | 20 | Mixed - cleanup candidates |
| `util/command_choice_helpers/` | 17 | 57 | API types |
| `util/view_filters/` | 1 | 1 | View boundary |
| Root (`library/`) | 3 | 5 | Boundary code |
---
### Detailed Inventory
#### Root library/ (5 imports, 3 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ActionResultFilter.scala` | 3 | `common.action_result_notification_details.Notification` | Action result filtering |
| `ActionResultFilter.scala` | 4 | `common.action_result_type.ActionResultType` | |
| `ActionResultFilter.scala` | 5 | `common.action_result_type.ActionResultType.*` | |
| `Engine.scala` | 6 | `internal.action_result.ActionResult` | Trait interface |
| `EngineImpl.scala` | 8 | `internal.action_result.ActionResult` | Returns proto for persistence |
#### actions/impl/action/ (3 imports, 2 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ChronicleEventGenerator.scala` | 3 | `common.action_result_type.ActionResultType.FACTION_DESTROYED` | Single enum value |
| `PerformVassalCommandsPhaseAction.scala` | 5 | `api.available_command.{AvailableCommand, RestAvailableCommand}` | Commands from factory |
| `PerformVassalDefenseDecisionsAction.scala` | 4 | `api.available_command.AvailableCommand` | Commands from factory |
#### actions/impl/common/ (2 imports, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ActionWithResultingState.scala` | 3 | `internal.action_result.ActionResult` | Caches both proto and Scala |
| `ActionWithResultingState.scala` | 4 | `internal.game_state.GameState` | |
#### actions/llm_prompt_generators/ (46 imports, 24 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.AllianceOfferMessage` | LLM request type |
| `AllianceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `AllianceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `AllianceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.AllianceOfferResolutionMessage` | |
| `BattalionDescriptions.scala` | 3 | `common.battalion_type.BattalionTypeId` | |
| `BattalionDescriptions.scala` | 4 | `internal.battalion.Battalion` | |
| `BreakAllianceMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.BreakAllianceMessage` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.BreakAllianceResolutionMessage` | |
| `CapturedHeroMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.{...}` | |
| `ChronicleEventTextGenerator.scala` | 4 | `internal.event_for_chronicle.{...}` | |
| `ChronicleEventTextGenerator.scala` | 31 | `internal.event_for_chronicle.ShatteredArmyEvent.Reason.{...}` | |
| `ChronicleUpdatePromptGenerator.scala` | 4 | `common.date.Date` | |
| `ChronicleUpdatePromptGenerator.scala` | 5 | `internal.event_for_chronicle.EventForChronicle` | |
| `ChronicleUpdatePromptGenerator.scala` | 6 | `internal.generated_text_request.ChronicleUpdateMessage` | |
| `DivineMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.DivineMessage` | |
| `ExileVassalPromptGenerator.scala` | 4 | `internal.generated_text_request.ExileVassalMessage` | |
| `GeneratorUtilities.scala` | 13 | `common.date.Date` | |
| `HandleCapturedHeroPleaPromptGenerator.scala` | 4 | `internal.generated_text_request.HandleCapturedHeroPlea` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 9 | `internal.event_for_hero_backstory.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 37 | `internal.event_for_hero_backstory.FoughtInBattleBackstoryEvent.UnitStatus` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 38 | `internal.event_for_hero_backstory.FoughtInBattleBackstoryEvent.UnitStatus.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 47 | `internal.generated_text_request.HeroBackstoryUpdateRequest` | |
| `HeroDeparturePromptGenerator.scala` | 4 | `internal.generated_text_request.HeroDepartureMessage` | |
| `HeroInitialBackstoryPromptGenerator.scala` | 4 | `internal.generated_text_request.HeroInitialBackstoryRequest` | |
| `InvitationMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.InvitationMessage` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.InvitationResolutionMessage` | |
| `NewFactionHeadPromptGenerator.scala` | 4 | `internal.generated_text_request.NewFactionHeadMessage` | |
| `PleaseRecruitMePromptGenerator.scala` | 4 | `internal.generated_text_request.PleaseRecruitMeMessage` | |
| `PrisonerExecutedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerExecutedMessage` | |
| `PrisonerExiledPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerExiledMessage` | |
| `PrisonerReleasedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerReleasedMessage` | |
| `PrisonerReturnedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerReturnedMessage` | |
| `ProfessionGainedPromptGenerator.scala` | 4 | `internal.generated_text_request.ProfessionGainedMessage` | |
| `QuestEndedGeneratorUtilities.scala` | 5 | `common.battalion_type.BattalionTypeId` | |
| `QuestEndedGeneratorUtilities.scala` | 6 | `common.unaffiliated_hero_quest.{...}` | |
| `QuestEndedGeneratorUtilities.scala` | 29 | `common.unaffiliated_hero_quest.QuestDetails.Empty` | |
| `QuestFailedPromptGenerator.scala` | 4 | `internal.generated_text_request.QuestFailedMessage` | |
| `QuestFulfilledPromptGenerator.scala` | 4 | `internal.generated_text_request.QuestFulfilledMessage` | |
| `RansomOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.RansomOfferMessage` | |
| `RansomResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `RansomResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `RansomResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.RansomResolutionMessage` | |
| `RecruitmentRefusedPromptGenerator.scala` | 4 | `internal.generated_text_request.RecruitmentRefusedMessage` | |
| `SuppressBeastsPromptGenerator.scala` | 4 | `internal.generated_text_request.{...}` | |
| `SwearBrotherhoodPromptGenerator.scala` | 3 | `common.gender.Gender.{...}` | |
| `SwearBrotherhoodPromptGenerator.scala` | 4 | `internal.generated_text_request.SwearBrotherhoodMessage` | |
| `TruceOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.TruceOfferMessage` | |
| `TruceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `TruceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `TruceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.TruceResolutionMessage` | |
#### actions/llm_request_generators/ (2 imports, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `DiplomacyResolutionLlmRequestGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `DiplomacyResolutionLlmRequestGenerator.scala` | 5 | `internal.generated_text_request.{...}` | |
#### settings/loaders/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `BattalionTypeLoader.scala` | 8 | `common.battalion_type.{BattalionType, BattalionTypeId}` | Loads from file, converts immediately |
#### util/ (20 imports, 9 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ArmyUtils.scala` | 3 | `internal.army.{Army, HostileArmyGroup, MovingArmy}` | Has Scala overloads |
| `ArmyUtils.scala` | 4 | `internal.game_state.GameState` | |
| `CommandSelection.scala` | 4 | `api.available_command.AvailableCommand` | Wraps proto command |
| `CommandSelection.scala` | 5 | `api.selected_command.SelectedCommand` | |
| `DateProtoUtils.scala` | 5 | `common.date.Date` | Conversion utility |
| `GameStateViewDiffer.scala` | 3 | `common.round_phase.NewRoundPhase` | View diff for client |
| `IDable.scala` | 4 | `internal.battalion.Battalion` | Test helper only |
| `IDable.scala` | 5 | `internal.faction.Faction` | |
| `IDable.scala` | 6 | `internal.hero.Hero` | |
| `IDable.scala` | 7 | `internal.province.Province` | |
| `IncomingArmyUtils.scala` | 4 | `api.command.util.army_stats.ArmyStats` | Returns proto type |
| `IncomingArmyUtils.scala` | 5 | `internal.army.{Army, MovingArmy}` | Has Scala overloads |
| `IncomingArmyUtils.scala` | 6 | `internal.game_state.GameState` | |
| `IncomingArmyUtils.scala` | 7 | `internal.province.Province` | |
| `MapGenerator.scala` | 6 | `internal.province.{Neighbor, Province}` | Map generation |
| `ProvinceEventUtils.scala` | 3 | `common.beast_info.BeastInfo` | |
| `ProvinceEventUtils.scala` | 4 | `common.province_event.*` | |
#### util/command_choice_helpers/ (57 imports, 17 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceOfferCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `AllianceOfferCommandSelector.scala` | 5 | `api.command.util.diplomacy_option.{AllianceOption, DiplomacyOption}` | |
| `AllianceOfferCommandSelector.scala` | 6 | `api.selected_command.DiplomacySelectedCommand` | |
| `AlmsCommandSelector.scala` | 4 | `api.available_command.{AlmsAvailableCommand, AvailableCommand}` | |
| `AlmsCommandSelector.scala` | 5 | `api.selected_command.AlmsSelectedCommand` | |
| `AttackCommandChooser.scala` | 4 | `api.available_command.*` | |
| `AttackCommandChooser.scala` | 5 | `api.selected_command.*` | |
| `AttackCommandChooser.scala` | 6 | `common.combat_unit.CombatUnit` | |
| `AttackDecisionCommandChooser.scala` | 5 | `api.available_command.{AttackDecisionAvailableCommand, AvailableCommand}` | |
| `AttackDecisionCommandChooser.scala` | 6 | `api.command.util.attack_decision_type.{...}` | |
| `AttackDecisionCommandChooser.scala` | 12 | `api.selected_command.AttackDecisionSelectedCommand` | |
| `AttackDecisionCommandChooser.scala` | 13 | `common.tribute_amount.TributeAmount` | |
| `AvailableCommandSelector.scala` | 6 | `api.available_command.AvailableCommand` | |
| `CombatUnitSelector.scala` | 5 | `common.combat_unit.CombatUnit` | |
| `CommandChoiceHelpers.scala` | 5 | `api.available_command.*` | |
| `CommandChoiceHelpers.scala` | 6 | `api.command.util.armed_battalion.ArmedBattalion` | |
| `CommandChoiceHelpers.scala` | 7 | `api.command.util.attack_decision_type.{AdvanceDecision, WithdrawDecision}` | |
| `CommandChoiceHelpers.scala` | 8 | `api.command.util.captured_hero_option.CapturedHeroOption.{...}` | |
| `CommandChoiceHelpers.scala` | 13 | `api.command.util.diplomacy_option.RansomOfferOption` | |
| `CommandChoiceHelpers.scala` | 14 | `api.selected_command.*` | |
| `CommandChoiceHelpers.scala` | 15 | `api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}` | |
| `CommandChoiceHelpers.scala` | 16 | `common.battalion_type.BattalionTypeId` | |
| `CommandChoiceHelpers.scala` | 17 | `common.combat_unit.CombatUnit` | |
| `CommandChoiceHelpers.scala` | 18 | `common.improvement_type.ImprovementType.INFRASTRUCTURE` | |
| `CommandChoiceHelpers.scala` | 19 | `common.tribute_amount.TributeAmount` | |
| `CommandChooser.scala` | 6 | `api.available_command.AvailableCommand` | |
| `ExileVassalCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, ExileVassalAvailableCommand}` | |
| `ExileVassalCommandSelector.scala` | 5 | `api.selected_command.ExileVassalSelectedCommand` | |
| `ExpandCommandSelector.scala` | 7 | `api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}` | |
| `ExpandCommandSelector.scala` | 8 | `api.selected_command.MarchSelectedCommand` | |
| `ExpandCommandSelector.scala` | 9 | `common.combat_unit.CombatUnit` | |
| `FulfillQuestsCommandSelector.scala` | 4 | `api.available_command.AvailableCommand` | |
| `HeroGiftCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, HeroGiftAvailableCommand}` | |
| `HeroGiftCommandSelector.scala` | 5 | `api.selected_command.HeroGiftSelectedCommand` | |
| `ImproveCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, ImproveAvailableCommand}` | |
| `ImproveCommandSelector.scala` | 5 | `api.selected_command.ImproveSelectedCommand` | |
| `ImproveCommandSelector.scala` | 6 | `common.improvement_type.ImprovementType` | |
| `ImproveCommandSelector.scala` | 7 | `common.improvement_type.ImprovementType.DEVASTATION` | |
| `OrganizeCommandSelector.scala` | 5 | `api.available_command.{AvailableCommand, OrganizeTroopsAvailableCommand}` | |
| `OrganizeCommandSelector.scala` | 6 | `api.selected_command.OrganizeTroopsSelectedCommand` | |
| `OrganizeCommandSelector.scala` | 7 | `api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}` | |
| `OrganizeCommandSelector.scala` | 8 | `common.battalion_type.BattalionTypeId` | |
| `RansomOfferHelpers.scala` | 3 | `common.diplomacy_offer.RansomOfferDetails` | |
| `RansomOfferHelpers.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `RansomOfferHelpers.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `SwearBrotherhoodCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, SwearBrotherhoodAvailableCommand}` | |
| `SwearBrotherhoodCommandSelector.scala` | 5 | `api.selected_command.SwearBrotherhoodSelectedCommand` | |
| `TruceOfferCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `TruceOfferCommandSelector.scala` | 5 | `api.command.util.diplomacy_option.{DiplomacyOption, TruceOption}` | |
| `TruceOfferCommandSelector.scala` | 6 | `api.selected_command.DiplomacySelectedCommand` | |
#### util/command_choice_helpers/quest_command_selectors/ (12 imports, 10 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceQuestCommandChooser.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `AllianceQuestCommandChooser.scala` | 5 | `api.command.util.diplomacy_option.AllianceOption` | |
| `AlmsAcrossRealmQuestCommandChooser.scala` | 2 | `api.available_command.AvailableCommand` | |
| `AlmsToProvinceQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
| `DismissSpecificVassalCommandChooser.scala` | 5 | `api.available_command.AvailableCommand` | |
| `GiveToHeroesAcrossRealmQuestCommandChooser.scala` | 2 | `api.available_command.AvailableCommand` | |
| `GiveToHeroesInProvinceQuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `ImproveQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
| `ImproveQuestCommandChooser.scala` | 4 | `common.improvement_type.ImprovementType.{...}` | |
| `QuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `TruceCountQuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `TruceWithFactionQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
#### util/view_filters/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ProvinceViewFilter.scala` | 3 | `common.province_event.ProvinceEvent` | For knownEvents field |
#### util/province/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ProvinceUtils.scala` | 4 | `common.battalion_type.BattalionType` | Single proto reference |
---
### Cleanup Candidates (Priority Order)
1. **Delete proto overloads where Scala overloads exist and proto unused**
- `IncomingArmyUtils.scala` - verify all main callers use Scala overloads
- `ArmyUtils.scala` - verify all main callers use Scala overloads
2. **Move test-only utilities to test code**
- `IDable.scala` - only used by tests (except StartGameActionResultUtils)
3. **Create Scala versions of common enums**
- `DiplomacyOfferStatus` → already done in availability package
- `ImprovementType` → used by ImproveCommandSelector, CommandChoiceHelpers
- `BattalionTypeId` → used by multiple command selectors
4. **Large refactoring (requires Scala AvailableCommand/SelectedCommand)**
- `command_choice_helpers/` package (57 imports) - uses proto API command types
- Would need Scala versions of AvailableCommand and SelectedCommand hierarchies
+64 -18
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
@@ -57,31 +57,53 @@ llvm.toolchain(
llvm_version = "20.1.2",
)
# Linux sysroot for cross-compilation (Chromium's Debian sysroot)
# Linux x86_64 sysroot for cross-compilation
llvm.sysroot(
name = "llvm_toolchain_linux",
label = "@linux_sysroot//sysroot",
targets = ["linux-x86_64"],
)
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
# Cross-compilation toolchain (macOS -> Linux ARM64)
llvm.toolchain(
name = "llvm_toolchain_linux_arm64",
llvm_version = "20.1.2",
)
# Download the Linux sysroot (Ubuntu 24.04 Noble for C++23 support)
# Linux ARM64 sysroot for cross-compilation
llvm.sysroot(
name = "llvm_toolchain_linux_arm64",
label = "@linux_sysroot_arm64//sysroot",
targets = ["linux-aarch64"],
)
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux", "llvm_toolchain_linux_arm64")
# Download the Linux sysroots (Ubuntu 24.04 Noble for C++23 support)
# Built by: .github/workflows/build_sysroot.yml
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
# x86_64 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-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")
@@ -94,6 +116,8 @@ use_repo(
"com_github_aws_aws_sdk_go_v2_config",
"com_github_aws_aws_sdk_go_v2_credentials",
"com_github_aws_aws_sdk_go_v2_service_s3",
"com_github_golang_jwt_jwt_v5",
"com_github_google_uuid",
"org_golang_google_grpc",
"org_golang_google_protobuf",
)
@@ -103,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
@@ -113,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
@@ -125,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")
@@ -142,7 +166,10 @@ oci.pull(
oci.pull(
name = "ubuntu_24_04",
image = "docker.io/library/ubuntu",
platforms = ["linux/amd64"],
platforms = [
"linux/amd64",
"linux/arm64/v8",
],
tag = "24.04",
)
@@ -153,13 +180,13 @@ oci.pull(
platforms = ["linux/amd64"],
tag = "3.21",
)
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
#
# 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(
@@ -219,6 +246,9 @@ maven.install(
# JWT (for OAuth token handling)
"com.nimbusds:nimbus-jose-jwt:9.37.3",
# Error tracking
"io.sentry:sentry:7.19.0",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -264,11 +294,26 @@ 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,
)
http_file(
name = "busybox_aarch64",
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
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,
)
@@ -286,5 +331,6 @@ register_toolchains(
register_toolchains(
"@llvm_toolchain//:all",
"@llvm_toolchain_linux//:all",
"@llvm_toolchain_linux_arm64//:all",
dev_dependency = True,
)
+136 -31
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": "uqsqMpE+yaVuTByR2rIA2v1Vkm1JwwuN6nbXOo1W9G0=",
"bzlTransitiveDigest": "AOLP47LtVHSKSDiukosQymx543OwcgeoQP666wwuj3o=",
"usagesDigest": "3Xsv1/UEV8MOARW4BZScPw3Gxtx19OQ5EXOqcL1p9bI=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1343,6 +1395,20 @@
"bazel_tags": []
}
},
"ubuntu_24_04_linux_arm64_v8": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platform": "linux/arm64/v8",
"target_name": "ubuntu_24_04_linux_arm64_v8",
"bazel_tags": []
}
},
"ubuntu_24_04": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
@@ -1354,7 +1420,8 @@
"repository": "library/ubuntu",
"identifier": "24.04",
"platforms": {
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64"
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64",
"@@platforms//cpu:arm64": "@ubuntu_24_04_linux_arm64_v8"
},
"bzlmod_repository": "ubuntu_24_04",
"reproducible": true
@@ -1528,6 +1595,7 @@
"eclipse_temurin_17_linux_amd64",
"ubuntu_24_04",
"ubuntu_24_04_linux_amd64",
"ubuntu_24_04_linux_arm64_v8",
"alpine_linux",
"alpine_linux_linux_amd64"
],
@@ -1564,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=",
@@ -5040,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": {},
+108
View File
@@ -17,6 +17,18 @@ pkg_tar(
},
)
pkg_tar(
name = "busybox_layer_arm64",
srcs = ["@busybox_aarch64//file"],
package_dir = "/usr/local/bin",
remap_paths = {
"file/busybox": "busybox",
},
symlinks = {
"/usr/local/bin/nc": "busybox",
},
)
#
# Eagle Server Docker Image
#
@@ -155,6 +167,54 @@ oci_push(
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
#
# Shardok Server ARM64 Docker Image (for Hetzner on-demand compute)
#
# Build: bazel build //ci:shardok_server_image_arm64 --platforms=//:linux_arm64 --extra_toolchains=@llvm_toolchain_linux_arm64//:all
# Load: bazel run //ci:shardok_server_load_arm64
# Push: bazel run //ci:shardok_server_push_arm64
#
# Package the Shardok binary (ARM64 version - must be built with --platforms=//:linux_arm64)
pkg_tar(
name = "shardok_binary_layer_arm64",
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
package_dir = "/app",
)
oci_image(
name = "shardok_server_image_arm64",
base = "@ubuntu_24_04_linux_arm64_v8",
entrypoint = ["/app/shardok-server"],
exposed_ports = [
"40042/tcp",
"40052/tcp",
],
tars = [
# Note: busybox_layer_arm64 omitted - busybox.net has SSL issues
# Health checks can use the shardok-server binary itself or be added later
":shardok_binary_layer_arm64",
":shardok_resources_layer",
":shardok_maps_layer",
],
workdir = "/app",
)
# Load into Docker locally (ARM64): bazel run //ci:shardok_server_load_arm64
oci_load(
name = "shardok_server_load_arm64",
image = ":shardok_server_image_arm64",
repo_tags = ["eagle0/shardok-server:latest-arm64"],
)
# Push to DigitalOcean Container Registry (for Hetzner deployment)
# Uses same repository as x86 but with arm64- tag prefix
oci_push(
name = "shardok_server_push_arm64",
image = ":shardok_server_image_arm64",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
#
# Admin Server Docker Image (Go)
#
@@ -237,3 +297,51 @@ oci_push(
image = ":jfr_sidecar_image",
repository = "registry.digitalocean.com/eagle0/jfr-sidecar",
)
#
# Auth Server Docker Image (Go)
#
# This is the external OAuth service that handles OAuth flows and JWT creation.
# Build: bazel build //ci:auth_server_image
# Load: bazel run //ci:auth_server_load
# Push: bazel run //ci:auth_server_push
#
# Package the Go auth binary (explicit Linux x86_64 target)
pkg_tar(
name = "auth_binary_layer",
srcs = [
"//src/main/go/net/eagle0/authcli:authcli_linux_amd64",
"//src/main/go/net/eagle0/authservice:authservice_linux_amd64",
],
package_dir = "/app",
)
oci_image(
name = "auth_server_image",
base = "@alpine_linux_linux_amd64",
entrypoint = ["/app/authservice_linux_amd64"],
exposed_ports = [
"40033/tcp", # gRPC
"8080/tcp", # HTTP OAuth callback
],
tars = [
":busybox_layer",
":auth_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:auth_server_load
oci_load(
name = "auth_server_load",
image = ":auth_server_image",
repo_tags = ["eagle0/auth-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "auth_server_push",
image = ":auth_server_image",
repository = "registry.digitalocean.com/eagle0/auth-server",
)
+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>
-1
View File
@@ -1 +0,0 @@
node_modules/
-46
View File
@@ -1,46 +0,0 @@
# OAuth Relay Worker
Cloudflare Worker that relays OAuth callbacks to the eagle0:// custom URL scheme.
## Why?
Discord (and some other OAuth providers) don't support custom URL schemes as redirect URIs. This worker acts as a relay:
1. Discord redirects to `https://eagle0-oauth-relay.<account>.workers.dev/oauth/callback?code=xxx&state=yyy`
2. Worker responds with 302 redirect to `eagle0://auth/callback?code=xxx&state=yyy`
3. OS opens the Eagle0 app via deep link
## Deploy
1. Install wrangler: `npm install -g wrangler`
2. Login: `wrangler login`
3. Deploy: `wrangler deploy`
The worker will be available at `https://eagle0-oauth-relay.<your-account>.workers.dev`
## Test Locally
```bash
wrangler dev
# Then in another terminal:
curl -I "http://localhost:8787/oauth/callback?code=test&state=abc"
```
## Test Production
```bash
curl -I "https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback?code=test&state=abc"
```
Should return:
```
HTTP/2 302
location: eagle0://auth/callback?code=test&state=abc
```
## OAuth Provider Configuration
In Discord Developer Portal / Google Cloud Console, set the redirect URI to:
```
https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback
```
-38
View File
@@ -1,38 +0,0 @@
/**
* OAuth Relay Worker
*
* Receives OAuth callbacks from providers (Discord, Google) and redirects
* to the eagle0:// custom URL scheme for the native app to handle.
*
* Input: GET /oauth/callback?code=xxx&state=yyy
* Output: 302 Redirect to eagle0://auth/callback?code=xxx&state=yyy
*/
export default {
async fetch(request) {
const url = new URL(request.url);
// Only handle /oauth/callback path
if (url.pathname !== '/oauth/callback') {
return new Response('Not Found', { status: 404 });
}
// Build the deep link URL
const deepLink = new URL('eagle0://auth/callback');
// Forward all query parameters
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
if (code) deepLink.searchParams.set('code', code);
if (state) deepLink.searchParams.set('state', state);
if (error) deepLink.searchParams.set('error', error);
if (errorDescription) deepLink.searchParams.set('error_description', errorDescription);
console.log(`OAuth relay: redirecting to ${deepLink.toString()}`);
return Response.redirect(deepLink.toString(), 302);
}
}
-4
View File
@@ -1,4 +0,0 @@
name = "eagle0-oauth-relay"
main = "worker.js"
compatibility_date = "2024-01-01"
workers_dev = true
+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)
# Note: Shardok runs on Hetzner, deployed via shardok_arm64_build.yml
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
# OpenAI / LLM
OPENAI_API_KEY=
GPT_MODEL_NAME=gpt-4o
# DigitalOcean Spaces (S3-compatible storage)
EAGLE_ENABLE_S3=false
DO_SPACES_ACCESS_KEY=
DO_SPACES_SECRET_KEY=
# JWT authentication
JWT_PRIVATE_KEY=
# OAuth providers
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Shardok connection (Hetzner ARM64 server)
SHARDOK_ADDRESS=
SHARDOK_AUTH_TOKEN=
# Monitoring
SENTRY_DSN=
# Email (Fastmail JMAP)
FASTMAIL_API_TOKEN=
FASTMAIL_FROM_EMAIL=
FASTMAIL_FROM_NAME=
+59
View File
@@ -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"
+162 -28
View File
@@ -1,21 +1,29 @@
# Docker Compose for production deployment
#
# Local testing:
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:auth_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
# Run: docker compose -f docker-compose.prod.yml up -d
#
# Note: Shardok runs on Hetzner ARM64 server, deployed via shardok_arm64_build.yml workflow.
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}"
- "--shardok-interface-remote-address"
- "shardok:40042"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "--auth-service-url"
- "auth:40033"
ports:
- "40032:40032"
environment:
@@ -27,12 +35,21 @@ services:
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
# Auth token for Shardok on Hetzner (required)
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
# Use persistent volume for save data (users, games, etc.)
EAGLE_SAVE_DIR: "/app/saves"
EAGLE_ARCHIVE_DIR: "/app/archived"
SENTRY_DSN: "${SENTRY_DSN:-}"
SENTRY_ENVIRONMENT: "production"
volumes:
- ./saves:/app/saves
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- ./saves:/app/saves # Game saves and user database
- ./archived:/app/archived # Archived completed games
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: unless-stopped
logging:
driver: "json-file"
@@ -46,44 +63,124 @@ services:
retries: 3
start_period: 30s
shardok:
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
container_name: shardok-server
mem_limit: 1g
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
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:
- "40042:40042"
- "40052:40052"
- "40034:40032" # Different host port for staging
environment:
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
restart: unless-stopped
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
- ./jfr:/app/jfr # JFR recordings (same as blue)
- jvm-tmp:/tmp # Shared with jfr-sidecar-green for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- 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 40042 || exit 1"]
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
environment:
# gRPC port for Auth service
AUTH_GRPC_PORT: "40033"
# HTTP port for OAuth callbacks
AUTH_HTTP_PORT: "8080"
# User data persistence directory
AUTH_DATA_DIR: "/app/data"
# Legacy path for migrating users from Eagle (Phase 1 migration)
AUTH_LEGACY_DATA_DIR: "/app/saves/auth"
# OAuth provider credentials
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
# Server base URL for OAuth callbacks
SERVER_BASE_URL: "${SERVER_BASE_URL:-https://prod.eagle0.net}"
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
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
- ./auth-data:/app/data # User database persistence
- ./saves:/app/saves:ro # Read-only access to Eagle's saves for migration
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40033 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Note: Shardok runs on Hetzner ARM64 server, not in this docker-compose.
# Configure SHARDOK_ADDRESS to point to the Hetzner instance.
nginx:
image: nginx:alpine
container_name: nginx
ports:
- "443:443"
- "80:80"
- "40033:40033" # Go Auth service gRPC (Phase 2 direct client connections)
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./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"
@@ -96,16 +193,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"
ports:
- "8080:8080"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- eagle
- jfr-sidecar
- auth
# 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"
@@ -123,11 +222,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"
@@ -141,6 +241,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
@@ -152,3 +275,14 @@ services:
volumes:
jvm-tmp:
# Shared /tmp for JVM attach socket files between Eagle and jfr-sidecar
jwt-keys:
# Shared JWT RSA keys between Eagle and auth service
networks:
default:
driver: bridge
enable_ipv6: true
ipam:
config:
- subnet: 172.28.0.0/16
- subnet: fd00:dead:beef::/48
+240
View File
@@ -0,0 +1,240 @@
# Eagle0 Media Asset Audit
This document catalogs all media assets in the Unity project for licensing review.
**Total Assets:** ~12,500 files | **Size:** 1.4 GB
---
## Summary by Category
| Category | Count | Notes |
|----------|-------|-------|
| Images | 10,637 | Mostly PNG icons and UI sprites |
| Audio | 1,778 | 26 music tracks + 1,752 sound effects |
| 3D Models | 52 | Bridge pack only |
| Fonts | 16 | TTF files |
---
## 1. Purchased Asset Store Packages
These are commercial Unity Asset Store purchases tied to your account:
### 4000_Fantasy_Icons
- **Location:** `Assets/4000_Fantasy_Icons/`
- **Size:** 495 MB (5,621 PNG files)
- **Contents:** Icons for armor, weapons, skills, resources
- **License:** Unity Asset Store (check invoice/account)
### GUI Pro Kit Fantasy RPG
- **Location:** `Assets/GUI Pro Kit Fantasy RPG/`
- **Size:** 117 MB (3,755 PNG files)
- **Contents:** UI sprites, animations, prefabs
- **Includes fonts:** Alata-Regular.ttf, JosefinSans-Bold.ttf
- **License:** Unity Asset Store
### Modern UI Pack v4.2.0
- **Location:** `Assets/Modern UI Pack/`
- **Size:** 40 MB (191 PNG files)
- **Author:** Michsky (support@michsky.com)
- **Website:** https://www.michsky.com
- **Includes fonts:** Open Sans family (12 variants)
- **License:** Unity Asset Store
### Pixel Fonts Megapack
- **Location:** `Assets/Pixel Fonts Megapack/`
- **Publisher ID:** 17384
- **Author:** @pixelmush_ on Twitter
- **Asset Store Link:** http://u3d.as/w4v
- **License:** Unity Asset Store
### TileableBridgePack
- **Location:** `Assets/TileableBridgePack/`
- **Size:** 3.1 MB (52 FBX models)
- **Contents:** Bridge construction pieces
- **License:** Unity Asset Store
### Fantasy Interface Sounds
- **Location:** `Assets/Fantasy Interface Sounds/`
- **Count:** 320 WAV files
- **Contents:** UI sounds (bag, book, coins, dice, etc.)
- **License:** Unity Asset Store (verify)
### Medieval Combat Sounds
- **Location:** `Assets/Medieval Combat Sounds/`
- **Count:** 1,072 WAV files
- **Contents:** Footsteps, swings, shields, weapons, magic
- **License:** Unity Asset Store (verify)
### Magic Spells Sound Effects LITE
- **Location:** `Assets/Magic Spells Sound Effects LITE/`
- **Count:** 254 WAV files
- **Contents:** Spell casting, element effects
- **Note:** "LITE" version - check if restrictions apply
- **License:** Unity Asset Store (verify)
---
## 2. Creative Commons Music (Properly Licensed)
**Location:** `Assets/Resources/Music/`
**Documentation:** `Music Credits.txt` (attribution file exists)
All 26 tracks have CC licenses with proper attribution:
| Track | Artist | License |
|-------|--------|---------|
| A Robust Crew | Darren Curtis | CC BY 3.0 |
| Asian Graveyard | Darren Curtis | CC BY 3.0 |
| Fall From Grace | Darren Curtis | CC BY 3.0 |
| Samurai Sake Showdown | Darren Curtis | CC BY 3.0 |
| Deflector | Ghostrifter Official | CC BY-SA 3.0 |
| Chase | Alexander Nakarada | CC BY 4.0 |
| Wintersong | Alexander Nakarada | CC BY 4.0 |
| One Bard Band | Alexander Nakarada | CC BY 4.0 |
| Now We Ride | Alexander Nakarada | CC BY 4.0 |
| The Northern Path | Alexander Nakarada | CC BY 4.0 |
| Victory | MaxKoMusic | CC BY-SA 3.0 |
| Sakuya2 | PeriTune | CC BY 3.0 |
| Under The Sun | Keys of Moon | CC BY 4.0 |
| One Piece of Summer | Keys of Moon | CC BY 4.0 |
| Fluffing a Duck | Kevin MacLeod | CC BY 3.0 |
| Space Jazz | Kevin MacLeod | CC BY 3.0 |
| The Ice Giants | Kevin MacLeod | CC BY 4.0 |
| Epic Cinematic Trailer ELITE | Alex-Productions | CC BY 3.0 |
| Push | Alex-Productions | CC BY 3.0 |
| Virus | Alex-Productions | CC BY 3.0 |
| Duel | Makai Symphony | CC BY-SA 3.0 |
| Dragon Castle | Makai Symphony | CC BY-SA 3.0 |
| Durandal | Makai Symphony | CC BY-SA 3.0 |
**Tracks without specific license (verify):**
- Market Day
- Shopping List
- Medieval: Victory Theme
- Tracks by Dima Koltsov (AUDIUS): No Time for Greatness, Warriors of Demacia, Forest Queen Tale, Valor, Clouds
---
## 3. CC0 / Public Domain Assets
### SimpleFileBrowser Icons
- **Location:** `Assets/Plugins/SimpleFileBrowser/Sprites/FileIcons/`
- **License:** CC0 (documented in LICENSE.txt)
- **Source:** pngrepo.com
- **Items:** Archive, Audio, Default, Drive, Folder, Image, PDF, Text, Video icons
---
## 4. Potentially Problematic Assets (Review Needed)
### Stock Images (Possible License Issues)
These appear to be stock images that may have been used as placeholders:
| File | Concern |
|------|---------|
| ~~`Assets/Eagle/79066358-stock-illustration-raster-illustration-medieval-purse-bag...jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Images/kisspng-hammer-hand-saws-tool-clip-art...jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Images/lee-ermy-cropped.jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Eagle/images.jpeg`~~ | **DELETED** (2025-01-04) |
### Clip Art (Unknown License)
| File | Concern |
|------|---------|
| `Assets/Shardok/commandImages/bridge.png` | Clip art style wooden bridge, unknown source - **needs replacement** |
| `Assets/Images/startFire.png` | Icon, unknown source - **needs verification or replacement** |
### Shardok Sound Effects
- **Location:** `Assets/Shardok/soundEffects/`
- **Count:** 56 MP3 files
- **Contents:** Spell effects, movement, combat sounds
- **Status:** Unknown origin - may be custom or need verification
### Free Icons
- **Location:** `Assets/free_icons/`
- **Count:** 8 PNG weather icons
- **Status:** Verify "free" means commercially usable
### Terrain Hexes
- **Location:** `Assets/Terrain Hexes/`
- **Count:** 85 PNG files
- **Status:** Unknown source - verify licensing
### StrategyGameIcons
- **Location:** `Assets/StrategyGameIcons/`
- **Count:** 138 PNG files
- **Status:** Unknown source - verify licensing
---
## 5. Fonts
| Font | Location | License |
|------|----------|---------|
| Open Sans (12 variants) | Modern UI Pack | Apache 2.0 (Google Font) |
| Alata-Regular | GUI Pro Kit | SIL OFL (Google Font) |
| JosefinSans-Bold | GUI Pro Kit | SIL OFL (Google Font) |
| LiberationSans | TextMesh Pro | SIL OFL |
| NotoColorEmoji | Assets root | SIL OFL (Google) |
| Stoke-Light, Stoke-Regular | Assets root | SIL OFL (Google Font) |
All fonts appear to be open-source Google Fonts or Liberation fonts - should be fine.
---
## 6. Third-Party Code Packages
NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
- Microsoft.Extensions.* - MIT License
- System.* - MIT License
- Grpc.* - Apache 2.0
---
## Action Items
### Must Verify Before Opening Public Access:
1. ~~**Stock images** - The JPG files with stock image filenames need review.~~ **DONE** - Deleted lee-ermy, kisspng, stock-illustration, Yosemite Sam, and images.jpeg (2025-01-04)
2. **Clip art images** - Unknown license, need replacement with properly licensed alternatives:
- `Assets/Shardok/commandImages/bridge.png` - wooden bridge icon
- `Assets/Images/startFire.png` - fire icon
3. **Shardok sound effects** - 56 MP3 files of unknown origin. Either:
- Document their source
- Replace with known-licensed alternatives
- Confirm they were custom-created
4. **Terrain Hexes** - 85 hex tiles of unknown source
- **TODO:** Confirm if this is a Unity Asset Store purchase (owner believes it is)
5. **StrategyGameIcons** - 138 icons of unknown source
- **TODO:** Investigate origin - check Unity Asset Store purchase history
6. **AUDIUS music tracks** - Verify Dima Koltsov tracks are licensed for commercial use
7. **Discord logo** (`Eagle/Discord-Logo-Blurple.png`) - Likely fine for "Login with Discord" button per Discord brand guidelines, but verify usage complies with their terms
### Already Safe:
- All Asset Store purchases (license tied to your account)
- CC-licensed music (attribution in Music Credits.txt)
- CC0 SimpleFileBrowser icons
- Google Fonts / Liberation fonts
- NuGet packages
---
## Recommendation
Before removing HTTP basic auth:
1. ~~Delete or replace the 4 suspicious JPG/JPEG files in `Assets/Eagle/` and `Assets/Images/`~~ **DONE** (2025-01-04)
2. Replace clip art images (`bridge.png`, `startFire.png`) with properly licensed alternatives (e.g., from [game-icons.net](https://game-icons.net) CC BY 3.0)
3. Verify source of `Assets/Shardok/soundEffects/` MP3s
4. Verify source of `Assets/Terrain Hexes/` and `Assets/StrategyGameIcons/`
5. If any are from early development with unclear licensing, replace them
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
+53 -31
View File
@@ -190,24 +190,24 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
| `ResolveBattleAction.scala` | Heavy proto usage | Blocked by proto dependencies |
| `ResolveBattleAction.scala` | ✅ Fully protoless | Complete (PR #5048) |
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
### Remaining Proto Usage in Actions
**Progress: 47 of 52 action files (90%) are fully protoless.**
**Progress: 52 of 52 action files (100%) are fully protoless.**
The following 5 actions still have proto usage:
All action files have been migrated to use Scala types:
| Action | Proto Usages | Blocker | Effort |
|--------|--------------|---------|--------|
| `ResolveBattleAction` | 24 | Shardok interface, complex battle logic | High |
| `PerformVassalCommandsPhaseAction` | 3 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndHandleRiotsPhaseAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `PerformVassalDefenseDecisionsAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndVassalCommandsPhaseAction` | 1 | `CommandChoiceHelpers` takes proto GameState | Medium |
| Action | Status | Notes |
|--------|--------|-------|
| `ResolveBattleAction` | ✅ Complete | PR #5048 - uses Scala GameState and ActionResultApplier |
| `PerformVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndHandleRiotsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `PerformVassalDefenseDecisionsAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
@@ -223,13 +223,28 @@ The following 5 actions still have proto usage:
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~2600** | | |
| **Total Remaining** | **~100** | | |
**Completed:**
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
-`CommandChoiceHelpers` migrated to Scala types
-`ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
### Enum Type Migrations
Proto enums are being converted to Scala sealed traits with converters at boundaries:
| Enum | Scala Type | Status | Notes |
|------|------------|--------|-------|
| `DiplomacyOfferStatus` | `Status` sealed trait | ✅ **Complete** | PR #5093 - `EligibleDiplomacyStatuses` uses Scala types internally |
| `RoundPhase` | `RoundPhase` sealed trait | Partial | Some usages converted |
| `BattalionType` | `BattalionType` sealed trait | Partial | Some usages converted |
**DiplomacyOfferStatus Migration (PR #5093):**
- `EligibleDiplomacyStatuses.scala` now returns `Vector[Status]` instead of `Vector[DiplomacyOfferStatus]`
- Call sites in `AvailableResolve*CommandFactory` files convert to proto via `StatusConverter.toProto` at the boundary
- This pattern should be applied to other proto enums
### CommandChoiceHelpers Migration Status
@@ -246,28 +261,34 @@ Several command selectors have already been converted to use Scala types:
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ❌ Proto | Main entry point, converts to Scala when calling converted selectors |
| `ProvinceGoldSurplusCalculator.scala` | **Partial** | Has both Scala and proto overloads |
| Other selectors | ❌ Proto | Various proto dependencies |
| `CommandChoiceHelpers.scala` | **Protoless** | Uses Scala `GameState` throughout |
| `ProvinceGoldSurplusCalculator.scala` | **Protoless** | Uses Scala types |
**Pattern**: `CommandChoiceHelpers` currently uses `GameStateConverter.fromProto(gameState)` when calling already-converted selectors like `AlmsCommandSelector` and `AttackCommandChooser`. This allows incremental migration.
**Next Steps**:
1. ~~Convert `ExpandCommandSelector` to Scala types~~ ✅ Done
2. ~~Convert `ImproveCommandSelector` to Scala types~~ ✅ Done
3. ~~Convert `OrganizeCommandSelector` to Scala types~~ ✅ Done (PR #4812)
4. ~~Convert `RansomOfferHelpers` to Scala types~~ ✅ Done (PR #4821)
5. Convert remaining selectors one at a time
6. Update `CommandChoiceHelpers` to accept Scala `GameState` once all selectors are converted
**All CommandChoiceHelpers selectors have been migrated to Scala types.**
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 47 / 52 (90%) |
| Proto usages in remaining actions | 32 total |
| Biggest blocker | `ResolveBattleAction` (24 usages) |
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
| Action files fully protoless | 52 / 52 (100%) |
| Proto usages in remaining actions | 0 |
| Next target | See "Next Candidates" section below |
### Next Candidates
Priority candidates for further deproto work:
1. **More Enum Migrations** - Apply the `DiplomacyOfferStatus` pattern to other proto enums:
- Files importing `net.eagle0.eagle.common.round_phase.RoundPhase` (proto) could use Scala `RoundPhase`
- Files importing `net.eagle0.eagle.common.battalion_type.BattalionType` (proto) could use Scala `BattalionType`
2. **AvailableCommandsFactory Files** - Many still use proto `GameState` internally:
- These files build proto `AvailableCommand` messages but could use Scala types for internal logic
- Convert to accept Scala `GameState`, only convert fields to proto when building the response
3. **History APIs** - `InMemoryHistory` and `PersistedHistory`:
- Change to vend Scala `GameState` and `ActionResultT` instead of proto versions
- `PersistedHistory` converts to proto internally for disk persistence
### Validation
- [x] `ActionResultApplier` created and tested
@@ -276,8 +297,9 @@ Several command selectors have already been converted to use Scala types:
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
- [x] `CommandChoiceHelpers` uses Scala types ✅
- [x] All action files (52/52) are fully protoless ✅
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] `CommandChoiceHelpers` uses Scala types
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
- [ ] All tests pass
+159
View File
@@ -0,0 +1,159 @@
# Hetzner Setup Guide
This guide walks through setting up Hetzner Cloud infrastructure for running Shardok on-demand compute.
## Prerequisites
- All code PRs merged (#4990, #4996, #4998, #5001, #5009)
- Access to DigitalOcean Container Registry (for pulling Shardok ARM64 image)
---
## Step 1: Create Hetzner Cloud Account
1. Go to https://console.hetzner.cloud/
2. Sign up and add payment method
3. Create a new project (e.g., "eagle0")
---
## Step 2: Generate Hetzner API Token
1. In Hetzner Console → Security → API Tokens
2. Click "Generate API Token"
3. Give it **Read & Write** permissions
4. Copy the token (you'll only see it once)
---
## Step 3: Generate Shardok Auth Token
Generate a 256-bit random token for Eagle-Shardok authentication:
```bash
openssl rand -hex 32
```
Save this output - it's the shared secret between Eagle and Shardok.
---
## Step 4: Store Secrets in GitHub Actions
Add these secrets in GitHub → Settings → Secrets and variables → Actions:
| Secret Name | Description |
|-------------|-------------|
| `HETZNER_API_TOKEN` | From Step 2 - for Hetzner API calls |
| `SHARDOK_AUTH_TOKEN` | From Step 3 - shared secret for gRPC auth |
Note: `DO_REGISTRY_TOKEN` already exists and will be used for Hetzner to pull container images.
These secrets will be passed to Eagle at runtime via `docker_build.yml`, similar to how `OPENAI_API_KEY` and other secrets are handled.
---
## Step 5: DNS Setup (for Let's Encrypt)
You need a domain pointing to the Shardok instance for TLS certificates.
### Option A: Floating IP (Recommended)
1. In Hetzner Console → Networking → Floating IPs
2. Create a **Floating IPv6** in **Hillsboro, Oregon (hil)** region
- IPv6 costs €1/month vs €3/month for IPv4
- Hillsboro has better latency to DigitalOcean SFO than Ashburn
- Server-to-server communication works fine with IPv6-only
3. Point `shardok.prod.eagle0.net` to this IP via AAAA record
4. The ShardokInstanceManager will attach this IP to instances on spin-up
**Location choice**: Hillsboro, OR (`hil`) is recommended for US West Coast. Same pricing as Ashburn (`ash`).
### Option B: Dynamic DNS
Update DNS programmatically when instance spins up. More complex but avoids floating IP cost.
---
## Step 6: Upload SSH Key to Hetzner
For debugging access to instances:
1. In Hetzner Console → Security → SSH Keys
2. Click "Add SSH Key"
3. Paste your public key (e.g., `~/.ssh/id_rsa.pub`)
4. Give it a name (e.g., "eagle-deploy")
---
## Step 7: Wire Security Config into Eagle
Update Eagle's startup code to use the security config when connecting to remote Shardok:
```scala
val securityConfig = ShardokSecurityConfig(
useTls = true,
authToken = Some(sys.env("SHARDOK_AUTH_TOKEN"))
)
val channel = ServerSetupHelpers.newChannel(
"shardok.prod.eagle0.net",
50051,
securityConfig
)
```
---
## Testing
### Manual Instance Spin-up
Test the Hetzner integration by triggering instance creation:
```scala
val manager = new ShardokInstanceManager(
hetznerApiToken = sys.env("HETZNER_API_TOKEN"),
// ... other config
)
manager.ensureInstanceRunning()
```
### Verify TLS and Auth
1. Instance spins up and gets Let's Encrypt certificate
2. Eagle connects via TLS
3. Auth token is validated on each request
---
## Cost Estimate
| Component | Cost |
|-----------|------|
| CAX41 (16 ARM cores) | ~$0.04/hour |
| Floating IP | ~$4/month |
| Typical usage (20 hrs/week) | ~$3.50/month compute |
**Total: ~$7-8/month** for typical usage.
---
## Troubleshooting
### Instance won't start
- Check Hetzner API token has Read & Write permissions
- Verify you're using the correct region (`hil` for Hillsboro OR, or `ash` for Ashburn VA)
### TLS certificate fails
- Ensure DNS points to the instance IP before certbot runs
- Check port 80 is open for Let's Encrypt HTTP-01 challenge
### Auth failures
- Verify `SHARDOK_AUTH_TOKEN` matches on both Eagle and Shardok
- Check the token file is readable by Shardok container
### Can't pull container image
- Ensure `DO_REGISTRY_TOKEN` is passed to cloud-init
- Verify the ARM64 image exists: `registry.digitalocean.com/eagle0/shardok-server:arm64-latest`
+383
View File
@@ -0,0 +1,383 @@
# Plan: Extract OAuth to Go Service
## Goal
Move OAuth authentication handling from the Eagle Scala server into a separate Go service. This simplifies Eagle (gRPC-only, no HTTP), reduces complexity, and sets up for potentially moving JWT validation outside Eagle too.
## Architecture Decision: Sidecar Service (Not DO Functions)
**Recommendation: Go sidecar service on the same droplet, in a separate container**
**Why not DO Functions:**
- OAuth requires **stateful sessions** (pendingOAuth/completedOAuth maps with 10-min TTL)
- Client polling pattern (every 2 seconds) would incur high function invocation costs
- Cold start latency problematic for auth flows
- State would require external store (Redis), adding complexity
**Why sidecar (separate container):**
- Simple process on same droplet, minimal network latency
- In-memory state management (like current Scala impl)
- Easy to monitor/debug alongside Eagle
- Can share filesystem for key files (RSA keys) via volume mounts
- **Independent deployment**: Deploying Eagle doesn't restart auth service (and vice versa)
- **Independent scaling**: Could move to separate droplet later if needed
## Current Architecture (What Exists)
```
Unity Client
├── GetOAuthUrl RPC → Eagle AuthServiceImpl → OAuthService.getAuthUrl()
├── [User browser auth] → HTTP callback → OAuthHttpHandler → OAuthService.handleCallback()
├── CheckOAuthStatus RPC (polling) → AuthServiceImpl → OAuthService.checkStatus()
└── All other RPCs include JWT → AuthorizationInterceptor validates
```
**Key files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow, state management
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD (persisted)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala` - HTTP callback handler
## Target Architecture (Phase 1)
```
Unity Client
├── GetOAuthUrl RPC ──────────────┐
├── CheckOAuthStatus RPC (polling)├──→ Eagle (port 40032) ──proxy──→ Go Auth Container (port 40033)
├── RefreshToken RPC ─────────────┘ │
├── [User browser] → HTTP callback ────────────────────────────────────────┤
│ ↓
│ (Internal gRPC: GetOrCreateUser, GetUser)
│ ↓
└── Game RPCs with JWT ─────────────────────→ Eagle (port 40032) ← JWT validation stays here
[Same Droplet]
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
│ │ Go Auth Container │◄────────►│ Eagle Container │ │
│ │ (eagle0-auth) │ internal │ (eagle0-server) │ │
│ │ │ gRPC │ │ │
│ │ - OAuth flow │ │ - JWT validation │ │
│ │ - JWT creation │ │ - UserService (persistence) │ │
│ │ - HTTP callback │ │ - Game logic │ │
│ └──────────────────────┘ └────────────────────────────────────┘ │
│ │ │ │
│ └────────────────┬───────────────────────┘ │
│ ▼ │
│ /etc/eagle0/keys/ (shared volume) │
│ - private.pem │
│ - public.pem │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
### Go Auth Service (NEW - separate container)
- **OAuth flow**: getAuthUrl, handleCallback (HTTP), checkStatus
- **State management**: pendingOAuth, completedOAuth maps with TTL
- **JWT creation**: Issue access/refresh tokens (shares RSA private key with Eagle)
- **Token refresh**: Validate refresh token, issue new access token
- Calls Eagle's internal UserService gRPC to find/create users
### Eagle Server (SIMPLIFIED)
- **JWT validation**: AuthorizationInterceptor stays (validates tokens on game RPCs)
- **UserService**: Stays in Eagle (user persistence, display name logic)
- **New internal gRPC**: Expose GetOrCreateUser, GetUser for Go service to call
- **Proxy (Phase 1)**: Forward OAuth RPCs to Go service
- **Remove (Phase 2)**: OAuthService, OAuthHttpHandler, HTTP server setup
### Unity Client (NO CHANGES in Phase 1)
- Eagle proxies Auth RPCs to Go service
- Client still connects to Eagle on port 40032
## Implementation Phases
### Phase 1: Go Auth Service with Eagle Proxy (Zero Client Changes)
1. **Create Go service structure**
```
src/main/go/net/eagle0/authservice/
├── main.go # Entry point, starts gRPC + HTTP servers
├── oauth.go # OAuth state management, provider configs
├── jwt.go # JWT creation (copy logic from Scala)
├── handlers.go # gRPC handlers for Auth service
├── http_callback.go # HTTP handler for OAuth callback
└── BUILD.bazel
```
2. **Internal gRPC proto for Eagle UserService**
```protobuf
// src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto
service InternalUserService {
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetOrCreateUserRequest {
string provider = 1; // "discord" or "google"
string provider_user_id = 2;
string email = 3;
string avatar_url = 4;
}
message GetOrCreateUserResponse {
string user_id = 1;
string display_name = 2;
string avatar_url = 3;
bool is_admin = 4;
bool is_new_user = 5;
}
```
3. **Eagle: Expose InternalUserService**
- New `InternalUserServiceImpl.scala` wrapping UserService
- Bind to same port, different service name (internal only)
4. **Eagle: Proxy Auth RPCs to Go**
- AuthServiceImpl delegates GetOAuthUrl, CheckOAuthStatus, RefreshToken to Go service
- SetDisplayName, GetCurrentUser, Logout stay in Eagle
5. **Share RSA keys via volume mount**
- Go service reads same key files as Eagle
- Both can create valid JWTs
- Eagle continues to validate JWTs
6. **Docker/Container setup**
- New Dockerfile for Go auth service
- docker-compose or Kubernetes config for both containers
- Shared volume for /etc/eagle0/keys/
- Internal network for container-to-container gRPC
### Phase 2: Client Direct to Go Service (Future)
1. **Update Unity client**
- Connect to Go Auth service directly for OAuth RPCs
- Keep connecting to Eagle for game RPCs
2. **Remove Eagle proxy code**
- Delete AuthServiceImpl OAuth delegation
- AuthServiceImpl only handles SetDisplayName, GetCurrentUser, Logout
### Phase 3: Move JWT Validation to Go (Optional Future)
1. **Go service validates JWTs**
- Add ValidateToken RPC or use shared middleware pattern
2. **Eagle calls Go for validation**
- AuthorizationInterceptor calls Go to validate tokens
- OR: Use stateless validation (both share public key)
## Files to Create
### Go Service
- `src/main/go/net/eagle0/authservice/main.go`
- `src/main/go/net/eagle0/authservice/oauth.go`
- `src/main/go/net/eagle0/authservice/jwt.go`
- `src/main/go/net/eagle0/authservice/handlers.go`
- `src/main/go/net/eagle0/authservice/http_callback.go`
- `src/main/go/net/eagle0/authservice/BUILD.bazel`
### Protos
- `src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto`
### Scala
- `src/main/scala/net/eagle0/eagle/service/InternalUserServiceImpl.scala`
### Docker/Deployment
- `ci/auth_service.Dockerfile`
- Update `docker-compose.yml` (or equivalent)
## Files to Modify
### Scala (Phase 1)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - Proxy OAuth RPCs to Go
- `src/main/scala/net/eagle0/eagle/Main.scala` - Start internal user service, add auth-service-url flag
### Scala (Phase 2 - Removal)
- Delete `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala`
- Delete `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala`
- Simplify `src/main/scala/net/eagle0/eagle/Main.scala` - Remove HTTP server
### Unity (Phase 2)
- `Assets/Auth/OAuthManager.cs` - Point OAuth RPCs to Go service port
- `Assets/EagleConnection.cs` - Add second channel for auth service
## Key Implementation Details
### State Management in Go
```go
type OAuthState struct {
Provider string
CreatedAt time.Time
}
type OAuthResult struct {
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
}
var pendingOAuth = sync.Map{} // state -> OAuthState
var completedOAuth = sync.Map{} // state -> OAuthResult
const stateExpiration = 10 * time.Minute
// Background goroutine cleans expired states every minute
func cleanupExpiredStates() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
cutoff := time.Now().Add(-stateExpiration)
pendingOAuth.Range(func(key, value any) bool {
if value.(OAuthState).CreatedAt.Before(cutoff) {
pendingOAuth.Delete(key)
}
return true
})
// Similar for completedOAuth
}
}
```
### JWT Creation in Go
```go
import "github.com/golang-jwt/jwt/v5"
type EagleClaims struct {
jwt.RegisteredClaims
UserId string `json:"userId"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
}
func CreateAccessToken(userId, displayName string, isAdmin bool) (string, error) {
claims := EagleClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
UserId: userId,
DisplayName: displayName,
IsAdmin: isAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
```
### OAuth Provider Configs
- Read from environment variables (same as current OAuthConfig.scala)
- DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
- OAUTH_CALLBACK_URL (e.g., https://eagle0.shardok.games/oauth/callback)
### Container Networking
```yaml
# docker-compose.yml example
services:
eagle0-auth:
build:
context: .
dockerfile: ci/auth_service.Dockerfile
ports:
- "40033:40033" # gRPC
- "8080:8080" # HTTP callback
volumes:
- ./keys:/etc/eagle0/keys:ro
environment:
- DISCORD_CLIENT_ID
- DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
- EAGLE_INTERNAL_URL=eagle0-server:40034
eagle0-server:
build:
context: .
dockerfile: ci/eagle_run.Dockerfile
ports:
- "40032:40032" # Public gRPC
expose:
- "40034" # Internal gRPC (container-to-container only)
volumes:
- ./keys:/etc/eagle0/keys:ro
- ./data:/var/lib/eagle0
environment:
- AUTH_SERVICE_URL=eagle0-auth:40033
```
## Deployment
### Development
```bash
# Terminal 1: Go Auth Service
bazel run //src/main/go/net/eagle0/authservice:authservice -- \
--grpc-port=40033 \
--http-port=8080 \
--eagle-internal-url=localhost:40034
# Terminal 2: Eagle Server
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- \
--eagle-grpc-port=40032 \
--internal-grpc-port=40034 \
--auth-service-url=localhost:40033
```
### Production
- Both containers on same droplet via docker-compose
- Shared volume for RSA keys at /etc/eagle0/keys/
- Internal Docker network for container-to-container communication
- External access: 40032 (Eagle gRPC), 8080 (OAuth HTTP callback)
## Testing Strategy
1. **Unit tests for Go service**
- OAuth state management (expiration, cleanup)
- JWT creation matches Scala output (test with same keys)
- HTTP callback parsing
2. **Integration tests**
- Go service ↔ Eagle internal gRPC
- Full OAuth flow with mock provider
3. **Existing tests continue to pass**
- All Scala tests (JWT validation, user service)
4. **End-to-end test**
- Spin up both containers
- Run OAuth flow through proxy
## Risks and Mitigations
| Risk | Mitigation |
|------|------------|
| Key file permissions | Shared volume with read-only mount |
| State loss on Go restart | Document this (same as current Scala behavior); consider Redis later |
| Clock skew affecting JWT | Both on same machine |
| OAuth callback race | HTTP callback completes before gRPC poll |
| Container networking | Use docker-compose for reliable internal DNS |
| Proxy adds latency | Minimal (same machine), remove in Phase 2 |
## Estimated Scope
- **Phase 1**: ~500-700 lines Go, ~100 lines Scala changes, ~50 lines Docker config
- **Phase 2**: ~50 lines Unity, deletion of ~300 lines Scala
- **Phase 3**: Optional, separate decision
## Alternative Considered: Move Everything to Go
Could move UserService to Go as well, but:
- UserService is tightly integrated with game persistence
- Would require duplicating persistence layer
- Not worth the complexity for now
Keep UserService in Eagle, expose via internal gRPC.
## Open Questions
1. **HTTP callback routing**: Does the OAuth callback URL need to change, or can we route traffic from the existing URL to the new Go service?
2. **Health checks**: Should we add health check endpoints for container orchestration?
3. **Logging**: Should Go service log to same format/destination as Eagle?
+350
View File
@@ -0,0 +1,350 @@
# OAuth Implementation: Next Steps and Design
## Executive Summary
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
## Current State (Updated January 2026)
### What Works ✅
- Discord OAuth flow (server-mediated polling)
- Google OAuth flow
- JWT token generation and validation
- User creation and display name setting
- Auto-login with stored tokens
- Basic game creation and play with OAuth users
- Headshot fetching via public CDN (no auth required)
- Logout button in lobby (preserves tokens for quick reconnect)
- Environment (prod/qa) and user display in lobby
- Game identity with userName = displayName (PR #4964 merged)
### Known Issues
#### 1. Game Identity Model Fragility (Deferred)
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
**Current behavior**:
- Games store `userNameToFactionId: Map[String, Int]`
- For JWT users, this maps displayName → factionId
- displayName is technically mutable (users could change it)
- No migration path when displayName changes
**Why this is acceptable**:
1. We don't currently have a "change display name" feature
2. The alternative (using userId) requires more extensive changes
3. Can migrate to userId-based identity later if needed
#### 2. In-Game Headshot Fetching ✅ FIXED
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
- No authentication required
- Works for both OAuth and Basic Auth users
- Simpler architecture, no dependency on home Mac server
#### 3. Logout from Lobby ✅ FIXED
**Solution**: Added logout button to lobby UI (PR #4967).
- Button disconnects from server and returns to connection screen
- Intentionally does NOT clear OAuth tokens
- Allows quick reconnect with same account without full OAuth flow
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
**Problem**: User was able to set displayName "nolen" when that name was already taken.
**Root cause**: Unknown - needs investigation. Either:
- The uniqueness check is buggy
- The displayNameIndex wasn't populated correctly during user creation
- Race condition during concurrent registrations
#### 5. Admin Server Crashes ✅ FIXED
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
**Status**: Added diagnostic logging in PR #4974 to trace:
- State creation in `getAuthUrl`
- State lookup in `handleCallback`
- Result lookup in `checkStatus`
**Possible causes**:
- State mismatch between client and server
- Race condition in polling
- Cleanup running at wrong time
#### 7. Token Expiry Field Bug ✅ FIXED
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
---
## Proposed User Identity Model
### Design Principles
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
2. **Display Name is Cosmetic**: Can change without breaking game associations
3. **Backwards Compatibility**: Basic Auth continues to work for local development
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
### Data Model
```
User {
userId: String (UUID) // Primary key, immutable, used for all internal references
displayName: String // Unique, user-visible, mutable with migration
displayNameLower: String // Case-insensitive uniqueness
email: String // Primary email for account recovery/linking
avatarUrl: String // Current avatar URL
avatarData: bytes // Cached avatar for offline/fast access (future)
oauthIdentities: [OAuthIdentity]
createdAt: Timestamp
lastLoginAt: Timestamp
isAdmin: Boolean
}
OAuthIdentity {
provider: String // "discord", "google", etc.
providerUserId: String // Provider's user ID
providerEmail: String // Email from this provider
avatarUrl: String // Avatar from this provider
linkedAt: Timestamp
}
```
### Identity Resolution Strategy
The key question: **What should `AuthorizationUtils.userName` return?**
#### Option A: userName = displayName (Current PR #4964)
- **Pro**: Human-readable in logs, game saves, debugging
- **Con**: Breaks if displayName changes
- **Migration**: None needed now, complex later
#### Option B: userName = userId (Recommended)
- **Pro**: Stable identity, displayName changes are safe
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
#### Option C: Hybrid with Migration Support
- **userName** = userId for new games
- **Legacy lookup** for old games by displayName
- **Display layer** resolves userId → displayName for UI
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
### Account Linking Strategy
#### Automatic Linking (Future)
When a user logs in with a new OAuth provider:
1. Check if the provider email matches an existing user's email
2. If match found, prompt: "An account exists with this email. Link accounts?"
3. If confirmed, add new OAuthIdentity to existing user
4. If declined, create separate account (different email required)
#### Manual Linking (MVP)
1. User logs in with primary account
2. User goes to Settings → Linked Accounts
3. User clicks "Link Discord" or "Link Google"
4. OAuth flow adds new identity to current user
### Avatar/Headshot Strategy
#### Phase 1: OAuth Avatars (MVP)
- Store `avatarUrl` from OAuth provider during login
- Server proxies avatar requests to avoid CORS issues
- Cache avatars locally with TTL
#### Phase 2: Avatar Caching
- Download avatar to local storage on login
- Serve from local storage for reliability
- Refresh periodically or on login
#### Phase 3: Custom Avatars (Future)
- Allow users to upload custom avatar
- Store in S3/DO Spaces
- Custom avatar overrides OAuth avatar
---
## Implementation Plan
### Phase 1: Stabilization ✅ COMPLETE
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
- [ ] Investigate why "nolen" was allowed when it existed
- [ ] Add logging to `setDisplayName` to trace the issue
- [ ] Ensure `displayNameIndex` is correctly maintained
- [ ] Add unit tests for uniqueness enforcement
#### 1.2 Add Logout Button to Lobby ✅ DONE
- [x] Add "Logout" button to lobby UI
- [x] Disconnect from server
- [x] Navigate to connection screen
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
- [x] Merged - games work with OAuth users
- [x] Documented limitation (games break if displayName changes)
#### 1.4 Fix Headshot Fetching ✅ DONE
- [x] Made eagle0-headshots bucket public
- [x] Client fetches directly from CDN
- [x] No authentication required
#### 1.5 Add Lobby Status Display ✅ DONE
- [x] Show environment (prod/qa) in lobby
- [x] Show current user in lobby (OAuth displayName or classic username)
### Phase 2: Remaining Work (Priority Order)
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
- [x] Add diagnostic logging (PR #4974)
- [ ] Deploy and reproduce the issue
- [ ] Analyze logs to identify root cause
- [ ] Implement fix based on findings
#### 2.2 Fix Display Name Uniqueness
- [ ] Investigate UserService.setDisplayName logic
- [ ] Check displayNameIndex population
- [ ] Add logging to trace the issue
- [ ] Fix the bug and add tests
#### 2.3 Wire Up Lobby UI in Unity
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
#### 2.4 Implement Token Refresh During Gameplay
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
- [ ] Store refresh tokens server-side for validation
- [ ] Add proactive refresh in client before token expires
- [ ] Handle refresh during reconnection attempts
### Phase 3: Nice-to-Haves (Future)
#### 3.1 Proactive Token Refresh
- [ ] Monitor token expiry in client
- [ ] Refresh automatically when < 5 minutes remaining
- [ ] Update TokenStorage with new access token
#### 3.2 Better Error Messages
- [ ] Distinguish between network errors and auth errors
- [ ] Show user-friendly messages for OAuth failures
- [ ] Add retry suggestions
#### 3.3 Session Persistence Across Server Restarts
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
- [ ] Move completedOAuth to Redis with TTL
- [ ] Server can restart without breaking in-flight OAuth flows
#### 3.4 Migrate to userId-based Game Identity (Deferred)
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
- [ ] Update game UI to resolve userIds to displayNames
- [ ] Existing Basic Auth games continue to work (userName is literal)
#### 3.5 Display Name Change Support (Requires 3.4)
- [ ] Add `ChangeDisplayName` RPC
- [ ] Validate new name is unique
- [ ] Update user record
- [ ] No game migration needed (games use userId)
### Phase 3: Multi-Provider Support (Future)
#### 3.1 Account Linking UI
- [ ] Add Settings page with "Linked Accounts" section
- [ ] Show currently linked providers
- [ ] "Link Another Account" button triggers OAuth flow
- [ ] `LinkOAuthProvider` RPC adds identity to current user
#### 3.2 Login Provider Selection
- [ ] If user has multiple providers, any can be used to login
- [ ] All resolve to same userId
- [ ] Session shows which provider was used
#### 3.3 Account Merging (Complex)
- [ ] Handle case where user created separate accounts
- [ ] Merge game history, stats, etc.
- [ ] Delete duplicate user record
- [ ] This is complex - may defer or not implement
### Phase 4: Enhanced Avatars (Future)
#### 4.1 Avatar Caching
- [ ] Download avatars to S3/DO Spaces on login
- [ ] Serve from our CDN
- [ ] Refresh on login if changed
#### 4.2 Custom Avatar Upload
- [ ] Upload endpoint with size/format validation
- [ ] Store in S3/DO Spaces
- [ ] Custom avatar overrides OAuth avatar
---
## Technical Debt to Address
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
- Should Basic Auth be deprecated for production?
- Should it remain for local development only?
- How do Basic Auth users interact with OAuth users in the same game?
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
- Implement refresh token storage and validation
- Handle token refresh in client
- Consider refresh token rotation for security
4. **Session Management**: No server-side session tracking. Consider:
- Track active sessions per user
- Allow "logout all devices"
- Detect concurrent logins
---
## Open Questions
1. **What happens when a Basic Auth user and OAuth user have the same name?**
- Currently possible - Basic Auth doesn't check UserService
- Could cause confusion in games
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
2. **Should displayName changes be allowed?**
- With userId-based identity, it's safe
- But could cause confusion ("who is this new player?")
- Consider: rate limit changes, show "formerly known as" temporarily
3. **How to handle OAuth provider account deletion?**
- User deletes their Discord account
- Their Eagle0 account still exists
- They can't login unless they linked another provider
- Solution: Encourage linking multiple providers, or add email/password fallback
4. **Admin impersonation with OAuth**
- Currently works via X-Impersonate-User header
- Should this use userId or displayName?
- Probably userId for stability
---
## Appendix: File Locations
### Server (Scala)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
### Client (C#)
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
### Protos
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
+216
View File
@@ -0,0 +1,216 @@
# Shardok Latency Hiding Strategies
## Problem Statement
With Shardok running on Hetzner (Helsinki) and Eagle on DigitalOcean (US), the round-trip latency for human commands is ~200-400ms:
```
Human posts command:
Unity → Eagle (DO) → Shardok (Hetzner) → Eagle (DO) → Unity
~10ms ~100ms ~100ms ~10ms
Total: ~220ms round-trip
```
This latency is acceptable for AI turns (users watch animations anyway), but creates noticeable lag when humans post commands.
---
## Strategy 1: Client-Side Animation Masking
### Concept
Start animations immediately when the user clicks, before server confirmation arrives. The animation duration masks the network latency.
### Implementation by Command Type
**Movement Commands** (deterministic):
- Client knows the destination hex and movement path
- Start movement animation immediately on click
- Server confirms the move (should always match)
- If server rejects (invalid state), snap unit back to origin
**Attack Commands** (RNG-dependent):
- Show attack animation immediately (unit swings sword, fires arrow)
- Wait for server to return dice roll result
- Show damage numbers / hit effects when server responds
- Animation typically takes 300-500ms, masking most of the latency
**End Turn**:
- Latency not noticeable (user expects transition delay)
### Unity Implementation Sketch
```csharp
// In CommandHandler.cs
public void OnCommandSelected(Command command) {
// Start animation immediately
if (command.Type == CommandType.Move) {
unitController.StartMoveAnimation(command.TargetHex);
} else if (command.Type == CommandType.Attack) {
unitController.StartAttackAnimation(command.TargetUnit);
}
// Send to server in parallel
connection.SendCommand(command, (response) => {
if (response.Success) {
// Animation continues, apply result
ApplyCommandResult(response);
} else {
// Rollback animation
unitController.CancelAnimation();
ShowError(response.ErrorMessage);
}
});
}
```
### Pros
- Simple implementation
- No server-side changes
- Works with existing architecture
### Cons
- Doesn't eliminate latency for attacks with RNG (must wait for dice roll)
- Rollback needed if server rejects command (rare but possible)
### Estimated Improvement
- Movement: ~200ms latency hidden (feels instant)
- Attacks: ~100-200ms hidden by animation, ~100ms visible wait for dice result
---
## Strategy 2: Split Shardok Architecture
### Concept
Run two Shardok instances:
- **Shardok-Primary (DigitalOcean)**: Handles command processing, source of truth
- **Shardok-AI (Hetzner)**: AI computation only
Human commands go to the nearby Primary for low latency. AI computation uses the powerful Hetzner instance.
### Architecture
```
Human commands (low latency ~20ms)
Unity ←→ Eagle ←→ Shardok-Primary (DigitalOcean)
↓ state sync (when AI turn starts)
Shardok-AI (Hetzner)
↑ AI command response
```
### How It Works
**Human Turn:**
1. Human posts command → Eagle → Shardok-Primary (DO)
2. Primary processes command immediately (~10ms local)
3. Primary streams result to client via Eagle (~10ms)
4. **Total latency: ~20ms** (vs ~220ms current)
**AI Turn:**
1. When AI's turn starts, Primary sends game state snapshot to Hetzner
2. Shardok-AI computes best command using full CPU power
3. Shardok-AI returns command index to Primary
4. Primary executes command locally and streams to client
5. Repeat until AI turn ends
### Protocol Changes
```protobuf
// New service for AI-only computation
service ShardokAIService {
// Send game state, receive AI's chosen command
rpc GetAICommand(AICommandRequest) returns (AICommandResponse);
}
message AICommandRequest {
bytes game_state = 1; // Serialized game state
int32 player_id = 2; // Which AI player
repeated bytes available_commands = 3; // Available command descriptors
}
message AICommandResponse {
int32 command_index = 1; // Index into available_commands
int32 search_depth = 2; // For debugging
double best_score = 3; // For debugging
}
```
### Shardok-Primary Requirements
Shardok-Primary on DigitalOcean needs to:
- Process all commands (human and AI)
- Maintain authoritative game state
- Serialize/deserialize game state for AI requests
- Run on minimal CPU (command processing is fast)
This is essentially the current Shardok, but without running the AI search.
### Shardok-AI Requirements
Shardok-AI on Hetzner needs to:
- Receive game state snapshots
- Run AI evaluation (IterativeDeepeningAI or MCTS)
- Return best command index
- No persistent state (stateless worker)
### AI Turn Latency
Each AI command has ~200ms network latency. This is acceptable because:
1. User is watching animations anyway
2. Natural pacing lets user observe AI decisions
3. AI computation is fast on Hetzner's 16 cores
For a typical AI turn with 5 commands: 5 × 200ms = 1 second network overhead, plus AI thinking time. With animations, this feels natural.
### Implementation Phases
**Phase 1: Add Shardok-Primary (minimal)**
- Deploy existing Shardok container to DigitalOcean
- Configure Eagle to use local Shardok for all commands
- Human latency immediately improves
**Phase 2: Add AI offload**
- Implement `ShardokAIService` RPC
- Shardok-Primary calls Hetzner for AI commands
- Shardok-AI processes requests statelessly
**Phase 3: Optimize**
- Batch multiple AI actions if possible
- Pre-warm Shardok-AI connection
- Add fallback if Hetzner unavailable
### Pros
- Human command latency drops from ~220ms to ~20ms
- AI still gets Hetzner's CPU power
- Clear separation of concerns
- Shardok-Primary can fall back to local AI if Hetzner unavailable
### Cons
- Two Shardok instances to maintain
- State serialization overhead for AI requests
- Each AI action has network round-trip (acceptable with animations)
---
## Comparison
| Approach | Human Latency | AI Throughput | Complexity | Changes Required |
|----------|---------------|---------------|------------|------------------|
| Current | ~220ms | High | - | - |
| Animation masking | ~220ms (perceived ~50ms) | High | Low | Unity only |
| Split architecture | ~20ms | High | Medium | New RPC, two deployments |
---
## Recommendation
**Phase 1 (now)**: Implement animation masking in Unity client
- Quick win, no server changes
- Improves perceived latency significantly for movement
- Attacks still show dice animation while waiting
**Phase 2 (future)**: Split architecture if animation masking insufficient
- Only needed if users complain about attack latency
- More complex but provides true low latency
- Natural evolution of current architecture
+181
View File
@@ -0,0 +1,181 @@
# Tutorial Content Guide
This document defines all tutorial content. Edit this to refine the text, then update `TutorialContentDefinitions.cs` to match.
---
## Onboarding Sequence
Shown to first-time players. Guides them through the basics of strategic and tactical gameplay.
| Step | ID | Display | Trigger | Title | Description |
|------|-----|---------|---------|-------|-------------|
| 1 | `welcome` | Modal | Auto (game start) | Welcome to Eagle0 | Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.<br><br>Let's walk through the basics! |
| 2 | `select_province` | Overlay | Completes on: `province_selected` | The Strategic Map | This is your kingdom. Each colored region is a province.<br><br>Tap a province you control (shown in your color) to see what you can do there. |
| 3 | `province_panel` | Modal | Button click | Province Information | This panel shows province details: its name, terrain, any armies present, and the commands available to you.<br><br>Commands let you move troops, recruit heroes, and more. |
| 4 | `try_march` | Overlay | Completes on: `command_issued` | Issue a Command | Try issuing a March command to move your army to an adjacent province.<br><br>Select a destination and confirm the order. |
| 5 | `turn_cycle` | Modal | Button click | The Turn Cycle | Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.<br><br>When all players are ready, the server processes everyone's commands and shows the results. |
| 6 | `wait_for_battle` | Hidden | Completes on: `first_battle_available` | *(none)* | *(Invisible step - waits for a battle to become available)* |
| 7 | `battle_intro` | Modal | Button click | Battle Time! | When armies collide, you'll fight tactical battles on a hex grid.<br><br>You command individual units - infantry, cavalry, archers, and heroes with special abilities. |
| 8 | `enter_battle` | Overlay | Completes on: `battle_entered` | Enter the Battle | Tap the Battle button to enter tactical combat. |
| 9 | `tactical_overview` | Modal | Button click | Tactical Combat | Each unit has movement points and attack power. Position your troops wisely!<br><br>Units attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage. |
| 10 | `move_unit` | Overlay | Completes on: `battle_action` | Move Your Units | Tap one of your units to select it, then tap a highlighted hex to move there.<br><br>Blue hexes show where you can move. |
| 11 | `attack_enemy` | Overlay | Completes on: `battle_action` | Attack! | Move next to an enemy unit, then tap the enemy to attack.<br><br>Red highlights show valid attack targets. |
| 12 | `end_turn` | Overlay | Completes on: `turn_ended` | End Your Turn | When you've moved all units or want to pass, tap End Turn.<br><br>The enemy will then take their turn. |
| 13 | `complete` | Modal | Button click (no skip) | You're Ready! | You now know the basics of Eagle0!<br><br>Explore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander! |
### Notes on Onboarding Flow
- Steps 1-5 cover strategic gameplay
- Step 6 is invisible - just waits for a battle
- Steps 7-12 cover tactical combat
- Step 13 celebrates completion
**Questions to consider:**
- Should we skip tactical tutorial if player skips to first battle themselves?
- Should there be a "skip all" option visible from step 1?
- Is the step order correct for typical first-game flow?
---
## Strategic Contextual Tutorials
Triggered when players encounter features for the first time.
### Diplomacy Introduction
| Field | Value |
|-------|-------|
| ID | `diplomacy_intro` |
| Trigger | `diplomacy_available` (diplomacy commands appear) |
| Display | Modal |
| Title | Diplomacy |
| Description | You can negotiate with other factions!<br><br>Offer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm. |
### Hero Recruitment
| Field | Value |
|-------|-------|
| ID | `hero_recruitment` |
| Trigger | `hero_recruitment_available` (free heroes detected) |
| Display | Modal |
| Title | Heroes Available |
| Description | Free heroes wander the realm seeking a lord to serve.<br><br>Recruit them to lead your armies! Heroes have unique abilities and grow stronger with experience. |
### Weather Control
| Field | Value |
|-------|-------|
| ID | `weather_control` |
| Trigger | `weather_control_available` (weather command appears) |
| Display | Overlay |
| Title | Weather Magic |
| Description | Your mages can influence the weather!<br><br>Rain slows movement, storms disrupt enemies, and clear skies speed your march. |
### Prisoner Management
| Field | Value |
|-------|-------|
| ID | `prisoner_management` |
| Trigger | `prisoner_command_issued` (player uses prisoner command) |
| Display | Modal |
| Title | Prisoners Captured |
| Description | You've captured enemy soldiers!<br><br>You can ransom them for gold, recruit them into your army, or execute them as a warning. |
---
## Tactical Contextual Tutorials
Triggered during battles when players encounter spells, terrain, or abilities.
### Lightning Bolt Spell
| Field | Value |
|-------|-------|
| ID | `spell_lightning` |
| Trigger | `spell_lightning_available` |
| Display | Tooltip |
| Title | Lightning Bolt |
| Description | Your mage can cast Lightning Bolt!<br><br>This spell strikes a single target for heavy damage. Great for eliminating key enemy units. |
### Meteor Strike Spell
| Field | Value |
|-------|-------|
| ID | `spell_meteor` |
| Trigger | `spell_meteor_available` |
| Display | Modal |
| Title | Meteor Strike |
| Description | Meteor is a devastating area spell!<br><br>It takes a turn to cast: first select target, then it lands next turn. Plan ahead! |
### Holy Wave Spell
| Field | Value |
|-------|-------|
| ID | `spell_holywave` |
| Trigger | `spell_holywave_available` |
| Display | Tooltip |
| Title | Holy Wave |
| Description | Holy Wave heals your units and damages undead!<br><br>Position your troops carefully to maximize its effect. |
### Raise Dead Spell
| Field | Value |
|-------|-------|
| ID | `spell_raisedead` |
| Trigger | `spell_raisedead_available` |
| Display | Modal |
| Title | Raise Dead |
| Description | Dark magic can raise fallen soldiers as undead!<br><br>They fight for you, but beware - they may crumble if your necromancer falls. |
### Fire Terrain
| Field | Value |
|-------|-------|
| ID | `terrain_fire` |
| Trigger | `terrain_fire_encountered` (fire damage occurs) |
| Display | Tooltip |
| Title | Fire Hazard |
| Description | Fire spreads across the battlefield!<br><br>Units in burning hexes take damage. Use fire to block enemy routes or avoid it yourself. |
### Water Crossing
| Field | Value |
|-------|-------|
| ID | `terrain_water` |
| Trigger | `terrain_water_encountered` (water crossing attempted) |
| Display | Tooltip |
| Title | Water Crossing |
| Description | Units can cross shallow water, but it's risky.<br><br>Crossing takes extra movement and may fail. Some units swim better than others. |
### Cavalry Charge
| Field | Value |
|-------|-------|
| ID | `ability_charge` |
| Trigger | `ability_charge_available` |
| Display | Overlay |
| Title | Cavalry Charge |
| Description | Your cavalry can Charge!<br><br>Charging deals bonus damage based on distance traveled. Use open terrain for maximum impact. |
---
## Display Modes
| Mode | Description | Use For |
|------|-------------|---------|
| **Modal** | Full popup with dimmed background, blocks interaction | Important concepts, multi-paragraph explanations |
| **Overlay** | Semi-transparent overlay, can highlight UI elements | Guiding player to interact with specific UI |
| **Tooltip** | Small popup near target element | Quick tips, less important info |
| **Hint** | Pulsing dot indicator only | Subtle suggestions |
| **None** | Invisible, just waits for event | Transition steps |
---
## Adding New Tutorials
1. Add entry to this document
2. Update `TutorialContentDefinitions.cs`:
- For onboarding: add to `CreateOnboardingSequence()`
- For contextual: add to `RegisterStrategicTutorials()` or `RegisterTacticalTutorials()`
3. Ensure trigger event exists in `TutorialTriggerRegistry.cs`
4. Test the flow
---
## Content Guidelines
- Keep descriptions to 2-3 short paragraphs max
- Use `<br><br>` for paragraph breaks (renders as newlines in Unity)
- Avoid jargon - explain game terms when first introduced
- Be encouraging, not condescending
- Focus on "what to do" not exhaustive "how it works"
+2
View File
@@ -9,6 +9,8 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.28.10
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.36.3
)
+4
View File
@@ -34,9 +34,13 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dv
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+33 -2
View File
@@ -1,7 +1,7 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
"__INPUT_ARTIFACTS_HASH": -1064460283,
"__RESOLVED_ARTIFACTS_HASH": -1574144850,
"__INPUT_ARTIFACTS_HASH": -2049857450,
"__RESOLVED_ARTIFACTS_HASH": -1728186926,
"conflict_resolution": {
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
@@ -413,6 +413,12 @@
},
"version": "0.27.0"
},
"io.sentry:sentry": {
"shasums": {
"jar": "740a118182fc089d307830f4e508372e01ad94639b00b4e1b1d83762298a5f35"
},
"version": "7.19.0"
},
"javax.activation:javax.activation-api": {
"shasums": {
"jar": "43fdef0b5b6ceb31b0424b208b930c74ab58fac2ceeb7b3f6fd3aeb8b5ca4393"
@@ -1785,6 +1791,30 @@
"io.perfmark:perfmark-api": [
"io.perfmark"
],
"io.sentry:sentry": [
"io.sentry",
"io.sentry.backpressure",
"io.sentry.cache",
"io.sentry.clientreport",
"io.sentry.config",
"io.sentry.exception",
"io.sentry.hints",
"io.sentry.instrumentation.file",
"io.sentry.internal.debugmeta",
"io.sentry.internal.gestures",
"io.sentry.internal.modules",
"io.sentry.internal.viewhierarchy",
"io.sentry.metrics",
"io.sentry.profilemeasurements",
"io.sentry.protocol",
"io.sentry.rrweb",
"io.sentry.transport",
"io.sentry.util",
"io.sentry.util.thread",
"io.sentry.vendor",
"io.sentry.vendor.gson.internal.bind.util",
"io.sentry.vendor.gson.stream"
],
"javax.activation:javax.activation-api": [
"javax.activation"
],
@@ -2452,6 +2482,7 @@
"io.opencensus:opencensus-contrib-grpc-metrics",
"io.opencensus:opencensus-contrib-http-util",
"io.perfmark:perfmark-api",
"io.sentry:sentry",
"javax.activation:javax.activation-api",
"javax.xml.bind:jaxb-api",
"joda-time:joda-time",
+139 -11
View File
@@ -3,6 +3,9 @@ events {
}
http {
# Allow large request bodies for game uploads (default is 1MB)
client_max_body_size 50M;
# Logging
log_format grpc_json escape=json '{'
'"time":"$time_iso8601",'
@@ -24,15 +27,18 @@ 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 - blue-green deployment with variable-based routing
# Uses a variable so nginx only resolves the configured backend (not all backends).
# This allows nginx to start/reload even when the inactive backend is stopped.
# The deploy script updates this map, then recreates nginx.
map $host $eagle_backend {
default "eagle-blue:40032";
}
# HTTP server for Let's Encrypt challenge and redirect
server {
listen 80;
listen [::]:80;
server_name prod.eagle0.net;
# Let's Encrypt challenge
@@ -49,6 +55,7 @@ http {
# HTTPS server for gRPC
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name prod.eagle0.net;
@@ -69,8 +76,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
grpc_pass grpc://$eagle_backend;
# Timeouts for long-running streams
grpc_read_timeout 1200s;
@@ -81,13 +88,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;
@@ -97,9 +105,16 @@ http {
error_page 502 = /error502grpc;
}
# OAuth callback endpoint (proxied to Eagle's HTTP handler)
# OAuth callback endpoint (proxied to Go auth service)
location /oauth/callback {
proxy_pass http://eagle:8080;
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Invitation landing page (proxied to Go auth service)
location /invite/ {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
@@ -120,4 +135,117 @@ http {
return 204;
}
}
# HTTPS server for Go Auth service (port 40033)
# Clients connect here directly for OAuth RPCs in Phase 2
server {
listen 40033 ssl;
listen [::]:40033 ssl;
http2 on;
server_name prod.eagle0.net;
# SSL certificates (same as main server)
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# gRPC proxy for Auth service
# Uses variable-based resolution so nginx can start even if auth isn't ready yet
# DNS is cached by the resolver directive (valid=10s)
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# Dynamic upstream resolution (doesn't block nginx startup)
set $auth_backend "auth:40033";
grpc_pass grpc://$auth_backend;
# Timeouts
grpc_read_timeout 30s;
grpc_send_timeout 30s;
# Error handling
error_page 502 = /error502grpc;
}
# gRPC proxy for Admin service
location /net.eagle0.eagle.api.admin.Admin {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# Dynamic upstream resolution (doesn't block nginx startup)
set $auth_backend "auth:40033";
grpc_pass grpc://$auth_backend;
# Timeouts
grpc_read_timeout 30s;
grpc_send_timeout 30s;
# Error handling
error_page 502 = /error502grpc;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# gRPC error handling
location = /error502grpc {
internal;
default_type application/grpc;
add_header grpc-status 14;
add_header grpc-message "unavailable";
return 204;
}
}
# HTTP server for Admin Console (Let's Encrypt + redirect)
server {
listen 80;
listen [::]:80;
server_name admin.prod.eagle0.net admin.eagle0.net;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for Admin Console
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name admin.prod.eagle0.net admin.eagle0.net;
ssl_certificate /etc/letsencrypt/live/admin.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.eagle0.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
location / {
proxy_pass http://admin:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
+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"
+383
View File
@@ -0,0 +1,383 @@
#!/bin/bash
#
# Blue-Green Deployment Script for Eagle Server
#
# This script performs a zero-downtime deployment with state consistency:
# 1. Create .deployment_in_progress marker (signals deployment started)
# 2. Start the staging instance (green) with new image
# 3. Run warmup/smoke tests against staging (warms JIT)
# 4. Switch nginx to staging (zero downtime - users immediately route to staging)
# 5. Stop the active instance (blue) - blocks until flush completes
# 6. Create .flush_complete marker (signals disk state is fresh)
#
# The flush marker coordination ensures green never serves stale game data:
# - When users reconnect to green and trigger lazy-load, the code checks for markers
# - If .deployment_in_progress exists, lazy-load WAITS for .flush_complete
# - Once blue's flush completes and marker is created, lazy-load proceeds with fresh data
#
# Key insight: nginx switches to green BEFORE blue stops, achieving zero downtime.
# Users who trigger lazy-load during blue's shutdown will wait for the flush marker.
#
# 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"
SAVES_DIR="${APP_DIR}/saves"
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
# 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"; }
# Marker file operations use docker exec because saves directory is owned by root (Docker).
# We run commands inside a container that has the saves directory mounted.
create_deployment_marker() {
local deploy_id=$1
local container=$2 # Container to use for file operations
docker exec "${container}" rm -f /app/saves/.flush_complete
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.deployment_in_progress"
}
create_flush_marker() {
local deploy_id=$1
local container=$2 # Container to use for file operations
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.flush_complete"
docker exec "${container}" rm -f /app/saves/.deployment_in_progress
}
cleanup_markers_on_failure() {
local container=$1 # Container to use for file operations
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
docker exec "${container}" touch /app/saves/.flush_complete 2>/dev/null || true
}
remove_stale_deployment_marker() {
# Try any running eagle container
local container
container=$(docker ps --filter "name=eagle-" --format "{{.Names}}" | head -1)
if [ -n "${container}" ]; then
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
fi
}
# Determine which instance is currently running (not from nginx config)
get_running_instance() {
local blue_running green_running
blue_running=$(docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null || echo "false")
green_running=$(docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null || echo "false")
if [ "$blue_running" = "true" ] && [ "$green_running" = "true" ]; then
# Both running - use nginx config to determine primary
if grep -q "server eagle-blue:40032;" "${NGINX_CONF}" | head -1 | grep -qv backup; then
echo "blue"
else
echo "green"
fi
elif [ "$blue_running" = "true" ]; then
echo "blue"
elif [ "$green_running" = "true" ]; then
echo "green"
else
# Neither running - default to blue (first deploy or recovery)
echo "none"
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
# Skip pull if image already exists locally (e.g., CI already pulled it)
if docker image inspect "${image}" &>/dev/null; then
log_info "Image ${image} already exists locally, skipping pull"
return 0
fi
# 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}"
# Generate deployment ID for log correlation with server logs
local deploy_id
deploy_id=$(date +%s)
local deploy_start_time=$deploy_id
log_info "========================================="
log_info "Starting blue-green deployment"
log_info "Deployment ID: ${deploy_id}"
log_info "New image: ${new_image}"
log_info "========================================="
cd "${APP_DIR}"
# Determine current active instance (need this before creating marker)
local active=$(get_running_instance)
local staging
if [ "$active" = "blue" ] || [ "$active" = "none" ]; then
staging="green"
active="blue" # Normalize "none" to "blue" for first deploy
else
staging="blue"
fi
# Step 1: Signal deployment in progress
log_info "[DEPLOY:${deploy_id}] Step 1: Signaling deployment in progress..."
# Use active container for marker operations (it's the one currently running)
if [ "$active" != "none" ] && docker ps --filter "name=eagle-${active}" --format "{{.Names}}" | grep -q .; then
create_deployment_marker "${deploy_id}" "eagle-${active}"
log_info "[DEPLOY:${deploy_id}] Deployment marker created via eagle-${active}"
else
log_warn "[DEPLOY:${deploy_id}] No running container to create marker (first deploy?)"
fi
# 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"
cleanup_markers_on_failure "eagle-${active}"
exit 1
fi
# Step 2: Start staging instance with new image
log_info "Step 2: 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}"
cleanup_markers_on_failure "eagle-${active}"
exit 1
fi
# Step 3: Run warmup/smoke test
local staging_port
if [ "$staging" = "green" ]; then
staging_port=40034
else
staging_port=40032
fi
log_info "Step 3: 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}"
cleanup_markers_on_failure "eagle-${active}"
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
# Step 4: Switch nginx to staging BEFORE stopping active
# This achieves zero downtime - users immediately route to staging.
# Any lazy-loads will wait for the flush marker (created in step 6).
local nginx_switch_start
nginx_switch_start=$(date +%s)
log_info "[DEPLOY:${deploy_id}] Step 4: Switching nginx to eagle-${staging}..."
# Update nginx config (variable-based routing)
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
# Recreate nginx to pick up new config
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
# Verify nginx picked up the correct config
local nginx_backend
nginx_backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf | head -1 || echo "unknown")
if [ "$nginx_backend" = "eagle-${staging}:40032" ]; then
log_info "[DEPLOY:${deploy_id}] Verified: nginx routing to eagle-${staging}"
else
log_error "[DEPLOY:${deploy_id}] nginx config mismatch! Expected eagle-${staging}:40032, got ${nginx_backend}"
exit 1
fi
log_info "[DEPLOY:${deploy_id}] Traffic switched to eagle-${staging} (lazy-loads will wait for flush marker)"
local nginx_switch_end
nginx_switch_end=$(date +%s)
# Step 5: Stop active instance (blocks until exit, ensuring flush completes)
# Users may be lazy-loading on staging during this time - they'll wait for the marker.
local flush_start
flush_start=$(date +%s)
log_info "[DEPLOY:${deploy_id}] Step 5: Stopping eagle-${active} (waiting for flush)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
local flush_end
flush_end=$(date +%s)
local flush_duration=$((flush_end - flush_start))
log_info "[DEPLOY:${deploy_id}] eagle-${active} stopped, flush completed in ${flush_duration}s"
# Step 6: Create flush marker - signals that disk state is fresh
# Any waiting lazy-loads on staging will now proceed with fresh data.
# The Eagle server automatically detects the flush marker update and invalidates any stale cached games.
log_info "[DEPLOY:${deploy_id}] Step 6: Creating flush marker..."
create_flush_marker "${deploy_id}" "eagle-${staging}"
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
# Update .env 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 .env
log_info "Restarting admin service..."
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
# Clean up old instance
log_info "Cleaning up old eagle-${active}..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" 2>/dev/null || true
# Stop the old jfr-sidecar (it can't attach to removed container anyway)
if [ "$active" = "green" ]; then
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar-green" 2>/dev/null || true
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar-green" 2>/dev/null || true
else
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar" 2>/dev/null || true
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar" 2>/dev/null || true
fi
local deploy_end_time
deploy_end_time=$(date +%s)
local total_duration=$((deploy_end_time - deploy_start_time))
local user_wait_window=$((flush_end - nginx_switch_end))
log_info ""
log_info "========================================="
log_info "[DEPLOY:${deploy_id}] Deployment complete!"
log_info " Active instance: eagle-${staging}"
log_info " Total duration: ${total_duration}s"
log_info " Flush duration: ${flush_duration}s"
log_info " Max user wait window: ${user_wait_window}s"
log_info "========================================="
}
# 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 saves directory exists
if [ ! -d "${SAVES_DIR}" ]; then
log_info "Creating saves directory at ${SAVES_DIR}"
mkdir -p "${SAVES_DIR}"
fi
# Clean up any stale deployment-in-progress marker from a previous failed deploy
if [ -f "${DEPLOYMENT_IN_PROGRESS}" ]; then
log_warn "Found stale deployment-in-progress marker, removing it"
remove_stale_deployment_marker
fi
}
# 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 (use -f to avoid failure if already deleted)
rm -f "$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"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
#
# Submit a macOS .app bundle to Apple for notarization (no waiting)
# Usage: notarize_submit.sh <app_path>
# Outputs: submission_id=<id> to stdout (for GitHub Actions)
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euo pipefail
APP_PATH="$1"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH" >&2
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set" >&2
echo " APPLE_ID: ${APPLE_ID:-<not set>}" >&2
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}" >&2
echo " TEAM_ID: ${TEAM_ID:-<not set>}" >&2
exit 1
fi
# Create ZIP for notarization submission
ZIP_PATH="${APP_PATH%.app}.zip"
echo "=== Creating ZIP for notarization: $ZIP_PATH ===" >&2
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "=== Submitting to Apple for notarization ===" >&2
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" 2>&1)
echo "$SUBMIT_OUTPUT" >&2
# Extract submission ID
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
if [ -z "$SUBMISSION_ID" ]; then
echo "ERROR: Failed to get submission ID" >&2
exit 1
fi
# Clean up the zip
rm "$ZIP_PATH"
echo "Submission ID: $SUBMISSION_ID" >&2
# Output for GitHub Actions
echo "submission_id=$SUBMISSION_ID"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# Wait for Apple notarization to complete and staple the ticket
# Usage: notarize_wait.sh <submission_id> <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 -euo pipefail
SUBMISSION_ID="$1"
APP_PATH="$2"
if [ -z "$SUBMISSION_ID" ]; then
echo "ERROR: submission_id is required" >&2
exit 1
fi
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH" >&2
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set" >&2
exit 1
fi
echo "=== Waiting for notarization of submission $SUBMISSION_ID ==="
WAIT_OUTPUT=$(xcrun notarytool wait "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" 2>&1) || true
echo "$WAIT_OUTPUT"
# Extract status (look for " status:" to avoid matching "Current status:")
STATUS=$(echo "$WAIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
echo "Status: $STATUS"
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"
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
bazel run //src/main/go/net/eagle0/build/action_result_type_build_file_generator \
${PWD}/src/main/scala/net/eagle0/eagle/model/action_result/types/
+143
View File
@@ -0,0 +1,143 @@
#!/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}"
# Use 5 minute timeout to allow for slow operations on cold JVM
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=300s; 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"
@@ -14,6 +14,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/server:eagle_interface_grpc_server",
"//src/main/cpp/net/eagle0/shardok/server:server_configuration",
"//src/main/cpp/net/eagle0/shardok/server:token_auth",
"//src/main/protobuf/net/eagle0/common:common_unit_cc_proto",
"@grpc//:grpc++",
],
@@ -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);
@@ -191,6 +191,7 @@ auto MoveCommand::GetCommandProto() const -> CommandProto {
proto.mutable_action_points()->set_value(pointCost);
proto.mutable_actor()->set_value(moverId);
*proto.mutable_target() = ToCoordsProto(interimTargets.back());
for (const auto& coords : interimTargets) { *proto.add_path() = ToCoordsProto(coords); }
for (const auto& fup : followUpCommandTypes) { proto.add_follow_up_command_types(fup); }
proto.set_will_unhide(willUnhide);
@@ -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);
@@ -34,6 +34,17 @@ cc_library(
],
)
cc_library(
name = "token_auth",
hdrs = ["TokenAuthInterceptor.hpp"],
copts = COPTS,
visibility = ["//src/main/cpp/net/eagle0/shardok:__subpackages__"],
deps = [
"//src/main/cpp/net/eagle0/common:protobuf_warning_suppression",
"@grpc//:grpc++",
],
)
cc_library(
name = "eagle_interface_grpc_server",
srcs = ["EagleInterfaceGrpcServer.cpp"],
@@ -42,6 +53,7 @@ cc_library(
visibility = ["//src/main/cpp/net/eagle0/shardok:__subpackages__"],
deps = [
":games_manager",
":token_auth",
"//src/main/cpp/net/eagle0/common:unit_conversions",
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_cc_grpc",
"//src/main/protobuf/net/eagle0/common:victory_condition_cc_proto",
@@ -81,8 +81,17 @@ static auto FromInternalStatus(
const std::string &kShardokGameRequestExtension = *(new string(".e0gr"));
EagleInterfaceImpl::EagleInterfaceImpl(shared_ptr<ShardokGamesManager> manager)
: gamesManager(std::move(manager)) {}
EagleInterfaceImpl::EagleInterfaceImpl(
shared_ptr<ShardokGamesManager> manager,
std::string authToken)
: gamesManager(std::move(manager)),
tokenValidator_(std::move(authToken)) {
if (tokenValidator_.IsEnabled()) {
std::cout << "Token authentication enabled" << std::endl;
} else {
std::cout << "Token authentication disabled (no token configured)" << std::endl;
}
}
auto ConvertPlayerInfo(
const google::protobuf::RepeatedPtrField<net::eagle0::common::PlayerSetupInfo> &allPis,
@@ -188,9 +197,12 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) {
}
auto EagleInterfaceImpl::PostCommand(
ServerContext * /*context*/,
ServerContext *context,
const PostCommandRequest *request,
GameStatusResponse *response) -> Status {
// Validate auth token
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
@@ -229,9 +241,12 @@ auto EagleInterfaceImpl::PostCommand(
}
auto EagleInterfaceImpl::PostPlacementCommands(
ServerContext * /*context*/,
ServerContext *context,
const PlacementCommandsRequest *request,
GameStatusResponse *response) -> Status {
// Validate auth token
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
@@ -323,9 +338,12 @@ void EagleInterfaceImpl::PopulateGameStatusResponse(
}
auto EagleInterfaceImpl::GetHexMap(
ServerContext * /*context*/,
ServerContext *context,
const HexMapRequest *request,
HexMapResponse *response) -> Status {
// Validate auth token
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
// TODO: return appropriate status code if a bad map name is sent
*response->mutable_map() = LoadMap(request->map_name());
@@ -333,9 +351,12 @@ auto EagleInterfaceImpl::GetHexMap(
}
auto EagleInterfaceImpl::GetHexMapNames(
ServerContext * /*context*/,
ServerContext *context,
const HexMapNamesRequest * /*request*/,
HexMapNamesResponse *response) -> Status {
// Validate auth token
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
for (const string &mapName : GetMapNames()) { response->add_map_names(mapName); }
return Status::OK;
@@ -516,6 +537,9 @@ auto EagleInterfaceImpl::SubscribeToGame(
ServerContext *context,
const GameSubscriptionRequest *request,
grpc::ServerWriter<GameStatusResponse> *writer) -> Status {
// Validate auth token
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
@@ -13,8 +13,9 @@
#include <string>
#include <thread>
#include "ShardokGamesManager.hpp"
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
#include "src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp"
#include "src/main/cpp/net/eagle0/shardok/server/TokenAuthInterceptor.hpp"
#pragma GCC diagnostic push
SUPPRESS_PROTOBUF_WARNINGS
#include <grpc/grpc.h>
@@ -46,6 +47,7 @@ using std::shared_ptr;
class EagleInterfaceImpl final : public ShardokInternalInterface::Service {
private:
std::shared_ptr<ShardokGamesManager> gamesManager;
TokenValidator tokenValidator_;
auto ControllerForGame(const GameId& gameId, const GameSetupInfo& setupInfo)
-> std::shared_ptr<ShardokGameController>;
@@ -57,7 +59,14 @@ private:
GameStatusResponse* response);
public:
explicit EagleInterfaceImpl(std::shared_ptr<ShardokGamesManager> manager);
/**
* Create the service.
*
* @param manager Games manager
* @param authToken Optional auth token. If non-empty, all requests must include
* "authorization: Bearer <token>" metadata.
*/
EagleInterfaceImpl(std::shared_ptr<ShardokGamesManager> manager, std::string authToken = "");
auto PostCommand(
ServerContext* context,
@@ -24,6 +24,7 @@ const string ServerConfiguration::kEagleInterfaceGrpcAddress = "eagleInterfaceGr
const string ServerConfiguration::kEagleGrpcAddress = "eagleGrpcAddress";
const string ServerConfiguration::kSslCertPath = "sslCertPath";
const string ServerConfiguration::kSslPrivateKeyPath = "sslPrivateKeyPath";
const string ServerConfiguration::kAuthTokenPath = "authTokenPath";
#pragma GCC diagnostic pop
using std::unordered_map;
@@ -29,6 +29,7 @@ public:
const static string kEagleGrpcAddress;
const static string kSslCertPath;
const static string kSslPrivateKeyPath;
const static string kAuthTokenPath;
};
#endif /* ServerConfiguration_hpp */
@@ -0,0 +1,119 @@
//
// TokenAuthInterceptor.hpp
// eagle0
//
// Token-based authentication for gRPC.
// Validates Bearer tokens in the 'authorization' metadata header.
//
#ifndef TokenAuthInterceptor_hpp
#define TokenAuthInterceptor_hpp
#include <fstream>
#include <iostream>
#include <optional>
#include <sstream>
#include <string>
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
#pragma GCC diagnostic push
SUPPRESS_PROTOBUF_WARNINGS
#include <grpcpp/server_context.h>
#include <grpcpp/support/status.h>
#pragma GCC diagnostic pop
namespace shardok {
/**
* Validates Bearer tokens in gRPC request metadata.
*
* Usage:
* TokenValidator validator(expectedToken);
* if (!validator.Validate(context)) {
* return grpc::Status(grpc::UNAUTHENTICATED, "Invalid token");
* }
*/
class TokenValidator {
public:
/**
* Create a validator with the expected token.
* If expectedToken is empty, all requests are allowed (no auth required).
*/
explicit TokenValidator(std::string expectedToken) : expectedToken_(std::move(expectedToken)) {}
/**
* Validate the authorization header in the server context.
*
* Expects: "authorization" metadata with value "Bearer <token>"
*
* @return true if valid (or no auth configured), false otherwise
*/
[[nodiscard]] auto Validate(grpc::ServerContext* context) const -> bool {
// If no auth token is configured, allow all requests
if (expectedToken_.empty()) { return true; }
const auto& metadata = context->client_metadata();
auto it = metadata.find("authorization");
if (it == metadata.end()) {
std::cerr << "Auth failed: no authorization header" << std::endl;
return false;
}
std::string authHeader(it->second.begin(), it->second.end());
std::string expectedHeader = "Bearer " + expectedToken_;
if (authHeader != expectedHeader) {
std::cerr << "Auth failed: invalid token" << std::endl;
return false;
}
return true;
}
/**
* Validate and return appropriate Status.
*
* @return std::nullopt if valid, Status(UNAUTHENTICATED) if invalid
*/
[[nodiscard]] auto ValidateOrStatus(grpc::ServerContext* context) const
-> std::optional<grpc::Status> {
if (Validate(context)) { return std::nullopt; }
return grpc::Status(grpc::UNAUTHENTICATED, "Invalid or missing authentication token");
}
/**
* Check if authentication is enabled.
*/
[[nodiscard]] auto IsEnabled() const -> bool { return !expectedToken_.empty(); }
private:
std::string expectedToken_;
};
/**
* Read auth token from a file, stripping whitespace.
* Returns empty string if file doesn't exist or is empty.
*/
inline auto ReadAuthTokenFromFile(const std::string& filePath) -> std::string {
if (filePath.empty()) { return ""; }
std::ifstream file(filePath);
if (!file.is_open()) {
std::cerr << "Warning: Could not open auth token file: " << filePath << std::endl;
return "";
}
std::stringstream buffer;
buffer << file.rdbuf();
std::string token = buffer.str();
// Trim whitespace
auto start = token.find_first_not_of(" \t\n\r");
if (start == std::string::npos) { return ""; }
auto end = token.find_last_not_of(" \t\n\r");
return token.substr(start, end - start + 1);
}
} // namespace shardok
#endif /* TokenAuthInterceptor_hpp */
@@ -7,7 +7,9 @@
//
#include <cstddef>
#include <fstream>
#include <memory>
#include <sstream>
#include <thread>
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
@@ -17,14 +19,16 @@ SUPPRESS_PROTOBUF_WARNINGS
#include <execinfo.h>
#include <grpcpp/channel.h>
#include <grpcpp/security/server_credentials.h>
#include <signal.h>
#include <unistd.h>
#pragma GCC diagnostic pop
#include "server/EagleInterfaceGrpcServer.hpp"
#include "server/ServerConfiguration.hpp"
#include "server/ShardokGamesManager.hpp"
#include "src/main/cpp/net/eagle0/shardok/server/EagleInterfaceGrpcServer.hpp"
#include "src/main/cpp/net/eagle0/shardok/server/ServerConfiguration.hpp"
#include "src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp"
#include "src/main/cpp/net/eagle0/shardok/server/TokenAuthInterceptor.hpp"
using ::GameStatePersister;
using shardok::EagleInterfaceImpl;
@@ -41,12 +45,15 @@ struct ServerThreadInfo {
auto StartInThread(grpc::ServerBuilder *serverBuilder) -> ServerThreadInfo;
auto CreateServerBuilder(const string &serverAddress) -> grpc::ServerBuilder *;
auto CreateServerBuilder(const string &serverAddress, const string &certPath, const string &keyPath)
-> grpc::ServerBuilder *;
auto CreateEagleInterfaceService(
const std::shared_ptr<ServerConfiguration> &config,
std::shared_ptr<ShardokGamesManager> shardokGamesManager) -> ServerThreadInfo;
auto ReadFileContents(const string &path) -> string;
void handler(const int sig) {
void *array[10];
size_t size;
@@ -82,11 +89,46 @@ auto main(const int argc, char **argv) -> int {
eagleInterfaceInfo.thread.join();
}
auto CreateServerBuilder(const string &serverAddress) -> grpc::ServerBuilder * {
auto ReadFileContents(const string &path) -> string {
std::ifstream file(path);
if (!file.is_open()) { return ""; }
std::stringstream buffer;
buffer << file.rdbuf();
return buffer.str();
}
auto CreateServerBuilder(const string &serverAddress, const string &certPath, const string &keyPath)
-> grpc::ServerBuilder * {
auto *serverBuilder = new grpc::ServerBuilder;
const auto credentials = grpc::InsecureServerCredentials();
std::cout << "Creating insecure connection" << std::endl;
std::shared_ptr<grpc::ServerCredentials> credentials;
// Check if TLS is configured
if (!certPath.empty() && !keyPath.empty()) {
const string certContents = ReadFileContents(certPath);
const string keyContents = ReadFileContents(keyPath);
if (certContents.empty() || keyContents.empty()) {
std::cerr << "Error: Could not read TLS certificate or key file" << std::endl;
std::cerr << " cert path: " << certPath << std::endl;
std::cerr << " key path: " << keyPath << std::endl;
std::cerr << "Falling back to insecure connection" << std::endl;
credentials = grpc::InsecureServerCredentials();
} else {
grpc::SslServerCredentialsOptions::PemKeyCertPair keyCert;
keyCert.private_key = keyContents;
keyCert.cert_chain = certContents;
grpc::SslServerCredentialsOptions sslOpts;
sslOpts.pem_key_cert_pairs.push_back(keyCert);
credentials = grpc::SslServerCredentials(sslOpts);
std::cout << "TLS enabled with certificate from: " << certPath << std::endl;
}
} else {
credentials = grpc::InsecureServerCredentials();
std::cout << "Creating insecure connection (no TLS configured)" << std::endl;
}
serverBuilder->AddListeningPort(serverAddress, credentials);
return serverBuilder;
@@ -98,9 +140,19 @@ auto CreateEagleInterfaceService(
const string eagleInterfaceServerAddress =
config->stringForKey(ServerConfiguration::kEagleInterfaceGrpcAddress);
auto *eagleInterfaceServerBuilder = CreateServerBuilder(eagleInterfaceServerAddress);
// TLS configuration
const string certPath = config->stringForKey(ServerConfiguration::kSslCertPath);
const string keyPath = config->stringForKey(ServerConfiguration::kSslPrivateKeyPath);
const auto eagleInterfaceService = std::make_shared<EagleInterfaceImpl>(shardokGamesManager);
auto *eagleInterfaceServerBuilder =
CreateServerBuilder(eagleInterfaceServerAddress, certPath, keyPath);
// Auth token configuration
const string authTokenPath = config->stringForKey(ServerConfiguration::kAuthTokenPath);
const string authToken = shardok::ReadAuthTokenFromFile(authTokenPath);
const auto eagleInterfaceService =
std::make_shared<EagleInterfaceImpl>(shardokGamesManager, authToken);
eagleInterfaceServerBuilder->RegisterService(eagleInterfaceService.get());
ServerThreadInfo eagleInterfaceThreadInfo = StartInThread(eagleInterfaceServerBuilder);
@@ -64,6 +64,7 @@
<Compile Include="Assets/common/CommonExtensions.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Modal Window/ModalWindowTabs.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/CapturedHeroImprisonedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/MoveAnimator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/SuppressBeastsCommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveInvitationCommandSelector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/SliderManager.cs" />
@@ -92,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" />
@@ -106,21 +108,25 @@
<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" />
<Compile Include="Assets/Shardok/ArrowVolleyAnimator.cs" />
<Compile Include="Assets/Eagle/Table Rows/UnaffiliatedHeroRowController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Tooltip/TooltipContent.cs" />
<Compile Include="Assets/Eagle/ConnectionCircuitBreaker.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerHSelector.cs" />
<Compile Include="Assets/Shardok/Grid.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ArmTroopsCommandSelector.cs" />
<Compile Include="Assets/Shardok/RaiseDeadAnimator.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/PrisonerExiledDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBar.cs" />
<Compile Include="Assets/Eagle/Table Rows/OrganizeTroopsComponentRow.cs" />
<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" />
@@ -131,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" />
@@ -148,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" />
@@ -158,6 +167,7 @@
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
<Compile Include="Assets/Auth/JwtAuthInterceptor.cs" />
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialContentDefinitions.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ShatteredArmyDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/TurnHistoryPanelController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/HeroDepartureDetailsNotificationGenerator.cs" />
@@ -180,6 +190,7 @@
<Compile Include="Assets/Shardok/Table Rows/ReserveRowController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/OutlawSpottedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Fixes/UIElementInFront.cs" />
<Compile Include="Assets/Shardok/MeleeAnimator.cs" />
<Compile Include="Assets/ConnectionHandler/WaitingGameItem.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicIcon.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerSlider.cs" />
@@ -193,10 +204,13 @@
<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/Tutorial/UI/TutorialOverlayBuilder.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" />
@@ -214,8 +228,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" />
@@ -224,6 +240,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" />
@@ -232,6 +249,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" />
@@ -248,9 +266,11 @@
<Compile Include="Assets/Eagle/CommandButtonPanelController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeMinSlider.cs" />
<Compile Include="Assets/Eagle/Notifications/WeatherForcedSuppliesBackNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/AnimationTestController.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/TradeCommandSelector.cs" />
<Compile Include="Assets/Eagle/Table Rows/DominionTableRowController.cs" />
<Compile Include="Assets/Bluetooth/RollPanelController.cs" />
<Compile Include="Assets/Shardok/ChargeAnimator.cs" />
<Compile Include="Assets/Eagle/ClientTextProvider.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManager.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Progress Bar/ProgressBar.cs" />
@@ -258,6 +278,7 @@
<Compile Include="Assets/UI/Scripts/GameSceneManager.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/RansomCommandSelector.cs" />
<Compile Include="Assets/Eagle/Notifications/WeatherForcedSuppliesLostNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/MeteorAnimator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ReturnCommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveTruceCommandSelector.cs" />
<Compile Include="Assets/Eagle/ClientPregeneratedText.cs" />
@@ -271,6 +292,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" />
@@ -278,6 +301,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" />
@@ -293,14 +317,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" />
@@ -327,6 +354,7 @@
<Compile Include="Assets/Eagle/CommandSelectors/ControlWeatherCommandSelector.cs" />
<Compile Include="Assets/Shardok/ShardokGameController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Rendering/UIGradientEditor.cs" />
<Compile Include="Assets/Shardok/LightningAnimator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Progress Bar/PBFilled.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Rendering/UIGradient.cs" />
<Compile Include="Assets/Eagle/DominionPanelController.cs" />
@@ -335,6 +363,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" />
@@ -344,7 +373,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" />
@@ -357,12 +388,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" />
@@ -371,6 +405,7 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/NewFactionHeadDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/HexGrid.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Tooltip/TooltipManager.cs" />
<Compile Include="Assets/Shardok/CatapultAnimator.cs" />
<None Include="Assets/TextMesh Pro/Resources/Shaders/TMPro.cginc" />
<None Include="Assets/TextMesh Pro/Resources/Shaders/TMP_SDF-Mobile Overlay.shader" />
<None Include="Assets/Packages/System.IO.Pipelines.8.0.0/lib/netstandard2.0/System.IO.Pipelines.xml" />
@@ -11,43 +11,67 @@ using GrpcAuthClient = Net.Eagle0.Eagle.Api.Auth.Auth.AuthClient;
namespace Auth {
/// <summary>
/// gRPC client for the Auth service.
/// Handles OAuth URL generation, status polling, and token refresh.
/// Routes OAuth requests (GetOAuthUrl, CheckOAuthStatus, RefreshToken) to the Go auth service.
/// Routes user requests (SetDisplayName, GetCurrentUser, Logout) to Eagle.
/// </summary>
public class AuthClient : IDisposable {
private const int PollIntervalMs = 2000; // Poll every 2 seconds
private const int PollTimeoutMs = 300000; // 5 minute timeout
private readonly GrpcAuthClient _client;
private readonly GrpcChannel _channel;
private readonly GrpcAuthClient _authServiceClient; // Go auth service
private readonly GrpcAuthClient _eagleClient; // Eagle server
private readonly GrpcChannel _authServiceChannel;
private readonly GrpcChannel _eagleChannel;
/// <summary>
/// Create auth client for the given server URL.
/// Create auth client with separate channels for auth service and Eagle.
/// </summary>
/// <param name="serverUrl">Full URL with scheme, e.g. "http://localhost:40032" or
/// "https://prod.eagle0.net"</param>
public AuthClient(string serverUrl) {
_channel = GrpcChannel.ForAddress(
serverUrl,
/// <param name="authServiceUrl">Go auth service URL, e.g.
/// "https://prod.eagle0.net:40033"</param> <param name="eagleUrl">Eagle server URL, e.g.
/// "https://prod.eagle0.net:40032"</param>
public AuthClient(string authServiceUrl, string eagleUrl) {
// Auth service channel (no JWT needed for OAuth flow)
_authServiceChannel = GrpcChannel.ForAddress(
authServiceUrl,
new GrpcChannelOptions {
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
DisposeHttpClient = true
});
_authServiceClient = new GrpcAuthClient(_authServiceChannel);
// Create invoker with JWT interceptor for authenticated requests
CallInvoker invoker = _channel.Intercept(new JwtAuthInterceptor());
_client = new GrpcAuthClient(invoker);
// Eagle channel with JWT interceptor for authenticated requests
_eagleChannel = GrpcChannel.ForAddress(
eagleUrl,
new GrpcChannelOptions {
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
DisposeHttpClient = true
});
CallInvoker eagleInvoker = _eagleChannel.Intercept(new JwtAuthInterceptor());
_eagleClient = new GrpcAuthClient(eagleInvoker);
Debug.Log($"[AuthClient] Configured: authService={authServiceUrl}, eagle={eagleUrl}");
}
/// <summary>
/// Get OAuth URL to open in system browser.
/// Returns both the URL and the state token for polling.
/// 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 };
var response = await _client.GetOAuthUrlAsync(request);
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}");
return (response.AuthUrl, response.State);
@@ -56,6 +80,7 @@ namespace Auth {
/// <summary>
/// Poll for OAuth completion. Blocks until success, failure, or timeout.
/// The server handles the OAuth callback and token exchange.
/// Routed to Go auth service.
/// </summary>
/// <param name="state">State token from GetOAuthUrlAsync</param>
/// <returns>Response with tokens and user info on success</returns>
@@ -64,7 +89,7 @@ namespace Auth {
while (true) {
var request = new CheckOAuthStatusRequest { State = state };
var response = await _client.CheckOAuthStatusAsync(request);
var response = await _authServiceClient.CheckOAuthStatusAsync(request);
switch (response.Status) {
case OAuthStatus.Success:
@@ -78,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) {
@@ -94,6 +123,7 @@ namespace Auth {
/// <summary>
/// Refresh access token using stored refresh token.
/// Routed to Go auth service.
/// </summary>
/// <returns>New access token and expiry, or null if refresh failed</returns>
public async Task<RefreshTokenResponse> RefreshTokenAsync() {
@@ -104,7 +134,7 @@ namespace Auth {
var request = new RefreshTokenRequest { RefreshToken = refreshToken };
var response = await _client.RefreshTokenAsync(request);
var response = await _authServiceClient.RefreshTokenAsync(request);
// Update stored access token
TokenStorage.UpdateAccessToken(response.AccessToken, response.ExpiresAt);
@@ -116,14 +146,23 @@ namespace Auth {
/// <summary>
/// Set display name for new users.
/// Requires valid JWT in interceptor.
/// Routed to Eagle.
/// </summary>
public async Task<SetDisplayNameResponse> SetDisplayNameAsync(string displayName) {
var request = new SetDisplayNameRequest { DisplayName = displayName };
var response = await _client.SetDisplayNameAsync(request);
var response = await _eagleClient.SetDisplayNameAsync(request);
if (response.Success) {
TokenStorage.UpdateDisplayName(displayName);
Debug.Log($"[AuthClient] Display name set to: {displayName}");
// Update the access token with the new one that has updated displayName claim
if (!string.IsNullOrEmpty(response.AccessToken)) {
// Token expires in 7 days from now
var expiresAt = DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeSeconds();
TokenStorage.UpdateAccessToken(response.AccessToken, expiresAt);
Debug.Log($"[AuthClient] Display name set to: {displayName}, token updated");
} else {
Debug.Log($"[AuthClient] Display name set to: {displayName}");
}
} else {
Debug.LogWarning(
$"[AuthClient] Failed to set display name: {response.ErrorMessage}");
@@ -134,28 +173,31 @@ namespace Auth {
/// <summary>
/// Get current user info. Validates the stored JWT.
/// Routed to Eagle.
/// </summary>
public async Task<GetCurrentUserResponse> GetCurrentUserAsync() {
var response = await _client.GetCurrentUserAsync(new GetCurrentUserRequest());
var response = await _eagleClient.GetCurrentUserAsync(new GetCurrentUserRequest());
Debug.Log($"[AuthClient] Current user: {response.User?.DisplayName}");
return response;
}
/// <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 _client.LogoutAsync(new LogoutRequest());
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() { _channel?.Dispose(); }
public void Dispose() {
_authServiceChannel?.Dispose();
_eagleChannel?.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,18 +10,22 @@ 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; }
private AuthClient _authClient;
private string _currentServerUrl;
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;
@@ -34,22 +39,31 @@ namespace Auth {
Instance = this;
DontDestroyOnLoad(gameObject);
// AuthClient is created lazily when SetServerUrl is called
// Initialize TokenStorage cache from PlayerPrefs on main thread
// This allows background threads to access tokens safely
TokenStorage.InitializeCache();
// AuthClient is created lazily when SetServerUrls is called
}
/// <summary>
/// Set the server URL for OAuth requests. Call this before any OAuth operations.
/// Set the server URLs for OAuth and game requests. Call this before any OAuth operations.
/// </summary>
/// <param name="serverUrl">Full URL with scheme, e.g. "https://prod.eagle0.net"</param>
public void SetServerUrl(string serverUrl) {
if (_currentServerUrl == serverUrl && _authClient != null) {
return; // Already configured for this URL
/// <param name="authServiceUrl">Go auth service URL, e.g.
/// "https://prod.eagle0.net:40033"</param> <param name="eagleUrl">Eagle server URL, e.g.
/// "https://prod.eagle0.net:40032"</param>
public void SetServerUrls(string authServiceUrl, string eagleUrl) {
if (_currentAuthServiceUrl == authServiceUrl && _currentEagleUrl == eagleUrl &&
_authClient != null) {
return; // Already configured for these URLs
}
_authClient?.Dispose();
_currentServerUrl = serverUrl;
_authClient = new AuthClient(serverUrl);
Debug.Log($"[OAuthManager] Configured for server: {serverUrl}");
_currentAuthServiceUrl = authServiceUrl;
_currentEagleUrl = eagleUrl;
_authClient = new AuthClient(authServiceUrl, eagleUrl);
Debug.Log($"[OAuthManager] Configured: authService={authServiceUrl}, eagle={eagleUrl}");
}
private void OnDestroy() {
@@ -60,7 +74,7 @@ namespace Auth {
private void EnsureConfigured() {
if (_authClient == null) {
throw new InvalidOperationException(
"OAuthManager not configured. Call SetServerUrl() first.");
"OAuthManager not configured. Call SetServerUrls() first.");
}
}
@@ -70,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}");
@@ -81,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);
@@ -119,7 +148,7 @@ namespace Auth {
/// <summary>
/// Try to restore session from stored tokens.
/// Returns true if valid session exists.
/// Must call SetServerUrl() before this method.
/// Must call SetServerUrls() before this method.
/// </summary>
public async Task<bool> TryRestoreSessionAsync() {
if (_authClient == null) {
@@ -146,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;
@@ -168,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,121 +1,309 @@
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";
public static bool HasValidToken {
get {
var token = AccessToken;
var expiresAt = ExpiresAt;
return !string.IsNullOrEmpty(token) &&
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
// 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>
/// Initialize cache from PlayerPrefs. Must be called from main thread.
/// Call this early in app startup (e.g., in Awake or Start).
/// </summary>
public static void InitializeCache() {
var json = PlayerPrefs.GetString(AccountsDataKey, "");
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();
}
} else {
_accountsData = new StoredAccountsData();
// Try to migrate legacy single-account data
MigrateLegacyData();
}
_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 bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
public static string AccessToken {
get => PlayerPrefs.GetString(AccessTokenKey, null);
private
set => PlayerPrefs.SetString(AccessTokenKey, value ?? "");
}
public static string RefreshToken {
get => PlayerPrefs.GetString(RefreshTokenKey, null);
private
set => PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
}
public static long ExpiresAt {
get => long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
private
set => PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
}
public static string UserId {
get => PlayerPrefs.GetString(UserIdKey, null);
private
set => PlayerPrefs.SetString(UserIdKey, value ?? "");
}
public static string DisplayName {
get => PlayerPrefs.GetString(DisplayNameKey, null);
private
set => PlayerPrefs.SetString(DisplayNameKey, value ?? "");
private static void SaveAccountsData() {
var json = JsonUtility.ToJson(_accountsData);
PlayerPrefs.SetString(AccountsDataKey, json);
PlayerPrefs.Save();
}
/// <summary>
/// Store tokens received from OAuth exchange.
/// Get all stored accounts.
/// </summary>
public static IReadOnlyList<StoredAccount> GetAllAccounts() {
EnsureInitialized();
return _accountsData.Accounts.AsReadOnly();
}
/// <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>
/// 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() {
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,25 +88,43 @@ 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;
[Header("Connection Panel Environment")]
[Tooltip("Environment dropdown in connection panel (fallback if lobby unreachable)")]
public TMP_Dropdown connectionEnvironmentDropdown;
public GameObject connectionPanel;
public GameObject gameSelectionPanel;
public GameObject customBattlePanel;
public ErrorHandler errorHandler;
[Header("Lobby Controls")]
public Button logoutButton;
public Button customBattleButton;
public Button cancelCustomBattleButton;
public TMP_Dropdown lobbyEnvironmentDropdown;
public TextMeshProUGUI lobbyUserText;
public GameObject runningGamesListArea;
public GameObject runningGamesListItemPrefab;
@@ -135,7 +150,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;
@@ -159,16 +174,22 @@ 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;
}
/// <summary>
/// Get full URL with scheme for the selected environment.
/// Get full URL with scheme for the selected environment (Eagle server).
/// Remote servers use HTTPS, could be extended for local HTTP.
/// </summary>
private string GetFullUrlFromEnvironment() { return "https://" + GetUrlFromEnvironment(); }
private string GetEagleUrl() { return "https://" + GetUrlFromEnvironment(); }
/// <summary>
/// Get full URL with scheme for the Go auth service.
/// Always uses prod auth service - OAuth tokens work across all environments.
/// </summary>
private string GetAuthServiceUrl() { return "https://prod.eagle0.net:40033"; }
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
_handleLobbyResponse(lobbyResponse);
@@ -186,14 +207,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);
@@ -208,6 +221,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize OAuth UI
SetupOAuthUI();
// Initialize connection panel environment dropdown
SetupConnectionEnvironmentDropdown();
// Initialize Lobby UI (logout button, etc.)
SetupLobbyUI();
// Initialize status text
UpdateConnectionStatus();
@@ -215,8 +234,24 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
TryRestoreSession();
}
private void SetupConnectionEnvironmentDropdown() {
if (connectionEnvironmentDropdown == null) return;
connectionEnvironmentDropdown.ClearOptions();
connectionEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(OnConnectionEnvironmentChanged);
}
private void OnConnectionEnvironmentChanged(int newEnvironmentIndex) {
// Just save the preference - it will be used on next connection attempt
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
Debug.Log(
$"[ConnectionHandler] Environment changed to {EnvironmentDisplayNames[newEnvironmentIndex]}");
}
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));
@@ -227,8 +262,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
@@ -237,34 +272,185 @@ 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";
} else {
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
// Sync connection environment dropdown with saved preference
if (connectionEnvironmentDropdown != null) {
connectionEnvironmentDropdown.onValueChanged.RemoveListener(
OnConnectionEnvironmentChanged);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(
OnConnectionEnvironmentChanged);
}
// 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 {
// Failed - might need re-auth, refresh the buttons
RefreshStoredAccountButtons();
}
}
private void SetupLobbyUI() {
if (logoutButton != null) { logoutButton.onClick.AddListener(OnLogoutClicked); }
if (customBattleButton != null) { customBattleButton.onClick.AddListener(CustomBattle); }
if (cancelCustomBattleButton != null) {
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
}
// 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() {
// 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 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() {
Debug.Log("[ConnectionHandler] Logout button clicked");
// Clear OAuth tokens
if (OAuthManager.Instance != null) { await OAuthManager.Instance.LogoutAsync(); }
// Disconnect from server
_persistentClientConnection?.Dispose();
eagleConnection?.Dispose();
_httpClient?.Dispose();
_cancellationTokenSource?.Dispose();
_persistentClientConnection = null;
eagleConnection = null;
_httpClient = null;
_cancellationTokenSource = null;
_connectedEnvironmentIndex = -1;
// Return to connection screen
gameSelectionPanel.SetActive(false);
connectionPanel.SetActive(true);
// Re-initialize the auth UI
ShowAuthPanel();
UpdateConnectionStatus();
}
private async void TryRestoreSession() {
if (OAuthManager.Instance == null) return;
// Configure OAuthManager with current environment URL
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
// Configure OAuthManager with auth service and Eagle URLs
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
if (oauthStatusText != null) { oauthStatusText.text = "Checking for existing session..."; }
@@ -278,8 +464,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private async void OnOAuthLoginClicked(OAuthProvider provider) {
// Configure OAuthManager with current environment URL (user may have changed dropdown)
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
// Configure OAuthManager with auth service and Eagle URLs (user may have changed dropdown)
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
if (oauthStatusText != null) {
oauthStatusText.text = $"Opening browser for {provider} login...";
@@ -311,11 +497,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;
@@ -343,27 +571,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;
@@ -440,6 +648,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public void CustomBattle() { _internalCustomBattle(); }
public void CancelCustomBattle() {
customBattlePanel.SetActive(false);
shardokCanvas.gameObject.SetActive(false);
connectionCanvas.gameObject.SetActive(true);
gameSelectionPanel.SetActive(true);
StartListeningForLobbyUpdates();
}
public void OnApplicationQuit() { Dispose(); }
public void Dispose() {
@@ -486,6 +702,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
connectionPanel.gameObject.SetActive(false);
gameSelectionPanel.gameObject.SetActive(true);
// Update lobby status displays
UpdateLobbyStatusDisplays();
// Set up running games table
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
foreach (var runningGame in lobbyResponse.RunningGames) {
@@ -495,7 +714,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
var runningGameItem = listItem.GetComponent<RunningGameItem>();
runningGameItem.SetRunningGame(runningGame.GameId, runningGame.Leader);
runningGameItem.SetRunningGame(
runningGame.GameId,
runningGame.Leader,
runningGame.LastPlayedTimestampMillis);
runningGameItem.GoCallback = this.SelectEagleGame;
runningGameItem.DropCallback = this.DropGame;
}
@@ -556,10 +778,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();
@@ -572,25 +791,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,
@@ -602,12 +814,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void _internalCustomBattle() {
_createConnection();
// Connection already exists when called from lobby
_persistentClientConnection.SetLobbySubscriber(this);
SetCustomBattleActive(true);
connectionPanel.SetActive(false);
gameSelectionPanel.SetActive(false);
customBattlePanel.SetActive(true);
}
@@ -1,12 +1,12 @@
fileFormatVersion: 2
guid: aa2fcd8d8f12d4218aa9ee793adfe434
guid: af9f637b3a1f6433f819a0bcdb326453
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 12
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
@@ -20,11 +20,12 @@ TextureImporter:
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
@@ -36,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
@@ -51,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
@@ -62,10 +63,11 @@ TextureImporter:
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 1
cookieLightType: 1
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 3
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -75,9 +77,10 @@ TextureImporter:
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
@@ -87,37 +90,28 @@ TextureImporter:
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Server
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:
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
spritePackingTag:
mipmapLimitGroupName:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,5 +1,164 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &246340277068347999
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5589171621630283209}
- component: {fileID: 9020655488278447847}
- component: {fileID: 5482253986454480250}
- component: {fileID: 6109349435209553009}
m_Layer: 5
m_Name: Last Played
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5589171621630283209
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
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: 909271087739711220}
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 &9020655488278447847
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_CullTransparentMesh: 0
--- !u!114 &5482253986454480250
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Tars Tarkas
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: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, 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: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 1
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 &6109349435209553009
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 100
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &649403511955802327
GameObject:
m_ObjectHideFlags: 0
@@ -94,8 +253,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 30
m_fontSizeBase: 30
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -153,9 +312,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 450
m_MinHeight: 60
m_MinHeight: -1
m_PreferredWidth: 450
m_PreferredHeight: 60
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -253,8 +412,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 36
m_fontSizeBase: 36
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -314,7 +473,7 @@ MonoBehaviour:
m_MinWidth: 200
m_MinHeight: 35
m_PreferredWidth: -1
m_PreferredHeight: 35
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -521,10 +680,10 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 0
m_MinHeight: -1
m_PreferredWidth: 50
m_PreferredHeight: -1
m_MinWidth: 40
m_MinHeight: 40
m_PreferredWidth: 40
m_PreferredHeight: 40
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -621,8 +780,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 36
m_fontSizeBase: 36
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -885,9 +1044,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 200
m_MinHeight: -1
m_MinHeight: 40
m_PreferredWidth: 200
m_PreferredHeight: -1
m_PreferredHeight: 40
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -926,6 +1085,7 @@ RectTransform:
m_Children:
- {fileID: 2008335870574158510}
- {fileID: 6674238761151365000}
- {fileID: 5589171621630283209}
- {fileID: 1348356180262758599}
- {fileID: 7336928442537586311}
- {fileID: 7341333336313446738}
@@ -994,7 +1154,7 @@ MonoBehaviour:
m_ChildAlignment: 0
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 1
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 1
m_ChildControlHeight: 1
m_ChildScaleWidth: 0
@@ -1015,6 +1175,7 @@ MonoBehaviour:
item: {fileID: 5643565463360785033}
gameIdField: {fileID: 8058295588038032232}
leaderField: {fileID: 3311450659768657245}
lastPlayedField: {fileID: 5482253986454480250}
goButton: {fileID: 145214844678457394}
dropButton: {fileID: 4081637582368108930}
--- !u!114 &2208043217657811503
@@ -1033,9 +1194,9 @@ MonoBehaviour:
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_PreferredHeight: 45
m_FlexibleWidth: 1
m_FlexibleHeight: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &7814378479006347708
GameObject:
@@ -9,6 +9,7 @@ public class RunningGameItem : MonoBehaviour {
public GameObject item;
public TextMeshProUGUI gameIdField;
public TextMeshProUGUI leaderField;
public TextMeshProUGUI lastPlayedField;
public Button goButton;
public Button dropButton;
@@ -24,7 +25,8 @@ public class RunningGameItem : MonoBehaviour {
public void DropClicked() { DropCallback?.Invoke(gameId); }
public void SetRunningGame(long gameId, AvailableLeader leader) {
public void
SetRunningGame(long gameId, AvailableLeader leader, long lastPlayedTimestampMillis) {
this.gameId = gameId;
gameIdField.text = string.Format("{0:X}", gameId);
@@ -34,5 +36,31 @@ public class RunningGameItem : MonoBehaviour {
"{0} ({1})",
leaderName,
DisplayNames.ProfessionNames[leader.Profession]);
// Display last played time in user's local timezone
if (lastPlayedField != null) {
if (lastPlayedTimestampMillis > 0) {
var lastPlayed = DateTimeOffset.FromUnixTimeMilliseconds(lastPlayedTimestampMillis)
.LocalDateTime;
var now = DateTime.Now;
var diff = now - lastPlayed;
string timeText;
if (diff.TotalMinutes < 1) {
timeText = "Just now";
} else if (diff.TotalHours < 1) {
timeText = $"{(int)diff.TotalMinutes}m ago";
} else if (diff.TotalDays < 1) {
timeText = $"{(int)diff.TotalHours}h ago";
} else if (diff.TotalDays < 7) {
timeText = $"{(int)diff.TotalDays}d ago";
} else {
timeText = lastPlayed.ToString("MMM d");
}
lastPlayedField.text = timeText;
} else {
lastPlayedField.text = "";
}
}
}
}
@@ -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:
@@ -1,88 +0,0 @@
fileFormatVersion: 2
guid: b853582537dc740aabe996b607191fe8
TextureImporter:
fileIDToRecycleName: {}
externalObjects: {}
serializedVersion: 9
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
platformSettings:
- serializedVersion: 2
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
vertices: []
indices:
edges: []
weights: []
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
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:
@@ -299,6 +299,36 @@ namespace eagle {
}
}
/// <summary>
/// Remove all pending commands for a specific game.
/// Called when the server confirms a command was processed (SUCCESS or BAD_TOKEN).
/// </summary>
private void RemovePendingCommandsForGame(long gameId) {
lock (this) {
var toRemove = _pendingCommands.Where(cmd => cmd.GameId == gameId).ToList();
foreach (var cmd in toRemove) { _pendingCommands.Remove(cmd); }
if (toRemove.Count > 0) {
_remoteEagleClientLogger.LogLine(
$"[POST] Removed {toRemove.Count} pending command(s) for game {gameId}");
}
}
}
/// <summary>
/// Refresh the subscription for a specific game to get fresh state from the server.
/// Used when a command is rejected (e.g., BAD_TOKEN) and we need current state.
/// </summary>
private void RefreshGameSubscription(long gameId) {
IClientConnectionSubscriber subscriber;
lock (this) { _subscribers.TryGetValue(gameId, out subscriber); }
if (subscriber != null) {
_ = StreamOneGameAsync(subscriber);
} else {
_remoteEagleClientLogger.LogLine(
$"[REFRESH] No subscriber found for game {gameId}");
}
}
public async Task Connect() {
// Prevent concurrent connection attempts
if (_isConnecting) {
@@ -468,7 +498,13 @@ namespace eagle {
await PostRequest(nextCommand);
} else if (eagleToken > providedToken) {
_remoteEagleClientLogger.LogLine(
$"{providedToken} seems to be stale, dropping");
$"{providedToken} seems to be stale (current token " +
$"{eagleToken}), dropping and refreshing state");
// Server already processed this command and advanced
// the token. Re-subscribe to ensure we have current
// state, especially if turn passed and we need new
// commands.
_ = StreamOneGameAsync(subscriber);
} else {
_remoteEagleClientLogger.LogLine(
$"{providedToken} seems to be from the future, adding back to the queue");
@@ -503,7 +539,10 @@ namespace eagle {
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
$"Shardok token mismatch: pending={providedShardokToken} " +
$"current={currentShardokToken}, dropping and refreshing state");
// Server processed command, token advanced. Refresh state.
_ = StreamOneGameAsync(subscriber);
}
break;
@@ -528,7 +567,10 @@ namespace eagle {
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
$"Shardok placement token mismatch: pending={providedShardokToken} " +
$"current={currentShardokToken}, dropping and refreshing state");
// Server processed command, token advanced. Refresh state.
_ = StreamOneGameAsync(subscriber);
}
break;
@@ -658,11 +700,12 @@ namespace eagle {
return true;
});
// Only remove from pending if successfully sent.
// If connection was dead, leave in queue for retry after reconnect.
if (success) {
lock (this) { _pendingCommands.Remove(request); }
} else {
// IMPORTANT: Do NOT remove from pending here even if write succeeded.
// WriteAsync completing only means data was written to local buffers,
// not that the server received and processed it. The command stays in
// _pendingCommands until we receive PostCommandResponse SUCCESS or
// TryPendingCommands sees the token has advanced (command was processed).
if (!success) {
_remoteEagleClientLogger.LogLine(
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
}
@@ -981,54 +1024,69 @@ namespace eagle {
HandleGameUpdate(current.GameUpdate, receivedTime);
break;
case UpdateStreamResponse.ResponseDetailsOneofCase.LobbyResponse:
if (_lobbySubscriber != null) {
case UpdateStreamResponse.ResponseDetailsOneofCase.LobbyResponse: {
// Capture subscriber reference to avoid race condition - if
// subscriber changes or is cleared during dispose, we deliver to
// whoever was subscribed when the message arrived
var lobbySubscriber = _lobbySubscriber;
if (lobbySubscriber != null) {
MainQueue.Q.Enqueue(() => {
_lobbySubscriber.ReceiveLobbyUpdate(current.LobbyResponse);
lobbySubscriber.ReceiveLobbyUpdate(current.LobbyResponse);
});
}
break;
}
case UpdateStreamResponse.ResponseDetailsOneofCase
.PregeneratedTextResponse:
if (_lobbySubscriber != null) {
.PregeneratedTextResponse: {
var lobbySubscriber = _lobbySubscriber;
if (lobbySubscriber != null) {
MainQueue.Q.Enqueue(() => {
_lobbySubscriber.ReceivePregeneratedTextUpdate(
lobbySubscriber.ReceivePregeneratedTextUpdate(
current.PregeneratedTextResponse);
});
}
break;
}
case UpdateStreamResponse.ResponseDetailsOneofCase.JoinGameResponse:
if (_lobbySubscriber != null) {
case UpdateStreamResponse.ResponseDetailsOneofCase.JoinGameResponse: {
var lobbySubscriber = _lobbySubscriber;
if (lobbySubscriber != null) {
MainQueue.Q.Enqueue(() => {
_lobbySubscriber.HandleJoinGameResponse(
lobbySubscriber.HandleJoinGameResponse(
current.JoinGameResponse);
});
}
break;
}
case UpdateStreamResponse.ResponseDetailsOneofCase.CreateGameResponse:
if (_lobbySubscriber != null) {
case UpdateStreamResponse.ResponseDetailsOneofCase.CreateGameResponse: {
var lobbySubscriber = _lobbySubscriber;
if (lobbySubscriber != null) {
MainQueue.Q.Enqueue(() => {
_lobbySubscriber.HandleCreateGameResponse(
lobbySubscriber.HandleCreateGameResponse(
current.CreateGameResponse);
});
}
break;
case UpdateStreamResponse.ResponseDetailsOneofCase.CustomBattleResponse:
if (_lobbySubscriber != null) {
}
case UpdateStreamResponse.ResponseDetailsOneofCase
.CustomBattleResponse: {
var lobbySubscriber = _lobbySubscriber;
if (lobbySubscriber != null) {
MainQueue.Q.Enqueue(() => {
_lobbySubscriber.HandleCustomBattleResponse(
lobbySubscriber.HandleCustomBattleResponse(
current.CustomBattleResponse);
});
}
break;
}
case UpdateStreamResponse.ResponseDetailsOneofCase.HexMapResponse:
var hexMapResponse = current.HexMapResponse;
@@ -1058,6 +1116,41 @@ namespace eagle {
}
ackTcs?.TrySetResult(ack);
break;
case UpdateStreamResponse.ResponseDetailsOneofCase.PostCommandResponse:
var postResponse = current.PostCommandResponse;
if (postResponse.Status == PostCommandResponse.Types.Status.Error) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server returned ERROR: {postResponse.ErrorMessage}");
LogConnectionEvent(
"post_command_error",
postResponse.ErrorMessage);
// Disconnect and let normal reconnect flow handle recovery
_streamingCall?.Dispose();
_streamingCall = null;
} else if (
postResponse.Status ==
PostCommandResponse.Types.Status.BadToken) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server returned BAD_TOKEN for game {postResponse.GameId} " +
"- command rejected, refreshing state");
// Server rejected command due to stale token. Remove from
// pending (already processed) and re-subscribe to ensure we
// have current state.
RemovePendingCommandsForGame(postResponse.GameId);
RefreshGameSubscription(postResponse.GameId);
} else if (
postResponse.Status ==
PostCommandResponse.Types.Status.Success) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server confirmed command for game {postResponse.GameId}");
// Command was successfully processed. Remove from pending.
RemovePendingCommandsForGame(postResponse.GameId);
}
// UNKNOWN is benign (old servers that don't set status)
break;
}
@@ -1072,7 +1165,21 @@ namespace eagle {
var tokenCancelled = _currentThreadToken.IsCancellationRequested;
LogFlow($"HandleStreamingCall ENDED normally: sc_null={scNull} token_cancelled={tokenCancelled}");
_remoteEagleClientLogger.LogLine(
"How did we get here? This is not my beautiful wife!");
$"Stream ended normally: sc_null={scNull} token_cancelled={tokenCancelled}");
// Only schedule reconnect if:
// 1. App is not shutting down (main token not cancelled)
// 2. Thread token was NOT cancelled (if it was, something else like
// SyncMismatch already disposed the call and scheduled reconnect)
if (!_cancellationToken.IsCancellationRequested && !tokenCancelled) {
lock (this) {
_lastDisconnect = DateTime.UtcNow;
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
}
LogConnectionEvent("disconnect", "StreamEndedNormally");
ScheduleReconnect("StreamEndedNormally");
}
} catch (RpcException e) {
lock (this) {
_lastDisconnect = DateTime.UtcNow;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because it is too large Load Diff
@@ -1,90 +0,0 @@
fileFormatVersion: 2
guid: fc00df0d402d141999c4b8bf71901287
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 10
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
platformSettings:
- serializedVersion: 2
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,88 +0,0 @@
fileFormatVersion: 2
guid: 354f50a479c2a4ca3bf062e12f528f1c
TextureImporter:
fileIDToRecycleName: {}
externalObjects: {}
serializedVersion: 7
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
platformSettings:
- serializedVersion: 2
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
vertices: []
indices:
edges: []
weights: []
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,91 +0,0 @@
fileFormatVersion: 2
guid: 1c5eda65433424fb183c62967354168b
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 10
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: -1
aniso: -1
mipBias: -100
wrapU: -1
wrapV: -1
wrapW: -1
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 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
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:

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