Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 f074c7ac73 Optimize GetGameHistory to not load entries when only count is needed
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-02 22:42:35 -08:00
adminandClaude Opus 4.5 198e6ed3c0 Fix game ID parsing in download handler
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-02 22:39:14 -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
56a5c27d42 Add protoless overloads to HeroUtils (#4961)
- Add faction(hero, factions) method
- Add loyaltyAsStatWithCondition(hero, factions) method
- Add vigorAsStatWithCondition(hero) method
- Add consideredProvinces(gameState, factionId, provinceId) method

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:29:43 -08:00
1d707ba5f0 Fix gRPC context propagation in AuthServiceImpl (#4960)
The userId was being read inside the Future block, which runs on a
thread from ExecutionContext.global. However, gRPC Context.current
returns the context for the current thread, not the gRPC handler
thread where the JWT claims were attached.

This caused setDisplayName and getCurrentUser to always get null/empty
userId, resulting in "User not found" errors after successful OAuth.

Fix by capturing userId before creating the Future, on the gRPC thread
where Context.current has the proper claims attached. This matches
the pattern used in EagleServiceImpl.lockAndDoWithUserName.

Also update AuthorizationInterceptor publicEndpoints to use
CheckOAuthStatus instead of the old ExchangeCode method name.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:19:41 -08:00
e4f3df7e26 Add protoless overloads to ArmyUtils (#4959)
- Add protoless overloads for heroCount and troopCount methods
- Protoless versions take Map[BattalionId, BattalionT] instead of GameState

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:19:13 -08:00
d68302d2b5 Implement client-side OAuth polling flow (#4945)
Client changes for server-mediated OAuth:
- AuthClient: Use PollForOAuthCompletionAsync instead of deep links
- OAuthManager: Remove deep link handling, use polling instead

The client now:
1. Calls GetOAuthUrl to get browser URL and state token
2. Opens browser for user to authorize
3. Polls CheckOAuthStatus every 2s until success/failure/timeout

Works in Unity Editor and all platforms without URL scheme registration.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:06:03 -08:00
4a7559c25d Add protoless overloads to IncomingArmyUtils and FactionUtils (#4958)
- Add factionsAreMutuallyAllied and hostilityStatus to FactionUtils
- Add protoless overloads to IncomingArmyUtils:
  - hasRelevantIncomingArmy
  - relevantIncomingArmies
  - incomingArmiesAreMutuallyAllied
  - isHostileProvince
  - stats

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 15:03:54 -08:00
1eb253d866 Add protoless versions to AIClientUtils and ResolveDiplomacyCommandSelector (#4957)
- Add protoless extraHeroCount and desiredHeroCount to AIClientUtils
- Add protoless truceOfferAcceptanceChance to ResolveDiplomacyCommandSelector
- Add protoless allianceOfferAcceptanceChance to ResolveDiplomacyCommandSelector

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 14:50:00 -08:00
c770a3693b Deploy: Write image tags to .env file (#4956)
Previously image tags were only passed via ssh-action envs, which meant
manual docker-compose restarts would fall back to :latest. Now the SHA-tagged
image names are persisted in .env.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 14:49:40 -08:00
99ad9fb49e Add protoless versions to AI command selectors (#4955)
- Add protoless overloads to FixLeaderAloneCommandSelector
- Add protoless overloads to InvitationCommandSelector
- Add protoless overloads to MoveLeaderToBetterProvinceCommandChooser
- Add protoless overloads to FactionLeaderProvinceRanker
- Add hostileNeighbors helper to FactionUtils for protoless use

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 14:26:29 -08:00
d80a151303 Nginx: Add routes for Auth gRPC service and OAuth callback (#4954)
- Add location block for /net.eagle0.eagle.api.auth.Auth gRPC service
- Add location block for /oauth/callback HTTP endpoint

These routes are required for OAuth login to work.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 12:24:17 -08:00
b9f48e14be Add protoless versions for chosenExpandCommand and chosenEntrustCommand (#4953)
Convert protoless overloads to use Vector[CommandChooser] directly instead
of delegating to legacy implementations with proto conversion.

Changes:
- chosenExpandCommand: now uses Scala GameState directly
- chosenEntrustCommand: now uses Scala GameState directly
- Added protoless overload for chosenMobilizeIfAdjacentEnemyCommand

This completes the protoless conversion for all main command chooser
entry points (chosenUniversalCommand, chosenDevelopCommand,
chosenMobilizeCommand, chosenExpandCommand, chosenEntrustCommand).

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:20:20 -08:00
32c62357eb Add protoless versions for chosenDevelopCommand and chosenMobilizeCommand (#4952)
Convert protoless overloads to use Vector[CommandChooser] directly instead
of delegating to legacy implementations with proto conversion.

Changes:
- chosenDevelopCommand: now uses Scala GameState directly
- chosenMobilizeCommand: now uses Scala GameState directly
- Added protoless overloads for helper methods:
  - excessBeyondMaximumSupplies
  - chosenShipExcessSuppliesCommand
  - chosenImproveInfrastructureIfBelowTrainingCommand
  - maybeChosenTravelToArmTroopsCommand
  - chosenTrainCommand
  - suppliesToSend
  - preferredSuppliesDestination
  - chosenSendSuppliesCommandWithSuppliesAndDestination
  - chosenSendSuppliesCommandWithSupplies
  - maybeChosenSendSuppliesCommand

This eliminates Scala→proto→Scala conversion roundtrips for callers
that already have a Scala GameState.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 08:08:20 -08:00
a4bf652bf7 Add protoless chosenUniversalCommand using Scala GameState directly (#4951)
- Convert protoless chosenUniversalCommand overload to use Vector[CommandChooser]
  directly instead of delegating to legacy implementation with proto conversion
- Add protoless overloads for nested chooser methods:
  - chosenCommandWhileTraveling
  - chosenLoyaltyManagementCommand
  - chosenRescueLeaderIfAllPrisonersCommand
- Fix ambiguous method reference in EarlyGameAIClient by using explicit lambda

This eliminates the Scala→proto→Scala conversion roundtrip for callers
that already have a Scala GameState.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 06:53:14 -08:00
31458c2a98 Deploy: Pass Discord OAuth credentials to Eagle container (#4950)
Adds DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET to deploy config,
enabling Discord OAuth login in production.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 06:52:38 -08:00
dabf309e6d Deploy: Pass JWT_PRIVATE_KEY to Eagle container (#4949)
Enables OAuth JWT authentication in production by:
- Adding JWT_PRIVATE_KEY secret to deploy workflow env
- Passing it through SSH to the droplet .env file
- Configuring docker-compose to pass it to the Eagle container

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 00:21:53 -08:00
f3bcf44cb3 Add protoless overloads to CommandChoiceHelpers main entry points (#4948)
- Add protoless overloads for chosenUniversalCommand, chosenDevelopCommand,
  chosenMobilizeCommand, chosenExpandCommand, chosenEntrustCommand
- Add protoless helpers: foodAmountToBuy, maybeChosenTravelToBuyFoodCommand,
  chosenShouldRestCommand, chosenSuppressBeastsCommand, and beast-fighting
  command helpers
- Add protoless isUnderAttack overload to IncomingArmyUtils
- Convert PerformVassalCommandsPhaseAction to use protoless overloads,
  eliminating GameStateConverter.toProto() call
- Fix ambiguous method references in EarlyGameAIClient and MidGameAIClient
  by using explicit lambdas

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 00:19:59 -08:00
6f486cbd32 Trigger deploy when docker-compose.prod.yml or nginx config changes (#4947)
Previously, changes to these files wouldn't trigger a deploy since they
weren't in the paths filter. The deploy step already copies these files
to the droplet, it just wasn't being triggered.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 20:39:44 -08:00
bb07a90c0d Add protoless overloads to CommandChoiceHelpers and update callers (#4946)
* Add protoless overloads for CommandChoiceHelpers entry points

Add Scala GameState (GameStateC) accepting overloads for these methods:
- handleRiotGiveSelectedCommand
- handleRiotCrackDownSelectedCommand
- handleRiotDoNothingSelectedCommand
- handleRiotSelectedCommand
- resolveTributeSelectedCommand
- defendSelectedCommand
- defendingUnits
- chosenRestCommand

The proto-accepting versions now delegate to the Scala versions,
allowing callers to switch to passing Scala GameState directly
and eliminate unnecessary proto conversions.

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

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

* Eliminate proto GameState conversions in action files

Update EndHandleRiotsPhaseAction and PerformVassalDefenseDecisionsAction
to call the new protoless overloads in CommandChoiceHelpers directly,
eliminating unnecessary GameStateConverter.toProto calls.

This removes the following wasteful round-trip conversions:
- EndHandleRiotsPhaseAction: handleRiotSelectedCommand call
- PerformVassalDefenseDecisionsAction: resolveTributeSelectedCommand
  and defendSelectedCommand calls

🤖 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>
2025-12-31 20:37:45 -08:00
30c3093829 Implement server-side OAuth polling flow (#4944)
Server changes for server-mediated OAuth:
- OAuthService: Store pending/completed sessions by state, handle callbacks
- AuthServiceImpl: Implement checkOAuthStatus gRPC method
- OAuthHttpHandler: New HTTP server for /oauth/callback endpoint
- Main: Start OAuth HTTP handler on configurable port (default 8080)

New command line options:
- --server-base-url: Base URL for OAuth callbacks (default: https://prod.eagle0.net)
- --oauth-http-port: Port for OAuth HTTP server (default: 8080)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:34:12 -08:00
d723a7e026 Add protoless CommandChooser with legacy compatibility layer (#4941)
- Convert CommandChooser trait to use Scala GameState (protoless)
- Add LegacyCommandChooser trait for callers still using proto GameState
- Add legacy implicit conversions (LegacyDeterministicCommandChooser, LegacyRandomCommandChooser)
- Convert AttackDecisionCommandChooser to fully protoless
- Add Scala overload for IncomingArmyUtils.armyPower
- Update all callers to use LegacyCommandChooser where proto GameState is used:
  - CommandChoiceHelpers, MidGameAIClient, AIClient, EarlyGameAIClient, ResolveDiplomacyCommandSelector

This establishes a migration path where new code uses CommandChooser with Scala
GameState while legacy code uses LegacyCommandChooser with proto GameState.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:32:35 -08:00
7c8453b6c0 Add CheckOAuthStatus RPC for server-mediated OAuth polling (#4943)
Replace client-side deep link OAuth with server-mediated polling:
- Add CheckOAuthStatus RPC and OAuthStatus enum
- Remove ExchangeCode RPC (server handles callback internally)
- Remove redirect_uri from GetOAuthUrlRequest (server uses its own)

Flow: Client opens browser → user authorizes → server receives callback →
client polls CheckOAuthStatus until success/failure.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:21:05 -08:00
7e87ca792e Add memory limit to Shardok container (#4942)
Set 1GB hard limit so Shardok can't OOM the entire droplet.
If exceeded, Docker kills just that container and it restarts
automatically (restart: unless-stopped).

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 16:20:24 -08:00
89db5be805 Add OAuth relay worker and infrastructure (#4939)
* Add Cloudflare Worker for OAuth callback relay

Discord doesn't support custom URL schemes (eagle0://) as redirect URIs.
This worker receives the OAuth callback and redirects to the deep link.

- Worker deployed at eagle0-oauth-relay.eagle0-auth-relay.workers.dev
- GitHub Actions workflow for auto-deploy on push to main

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

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

* Add GenerateJwtKey utility for generating JWT signing keys

🤖 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>
2025-12-31 15:25:10 -08:00
122 changed files with 10285 additions and 2735 deletions
+175
View File
@@ -0,0 +1,175 @@
name: Auth Service Build and Deploy
on:
push:
branches: [ "main" ]
paths:
- 'src/main/go/net/eagle0/authservice/**'
- 'src/main/protobuf/net/eagle0/eagle/api/auth.proto'
- '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
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 }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- 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
script: |
set -x
cd /opt/eagle0
# Update AUTH_IMAGE in .env file (preserve other vars)
if [ -f .env ]; then
# Remove old AUTH_IMAGE line and add new one
grep -v '^AUTH_IMAGE=' .env > .env.tmp || true
echo "AUTH_IMAGE=${AUTH_IMAGE}" >> .env.tmp
# Also update OAuth credentials
grep -v '^DISCORD_CLIENT_ID=' .env.tmp > .env.tmp2 || true
echo "DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}" >> .env.tmp2
grep -v '^DISCORD_CLIENT_SECRET=' .env.tmp2 > .env.tmp3 || true
echo "DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}" >> .env.tmp3
grep -v '^GOOGLE_CLIENT_ID=' .env.tmp3 > .env.tmp4 || true
echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}" >> .env.tmp4
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
# Update JWT private key (JWK format for auth service bootstrap)
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env || true
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5
chmod 600 .env
else
echo "ERROR: .env file not found. Run main deploy first."
exit 1
fi
# 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
+75 -4
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,7 +51,7 @@ 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 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
@@ -54,7 +64,7 @@ jobs:
--acl public-read
echo ""
echo "=== Sysroot uploaded ==="
echo "=== AMD64 Sysroot uploaded ==="
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
echo ""
@@ -64,3 +74,64 @@ jobs:
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 ")"
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-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== ARM64 Sysroot uploaded ==="
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot_arm64\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
echo ")"
@@ -0,0 +1,24 @@
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
+35 -4
View File
@@ -11,6 +11,8 @@ on:
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- 'docker-compose.prod.yml'
- 'nginx/**'
- '.github/workflows/docker_build.yml'
workflow_dispatch:
inputs:
@@ -433,6 +435,11 @@ jobs:
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
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 }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -453,19 +460,35 @@ jobs:
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
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET
script: |
set -x
cd /opt/eagle0
# Write env vars to .env file for docker-compose
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
EXISTING_AUTH_IMAGE=""
if [ -f .env ]; then
EXISTING_AUTH_IMAGE=$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
fi
rm -f .env 2>/dev/null || true
cat > .env << EOF
EAGLE_IMAGE=${EAGLE_IMAGE}
SHARDOK_IMAGE=${SHARDOK_IMAGE}
ADMIN_IMAGE=${ADMIN_IMAGE}
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
AUTH_IMAGE=${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
OPENAI_API_KEY=${OPENAI_API_KEY:-}
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
EOF
chmod 600 .env
@@ -473,6 +496,7 @@ jobs:
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# Use exact image tags passed from build jobs (no :latest fallback)
# Note: AUTH_IMAGE is managed separately by auth_build.yml
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
@@ -514,12 +538,19 @@ jobs:
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
# Recreate only the services we're updating (not auth - managed by auth_build.yml)
# This prevents restarting auth service during every Eagle deploy
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle shardok admin jfr-sidecar
# Ensure auth is running (but don't force-recreate it)
docker compose -f docker-compose.prod.yml up -d auth
# 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
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
# Cleanup orphaned containers
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Wait for health checks
sleep 10
+159
View File
@@ -0,0 +1,159 @@
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
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 SHA 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 :latest tag for convenience
echo "Copying to :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"
+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
+56 -25
View File
@@ -70,43 +70,73 @@ 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, but converts internally (sub-filters still need proto)
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
- `view_filters/FactionViewFilter`, `HeroViewFilter`, `BattalionNameFilter`, `BattleFilter` - still proto-only
**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 (In Progress)
### Phase 2: CommandChooser Trait
The view_filters package is being migrated. Progress:
Once CommandChoiceHelpers is protoless:
**Completed:**
- [x] `GameStateViewFilter` - added Scala GameState overload (converts internally for now)
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
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:**
- [ ] `HeroViewFilter` - needs Scala overload
- [ ] `FactionViewFilter` - needs Scala overload
- [ ] `BattalionNameFilter` - needs Scala overload
- [ ] `BattleFilter` - needs 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
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
## Key Files
@@ -123,6 +153,7 @@ 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, and client view creation
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
- Next focus: view_filters package to reduce proto usage in client view creation
+41 -5
View File
@@ -57,25 +57,47 @@ 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"],
)
# ARM64 sysroot
sysroot(
name = "linux_sysroot_arm64",
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
)
#
# Language Support - Go
#
@@ -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",
)
@@ -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,7 +180,7 @@ 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
@@ -273,6 +300,14 @@ http_file(
executable = True,
)
http_file(
name = "busybox_aarch64",
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
urls = ["https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox"],
downloaded_file_path = "busybox",
executable = True,
)
#
# Toolchain Registration
#
@@ -286,5 +321,6 @@ register_toolchains(
register_toolchains(
"@llvm_toolchain//:all",
"@llvm_toolchain_linux//:all",
"@llvm_toolchain_linux_arm64//:all",
dev_dependency = True,
)
+18 -2
View File
@@ -1293,7 +1293,7 @@
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "uqsqMpE+yaVuTByR2rIA2v1Vkm1JwwuN6nbXOo1W9G0=",
"usagesDigest": "39yHQmifPoGf+JSYpnQSnJGugxbrFVge/+ENmUiqZ6M=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1343,6 +1343,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 +1368,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 +1543,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"
],
+104
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,53 @@ 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)
oci_push(
name = "shardok_server_push_arm64",
image = ":shardok_server_image_arm64",
repository = "registry.digitalocean.com/eagle0/shardok-server-arm64",
)
#
# Admin Server Docker Image (Go)
#
@@ -237,3 +296,48 @@ 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/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",
)
+1
View File
@@ -0,0 +1 @@
node_modules/
+46
View File
@@ -0,0 +1,46 @@
# 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
@@ -0,0 +1,38 @@
/**
* 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
@@ -0,0 +1,4 @@
name = "eagle0-oauth-relay"
main = "worker.js"
compatibility_date = "2024-01-01"
workers_dev = true
+52 -1
View File
@@ -1,7 +1,7 @@
# 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:shardok_server_load && bazel run //ci:auth_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
@@ -16,20 +16,30 @@ services:
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "shardok:40042"
- "--auth-service-url"
- "auth:40033"
- "--internal-grpc-port"
- "40034"
ports:
- "40032:40032"
- "40034:40034" # Internal gRPC for auth service
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
volumes:
- ./saves:/app/saves
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server 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"
@@ -43,9 +53,47 @@ services:
retries: 3
start_period: 30s
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"
# Eagle's internal gRPC address for InternalUserService
EAGLE_INTERNAL_ADDRESS: "eagle:40034"
# 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:-}"
# Note: port 40033 is exposed via nginx, not directly
volumes:
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
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
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
ports:
- "40042:40042"
- "40052:40052"
@@ -72,6 +120,7 @@ services:
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
@@ -147,3 +196,5 @@ 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
+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
+736
View File
@@ -0,0 +1,736 @@
# Shardok On-Demand Compute Infrastructure Analysis
## Overview
This document analyzes options for running Shardok (the tactical combat AI server) on powerful on-demand compute, separate from the low-power droplet that hosts Eagle.
**Current State**: Shardok runs on the same DigitalOcean droplet as Eagle, limiting AI performance.
**Goal**: Spin up powerful compute only when battles occur, keeping costs low while improving AI quality.
---
## Cloud Provider Pricing Comparison
### High-Performance CPU Options (8-16 cores)
| Provider | Instance Type | vCPUs | RAM | Hourly Cost | Monthly Est.* |
|----------|--------------|-------|-----|-------------|---------------|
| **Hetzner** | CCX23 | 8 | 32GB | €0.078 (~$0.085) | ~$6-8 |
| **Hetzner** | CCX33 | 16 | 64GB | €0.155 (~$0.17) | ~$12-16 |
| **Vultr** | High Frequency 8-core | 8 | 32GB | $0.238 | ~$20-25 |
| **Vultr** | High Frequency 16-core | 16 | 64GB | $0.476 | ~$40-50 |
| **DigitalOcean** | Premium CPU 8-core | 8 | 32GB | $0.250 | ~$22-28 |
| **DigitalOcean** | Premium CPU 16-core | 16 | 64GB | $0.500 | ~$44-55 |
*Monthly estimates assume ~4 hours/week of battle usage (~17 hours/month)
### Key Finding: Hetzner is 3-5x Cheaper
Hetzner's dedicated vCPU instances offer the best price-performance ratio by a significant margin. For equivalent 16-core compute:
- Hetzner: ~$0.17/hr
- DigitalOcean: ~$0.50/hr (2.9x more expensive)
- Vultr: ~$0.48/hr (2.8x more expensive)
---
## Instance Startup Time Analysis
### Current Startup Times by Provider
| Provider | Cold Start | Warm Image | Container Registry |
|----------|-----------|------------|-------------------|
| Hetzner | 30-60s | 20-40s | Native support |
| Vultr | 30-45s | 20-30s | Native support |
| DigitalOcean | 30-60s | 25-45s | Already in use |
### 10-Second Max Wait Time Constraint
A 10-second startup constraint eliminates traditional VM spin-up as an option. Alternatives:
#### Option 1: Pre-warmed Instance Pool (Recommended)
- Keep one small instance running 24/7 as a "hot standby"
- Cost: ~$5-10/month for a minimal Hetzner instance
- Startup time: **< 1 second** (already running)
- Upgrade on demand when battle starts (resize takes 2-3 min, but battle can start immediately on small instance)
#### Option 2: Predictive Spin-up
- Start spinning up when user enters battle setup
- Battle setup typically takes 30-60 seconds (map selection, army placement)
- Could achieve "instant" availability if spin-up completes during setup
- Risk: User might cancel, wasting spin-up cost (minimal at ~$0.01-0.02)
#### Option 3: Container-Based Quick Start
- Use pre-pulled Docker images on a warm container host
- Providers like Fly.io or Railway can start containers in 1-5 seconds
- Trade-off: Container platforms typically cost more than raw VMs
#### Option 4: Serverless/Function Compute
- Not viable for Shardok due to:
- Long-running connections (battles last minutes)
- High memory requirements
- Stateful game state
### Recommendation for 10s Constraint
**Hybrid approach**: Keep a minimal "always-on" instance ($5-10/month) that can handle battles at reduced AI quality. When a battle is anticipated (entering battle setup), start spinning up a powerful instance. If the powerful instance is ready when the battle starts, use it; otherwise, fall back to the always-on instance.
---
## ARM Options Outside AWS
### Available ARM Compute
| Provider | ARM Offering | vCPUs | RAM | Hourly Cost | Notes |
|----------|-------------|-------|-----|-------------|-------|
| **Hetzner** | CAX21 | 4 | 8GB | €0.009 | Ampere Altra |
| **Hetzner** | CAX31 | 8 | 16GB | €0.018 | Ampere Altra |
| **Hetzner** | CAX41 | 16 | 32GB | €0.035 | Ampere Altra |
| **Oracle Cloud** | A1 Flex | 4 | 24GB | Free tier | Always Free eligible |
| **Oracle Cloud** | A1 Flex | 24 | 144GB | ~$0.10 | Ampere Altra |
| **Scaleway** | AMP2-C8 | 8 | 16GB | €0.104 | Ampere Altra |
### Key Finding: Hetzner ARM is Extremely Cheap
Hetzner's ARM instances are 4-5x cheaper than their x86 equivalents:
- CAX41 (16 ARM cores): €0.035/hr (~$0.04)
- CCX33 (16 x86 cores): €0.155/hr (~$0.17)
### ARM Considerations for Shardok
**Pros:**
- Significant cost savings (4-5x)
- Good single-threaded performance on Ampere Altra
- Native ARM builds already supported (macOS development)
**Cons:**
- Requires ARM64 Linux build (currently only building x86_64 Linux)
- Some performance tuning may be needed
- Less mature ecosystem than x86
**Build Effort**: Medium. Bazel supports cross-compilation, and the codebase is portable C++. Main work is adding ARM64 Linux to the build matrix.
---
## Hetzner Geographic Presence
### Current Locations
Hetzner operates datacenters in:
- **Germany**: Nuremberg (NBG1), Falkenstein (FSN1)
- **Finland**: Helsinki (HEL1)
- **USA**: Ashburn, Virginia (ASH) - **opened 2023**
- **Singapore**: (coming 2024/2025)
### Latency Implications
For users primarily in North America:
- **Hetzner US (Ashburn)**: 20-40ms to East Coast, 60-80ms to West Coast
- **Hetzner EU**: 80-120ms from US East Coast, 140-180ms from US West Coast
Since Shardok handles AI computation (not real-time player input), latency is less critical. The Eagle-Shardok communication adds some overhead, but AI "thinking time" dominates.
### Recommendation
Use **Hetzner US (Ashburn)** for North American users. The US datacenter has the same pricing as EU datacenters and provides better latency.
---
## Implementation Options Summary
### Option A: Simple On-Demand (30-60s startup)
- Spin up Hetzner CCX33 when battle starts
- Cost: ~$0.17/hr × 4 hrs/week = **~$3/month**
- Latency: 30-60s wait at battle start
- Complexity: Low
### Option B: Predictive Spin-up (0-30s startup)
- Start spinning up during battle setup phase
- Cost: Same as Option A, plus occasional wasted spin-ups (~$1/month)
- Latency: 0-30s depending on setup duration
- Complexity: Medium
### Option C: Hybrid Always-On + On-Demand (< 1s startup)
- Keep minimal instance always running ($5-10/month)
- Spin up powerful instance during battle setup
- Use powerful if ready, fall back to minimal
- Cost: **~$8-15/month**
- Latency: < 1s (always-on fallback)
- Complexity: Medium-High
### Option D: ARM Compute (Cheapest)
- Build ARM64 Linux target
- Use Hetzner CAX41 (16 ARM cores) at €0.035/hr
- Cost: **~$0.60/month** for compute (+ always-on if needed)
- Latency: Same as x86 options
- Complexity: Medium (build system changes)
---
## Recommendation
**Phase 1 (Quick Win)**: Implement Option B (Predictive Spin-up) with Hetzner CCX33 in Ashburn.
- Minimal code changes (add spin-up trigger in Eagle when battle setup begins)
- Good user experience (usually ready by battle start)
- Low cost (~$3-5/month)
**Phase 2 (Future)**: Add ARM64 Linux build and switch to ARM instances.
- 4-5x cost reduction
- Same or better performance
- Requires build system work
**Phase 3 (If Needed)**: Add always-on fallback for guaranteed instant start.
- Only if Phase 1 startup times prove problematic
- Adds ~$5-10/month fixed cost
---
# Implementation Plan
## Chosen Architecture
- **Provider**: Hetzner Cloud
- **Instance**: CAX41 (16 ARM cores, 32GB RAM)
- **Location**: Ashburn, Virginia (ash)
- **Cost**: €0.035/hr (~$0.04/hr)
- **Strategy**: On-demand with predictive spin-up and idle keep-alive
---
## System Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ DigitalOcean │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Eagle Server │ │
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
│ │ │ ShardokManager │ │ ActivityTracker │ │ │
│ │ │ - spin up/down │ │ - player active │ │ │
│ │ │ - health check │ │ - trigger start │ │ │
│ │ └────────┬────────┘ └────────┬────────┘ │ │
│ │ │ │ │ │
│ │ └────────┬───────────┘ │ │
│ │ ▼ │ │
│ │ ┌────────────────┐ │ │
│ │ │ ShardokClient │◄─── TLS + Token Auth │ │
│ │ └────────┬───────┘ │ │
│ └────────────────────┼─────────────────────────────────────────┘ │
└───────────────────────┼──────────────────────────────────────────────┘
│ gRPC over TLS (Let's Encrypt)
┌───────────────────────────────────────────────────────────────────────┐
│ Hetzner Cloud (Ashburn) │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ CAX41 (On-Demand) │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Shardok Server │ │ │
│ │ │ - 16 ARM cores for AI computation │ │ │
│ │ │ - TLS cert from Let's Encrypt (auto-renewed) │ │ │
│ │ │ - Validates auth token on each request │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
```
---
## Instance Lifecycle Management
### State Machine
```
┌──────────────┐
│ STOPPED │◄────────────────────┐
└──────┬───────┘ │
│ battle imminent │ idle timeout
▼ │ (5 minutes)
┌──────────────┐ │
│ STARTING │ │
└──────┬───────┘ │
│ health check passes │
▼ │
┌──────────────┐ │
┌─────────►│ READY │─────────────────────┤
│ └──────┬───────┘ │
│ │ battle starts │
│ ▼ │
│ ┌──────────────┐ │
│ │ IN_USE │ │
│ └──────┬───────┘ │
│ │ battle ends │
│ ▼ │
│ ┌──────────────┐ │
└──────────┤ IDLE ├─────────────────────┘
└──────────────┘
```
### Lifecycle Rules
1. **Spin-up Trigger** ("battle imminent"):
- Player enters battle setup screen
- Army placement phase begins
- Strategic action that will trigger a battle (e.g., march into enemy territory)
2. **Keep-Alive Period**: 5 minutes after last battle ends
- Allows quick start if another battle occurs
- Cost: ~$0.003 per keep-alive period (negligible)
3. **Shutdown**: After 5 minutes idle with no pending battles
- Hetzner API: `DELETE /servers/{id}` or power off to stop billing
4. **Health Checks**: Every 10 seconds while STARTING or READY
- gRPC health check endpoint
- Mark READY only when Shardok responds successfully
---
## Spin-up Strategy
### Simple Approach: Activity-Based
Keep Shardok running whenever there's player activity. The cost is so low (~$0.04/hr) that optimizing spin-up triggers isn't worth the complexity.
**Spin-up triggers:**
- Any player connects to Eagle
- March command into hostile territory (battle likely)
**Shutdown triggers:**
- No player has taken a turn in the last hour
- No active battles
This means Shardok stays running during active play sessions and shuts down during idle periods.
### Cost Impact
| Scenario | Hours/Week | Cost/Month |
|----------|------------|------------|
| Optimized (battles only) | ~4 hrs | ~$0.70 |
| Activity-based (during play) | ~20 hrs | ~$3.50 |
| Always-on 24/7 | 168 hrs | ~$29 |
Activity-based is a good middle ground: simple logic, minimal user wait time, still very cheap.
### Implementation in Eagle
```scala
class ShardokInstanceManager {
private var instanceState: InstanceState = STOPPED
private var lastActivityTime: Instant = Instant.MIN
// Called when any player connects or takes a turn
def onPlayerActivity(): Unit = {
lastActivityTime = Instant.now()
if (instanceState == STOPPED) {
startInstance()
}
}
// Called on march into hostile territory for earlier spin-up
def onBattleLikely(): Unit = {
onPlayerActivity() // Same effect, just a semantic alias
}
def onBattleEnded(battleId: BattleId): Unit = {
lastActivityTime = Instant.now()
// Don't shut down - keep alive for potential next battle
}
// Run every 10 minutes
def periodicShutdownCheck(): Unit = {
val idleTime = Duration.between(lastActivityTime, Instant.now())
if (instanceState != STOPPED && idleTime > Duration.ofHours(1)) {
stopInstance()
}
}
}
```
---
## Security: Eagle ↔ Shardok Authentication
Since Eagle (DigitalOcean) and Shardok (Hetzner) are on different cloud providers, they cannot share a private network. We need secure authentication over the public internet.
### Approach: TLS + Token Authentication
This is the same pattern used by Stripe, GitHub, and most cloud APIs.
**Layer 1: TLS Encryption (Let's Encrypt)**
- Server certificate from Let's Encrypt, auto-renewed via certbot
- All gRPC traffic encrypted with TLS 1.3
- Prevents eavesdropping and MITM attacks
**Layer 2: Token Authentication**
- 256-bit random shared secret in gRPC metadata
- Only requests with valid token are processed
- Easy to rotate without any certificate changes
### How Token Auth Works
**Setup (once):**
```bash
# Generate a 256-bit random token
openssl rand -hex 32
# Output: a3f8b2c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1
# Store in GitHub Secrets as SHARDOK_AUTH_TOKEN
# Eagle reads from environment, Shardok receives via cloud-init
```
**Every Request:**
```
Eagle Shardok
────── ───────
gRPC call with metadata: → Validate token:
"authorization: Bearer a3f8b2c9..." if token != expected:
return UNAUTHENTICATED
else:
process request
```
### Server Certificate (Let's Encrypt)
Shardok needs a domain name for Let's Encrypt. Options:
1. **Dynamic DNS**: Assign `shardok.eagle0.net` to instance IP on spin-up
2. **Elastic IP**: Reserve a Hetzner floating IP, always points to active instance
Cloud-init runs certbot on first boot:
```yaml
runcmd:
# Install certbot
- apt-get install -y certbot
# Get certificate (DNS must already point to this IP)
- certbot certonly --standalone -d shardok.eagle0.net --non-interactive --agree-tos -m admin@eagle0.net
# Certificate auto-renews via systemd timer (certbot installs this)
# Certs at: /etc/letsencrypt/live/shardok.eagle0.net/
```
### Implementation
**Shardok gRPC Server (C++):**
```cpp
// Load Let's Encrypt certificates
std::string cert = ReadFile("/etc/letsencrypt/live/shardok.eagle0.net/fullchain.pem");
std::string key = ReadFile("/etc/letsencrypt/live/shardok.eagle0.net/privkey.pem");
grpc::SslServerCredentialsOptions ssl_opts;
ssl_opts.pem_key_cert_pairs.push_back({key, cert});
auto creds = grpc::SslServerCredentials(ssl_opts);
builder.AddListeningPort("0.0.0.0:50051", creds);
// Token validation - check on every RPC
Status PostCommand(ServerContext* context, const Request* req, Response* resp) {
auto auth = context->client_metadata().find("authorization");
std::string expected = "Bearer " + auth_token_;
if (auth == context->client_metadata().end() ||
auth->second != expected) {
return Status(grpc::UNAUTHENTICATED, "Invalid token");
}
// ... handle request
}
```
**Eagle gRPC Client (Scala):**
```scala
// Standard TLS (trusts Let's Encrypt via system CA store)
val channel = NettyChannelBuilder
.forAddress("shardok.eagle0.net", 50051)
.useTransportSecurity() // TLS with system trust store
.intercept(new ClientInterceptor {
override def interceptCall[Req, Resp](
method: MethodDescriptor[Req, Resp],
callOptions: CallOptions,
next: Channel): ClientCall[Req, Resp] = {
new ForwardingClientCall.SimpleForwardingClientCall(
next.newCall(method, callOptions)) {
override def start(listener: Listener[Resp], headers: Metadata): Unit = {
headers.put(
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER),
s"Bearer $authToken"
)
super.start(listener, headers)
}
}
}
})
.build()
```
### Token Rotation
When rotating tokens (e.g., if compromised or as routine hygiene):
1. Generate new token
2. Update Shardok to accept **both** old and new tokens temporarily
3. Update Eagle to use new token
4. Remove old token from Shardok
No downtime, no certificate regeneration.
### Firewall Rules
```bash
# Hetzner firewall via cloud-init
ufw default deny incoming
ufw allow 80/tcp # Let's Encrypt HTTP-01 challenge (certbot)
ufw allow 50051/tcp # gRPC (protected by TLS + token)
ufw allow from <admin-ip>/32 to any port 22 # SSH for debugging
ufw enable
```
### Security Summary
| Threat | Mitigation |
|--------|------------|
| Eavesdropping | TLS 1.3 encryption |
| MITM attack | Let's Encrypt certificate validates server identity |
| Unauthorized access | 256-bit token required on every request |
| Token theft | TLS prevents sniffing; rotate if compromised |
This is the industry-standard approach for API security.
---
## Hetzner API Integration
### API Authentication
```bash
# Store in secrets manager
HETZNER_API_TOKEN=<token from Hetzner Cloud Console>
```
### Key API Operations
**Create Instance:**
```bash
curl -X POST "https://api.hetzner.cloud/v1/servers" \
-H "Authorization: Bearer $HETZNER_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "shardok-prod",
"server_type": "cax41",
"location": "ash",
"image": "docker-ce",
"ssh_keys": ["eagle-deploy"],
"user_data": "<cloud-init script>",
"labels": {"service": "shardok", "env": "prod"}
}'
```
**Check Status:**
```bash
curl "https://api.hetzner.cloud/v1/servers/{id}" \
-H "Authorization: Bearer $HETZNER_API_TOKEN"
# Response includes: status (running/starting/off), public IP
```
**Delete Instance (Stop Billing):**
```bash
curl -X DELETE "https://api.hetzner.cloud/v1/servers/{id}" \
-H "Authorization: Bearer $HETZNER_API_TOKEN"
```
### Cloud-Init Script
```yaml
#cloud-config
package_update: true
packages:
- docker.io
- certbot
write_files:
- path: /etc/shardok/auth_token
permissions: '0600'
content: |
${AUTH_TOKEN}
runcmd:
# Get Let's Encrypt certificate (DNS must point to this IP first)
- certbot certonly --standalone -d shardok.eagle0.net --non-interactive --agree-tos -m admin@eagle0.net
# Pull and run Shardok container
- echo ${DO_REGISTRY_TOKEN} | docker login registry.digitalocean.com -u ${DO_REGISTRY_TOKEN} --password-stdin
- docker pull registry.digitalocean.com/eagle0/shardok-server-arm64:latest
- docker run -d --name shardok \
-p 50051:50051 \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-v /etc/shardok:/etc/shardok:ro \
registry.digitalocean.com/eagle0/shardok-server-arm64:latest \
--cert=/etc/letsencrypt/live/shardok.eagle0.net/fullchain.pem \
--key=/etc/letsencrypt/live/shardok.eagle0.net/privkey.pem \
--token-file=/etc/shardok/auth_token
```
---
## Container Registry
Using DigitalOcean Container Registry for all images (consistent with Eagle and other services).
- ARM64 image: `registry.digitalocean.com/eagle0/shardok-server-arm64:latest`
- x86 image: `registry.digitalocean.com/eagle0/shardok-server:latest`
CI automatically builds and pushes ARM64 images when C++ code changes.
---
## Fallback Strategy
### If Shardok Instance Not Ready
When a battle starts but the Hetzner instance isn't ready:
1. **Option A: Wait with Progress Indicator**
- Show "Preparing battle arena..." with spinner
- Most users won't notice 20-30s wait if UI is engaging
- Simplest implementation
2. **Option B: Local Fallback** (Current behavior)
- Run Shardok on Eagle's droplet temporarily
- Lower AI quality but instant start
- Migrate battle to Hetzner when ready (complex)
3. **Option C: Reduced AI Settings**
- Start battle immediately with very short time budgets
- Improve AI quality when Hetzner ready
- Seamless to user but requires AI hot-swap
**Recommendation**: Start with Option A. If user feedback indicates wait time is problematic, implement Option B.
---
## Monitoring & Observability
### Metrics to Track
| Metric | Purpose |
|--------|---------|
| `shardok_instance_state` | Current lifecycle state |
| `shardok_startup_duration_seconds` | Time from spin-up to ready |
| `shardok_battles_per_instance` | Efficiency of keep-alive |
| `shardok_wasted_spinups` | Spin-ups without battles |
| `shardok_hourly_cost` | Running cost tracking |
### Alerting
- Alert if startup takes > 90 seconds
- Alert if instance stuck in STARTING for > 3 minutes
- Alert if Hetzner API errors persist
---
## Cost Estimate (Activity-Based, ARM)
With activity-based spin-up, Shardok runs during active play sessions:
| Play Pattern | Hours/Week | Compute/Month |
|--------------|------------|---------------|
| Casual (few sessions) | ~10 hrs | **~$1.75** |
| Regular (daily play) | ~20 hrs | **~$3.50** |
| Heavy (multiple daily) | ~40 hrs | **~$7.00** |
Plus fixed costs:
- Container registry: $0 (GitHub Container Registry)
- Secrets management: $0 (GitHub Secrets)
**Total estimated cost: $2-7/month** for typical usage.
Compare to current DigitalOcean droplet upgrade (~$20-40/month for equivalent CPU power).
---
## Implementation Phases
### Phase 1: Basic On-Demand (1-2 weeks)
1. ✅ Add ARM64 Linux to CI build matrix (PR #4990, merged 2026-01-02)
- Added LLVM toolchain for ARM64 cross-compilation
- Created ARM64 sysroot build workflow
- Added Ubuntu 24.04 ARM64 base image for containers
2. ✅ Push ARM64 container to DigitalOcean Container Registry (PR #4990)
- Created shardok_arm64_build.yml workflow
- Pushes to registry.digitalocean.com/eagle0/shardok-server-arm64:latest
3. ✅ Implement Hetzner API client in Eagle (Scala) (PR #4996, merged 2026-01-02)
- HetznerApiClient with create/delete/list/power operations
- Async HTTP via OkHttp with Future-based API
4. ✅ Add ShardokInstanceManager with spin-up/shutdown logic (PR #4998, merged 2026-01-02)
- Activity-based spin-up when players connect
- Idle timeout shutdown (default 60 minutes)
- State machine: Stopped → Starting → Ready → InUse → Stopping
5. ✅ Add TLS + token authentication to Shardok server (PR #5001, merged 2026-01-02)
- C++ server: TLS via Let's Encrypt, Bearer token validation
- Scala client: TLS and auth token support in ServerSetupHelpers
6. Test end-to-end with manual triggers ← **CURRENT**
### Phase 2: Predictive Spin-up (1 week)
1. Identify battle-imminent triggers in Eagle code
2. Wire triggers to ShardokInstanceManager
3. Implement keep-alive timer
4. Add "Preparing battle..." UI state
### Phase 3: Monitoring & Polish (1 week)
1. Add metrics and dashboards
2. Implement alerting
3. Cost tracking
4. Documentation and runbooks
---
## Design Decisions
### Certificate Management
**Fully automated via Let's Encrypt + certbot.**
- Certbot runs on instance startup to obtain certificate
- Systemd timer auto-renews every 60 days (certs valid 90 days)
- No manual intervention required
- Token rotation is manual but simple (generate new token, update both sides)
### Concurrent Battles
**Decision**: Single shared Shardok instance handles all concurrent battles.
Shardok already supports multiple simultaneous games. With 16 cores and activity-based spin-up, one instance is sufficient for the foreseeable future.
---
## Future Enhancements
These are not needed now but documented for future consideration:
### Multi-Region Deployment
If player base expands globally:
- Deploy Shardok instances in multiple Hetzner regions (Ashburn, Helsinki, Singapore)
- Route players to nearest region based on their Eagle connection
- Each region independent (no cross-region state sharing needed)
**Trigger**: Consistent complaints about battle latency from non-US players.
### Dynamic Instance Sizing
Could adjust instance size based on battle complexity:
- Small battles (< 10 units): CAX21 (4 cores)
- Medium battles: CAX31 (8 cores)
- Large battles: CAX41 (16 cores)
**Current decision**: Start with CAX41 for all battles. The cost difference is minimal (~$0.02/hr between sizes) and simplicity is valuable.
### Dedicated Shardok Instances Per Game
For competitive/tournament play, could spin up dedicated instances:
- Guaranteed resources, no contention
- Isolated failures
- Higher cost
**Current decision**: Shared instance is fine. Revisit if multiplayer competitive mode launches.
+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=
+78
View File
@@ -81,6 +81,84 @@ http {
error_page 502 = /error502grpc;
}
# gRPC proxy for Auth service
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Timeouts
grpc_read_timeout 30s;
grpc_send_timeout 30s;
# Error handling
error_page 502 = /error502grpc;
}
# OAuth callback endpoint (proxied to Go auth service)
location /oauth/callback {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Health check endpoint
location /health {
access_log off;
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;
}
}
# HTTPS server for Go Auth service (port 40033)
# Clients connect here directly for OAuth RPCs in Phase 2
server {
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;
}
# Health check endpoint
location /health {
access_log off;
@@ -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++",
],
@@ -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);
@@ -11,87 +11,106 @@ using GrpcAuthClient = Net.Eagle0.Eagle.Api.Auth.Auth.AuthClient;
namespace Auth {
/// <summary>
/// gRPC client for the Auth service.
/// Handles OAuth URL generation, code exchange, 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 string RedirectUri = "eagle0://auth/callback";
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;
// State parameter for CSRF protection (stored between GetOAuthUrl and ExchangeCode)
private string _pendingState;
private OAuthProvider _pendingProvider;
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>URL to open in browser</returns>
public async Task<string> GetOAuthUrlAsync(OAuthProvider provider) {
var request = new GetOAuthUrlRequest { Provider = provider, RedirectUri = RedirectUri };
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
var request = new GetOAuthUrlRequest { Provider = provider };
var response = await _client.GetOAuthUrlAsync(request);
// Store state for validation when code comes back
_pendingState = response.State;
_pendingProvider = provider;
var response = await _authServiceClient.GetOAuthUrlAsync(request);
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
return response.AuthUrl;
return (response.AuthUrl, response.State);
}
/// <summary>
/// Exchange authorization code for JWT tokens.
/// Call this after receiving the OAuth callback.
/// 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="code">Authorization code from callback</param>
/// <param name="state">State parameter from callback (for CSRF validation)</param>
/// <returns>Exchange response with tokens and user info</returns>
public async Task<ExchangeCodeResponse> ExchangeCodeAsync(string code, string state) {
// Validate state matches what we sent
if (state != _pendingState) {
throw new InvalidOperationException(
$"State mismatch: expected {_pendingState}, got {state}. Possible CSRF attack.");
/// <param name="state">State token from GetOAuthUrlAsync</param>
/// <returns>Response with tokens and user info on success</returns>
public async Task<CheckOAuthStatusResponse> PollForOAuthCompletionAsync(string state) {
var startTime = DateTime.UtcNow;
while (true) {
var request = new CheckOAuthStatusRequest { State = state };
var response = await _authServiceClient.CheckOAuthStatusAsync(request);
switch (response.Status) {
case OAuthStatus.Success:
Debug.Log(
$"[AuthClient] OAuth successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
return response;
case OAuthStatus.Failed:
throw new Exception($"OAuth failed: {response.ErrorMessage}");
case OAuthStatus.Expired:
throw new Exception("OAuth session expired. Please try again.");
case OAuthStatus.Pending:
// Check timeout
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
throw new TimeoutException("OAuth login timed out. Please try again.");
}
// Wait before polling again
await Task.Delay(PollIntervalMs);
break;
default: throw new Exception($"Unknown OAuth status: {response.Status}");
}
}
var request = new ExchangeCodeRequest {
Provider = _pendingProvider,
AuthorizationCode = code,
State = state,
RedirectUri = RedirectUri
};
var response = await _client.ExchangeCodeAsync(request);
// Clear pending state
_pendingState = null;
Debug.Log(
$"[AuthClient] Code exchange successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
return response;
}
/// <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() {
@@ -102,7 +121,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);
@@ -114,14 +133,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}");
@@ -132,19 +160,21 @@ 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.
/// Routed to Eagle.
/// </summary>
public async Task LogoutAsync() {
try {
await _client.LogoutAsync(new LogoutRequest());
await _eagleClient.LogoutAsync(new LogoutRequest());
} catch (Exception ex) {
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
}
@@ -154,6 +184,9 @@ namespace Auth {
Debug.Log("[AuthClient] Logged out, tokens cleared");
}
public void Dispose() { _channel?.Dispose(); }
public void Dispose() {
_authServiceChannel?.Dispose();
_eagleChannel?.Dispose();
}
}
}
@@ -1,22 +1,21 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
namespace Auth {
/// <summary>
/// Manages OAuth authentication flow including:
/// - Opening system browser for OAuth consent
/// - Handling deep link callbacks (eagle0://auth/callback)
/// Manages OAuth authentication flow using server-mediated polling:
/// - Opens system browser for OAuth consent
/// - Polls server for OAuth completion (no deep links needed)
/// - Token storage and refresh
/// </summary>
public class OAuthManager : MonoBehaviour {
public static OAuthManager Instance { get; private set; }
private AuthClient _authClient;
private string _currentServerUrl;
private TaskCompletionSource<ExchangeCodeResponse> _pendingAuth;
private string _currentAuthServiceUrl;
private string _currentEagleUrl;
// Events for UI updates
public event Action<UserInfo> OnLoginSuccess;
@@ -36,36 +35,28 @@ namespace Auth {
Instance = this;
DontDestroyOnLoad(gameObject);
// AuthClient is created lazily when SetServerUrl is called
// 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 OnEnable() {
// Register for deep link callbacks
Application.deepLinkActivated += OnDeepLinkActivated;
// Check if app was launched via deep link
if (!string.IsNullOrEmpty(Application.absoluteURL)) {
OnDeepLinkActivated(Application.absoluteURL);
}
}
private void OnDisable() { Application.deepLinkActivated -= OnDeepLinkActivated; }
private void OnDestroy() {
_authClient?.Dispose();
if (Instance == this) { Instance = null; }
@@ -74,29 +65,26 @@ namespace Auth {
private void EnsureConfigured() {
if (_authClient == null) {
throw new InvalidOperationException(
"OAuthManager not configured. Call SetServerUrl() first.");
"OAuthManager not configured. Call SetServerUrls() first.");
}
}
/// <summary>
/// Start OAuth login with specified provider.
/// Opens system browser for user consent.
/// Opens system browser for user consent, then polls for completion.
/// </summary>
public async Task<ExchangeCodeResponse> LoginAsync(OAuthProvider provider) {
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
try {
// Get OAuth URL from server
var authUrl = await _authClient.GetOAuthUrlAsync(provider);
// Create completion source for callback
_pendingAuth = new TaskCompletionSource<ExchangeCodeResponse>();
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
// Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
Application.OpenURL(authUrl);
// Wait for deep link callback
var response = await _pendingAuth.Task;
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// Store tokens
TokenStorage.StoreTokens(
@@ -118,44 +106,6 @@ namespace Auth {
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
throw;
} finally { _pendingAuth = null; }
}
/// <summary>
/// Handle deep link callback from OAuth provider.
/// URL format: eagle0://auth/callback?code=xxx&state=yyy
/// </summary>
private async void OnDeepLinkActivated(string url) {
Debug.Log($"[OAuthManager] Deep link received: {url}");
if (!url.StartsWith("eagle0://auth/callback")) { return; }
try {
// Parse query parameters
var queryParams = ParseQueryString(url);
if (queryParams.TryGetValue("error", out var error)) {
var errorDesc =
queryParams.GetValueOrDefault("error_description", "Unknown error");
throw new Exception($"OAuth error: {error} - {errorDesc}");
}
if (!queryParams.TryGetValue("code", out var code)) {
throw new Exception("No authorization code in callback");
}
if (!queryParams.TryGetValue("state", out var state)) {
throw new Exception("No state parameter in callback");
}
// Exchange code for tokens
var response = await _authClient.ExchangeCodeAsync(code, state);
// Complete pending auth task
_pendingAuth?.TrySetResult(response);
} catch (Exception ex) {
Debug.LogError($"[OAuthManager] Callback handling failed: {ex.Message}");
_pendingAuth?.TrySetException(ex);
}
}
@@ -174,7 +124,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) {
@@ -234,27 +184,5 @@ namespace Auth {
}
OnLogout?.Invoke();
}
/// <summary>
/// Parse query string from URL.
/// </summary>
private static Dictionary<string, string> ParseQueryString(string url) {
var result = new Dictionary<string, string>();
var queryStart = url.IndexOf('?');
if (queryStart < 0) return result;
var query = url.Substring(queryStart + 1);
var pairs = query.Split('&');
foreach (var pair in pairs) {
var parts = pair.Split('=');
if (parts.Length == 2) {
result[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]);
}
}
return result;
}
}
}
@@ -110,6 +110,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject customBattlePanel;
public ErrorHandler errorHandler;
[Header("Lobby Controls")]
public Button logoutButton;
public TextMeshProUGUI lobbyEnvironmentText;
public TextMeshProUGUI lobbyUserText;
public GameObject runningGamesListArea;
public GameObject runningGamesListItemPrefab;
@@ -165,10 +170,16 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
/// <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.
/// Auth service runs on port 40033.
/// </summary>
private string GetAuthServiceUrl() { return "https://" + GetUrlFromEnvironment() + ":40033"; }
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
_handleLobbyResponse(lobbyResponse);
@@ -208,6 +219,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize OAuth UI
SetupOAuthUI();
// Initialize Lobby UI (logout button, etc.)
SetupLobbyUI();
// Initialize status text
UpdateConnectionStatus();
@@ -260,11 +274,58 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
}
private void SetupLobbyUI() {
if (logoutButton != null) { logoutButton.onClick.AddListener(OnLogoutClicked); }
}
private void UpdateLobbyStatusDisplays() {
// Show environment (prod/qa)
if (lobbyEnvironmentText != null) {
lobbyEnvironmentText.text = ConnectedEnvironmentName ?? "";
}
// Show current user - prefer OAuth display name, fall back to classic login name
if (lobbyUserText != null) {
if (_useOAuth && !string.IsNullOrEmpty(TokenStorage.DisplayName)) {
lobbyUserText.text = TokenStorage.DisplayName;
} else {
lobbyUserText.text = nameField.text;
}
}
}
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 +339,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...";
@@ -486,6 +547,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) {
File diff suppressed because it is too large Load Diff
@@ -13,9 +13,6 @@ public class MainQueue : MonoBehaviour {
// 8ms leaves room for rendering within a 16ms (60fps) frame budget
private const long MaxMillisecondsPerFrame = 8;
// Track queue depth for logging
private int _lastLoggedQueueDepth = 0;
private MainQueue() {}
void Awake() {
@@ -38,14 +35,6 @@ public class MainQueue : MonoBehaviour {
return;
}
// Log when queue has built up (e.g., after resuming from background)
if (queueDepthBefore > 100 && queueDepthBefore != _lastLoggedQueueDepth) {
Debug.Log($"[MainQueue] Processing backlog: {queueDepthBefore} actions queued");
_lastLoggedQueueDepth = queueDepthBefore;
} else if (queueDepthBefore <= 100) {
_lastLoggedQueueDepth = 0;
}
var stopwatch = Stopwatch.StartNew();
Action possibleAction;
do {
@@ -413,6 +413,7 @@ public class HexGrid : MonoBehaviour {
}
public void SetUnitInfoLabels(int cellIndex, string mainValue, string secondaryValue = null) {
if (cells == null) return;
cells[cellIndex].UpperUnitInfoLabel.text = mainValue;
cells[cellIndex].LowerUnitInfoLabel.text = secondaryValue;
@@ -25,10 +25,9 @@ namespace common {
// Client that doesn't follow redirects, so we can retry each hop independently
private readonly HttpClient _noRedirectClient;
// Auth header to send only to eagle0.net (not to S3 signed URLs)
private readonly System.Net.Http.Headers.AuthenticationHeaderValue _authHeader;
private static readonly Uri BaseUri = new("https://eagle0.net/assets/headshots/");
// Public CDN URL for headshots (no auth required)
private static readonly Uri BaseUri =
new("https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/");
private const int MaxRetryAttempts = 5;
private const int MaxRedirectHops = 5;
@@ -73,9 +72,6 @@ namespace common {
};
_noRedirectClient = new HttpClient(
handler) { Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds) };
// Store auth header to add per-request only for eagle0.net
// (sending it to S3 signed URLs causes HTTP 400)
_authHeader = httpClient.DefaultRequestHeaders.Authorization;
// Start periodic retry timer
_periodicRetryTimer = new Timer(
@@ -163,10 +159,6 @@ namespace common {
try {
var request =
new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = uri };
// Only send auth header to eagle0.net, not to S3 signed URLs
if (_authHeader != null && uri.Host.EndsWith("eagle0.net")) {
request.Headers.Authorization = _authHeader;
}
var response = await _noRedirectClient.SendAsync(request);
var statusCode = (int)response.StatusCode;
@@ -3,6 +3,8 @@
package main
import (
"archive/zip"
"bytes"
"context"
"embed"
"encoding/json"
@@ -13,6 +15,9 @@ import (
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
@@ -122,6 +127,7 @@ func main() {
// Set up HTTP routes
http.HandleFunc("/", handleIndex)
http.HandleFunc("/games", handleGamesPage)
http.HandleFunc("/games/upload", handleGameUpload) // Must be before /games/ to match first
http.HandleFunc("/games/", handleGameRoutes)
http.HandleFunc("/settings", handleSettingsPage)
http.HandleFunc("/settings/", handleSettingsRoutes)
@@ -432,6 +438,9 @@ func handleGameRoutes(w http.ResponseWriter, r *http.Request) {
} else if parts[1] == "rewind" {
// /games/{id}/rewind - rewind game to a specific action (htmx POST)
handleRewind(w, r, gameIDHex, gameID)
} else if parts[1] == "download" {
// /games/{id}/download - download game save as zip
handleGameDownload(w, r, gameIDHex, gameID)
} else if parts[1] == "state" && len(parts) >= 3 {
// /games/{id}/state/{index} - game state at action (htmx partial)
actionIndex, err := strconv.Atoi(parts[2])
@@ -671,6 +680,178 @@ func handleRewind(w http.ResponseWriter, r *http.Request, gameIDHex string, game
}
}
func handleGameDownload(w http.ResponseWriter, r *http.Request, gameIDHex string, gameID int64) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Call gRPC streaming endpoint to download the zip
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
stream, err := grpcClient.DownloadGameSave(ctx, &eagle.DownloadGameSaveRequest{
GameId: gameID,
})
if err != nil {
log.Printf("Failed to start download stream for game %s: %v", gameIDHex, err)
http.Error(w, "Failed to download game save", http.StatusInternalServerError)
return
}
// Collect all chunks into a buffer
buf := new(bytes.Buffer)
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Printf("Error receiving chunk for game %s: %v", gameIDHex, err)
// Check if it's a "not found" type error
if strings.Contains(err.Error(), "not found") {
http.Error(w, "Game save not found", http.StatusNotFound)
} else {
http.Error(w, "Failed to download game save", http.StatusInternalServerError)
}
return
}
buf.Write(resp.Chunk)
}
// Send as download
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"game_%s.zip\"", gameIDHex))
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
w.Write(buf.Bytes())
log.Printf("Downloaded game save %s (%d bytes)", gameIDHex, buf.Len())
}
// gameIDPattern matches a hex game ID from a zip filename like "game_abc123.zip"
var gameIDPattern = regexp.MustCompile(`^game_([0-9a-fA-F]+)\.zip$`)
func handleGameUpload(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse multipart form (max 100MB)
if err := r.ParseMultipartForm(100 << 20); err != nil {
log.Printf("Failed to parse multipart form: %v", err)
http.Error(w, "Failed to parse upload", http.StatusBadRequest)
return
}
file, header, err := r.FormFile("gamefile")
if err != nil {
log.Printf("Failed to get uploaded file: %v", err)
http.Error(w, "Failed to get uploaded file", http.StatusBadRequest)
return
}
defer file.Close()
// Extract gameId from filename (expect "game_{hex}.zip")
matches := gameIDPattern.FindStringSubmatch(header.Filename)
if matches == nil {
log.Printf("Invalid filename format: %s (expected game_{hex}.zip)", header.Filename)
http.Error(w, "Invalid filename format. Expected game_{hex}.zip", http.StatusBadRequest)
return
}
gameIDHex := matches[1]
// Parse game ID to validate it
gameIDUint, err := strconv.ParseUint(gameIDHex, 16, 64)
if err != nil {
log.Printf("Invalid game ID hex: %s", gameIDHex)
http.Error(w, "Invalid game ID in filename", http.StatusBadRequest)
return
}
// Extract to save directory
homeDir, err := os.UserHomeDir()
if err != nil {
log.Printf("Failed to get home directory: %v", err)
http.Error(w, "Failed to get home directory", http.StatusInternalServerError)
return
}
saveDir := filepath.Join(homeDir, "eagle0", "eagle", "save", gameIDHex)
// Create save directory
if err := os.MkdirAll(saveDir, 0755); err != nil {
log.Printf("Failed to create save directory %s: %v", saveDir, err)
http.Error(w, "Failed to create save directory", http.StatusInternalServerError)
return
}
// Read zip contents
zipBytes, err := io.ReadAll(file)
if err != nil {
log.Printf("Failed to read uploaded file: %v", err)
http.Error(w, "Failed to read uploaded file", http.StatusInternalServerError)
return
}
// Unzip to saveDir
zipReader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
log.Printf("Failed to read zip file: %v", err)
http.Error(w, "Invalid zip file", http.StatusBadRequest)
return
}
for _, f := range zipReader.File {
destPath := filepath.Join(saveDir, f.Name)
// Security check: prevent path traversal
if !strings.HasPrefix(destPath, saveDir) {
log.Printf("Invalid file path in zip: %s", f.Name)
continue
}
outFile, err := os.Create(destPath)
if err != nil {
log.Printf("Failed to create file %s: %v", destPath, err)
continue
}
rc, err := f.Open()
if err != nil {
outFile.Close()
log.Printf("Failed to open zip entry %s: %v", f.Name, err)
continue
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
log.Printf("Failed to extract %s: %v", f.Name, err)
}
}
// Call gRPC to register the game
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
resp, err := grpcClient.ImportGame(ctx, &eagle.ImportGameRequest{
GameId: int64(gameIDUint),
})
if err != nil {
log.Printf("Failed to import game %s via gRPC: %v", gameIDHex, err)
http.Error(w, fmt.Sprintf("Failed to register game: %v", err), http.StatusInternalServerError)
return
}
if resp.Result != eagle.ImportGameResponse_SUCCESS {
log.Printf("Failed to import game %s: %s (result=%v)", gameIDHex, resp.ErrorMessage, resp.Result)
http.Error(w, fmt.Sprintf("Failed to import game: %s", resp.ErrorMessage), http.StatusBadRequest)
return
}
log.Printf("Successfully uploaded and imported game %s", gameIDHex)
// Redirect to game detail page
http.Redirect(w, r, fmt.Sprintf("/games/%s", gameIDHex), http.StatusFound)
}
// API handlers (keep JSON endpoints for programmatic access)
func handleGamesAPI(w http.ResponseWriter, r *http.Request) {
@@ -10,6 +10,7 @@
<span class="status-badge {{if eq .Game.RunStatus "Running"}}running{{else}}finished{{end}}">
{{.Game.RunStatus}}
</span>
<a href="/games/{{.Game.GameID}}/download" class="btn-secondary" style="margin-left: 1rem;">Download Save</a>
</div>
<article>
@@ -4,6 +4,21 @@
<span>{{len .Games}} game{{if ne (len .Games) 1}}s{{end}}</span>
</div>
<details class="upload-section" style="margin-bottom: 1.5rem;">
<summary style="cursor: pointer; padding: 0.5rem; background: var(--pico-secondary-background); border-radius: 4px;">
Upload Game Save
</summary>
<form action="/games/upload" method="POST" enctype="multipart/form-data" style="padding: 1rem; margin-top: 0.5rem;">
<p style="font-size: 0.9rem; color: var(--pico-muted-color);">
Upload a game save zip file (e.g., game_abc123.zip). The game ID is extracted from the filename.
</p>
<div style="display: flex; gap: 1rem; align-items: center;">
<input type="file" name="gamefile" accept=".zip" required style="flex: 1;">
<button type="submit" class="btn-primary">Upload</button>
</div>
</form>
</details>
{{if .Games}}
{{range .Games}}
<article class="game-card">
@@ -0,0 +1,60 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
go_library(
name = "authservice_lib",
srcs = [
"handlers.go",
"jwt.go",
"main.go",
"oauth.go",
],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/authservice",
visibility = ["//visibility:private"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:api_go_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:auth_internal_go_grpc",
"@com_github_golang_jwt_jwt_v5//:jwt",
"@com_github_google_uuid//:uuid",
"@org_golang_google_grpc//:grpc",
"@org_golang_google_grpc//codes",
"@org_golang_google_grpc//credentials/insecure",
"@org_golang_google_grpc//status",
],
)
go_binary(
name = "authservice",
embed = [":authservice_lib"],
pure = "on",
visibility = ["//visibility:public"],
x_defs = {
"main.buildTime": "{BUILD_TIMESTAMP}",
"main.gitCommit": "{STABLE_GIT_COMMIT}",
},
)
# Linux x86_64 target for Docker deployment
go_binary(
name = "authservice_linux_amd64",
embed = [":authservice_lib"],
goarch = "amd64",
goos = "linux",
pure = "on",
visibility = ["//visibility:public"],
x_defs = {
"main.buildTime": "{BUILD_TIMESTAMP}",
"main.gitCommit": "{STABLE_GIT_COMMIT}",
},
)
go_test(
name = "authservice_test",
srcs = [
"jwt_test.go",
"oauth_test.go",
],
embed = [":authservice_lib"],
deps = [
"@com_github_golang_jwt_jwt_v5//:jwt",
],
)
@@ -0,0 +1,195 @@
package main
import (
"context"
"log"
"time"
auth "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
internalauth "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/auth"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// AuthHandler implements the Auth gRPC service
type AuthHandler struct {
auth.UnimplementedAuthServer
oauthSvc *OAuthService
userClient internalauth.InternalUserServiceClient
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(oauthSvc *OAuthService, userClient internalauth.InternalUserServiceClient) *AuthHandler {
return &AuthHandler{
oauthSvc: oauthSvc,
userClient: userClient,
}
}
// GetOAuthUrl returns the OAuth authorization URL
func (h *AuthHandler) GetOAuthUrl(ctx context.Context, req *auth.GetOAuthUrlRequest) (*auth.GetOAuthUrlResponse, error) {
provider := providerToString(req.Provider)
authURL, state, err := h.oauthSvc.GetAuthURL(provider)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to get auth URL: %v", err)
}
return &auth.GetOAuthUrlResponse{
AuthUrl: authURL,
State: state,
}, nil
}
// CheckOAuthStatus polls for OAuth completion
func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuthStatusRequest) (*auth.CheckOAuthStatusResponse, error) {
result := h.oauthSvc.CheckStatus(req.State)
// Still pending
if h.oauthSvc.IsPending(req.State) {
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_PENDING,
}, nil
}
// Error or expired
if result.Error != "" {
if result.Error == "expired" {
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_EXPIRED,
}, nil
}
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
ErrorMessage: result.Error,
}, nil
}
// Success - call Eagle to find/create user
if result.Success {
userResp, err := h.userClient.GetOrCreateUser(ctx, &internalauth.GetOrCreateUserRequest{
Provider: result.Provider,
ProviderUserId: result.UserInfo.ID,
Email: result.UserInfo.Email,
AvatarUrl: result.UserInfo.AvatarURL,
})
if err != nil {
log.Printf("Failed to get/create user: %v", err)
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
ErrorMessage: "Failed to create user account",
}, nil
}
// Create JWT tokens
accessToken, err := CreateAccessToken(userResp.UserId, userResp.DisplayName, userResp.IsAdmin)
if err != nil {
log.Printf("Failed to create access token: %v", err)
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
ErrorMessage: "Failed to create access token",
}, nil
}
refreshToken, err := CreateRefreshToken(userResp.UserId)
if err != nil {
log.Printf("Failed to create refresh token: %v", err)
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
ErrorMessage: "Failed to create refresh token",
}, nil
}
// Access token expires in 7 days
expiresAt := time.Now().Add(7 * 24 * time.Hour).Unix()
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_SUCCESS,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: expiresAt,
User: &auth.UserInfo{
UserId: userResp.UserId,
DisplayName: userResp.DisplayName,
AvatarUrl: userResp.AvatarUrl,
Provider: stringToProvider(result.Provider),
},
IsNewUser: userResp.IsNewUser,
}, nil
}
// Unknown state - treat as expired
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_EXPIRED,
}, nil
}
// SetDisplayName is handled by Eagle, not this service
func (h *AuthHandler) SetDisplayName(ctx context.Context, req *auth.SetDisplayNameRequest) (*auth.SetDisplayNameResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "SetDisplayName is handled by Eagle server")
}
// RefreshToken refreshes an access token using a refresh token
func (h *AuthHandler) RefreshToken(ctx context.Context, req *auth.RefreshTokenRequest) (*auth.RefreshTokenResponse, error) {
// Validate the refresh token
userID, err := ValidateRefreshToken(req.RefreshToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid refresh token: %v", err)
}
// Get user info from Eagle
userResp, err := h.userClient.GetUser(ctx, &internalauth.GetUserRequest{
UserId: userID,
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
}
if !userResp.Found {
return nil, status.Errorf(codes.NotFound, "user not found")
}
// Create new access token
accessToken, err := CreateAccessToken(userResp.UserId, userResp.DisplayName, userResp.IsAdmin)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to create access token: %v", err)
}
expiresAt := time.Now().Add(7 * 24 * time.Hour).Unix()
return &auth.RefreshTokenResponse{
AccessToken: accessToken,
ExpiresAt: expiresAt,
}, nil
}
// GetCurrentUser is handled by Eagle, not this service
func (h *AuthHandler) GetCurrentUser(ctx context.Context, req *auth.GetCurrentUserRequest) (*auth.GetCurrentUserResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "GetCurrentUser is handled by Eagle server")
}
// Logout is handled by Eagle, not this service
func (h *AuthHandler) Logout(ctx context.Context, req *auth.LogoutRequest) (*auth.LogoutResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "Logout is handled by Eagle server")
}
func providerToString(p auth.OAuthProvider) string {
switch p {
case auth.OAuthProvider_OAUTH_PROVIDER_DISCORD:
return "discord"
case auth.OAuthProvider_OAUTH_PROVIDER_GOOGLE:
return "google"
default:
return "unknown"
}
}
func stringToProvider(s string) auth.OAuthProvider {
switch s {
case "discord":
return auth.OAuthProvider_OAUTH_PROVIDER_DISCORD
case "google":
return auth.OAuthProvider_OAUTH_PROVIDER_GOOGLE
default:
return auth.OAuthProvider_OAUTH_PROVIDER_UNSPECIFIED
}
}
+289
View File
@@ -0,0 +1,289 @@
package main
import (
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
var (
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
)
// EagleClaims represents the JWT claims for Eagle access tokens
// Claim names must match what Eagle's JwtServiceImpl expects:
// - Subject (sub) = userId
// - name = displayName
// - admin = isAdmin (only if true)
type EagleClaims struct {
jwt.RegisteredClaims
Name string `json:"name,omitempty"`
IsAdmin bool `json:"admin,omitempty"`
}
// RefreshClaims represents the JWT claims for refresh tokens
type RefreshClaims struct {
jwt.RegisteredClaims
// UserID is stored in Subject (sub) claim
}
const (
accessTokenDuration = 7 * 24 * time.Hour // 7 days
refreshTokenDuration = 30 * 24 * time.Hour // 30 days
)
// loadKeys loads the RSA keys from PEM files
func loadKeys(privatePath, publicPath string) error {
// Load private key
privateKeyBytes, err := os.ReadFile(privatePath)
if err != nil {
return fmt.Errorf("failed to read private key: %w", err)
}
privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
if err != nil {
return fmt.Errorf("failed to parse private key: %w", err)
}
// Load public key
publicKeyBytes, err := os.ReadFile(publicPath)
if err != nil {
return fmt.Errorf("failed to read public key: %w", err)
}
publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
if err != nil {
return fmt.Errorf("failed to parse public key: %w", err)
}
return nil
}
// bootstrapKeysFromJWK converts JWT_PRIVATE_KEY (JWK format) to PEM files if they don't exist.
// Uses only Go stdlib - no third-party JWK libraries needed.
func bootstrapKeysFromJWK(jwkJSON, privatePath, publicPath string) error {
// Parse JWK JSON
var jwk struct {
N string `json:"n"` // modulus
E string `json:"e"` // public exponent
D string `json:"d"` // private exponent
P string `json:"p"` // first prime
Q string `json:"q"` // second prime
Dp string `json:"dp"` // d mod (p-1)
Dq string `json:"dq"` // d mod (q-1)
Qi string `json:"qi"` // q^-1 mod p
}
if err := json.Unmarshal([]byte(jwkJSON), &jwk); err != nil {
return fmt.Errorf("failed to parse JWK JSON: %w", err)
}
// Decode base64url values to big.Int
n, err := base64urlToBigInt(jwk.N)
if err != nil {
return fmt.Errorf("failed to decode modulus: %w", err)
}
e, err := base64urlToBigInt(jwk.E)
if err != nil {
return fmt.Errorf("failed to decode exponent: %w", err)
}
d, err := base64urlToBigInt(jwk.D)
if err != nil {
return fmt.Errorf("failed to decode private exponent: %w", err)
}
p, err := base64urlToBigInt(jwk.P)
if err != nil {
return fmt.Errorf("failed to decode prime p: %w", err)
}
q, err := base64urlToBigInt(jwk.Q)
if err != nil {
return fmt.Errorf("failed to decode prime q: %w", err)
}
dp, err := base64urlToBigInt(jwk.Dp)
if err != nil {
return fmt.Errorf("failed to decode dp: %w", err)
}
dq, err := base64urlToBigInt(jwk.Dq)
if err != nil {
return fmt.Errorf("failed to decode dq: %w", err)
}
qi, err := base64urlToBigInt(jwk.Qi)
if err != nil {
return fmt.Errorf("failed to decode qi: %w", err)
}
// Construct RSA private key
rsaKey := &rsa.PrivateKey{
PublicKey: rsa.PublicKey{
N: n,
E: int(e.Int64()),
},
D: d,
Primes: []*big.Int{p, q},
Precomputed: rsa.PrecomputedValues{
Dp: dp,
Dq: dq,
Qinv: qi,
},
}
// Validate the key
if err := rsaKey.Validate(); err != nil {
return fmt.Errorf("invalid RSA key: %w", err)
}
// Encode private key to PKCS#8 PEM
privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(rsaKey)
if err != nil {
return fmt.Errorf("failed to marshal private key: %w", err)
}
privatePEM := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privateKeyBytes,
})
if err := os.WriteFile(privatePath, privatePEM, 0600); err != nil {
return fmt.Errorf("failed to write private key: %w", err)
}
// Encode public key to X.509 PEM
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
if err != nil {
return fmt.Errorf("failed to marshal public key: %w", err)
}
publicPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
})
if err := os.WriteFile(publicPath, publicPEM, 0644); err != nil {
return fmt.Errorf("failed to write public key: %w", err)
}
return nil
}
// base64urlToBigInt decodes a base64url or standard base64 string to a big.Int
// JWK spec uses base64url without padding, but some libraries use standard base64
func base64urlToBigInt(s string) (*big.Int, error) {
// Try base64url without padding first (JWK standard)
b, err := base64.RawURLEncoding.DecodeString(s)
if err == nil {
return new(big.Int).SetBytes(b), nil
}
// Try base64url with padding
b, err = base64.URLEncoding.DecodeString(addPadding(s))
if err == nil {
return new(big.Int).SetBytes(b), nil
}
// Try standard base64 without padding
b, err = base64.RawStdEncoding.DecodeString(s)
if err == nil {
return new(big.Int).SetBytes(b), nil
}
// Try standard base64 with padding
b, err = base64.StdEncoding.DecodeString(addPadding(s))
if err != nil {
return nil, fmt.Errorf("failed to decode base64: %w", err)
}
return new(big.Int).SetBytes(b), nil
}
// addPadding adds base64 padding if needed
func addPadding(s string) string {
switch len(s) % 4 {
case 2:
return s + "=="
case 3:
return s + "="
}
return s
}
// CreateAccessToken creates a new JWT access token
func CreateAccessToken(userID, displayName string, isAdmin bool) (string, error) {
if privateKey == nil {
return "", fmt.Errorf("private key not loaded")
}
now := time.Now()
claims := EagleClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID, // Eagle reads userId from "sub" claim
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenDuration)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "eagle0",
},
Name: displayName,
IsAdmin: isAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
// CreateRefreshToken creates a new refresh token
func CreateRefreshToken(userID string) (string, error) {
if privateKey == nil {
return "", fmt.Errorf("private key not loaded")
}
now := time.Now()
tokenID := uuid.New().String()
claims := RefreshClaims{
RegisteredClaims: jwt.RegisteredClaims{
ID: tokenID,
Subject: userID,
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenDuration)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "eagle0",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
// ValidateRefreshToken validates a refresh token and returns the user ID
func ValidateRefreshToken(tokenString string) (string, error) {
if publicKey == nil {
return "", fmt.Errorf("public key not loaded")
}
token, err := jwt.ParseWithClaims(tokenString, &RefreshClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return publicKey, nil
})
if err != nil {
return "", fmt.Errorf("failed to parse token: %w", err)
}
claims, ok := token.Claims.(*RefreshClaims)
if !ok || !token.Valid {
return "", fmt.Errorf("invalid token claims")
}
return claims.Subject, nil
}
// HashRefreshToken creates a hash of a refresh token for storage
func HashRefreshToken(token string) string {
hash := sha256.Sum256([]byte(token))
return hex.EncodeToString(hash[:])
}
@@ -0,0 +1,268 @@
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// setupTestKeys generates temporary RSA keys for testing
func setupTestKeys(t *testing.T) (string, func()) {
t.Helper()
// Generate a test RSA key pair
testPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("Failed to generate test private key: %v", err)
}
// Create temp directory
tempDir, err := os.MkdirTemp("", "jwt_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
// Write private key
privateKeyPath := filepath.Join(tempDir, "private.pem")
privateKeyBytes := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(testPrivateKey),
})
if err := os.WriteFile(privateKeyPath, privateKeyBytes, 0600); err != nil {
os.RemoveAll(tempDir)
t.Fatalf("Failed to write private key: %v", err)
}
// Write public key
publicKeyPath := filepath.Join(tempDir, "public.pem")
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&testPrivateKey.PublicKey)
if err != nil {
os.RemoveAll(tempDir)
t.Fatalf("Failed to marshal public key: %v", err)
}
publicKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
})
if err := os.WriteFile(publicKeyPath, publicKeyPEM, 0644); err != nil {
os.RemoveAll(tempDir)
t.Fatalf("Failed to write public key: %v", err)
}
// Load the keys
if err := loadKeys(privateKeyPath, publicKeyPath); err != nil {
os.RemoveAll(tempDir)
t.Fatalf("Failed to load keys: %v", err)
}
cleanup := func() {
os.RemoveAll(tempDir)
privateKey = nil
publicKey = nil
}
return tempDir, cleanup
}
func TestCreateAccessToken(t *testing.T) {
_, cleanup := setupTestKeys(t)
defer cleanup()
token, err := CreateAccessToken("user-123", "TestUser", false)
if err != nil {
t.Fatalf("CreateAccessToken failed: %v", err)
}
if token == "" {
t.Error("Expected non-empty token")
}
// Parse and validate the token
parsed, err := jwt.ParseWithClaims(token, &EagleClaims{}, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil
})
if err != nil {
t.Fatalf("Failed to parse token: %v", err)
}
claims, ok := parsed.Claims.(*EagleClaims)
if !ok {
t.Fatal("Failed to cast claims")
}
if claims.Subject != "user-123" {
t.Errorf("Expected Subject 'user-123', got '%s'", claims.Subject)
}
if claims.Name != "TestUser" {
t.Errorf("Expected Name 'TestUser', got '%s'", claims.Name)
}
if claims.IsAdmin != false {
t.Error("Expected IsAdmin to be false")
}
if claims.Issuer != "eagle0" {
t.Errorf("Expected Issuer 'eagle0', got '%s'", claims.Issuer)
}
}
func TestCreateAccessToken_Admin(t *testing.T) {
_, cleanup := setupTestKeys(t)
defer cleanup()
token, err := CreateAccessToken("admin-456", "AdminUser", true)
if err != nil {
t.Fatalf("CreateAccessToken failed: %v", err)
}
parsed, err := jwt.ParseWithClaims(token, &EagleClaims{}, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil
})
if err != nil {
t.Fatalf("Failed to parse token: %v", err)
}
claims := parsed.Claims.(*EagleClaims)
if claims.IsAdmin != true {
t.Error("Expected IsAdmin to be true")
}
}
func TestCreateAccessToken_Expiration(t *testing.T) {
_, cleanup := setupTestKeys(t)
defer cleanup()
token, err := CreateAccessToken("user-123", "TestUser", false)
if err != nil {
t.Fatalf("CreateAccessToken failed: %v", err)
}
parsed, err := jwt.ParseWithClaims(token, &EagleClaims{}, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil
})
if err != nil {
t.Fatalf("Failed to parse token: %v", err)
}
claims := parsed.Claims.(*EagleClaims)
// Check expiration is approximately 7 days from now
expectedExpiry := time.Now().Add(7 * 24 * time.Hour)
actualExpiry := claims.ExpiresAt.Time
// Allow 1 minute tolerance
diff := actualExpiry.Sub(expectedExpiry)
if diff < -time.Minute || diff > time.Minute {
t.Errorf("Token expiry not within expected range. Expected ~%v, got %v", expectedExpiry, actualExpiry)
}
}
func TestCreateRefreshToken(t *testing.T) {
_, cleanup := setupTestKeys(t)
defer cleanup()
token, err := CreateRefreshToken("user-123")
if err != nil {
t.Fatalf("CreateRefreshToken failed: %v", err)
}
if token == "" {
t.Error("Expected non-empty token")
}
// Validate the refresh token
userID, err := ValidateRefreshToken(token)
if err != nil {
t.Fatalf("ValidateRefreshToken failed: %v", err)
}
if userID != "user-123" {
t.Errorf("Expected UserID 'user-123', got '%s'", userID)
}
}
func TestValidateRefreshToken_InvalidToken(t *testing.T) {
_, cleanup := setupTestKeys(t)
defer cleanup()
_, err := ValidateRefreshToken("invalid-token")
if err == nil {
t.Error("Expected error for invalid token")
}
}
func TestValidateRefreshToken_WrongSignature(t *testing.T) {
_, cleanup := setupTestKeys(t)
defer cleanup()
// Create a token with a different key
otherKey, _ := rsa.GenerateKey(rand.Reader, 2048)
claims := RefreshClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: "user-123",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
Issuer: "eagle0",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
tokenString, _ := token.SignedString(otherKey)
_, err := ValidateRefreshToken(tokenString)
if err == nil {
t.Error("Expected error for token with wrong signature")
}
}
func TestHashRefreshToken(t *testing.T) {
hash1 := HashRefreshToken("token123")
hash2 := HashRefreshToken("token123")
hash3 := HashRefreshToken("token456")
if hash1 != hash2 {
t.Error("Same input should produce same hash")
}
if hash1 == hash3 {
t.Error("Different inputs should produce different hashes")
}
if len(hash1) != 64 { // SHA256 produces 64 hex characters
t.Errorf("Expected hash length 64, got %d", len(hash1))
}
}
func TestCreateAccessToken_NoPrivateKey(t *testing.T) {
// Ensure keys are nil
privateKey = nil
publicKey = nil
_, err := CreateAccessToken("user-123", "TestUser", false)
if err == nil {
t.Error("Expected error when private key is not loaded")
}
}
func TestCreateRefreshToken_NoPrivateKey(t *testing.T) {
privateKey = nil
publicKey = nil
_, err := CreateRefreshToken("user-123")
if err == nil {
t.Error("Expected error when private key is not loaded")
}
}
func TestValidateRefreshToken_NoPublicKey(t *testing.T) {
privateKey = nil
publicKey = nil
_, err := ValidateRefreshToken("some-token")
if err == nil {
t.Error("Expected error when public key is not loaded")
}
}
+160
View File
@@ -0,0 +1,160 @@
// Package main provides the OAuth authentication service for Eagle.
// It handles OAuth flows and issues JWT tokens, calling Eagle's internal
// UserService for user management.
package main
import (
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
auth "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
internalauth "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/auth"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
var (
grpcPort = flag.Int("grpc-port", 40033, "gRPC server port")
httpPort = flag.Int("http-port", 8080, "HTTP server port for OAuth callbacks")
eagleInternalURL = flag.String("eagle-internal-url", "localhost:40034", "Eagle internal gRPC address")
privateKeyPath = flag.String("private-key", "/etc/eagle0/keys/private.pem", "Path to RSA private key")
publicKeyPath = flag.String("public-key", "/etc/eagle0/keys/public.pem", "Path to RSA public key")
)
// getEnvInt reads an int from environment variable or returns the flag default
func getEnvInt(key string, flagValue *int) int {
if v := os.Getenv(key); v != "" {
if n, err := fmt.Sscanf(v, "%d", flagValue); n == 1 && err == nil {
return *flagValue
}
}
return *flagValue
}
// getEnvString reads a string from environment variable or returns the flag default
func getEnvString(key string, flagValue *string) string {
if v := os.Getenv(key); v != "" {
return v
}
return *flagValue
}
// Build info variables - set via ldflags
var (
buildTime = "unknown"
gitCommit = "unknown"
)
func main() {
flag.Parse()
log.Printf("Eagle Auth Service starting (build: %s, commit: %s)", buildTime, gitCommit)
// Read config from environment variables (with flag defaults as fallback)
actualGRPCPort := getEnvInt("AUTH_GRPC_PORT", grpcPort)
actualHTTPPort := getEnvInt("AUTH_HTTP_PORT", httpPort)
actualEagleURL := getEnvString("EAGLE_INTERNAL_ADDRESS", eagleInternalURL)
// JWT keys path can be a directory or explicit paths
keysPath := os.Getenv("JWT_KEYS_PATH")
actualPrivateKeyPath := *privateKeyPath
actualPublicKeyPath := *publicKeyPath
if keysPath != "" {
actualPrivateKeyPath = keysPath + "/private.pem"
actualPublicKeyPath = keysPath + "/public.pem"
}
// Try to load RSA keys from PEM files
err := loadKeys(actualPrivateKeyPath, actualPublicKeyPath)
if err != nil {
// PEM files don't exist - try to bootstrap from JWT_PRIVATE_KEY env var (JWK format)
jwkJSON := os.Getenv("JWT_PRIVATE_KEY")
if jwkJSON == "" {
log.Fatalf("Failed to load RSA keys: %v (and JWT_PRIVATE_KEY not set for bootstrap)", err)
}
log.Printf("PEM files not found, bootstrapping from JWT_PRIVATE_KEY...")
if err := bootstrapKeysFromJWK(jwkJSON, actualPrivateKeyPath, actualPublicKeyPath); err != nil {
log.Fatalf("Failed to bootstrap keys from JWK: %v", err)
}
log.Printf("Created PEM files from JWT_PRIVATE_KEY")
// Now load the newly created PEM files
if err := loadKeys(actualPrivateKeyPath, actualPublicKeyPath); err != nil {
log.Fatalf("Failed to load bootstrapped RSA keys: %v", err)
}
}
log.Printf("Loaded RSA keys from %s", actualPrivateKeyPath)
// Connect to Eagle's internal UserService
eagleConn, err := grpc.Dial(actualEagleURL,
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
log.Fatalf("Failed to connect to Eagle internal service: %v", err)
}
defer eagleConn.Close()
userClient := internalauth.NewInternalUserServiceClient(eagleConn)
log.Printf("Connected to Eagle internal service at %s", actualEagleURL)
// Create OAuth service
oauthSvc := NewOAuthService(getOAuthConfigs())
// Create auth service handler
authHandler := NewAuthHandler(oauthSvc, userClient)
// Start gRPC server
grpcLis, err := net.Listen("tcp", fmt.Sprintf(":%d", actualGRPCPort))
if err != nil {
log.Fatalf("Failed to listen on gRPC port: %v", err)
}
grpcServer := grpc.NewServer()
auth.RegisterAuthServer(grpcServer, authHandler)
log.Printf("gRPC server listening on port %d", actualGRPCPort)
go func() {
if err := grpcServer.Serve(grpcLis); err != nil {
log.Fatalf("gRPC server failed: %v", err)
}
}()
// Start HTTP server for OAuth callbacks
http.HandleFunc("/oauth/callback", oauthSvc.HandleCallback)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "OK")
})
log.Printf("HTTP server listening on port %d", actualHTTPPort)
if err := http.ListenAndServe(fmt.Sprintf(":%d", actualHTTPPort), nil); err != nil {
log.Fatalf("HTTP server failed: %v", err)
}
}
// getOAuthConfigs reads OAuth configuration from environment variables
func getOAuthConfigs() map[string]OAuthProviderConfig {
return map[string]OAuthProviderConfig{
"discord": {
ClientID: os.Getenv("DISCORD_CLIENT_ID"),
ClientSecret: os.Getenv("DISCORD_CLIENT_SECRET"),
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
TokenEndpoint: "https://discord.com/api/oauth2/token",
UserInfoEndpoint: "https://discord.com/api/users/@me",
Scopes: []string{"identify", "email"},
},
"google": {
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
AuthorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
TokenEndpoint: "https://oauth2.googleapis.com/token",
UserInfoEndpoint: "https://www.googleapis.com/oauth2/v2/userinfo",
Scopes: []string{"openid", "email", "profile"},
},
}
}
+374
View File
@@ -0,0 +1,374 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
// OAuthProviderConfig holds configuration for an OAuth provider
type OAuthProviderConfig struct {
ClientID string
ClientSecret string
AuthorizationEndpoint string
TokenEndpoint string
UserInfoEndpoint string
Scopes []string
}
// OAuthState represents a pending OAuth session
type OAuthState struct {
Provider string
CreatedAt time.Time
}
// ProviderUserInfo holds user info from an OAuth provider
type ProviderUserInfo struct {
ID string
Email string
AvatarURL string
Username string
}
// OAuthResult represents the result of an OAuth flow
type OAuthResult struct {
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
}
// OAuthService manages OAuth state and handles callbacks
type OAuthService struct {
configs map[string]OAuthProviderConfig
callbackURL string
pendingOAuth sync.Map // state -> OAuthState
completedOAuth sync.Map // state -> OAuthResult
httpClient *http.Client
}
const stateExpiration = 10 * time.Minute
// NewOAuthService creates a new OAuth service
func NewOAuthService(configs map[string]OAuthProviderConfig) *OAuthService {
callbackURL := getCallbackURL()
log.Printf("OAuth: using callback URL: %s", callbackURL)
svc := &OAuthService{
configs: configs,
callbackURL: callbackURL,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
// Start cleanup goroutine
go svc.cleanupLoop()
return svc
}
func getCallbackURL() string {
// Explicit callback URL takes precedence
if url := os.Getenv("OAUTH_CALLBACK_URL"); url != "" {
return url
}
// Construct from SERVER_BASE_URL (matches Scala behavior)
if baseURL := os.Getenv("SERVER_BASE_URL"); baseURL != "" {
return baseURL + "/oauth/callback"
}
return "http://localhost:8080/oauth/callback"
}
// GetAuthURL generates an OAuth authorization URL
func (s *OAuthService) GetAuthURL(provider string) (authURL string, state string, err error) {
config, ok := s.configs[provider]
if !ok {
return "", "", fmt.Errorf("unsupported provider: %s", provider)
}
state = uuid.New().String()
s.pendingOAuth.Store(state, OAuthState{
Provider: provider,
CreatedAt: time.Now(),
})
log.Printf("OAuth: created state=%s for provider=%s", state, provider)
params := url.Values{}
params.Set("client_id", config.ClientID)
params.Set("redirect_uri", s.callbackURL)
params.Set("response_type", "code")
params.Set("scope", strings.Join(config.Scopes, " "))
params.Set("state", state)
authURL = config.AuthorizationEndpoint + "?" + params.Encode()
return authURL, state, nil
}
// CheckStatus checks the status of an OAuth flow
func (s *OAuthService) CheckStatus(state string) OAuthResult {
// Check completed results first
if result, ok := s.completedOAuth.Load(state); ok {
return result.(OAuthResult)
}
// Check if still pending
if stateData, ok := s.pendingOAuth.Load(state); ok {
oauthState := stateData.(OAuthState)
if time.Since(oauthState.CreatedAt) < stateExpiration {
return OAuthResult{} // Pending - empty result
}
// Expired
s.pendingOAuth.Delete(state)
return OAuthResult{Error: "expired"}
}
// Unknown state
return OAuthResult{Error: "expired"}
}
// IsPending returns true if the state is still pending
func (s *OAuthService) IsPending(state string) bool {
if stateData, ok := s.pendingOAuth.Load(state); ok {
oauthState := stateData.(OAuthState)
return time.Since(oauthState.CreatedAt) < stateExpiration
}
return false
}
// HandleCallback handles the OAuth callback from the provider
func (s *OAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
errorParam := r.URL.Query().Get("error")
log.Printf("OAuth callback: state=%s, hasCode=%v, error=%s", state, code != "", errorParam)
if errorParam != "" {
s.completeWithError(state, fmt.Sprintf("OAuth error: %s", errorParam))
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
return
}
if code == "" || state == "" {
http.Error(w, "Missing code or state", http.StatusBadRequest)
return
}
// Get pending OAuth info
stateData, ok := s.pendingOAuth.Load(state)
if !ok {
log.Printf("OAuth callback: state=%s not found in pending", state)
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
return
}
oauthState := stateData.(OAuthState)
if time.Since(oauthState.CreatedAt) >= stateExpiration {
s.pendingOAuth.Delete(state)
s.completeWithError(state, "OAuth session expired")
http.Error(w, "OAuth session expired", http.StatusBadRequest)
return
}
// Exchange code for token
config := s.configs[oauthState.Provider]
accessToken, err := s.exchangeCodeForToken(config, code)
if err != nil {
log.Printf("OAuth callback: token exchange failed: %v", err)
s.completeWithError(state, err.Error())
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
}
// Fetch user info
userInfo, err := s.fetchUserInfo(oauthState.Provider, config, accessToken)
if err != nil {
log.Printf("OAuth callback: user info fetch failed: %v", err)
s.completeWithError(state, err.Error())
http.Error(w, "Failed to fetch user info", http.StatusInternalServerError)
return
}
// Mark as completed
s.pendingOAuth.Delete(state)
s.completedOAuth.Store(state, OAuthResult{
Success: true,
UserInfo: userInfo,
Provider: oauthState.Provider,
})
log.Printf("OAuth callback: SUCCESS state=%s user=%s", state, userInfo.Username)
// Return success page
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head><title>Login Successful</title></head>
<body>
<h1>Login Successful!</h1>
<p>You can close this window and return to the game.</p>
<script>window.close();</script>
</body>
</html>`)
}
func (s *OAuthService) completeWithError(state string, errMsg string) {
s.pendingOAuth.Delete(state)
s.completedOAuth.Store(state, OAuthResult{
Success: false,
Error: errMsg,
})
}
func (s *OAuthService) exchangeCodeForToken(config OAuthProviderConfig, code string) (string, error) {
data := url.Values{}
data.Set("client_id", config.ClientID)
data.Set("client_secret", config.ClientSecret)
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("redirect_uri", s.callbackURL)
req, err := http.NewRequest("POST", config.TokenEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("token exchange failed: %d - %s", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
}
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", err
}
return tokenResp.AccessToken, nil
}
func (s *OAuthService) fetchUserInfo(provider string, config OAuthProviderConfig, accessToken string) (*ProviderUserInfo, error) {
req, err := http.NewRequest("GET", config.UserInfoEndpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("user info fetch failed: %d - %s", resp.StatusCode, string(body))
}
return s.parseUserInfo(provider, body)
}
func (s *OAuthService) parseUserInfo(provider string, data []byte) (*ProviderUserInfo, error) {
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return nil, err
}
switch provider {
case "discord":
id, _ := result["id"].(string)
username, _ := result["username"].(string)
email, _ := result["email"].(string)
avatar, _ := result["avatar"].(string)
avatarURL := ""
if avatar != "" {
avatarURL = fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", id, avatar)
}
return &ProviderUserInfo{
ID: id,
Username: username,
Email: email,
AvatarURL: avatarURL,
}, nil
case "google":
id, _ := result["id"].(string)
name, _ := result["name"].(string)
email, _ := result["email"].(string)
picture, _ := result["picture"].(string)
return &ProviderUserInfo{
ID: id,
Username: name,
Email: email,
AvatarURL: picture,
}, nil
default:
return nil, fmt.Errorf("unsupported provider: %s", provider)
}
}
func (s *OAuthService) cleanupLoop() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
s.cleanupExpiredStates()
}
}
func (s *OAuthService) cleanupExpiredStates() {
cutoff := time.Now().Add(-stateExpiration)
s.pendingOAuth.Range(func(key, value interface{}) bool {
state := value.(OAuthState)
if state.CreatedAt.Before(cutoff) {
s.pendingOAuth.Delete(key)
}
return true
})
// Keep completed results for a bit longer (2x expiration)
completedCutoff := time.Now().Add(-2 * stateExpiration)
s.completedOAuth.Range(func(key, value interface{}) bool {
// We don't have a timestamp on completed results, so we check if
// the corresponding pending state exists and is old
if _, ok := s.pendingOAuth.Load(key); !ok {
// No pending state, check if it's been a while
// For simplicity, delete completed results that aren't claimed within 2x expiration
s.completedOAuth.Delete(key)
}
_ = completedCutoff // suppress unused warning
return true
})
}
@@ -0,0 +1,312 @@
package main
import (
"os"
"testing"
"time"
)
func TestNewOAuthService(t *testing.T) {
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
},
}
svc := NewOAuthService(configs)
if svc == nil {
t.Fatal("Expected non-nil OAuthService")
}
}
func TestGetAuthURL(t *testing.T) {
// Set callback URL for test
os.Setenv("OAUTH_CALLBACK_URL", "https://test.eagle0.net/oauth/callback")
defer os.Unsetenv("OAUTH_CALLBACK_URL")
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
Scopes: []string{"identify", "email"},
},
}
svc := NewOAuthService(configs)
url, state, err := svc.GetAuthURL("discord")
if err != nil {
t.Fatalf("GetAuthURL failed: %v", err)
}
if url == "" {
t.Error("Expected non-empty URL")
}
if state == "" {
t.Error("Expected non-empty state")
}
// Verify state is stored as pending
if !svc.IsPending(state) {
t.Error("State should be pending after GetAuthURL")
}
}
func TestGetAuthURL_UnknownProvider(t *testing.T) {
configs := map[string]OAuthProviderConfig{}
svc := NewOAuthService(configs)
_, _, err := svc.GetAuthURL("unknown")
if err == nil {
t.Error("Expected error for unknown provider")
}
}
func TestGetAuthURL_EmptyClientID(t *testing.T) {
// Note: The current implementation doesn't validate empty ClientID
// It will still generate a URL, just with an empty client_id parameter
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "",
ClientSecret: "test-secret",
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
},
}
svc := NewOAuthService(configs)
url, state, err := svc.GetAuthURL("discord")
// No error expected - the provider exists, it just has empty client ID
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if url == "" {
t.Error("Expected URL to be generated")
}
if state == "" {
t.Error("Expected state to be generated")
}
}
func TestIsPending(t *testing.T) {
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
},
}
svc := NewOAuthService(configs)
// Unknown state should not be pending
if svc.IsPending("unknown-state") {
t.Error("Unknown state should not be pending")
}
// Create a pending state
_, state, _ := svc.GetAuthURL("discord")
if !svc.IsPending(state) {
t.Error("State should be pending after GetAuthURL")
}
}
func TestCheckStatus_Pending(t *testing.T) {
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
},
}
svc := NewOAuthService(configs)
_, state, _ := svc.GetAuthURL("discord")
result := svc.CheckStatus(state)
if result.Success {
t.Error("Expected Success to be false for pending state")
}
if result.Error != "" {
t.Errorf("Expected no error for pending state, got: %s", result.Error)
}
}
func TestCheckStatus_Unknown(t *testing.T) {
configs := map[string]OAuthProviderConfig{}
svc := NewOAuthService(configs)
result := svc.CheckStatus("unknown-state")
if result.Success {
t.Error("Expected Success to be false for unknown state")
}
if result.Error != "expired" {
t.Errorf("Expected 'expired' error for unknown state, got: %s", result.Error)
}
}
func TestCheckStatus_Completed(t *testing.T) {
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
},
}
svc := NewOAuthService(configs)
// Create a pending state
_, state, _ := svc.GetAuthURL("discord")
// Simulate completion by directly storing in completedOAuth
testResult := OAuthResult{
Success: true,
Provider: "discord",
UserInfo: &ProviderUserInfo{
ID: "user-123",
Email: "test@example.com",
},
}
svc.completedOAuth.Store(state, testResult)
svc.pendingOAuth.Delete(state)
result := svc.CheckStatus(state)
if !result.Success {
t.Error("Expected Success to be true for completed state")
}
if result.Provider != "discord" {
t.Errorf("Expected provider 'discord', got '%s'", result.Provider)
}
if result.UserInfo.ID != "user-123" {
t.Errorf("Expected user ID 'user-123', got '%s'", result.UserInfo.ID)
}
}
func TestCheckStatus_Error(t *testing.T) {
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
},
}
svc := NewOAuthService(configs)
// Create a pending state
_, state, _ := svc.GetAuthURL("discord")
// Simulate error by storing error result
errorResult := OAuthResult{
Success: false,
Error: "access_denied",
}
svc.completedOAuth.Store(state, errorResult)
svc.pendingOAuth.Delete(state)
result := svc.CheckStatus(state)
if result.Success {
t.Error("Expected Success to be false for error result")
}
if result.Error != "access_denied" {
t.Errorf("Expected error 'access_denied', got '%s'", result.Error)
}
}
func TestPendingOAuthExpiry(t *testing.T) {
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
},
}
svc := NewOAuthService(configs)
_, state, _ := svc.GetAuthURL("discord")
// Verify the pending entry has a creation time
value, ok := svc.pendingOAuth.Load(state)
if !ok {
t.Fatal("Expected pending entry to exist")
}
pending := value.(OAuthState)
if pending.CreatedAt.After(time.Now()) {
t.Error("CreatedAt should be in the past or present")
}
if time.Since(pending.CreatedAt) > time.Second {
t.Error("CreatedAt should be very recent")
}
}
func TestProviderUserInfo(t *testing.T) {
info := ProviderUserInfo{
ID: "12345",
Email: "user@example.com",
AvatarURL: "https://example.com/avatar.png",
}
if info.ID != "12345" {
t.Errorf("Expected ID '12345', got '%s'", info.ID)
}
if info.Email != "user@example.com" {
t.Errorf("Expected Email 'user@example.com', got '%s'", info.Email)
}
if info.AvatarURL != "https://example.com/avatar.png" {
t.Errorf("Expected AvatarURL, got '%s'", info.AvatarURL)
}
}
func TestOAuthResult(t *testing.T) {
// Test successful result
successResult := OAuthResult{
Success: true,
Provider: "google",
UserInfo: &ProviderUserInfo{ID: "user-1"},
}
if !successResult.Success {
t.Error("Expected Success to be true")
}
if successResult.Provider != "google" {
t.Errorf("Expected provider 'google', got '%s'", successResult.Provider)
}
// Test error result
errorResult := OAuthResult{
Success: false,
Error: "token_expired",
}
if errorResult.Success {
t.Error("Expected Success to be false")
}
if errorResult.Error != "token_expired" {
t.Errorf("Expected error 'token_expired', got '%s'", errorResult.Error)
}
}
func TestMultiplePendingStates(t *testing.T) {
configs := map[string]OAuthProviderConfig{
"discord": {
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
},
}
svc := NewOAuthService(configs)
// Create multiple pending states
_, state1, _ := svc.GetAuthURL("discord")
_, state2, _ := svc.GetAuthURL("discord")
_, state3, _ := svc.GetAuthURL("discord")
// All should be unique
if state1 == state2 || state2 == state3 || state1 == state3 {
t.Error("States should be unique")
}
// All should be pending
if !svc.IsPending(state1) || !svc.IsPending(state2) || !svc.IsPending(state3) {
t.Error("All states should be pending")
}
}
@@ -13,11 +13,13 @@ option objc_class_prefix = "E0A";
// Authentication service for OAuth login
service Auth {
// Get OAuth URL to open in system browser
// Get OAuth URL to open in system browser.
// The server handles the OAuth callback - client polls CheckOAuthStatus.
rpc GetOAuthUrl(GetOAuthUrlRequest) returns (GetOAuthUrlResponse) {}
// Exchange authorization code for JWT tokens
rpc ExchangeCode(ExchangeCodeRequest) returns (ExchangeCodeResponse) {}
// Poll for OAuth completion. Call this after opening the URL from GetOAuthUrl.
// The server receives the OAuth callback and exchanges the code for tokens.
rpc CheckOAuthStatus(CheckOAuthStatusRequest) returns (CheckOAuthStatusResponse) {}
// Set or update display name (requires valid JWT)
rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse) {}
@@ -42,28 +44,36 @@ enum OAuthProvider {
// Request to initiate OAuth flow
message GetOAuthUrlRequest {
OAuthProvider provider = 1;
string redirect_uri = 2; // e.g., "eagle0://auth/callback"
}
message GetOAuthUrlResponse {
string auth_url = 1; // Full OAuth URL to open in system browser
string state = 2; // State parameter for CSRF protection
string state = 2; // State parameter - use this to poll CheckOAuthStatus
}
// Exchange authorization code for JWT
message ExchangeCodeRequest {
OAuthProvider provider = 1;
string authorization_code = 2;
string state = 3; // Must match state from GetOAuthUrlResponse
string redirect_uri = 4; // Must match exactly what was used in GetOAuthUrlRequest
// Poll for OAuth completion status
message CheckOAuthStatusRequest {
string state = 1; // State from GetOAuthUrlResponse
}
message ExchangeCodeResponse {
string access_token = 1; // JWT for API access
string refresh_token = 2; // Long-lived token for getting new access tokens
int64 expires_at = 3; // Unix timestamp when access_token expires
UserInfo user = 4;
bool is_new_user = 5; // True if user needs to set display name
message CheckOAuthStatusResponse {
OAuthStatus status = 1;
// Only set when status is OAUTH_STATUS_SUCCESS:
string access_token = 2;
string refresh_token = 3;
int64 expires_at = 4;
UserInfo user = 5;
bool is_new_user = 6;
// Only set when status is OAUTH_STATUS_FAILED:
string error_message = 7;
}
enum OAuthStatus {
OAUTH_STATUS_UNSPECIFIED = 0;
OAUTH_STATUS_PENDING = 1; // User hasn't completed OAuth yet
OAUTH_STATUS_SUCCESS = 2; // OAuth complete, tokens available
OAUTH_STATUS_FAILED = 3; // OAuth failed (user denied, etc.)
OAUTH_STATUS_EXPIRED = 4; // State token expired (typically 10 minutes)
}
// User information returned from auth endpoints
@@ -83,6 +93,7 @@ message SetDisplayNameResponse {
bool success = 1;
string error_message = 2; // If not success: "Name taken", "Invalid characters", etc.
UserInfo user = 3;
string access_token = 4; // New JWT with updated displayName claim
}
// Refresh access token
@@ -41,6 +41,8 @@ service Eagle {
rpc ConvertHumanToAi(ConvertHumanToAiRequest) returns (ConvertHumanToAiResponse) {}
rpc ReassignFaction(ReassignFactionRequest) returns (ReassignFactionResponse) {}
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse) {}
rpc ImportGame(ImportGameRequest) returns (ImportGameResponse) {}
rpc DownloadGameSave(DownloadGameSaveRequest) returns (stream DownloadGameSaveResponse) {}
}
message PostCommandRequest {
@@ -554,3 +556,26 @@ message RewindGameResponse {
int32 new_action_count = 3;
int32 resynced_clients = 4; // Number of clients that were resynced with the new state
}
message ImportGameRequest {
int64 game_id = 1;
}
message ImportGameResponse {
enum Result {
SUCCESS = 0;
GAME_NOT_FOUND = 1;
ALREADY_RUNNING = 2;
INVALID_SAVE = 3;
}
Result result = 1;
string error_message = 2;
}
message DownloadGameSaveRequest {
int64 game_id = 1;
}
message DownloadGameSaveResponse {
bytes chunk = 1; // Chunk of zip file data
}
@@ -783,6 +783,34 @@ proto_library(
deps = ["@com_google_protobuf//:timestamp_proto"],
)
# Internal auth service for Go auth service to call Eagle
scala_proto_library(
name = "auth_internal_scala_grpc",
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [":auth_internal_proto"],
)
proto_library(
name = "auth_internal_proto",
srcs = ["auth_internal.proto"],
visibility = [
"//src/main/go/net/eagle0:__subpackages__",
],
)
# Uses go_grpc (v1) which generates both messages and gRPC code
# (go_grpc_v2 only generates gRPC code, not the message types)
go_proto_library(
name = "auth_internal_go_grpc",
compilers = ["@io_bazel_rules_go//proto:go_grpc"], # keep
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/auth",
proto = ":auth_internal_proto",
visibility = ["//src/main/go/net/eagle0:__subpackages__"],
)
go_proto_library(
name = "internal_go_proto",
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal",
@@ -0,0 +1,54 @@
//
// Copyright 2026 Dan Crosby
//
syntax = "proto3";
package net.eagle0.eagle.internal.auth;
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.internal.auth";
option go_package = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/auth";
// Internal service for user management, called by the Go auth service.
// This is NOT exposed to clients - only for internal container-to-container communication.
service InternalUserService {
// Find or create a user from OAuth provider info.
// Called by Go auth service after successful OAuth callback.
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse) {}
// Get user by internal user ID.
// Used to look up user info for JWT claims.
rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
}
// Request to find or create a user from OAuth provider info
message GetOrCreateUserRequest {
string provider = 1; // "discord" or "google"
string provider_user_id = 2; // ID from the OAuth provider
string email = 3; // Email from provider
string avatar_url = 4; // Avatar URL from provider
}
message GetOrCreateUserResponse {
string user_id = 1; // Internal Eagle0 user ID (UUID)
string display_name = 2; // Player-chosen display name (may be empty for new users)
string avatar_url = 3; // Profile picture URL
bool is_admin = 4; // Admin flag for impersonation support
bool is_new_user = 5; // True if this user was just created
string provider = 6; // The provider used for this login
}
// Request to get user by ID
message GetUserRequest {
string user_id = 1; // Internal Eagle0 user ID (UUID)
}
message GetUserResponse {
bool found = 1; // True if user exists
string user_id = 2; // Internal Eagle0 user ID (UUID)
string display_name = 3; // Player-chosen display name
string avatar_url = 4; // Profile picture URL
bool is_admin = 5; // Admin flag
string provider = 6; // Primary OAuth provider
}
@@ -9,6 +9,7 @@ scala_binary(
":eagle_pkg",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:auth_internal_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
@@ -20,7 +21,10 @@ scala_binary(
"//src/main/scala/net/eagle0/eagle/service:authorization_interceptor",
"//src/main/scala/net/eagle0/eagle/service:custom_battle_manager",
"//src/main/scala/net/eagle0/eagle/service:exception_interceptor",
"//src/main/scala/net/eagle0/eagle/service:external_auth_client",
"//src/main/scala/net/eagle0/eagle/service:games_manager",
"//src/main/scala/net/eagle0/eagle/service:internal_user_service",
"//src/main/scala/net/eagle0/eagle/service:oauth_http_handler",
"//src/main/scala/net/eagle0/eagle/service:server_setup_helpers",
"//src/main/scala/net/eagle0/eagle/service/persistence:async_s3_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister",
+97 -9
View File
@@ -8,7 +8,8 @@ import io.grpc.{Server, ServerBuilder}
import net.eagle0.common.{FunctionalRandom, SeededRandom, SimpleTimedLogger}
import net.eagle0.eagle.api.auth.auth.AuthGrpc
import net.eagle0.eagle.api.eagle.EagleGrpc
import net.eagle0.eagle.auth.{JwtService, JwtServiceImpl, OAuthServiceImpl, UserServiceImpl}
import net.eagle0.eagle.auth.{JwtService, JwtServiceImpl, OAuthService, OAuthServiceImpl, UserService, UserServiceImpl}
import net.eagle0.eagle.internal.auth.auth_internal.InternalUserServiceGrpc
import net.eagle0.eagle.service.*
import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, SaveDirectory}
@@ -25,6 +26,19 @@ object Main {
private val gptModelNameKey = Symbol("gptModelName")
private val serverBaseUrlKey = Symbol("serverBaseUrl")
private val defaultServerBaseUrl = "https://prod.eagle0.net"
private val oauthHttpPortKey = Symbol("oauthHttpPort")
private val defaultOauthHttpPort = "8080"
// External Go auth service address (optional - if set, OAuth is forwarded to Go)
private val authServiceUrlKey = Symbol("authServiceUrl")
// Internal gRPC port for Go auth service to call back (InternalUserService)
private val internalGrpcPortKey = Symbol("internalGrpcPort")
private val defaultInternalGrpcPort = "40034"
def parsedOptions(args: Array[String]): Map[Symbol, String] = {
def nextOption(
map: Map[Symbol, String],
@@ -43,6 +57,26 @@ object Main {
map ++ Map(gptModelNameKey -> value),
tail
)
case "--server-base-url" +: value +: tail =>
nextOption(
map ++ Map(serverBaseUrlKey -> value),
tail
)
case "--oauth-http-port" +: value +: tail =>
nextOption(
map ++ Map(oauthHttpPortKey -> value),
tail
)
case "--auth-service-url" +: value +: tail =>
nextOption(
map ++ Map(authServiceUrlKey -> value),
tail
)
case "--internal-grpc-port" +: value +: tail =>
nextOption(
map ++ Map(internalGrpcPortKey -> value),
tail
)
case option +: _ =>
throw new IllegalArgumentException("Unknown option " + option)
@@ -77,11 +111,59 @@ object Main {
val customBattleManager = newCustomBattleManager(shardokInterfaceAddress)
customBattleManager.begin()
val serverBaseUrl =
options.getOrElse(serverBaseUrlKey, defaultServerBaseUrl)
// Create OAuth service (shared between gRPC and HTTP)
val oauthService = new OAuthServiceImpl(serverBaseUrl)
// Initialize auth services
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
val userService = new UserServiceImpl(authPersister)
// Check if external auth service is configured
val authServiceUrl: Option[String] = options.get(authServiceUrlKey)
// Start OAuth HTTP server only if NOT using external auth service
if authServiceUrl.isEmpty then {
val oauthHttpPort =
options.getOrElse(oauthHttpPortKey, defaultOauthHttpPort).toInt
val oauthHttpHandler = new OAuthHttpHandler(oauthService, oauthHttpPort)
oauthHttpHandler.start()
}
// Start internal gRPC server if external auth service is configured
val internalServer: Option[Server] = authServiceUrl.map { _ =>
val internalGrpcPort =
options.getOrElse(internalGrpcPortKey, defaultInternalGrpcPort).toInt
val internalServerBuilder = ServerBuilder.forPort(internalGrpcPort)
internalServerBuilder.addService(
InternalUserServiceGrpc.bindService(
InternalUserServiceImpl(userService),
ExecutionContext.global
)
)
val server = internalServerBuilder.build.start
SimpleTimedLogger.printLogger.logLine(
s"Internal gRPC server (InternalUserService) listening on port $internalGrpcPort"
)
server
}
// Create external auth client if configured
val externalAuthClient: Option[ExternalAuthClient] = authServiceUrl.map { url =>
SimpleTimedLogger.printLogger.logLine(s"Using external auth service at $url")
ExternalAuthClient(url)(ExecutionContext.global)
}
val server = buildServer(
gamesManager,
customBattleManager,
options.getOrElse(eagleGrpcPortKey, defaultEagleGrpcPort).toInt,
SeededRandom(random.nextLong())
SeededRandom(random.nextLong()),
oauthService,
userService,
externalAuthClient
).start
SimpleTimedLogger.printLogger.logLine(
s"Eagle server is listening on port ${server.getPort}"
@@ -94,15 +176,15 @@ object Main {
gamesManager: GamesManager,
customBattleManager: CustomBattleManager,
grpcPort: Int,
functionalRandom: FunctionalRandom
functionalRandom: FunctionalRandom,
oauthService: OAuthService,
userService: UserService,
externalAuthClient: Option[ExternalAuthClient]
): Server = {
val serverBuilder = ServerBuilder.forPort(grpcPort)
// Initialize auth services
// Initialize JWT service
val jwtService: Option[JwtService] = JwtServiceImpl.fromEnvironment()
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
val userService = new UserServiceImpl(authPersister)
val oauthService = new OAuthServiceImpl()
// Add Eagle game service
serverBuilder.addService(
@@ -118,11 +200,17 @@ object Main {
serverBuilder.addService(
AuthGrpc
.bindService(
AuthServiceImpl(oauthService, userService, jwt),
AuthServiceImpl(oauthService, userService, jwt, externalAuthClient),
ExecutionContext.global
)
)
SimpleTimedLogger.printLogger.logLine("OAuth auth service enabled")
if externalAuthClient.isDefined then {
SimpleTimedLogger.printLogger.logLine(
"OAuth auth service enabled (proxying to external Go auth service)"
)
} else {
SimpleTimedLogger.printLogger.logLine("OAuth auth service enabled")
}
}
if jwtService.isEmpty then {
@@ -7,16 +7,14 @@ import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
AlmsCommandSelector,
AttackDecisionCommandChooser,
CommandChooser
}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.Engine
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
case class AIClientWithSelectedCommand(
client: AIClient,
@@ -55,13 +53,13 @@ case class AIClient(
else
chooseFrom(
maybeCommandsMap.get.commandsByProvince,
GameStateConverter.toProto(engine.currentState),
engine.currentState,
functionalRandom
)
}
private def chooseMidGameCommandFrom(
gameState: GameStateProto,
gameState: GameState,
oneProvinceAvailableCommands: OneProvinceAvailableCommands,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] =
@@ -82,7 +80,7 @@ case class AIClient(
fr
) =>
logger.logLine(
s"${gameState.currentDate.get.year} ${gameState.currentDate.get.month} ${gameState.currentPhase}"
s"${gameState.currentDate.map(_.year).getOrElse("?")} ${gameState.currentDate.map(_.month).getOrElse("?")} ${gameState.currentPhase}"
)
logger.logLine(
s"$actingProvinceId (${gameState.provinces(actingProvinceId).name})"
@@ -116,7 +114,7 @@ case class AIClient(
private def chooseFrom(
acs: Map[ProvinceId, OneProvinceAvailableCommands],
gs: GameStateProto,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] = {
val opac = acs.head._2
@@ -151,7 +149,7 @@ case class AIClient(
(actingFactionId, gameState, availableCommands, functionalRandom) =>
AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands,
functionalRandom
),
@@ -187,7 +185,7 @@ case class AIClient(
),
(
fid: FactionId,
gs: GameStateProto,
gameState: GameState,
acs: Vector[AvailableCommand],
functionalRandom
) =>
@@ -195,7 +193,7 @@ case class AIClient(
AlmsCommandSelector
.chosenAlmsForSupportWithMinimumCommandForProvince(
actingFactionId = fid,
gameState = GameStateConverter.fromProto(gs),
gameState = gameState,
availableCommands = acs,
minimumFood = 0,
provinceId = opac.provinceId
@@ -1,49 +1,35 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.province.Province as ProvinceProto
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
object AIClientUtils {
def extraHeroCount(pid: ProvinceId, fid: FactionId, gs: GameStateProto): Int =
(gs.provinces(pid).rulingFactionHeroIds.size - desiredHeroCount(
pid,
def extraHeroCount(
province: ProvinceT,
fid: FactionId,
allProvinces: Map[ProvinceId, ProvinceT],
factions: Vector[FactionT]
): Int =
(province.rulingFactionHeroIds.size - desiredHeroCount(
province,
fid,
gs
allProvinces,
factions
)).max(0)
def desiredHeroCount(pid: ProvinceId, fid: FactionId, gs: GameStateProto): Int =
if LegacyFactionUtils.hostileNeighbors(pid, fid, gs).nonEmpty then 3
else if gs.provinces(pid).support < 65 then 2
else 1
def desiredCountForMarchTowardFocus(
originProvince: ProvinceProto,
destinationProvince: ProvinceProto,
availableHeroIds: Vector[HeroId],
factionLeaders: Vector[HeroId],
favorLeaders: Boolean
def desiredHeroCount(
province: ProvinceT,
fid: FactionId,
allProvinces: Map[ProvinceId, ProvinceT],
factions: Vector[FactionT]
): Int =
if favorLeaders &&
originProvince.rulingFactionHeroIds.size == 2 &&
originProvince.rulingFactionHeroIds
.intersect(factionLeaders)
.nonEmpty &&
destinationProvince.rulingFactionHeroIds
.intersect(factionLeaders)
.isEmpty
then 1
else
Vector(
10,
originProvince.rulingFactionHeroIds.size - 2,
availableHeroIds.size
).min
if FactionUtils.hostileNeighbors(province, fid, allProvinces, factions).nonEmpty then 3
else if province.support < 65 then 2
else 1
def desiredCountForMarchTowardFocus(
originProvince: ProvinceT,
@@ -68,26 +54,6 @@ object AIClientUtils {
availableHeroIds.size
).min
def takenHeroIdsForMarchTowardFocus(
originProvince: ProvinceProto,
destinationProvince: ProvinceProto,
availableHeroIds: Vector[HeroId],
favorLeaders: Boolean,
gs: GameStateProto
): Vector[HeroId] =
mostPowerfulHeroes(
desiredCountForMarchTowardFocus(
originProvince = originProvince,
destinationProvince = destinationProvince,
availableHeroIds = availableHeroIds,
factionLeaders = gs.factions(originProvince.rulingFactionId.get).leaders.toVector,
favorLeaders = favorLeaders
),
gs,
availableHeroIds,
favorLeaders
)
def takenHeroIdsForMarchTowardFocus(
originProvince: ProvinceT,
destinationProvince: ProvinceT,
@@ -110,29 +76,6 @@ object AIClientUtils {
heroes
)
def mostPowerfulHeroes(
desiredCount: Int,
gs: GameStateProto,
availableHeroIds: Vector[HeroId],
favorLeaders: Boolean
): Vector[HeroId] = {
val availableHeroes = availableHeroIds
.map(gs.heroes)
if availableHeroes.size <= desiredCount then availableHeroes.map(_.id)
else
availableHeroes
.sortBy(hero =>
(
if favorLeaders then !LegacyFactionUtils.isFactionLeader(hero.id, gs)
else LegacyFactionUtils.isFactionLeader(hero.id, gs),
-LegacyHeroUtils.power(hero)
)
)
.map(_.id)
.take(desiredCount)
}
def mostPowerfulHeroes(
desiredCount: Int,
availableHeroIds: Vector[HeroId],
+45 -31
View File
@@ -15,7 +15,6 @@ scala_library(
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:engine",
@@ -25,7 +24,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
],
)
@@ -49,12 +47,9 @@ scala_library(
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -69,17 +64,19 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/service:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":resolve_diplomacy_command_selector",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:min_chance_for_a_i_invite",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -91,15 +88,18 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/service:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -109,12 +109,16 @@ scala_library(
visibility = [
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -124,15 +128,19 @@ scala_library(
visibility = [
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = ["//src/main/scala/net/eagle0/eagle/library/util:command_selection"],
exports = [
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -167,6 +175,7 @@ scala_library(
exports = [
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":ai_client_utils",
@@ -178,17 +187,14 @@ scala_library(
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/library/settings:months_recon_considered_recent",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:date_utils",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
@@ -200,12 +206,17 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:improve_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:organize_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption:legacy_food_consumption_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
)
@@ -215,16 +226,17 @@ scala_library(
visibility = [
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":faction_leader_province_ranker",
":march_toward_province_command_chooser",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -235,10 +247,12 @@ scala_library(
visibility = [
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
@@ -251,8 +265,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:ransom_offer_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -2,24 +2,23 @@ package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.{
chosenCommandWhileTraveling,
chosenDevelopCommand,
chosenUnderAttackCommand
}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.DeterministicCommandChooser
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object EarlyGameAIClient {
def isEarlyGame(gs: GameState, factionId: FactionId): Boolean =
LegacyFactionUtils.provinceCount(factionId, gs) == 1 && !LegacyFactionUtils
.provinces(factionId, gs)
.exists(LegacyProvinceUtils.getsTaxes)
FactionUtils.provinceCount(factionId, gs.provinces.values) == 1 && !FactionUtils
.provinces(factionId, gs.provinces.values.toVector)
.exists(ProvinceUtils.getsTaxes)
def chooseEarlyGameCommand(
actingFactionId: FactionId,
@@ -33,9 +32,12 @@ object EarlyGameAIClient {
gameState = gs,
availableCommands = acs,
rankedChoosers = Vector[CommandChooser](
chosenCommandWhileTraveling _,
chosenUnderAttackCommand _,
chosenDevelopCommand _
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenCommandWhileTraveling(fid, gs, acs, fr),
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
RandomState(chosenUnderAttackCommand(fid, gs, acs), fr),
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenDevelopCommand(fid, gs, acs, fr)
),
functionalRandom = functionalRandom
)
@@ -1,59 +1,58 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.LegacyProvinceDistances
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
object FactionLeaderProvinceRanker {
private def factionLeaderProvinceOrdering(
factionId: FactionId,
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[Province] = factionLeaderProvinceOrdering(factionId, gameState)
): Ordering[ProvinceT] = factionLeaderProvinceOrdering(factionId, gameState)
.orElse(
nearestThroughFriendliesOrdering(factionId, gameState, fromProvinceId)
)
// Orders by nearest-through-friendlies first
private def nearestThroughFriendliesOrdering(
factionId: FactionId,
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[Province] = Ordering.by((p: Province) =>
LegacyProvinceDistances.distanceThroughFriendliesOption(
): Ordering[ProvinceT] = Ordering.by((p: ProvinceT) =>
ProvinceDistances.distanceThroughFriendliesOption(
fromProvinceId,
p.id,
factionId,
gameState
gameState.provinces
)
)
// Orders by most-hostile-neighbors-first
private def hostileNeighborCountOrdering(
factionId: FactionId,
gameState: GameState
): Ordering[Province] = Ordering
.by((p: Province) => LegacyFactionUtils.hostileNeighbors(p, factionId, gameState).size)
): Ordering[ProvinceT] = Ordering
.by((p: ProvinceT) =>
FactionUtils.hostileNeighbors(p, factionId, gameState.provinces, gameState.factions.values.toVector).size
)
.reverse
// Orders by most-neutral-neighbors-first
private def neutralNeighborCountOrdering(
gameState: GameState
): Ordering[Province] =
Ordering.by((p: Province) => neutralNeighborCount(p, gameState)).reverse
): Ordering[ProvinceT] =
Ordering.by((p: ProvinceT) => neutralNeighborCount(p, gameState)).reverse
private def factionLeaderProvinceOrdering(
factionId: FactionId,
gameState: GameState
): Ordering[Province] =
): Ordering[ProvinceT] =
hostileNeighborCountOrdering(factionId = factionId, gameState = gameState)
.orElse(neutralNeighborCountOrdering(gameState))
private def neutralNeighborCount(
province: Province,
province: ProvinceT,
gameState: GameState
): Int =
province.neighbors
@@ -62,7 +61,6 @@ object FactionLeaderProvinceRanker {
.filterNot(_.rulingFactionId.isDefined)
.size
// Sorts the owned provinces for this faction, with the most desirable for faction leaders coming first
def rankedProvinceIds(
factionId: FactionId,
gameState: GameState
@@ -71,8 +69,8 @@ object FactionLeaderProvinceRanker {
factionId = factionId,
gameState = gameState
)
LegacyFactionUtils
.provinces(factionId, gameState)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.sorted(ordering)
.map(_.id)
}
@@ -85,8 +83,8 @@ object FactionLeaderProvinceRanker {
factionId = factionId,
gameState = gameState
)
LegacyFactionUtils
.provinces(factionId, gameState)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.minOption(ordering)
.map(_.id)
}
@@ -101,8 +99,8 @@ object FactionLeaderProvinceRanker {
gameState = gameState,
fromProvinceId = fromProvinceId
)
LegacyFactionUtils
.provinces(factionId, gameState)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.minOption(ordering)
.map(_.id)
}
@@ -117,27 +115,28 @@ object FactionLeaderProvinceRanker {
gameState = gameState,
fromProvinceId = fromProvinceId
)
LegacyFactionUtils
.provinces(factionId, gameState)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.sorted(ordering)
.map(_.id)
}
def bestSettlementPidForFactionLeader(
factionId: FactionId,
currentProvince: Province,
currentProvince: ProvinceT,
gameState: GameState
): Option[ProvinceId] = {
val factions = gameState.factions.values.toVector
val ordering = factionLeaderProvinceOrdering(
factionId = factionId,
gameState = gameState,
fromProvinceId = currentProvince.id
)
LegacyFactionUtils
.provinces(factionId, gameState)
FactionUtils
.provinces(factionId, gameState.provinces.values.toVector)
.filter(ordering.lt(_, currentProvince))
.filterNot(LegacyProvinceUtils.ruledByFactionLeader(_, gameState))
.filterNot(ProvinceUtils.ruledByFactionLeader(_, factions))
.minOption(ordering)
.map(_.id)
}
@@ -4,10 +4,10 @@ import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object FixLeaderAloneCommandSelector {
@@ -16,7 +16,9 @@ object FixLeaderAloneCommandSelector {
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
): RandomState[Option[CommandSelection]] = {
val factions = gameState.factions.values.toVector
val provinces = gameState.provinces
randomSelectionForType[MarchAvailableCommand](
availableCommands,
functionalRandom
@@ -25,21 +27,16 @@ object FixLeaderAloneCommandSelector {
frOuter.nextFlatCollectFirst(
ac.oneProvinceCommands
.filter(opmc =>
gameState
.provinces(opmc.originProvinceId)
.rulingFactionHeroIds
.filterNot(LegacyFactionUtils.isFactionLeader(_, gameState))
provinces(opmc.originProvinceId).rulingFactionHeroIds
.filterNot(FactionUtils.isFactionLeader(_, factions))
.size > 1
)
) { opmc => fr =>
opmc.availableDestinationProvinces
.map(dest => gameState.provinces(dest.provinceId))
.map(dest => provinces(dest.provinceId))
.filter(_.rulingFactionId.contains(actingFactionId))
.filter(_.rulingFactionHeroIds.size == 1)
.filter(dest =>
LegacyFactionUtils
.isFactionLeader(dest.rulingFactionHeroIds.head, gameState)
)
.filter(dest => FactionUtils.isFactionLeader(dest.rulingFactionHeroIds.head, factions))
.map { destination =>
fr.nextRandomElement(opmc.availableHeroIds)
.map { hid =>
@@ -69,4 +66,5 @@ object FixLeaderAloneCommandSelector {
.getOrElse(RandomState(None, fr))
}
}
}
}
@@ -3,12 +3,11 @@ package net.eagle0.eagle.ai
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
import net.eagle0.eagle.api.command.util.diplomacy_option.InvitationOption
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.settings.MinChanceForAIInvite
import net.eagle0.eagle.library.util.command_choice_helpers.{ProvinceGoldSurplusCalculator, TrustForDiplomacy}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object InvitationCommandSelector {
@@ -23,16 +22,15 @@ object InvitationCommandSelector {
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) { diplomacyAvailableCommand =>
val nativeGameState = GameStateConverter.fromProto(gameState)
diplomacyAvailableCommand.options.collect { case io: InvitationOption => io }.filter { invitationOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = invitationOption.targetFactionId,
gameState = nativeGameState
gameState = gameState
)
}.filter { invitationOption =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
nativeGameState.provinces(diplomacyAvailableCommand.actingProvinceId)
gameState.provinces(diplomacyAvailableCommand.actingProvinceId)
) >= invitationOption.goldCost
}.map { invitationOption =>
InvitationOptionWithChance(
@@ -40,8 +38,8 @@ object InvitationCommandSelector {
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
invitationOption.targetFactionId,
nativeGameState.factions.values.toVector,
nativeGameState.provinces.values.toVector
gameState.factions.values.toVector,
gameState.provinces.values.toVector
)
)
}.maxByOption(_.acceptanceChance)
@@ -2,15 +2,12 @@ package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, MoreOption, RandomState}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.ai.AIClientUtils.extraHeroCount
import net.eagle0.eagle.ai.AIClientUtils
import net.eagle0.eagle.api.available_command.*
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.{MarchSelectedCommand, ReconSelectedCommand}
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.improvement_type.ImprovementType
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, MonthsReconConsideredRecent}
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
@@ -27,27 +24,29 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.food_consumption.LegacyFoodConsumptionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.food_consumption.FoodConsumptionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.LegacyProvinceDistances
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.views.province_view.FullProvinceInfo
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.model.state.date.Date.given
import net.eagle0.eagle.model.state.faction.FactionRelationship as FactionRelationshipC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.view.battalion.BattalionView as BattalionViewC
import net.eagle0.eagle.model.view.province.FullProvinceInfo as FullProvinceInfoC
object MidGameAIClient {
private def validateNoFactionLeaderAlone(
gs: GameState,
factionId: FactionId
): Unit =
LegacyFactionUtils
.provinces(factionId, gs)
FactionUtils
.provinces(factionId, gs.provinces.values.toVector)
.filter { province =>
province.rulingFactionHeroIds.forall(
LegacyFactionUtils.isFactionLeader(_, gs)
FactionUtils.isFactionLeader(_, gs.factions.values.toVector)
)
}
.foreach { province =>
@@ -69,7 +68,8 @@ object MidGameAIClient {
gameState = gs,
availableCommands = opac.commands.toVector,
rankedChoosers = Vector[CommandChooser](
chosenUniversalCommand,
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenUniversalCommand(fid, gs, acs, fr),
chosenRescueLeaderCommand,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
@@ -140,44 +140,41 @@ object MidGameAIClient {
) =>
val actingProvince = gameState.provinces(actingProvinceId)
val foodMonthsToHoldBack =
LegacyFoodConsumptionUtils.foodConsumptionMonthsToHold(
actingProvinceId,
FoodConsumptionUtils.foodConsumptionMonthsToHold(
actingProvince,
gameState
)
val foodToHoldBack =
foodMonthsToHoldBack * LegacyProvinceUtils.monthlyFoodConsumption(
actingProvinceId,
foodMonthsToHoldBack * ProvinceUtils.monthlyFoodConsumption(
actingProvince,
gameState
)
val maxFoodToSend =
Math.min(actingProvince.food - foodToHoldBack, foodAvailable)
val maxGoldToSend = Math.min(
ProvinceGoldSurplusCalculator
.provinceGoldSurplus(ProvinceConverter.fromProto(actingProvince)),
ProvinceGoldSurplusCalculator.provinceGoldSurplus(actingProvince),
goldAvailable
)
MoreOption.flatWhen(maxFoodToSend > 0) {
case class DestinationFoodStatus(
province: Province,
case class DestinationFoodStatusC(
province: ProvinceT,
hasSupport: Boolean,
monthsOfFood: Int
)
// If any province is running low on food, choose the one with the fewest months remaining and send some
val bestDestination = availableDestinationProvinceIds
.map(gameState.provinces)
.filter(_.rulingFactionId.contains(factionId))
.map { province =>
val consumption = LegacyProvinceUtils
.monthlyFoodConsumption(province.id, gameState)
val consumption = ProvinceUtils.monthlyFoodConsumption(province, gameState)
val incomingFood =
province.incomingShipments.flatMap(_.supplies).map(_.food).sum
province.incomingShipments.map(_.supplies.food).sum
val availableFood = province.food + incomingFood
val monthsOfFood =
if consumption == 0 then 120
else availableFood / consumption
DestinationFoodStatus(
DestinationFoodStatusC(
province = province,
hasSupport = province.support >= MinSupportForTaxes.doubleValue,
monthsOfFood = monthsOfFood
@@ -188,8 +185,8 @@ object MidGameAIClient {
bestDestination.map { destinationFoodStatus =>
val destinationProvince = destinationFoodStatus.province
val desiredFood = LegacyProvinceUtils.monthlyFoodConsumption(
destinationProvince.id,
val desiredFood = ProvinceUtils.monthlyFoodConsumption(
destinationProvince,
gameState
) * 6 - destinationProvince.food
val foodToSend =
@@ -272,7 +269,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -301,7 +298,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -346,20 +343,9 @@ object MidGameAIClient {
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands,
choosersAndReasons = Vector(
choosersAndReasons = Vector[(CommandChooser, String)](
(
(
fid: FactionId,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
ExpandCommandSelector.maybeChosenExpandCommand(
fid,
GameStateConverter.fromProto(gs),
acs,
fr
),
ExpandCommandSelector.maybeChosenExpandCommand,
"no hostile neighbors, expand"
),
(
@@ -376,7 +362,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -414,8 +400,7 @@ object MidGameAIClient {
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.DeterministicCommandChooser
): RandomState[Option[CommandSelection]] =
provincesWithFactionLeader(actingFactionId, gameState).map { province =>
MoreOption
.flatWhen(province.rulingFactionHeroIds.size < 4)(
@@ -431,7 +416,7 @@ object MidGameAIClient {
actingFactionId = actingFactionId,
gs = gameState,
acs = availableCommands,
choosersAndReasons = Vector(
choosersAndReasons = Vector[(CommandChooser, String)](
(
maybeMoveToRecruitCommand,
"chosen no neutral neighbors, travel to recruit"
@@ -464,7 +449,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -496,30 +481,29 @@ object MidGameAIClient {
}
.find(_.newValue.isDefined)
.get
}
private def chosenAttackCommandWithReconInfo(
actingFactionId: FactionId,
gs: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
def reconnedFullInfoFor(provinceId: ProvinceId): Option[FullProvinceInfo] =
def reconnedFullInfoFor(provinceId: ProvinceId): Option[FullProvinceInfoC] =
gs.factions(actingFactionId)
.reconnedProvinces
.find(_.id == provinceId)
.flatMap(_.fullInfo)
def reconnedHeroCountFor(provinceId: ProvinceId): Int =
def reconnedHeroCountFor(provinceId: ProvinceId): Int =
reconnedFullInfoFor(provinceId)
.map(_.rulingFactionHeroIds.size)
.getOrElse(0)
def reconnedBattalionsFor(provinceId: ProvinceId): Vector[BattalionView] =
def reconnedBattalionsFor(provinceId: ProvinceId): Vector[BattalionViewC] =
reconnedFullInfoFor(provinceId)
.map(_.battalions.toVector)
.map(_.battalions)
.getOrElse(Vector())
AttackCommandChooser.chosenAttackCommand(
actingFactionId,
GameStateConverter.fromProto(gs),
gs,
acs,
reconnedHeroCountFor,
reconnedBattalionsFor
@@ -539,13 +523,13 @@ object MidGameAIClient {
AlmsCommandSelector
.chosenAlmsForSupportCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = availableCommands
)
.orElse {
ImproveCommandSelector.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = availableCommands
)
}
@@ -578,7 +562,7 @@ object MidGameAIClient {
RandomState(
AttackCommandChooser.chosenSupportAttackCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -628,7 +612,7 @@ object MidGameAIClient {
RandomState(
SeekMoreLeadersCommandChooser.maybeSeekMoreLeadersCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -648,11 +632,12 @@ object MidGameAIClient {
maybeCommand.filter { _ =>
provincesWithFactionLeader(fid, gs).exists { p =>
p.id == opac.provinceId &&
LegacyFactionUtils
FactionUtils
.hostileNeighbors(
province = p,
fid = fid,
gs = gs
allProvinces = gs.provinces,
factions = gs.factions.values.toVector
)
.nonEmpty
}
@@ -668,11 +653,10 @@ object MidGameAIClient {
maybeCommand.filter { _ =>
provincesWithFactionLeader(fid, gs).exists { p =>
p.id == opac.provinceId &&
LegacyFactionUtils
FactionUtils
.neutralNeighbors(
province = p,
fid = fid,
gs = gs
allProvinces = gs.provinces
)
.nonEmpty
}
@@ -690,7 +674,7 @@ object MidGameAIClient {
): Int =
gs.provinces.values
.filter(_.rulingFactionId.contains(factionId))
.map(p => LegacyProvinceUtils.monthlyFoodSurplus(gs, p.id))
.map(p => ProvinceUtils.monthlyFoodSurplus(p, gs))
.sum
.max(0) / 2 // making this up
@@ -713,7 +697,7 @@ object MidGameAIClient {
// we are under one of the required stats for better units
MoreOption.flatWhen(
available
.forall(bt => existingBattalions.exists(_.`type` == bt.typeId)) &&
.forall(bt => existingBattalions.exists(_.typeId == bt.typeId)) &&
existingBattalions.size < province.rulingFactionHeroIds.size &&
notAvailable.nonEmpty
) {
@@ -767,7 +751,7 @@ object MidGameAIClient {
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
def go(
provincesRS: RandomState[Vector[Province]]
provincesRS: RandomState[Vector[ProvinceT]]
): RandomState[Option[CommandSelection]] = provincesRS.continue {
case (province +: tail, fr) =>
MoreOption
@@ -794,22 +778,14 @@ object MidGameAIClient {
"improve to hire better troops in preparation for attack"
),
(
(
fid: FactionId,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
(fid, gs, acs, fr) =>
RandomState(
OrganizeCommandSelector
.chosenOrganizeCommandWithFoodSurplus(
actingFactionId = fid,
gameState = GameStateConverter.fromProto(gs),
gameState = gs,
availableCommands = acs,
extraFoodSurplus = foodSurplus(
gs,
actingFactionId
),
extraFoodSurplus = foodSurplus(gs, actingFactionId),
reason = "organize in preparation for attack"
),
fr
@@ -817,12 +793,7 @@ object MidGameAIClient {
"organize in preparation for attack"
),
(
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
maybeChosenTravelToArmTroopsCommand(
actingFactionId,
@@ -834,12 +805,7 @@ object MidGameAIClient {
"travel to arm troops in preparation for attack"
),
(
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
chosenImproveInfrastructureIfBelowTrainingCommand(
actingFactionId,
@@ -851,12 +817,7 @@ object MidGameAIClient {
"improve infrastructure in preparation for attack"
),
(
(
actingFactionId,
gameState,
availableCommands,
functionalRandom
) =>
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
chosenTrainCommand(
actingFactionId,
@@ -894,24 +855,29 @@ object MidGameAIClient {
provincesWithFactionLeader(actingFactionId, gs)
.filter(p => p.rulingFactionHeroIds.size < p.heroCap)
val cs = for {
(marchCommand, idx) <- acs.zipWithIndex.collectFirst {
case (ac: MarchAvailableCommand, idx) => (ac, idx)
}
(marchCommand, _) <- acs.zipWithIndex.collectFirst {
case (ac: MarchAvailableCommand, idx) => (ac, idx)
}
} yield {
// Find the march command origin provinces that contain extra heroes
val candidateCommands = marchCommand.oneProvinceCommands.filter { opmc =>
extraHeroCount(opmc.originProvinceId, actingFactionId, gs) > 0
AIClientUtils.extraHeroCount(
gs.provinces(opmc.originProvinceId),
actingFactionId,
gs.provinces,
gs.factions.values.toVector
) > 0
}
val furthestOrigin = candidateCommands.flatMap { opmc =>
// distance to the faction leader province we're closest to
factionLeaderProvincesNeedingHeroes.flatMap { flp =>
LegacyProvinceDistances
ProvinceDistances
.distanceThroughFriendliesOption(
p1 = flp.id,
p2 = opmc.originProvinceId,
fid = actingFactionId,
gs = gs
provinces = gs.provinces
)
.map {
(_: Int, flp.id: ProvinceId)
@@ -925,7 +891,12 @@ object MidGameAIClient {
// distance to the faction leader province we're closest to
distance,
destinationProvinceId,
extraHeroCount(opmc.originProvinceId, actingFactionId, gs),
AIClientUtils.extraHeroCount(
gs.provinces(opmc.originProvinceId),
actingFactionId,
gs.provinces,
gs.factions.values.toVector
),
opmc
)
}
@@ -941,7 +912,7 @@ object MidGameAIClient {
fid = actingFactionId,
opmc = opmc,
destinationProvinceId = destinationProvinceId,
gs = GameStateConverter.fromProto(gs),
gs = gs,
favorLeaders = false
)
}
@@ -971,9 +942,7 @@ object MidGameAIClient {
actingFactionId: FactionId,
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
import DateProtoUtils._Date
): Option[CommandSelection] =
flatSelectionForType[ReconAvailableCommand](acs) { ac =>
val targetProvincesNeighboringMe = ac.availableTargetProvinces
.map(gameState.provinces)
@@ -1005,13 +974,7 @@ object MidGameAIClient {
.find(_.id == province.id)
)
.filter(
_.asOf.exists(date =>
DateProtoUtils
.addMonths(
date,
MonthsReconConsideredRecent.intValue
) < gameState.currentDate.get
)
_.asOf.exists(date => date.addMonths(MonthsReconConsideredRecent.intValue) < gameState.currentDate.get)
)
.sortBy(province =>
(
@@ -1024,7 +987,7 @@ object MidGameAIClient {
.provinces(province.id)
.rulingFactionId
.get
&& fr.relationshipLevel != FactionRelationship.RelationshipLevel.HOSTILE
&& fr.relationshipLevel != FactionRelationshipC.RelationshipLevel.Hostile
),
// then the least recently scouted
province.asOf.get
@@ -1041,8 +1004,7 @@ object MidGameAIClient {
targetProvinceId = pid
)
)
}
}.map(_.withReason("recon a neighbor"))
}.map(_.withReason("recon a neighbor"))
def maybeSpreadLeadersCommand(
actingFactionId: FactionId,
@@ -1050,7 +1012,7 @@ object MidGameAIClient {
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
val faction = gameState.factions(actingFactionId)
val factionLeaders = faction.leaders
val factionLeaders = faction.leaderIds
flatSelectionForType[MarchAvailableCommand](acs) { marchAvailableCommand =>
marchAvailableCommand.oneProvinceCommands.flatMap { opmc =>
val originProvince = gameState.provinces(opmc.originProvinceId)
@@ -1064,7 +1026,7 @@ object MidGameAIClient {
val leaderIdToMove =
opmc.availableHeroIds
.intersect(factionLeaders)
.maxBy(hid => LegacyHeroUtils.seniorityOrder(faction, hid))
.maxBy(hid => HeroUtils.seniorityOrder(faction, hid))
val validDestinations = opmc.availableDestinationProvinces
.map(dest => gameState.provinces(dest.provinceId))
.filter(
@@ -1106,7 +1068,7 @@ object MidGameAIClient {
private def provincesWithFactionLeader(
factionId: FactionId,
gs: GameState
): Vector[Province] = LegacyFactionUtils
.provinces(factionId, gs)
.filter(p => LegacyProvinceUtils.ruledByFactionLeader(p, gs))
): Vector[ProvinceT] = FactionUtils
.provinces(factionId, gs.provinces.values.toVector)
.filter(p => ProvinceUtils.ruledByFactionLeader(p, gs.factions.values.toVector))
}
@@ -2,12 +2,11 @@ package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.internal.faction.Faction
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.{LegacyProvinceDistances, ProvinceDistances}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
object MoveLeaderToBetterProvinceCommandChooser {
private case class MCFOPWithDestinationAndDistance(
@@ -27,17 +26,18 @@ object MoveLeaderToBetterProvinceCommandChooser {
actingFactionId = actingFactionId,
gs = gs,
acs = acs,
betterDestinationChooser = FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader
betterDestinationChooser = (fid: FactionId, pid: ProvinceId, gameState: GameState) =>
FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader(fid, pid, gameState)
)
private def candidatesForMarchCommand(
marchCommand: MarchAvailableCommand,
gs: GameState,
faction: Faction
faction: FactionT
): Vector[MarchCommandFromOneProvince] =
marchCommand.oneProvinceCommands.toVector.filter { opmc =>
val originProvince = gs.provinces(opmc.originProvinceId)
if opmc.availableHeroIds.intersect(faction.leaders).isEmpty then false
if opmc.availableHeroIds.intersect(faction.leaderIds).isEmpty then false
else if originProvince.rulingFactionHeroIds.length < 2 then false
else true
}
@@ -54,12 +54,12 @@ object MoveLeaderToBetterProvinceCommandChooser {
mcfop.originProvinceId,
gs
).flatMap { desiredPid =>
LegacyProvinceDistances
ProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
actingFactionId,
gs
gs.provinces
)
.map { provinceAndDistance =>
MCFOPWithDestinationAndDistance(
@@ -96,7 +96,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
fid = actingFactionId,
opmc = mcfopwd.mcfop,
destinationProvinceId = mcfopwd.provinceAndDistance.provinceId,
gs = GameStateConverter.fromProto(gs),
gs = gs,
favorLeaders = true
)
}
@@ -23,32 +23,27 @@ import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
DIPLOMACY_OFFER_STATUS_IMPRISONED,
DIPLOMACY_OFFER_STATUS_REJECTED
}
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship.RelationshipLevel.ALLY
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.settings.{
AiAllianceAcceptanceBaseChance,
AiTruceAcceptanceBaseChance,
MinimumPrestigeDifferenceToInvite
}
import net.eagle0.eagle.library.util.command_choice_helpers.{
CommandChooser,
CommandChooserImplicits,
RansomOfferHelpers
}
import net.eagle0.eagle.library.util.command_choice_helpers.{CommandChooser, RansomOfferHelpers}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.{
randomSelectionForType,
selectionForType
}
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.FactionId
object ResolveDiplomacyCommandSelector {
import CommandChooserImplicits.*
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
def resolveDiplomacySelectedCommand(
actingFactionId: FactionId,
@@ -106,8 +101,10 @@ object ResolveDiplomacyCommandSelector {
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val invitedFid = actingFactionId
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestInvitation = ac.invitations
.maxBy(inv => invitationAcceptanceChance(inv.originatingFactionId, invitedFid, gs))
.maxBy(inv => invitationAcceptanceChance(inv.originatingFactionId, invitedFid, factions, provinces))
internalRequire(
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
@@ -126,23 +123,14 @@ object ResolveDiplomacyCommandSelector {
if 100.0 * roll < invitationAcceptanceChance(
bestOriginatingFid,
invitedFid,
gs
factions,
provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
)
}
def invitationAcceptanceChance(
originatingFid: FactionId,
invitedFid: FactionId,
gs: GameState
): Int =
(LegacyFactionUtils.prestige(originatingFid, gs)
- LegacyFactionUtils.prestige(invitedFid, gs)
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
/** Protoless version */
def invitationAcceptanceChance(
originatingFid: FactionId,
invitedFid: FactionId,
@@ -192,8 +180,10 @@ object ResolveDiplomacyCommandSelector {
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val actingFid = actingFactionId
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestOffer = ac.offers
.maxBy(inv => truceOfferAcceptanceChance(inv.originatingFactionId, actingFid, gs))
.maxBy(inv => truceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
@@ -212,7 +202,8 @@ object ResolveDiplomacyCommandSelector {
if 100.0 * roll < truceOfferAcceptanceChance(
originatingFid = bestOriginatingFid,
targetFid = actingFid,
gs = gs
factions = factions,
provinces = provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
@@ -222,51 +213,58 @@ object ResolveDiplomacyCommandSelector {
def truceOfferAcceptanceChance(
originatingFid: FactionId,
targetFid: FactionId,
gs: GameState
): Int =
factions: Vector[FactionT],
provinces: Vector[ProvinceT]
): Int = {
val originatingFaction = factions.find(_.id == originatingFid).get
val targetFaction = factions.find(_.id == targetFid).get
(
AiTruceAcceptanceBaseChance.doubleValue
+ LegacyFactionUtils.prestige(originatingFid, gs)
- LegacyFactionUtils.prestige(targetFid, gs)
+ LegacyFactionUtils.trust(
+ FactionUtils.prestige(originatingFaction, provinces)
- FactionUtils.prestige(targetFaction, provinces)
+ FactionUtils.trust(
by = targetFid,
of = originatingFid,
gameState = gs
factions = factions
)
).floor.toInt.max(0)
}
def allianceOfferAcceptanceChance(
originatingFid: FactionId,
targetFid: FactionId,
gs: GameState
): Int =
factions: Vector[FactionT],
provinces: Vector[ProvinceT]
): Int = {
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Ally
val targetFaction = factions.find(_.id == targetFid).get
val originatingFaction = factions.find(_.id == originatingFid).get
val provincesMap = provinces.map(p => p.id -> p).toMap
// Always reject if the faction already has an alliance
if gs
.factions(targetFid)
.factionRelationships
.exists(_.relationshipLevel == ALLY)
then 0
if targetFaction.factionRelationships.exists(_.relationshipLevel == Ally) then 0
// reject if this is our only neighbor and we have no expansion possibilities
else if LegacyFactionUtils.provinces(targetFid, gs).forall { province =>
province.neighbors.map(n => gs.provinces(n.provinceId)).forall { neighborProvince =>
neighborProvince.rulingFactionId.contains(
originatingFid
) || neighborProvince.rulingFactionId.contains(targetFid)
else if FactionUtils.provinces(targetFid, provinces).forall { province =>
province.neighbors.map(n => provincesMap(n.provinceId)).forall { neighborProvince =>
neighborProvince.rulingFactionId.contains(originatingFid) ||
neighborProvince.rulingFactionId.contains(targetFid)
}
}
then 0
else
(
AiAllianceAcceptanceBaseChance.doubleValue
+ LegacyFactionUtils.prestige(originatingFid, gs)
- LegacyFactionUtils.prestige(targetFid, gs)
+ LegacyFactionUtils.trust(
+ FactionUtils.prestige(originatingFaction, provinces)
- FactionUtils.prestige(targetFaction, provinces)
+ FactionUtils.trust(
by = targetFid,
of = originatingFid,
gameState = gs
factions = factions
)
).floor.toInt.max(0)
end if
}
private def resolveRansomOfferSelectedCommand(
actingFactionId: FactionId,
@@ -279,18 +277,14 @@ object ResolveDiplomacyCommandSelector {
actingProvinceId = 0,
available = resolveRansomAc,
selected = selectionForResolveRansomOfferSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveRansomAc,
gs = gameState
ac = resolveRansomAc
),
reason = "resolving ransom offer"
)
}
private def selectionForResolveRansomOfferSelectedCommand(
actingFactionId: FactionId,
ac: ResolveRansomOfferAvailableCommand,
gs: GameState
ac: ResolveRansomOfferAvailableCommand
): SelectedCommand =
ac.offers.head.offerDetails match {
case rod: RansomOfferDetails =>
@@ -343,9 +337,7 @@ object ResolveDiplomacyCommandSelector {
) {
case (resolveBreakAllianceAc, fr) =>
selectionForResolveBreakAllianceSelectedCommand(
actingFactionId = actingFactionId,
ac = resolveBreakAllianceAc,
gs = gameState,
functionalRandom = fr
).map { resolveBreakAllianceSelection =>
Some(
@@ -367,8 +359,10 @@ object ResolveDiplomacyCommandSelector {
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
val actingFid = actingFactionId
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestOffer = ac.offers
.maxBy(inv => allianceOfferAcceptanceChance(inv.originatingFactionId, actingFid, gs))
.maxBy(inv => allianceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
@@ -387,7 +381,8 @@ object ResolveDiplomacyCommandSelector {
if 100.0 * roll < allianceOfferAcceptanceChance(
originatingFid = bestOriginatingFid,
targetFid = actingFid,
gs = gs
factions = factions,
provinces = provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
@@ -395,12 +390,11 @@ object ResolveDiplomacyCommandSelector {
}
// FIXME: always imprison ambassadors for now
// Note: doesn't use gameState or functionalRandom roll
private def selectionForResolveBreakAllianceSelectedCommand(
actingFactionId: FactionId,
ac: ResolveBreakAllianceAvailableCommand,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { _ =>
val offer = ac.offers.head
ResolveBreakAllianceSelectedCommand(
@@ -28,9 +28,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
],
deps = [
":oauth_config",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
@@ -0,0 +1,6 @@
package net.eagle0.eagle.auth
object GenerateJwtKey {
def main(args: Array[String]): Unit =
println(JwtServiceImpl.generateKey().toJSONString)
}
@@ -10,6 +10,7 @@ import java.util.UUID
import scala.collection.concurrent.TrieMap
import scala.util.Try
import net.eagle0.common.SimpleTimedLogger
import net.eagle0.eagle.api.auth.auth.OAuthProvider
import org.json4s.{DefaultFormats, JObject, JString}
import org.json4s.jvalue2extractable
@@ -24,22 +25,30 @@ case class ProviderUserInfo(
username: String
)
/** Result of OAuth flow */
sealed trait OAuthResult
case class OAuthSuccess(providerInfo: ProviderUserInfo, provider: OAuthProvider) extends OAuthResult
case class OAuthFailure(error: String) extends OAuthResult
case object OAuthPending extends OAuthResult
case object OAuthExpired extends OAuthResult
/** Service for OAuth code exchange */
trait OAuthService {
/** Generate OAuth authorization URL */
def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) // (url, state)
/** Generate OAuth authorization URL. Server handles callback internally. */
def getAuthUrl(provider: OAuthProvider): (String, String) // (url, state)
/** Exchange authorization code for user info */
def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo]
/** Check status of OAuth flow for given state */
def checkStatus(state: String): OAuthResult
/** Handle OAuth callback - exchange code for user info and store result */
def handleCallback(code: String, state: String): Either[String, ProviderUserInfo]
/** Get the server's OAuth callback URL */
def callbackUrl: String
}
class OAuthServiceImpl extends OAuthService {
class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
private implicit val formats: DefaultFormats.type = DefaultFormats
private val json = new Json(formats)
@@ -49,25 +58,32 @@ class OAuthServiceImpl extends OAuthService {
.connectTimeout(Duration.ofSeconds(10))
.build()
// State parameter storage with timestamp (for CSRF protection)
private val stateStore = TrieMap[String, Long]()
// Pending OAuth sessions: state -> (provider, timestamp)
private val pendingOAuth = TrieMap[String, (OAuthProvider, Long)]()
// Completed OAuth results: state -> result
private val completedOAuth = TrieMap[String, OAuthResult]()
// State expiration (10 minutes)
private val stateExpirationMs = 600000L
override def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) = {
override def callbackUrl: String = s"$serverBaseUrl/oauth/callback"
override def getAuthUrl(provider: OAuthProvider): (String, String) = {
val config = getConfig(provider)
val state = UUID.randomUUID().toString
stateStore.put(state, System.currentTimeMillis())
pendingOAuth.put(state, (provider, System.currentTimeMillis()))
SimpleTimedLogger.printLogger.logLine(
s"OAuth: getAuthUrl created state=$state for provider=$provider (pending=${pendingOAuth.size}, completed=${completedOAuth.size})"
)
// Clean old states
val cutoff = System.currentTimeMillis() - stateExpirationMs
stateStore.filterInPlace((_, timestamp) => timestamp > cutoff)
cleanupExpiredStates()
val params = Map(
"client_id" -> config.clientId,
"redirect_uri" -> redirectUri,
"redirect_uri" -> callbackUrl,
"response_type" -> "code",
"scope" -> config.scopes.mkString(" "),
"state" -> state
@@ -83,30 +99,90 @@ class OAuthServiceImpl extends OAuthService {
(url, state)
}
override def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo] = {
// Verify state
stateStore.remove(state) match {
case Some(timestamp) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
// Valid state
case _ =>
return Left("Invalid or expired state parameter")
override def checkStatus(state: String): OAuthResult = {
// First check completed results
val result = completedOAuth.get(state) match {
case Some(result) => result
case None =>
// Check if still pending
pendingOAuth.get(state) match {
case Some((_, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
OAuthPending
case Some(_) =>
// Expired
pendingOAuth.remove(state)
OAuthExpired
case None =>
// Unknown state - might be expired and cleaned up
OAuthExpired
}
}
val config = getConfig(provider)
// Exchange code for access token
val tokenResult = exchangeCodeForToken(config, code, redirectUri)
tokenResult match {
case Left(error) => Left(error)
case Right(accessToken) =>
// Fetch user info
fetchUserInfo(provider, config, accessToken)
// Log non-pending results to help debug
result match {
case OAuthPending => // Don't spam logs with pending checks
case _ =>
SimpleTimedLogger.printLogger.logLine(
s"OAuth: checkStatus state=$state result=$result (pending=${pendingOAuth.size}, completed=${completedOAuth.size}, inPending=${pendingOAuth.contains(state)}, inCompleted=${completedOAuth.contains(state)})"
)
}
result
}
override def handleCallback(code: String, state: String): Either[String, ProviderUserInfo] = {
SimpleTimedLogger.printLogger.logLine(
s"OAuth: handleCallback state=$state (inPending=${pendingOAuth.contains(state)}, inCompleted=${completedOAuth.contains(state)})"
)
// Get pending OAuth info
pendingOAuth.remove(state) match {
case Some((provider, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
val config = getConfig(provider)
// Exchange code for access token
val tokenResult = exchangeCodeForToken(config, code)
tokenResult match {
case Left(error) =>
completedOAuth.put(state, OAuthFailure(error))
Left(error)
case Right(accessToken) =>
// Fetch user info
fetchUserInfo(provider, config, accessToken) match {
case Left(error) =>
completedOAuth.put(state, OAuthFailure(error))
Left(error)
case Right(userInfo) =>
completedOAuth.put(state, OAuthSuccess(userInfo, provider))
SimpleTimedLogger.printLogger.logLine(
s"OAuth: handleCallback SUCCESS state=$state user=${userInfo.username} (completed now has ${completedOAuth.size} entries)"
)
Right(userInfo)
}
}
case Some(_) =>
SimpleTimedLogger.printLogger.logLine(
s"OAuth: handleCallback EXPIRED state=$state (was in pending but timestamp expired)"
)
completedOAuth.put(state, OAuthExpired)
Left("OAuth session expired")
case None =>
SimpleTimedLogger.printLogger.logLine(
s"OAuth: handleCallback INVALID state=$state (not found in pending)"
)
Left("Invalid or expired state parameter")
}
}
private def cleanupExpiredStates(): Unit = {
val cutoff = System.currentTimeMillis() - stateExpirationMs
pendingOAuth.filterInPlace((_, v) => v._2 > cutoff)
completedOAuth.filterInPlace((state, _) =>
pendingOAuth.contains(state) ||
completedOAuth.get(state).exists {
case OAuthSuccess(_, _) => true // Keep successes for a bit
case _ => false
}
)
}
private def getConfig(provider: OAuthProvider): OAuthProviderConfig =
@@ -118,8 +194,7 @@ class OAuthServiceImpl extends OAuthService {
private def exchangeCodeForToken(
config: OAuthProviderConfig,
code: String,
redirectUri: String
code: String
): Either[String, String] =
Try {
val formData = Map(
@@ -127,7 +202,7 @@ class OAuthServiceImpl extends OAuthService {
"client_secret" -> config.clientSecret,
"grant_type" -> "authorization_code",
"code" -> code,
"redirect_uri" -> redirectUri
"redirect_uri" -> callbackUrl
)
val formBody = formData.map {
@@ -485,8 +485,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_handle_riots_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:notification_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -1372,7 +1370,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
@@ -1404,7 +1401,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
@@ -12,7 +12,6 @@ import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedPro
import net.eagle0.eagle.model.action_result.concrete.ActionResultC
import net.eagle0.eagle.model.action_result.types.EndHandleRiotsPhaseResultType
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.RoundPhase
@@ -38,12 +37,10 @@ case class EndHandleRiotsPhaseAction(
case (seq, province) =>
commandsForProvince(province.id).map { opac =>
seq.withRandomActionResults { (gs, fr) =>
// Convert to proto for CommandChoiceHelpers which expects proto GameState
val gsProto = GameStateConverter.toProto(gs)
CommandChoiceHelpers
.handleRiotSelectedCommand(
actingFactionId = province.rulingFactionId.get,
gameState = gsProto,
gameState = gs,
availableCommands = opac.commands.toVector,
functionalRandom = fr
)
@@ -4,8 +4,6 @@ import net.eagle0.common.{FunctionalRandom, MoreOption, RandomState}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, RestAvailableCommand}
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.common.province_order_type.ProvinceOrderType.*
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.command.TCommandFactory
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, TCommand}
@@ -14,8 +12,8 @@ import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceOrderType.*
import net.eagle0.eagle.model.state.province.ProvinceT
case class PerformVassalCommandsPhaseAction(
@@ -75,60 +73,57 @@ case class PerformVassalCommandsPhaseAction(
private def maybeRestCommand(
actingFactionId: FactionId,
gsProto: GameStateProto,
gs: GameState,
commandOptions: Vector[AvailableCommand],
provinceId: ProvinceId,
reason: String
): Option[CommandSelection] = {
val heroes =
gsProto
.provinces(provinceId)
.rulingFactionHeroIds
.map(gsProto.heroes)
gs.provinces(provinceId).rulingFactionHeroIds.map(gs.heroes)
MoreOption.flatWhen(
commandOptions.collectFirst {
case ac: RestAvailableCommand =>
ac
}.isDefined && shouldRestProto(heroes)
}.isDefined && shouldRest(heroes)
) {
chosenRestCommand(actingFactionId, gsProto, commandOptions, reason)
chosenRestCommand(actingFactionId, gs, commandOptions, reason)
}
}
private def selectedCommandFromOrders(
actingFactionId: FactionId,
gsProto: GameStateProto,
gs: GameState,
commandOptions: Vector[AvailableCommand],
functionalRandom: FunctionalRandom,
provinceId: ProvinceId
): RandomState[Option[CommandSelection]] =
gsProto.provinces(provinceId).provinceOrders match {
case ENTRUST =>
gs.provinces(provinceId).provinceOrders match {
case Entrust =>
chosenEntrustCommand(
actingFactionId = actingFactionId,
gameState = gsProto,
gameState = gs,
availableCommands = commandOptions,
actingProvinceId = provinceId,
functionalRandom = functionalRandom
)
case DEVELOP =>
case Develop =>
chosenDevelopCommand(
actingFactionId = actingFactionId,
gameState = gsProto,
gameState = gs,
availableCommands = commandOptions,
functionalRandom = functionalRandom
)
case MOBILIZE =>
case Mobilize =>
chosenMobilizeCommand(
actingFactionId = actingFactionId,
gameState = gsProto,
gameState = gs,
availableCommands = commandOptions,
functionalRandom = functionalRandom
)
case EXPAND =>
case Expand =>
chosenExpandCommand(
actingFactionId,
gsProto,
gs,
commandOptions,
functionalRandom
)
@@ -137,7 +132,7 @@ case class PerformVassalCommandsPhaseAction(
RandomState(
chosenRestCommand(
actingFactionId,
gsProto,
gs,
commandOptions,
"bad orders"
),
@@ -149,14 +144,11 @@ case class PerformVassalCommandsPhaseAction(
gs: GameState,
provinceId: ProvinceId,
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
// Convert to proto for CommandChoiceHelpers which expects proto GameState
val gsProto = GameStateConverter.toProto(gs)
): RandomState[Option[CommandSelection]] =
commandsForProvince(provinceId).map { oneProvinceAvailableCommands =>
CommandChooser.choose(
gs.provinces(provinceId).rulingFactionId.get,
gsProto,
gs,
oneProvinceAvailableCommands.commands.toVector,
Vector[CommandChooser](
resolveTributeSelectedCommand,
@@ -164,14 +156,14 @@ case class PerformVassalCommandsPhaseAction(
handleRiotSelectedCommand,
(
fid: FactionId,
gsP: GameStateProto,
gsC: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
RandomState(
maybeRestCommand(
fid,
gsP,
gsC,
acs,
provinceId,
"chosen vassal command: rest"
@@ -180,13 +172,12 @@ case class PerformVassalCommandsPhaseAction(
),
(
fid: FactionId,
gsP: GameStateProto,
gsC: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) => selectedCommandFromOrders(fid, gsP, acs, fr, provinceId)
) => selectedCommandFromOrders(fid, gsC, acs, fr, provinceId)
),
functionalRandom
)
}.get
}
}
@@ -10,7 +10,6 @@ import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.ProvinceId
@@ -72,13 +71,10 @@ case class PerformVassalDefenseDecisionsAction(
provinceId: ProvinceId,
commandOptions: Seq[net.eagle0.eagle.api.available_command.AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
// Convert to proto for CommandChoiceHelpers which expects proto GameState
val gsProto = GameStateConverter.toProto(gs)
): RandomState[Option[CommandSelection]] =
CommandChoiceHelpers.resolveTributeSelectedCommand(
actingFactionId = gs.provinces(provinceId).rulingFactionId.get,
gameState = gsProto,
gameState = gs,
availableCommands = commandOptions.toVector,
functionalRandom = functionalRandom
) match {
@@ -86,10 +82,9 @@ case class PerformVassalDefenseDecisionsAction(
case RandomState(None, fr) =>
CommandChoiceHelpers.defendSelectedCommand(
gs.provinces(provinceId).rulingFactionId.get,
gsProto,
gs,
commandOptions.toVector,
fr
)
}
}
}
@@ -495,7 +495,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_id_converter",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -32,7 +32,6 @@ import net.eagle0.eagle.library.actions.llm_prompt_generators.HeroDescriptionGen
descriptionWithoutFaction
}
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.proto_converters.BattalionTypeIdConverter
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
@@ -196,7 +195,7 @@ object QuestEndedGeneratorUtilities {
_ /* unknownFieldSet */
) =>
val battType =
gameState.battalionTypes.find(_.typeId == BattalionTypeIdConverter.fromProto(battalionTypeId)).get
gameState.battalionTypes.find(_.typeId == battalionTypeId).get
val targetProvince = gameState.provinces(provinceId)
for {
unaffiliatedHeroName <- unaffiliatedHeroNameResult
@@ -2,6 +2,9 @@ package net.eagle0.eagle.library.util
import net.eagle0.eagle.internal.army.{Army, HostileArmyGroup, MovingArmy}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.model.state.{Army as ArmyC, HostileArmyGroup as HostileArmyGroupC, MovingArmy as MovingArmyC}
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.BattalionId
object ArmyUtils {
def heroCount(armyGroup: HostileArmyGroup): Int =
@@ -23,4 +26,32 @@ object ArmyUtils {
.map(gs.battalions)
.map(_.size)
.sum
// ==================== Protoless overloads ====================
/** Protoless overload */
def heroCount(armyGroup: HostileArmyGroupC): Int =
armyGroup.armies.map(heroCountC).sum
/** Protoless overload */
def heroCountC(movingArmy: MovingArmyC): Int = heroCount(movingArmy.army)
/** Protoless overload */
def heroCount(army: ArmyC): Int = army.units.size
/** Protoless overload */
def troopCount(armyGroup: HostileArmyGroupC, battalions: Map[BattalionId, BattalionT]): Int =
armyGroup.armies.map(ma => troopCount(ma, battalions)).sum
/** Protoless overload */
def troopCount(movingArmy: MovingArmyC, battalions: Map[BattalionId, BattalionT]): Int =
troopCount(movingArmy.army, battalions)
/** Protoless overload */
def troopCount(army: ArmyC, battalions: Map[BattalionId, BattalionT]): Int =
army.units
.flatMap(_.battalionId)
.map(battalions)
.map(_.size)
.sum
}
@@ -8,10 +8,18 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
],
deps = [
"//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:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
],
)
@@ -47,6 +55,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
],
)
@@ -189,17 +198,26 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/ai:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = ["//src/main/scala/net/eagle0/eagle:eagle_pkg"],
exports = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//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/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_power",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -2,6 +2,7 @@ package net.eagle0.eagle.library.util
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.BattalionTypeId
import net.eagle0.eagle.model.view.battalion.BattalionView as BattalionViewC
object BattalionPower {
// When we don't know enemy battalion composition, assume Heavy Infantry/Light Cavalry (1.5)
@@ -53,4 +54,12 @@ object BattalionPower {
// Formula: typeMultiplier * (0.5 + armament/100) * (0.5 + training/100) * size
// = 1.5 * (0.5 + 0.5) * (0.5 + 0.5) * 1 = 1.5 * 1.0 * 1.0 * 1 = 1.5
BASELINE_UNIT_TYPE_MULTIPLIER * 1.0 * 1.0 * 1
// Calculate estimated power for a BattalionView (recon data), using defaults when stats unknown
def estimatedPower(battalionView: BattalionViewC): Double = powerForInfo(
battalionTypeId = battalionView.typeId,
size = battalionView.size,
armament = battalionView.armament.getOrElse(50.0),
training = battalionView.training.getOrElse(50.0)
)
}
@@ -5,9 +5,12 @@ import net.eagle0.eagle.api.command.util.army_stats.ArmyStats
import net.eagle0.eagle.internal.army.{Army, MovingArmy}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.BattalionPower
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.state.{Army as ArmyC, MovingArmy as MovingArmyC}
import net.eagle0.eagle.model.state.game_state.GameState as GameStateC
import net.eagle0.eagle.model.state.province.ProvinceT
object IncomingArmyUtils {
def hasRelevantIncomingArmy(
@@ -65,6 +68,21 @@ object IncomingArmyUtils {
.isHostileProvince(province, ma.getArmy.factionId, gameState)
)
/** Protoless overload */
def isUnderAttack(
province: net.eagle0.eagle.model.state.province.ProvinceT,
gameState: net.eagle0.eagle.model.state.game_state.GameState
): Boolean = {
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
province.incomingArmies
.filter(_.arrivalRound == gameState.currentRoundId)
.exists(ma =>
province.rulingFactionId.exists(targetFid =>
FactionUtils.factionsAreHostile(targetFid, ma.army.factionId, gameState.factions.values.toVector)
)
)
}
def stats(
toFid: FactionId,
army: Army,
@@ -88,4 +106,75 @@ object IncomingArmyUtils {
val battalions = army.units.flatMap(_.battalionId).map(gs.battalions)
battalions.map(BattalionConverter.fromProto).map(BattalionPower.power).sum
}
// Scala overload for protoless code
def armyPower(
army: net.eagle0.eagle.model.state.Army,
gs: net.eagle0.eagle.model.state.game_state.GameState
): Double = {
val battalions = army.units.flatMap(_.battalionId).map(gs.battalions)
battalions.map(BattalionPower.power).sum
}
// ==================== Protoless overloads ====================
/** Protoless overload */
def hasRelevantIncomingArmy(
province: ProvinceT,
factionId: FactionId,
currentRoundId: RoundId
): Boolean =
province.incomingArmies.exists(ia => ia.army.factionId == factionId && ia.arrivalRound == currentRoundId)
/** Protoless overload */
def relevantIncomingArmies(
province: ProvinceT,
factionId: FactionId,
currentRoundId: RoundId
): Vector[MovingArmyC] =
province.incomingArmies
.filter(ia => ia.army.factionId == factionId && ia.arrivalRound == currentRoundId)
/** Protoless overload */
def incomingArmiesAreMutuallyAllied(
province: ProvinceT,
gameState: GameStateC
): Boolean =
FactionUtils.factionsAreMutuallyAllied(
fids = province.incomingArmies
.filter(_.arrivalRound == gameState.currentRoundId)
.map(_.army.factionId) ++ province.hostileArmies
.flatMap(_.armies)
.map(_.army.factionId),
factions = gameState.factions.values.toVector
)
/** Protoless overload */
def isHostileProvince(
province: ProvinceT,
factionId: FactionId,
gameState: GameStateC
): Boolean =
province.rulingFactionId.exists(targetFid =>
FactionUtils.factionsAreHostile(targetFid, factionId, gameState.factions.values.toVector)
)
/** Protoless overload */
def stats(
toFid: FactionId,
army: ArmyC,
gs: GameStateC,
originProvinceId: ProvinceId
): ArmyStats = {
val battalions = army.units.flatMap(_.battalionId).map(gs.battalions)
val troopCount = battalions.map(_.size).sum
ArmyStats(
factionId = army.factionId,
heroCount = army.units.size,
troopCount = troopCount,
hostility = FactionUtils.hostilityStatus(toFid, army.factionId, gs.factions.values.toVector),
originProvinceId = originProvinceId
)
}
}
@@ -9,13 +9,13 @@ import net.eagle0.eagle.library.settings.{
RequiredRatioToAttack,
RequiredRatioToAttackWithoutTakingCastles
}
import net.eagle0.eagle.library.util.{BattalionPower, CommandSelection, LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.{BattalionPower, CommandSelection, ShardokMapInfo}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.{Engineer, Mage}
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.model.view.battalion.BattalionView as BattalionViewC
object AttackCommandChooser {
@@ -211,7 +211,7 @@ object AttackCommandChooser {
gameState: GameState,
acs: Vector[AvailableCommand],
knownEnemyHeroCount: ProvinceId => Int,
knownEnemyBattalions: ProvinceId => Vector[BattalionView]
knownEnemyBattalions: ProvinceId => Vector[BattalionViewC]
): Option[CommandSelection] =
// find march commands to an enemy-occupied territory
flatSelectionForType[MarchAvailableCommand](acs) { ac =>
@@ -251,7 +251,7 @@ object AttackCommandChooser {
case (dPid, commandInfos) =>
val expectedEnemyBattalionPower =
knownEnemyBattalions(dPid)
.map(LegacyBattalionUtils.estimatedPower)
.map(BattalionPower.estimatedPower)
.takeRight(knownEnemyHeroCount(dPid))
.sum
@@ -11,12 +11,10 @@ import net.eagle0.eagle.api.command.util.attack_decision_type.{
}
import net.eagle0.eagle.api.selected_command.AttackDecisionSelectedCommand
import net.eagle0.eagle.common.tribute_amount.TributeAmount
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.settings.MinimumPercentageOfEnemyToAttack
import net.eagle0.eagle.library.util.{BattalionPower, CommandSelection, IncomingArmyUtils}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
@@ -26,45 +24,44 @@ object AttackDecisionCommandChooser {
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val protoGameState = GameStateConverter.toProto(gameState)
): RandomState[Option[CommandSelection]] =
CommandChooser.choose(
actingFactionId = actingFactionId,
gameState = protoGameState,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
(actingFactionId, gs, availableCommands, functionalRandom) =>
RandomState(
withdrawArmySelectedCommand(
actingFactionId,
protoGs,
gs,
availableCommands
),
functionalRandom
),
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
(actingFactionId, gs, availableCommands, functionalRandom) =>
RandomState(
demandTributeSelectedCommand(
actingFactionId,
protoGs,
gs,
availableCommands
),
functionalRandom
),
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
(actingFactionId, gs, availableCommands, functionalRandom) =>
RandomState(
advanceArmySelectedCommand(
actingFactionId,
protoGs,
gs,
availableCommands
),
functionalRandom
),
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
(actingFactionId, gs, availableCommands, functionalRandom) =>
RandomState(
safePassageSelectedCommand(
actingFactionId,
protoGs,
gs,
availableCommands
),
functionalRandom
@@ -72,11 +69,10 @@ object AttackDecisionCommandChooser {
),
functionalRandom = functionalRandom
)
}
private def withdrawArmySelectedCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { withdrawAc =>
@@ -102,7 +98,7 @@ object AttackDecisionCommandChooser {
private def demandTributeSelectedCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { availableCommand =>
@@ -133,7 +129,7 @@ object AttackDecisionCommandChooser {
private def okToAttack(
availableCommand: AttackDecisionAvailableCommand,
gameState: GameStateProto
gameState: GameState
): Boolean = {
val armies = availableCommand.armies
val province = gameState.provinces(availableCommand.actingProvinceId)
@@ -147,7 +143,7 @@ object AttackDecisionCommandChooser {
.find(hag => hag.factionId == armyStats.factionId)
.map(_.armies)
.getOrElse(Vector.empty)
.map(ia => IncomingArmyUtils.armyPower(ia.getArmy, gameState))
.map(ia => IncomingArmyUtils.armyPower(ia.army, gameState))
.sum
)
.sum
@@ -178,7 +174,7 @@ object AttackDecisionCommandChooser {
private def advanceArmySelectedCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { availableCommand =>
@@ -206,7 +202,7 @@ object AttackDecisionCommandChooser {
private def safePassageSelectedCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { availableCommand =>
@@ -63,20 +63,18 @@ scala_library(
":march_supplies_helpers",
"//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/views:battalion_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_march",
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack",
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack_without_taking_castles",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_power",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:shardok_map_info",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
],
)
@@ -95,14 +93,12 @@ scala_library(
":command_chooser",
"//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/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_percentage_of_enemy_to_attack",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_power",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -151,13 +147,14 @@ scala_library(
],
exports = [
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -193,9 +190,6 @@ scala_library(
"//src/main/protobuf/net/eagle0/eagle/api:available_command_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/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/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -219,23 +213,18 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:round_phase_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
@@ -300,8 +289,6 @@ scala_library(
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
@@ -316,10 +303,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:truce_count_quest_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:truce_with_faction_quest_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:unaffiliated_hero_with_quest",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:quest_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
@@ -13,19 +13,10 @@ import net.eagle0.eagle.api.command.util.captured_hero_option.CapturedHeroOption
import net.eagle0.eagle.api.command.util.diplomacy_option.RansomOfferOption
import net.eagle0.eagle.api.selected_command.*
import net.eagle0.eagle.api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}
import net.eagle0.eagle.common.battalion_type.{BattalionType, BattalionTypeId}
import net.eagle0.eagle.common.battalion_type.BattalionTypeId
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.improvement_type.ImprovementType.INFRASTRUCTURE
import net.eagle0.eagle.common.recruitment_info.RecruitmentStatus.{
RECRUITMENT_STATUS_NOT_DIVINED,
RECRUITMENT_STATUS_WOULD_JOIN
}
import net.eagle0.eagle.common.tribute_amount.TributeAmount
import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType.UNAFFILIATED_HERO_PRISONER
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.library.settings.{
BaseFoodBuyPrice,
DesiredLoyaltyOverThreshold,
@@ -42,26 +33,21 @@ import net.eagle0.eagle.library.settings.{
import net.eagle0.eagle.library.util.{BattalionUtils, CommandSelection, IncomingArmyUtils, ProvinceDistances}
import net.eagle0.eagle.library.util.battalion_type_finder.BattalionTypeFinder
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.{
goldToHoldBack,
provinceGoldSurplus
}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.food_consumption.FoodConsumptionUtils
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.model.proto_converters.{BattalionConverter, BattalionTypeConverter, RoundPhaseConverter}
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.state.{BattalionType as BattalionTypeC, BattalionTypeId as BattalionTypeIdC}
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState as GameStateC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.NoProfession
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroT, UnaffiliatedHeroType}
object CommandChoiceHelpers {
import AvailableCommandSelector.{flatSelectionForType, selectionForType}
@@ -81,7 +67,7 @@ object CommandChoiceHelpers {
OrganizeCommandSelector.selectedOrganizeCommand(
actingFactionId = actingFactionId,
availableCommand = organizeCommand,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
extraFoodSurplus = gameState.provinces(organizeCommand.actingProvinceId).food,
reason = "organizing because we're under attack"
)
@@ -92,19 +78,16 @@ object CommandChoiceHelpers {
actingProvinceId: ProvinceId,
gameState: GameState
): Option[Supplies] = {
val gs = GameStateConverter.fromProto(gameState)
def battalionType(battalionTypeId: BattalionTypeIdC): BattalionTypeC =
BattalionTypeFinder.battalionType(battalionTypeId, gs.battalionTypes)
BattalionTypeFinder.battalionType(battalionTypeId, gameState.battalionTypes)
val province = gameState.provinces(actingProvinceId)
val provinceC = gs.provinces(actingProvinceId)
val province = gameState.provinces(actingProvinceId)
MoreOption.flatWhen(province.support >= MinSupportForTaxes.doubleValue) {
val heroCount = province.rulingFactionHeroIds.size
val battalionCount = province.battalionIds.size
val neededNewBattalions: Int = (heroCount - battalionCount).max(0)
val existingBattalions = provinceC.battalionIds.map(gs.battalions)
val existingBattalions = province.battalionIds.map(gameState.battalions)
val hcType = battalionType(BattalionTypeIdC.HeavyCavalry)
@@ -127,7 +110,7 @@ object CommandChoiceHelpers {
val goldExcess =
(province.gold - (fillUpBattalionsCost + newBattalionsCost + maximizeExistingBattalionArmamentCost + newBattalionArmamentCost + goldToHoldBack(
provinceC
province
)))
.max(0)
.floor
@@ -185,13 +168,7 @@ object CommandChoiceHelpers {
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
chosenCommandWhileTraveling,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands,
functionalRandom
),
AttackDecisionCommandChooser.attackDecisionSelectedCommand,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
chosenUnderAttackCommand(
@@ -225,7 +202,7 @@ object CommandChoiceHelpers {
RandomState(
AlmsCommandSelector.chosenAlmsForSupportCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = availableCommands
),
functionalRandom
@@ -269,7 +246,7 @@ object CommandChoiceHelpers {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -285,7 +262,7 @@ object CommandChoiceHelpers {
functionalRandom
)
),
functionalRandom
functionalRandom = functionalRandom
)
def chosenMobilizeCommand(
@@ -313,7 +290,7 @@ object CommandChoiceHelpers {
RandomState(
OrganizeCommandSelector.chosenOrganizeCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands,
"mobilizing"
),
@@ -346,7 +323,7 @@ object CommandChoiceHelpers {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -378,31 +355,17 @@ object CommandChoiceHelpers {
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
chosenUniversalCommand,
(
fid: FactionId,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
chosenMobilizeIfAdjacentEnemyCommand(
fid,
gs,
acs,
actingProvinceId,
fr
),
(fid, gs, acs, fr) => chosenMobilizeIfAdjacentEnemyCommand(fid, gs, acs, actingProvinceId, fr),
chosenDevelopCommand
),
functionalRandom = functionalRandom
)
/** Protoless version */
private def suppressBeastsValue(hero: HeroT): Double =
(hero.strength + hero.agility) / 2.0 + (if hero.profession == NoProfession
then 0.0
else 50.0)
/** Protoless version - calculates beast power for a province */
private def beastPower(province: ProvinceT): Int = {
val beastCount = ProvinceUtils.beastCount(province)
val beastInfo = ProvinceUtils.beastInfo(province).get
@@ -416,14 +379,12 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
): Option[CommandSelection] =
flatSelectionForType[SuppressBeastsAvailableCommand](availableCommands) { suppressAc =>
val actingPid = suppressAc.actingProvinceId
val province = gameState.provinces(actingPid)
val provinceC = gs.provinces(actingPid)
val beastPwr = beastPower(provinceC)
val province = gameState.provinces(actingPid)
val beastPwr = beastPower(province)
if province.battalionIds
.exists(b => gameState.battalions(b).size >= beastPwr)
@@ -431,7 +392,7 @@ object CommandChoiceHelpers {
val leaderId = HeroSelector
.minimallyFatiguedHeroes(
suppressAc.availableHeroIds
.map(gs.heroes)
.map(gameState.heroes)
.toVector
)
.maxBy(suppressBeastsValue(_))
@@ -461,7 +422,6 @@ object CommandChoiceHelpers {
)
end if
}
}
private def chosenMobilizeIfAdjacentEnemyCommand(
actingFactionId: FactionId,
@@ -470,10 +430,9 @@ object CommandChoiceHelpers {
actingProvinceId: ProvinceId,
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val gs = GameStateConverter.fromProto(gameState)
val province = gs.provinces(actingProvinceId)
val province = gameState.provinces(actingProvinceId)
if ProvinceUtils
.adjacentHostiles(province, gs.provinces)
.adjacentHostiles(province, gameState.provinces)
.nonEmpty
then {
chosenMobilizeCommand(
@@ -497,28 +456,12 @@ object CommandChoiceHelpers {
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
chosenUniversalCommand,
(
fid: FactionId,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
ExpandCommandSelector.maybeChosenExpandCommand(
fid,
GameStateConverter.fromProto(gs),
acs,
fr
),
(
fid: FactionId,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
(fid, gs, acs, fr) => ExpandCommandSelector.maybeChosenExpandCommand(fid, gs, acs, fr),
(fid, gs, acs, fr) =>
RandomState(
AttackCommandChooser.chosenAttackCommand(
actingFactionId = fid,
gameState = GameStateConverter.fromProto(gs),
gameState = gs,
acs = acs,
knownEnemyHeroCount = _ => 0,
knownEnemyBattalions = _ => Vector()
@@ -529,7 +472,7 @@ object CommandChoiceHelpers {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -553,8 +496,7 @@ object CommandChoiceHelpers {
gs: GameState,
acs: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] = {
val gsc = GameStateConverter.fromProto(gs)
): Option[CommandSelection] =
chosenRestCommand(
actingFactionId = actingFactionId,
gameState = gs,
@@ -563,17 +505,15 @@ object CommandChoiceHelpers {
).filter {
case CommandSelection(_, _, ac, _, _) =>
shouldRest(
gsc
gs
.provinces(
ac.asInstanceOf[RestAvailableCommand].actingProvinceId
)
.rulingFactionHeroIds
.map(gsc.heroes)
.map(gs.heroes)
)
}
}
/** Protoless version */
def shouldRest(heroes: Iterable[HeroT]): Boolean =
if heroes
.map(HeroUtils.fatigue)
@@ -581,17 +521,9 @@ object CommandChoiceHelpers {
then true
else false
/** Legacy proto version */
def shouldRestProto(heroes: Iterable[Hero]): Boolean =
if heroes
.map(LegacyHeroUtils.fatigue)
.min >= VassalCommandsMaxFatigueLevelBeforeResting.doubleValue
then true
else false
def chosenRestCommand(
actingFactionId: FactionId,
gameState: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
@@ -607,7 +539,15 @@ object CommandChoiceHelpers {
def handleCapturedHeroesSelectedCommand(
actingFactionId: FactionId,
gs: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
acs: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
// gameState not used, delegate to core implementation
handleCapturedHeroesSelectedCommandImpl(actingFactionId, acs, functionalRandom)
private def handleCapturedHeroesSelectedCommandImpl(
actingFactionId: FactionId,
acs: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -650,7 +590,14 @@ object CommandChoiceHelpers {
def resolvePleaseRecruitMeSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
// gameState not used, delegate to core implementation
resolvePleaseRecruitMeSelectedCommandImpl(actingFactionId, availableCommands)
private def resolvePleaseRecruitMeSelectedCommandImpl(
actingFactionId: FactionId,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[PleaseRecruitMeAvailableCommand](availableCommands) { pleaseRecruitMeAc =>
@@ -669,7 +616,7 @@ object CommandChoiceHelpers {
private def handleRiotGiveSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[HandleRiotGiveAvailableCommand](availableCommands) { availableCommand =>
@@ -714,7 +661,7 @@ object CommandChoiceHelpers {
private def handleRiotDoNothingSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[HandleRiotDoNothingAvailableCommand](availableCommands) { availableCommand =>
@@ -771,40 +718,26 @@ object CommandChoiceHelpers {
def freeForAllDecisionSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
CommandChooser.choose(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
advanceFFASelectedCommand(
actingFactionId,
gameState,
availableCommands
),
functionalRandom
),
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
withdrawFFASelectedCommand(
actingFactionId,
gameState,
availableCommands
),
functionalRandom
)
),
functionalRandom = functionalRandom
)
// gameState not used by inner methods, delegate to core implementation
freeForAllDecisionSelectedCommandImpl(actingFactionId, availableCommands, functionalRandom)
private def freeForAllDecisionSelectedCommandImpl(
actingFactionId: FactionId,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
advanceFFASelectedCommand(actingFactionId, availableCommands) match {
case Some(cs) => RandomState(Some(cs), functionalRandom)
case None =>
RandomState(withdrawFFASelectedCommand(actingFactionId, availableCommands), functionalRandom)
}
private def withdrawFFASelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[FreeForAllDecisionAvailableCommand](
@@ -828,7 +761,6 @@ object CommandChoiceHelpers {
private def advanceFFASelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[FreeForAllDecisionAvailableCommand](
@@ -852,7 +784,7 @@ object CommandChoiceHelpers {
def resolveTributeSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -930,30 +862,29 @@ object CommandChoiceHelpers {
CombatUnitSelector.selectedCombatBattalions(
desiredCount = availableCommand.availableHeroIds.size
.min(MaxCombatUnitCountPerSide.intValue),
heroes = availableCommand.availableHeroIds.map(gs.heroes).map(HeroConverter.fromProto).toVector,
heroes = availableCommand.availableHeroIds.map(gs.heroes).toVector,
battalions = availableCommand.availableBattalions
.map(_.battalionId)
.map(gs.battalions)
.map(BattalionConverter.fromProto)
.toVector,
battalionTypes = gs.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
battalionTypes = gs.battalionTypes
)
def foodAmountToBuy(
province: Province,
province: ProvinceT,
gameState: GameState,
goldAvailable: Int,
foodBuyPrice: Double
): Option[Int] = {
val provinceC = ProvinceConverter.fromProto(province)
val battalions = province.battalionIds.map(gameState.battalions).map(BattalionConverter.fromProto).toVector
val battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
val date = DateConverter.fromProto(gameState.currentDate)
val roundPhase = RoundPhaseConverter.fromProto(gameState.currentPhase)
val battalions = province.battalionIds.map(gameState.battalions).toVector
val battalionTypes = gameState.battalionTypes
val monthsRemainingInYear = FoodConsumptionUtils.foodConsumptionMonthsRemainingInYear(date, roundPhase)
val monthsRemainingInYear = FoodConsumptionUtils.foodConsumptionMonthsRemainingInYear(
gameState.currentDate.get,
gameState.currentPhase
)
val monthsToHold =
if provinceC.support >= MinSupportForTaxes.doubleValue
if province.support >= MinSupportForTaxes.doubleValue
then monthsRemainingInYear
else monthsRemainingInYear.max(6)
@@ -1022,25 +953,24 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
): Option[CommandSelection] =
flatSelectionForType[ImproveAvailableCommand](availableCommands) { availableCommand =>
val provinceC = gs.provinces(availableCommand.actingProvinceId)
val province = gameState.provinces(availableCommand.actingProvinceId)
// If we don't have enough gold that we would arm troops anyway, bail
if provinceGoldSurplus(provinceC) < MinGoldSurplusForArm.intValue
if provinceGoldSurplus(province) < MinGoldSurplusForArm.intValue
then None
else {
val battalions = provinceC.battalionIds.map(gs.battalions)
val battalions = province.battalionIds.map(gameState.battalions)
val battalionCount = battalions.size
// If fewer than half the battalions have training > infrastructure, raise the infrastructure so that we can arm
MoreOption.flatWhen(
battalions.count(
_.training > ProvinceUtils.effectiveInfrastructure(provinceC)
_.training > ProvinceUtils.effectiveInfrastructure(province)
) < battalionCount / 2
) {
ImproveCommandSelector.chosenSpecificImprovementCommand(
actingFactionId,
gs,
gameState,
availableCommands,
INFRASTRUCTURE
)
@@ -1048,18 +978,15 @@ object CommandChoiceHelpers {
}
end if
}
}
def suppliesToSend(provinceId: ProvinceId, gameState: GameState): Supplies = {
val gs = GameStateConverter.fromProto(gameState)
val province = gs.provinces(provinceId)
val province = gameState.provinces(provinceId)
Supplies(
food = provinceFoodSurplus(province, gs),
food = provinceFoodSurplus(province, gameState),
gold = provinceGoldSurplus(province)
)
}
/** Protoless version */
private def closestLeader(
actingFactionId: FactionId,
faction: FactionT,
@@ -1095,9 +1022,8 @@ object CommandChoiceHelpers {
gameState: GameState,
ac: SendSuppliesAvailableCommand
): Option[ProvinceId] = {
val gs = GameStateConverter.fromProto(gameState)
val faction = gs.factions(actingFactionId)
val provinces = gs.provinces
val faction = gameState.factions(actingFactionId)
val provinces = gameState.provinces
faction.focusProvinceId
.orElse(
closestLeader(
@@ -1124,8 +1050,7 @@ object CommandChoiceHelpers {
ac: SendSuppliesAvailableCommand,
supplies: Supplies,
destination: ProvinceId
): CommandSelection = {
val gs = GameStateConverter.fromProto(gameState)
): CommandSelection =
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ac.actingProvinceId,
@@ -1135,11 +1060,10 @@ object CommandChoiceHelpers {
food = supplies.food,
gold = supplies.gold,
actingHeroId = ac.availableHeroIds
.minBy(hid => HeroUtils.fatigue(gs.heroes(hid)))
.minBy(hid => HeroUtils.fatigue(gameState.heroes(hid)))
),
reason = "chosenSendSuppliesCommandWithSuppliesAndDestination"
)
}
def chosenSendSuppliesCommandWithSupplies(
actingFactionId: FactionId,
@@ -1171,7 +1095,6 @@ object CommandChoiceHelpers {
)
)
/** Protoless version */
private def lowLoyaltyHeroes(
heroes: Vector[HeroT],
factions: Vector[FactionT]
@@ -1214,35 +1137,32 @@ object CommandChoiceHelpers {
gameState: GameState,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
): Option[CommandSelection] =
flatSelectionForType[FeastAvailableCommand](availableCommands) { feastAvailableCommand =>
val actingProvinceId = feastAvailableCommand.actingProvinceId
val province = gs.provinces(actingProvinceId)
val province = gameState.provinces(actingProvinceId)
val heroes = lowLoyaltyHeroes(
heroes = province.rulingFactionHeroIds.map(gs.heroes),
factions = gs.factions.values.toVector
heroes = province.rulingFactionHeroIds.map(gameState.heroes),
factions = gameState.factions.values.toVector
)
Option.when(heroes.length > 1)(
chosenFeastCommand(actingFactionId, feastAvailableCommand, reason)
)
}
}
private def maybeChosenGiftCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
): Option[CommandSelection] =
flatSelectionForType[HeroGiftAvailableCommand](availableCommands) { heroGiftAvailableCommand =>
val heroIds = heroGiftAvailableCommand.eligibleGifts
.map(_.recipientHeroId)
.toVector
lowLoyaltyHeroes(
heroes = heroIds.map(gs.heroes),
factions = gs.factions.values.toVector
heroes = heroIds.map(gameState.heroes),
factions = gameState.factions.values.toVector
).headOption.flatMap { hero =>
HeroGiftCommandSelector.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
@@ -1252,7 +1172,6 @@ object CommandChoiceHelpers {
)
}
}
}
private def maybeSpreadIntoOwnedProvince(
opmc: MarchCommandFromOneProvince,
@@ -1260,22 +1179,20 @@ object CommandChoiceHelpers {
gameState: GameState,
ac: MarchAvailableCommand
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
val originProvince = gameState.provinces(opmc.originProvinceId)
val originProvinceC = gs.provinces(opmc.originProvinceId)
val originProvince = gameState.provinces(opmc.originProvinceId)
opmc.availableDestinationProvinces
.map(dest => gameState.provinces(dest.provinceId))
.filter(_.rulingFactionId.contains(actingFactionId))
.filter(p => p.rulingFactionHeroIds.length < p.heroCap)
.maxByOption(p => p.rulingFactionHeroIds.length - p.heroCap)
.map { destProvince =>
val foodToHoldBack = ProvinceUtils.monthlyFoodConsumption(originProvinceC, gs) *
FoodConsumptionUtils.foodConsumptionMonthsToHold(originProvinceC, gs)
val foodToHoldBack = ProvinceUtils.monthlyFoodConsumption(originProvince, gameState) *
FoodConsumptionUtils.foodConsumptionMonthsToHold(originProvince, gameState)
val foodToTake = Math.max(0, (originProvince.food - foodToHoldBack) / 2)
val goldToTake = Math.max(
0,
(originProvince.gold - provinceGoldSurplus(originProvinceC).max(0)) / 2
(originProvince.gold - provinceGoldSurplus(originProvince).max(0)) / 2
)
CommandSelection(
@@ -1288,7 +1205,7 @@ object CommandChoiceHelpers {
originProvince = opmc.originProvinceId,
destinationProvinceId = destProvince.id,
marchingUnits = opmc.availableHeroIds
.sortBy(hid => HeroUtils.power(gs.heroes(hid)))
.sortBy(hid => HeroUtils.power(gameState.heroes(hid)))
.take(
originProvince.rulingFactionHeroIds.length - originProvince.heroCap
)
@@ -1312,7 +1229,6 @@ object CommandChoiceHelpers {
ac: MarchAvailableCommand,
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val gs = GameStateConverter.fromProto(gameState)
val originProvince = gameState.provinces(opmc.originProvinceId)
val sendingHeroCount = (originProvince.rulingFactionHeroIds.length -
originProvince.heroCap).min(opmc.availableHeroIds.length)
@@ -1349,7 +1265,7 @@ object CommandChoiceHelpers {
originProvince = opmc.originProvinceId,
destinationProvinceId = destProvince.id,
marchingUnits = opmc.availableHeroIds
.sortBy(hid => HeroUtils.power(gs.heroes(hid)))
.sortBy(hid => HeroUtils.power(gameState.heroes(hid)))
.take(sendingHeroCount)
.zipAll(
sendingBattalions.map(batt => Some(batt.battalionId)),
@@ -1424,14 +1340,13 @@ object CommandChoiceHelpers {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val gs = GameStateConverter.fromProto(gameState)
// To avoid multiple ransoms going out from the same faction, make this dependent on the current round ID
val myProvinces = gameState.provinces.values
.filter(_.rulingFactionId.contains(actingFactionId))
.toVector
val chosenIndex = gameState.currentRoundId % myProvinces.length
val chosenPid = myProvinces(chosenIndex).id
val faction = gs.factions(actingFactionId)
val faction = gameState.factions(actingFactionId)
randomSelectionForType[DiplomacyAvailableCommand](
availableCommands,
@@ -1461,7 +1376,7 @@ object CommandChoiceHelpers {
originatingFactionId = actingFactionId,
targetFactionId = targetFactionId,
availableOffer = ransomOffer,
gameState = GameStateConverter.fromProto(gameState)
gameState = gameState
)
.map { chosenOffer =>
CommandSelection(
@@ -1495,11 +1410,11 @@ object CommandChoiceHelpers {
// Only rescue the leader as a universal command if *all* faction leaders are captured
(if gameState
.factions(actingFactionId)
.leaders
.leaderIds
.forall(hid =>
gameState.provinces.values.exists(
_.unaffiliatedHeroes
.exists(uh => uh.heroId == hid && uh.`type` == UNAFFILIATED_HERO_PRISONER)
.exists(uh => uh.heroId == hid && uh.unaffiliatedHeroType == UnaffiliatedHeroType.Prisoner)
)
)
then
@@ -1533,7 +1448,13 @@ object CommandChoiceHelpers {
),
functionalRandom
),
maybeRansomLeaderCommand
(actingFactionId, gameState, availableCommands, functionalRandom) =>
maybeRansomLeaderCommand(
actingFactionId,
gameState,
availableCommands,
functionalRandom
)
),
functionalRandom = functionalRandom
)
@@ -1551,7 +1472,13 @@ object CommandChoiceHelpers {
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
maybeChosenGetUnderHeroCapCommand,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
maybeChosenGetUnderHeroCapCommand(
actingFactionId,
gameState,
availableCommands,
functionalRandom
),
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
maybeChosenFeastCommand(
@@ -1659,7 +1586,13 @@ object CommandChoiceHelpers {
private def maybeChosenRecruitCommand(
actingFactionId: FactionId,
gameState: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
maybeChosenRecruitCommandImpl(actingFactionId, availableCommands)
private def maybeChosenRecruitCommandImpl(
actingFactionId: FactionId,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[RecruitHeroesAvailableCommand](availableCommands) { availableCommand =>
@@ -1681,11 +1614,10 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
): Option[CommandSelection] =
flatSelectionForType[DivineAvailableCommand](availableCommands) { availableCommand =>
val divinableHeroes = availableCommand.divinableHeroes
val provinceC = gs.provinces(availableCommand.actingProvinceId)
val provinceC = gameState.provinces(availableCommand.actingProvinceId)
val availableGold = provinceGoldSurplus(provinceC)
val heroCountToDivine = availableGold / availableCommand.costPerHero
@@ -1696,7 +1628,7 @@ object CommandChoiceHelpers {
actingProvinceId = availableCommand.actingProvinceId,
selected = DivineSelectedCommand(
heroIds = divinableHeroes
.sortBy(uh => HeroUtils.power(gs.heroes(uh.getHero.id)))
.sortBy(uh => HeroUtils.power(gameState.heroes(uh.getHero.id)))
.takeRight(heroCountToDivine)
.map(_.getHero.id)
),
@@ -1704,11 +1636,16 @@ object CommandChoiceHelpers {
)
}
}
}
private def chosenReturnCommand(
actingFactionId: FactionId,
gameState: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
chosenReturnCommandImpl(actingFactionId, availableCommands)
private def chosenReturnCommandImpl(
actingFactionId: FactionId,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[ReturnAvailableCommand](availableCommands) { availableCommand =>
@@ -1725,18 +1662,17 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
): Option[CommandSelection] =
flatSelectionForType[TravelAvailableCommand](availableCommands) { availableCommand =>
val actingProvinceId = availableCommand.actingProvinceId
val provinceC = gs.provinces(actingProvinceId)
val province = gameState.provinces(actingProvinceId)
// Don't travel if you don't have much extra gold to spend anyway
if provinceGoldSurplus(provinceC) < MinGoldSurplusForArm.intValue
if provinceGoldSurplus(province) < MinGoldSurplusForArm.intValue
then None
else {
val battalions = provinceC.battalionIds.map(gs.battalions)
val effectiveInfra = ProvinceUtils.effectiveInfrastructure(provinceC)
val battalions = province.battalionIds.map(gameState.battalions)
val effectiveInfra = ProvinceUtils.effectiveInfrastructure(province)
val highestArmamentWeShouldUpgrade =
if effectiveInfra > 90 then effectiveInfra - 3
else effectiveInfra - 10
@@ -1746,7 +1682,6 @@ object CommandChoiceHelpers {
)(chosenTravelCommand(actingFactionId, availableCommand))
}
}
}
def maybeMoveToRecruitCommand(
actingFactionId: FactionId,
@@ -1756,8 +1691,7 @@ object CommandChoiceHelpers {
): RandomState[Option[CommandSelection]] =
randomSelectionForType[MarchAvailableCommand](acs, functionalRandom) {
case (availableCommand, fr) =>
val gsc = GameStateConverter.fromProto(gs)
val factionsVector = gsc.factions.values.toVector
val factionsVector = gs.factions.values.toVector
val presentLeaders = gs
.provinces(availableCommand.actingProvinceId)
.rulingFactionHeroIds
@@ -1783,7 +1717,7 @@ object CommandChoiceHelpers {
fromHere.get.availableDestinationProvinces
.map(dest => gs.provinces(dest.provinceId))
.filter(_.rulingFactionId.contains(actingFactionId))
.filterNot(p => ProvinceUtils.containsFactionLeader(gsc.provinces(p.id), factionsVector))
.filterNot(p => ProvinceUtils.containsFactionLeader(p, factionsVector))
.filter(p => hasInterestingUH(p.id, gs))
val moveableLeaders = fromHere.get.availableHeroIds
@@ -1800,7 +1734,7 @@ object CommandChoiceHelpers {
(
interestingUHs.length,
interestingUHs
.map(uh => HeroUtils.power(gsc.heroes(uh.heroId)))
.map(uh => HeroUtils.power(gs.heroes(uh.heroId)))
.sum
)
}
@@ -1836,8 +1770,8 @@ object CommandChoiceHelpers {
end if
}
private def isInterestingUH(uh: UnaffiliatedHero): Boolean =
uh.recruitmentInfo.get.status == RECRUITMENT_STATUS_WOULD_JOIN || uh.recruitmentInfo.get.status == RECRUITMENT_STATUS_NOT_DIVINED
private def isInterestingUH(uh: UnaffiliatedHeroT): Boolean =
uh.recruitmentInfo == RecruitmentInfo.WouldJoin || uh.recruitmentInfo == RecruitmentInfo.Unknown
private def hasInterestingUH(provinceId: ProvinceId, gs: GameState): Boolean =
gs.provinces(provinceId)
@@ -1848,15 +1782,14 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
): Option[CommandSelection] =
flatSelectionForType[TravelAvailableCommand](availableCommands) { availableCommand =>
val actingProvinceId = availableCommand.actingProvinceId
Option.when(
ProvinceUtils.containsFactionLeader(
gs.provinces(actingProvinceId),
gs.factions.values
gameState.provinces(actingProvinceId),
gameState.factions.values
) && hasInterestingUH(
actingProvinceId,
gameState
@@ -1866,7 +1799,6 @@ object CommandChoiceHelpers {
}
}
.map(_.withReason("travel to recruit"))
}
private def chosenTravelCommand(
actingFactionId: FactionId,
@@ -1882,7 +1814,13 @@ object CommandChoiceHelpers {
def chosenTrainCommand(
actingFactionId: FactionId,
gameState: GameState,
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
chosenTrainCommandImpl(actingFactionId, availableCommands)
private def chosenTrainCommandImpl(
actingFactionId: FactionId,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[TrainAvailableCommand](availableCommands) { availableCommand =>
@@ -1904,9 +1842,7 @@ object CommandChoiceHelpers {
beastPower: Int
): Option[CommandSelection] =
flatSelectionForType[OrganizeTroopsAvailableCommand](availableCommands) { availableCommand =>
val province = gameState.provinces(
availableCommand.actingProvinceId
)
val province = gameState.provinces(availableCommand.actingProvinceId)
val costs = availableCommand.troopCosts
.map(tc => tc.`type` -> tc.costPerTroop)
.toMap
@@ -1938,11 +1874,10 @@ object CommandChoiceHelpers {
gameState: GameState,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
): Option[CommandSelection] =
flatSelectionForType[ArmTroopsAvailableCommand](availableCommands) { availableCommand =>
val provinceId = availableCommand.actingProvinceId
val provinceC = gs.provinces(provinceId)
val provinceC = gameState.provinces(provinceId)
val goldSurplus = provinceGoldSurplus(provinceC)
val lowestBattalion = availableCommand.availableBattalions
.map(gameState.battalions)
@@ -1953,7 +1888,7 @@ object CommandChoiceHelpers {
if lowestBattalion.armament >= provinceInfrastructure then None
else {
val costPerPointPerTroop = availableCommand.armamentCosts
.find(cost => cost.`type` == lowestBattalion.`type`)
.find(cost => cost.`type`.value == lowestBattalion.typeId.value)
.get
.cost
@@ -1983,14 +1918,11 @@ object CommandChoiceHelpers {
}
end if
}
}
/** Protoless version */
private def provinceFoodSurplus(province: ProvinceT, gs: GameStateC): Int =
private def provinceFoodSurplus(province: ProvinceT, gs: GameState): Int =
(ProvinceUtils.absoluteFoodSurplus(province, gs) - FoodPerProvinceHeldBack.intValue)
.max(0)
/** Protoless version */
private def destinationCloserTo(
actingFactionId: FactionId,
provinces: Map[ProvinceId, ProvinceT],
@@ -2016,39 +1948,38 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
organizeCommand: OrganizeTroopsAvailableCommand,
province: Province,
province: ProvinceT,
costs: Map[BattalionTypeId, Double],
beastPower: Int
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
// Helper to convert Scala BattalionTypeIdC to proto BattalionTypeId for map lookup
def toProtoTypeId(scalaTypeId: BattalionTypeIdC): BattalionTypeId =
BattalionTypeId.fromValue(scalaTypeId.value)
val topUpCandidates = province.battalionIds
.map(gameState.battalions)
.filter(batt =>
organizeCommand.availableBattalionTypes
.exists(tp => tp.typeId == batt.`type` && tp.meetsRequirements)
.exists(tp => tp.typeId.value == batt.typeId.value && tp.meetsRequirements)
)
.filter(b => costs.contains(b.`type`))
.filter(b => costs.contains(toProtoTypeId(b.typeId)))
if topUpCandidates.isEmpty then None
else {
val neededTroops = topUpCandidates.map(b => (b, beastPower - b.size))
val lowestCost = neededTroops.map {
case (batt, needed) =>
(batt, needed, needed * costs(batt.`type`))
(batt, needed, needed * costs(toProtoTypeId(batt.typeId)))
}
.minBy(_._3)
Option.when(lowestCost._3 <= province.gold) {
// Hire up to at least beastsCount, but if there's surplus gold, hire more.
val batt = lowestCost._1
val battC = gs.battalions(batt.id)
val capacity =
BattalionTypeFinder.battalionType(battC.typeId, gs.battalionTypes).capacity
BattalionTypeFinder.battalionType(batt.typeId, gameState.battalionTypes).capacity
val maxDesired = batt.size +
(provinceGoldSurplus(gs.provinces(province.id)).toDouble / costs(
batt.`type`
)).floor.toInt
(provinceGoldSurplus(province).toDouble / costs(toProtoTypeId(batt.typeId))).floor.toInt
val actualNewSize = Math.max(beastPower, maxDesired).min(capacity)
@@ -2075,19 +2006,17 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
organizeCommand: AvailableCommand,
province: Province,
province: ProvinceT,
costs: Map[BattalionTypeId, Double],
beastPower: Int
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
val lowestCost = costs.minBy(_._2)
val costForMinimalBattalion = (lowestCost._2 * beastPower).ceil.toInt
Option.when(costForMinimalBattalion <= province.gold) {
val goldSurplus = provinceGoldSurplus(gs.provinces(province.id))
val goldSurplus = provinceGoldSurplus(province)
val protolessTypeId = BattalionTypeIdC.fromInt(lowestCost._1.value)
val battalionTypeC =
BattalionTypeFinder.battalionType(protolessTypeId, gs.battalionTypes)
BattalionTypeFinder.battalionType(protolessTypeId, gameState.battalionTypes)
val couldAfford = (goldSurplus / lowestCost._2).floor.toInt
.min(battalionTypeC.capacity)
val countToBuy = couldAfford.max(beastPower).min(battalionTypeC.capacity)
@@ -4,8 +4,8 @@ import scala.annotation.tailrec
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
@FunctionalInterface
@@ -2,7 +2,6 @@ package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors.{
AllianceQuestCommandChooser,
AlmsAcrossRealmQuestCommandChooser,
@@ -16,10 +15,8 @@ import net.eagle0.eagle.library.util.command_choice_helpers.quest_command_select
TruceWithFactionQuestCommandChooser,
UnaffiliatedHeroWithQuest
}
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.QuestConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
@@ -56,7 +53,7 @@ object FulfillQuestsCommandSelector {
)
}
/** Protoless version - takes native GameState and uhsWithQuests directly */
/** Takes native GameState and uhsWithQuests directly */
def chosenFulfillEasyQuestsCommand(
actingFactionId: FactionId,
gameState: GameState,
@@ -73,31 +70,30 @@ object FulfillQuestsCommandSelector {
functionalRandom = functionalRandom
)
/** Proto version - converts to native and extracts uhsWithQuests */
/** Computes uhsWithQuests from native GameState */
def chosenFulfillEasyQuestsCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val myProvinces = gameState.provinces.values
.filter(_.rulingFactionId.contains(actingFactionId))
.filter(province => LegacyProvinceUtils.ruledByFactionLeader(province, gameState))
.filter(province => ProvinceUtils.ruledByFactionLeader(province, gameState.factions.values.toVector))
val uhsWithQuests = (for {
province <- myProvinces
uh <- province.unaffiliatedHeroes
recruitmentInfo <- uh.recruitmentInfo
quest <- recruitmentInfo.quest
province <- myProvinces
uh <- province.unaffiliatedHeroes
quest <- uh.quest
} yield UnaffiliatedHeroWithQuest(
province.id,
uh.heroId,
QuestConverter.fromProto(quest)
quest
)).toVector
chosenFulfillEasyQuestsCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = availableCommands,
uhsWithQuests = uhsWithQuests,
functionalRandom = functionalRandom
@@ -7,7 +7,11 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
exports = [
"//src/main/protobuf/net/eagle0/common:hostility_scala_proto",
],
deps = [
"//src/main/protobuf/net/eagle0/common:hostility_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/library/settings:prestige_per_supported_province",
@@ -1,5 +1,6 @@
package net.eagle0.eagle.library.util.faction_utils
import net.eagle0.common.hostility.Hostility
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, PrestigePerSupportedProvince}
import net.eagle0.eagle.model.state.faction.{FactionRelationship, FactionT}
@@ -76,6 +77,21 @@ object FactionUtils {
): Boolean =
fid1 != fid2 && !hasTruceOrAlliance(fid1, fid2, factions)
def factionsAreMutuallyAllied(
fids: Vector[FactionId],
factions: Vector[FactionT]
): Boolean =
fids.forall(fid1 => fids.forall(fid2 => !factionsAreHostile(fid1, fid2, factions)))
def hostilityStatus(
fid1: FactionId,
fid2: FactionId,
factions: Vector[FactionT]
): Hostility =
if fid1 == fid2 then Hostility.SELF_HOSTILITY
else if factionsAreHostile(fid1, fid2, factions) then Hostility.ENEMY_HOSTILITY
else Hostility.ALLIED_HOSTILITY
private def incomingHids(province: ProvinceT): Vector[HeroId] =
province.incomingArmies
.map(_.army)
@@ -136,6 +152,30 @@ object FactionUtils {
def provinceCount(factionId: FactionId, provinces: Iterable[ProvinceT]): Int =
provinces.count(_.rulingFactionId.contains(factionId))
def hostileNeighbors(
province: ProvinceT,
fid: FactionId,
allProvinces: Map[ProvinceId, ProvinceT],
factions: Vector[FactionT]
): Vector[ProvinceT] =
province.neighbors
.map(_.provinceId)
.map(allProvinces)
.filter(_.rulingFactionId.isDefined)
.filterNot(_.rulingFactionId.contains(fid))
.filterNot(neighbor => hasTruceOrAlliance(fid, neighbor.rulingFactionId.get, factions))
.toVector
def neutralNeighbors(
province: ProvinceT,
allProvinces: Map[ProvinceId, ProvinceT]
): Vector[ProvinceT] =
province.neighbors
.map(_.provinceId)
.map(allProvinces)
.filterNot(_.rulingFactionId.isDefined)
.toVector
def ownedNeighbors(
provinces: Map[ProvinceId, ProvinceT],
fid: FactionId
@@ -9,6 +9,9 @@ scala_library(
],
exports = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -17,8 +20,11 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_wisdom_for_start_fire",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
],
)
@@ -2,6 +2,7 @@ package net.eagle0.eagle.library.util.hero
import scala.annotation.tailrec
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.library.settings.{
MinimumAgilityForArchery,
MinimumAgilityForStartFire,
@@ -9,9 +10,11 @@ import net.eagle0.eagle.library.settings.{
}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.{Mage, NoProfession}
import net.eagle0.eagle.HeroId
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.view.stat_with_condition.{Condition, StatWithCondition}
object HeroUtils {
@@ -102,4 +105,66 @@ object HeroUtils {
hero.profession == Mage ||
(hero.agility >= MinimumAgilityForStartFire.doubleValue &&
hero.wisdom >= MinimumWisdomForStartFire.doubleValue)
def faction(hero: HeroT, factions: Iterable[FactionT]): Option[FactionT] =
hero.factionId.flatMap(fid => factions.find(_.id == fid))
def loyaltyAsStatWithCondition(
hero: HeroT,
factions: Vector[FactionT]
): Option[StatWithCondition] =
Option.when(!FactionUtils.isFactionLeader(hero.id, factions))(
scWithRanges(
hero.loyalty.floor.toInt,
minMedium = 50,
minHigh = 80
)
)
def vigorAsStatWithCondition(hero: HeroT): Option[StatWithCondition] =
Some(
scWithRanges(
hero.vigor.floor.toInt,
minMedium = 40,
minHigh = 70
)
)
private def scWithRanges(
value: Int,
minMedium: Int,
minHigh: Int
): StatWithCondition =
StatWithCondition(
stat = value,
condition =
if value < minMedium then Condition.Low
else if value < minHigh then Condition.Medium
else Condition.High
)
/**
* Returns provinces a faction leader can act on. For vassals, returns just the one province; for faction leaders,
* returns all owned provinces that don't contain other faction leaders.
*/
def consideredProvinces(
gameState: GameState,
factionId: FactionId,
provinceId: ProvinceId
): Vector[ProvinceT] = {
val factions = gameState.factions.values.toVector
val province = gameState.provinces(provinceId)
if province.rulingHeroId.exists(hid => FactionUtils.isFactionHead(hid, factions)) then
val factionHeadId = factions.find(_.id == factionId).map(_.factionHeadId)
val leadersBesidesHead = FactionUtils.leadersBesidesHead(factions.find(_.id == factionId).get)
gameState.provinces.values
.filter(_.rulingFactionId.contains(factionId))
.filterNot(p =>
p.rulingFactionHeroIds.exists(leadersBesidesHead.contains) &&
!p.rulingFactionHeroIds.exists(factionHeadId.contains)
)
.toVector
.sortBy(p => if p.id == provinceId then 0 else p.id)
else Vector(province)
}
}
@@ -100,6 +100,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
@@ -123,7 +126,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:faction_view_filter",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:hero_view_filter",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:province_view_filter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -1,12 +1,27 @@
package net.eagle0.eagle.library.util.view_filters
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.view.province.ProvinceViewConverter
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
import net.eagle0.eagle.views.faction_view.FactionView
import net.eagle0.eagle.views.game_state_view.GameStateView
import net.eagle0.eagle.FactionId
object GameStateViewFilter {
/**
* Scala GameState overload - converts to proto internally for sub-filters that still need it. This allows callers to
* pass Scala GameState directly, avoiding conversion at call site.
*/
def filteredGameState(
gs: ScalaGameState,
factionId: Option[FactionId]
): GameStateView =
// TODO: Update sub-filters (HeroViewFilter, FactionViewFilter, etc.) to accept Scala types,
// then remove this internal conversion
filteredGameState(GameStateConverter.toProto(gs), factionId)
def filteredGameState(
gs: GameState,
factionId: Option[FactionId]
@@ -10,6 +10,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__pkg__",
@@ -4,9 +4,13 @@ scala_library(
name = "battalion",
srcs = ["BattalionViewConverter.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -13,6 +13,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result:__subpackages__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/shardok_battle:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/test/scala/net/eagle0/eagle/model/state:__subpackages__",
@@ -57,6 +58,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result:__subpackages__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
deps = [
@@ -18,10 +18,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/state/hero:__pkg__",
"//src/main/scala/net/eagle0/eagle/shardok_interface:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
],
exports = ["//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id"],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
@@ -7,6 +7,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/main/scala/net/eagle0/eagle/shardok_interface:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
],
@@ -4,7 +4,10 @@ scala_library(
name = "battalion",
srcs = ["BattalionView.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
@@ -6,124 +6,175 @@ import scala.concurrent.Future
import net.eagle0.eagle.api.auth.auth.*
import net.eagle0.eagle.api.auth.auth.AuthGrpc.Auth
import net.eagle0.eagle.auth.{JwtService, OAuthService, ProviderUserInfo, UserService}
import net.eagle0.eagle.auth.{
JwtService,
OAuthExpired,
OAuthFailure,
OAuthPending,
OAuthService,
OAuthSuccess,
UserService
}
import net.eagle0.eagle.internal.user.user.User
/** gRPC service implementation for OAuth authentication */
/**
* gRPC service implementation for OAuth authentication.
*
* When externalAuthClient is provided, OAuth methods (getOAuthUrl, checkOAuthStatus, refreshToken) are forwarded to the
* external Go auth service. Other methods (setDisplayName, getCurrentUser, logout) are always handled locally.
*/
class AuthServiceImpl(
oauthService: OAuthService,
userService: UserService,
jwtService: JwtService
jwtService: JwtService,
externalAuthClient: Option[ExternalAuthClient] = None
) extends Auth {
override def getOAuthUrl(request: GetOAuthUrlRequest): Future[GetOAuthUrlResponse] =
Future {
val (url, state) = oauthService.getAuthUrl(request.provider, request.redirectUri)
GetOAuthUrlResponse(authUrl = url, state = state)
externalAuthClient match {
case Some(client) => client.getOAuthUrl(request)
case None =>
Future {
val (url, state) = oauthService.getAuthUrl(request.provider)
GetOAuthUrlResponse(authUrl = url, state = state)
}
}
override def exchangeCode(request: ExchangeCodeRequest): Future[ExchangeCodeResponse] =
override def checkOAuthStatus(
request: CheckOAuthStatusRequest
): Future[CheckOAuthStatusResponse] =
externalAuthClient match {
case Some(client) => client.checkOAuthStatus(request)
case None => checkOAuthStatusLocal(request)
}
private def checkOAuthStatusLocal(
request: CheckOAuthStatusRequest
): Future[CheckOAuthStatusResponse] = Future {
oauthService.checkStatus(request.state) match {
case OAuthPending =>
CheckOAuthStatusResponse(status = OAuthStatus.OAUTH_STATUS_PENDING)
case OAuthExpired =>
CheckOAuthStatusResponse(status = OAuthStatus.OAUTH_STATUS_EXPIRED)
case OAuthFailure(error) =>
CheckOAuthStatusResponse(
status = OAuthStatus.OAUTH_STATUS_FAILED,
errorMessage = error
)
case OAuthSuccess(providerInfo, provider) =>
// Find or create user
val providerName = provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD => "discord"
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => "google"
case _ => "unknown"
}
val user = userService.findOrCreateUser(
provider = providerName,
providerUserId = providerInfo.id,
email = providerInfo.email,
avatarUrl = providerInfo.avatarUrl
)
val isNewUser = user.displayName.isEmpty
// Create JWT tokens
val accessToken = jwtService.createAccessToken(
userId = user.userId,
displayName = user.displayName,
isAdmin = user.isAdmin
)
val (refreshToken, _) = jwtService.createRefreshToken(user.userId)
// Access token expires in 7 days (matches JwtServiceImpl.accessTokenDuration)
val accessTokenExpiresAt =
java.time.Instant.now().plus(java.time.Duration.ofDays(7)).getEpochSecond
CheckOAuthStatusResponse(
status = OAuthStatus.OAUTH_STATUS_SUCCESS,
accessToken = accessToken,
refreshToken = refreshToken,
expiresAt = accessTokenExpiresAt,
user = Some(toUserInfo(user, provider)),
isNewUser = isNewUser
)
}
}
override def setDisplayName(
request: SetDisplayNameRequest
): Future[SetDisplayNameResponse] = {
// Capture userId on gRPC thread before Future runs on executor thread
val userId = AuthorizationUtils.userId
Future {
oauthService.exchangeCode(
provider = request.provider,
code = request.authorizationCode,
state = request.state,
redirectUri = request.redirectUri
) match {
userService.setDisplayName(userId, request.displayName) match {
case Left(error) =>
throw new io.grpc.StatusRuntimeException(
io.grpc.Status.UNAUTHENTICATED.withDescription(error)
SetDisplayNameResponse(
success = false,
errorMessage = error,
user = None,
accessToken = ""
)
case Right(providerInfo) =>
// Find or create user
val providerName = request.provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD => "discord"
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => "google"
case _ => "unknown"
}
val user = userService.findOrCreateUser(
provider = providerName,
providerUserId = providerInfo.id,
email = providerInfo.email,
avatarUrl = providerInfo.avatarUrl
)
val isNewUser = user.displayName.isEmpty
// Create JWT tokens
val accessToken = jwtService.createAccessToken(
case Right(user) =>
// Create a new access token with the updated displayName
val newToken = jwtService.createAccessToken(
userId = user.userId,
displayName = user.displayName,
isAdmin = user.isAdmin
)
val (refreshToken, refreshInfo) = jwtService.createRefreshToken(user.userId)
ExchangeCodeResponse(
accessToken = accessToken,
refreshToken = refreshToken,
expiresAt = refreshInfo.expiresAt.toEpochMilli / 1000,
user = Some(toUserInfo(user, request.provider)),
isNewUser = isNewUser
SetDisplayNameResponse(
success = true,
errorMessage = "",
user = Some(toUserInfo(user, getProviderForUser(user))),
accessToken = newToken
)
}
}
override def setDisplayName(
request: SetDisplayNameRequest
): Future[SetDisplayNameResponse] = Future {
val userId = AuthorizationUtils.userId
userService.setDisplayName(userId, request.displayName) match {
case Left(error) =>
SetDisplayNameResponse(
success = false,
errorMessage = error,
user = None
)
case Right(user) =>
SetDisplayNameResponse(
success = true,
errorMessage = "",
user = Some(toUserInfo(user, getProviderForUser(user)))
)
}
}
override def refreshToken(
request: RefreshTokenRequest
): Future[RefreshTokenResponse] = Future {
// For now, just validate the refresh token format and issue a new access token
// Full refresh token validation would check against stored tokens
val tokenHash = jwtService.hashRefreshToken(request.refreshToken)
): Future[RefreshTokenResponse] =
externalAuthClient match {
case Some(client) => client.refreshToken(request)
case None =>
Future {
// For now, just validate the refresh token format and issue a new access token
// Full refresh token validation would check against stored tokens
val tokenHash = jwtService.hashRefreshToken(request.refreshToken)
// TODO: Look up user from refresh token database
// For now, extract user ID from the JWT-like structure if available
// This is a simplified implementation - production would validate against stored tokens
// TODO: Look up user from refresh token database
// For now, extract user ID from the JWT-like structure if available
// This is a simplified implementation - production would validate against stored tokens
throw new io.grpc.StatusRuntimeException(
io.grpc.Status.UNIMPLEMENTED.withDescription("Refresh token not yet implemented")
)
}
throw new io.grpc.StatusRuntimeException(
io.grpc.Status.UNIMPLEMENTED.withDescription("Refresh token not yet implemented")
)
}
}
override def getCurrentUser(
request: GetCurrentUserRequest
): Future[GetCurrentUserResponse] = Future {
): Future[GetCurrentUserResponse] = {
// Capture userId on gRPC thread before Future runs on executor thread
val userId = AuthorizationUtils.userId
Future {
userService.findByUserId(userId) match {
case Some(user) =>
GetCurrentUserResponse(
user = Some(toUserInfo(user, getProviderForUser(user)))
)
userService.findByUserId(userId) match {
case Some(user) =>
GetCurrentUserResponse(
user = Some(toUserInfo(user, getProviderForUser(user)))
)
case None =>
throw new io.grpc.StatusRuntimeException(
io.grpc.Status.NOT_FOUND.withDescription("User not found")
)
case None =>
throw new io.grpc.StatusRuntimeException(
io.grpc.Status.NOT_FOUND.withDescription("User not found")
)
}
}
}
@@ -29,7 +29,7 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None) extends Se
// Endpoints that don't require authentication
private val publicEndpoints = Set(
"net.eagle0.eagle.api.auth.Auth/GetOAuthUrl",
"net.eagle0.eagle.api.auth.Auth/ExchangeCode",
"net.eagle0.eagle.api.auth.Auth/CheckOAuthStatus",
"net.eagle0.eagle.api.auth.Auth/RefreshToken"
)
@@ -42,4 +42,6 @@ object AuthorizationUtils {
.withValue(userIdCtxKey, userId)
.withValue(displayNameCtxKey, displayName)
.withValue(isAdminCtxKey, isAdmin)
// Also set legacy userName for backwards compatibility with game management code
.withValue(userNameCtxKey, displayName)
}
@@ -9,6 +9,7 @@ scala_library(
],
deps = [
":authorization_utils",
":external_auth_client",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/eagle/auth:jwt_service",
@@ -18,6 +19,45 @@ scala_library(
],
)
scala_library(
name = "external_auth_client",
srcs = ["ExternalAuthClient.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"@maven//:io_grpc_grpc_api",
],
)
scala_library(
name = "internal_user_service",
srcs = ["InternalUserServiceImpl.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__pkg__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:auth_internal_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/eagle/auth:user_service",
],
)
scala_library(
name = "oauth_http_handler",
srcs = ["OAuthHttpHandler.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__pkg__",
],
deps = [
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle/auth:oauth_service",
],
)
scala_library(
name = "service",
srcs = [
@@ -52,6 +92,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:game_parameters_utils",
"//src/main/scala/net/eagle0/eagle/service/persistence:save_directory",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update_receiver",
@@ -411,6 +452,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update_receiver",
"//src/main/scala/net/eagle0/eagle/shardok_interface:shardok_interface_grpc_client",
"@grpc-java//api",
"@grpc-java//core",
],
)
@@ -1,10 +1,15 @@
package net.eagle0.eagle.service
import java.io.{ByteArrayOutputStream, File}
import java.nio.file.{Files, Paths}
import java.util.zip.{ZipEntry, ZipOutputStream}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
import scala.util.{Random, Try}
import scala.util.{Random, Try, Using}
import scala.util.hashing.MurmurHash3
import com.google.protobuf.ByteString
import io.grpc.stub.StreamObserver
import io.grpc.StatusRuntimeException
import net.eagle0.common.{FunctionalRandom, RandomState, SimpleTimedLogger}
@@ -19,6 +24,7 @@ import net.eagle0.eagle.library.settings.MaxSupportedPlayers
import net.eagle0.eagle.library.EagleClientException
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.service.new_game_creation.GameParametersUtils
import net.eagle0.eagle.service.persistence.SaveDirectory
import net.eagle0.eagle.service.CustomBattleManager.CreateCustomBattleResponse
import org.json4s.*
import org.json4s.native.Serialization.writePretty
@@ -651,60 +657,73 @@ class EagleServiceImpl(
gamesManager.gameControllerInfos.get(request.gameId) match {
case Some(controllerInfo) =>
val history = controllerInfo.controller.engine.history
val allResults = history.all
val totalCount = history.count
val startIndex = if request.startIndex > 0 then request.startIndex else 0
val limit = if request.limit > 0 then request.limit else allResults.length
val limit = request.limit
implicit val formats: DefaultFormats.type = DefaultFormats
val monthNames = Vector(
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
)
val entries = allResults.zipWithIndex
.drop(startIndex)
.take(limit)
.map {
case (actionWithState, index) =>
val ar = actionWithState.actionResult
val gs = actionWithState.gameState
val actionJson = writePretty(JsonFormat.toJson(ar))
val factionName = ar.player
.flatMap(fid => gs.factions.get(fid))
.map(_.name)
.getOrElse("")
val gameDate = gs.currentDate
.map(d => s"${monthNames(d.month)} ${d.year}")
.getOrElse("")
GameHistoryEntry(
index = index,
actionType = ar.`type`.name,
roundId = gs.currentRoundId,
actingFactionId = ar.player,
provinceId = ar.province,
summary = s"${ar.`type`.name} action",
actionJson = actionJson,
factionName = factionName,
gameDate = gameDate
)
}
// If limit is 0, return just the count without loading any entries
if limit <= 0 then
GetGameHistoryResponse(
gameId = request.gameId,
totalActionCount = totalCount,
entries = Seq.empty
)
else {
implicit val formats: DefaultFormats.type = DefaultFormats
val monthNames = Vector(
"",
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
)
// Use since(startIndex) for efficient partial loading instead of loading all results
val entries = history
.since(startIndex)
.take(limit)
.zipWithIndex
.map {
case (actionWithState, localIndex) =>
val index = startIndex + localIndex
val ar = actionWithState.actionResult
val gs = actionWithState.gameState
val actionJson = writePretty(JsonFormat.toJson(ar))
val factionName = ar.player
.flatMap(fid => gs.factions.get(fid))
.map(_.name)
.getOrElse("")
val gameDate = gs.currentDate
.map(d => s"${monthNames(d.month)} ${d.year}")
.getOrElse("")
GameHistoryEntry(
index = index,
actionType = ar.`type`.name,
roundId = gs.currentRoundId,
actingFactionId = ar.player,
provinceId = ar.province,
summary = s"${ar.`type`.name} action",
actionJson = actionJson,
factionName = factionName,
gameDate = gameDate
)
}
GetGameHistoryResponse(
gameId = request.gameId,
totalActionCount = allResults.length,
entries = entries
)
GetGameHistoryResponse(
gameId = request.gameId,
totalActionCount = totalCount,
entries = entries
)
}
end if
case None =>
GetGameHistoryResponse(
@@ -901,6 +920,80 @@ class EagleServiceImpl(
)
}
}
override def importGame(
request: ImportGameRequest
): Future[ImportGameResponse] = Future {
gamesManager.importGame(gameId = request.gameId) match {
case Right(()) =>
SimpleTimedLogger.printLogger.logLine(
s"Imported game ${request.gameId.toHexString}"
)
ImportGameResponse(result = ImportGameResponse.Result.SUCCESS)
case Left(error) =>
SimpleTimedLogger.printLogger.logLine(
s"Failed to import game ${request.gameId.toHexString}: $error"
)
val result =
if error.contains("already running") then ImportGameResponse.Result.ALREADY_RUNNING
else ImportGameResponse.Result.INVALID_SAVE
ImportGameResponse(result = result, errorMessage = error)
}
}
override def downloadGameSave(
request: DownloadGameSaveRequest,
responseObserver: StreamObserver[DownloadGameSaveResponse]
): Unit = {
val gameId = request.gameId
val saveDir = new File(SaveDirectory.saveDirectoryForGame(gameId))
val chunkSize = 64 * 1024 // 64KB chunks
try {
if !saveDir.exists() || !saveDir.isDirectory then {
responseObserver.onError(
new EagleClientException(s"Save directory not found for game ${gameId.toHexString}")
)
return
}
// Create zip in memory
val baos = new ByteArrayOutputStream()
val zipOut = new ZipOutputStream(baos)
val saveFiles = saveDir.listFiles().filter(_.isFile)
for file <- saveFiles do {
zipOut.putNextEntry(new ZipEntry(file.getName))
val fileBytes = Files.readAllBytes(file.toPath)
zipOut.write(fileBytes)
zipOut.closeEntry()
}
zipOut.close()
// Stream the zip in chunks
val zipBytes = baos.toByteArray
var offset = 0
while offset < zipBytes.length do {
val end = math.min(offset + chunkSize, zipBytes.length)
val chunk = java.util.Arrays.copyOfRange(zipBytes, offset, end)
responseObserver.onNext(
DownloadGameSaveResponse(chunk = ByteString.copyFrom(chunk))
)
offset = end
}
responseObserver.onCompleted()
SimpleTimedLogger.printLogger.logLine(
s"Downloaded game save ${gameId.toHexString} (${zipBytes.length} bytes)"
)
} catch {
case e: Exception =>
SimpleTimedLogger.printLogger.logLine(
s"Failed to download game save ${gameId.toHexString}: ${e.getMessage}"
)
responseObserver.onError(e)
}
}
}
object EagleServiceImpl {
@@ -0,0 +1,41 @@
// Copyright 2025 Dan Crosby
package net.eagle0.eagle.service
import scala.concurrent.{ExecutionContext, Future}
import io.grpc.{ManagedChannel, ManagedChannelBuilder}
import net.eagle0.eagle.api.auth.auth.*
import net.eagle0.eagle.api.auth.auth.AuthGrpc.AuthStub
/** Client for forwarding OAuth requests to an external Go auth service */
class ExternalAuthClient(channel: ManagedChannel)(implicit ec: ExecutionContext) {
private val stub: AuthStub = AuthGrpc.stub(channel)
def getOAuthUrl(request: GetOAuthUrlRequest): Future[GetOAuthUrlResponse] =
stub.getOAuthUrl(request)
def checkOAuthStatus(request: CheckOAuthStatusRequest): Future[CheckOAuthStatusResponse] =
stub.checkOAuthStatus(request)
def refreshToken(request: RefreshTokenRequest): Future[RefreshTokenResponse] =
stub.refreshToken(request)
def shutdown(): Unit = { val _ = channel.shutdown() }
}
object ExternalAuthClient {
/** Create an external auth client from an address string like "localhost:40033" */
def apply(address: String)(implicit ec: ExecutionContext): ExternalAuthClient = {
val parts = address.split(":")
val host = parts(0)
val port = parts(1).toInt
val channel = ManagedChannelBuilder
.forAddress(host, port)
.usePlaintext()
.build()
new ExternalAuthClient(channel)
}
}
@@ -1127,4 +1127,30 @@ class GamesManager(
}
end if
}
def importGame(gameId: GameId): Either[String, Unit] = this.synchronized {
if gameControllerInfos.contains(gameId) then Left("Game is already running")
else {
val gamePersister = gamePersisterCreation.persisterForGame(gameId)
// Verify save exists by trying to load history
GamesManager.gameHistoryFromFiles(gameId, gamePersister) match {
case None =>
Left("No valid save found for this game ID")
case Some(history) =>
// Create controller with no human players initially
val controller = GamesManager.gameController(
gameId = gameId,
gamePersister = gamePersister,
history = history,
userToFid = Map.empty, // No players assigned - admin can add later
pregeneratedHeroes = GameParametersUtils.allPregeneratedHeroes
)
addController(controller, isAiGame = true, maxPlayers = 2)
save()
Right(())
}
}
}
}

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