Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 cbd0bd8e31 Add Console.WriteLine for timer callbacks and make Logger thread-safe
The timers stopped logging entirely during the 28-second gap. To determine
if the timers are firing but the Logger is blocked vs timers not firing:

1. Add Console.WriteLine in timer Elapsed handlers BEFORE the callback
   - These bypass our Logger and write directly to stdout
   - Will show if timers are firing even if Logger is blocked

2. Make Logger thread-safe with locks
   - LogLine now uses lock(_lock) to prevent concurrent access issues
   - GetLogger now uses lock(_loggersLock) for thread-safe singleton access

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:04:39 -08:00
adminandClaude Opus 4.5 a884d373c7 Add diagnostic logging for heartbeat and idle timer issues
Add logging to diagnose why heartbeat/idle timers stop firing on Windows:
- Log when heartbeat timer fires (before any checks)
- Log when heartbeat is skipped and why (cancellation, not connected)
- Log when heartbeat send fails (stream unavailable)
- Log idle check when idle > 5s with cancellation status

This will help diagnose the 90-second gap where no heartbeat or idle
logs appear before PROTOCOL_ERROR on Windows client.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:36:18 -08:00
d6d1868fcb Stop timers before sync mismatch reconnect (#4854)
When HandleHeartbeatResponse detects a sync mismatch and triggers a
reconnect, it wasn't stopping the heartbeat and idle check timers.
This could cause them to fire during reconnection, potentially
interfering with the new connection.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:03:39 -08:00
8c9d38c546 Fix IllegalStateException when LLM stream client disconnects (#4855)
Handle the case where a gRPC stream is completed (client disconnected) but
the LLM update consumer thread still tries to send updates. This causes
IllegalStateException: "Stream is already completed, no further calls allowed".

Added catch for IllegalStateException in both humanClientsAfterUpdatingLlmStream
and humanClientsAfterPostingResults to gracefully handle disconnected clients.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:01:51 -08:00
139494d937 Fix race condition between heartbeat response and game updates (#4853)
The heartbeat handler was reading game state and sending responses without
holding the GamesManager lock. Since game updates ARE sent while holding
this lock, there was a race condition where:

1. AI processing holds GamesManager lock
2. Updates start being sent to clients via the stream
3. Heartbeat request arrives, acquires EagleServiceImpl lock (different lock)
4. Heartbeat reads game count and sends response (can interleave with updates)
5. Client receives heartbeat before some in-flight updates
6. Client detects sync mismatch despite updates being in transit

This particularly affected slower/remote connections (e.g., Windows clients)
because the timing window for the race was longer.

Fix: Wrap heartbeat handling in gamesManager.synchronized to ensure the
response is sent only after any in-progress game updates have been queued.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:33:10 -08:00
5c52dd9006 Add HTTP/2 timeout settings and upgrade YetAnotherHttpHandler to 1.11.4 (#4851)
Upgrade YetAnotherHttpHandler from 1.5.3 to 1.11.4 to get:
- ConnectTimeout support (added in 1.8.0)
- Fix deadlock during cancellation (1.11.4)
- Memory management improvements (1.11.1)
- Backpressure control (1.10.0)

Configure explicit timeouts to detect and recover from dead connections
faster, especially on Windows where firewalls may silently drop HTTP/2
keep-alive pings:

- Http2KeepAliveTimeout (5s): Close connection if ping not acknowledged
- Http2KeepAliveWhileIdle: Continue pinging during idle periods
- ConnectTimeout (10s): Don't wait forever for initial connection

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:10:44 -08:00
d473e5a5d6 Fix deadlock in PersistentClientConnection.Dispose() (#4850)
The Dispose() method held lock(this) while calling Thread.Join() on the
streaming thread. If the streaming thread was trying to acquire the same
lock, this caused a deadlock - making "Return to Lobby" hang indefinitely.

Fix: Capture thread reference inside lock, then join OUTSIDE the lock.

Also adds diagnostic logging for sync mismatch debugging:
- Heartbeat now logs client's count per game
- Result count updates are logged when they change

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:01:10 -08:00
e9b980e6d0 Add protoless shouldRest overload to CommandChoiceHelpers (#4849)
- Add protoless shouldRest(Iterable[HeroT]) using HeroUtils.fatigue
- Rename proto version to shouldRestProto(Iterable[Hero])
- Update callers (PerformVassalCommandsPhaseAction) to use shouldRestProto
- Add protoless tests using HeroC
- Add hero_utils dependency to BUILD.bazel

This follows the established pattern for incremental deproto migration.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 18:11:06 -08:00
8ba4268832 Deproto: Remove proto methods and convert tests to protoless (#4848)
* Remove SwornBrotherChooser.bestChoiceProto and convert test to protoless

- Delete bestChoiceProto method from SwornBrotherChooser
- Convert SwornBrotherChooserTest to use HeroC (protoless) instead of proto Hero
- Remove proto and LegacyHeroUtils dependencies from sworn_brother_chooser target
- Update DEPROTO_PLAN.md to mark SwornBrotherChooser as fully protoless

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

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

* Convert ImproveCommandSelectorTest to protoless and update DEPROTO_PLAN.md

- Replace proto Hero/Province/GameState with native HeroC/ProvinceC/GameState
- Remove GameStateConverter.fromProto calls - test now uses native types directly
- Remove proto dependencies from BUILD.bazel
- Update DEPROTO_PLAN.md with comprehensive status of all command selectors:
  - Document 11 fully protoless command selectors
  - Document 10 protoless quest command selectors
  - Document 2 files with dual proto/protoless versions
  - Document 4 files still blocked on proto GameState

🤖 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-26 17:17:17 -08:00
3a033ddf19 Fix admin server: use explicit goos/goarch for Linux binary (#4847)
The --platforms flag doesn't affect oci_image/pkg_tar dependencies -
they're exec rules that run on the host. This caused the Go binary
to be built for darwin/arm64 instead of linux/amd64.

Fix by creating an explicit admin_server_linux_amd64 target with
goos = "linux" and goarch = "amd64" set in the BUILD file.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:55:09 -08:00
61196d05e7 Add Go admin server to Docker deployment (#4846)
- Add oci_image, oci_load, oci_push targets for admin_server in ci/BUILD.bazel
- Add Alpine Linux 3.21 base image to MODULE.bazel (lightweight for Go binary)
- Add admin service to docker-compose.prod.yml with health check
- Add build-admin job to GitHub Actions workflow
- Update deploy job to pull and deploy admin image alongside eagle/shardok

The admin server connects to Eagle via internal Docker network (eagle:40032)
and exposes HTTP on port 8080 for the admin console.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 12:45:01 -08:00
e5a71bd3b4 Add Go admin server to Docker deployment (#4843)
* Add Go admin server to Docker deployment

- Add oci_image, oci_load, oci_push targets for admin_server in ci/BUILD.bazel
- Add Alpine Linux 3.21 base image to MODULE.bazel (lightweight for Go binary)
- Add admin service to docker-compose.prod.yml with health check
- Add build-admin job to GitHub Actions workflow
- Update deploy job to pull and deploy admin image alongside eagle/shardok

The admin server connects to Eagle via internal Docker network (eagle:40032)
and exposes HTTP on port 8080 for the admin console.

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

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

* Fix Go cross-compilation by enabling pure Go build

Add `pure = "on"` to admin_server go_binary to disable CGO.
This avoids linker errors when cross-compiling from macOS ARM64
to Linux x86_64 with the LLVM toolchain.

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

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

* Use rules_go platform for Go cross-compilation

Use @io_bazel_rules_go//go/toolchain:linux_amd64 instead of
//:linux_x86_64 for the admin server build. The rules_go platform
uses Go's native cross-compiler and doesn't involve the LLVM C
toolchain, avoiding linker errors.

🤖 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-26 11:46:07 -08:00
e545d9c616 Remove ActionPointDistances disk cache (#4844)
Profiling showed that GenerateDistances accounts for only 0.04% of
total runtime while the disk cache consumed 168 GB of storage. The
modifier hash explosion (ice melt, fire, etc.) caused unbounded cache
growth without providing meaningful performance benefit.

The in-memory caching layers (persistent, thread-local, and shared)
remain and provide effective caching for the hot paths.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:24:08 -08:00
17e5135915 Add OPENAI_API_KEY and GPT_MODEL_NAME to deploy .env file (#4842)
- Pass all required env vars from GitHub secrets
- Fix .env file permissions (rm before write, chmod 600)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 10:57:47 -08:00
5c9623717d Convert SeekMoreLeadersCommandChooser and ProvinceDistances to protoless (#4840)
- Split ProvinceDistances into protoless version + LegacyProvinceDistances
- Convert SeekMoreLeadersCommandChooser to protoless (MidGameAIClient converts proto→Scala)
- Add protoless overloads to FactionUtils.provinces and CommandChoiceHelpers
- Create protoless SeekMoreLeadersCommandChooserTest using Scala model types
- Add DEPROTO_PLAN.md documenting migration status and decisions
- Update visibility for model state concrete types to support AI tests

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 10:49:34 -08:00
2ed1c5769e Enable S3/DO Spaces persistence via environment variables (#4839)
* Enable S3/DO Spaces persistence via environment variables

Add support for persisting game saves to DigitalOcean Spaces (S3-compatible)
via environment variables, preventing game state loss during deployments.

Configuration:
- EAGLE_ENABLE_S3=true to enable S3 persistence
- DO_SPACES_ACCESS_KEY and DO_SPACES_SECRET_KEY for credentials
- Falls back to ~/.s3cfg if env vars not set

Changes:
- S3Credentials: Load credentials from env vars or ~/.s3cfg
- LocalGamePersisterCreation: Read EAGLE_ENABLE_S3 from env
- ServerSetupHelpers: Enable S3 persister based on env var
- S3Utils: Fix transferManager to use credentials
- docker-compose.prod.yml: Add S3 environment variables

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

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

* Centralize S3 config and add endpoint env var

- Move all S3 config to S3Credentials (single source of truth)
- Add DO_SPACES_ENDPOINT env var (defaults to sfo3.digitaloceanspaces.com)
- Remove duplicate config reading from LocalGamePersisterCreation and ServerSetupHelpers
- Pass S3 secrets from GitHub Actions to droplet via .env file

🤖 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-26 07:55:01 -08:00
7a3b382623 Build Eagle Docker image for linux_x86_64 platform (#4838)
The Eagle image was being built without --platforms, defaulting to the
self-hosted runner's platform (darwin/arm64). This caused digest mismatch
errors when trying to pull the image on the linux/amd64 production droplet.

Also fix the bazel-bin symlink issue: when building with --platforms,
the output goes to a platform-specific directory. Save the resolved path
immediately after build (before other bazel commands change the symlink)
and use that path for pushing.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 07:13:02 -08:00
d1e4ba4efe Delete unused ActionResultProtoUpdater type alias (#4836)
- Delete package.scala containing the unused ActionResultProtoUpdater type alias
- Remove action_pkg target from BUILD.bazel
- Remove unnecessary action_pkg dependencies from quest_creation BUILD targets

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 22:25:42 -08:00
03c26b01c9 Pass SHA-tagged images from build to deploy jobs (#4834)
Instead of computing SHA tags locally in the deploy step or falling back
to :latest (which has caching issues), pass the exact image tags used
during push as job outputs. This ensures deploy always uses the exact
images that were just built and pushed.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 22:18:10 -08:00
830c524c53 Remove unused xpForStatBump from applier traits (#4833)
- Remove xpForStatBump method from ActionResultProtoApplier, ActionResultApplier,
  and ActionResultTApplier traits and their implementations
- Update tests to use GameStateExtensions.xpForStatBump directly instead of
  calling through the applier
- Remove duplicate xpForStatBump tests from ActionResultProtoApplierImplTest
- Clean up unused imports from ActionResultProtoApplierImpl

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 21:58:38 -08:00
admin c348085439 Merge branch 'investigate/deployment-issue' 2025-12-25 21:54:31 -08:00
adminandClaude Opus 4.5 b0f5f05362 Switch to direct docker pull with :latest fallback
docker compose pull has issues with DO registry caching.
Use direct docker pull which may handle errors better,
and fall back to :latest tag if SHA tag fails.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:48:28 -08:00
adminandClaude Opus 4.5 92b41c9b36 Clear local Docker images before pull to fix manifest cache
The DigitalOcean registry seems to have caching issues where the local
Docker daemon has a cached manifest that doesn't match the new content.

- Remove specific images before pulling to clear cached manifests
- Add --quiet flag to pull
- Increase retry delay to 10s
- Clear buildkit cache too

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:45:24 -08:00
adminandClaude Opus 4.5 5c83fa479d Fix SHA tag length mismatch between push and deploy
Push was using git rev-parse --short (variable length)
Deploy was using cut -c1-9

Now both use exactly 8 characters consistently.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:42:13 -08:00
adminandClaude Opus 4.5 831b72b382 Fix crane push to use SHA tag first, then copy to :latest
Change push strategy:
1. Push with unique SHA tag first
2. Copy that tag to :latest

This ensures both :latest and :SHA have the same digest and
avoids issues with crane's "existing manifest" deduplication.

Also simplified the image path finding to just use readlink.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:39:10 -08:00
adminandClaude Opus 4.5 d3c1542eaa Fix Shardok image path to use cquery for cross-compilation
The bazel-bin symlink doesn't work correctly for cross-compiled targets.
It always points to the exec platform output, not the target platform.

Using `bazel cquery --output=files` with the platform flags gets the
actual output path for the cross-compiled image.

Also add validation that the path contains 'linux' to catch this issue
early if it happens again.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:34:58 -08:00
adminandClaude Opus 4.5 c7b5e2db0d Use SHA-tagged images instead of :latest to avoid registry cache issues
The :latest tag has a corrupted manifest in the DO registry causing
persistent digest mismatch errors. Using the SHA-tagged image
(which is pushed alongside :latest) should work around this.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 19:30:48 -08:00
adminandGitHub 9ac664659f Delete proto version of VigorXPApplier.withVigorXp (#4832) 2025-12-25 18:40:34 -08:00
adminandClaude Opus 4.5 4c37d33682 Fix retry logic to work with script_stop
The previous fix used set -e which caused immediate exit before
the || fallback could run. Now uses explicit loop with success flag.

Also adds 5s delay between retries to allow registry to settle.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:13:27 -08:00
71c7d700a5 Fix deployment to fail on errors and force recreate containers (#4830)
The deploy step was silently failing because:
1. script_stop was not set, so SSH action continued on errors
2. docker compose pull was failing with digest mismatch errors
3. containers weren't recreated because Docker thought image was unchanged

Fixes:
- Add script_stop: true to fail on any command error
- Add set -ex for verbose error handling
- Add retry logic for pull with cache clear
- Use --force-recreate to ensure containers use new images
- Add verification step to show container image tags

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 18:10:35 -08:00
ab485919ed Implement OAuth authentication with Discord and Google support (#4827)
Add server-side OAuth authentication infrastructure that supports both
Discord and Google as identity providers, while maintaining backwards
compatibility with existing Basic Auth.

## New Components

- **Auth Proto Definitions**: gRPC service with endpoints for OAuth flow
  (GetOAuthUrl, ExchangeCode, SetDisplayName, RefreshToken, GetCurrentUser)
- **User Proto**: Storage schema with admin flag for impersonation support
- **JwtService**: RS256 token creation/validation with 7-day access tokens
- **OAuthService**: OAuth code exchange with Discord and Google APIs
- **UserService**: User CRUD with file-based persistence via Persister
- **AuthServiceImpl**: gRPC service wiring OAuth flow together

## Key Features

- Dual auth: Basic Auth continues working; JWT activates when configured
- Admin impersonation: Set X-Impersonate-User header to debug as another user
- Display name validation: 3-20 chars, alphanumeric + underscore, unique
- Graceful degradation: Server starts without OAuth if keys not configured

## Environment Variables

```bash
# JWT (required for OAuth)
JWT_PRIVATE_KEY='{"kty":"RSA",...}'  # RSA key in JWK format

# Discord OAuth
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=

# Google OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
```

## Next Steps for Full Integration

1. Generate RSA key for JWT signing
2. Configure Discord/Google OAuth apps with redirect URI eagle0://auth/callback
3. Implement Unity client OAuth flow with system browser + deep links

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 17:55:39 -08:00
8725ccfb34 Fix lastCommandTypeForActingProvince by wrapping result before applying (#4829)
The previous fix (#4825) set lastCommandTypeForActingProvince on the
ActionResult after the ActionResultApplier had already computed the
GameState, so the province's lastCommand was never updated.

This fix adds withTCommandAndLastCommand to RandomStateSequencer, which
wraps the first result with lastCommandTypeForActingProvince BEFORE
passing it to the applier. This mimics how the old Command.resultWithLastCommand
worked and maintains the invariant that only ActionResultApplier creates
GameStates.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 17:53:26 -08:00
73372d82bf Add protoless invitationAcceptanceChance overload (#4826)
Add a protoless version of invitationAcceptanceChance to
ResolveDiplomacyCommandSelector that uses FactionUtils.prestige
instead of LegacyFactionUtils.prestige.

Update InvitationCommandSelector to use the protoless version,
since it already converts to native GameState internally.

Also:
- Add ai package to visibility of FactionT and ProvinceT
- Add FactionUtils dependency to resolve_diplomacy_command_selector

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 13:19:28 -08:00
f21839df81 Fix lastCommandTypeForActingProvince not being set on command results (#4825)
The transition to TCommand-based commands lost the code that sets
lastCommandTypeForActingProvince on action results. The old Command.execute
used to wrap results with this field, but the new protoless flow didn't.

Fix by updating EngineImpl.doCommand to set lastCommandTypeForActingProvince
on the first result from the sequencer, matching the original behavior.

Also:
- Add withLastCommandTypeForActingProvince helper to ActionResultT
- Export selected_command_scala_proto from action_result_trait
- Remove unused dep from hero_backstory_update_action_generator

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 12:55:41 -08:00
fc1d5aa7ad Add OAuth implementation plan for Discord + Google authentication (#4824)
Documents the design for replacing HTTP Basic Auth with OAuth 2.0:
- System browser + deep link flow for secure authentication
- JWT tokens (RS256) for session management
- User-chosen display names
- 5-phase implementation covering proto definitions, server auth services,
  Unity client OAuth flow, platform configuration, and testing

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 12:35:39 -08:00
c7cacb4915 Add protoless versions to SwornBrotherChooser and ProvinceGoldSurplusCalculator callers (#4823)
- Add protoless `bestChoice(HeroT)` to SwornBrotherChooser, rename proto version to `bestChoiceProto`
- Update SeekMoreLeadersCommandChooser to use `bestChoiceProto`
- Update SwornBrotherChooserTest to use `bestChoiceProto`
- Update InvitationCommandSelector to use Scala `provinceGoldSurplus(ProvinceT)` since it already has nativeGameState

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 12:34:35 -08:00
d26a2322e3 Add null check to ClearCellLabels to match ClearCellModifierImages (#4822)
Fixes NullReferenceException when cells array is null.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 12:22:48 -08:00
c9d252093a Convert RansomOfferHelpers to use Scala GameState (#4821)
* Convert RansomOfferHelpers to use Scala GameState

- Change RansomOfferHelpers from proto GameState to Scala GameState
- Update imports from proto to Scala types (FactionT.OutgoingOfferRound, GameState)
- Change FactionUtils.trust to use Scala factions vector instead of proto GameState
- Change FactionUtils.isFactionLeader to use Scala factions vector
- Simplify OutgoingOfferRound pattern match (no unknownFieldSet in Scala version)
- Update BUILD.bazel: remove proto game_state dependency, add Scala model deps
- Add GameStateConverter.fromProto call in CommandChoiceHelpers.maybeRansomLeaderCommand
- Update RansomOfferHelpersTest to use Scala types (FactionC, FactionRelationship)
- Add currentPhase to test GameStates in CommandChoiceHelpersTest for conversion

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

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

* Update DEPROTO_PLAN.md: mark RansomOfferHelpers as 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>
2025-12-25 12:18:39 -08:00
7c31faaee0 Add connection status display to Connection Panel (#4819)
Shows connection state in the Connection Panel:
- Connecting... (yellow)
- Connected (green)
- Server unavailable with countdown (red)
- Reconnecting with countdown (orange)

User can click Connect button to retry when connection fails.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 11:46:45 -08:00
3fab4b94f1 Remove unused gameState from HeroGiftCommandSelector.chosenCommandForSpecificHero (#4818)
The gameState parameter was passed but never used in the function body.
This also removes the GameStateConverter import and dependency from
SeekMoreLeadersCommandChooser since callers were converting proto to Scala
just to pass an unused parameter.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 11:35:44 -08:00
0ff8a3a8e1 Fix nginx DNS caching for container IP changes (#4817)
* Fix crane path discovery for cross-compiled Shardok image

Save the cross-compiled image path before building the push target,
since building push target without --platforms would overwrite it.
Also use find to locate crane binary dynamically.

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

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

* Add diagnostic steps to debug cross-compilation

- Build binary separately first with platform flag
- Verify binary is ELF format (Linux) not Mach-O (macOS)
- Verify binary in pkg_tar layer is also ELF
- This will help identify where cross-compilation breaks

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

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

* Add --extra_toolchains flag to force Linux cross-compilation

The --platforms flag wasn't working because the Linux toolchain is
registered with dev_dependency=True in MODULE.bazel. Adding
--extra_toolchains=@llvm_toolchain_linux//:all forces Bazel to
use the cross-compilation toolchain.

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

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

* Check binary directly from bazel-bin instead of searching bazel-out

The bazel-bin symlink points to the correct output directory, so we
should check that directly instead of searching through bazel-out
which may contain stale build artifacts.

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

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

* Add platform flags to push target build to preserve cross-compilation

The push target build was running without --platforms and --extra_toolchains,
which caused Bazel to rebuild the image without cross-compilation. This
overwrote the cross-compiled binary with a macOS binary before pushing.

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

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

* Use Darwin crane from Eagle push target for Shardok push

When using --platforms for cross-compilation, Bazel downloads the
target-platform crane (Linux) which can't run on the macOS host.
Instead, use the Eagle push target to get a Darwin crane binary,
since Eagle doesn't need cross-compilation.

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

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

* Fix crane detection to handle symlinks

Crane in Bazel runfiles is a symlink, not a regular file.
Changed find to not filter by type, and use -e instead of -f
for existence check.

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

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

* Fix nginx DNS caching for container IP changes

- Add Docker DNS resolver (127.0.0.11) with 10s TTL to nginx.conf
- Restart nginx after eagle/shardok in deploy workflow
- This ensures nginx picks up new container IPs after redeploy

🤖 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-25 11:30:12 -08:00
ea70bbb2ac Remove unused gameState from ExileVassalCommandSelector (#4816)
The gameState parameter was passed but never used in the function body.
Removing it eliminates the proto GameState dependency from this target.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 11:20:23 -08:00
8e14f4cdbb Fix domain reload hang by registering playModeStateChanged callback (#4815)
The OnPlayModeStateChanged callback was defined but never registered to
EditorApplication.playModeStateChanged. This meant when exiting play mode
in the editor, the cancellation token was never cancelled, so background
threads (especially the gRPC streaming thread) kept running, causing
domain reload to hang.

Added OnEnable/OnDisable to properly register/unregister the callback.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 11:16:50 -08:00
6ffbbe242e Remove unused gameState from SwearBrotherhoodCommandSelector (#4814)
The gameState parameter was passed but never used.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 11:14:58 -08:00
84108b995a Fix crane path discovery for cross-compiled Shardok image (#4810)
* Fix crane path discovery for cross-compiled Shardok image

Save the cross-compiled image path before building the push target,
since building push target without --platforms would overwrite it.
Also use find to locate crane binary dynamically.

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

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

* Add diagnostic steps to debug cross-compilation

- Build binary separately first with platform flag
- Verify binary is ELF format (Linux) not Mach-O (macOS)
- Verify binary in pkg_tar layer is also ELF
- This will help identify where cross-compilation breaks

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

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

* Add --extra_toolchains flag to force Linux cross-compilation

The --platforms flag wasn't working because the Linux toolchain is
registered with dev_dependency=True in MODULE.bazel. Adding
--extra_toolchains=@llvm_toolchain_linux//:all forces Bazel to
use the cross-compilation toolchain.

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

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

* Check binary directly from bazel-bin instead of searching bazel-out

The bazel-bin symlink points to the correct output directory, so we
should check that directly instead of searching through bazel-out
which may contain stale build artifacts.

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

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

* Add platform flags to push target build to preserve cross-compilation

The push target build was running without --platforms and --extra_toolchains,
which caused Bazel to rebuild the image without cross-compilation. This
overwrote the cross-compiled binary with a macOS binary before pushing.

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

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

* Use Darwin crane from Eagle push target for Shardok push

When using --platforms for cross-compilation, Bazel downloads the
target-platform crane (Linux) which can't run on the macOS host.
Instead, use the Eagle push target to get a Darwin crane binary,
since Eagle doesn't need cross-compilation.

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

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

* Fix crane detection to handle symlinks

Crane in Bazel runfiles is a symlink, not a regular file.
Changed find to not filter by type, and use -e instead of -f
for existence check.

🤖 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-25 11:06:46 -08:00
e4adac2eed Move BattalionTypeFinder to dedicated package (#4813)
- Create new battalion_type_finder package under library/util
- BattalionTypeFinder: protoless version using Scala BattalionType
- LegacyBattalionTypeFinder: proto version for legacy callers
- Update callers to use the appropriate finder:
  - OrganizeCommandSelector uses new BattalionTypeFinder
  - CommandChoiceHelpers, RuntimeValidator, CheckForFulfilledQuestsAction
    use LegacyBattalionTypeFinder
- Remove unused battalion_type_finder dep from TrainCommand

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 11:05:28 -08:00
3b83abe4c6 Convert OrganizeCommandSelector to use Scala types (#4812)
- OrganizeCommandSelector now accepts Scala GameState instead of proto
- Added protoless monthlyFoodSurplus method to ProvinceUtils
- BattalionTypeFinder.battalionType now has protoless overload; proto
  versions renamed to battalionTypeProto
- Updated callers (CommandChoiceHelpers, MidGameAIClient,
  CheckForFulfilledQuestsAction, RuntimeValidator) to use the
  appropriate method variant
- Updated OrganizeCommandSelectorTest to use Scala types

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 09:44:42 -08:00
355c06d342 Convert ImproveCommandSelector to use Scala types (#4811)
- Convert ImproveCommandSelector from proto to Scala types (GameState, ProvinceT, HeroT)
- Add protoless overload to HeroSelector.minimallyFatiguedHeroes, keep proto version as minimallyFatiguedHeroesProto
- Update all callers in CommandChoiceHelpers and MidGameAIClient to wrap with GameStateConverter.fromProto()
- Update ImproveCommandSelectorTest to use GameStateConverter.fromProto()
- Add exports to improve_command_selector for GameState visibility
- Update DEPROTO_PLAN.md to reflect progress

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 08:31:51 -08:00
dfd1da8b8a Convert ExpandCommandSelector to use Scala types (#4809)
- Convert ExpandCommandSelector from proto to Scala types (GameState, ProvinceT, FactionT)
- Add isAdjacentEnemy method to ProvinceUtils for protoless usage
- Add protoless overloads to ProvinceGoldSurplusCalculator
- Update ExpandCommandSelectorTest to use GameStateConverter.fromProto()
- Update DEPROTO_PLAN.md to reflect progress

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 07:58:21 -08:00
ded08187b2 Fix Shardok cross-compilation in Docker workflow (#4808)
* Fix Shardok cross-compilation in Docker workflow

The push step was rebuilding without --platforms flag, overwriting
the cross-compiled Linux binary with a macOS ARM64 binary.

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

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

* Update nginx config to use prod.eagle0.net

🤖 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-24 22:27:23 -08:00
8747682842 Convert quest command choosers to use native GameState (#4805)
* Convert quest command choosers to use native GameState

- Changed QuestCommandChooser trait to accept native GameState
- Updated FulfillQuestsCommandSelector to convert proto once at top level
- Updated all quest command choosers to use native GameState:
  - AllianceQuestCommandChooser
  - AlmsAcrossRealmQuestCommandChooser
  - AlmsToProvinceQuestCommandChooser
  - DismissSpecificVassalCommandChooser
  - GiveToHeroesAcrossRealmQuestCommandChooser
  - GiveToHeroesInProvinceQuestCommandChooser
  - ImproveQuestCommandChooser
  - TruceCountQuestCommandChooser
  - TruceWithFactionQuestCommandChooser
- Updated helper command selectors to use native types:
  - TrustForDiplomacy
  - TruceOfferCommandSelector
  - AllianceOfferCommandSelector
  - ExileVassalCommandSelector
  - HeroGiftCommandSelector
- Added trust() method to FactionUtils for native faction access
- Updated all test files to use native types (HeroC, FactionC, ProvinceC)

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

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

* Fix callers of TrustForDiplomacy and HeroGiftCommandSelector

Add GameStateConverter.fromProto() calls in:
- InvitationCommandSelector.maybeInviteOtherFaction
- SeekMoreLeadersCommandChooser.maybeSeekMoreLeadersCommand

These callers pass proto GameState but the underlying methods now
expect native GameState.

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

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

* Fix tests: add currentPhase to proto GameState in test files

Tests were failing because GameStateConverter.fromProto() can't
convert UNKNOWN_PHASE (the proto default). Added PLAYER_COMMANDS
to test GameState instances in:
- InvitationCommandSelectorTest
- SeekMoreLeadersCommandChooserTest

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

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

* Add round_phase_scala_proto dependency to test BUILD files

The tests import PLAYER_COMMANDS from round_phase proto but the
BUILD files were missing the dependency.

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

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

* Trigger CI

* Fix OutgoingOfferRound field order in FactionConverter

The positional pattern matching in both toProto and fromProto for
OutgoingOfferRound had the fields in the wrong order (roundId, toFactionId)
when the correct order is (toFactionId, roundId). This caused values to
be swapped when converting between proto and native types, breaking
the "recently invited" check in TrustForDiplomacy.

🤖 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-24 22:10:54 -08:00
ac5fa9a495 Replace URL text field with environment dropdown (#4798)
Change connection UI from editable URL field to dropdown with options:
- prod. (prod.eagle0.net)
- qa. (qa.eagle0.net)
- (none) (eagle0.net)

Selection is stored in PlayerPrefs. Unity scene update required to wire
up the new environmentDropdown field.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 21:41:25 -08:00
d65e486f36 Fix Shardok to default to localhost for local development (#4807)
The previous change defaulted to 0.0.0.0:40042 which broke local
development - Shardok would bind to the network IP instead of localhost,
causing Eagle to fail to connect.

- Default to localhost:40042 for local development
- Docker sets SHARDOK_EAGLE_INTERFACE_ADDRESS=0.0.0.0:40042 to listen
  on all interfaces for container networking

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 21:40:41 -08:00
9b719cafa8 Add busybox to container images for health checks (#4806)
The Docker health checks use `nc -z` to verify services are listening,
but the minimal base images (eclipse-temurin, ubuntu:24.04) don't
include netcat. This adds a statically-linked busybox binary to both
container images, providing nc and other basic utilities.

- Add http_file rule to download busybox 1.35.0 x86_64
- Create busybox_layer with symlink from nc -> busybox
- Include busybox_layer in both eagle and shardok images

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 16:35:52 -08:00
87218d7a72 Convert availability factories to use protoless BattalionSuitability (#4804)
- Create SuitableBattalionsConverter to convert from Scala to proto types
- Update AvailableDefendCommandsFactory to use BattalionSuitability + converter
- Update AvailableMarchCommandFactory to use BattalionSuitability + converter
- Add visibility for proto_converters to access battalion_suitability

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 15:52:24 -08:00
f7a0d562fe Create protoless BattalionSuitability, rename old to Legacy (#4803)
- Move BattalionSuitability to battalion_suitability/ package as LegacyBattalionSuitability
- Create new protoless BattalionSuitability with Scala types:
  - SuitabilityLevel enum (Optimal, Suboptimal, Restrictive)
  - BattalionIdWithSuitability case class
  - SuitableBattalions case class
- Convert CombatUnitSelector to use protoless version
- Keep AvailableDefendCommandsFactory and AvailableMarchCommandFactory on Legacy
  (they return proto types to the API)
- Add missing exports for Gender and BattalionType in BUILD files

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 15:23:57 -08:00
01ffbb8234 Add auth volume mount for nginx basic authentication (#4802)
Mount ./auth directory containing htpasswd file for nginx basic
authentication. This enables user authentication at the nginx
reverse proxy level.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 15:11:42 -08:00
aa4f58c0c4 Remove proto overloads from AlmsCommandSelector (#4800)
Updated callers to convert proto GameState to native Scala types before
calling AlmsCommandSelector:
- CommandChoiceHelpers: uses GameStateConverter.fromProto
- AlmsToProvinceQuestCommandChooser: uses GameStateConverter.fromProto
- AlmsAcrossRealmQuestCommandChooser: uses both GameStateConverter and
  ProvinceConverter

Removed proto compatibility overloads and unused imports from
AlmsCommandSelector since all callers now use native types directly.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:32:43 -08:00
61c2d92b01 Support API keys from environment variables (#4801)
Check OPENAI_API_KEY and ANTHROPIC_API_KEY environment variables first,
fall back to api_keys.txt file if not set. This allows the Docker
container to receive API keys via environment without needing a file.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:29:33 -08:00
08dedebf7d Fix crane binary discovery in CI workflow (#4799)
* Fix crane binary discovery in CI workflow

- Add set -ex for better error visibility
- Try finding crane in bazel-bin/external first
- Fall back to bazel cquery if not found
- Add debug output for digest and crane path

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

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

* Fix crane path - use runfiles directory

Crane binary is in the runfiles directory after bazel run:
bazel-bin/ci/push_*.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane

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

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

* Use crane push directly instead of bazel run

The registry converts OCI to Docker format immediately, changing the
digest. We can't reference the image by its original OCI digest after
push.

Solution: Use crane push directly to a tag (not by digest). Bazel is
still used to build the image and get crane in runfiles.

🤖 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-24 11:25:11 -08:00
c673f57179 Convert AttackCommandChooser and dependencies to use Scala types (#4794)
* Convert AttackCommandChooser and dependencies to use Scala types

- AttackCommandChooser: Use Scala GameState, HeroT, ProvinceT instead of proto
- CombatUnitSelector: Use Scala HeroT, BattalionT, BattalionType
- BattalionSuitability: Use Scala HeroT, BattalionT, BattalionType
- MarchSuppliesHelpers: Use Scala BattalionT (fixes pre-existing broken dep)
- CommandChoiceHelpers: Add GameStateConverter.fromProto() at call boundary
- AttackCommandChooserTest: Rewrite to use Scala types (GameState, HeroC, etc.)

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

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

* Fix type conversion for callers of CombatUnitSelector and BattalionSuitability

After deproto-ing CombatUnitSelector to use Scala types, all callers need
to convert proto types to Scala at call sites:

- AvailableDefendCommandsFactory: Add converter imports and calls
- AvailableMarchCommandFactory: Add converter imports and calls
- CommandChoiceHelpers: Add converter imports and calls in defendingUnits

BUILD file updates:
- Add converter deps to availability factories
- Add battalion/battalion_type state deps for return types
- Update visibility on battalion_type_converter

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

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

* Convert BattalionSuitabilityTest to use Scala types

BattalionSuitability now expects Scala types (HeroT, BattalionT, BattalionType),
so its test needs to:
- Use HeroC and BattalionC instead of proto Hero and Battalion
- Use Scala Profession enum
- Convert BattalionTypesTestData (proto) via BattalionTypeConverter.fromProto

Keep BattalionTypesTestData returning proto types since other tests
(like AttackDecisionCommandChooserTest) pass it to proto GameState.

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

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

* Fix MidGameAIClient to convert proto GameState when calling AttackCommandChooser

AttackCommandChooser now expects Scala GameState, so MidGameAIClient needs
to convert via GameStateConverter.fromProto() at the call sites.

🤖 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-24 11:19:27 -08:00
29ec88c6e2 Deproto-ify AlmsCommandSelector and test (#4792)
- Convert AlmsCommandSelector from proto types (GameState, Hero, Province) to Scala model types (GameState, HeroT, ProvinceT)
- Add backward-compatible proto type overloads that convert to Scala types internally
- Add Scala type overloads to FoodConsumptionUtils for foodConsumptionMonthsToHold
- Add fatigue() method to HeroUtils for Scala types
- Convert AlmsCommandSelectorTest to use Scala model types (ProvinceC, HeroC, BattalionC)
- Update BUILD.bazel dependencies, exports, and visibility for proper type resolution

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:12:55 -08:00
d50de9da3f Fix Docker push digest mismatch in CI (#4797)
DigitalOcean registry converts OCI manifests to Docker format, causing
digest mismatch when Bazel's oci_push tries to tag by digest.

Solution:
- Remove remote_tags from oci_push in BUILD.bazel (push by digest only)
- Use Bazel for the push, then crane copy/tag for tagging (handles format conversion)

This keeps Bazel as the primary build/push tool while working around
the registry's format conversion.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 11:05:41 -08:00
9340500b22 Update AlmsCommandSelector to use new FoodConsumptionUtils (#4796)
* Update AlmsCommandSelector to use new FoodConsumptionUtils

- Replace LegacyFoodConsumptionUtils with FoodConsumptionUtils
- Add private foodConsumptionMonthsToHold helper that converts proto
  types to Scala types using DateConverter and RoundPhaseConverter
- Update visibility of DateConverter and RoundPhaseConverter to allow
  access from command_choice_helpers
- Update tests to set currentPhase = PLAYER_COMMANDS (required by
  RoundPhaseConverter)

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

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

* Fix quest command chooser tests to set currentPhase

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

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

* Add parameter names to foodConsumptionMonthsToHold call

🤖 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-24 10:51:59 -08:00
1433f3d29c Fix Docker deployment connectivity issues (#4795)
* Fix Docker deployment connectivity issues

- Shardok: Listen on 0.0.0.0:40042 instead of localhost for container networking
- Shardok: Add env var fallback for resource paths (SHARDOK_RESOURCES_PATH, SHARDOK_MAPS_PATH)
- Eagle: Use plaintext gRPC for internal container-to-container communication
- docker-compose: Pass CLI args directly instead of env vars, default to gpt-5.1

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

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

* Document Docker networking and resource configuration

Added section explaining:
- Why Shardok binds to 0.0.0.0 instead of localhost for container networking
- Why Eagle uses .usePlaintext() for internal gRPC
- How env var fallbacks replace Bazel runfiles in Docker
- Future considerations for multi-host deployment

🤖 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-24 10:41:53 -08:00
331702bf3c Rename FoodConsumptionUtils to LegacyFoodConsumptionUtils, create new Scala-types version (#4793)
* Rename FoodConsumptionUtils to LegacyFoodConsumptionUtils, create new Scala-types version

- Renamed proto-based FoodConsumptionUtils to LegacyFoodConsumptionUtils
- Created new FoodConsumptionUtils that uses native Scala model types (Date, ProvinceT, GameState, RoundPhase) instead of proto types
- Updated all callers (AlmsCommandSelector, CommandChoiceHelpers, ExpandCommandSelector, MidGameAIClient) to use LegacyFoodConsumptionUtils
- Renamed test to LegacyFoodConsumptionUtilsTest
- Updated BUILD.bazel files with new targets

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

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

* Move FoodConsumptionUtils to dedicated food_consumption package

- Move FoodConsumptionUtils.scala and LegacyFoodConsumptionUtils.scala
  from command_choice_helpers to new food_consumption directory
- Add FoodConsumptionUtilsTest using pure Scala types instead of protos
- Update all dependent BUILD.bazel files with new import paths
- Update importing Scala files to use new package location

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

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

* Rename food_consumption_utils target to food_consumption

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

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

* Fix missing dependency and visibility for food_consumption package

- Add legacy_food_consumption_utils dependency to command_choice_helpers
- Fix visibility to use __pkg__ instead of __subpackages__

🤖 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-24 10:13:48 -08:00
adminandGitHub fbb04270a5 Add production deployment pipeline (#4790)
* Next steps for productionization

* Run deploy job on self-hosted runner for secure SSH

* Temporarily disable production environment to debug runner

* Use ubuntu-latest for deploy job

* Add remote_tags to oci_push for latest tag
2025-12-24 06:42:26 -08:00
b8599d9088 Fix Shardok push: don't use --platforms for push script (#4787)
The push script runs on the host (macOS) and needs native tools like jq.
Using --platforms=//:linux_x86_64 caused it to try running Linux binaries.

The image is already built for Linux; the push just uploads it.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:57:30 -08:00
d7e29eeb89 Client displays NewFactionHead notification with headshots (#4789)
Adds NewFactionHeadDetailsNotificationGenerator to display a notification
when a faction's leader changes. Shows both the new and previous leader
headshots, with appropriate text for player vs other factions.

Features:
- 10 randomized notification titles (New Leadership, Succession, etc.)
- Shows province where new leader is located (if player's faction)
- Includes previous leader name in text when available
- References LLM-generated text for full narrative

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:56:53 -08:00
6f6c205f11 Add NewFactionHead notification when faction leader changes (#4788)
The NewFactionHeadMessage LLM request was added in #4785 but without a
corresponding notification. This adds the notification type so clients
can display the event.

Changes:
- Add NewFactionHeadDetails proto message with new_head_hero_id,
  faction_id, and previous_head_hero_id fields
- Add NewFactionHead case class to NotificationDetails
- Add toProto/fromProto converters in NotificationConverter
- Create notification alongside LLM request in CheckForFactionChangesAction

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:52:11 -08:00
adminandClaude Opus 4.5 15e5d82504 Update sysroot to v3 (built from main)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 13:10:21 -08:00
b7ff76a38e Fix sysroot .so plugins and registry auth (#4784)
* Fix sysroot .so plugins and registry auth

- Exclude GCC plugin .so files from sysroot (Bazel can't handle them)
- Only copy GCC headers and static libs needed for cross-compilation
- Add tarball structure verification to build script
- Fix DO registry auth by setting DOCKER_CONFIG env var for Bazel

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

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

* Add triple symlinks to sysroot for clang compatibility

Clang uses --target=x86_64-unknown-linux-gnu but Ubuntu's GCC uses
x86_64-linux-gnu (without "unknown"). This caused clang to fail to detect
the GCC installation and couldn't find C++ standard library headers.

Add symlinks in the sysroot:
- /usr/lib/gcc/x86_64-unknown-linux-gnu -> x86_64-linux-gnu
- /lib/x86_64-unknown-linux-gnu -> x86_64-linux-gnu
- /include/x86_64-unknown-linux-gnu -> x86_64-linux-gnu

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

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

* Fix sysroot tarball structure for toolchains_llvm

The tarball was wrapping contents in sysroot/ which caused double nesting
when extracted by the sysroot() repo rule. Changed to tar from inside the
sysroot directory so usr/, lib/, etc. are at the root of the archive.

Before: sysroot/usr/...
After: ./usr/... (or usr/...)

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

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

* Add libgcc_s.so symlink to sysroot

The linker looks for libgcc_s.so but Ubuntu only provides libgcc_s.so.1.
Added a symlink to make -lgcc_s work.

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

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

* Fix sysroot for cross-compilation

1. Add ld-linux-x86-64.so.2 symlink in lib/ since libc.so linker script
   references /lib64/ld-linux-x86-64.so.2 as an absolute path

2. Change linkopt to host_linkopt in .bazelrc to avoid passing macOS-specific
   linker flags (-no_warn_duplicate_libraries) to Linux cross-compilation

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

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

* Update sysroot to v3.4 with all required symlinks

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

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

* Add missing includes for cross-compilation portability

These files relied on platform-specific transitive includes that work on
macOS but not on Linux. Added explicit includes for:
- <cstdint> for uint8_t, uint64_t
- <stdexcept> for std::out_of_range
- <cinttypes> for PRIu64 portable format specifier

🤖 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-23 13:02:35 -08:00
e0d5a9f2ec Convert ChronicleEventGenerator to return Scala ChronicleEvent types (#4786)
- Change return type from Vector[EventForChronicle] (proto) to Vector[ChronicleEvent] (Scala)
- Remove intermediate EventForChronicleDetails layer - create Scala types directly
- Update all 23 event type mappings to use Scala *ChronicleEvent types
- Add DateConverter.fromProto() to convert proto dates to Scala dates
- Update NewRoundAction to use Scala types directly (remove ChronicleEventConverter)
- Update DEPROTO_PLAN.md: now 47/52 (90%) action files are fully protoless

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 12:12:54 -08:00
bf02de3190 Add LLM notification when new faction head is declared (#4785)
* Add LLM notification when new faction head is declared

When a hero becomes the new head of a faction, generate an LLM message
where they declare their new leadership to the world. If the faction
is also being renamed (because the new head has a ledFactionName),
the declaration includes explanation of the new faction name.

Changes:
- Add NewFactionHeadMessage proto and Scala case to LlmRequestT
- Add NewFactionHeadPromptGenerator for generating the LLM prompt
- Update CheckForFactionChangesAction to create LLM requests
- Update LlmRequestConverter to handle the new message type
- Update LlmResolver to use the new prompt generator

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

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

* Include previous faction head's execution in LLM notification

The notification now mentions that the previous faction head was executed
and includes their name, providing context for the new leader's declaration.

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

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

* Include previous faction head's description in LLM prompt

Add the executed leader's full description (including backstory) to the
prompt, allowing the LLM to potentially reference their history when
generating the new leader's declaration message.

🤖 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-23 09:06:00 -08:00
cbc14e5131 Implement faction renaming when new head has ledFactionName (#4783)
When a faction head changes (e.g., due to leader death), if the new head
hero has a ledFactionName set, the faction is automatically renamed to
that name. This enables "great person" heroes to rename factions they lead.

Changes:
- Add new_name field to ChangedFaction proto and ChangedFactionC
- Add new_name field to FactionViewDiff for client updates
- Update ChangedFactionConverter for new field
- Update GameStateFactionExtensions applier to apply name changes
- Update GameStateViewDiffer to diff faction names
- Update CheckForFactionChangesAction to set newName from hero's ledFactionName

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:39:57 -08:00
9974f32ebb Update sysroot sha256 for v2 with GCC directories (#4782)
The v2 sysroot includes GCC installation directories that clang needs
to find libstdc++ headers for cross-compilation.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:38:28 -08:00
ee6189a9d7 Fix AWS CLI and use correct bucket for sysroot upload (#4780)
- Skip AWS CLI install if already present
- Use eagle0-windows bucket (same as other workflows, credentials have access)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:30:55 -08:00
e0304125b8 Add ledFactionName field to Hero for faction renaming (#4781)
Add a new field to the Hero proto and Scala models to store the name of the
faction that a hero would lead, if they became a faction leader. This is
populated from the "faction_name" column in the heroes TSV for "great person"
heroes, and is empty (or None in Scala) for other heroes.

This will be used in the future to rename factions when a great person
becomes the leader of a different faction.

Changes:
- Add led_faction_name (string) to Hero proto
- Add ledFactionName: Option[String] to HeroT trait and HeroC case class
- Add ledFactionName to LoadedHero intermediate type
- Update HeroConverter, LoadedHeroConversion, and FixedHeroes to handle the new field
- Update FixedHeroesTest to include the new field

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:23:04 -08:00
11eac42e30 Fix sysroot workflow secret names (#4779)
Use existing ACCESS_KEY_ID and SECRET_KEY secrets instead of
non-existent DO_SPACES_KEY and DO_SPACES_SECRET.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 08:12:19 -08:00
224 changed files with 10847 additions and 5279 deletions
+2 -2
View File
@@ -26,8 +26,8 @@ common --host_cxxopt="--std=c++23"
common --javacopt="-Xlint:-options"
# suppress warnings due to https://developer.apple.com/forums/thread/733317
common --linkopt=-Wl
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
+12 -9
View File
@@ -30,34 +30,37 @@ jobs:
- name: Install AWS CLI
run: |
sudo apt-get update
sudo apt-get install -y awscli
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.DO_SPACES_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DO_SPACES_SECRET }}
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces
# Upload sysroot tarball to DO Spaces (using eagle0-windows bucket, same as other workflows)
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== Sysroot uploaded ==="
echo "URL: https://eagle0.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
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 ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
echo " urls = [\"https://eagle0.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
+389 -5
View File
@@ -5,6 +5,7 @@ on:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- 'src/main/resources/**'
@@ -25,6 +26,8 @@ permissions:
jobs:
build-eagle:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -32,7 +35,15 @@ jobs:
lfs: false
- name: Build Eagle Docker image
run: bazel build //ci:eagle_server_image
id: build-eagle
run: |
set -ex
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
@@ -42,21 +53,168 @@ jobs:
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Eagle image to DO registry
id: push-eagle
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
run: bazel run //ci:eagle_server_push
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
echo "Using Eagle image: $EAGLE_IMAGE"
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
exit 1
fi
# Debug: show OCI layout contents
echo "=== OCI Layout Contents ==="
cat "$EAGLE_IMAGE/index.json"
echo ""
echo "=== Blobs ==="
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
# Verify OCI layout consistency before pushing
echo "=== Verifying OCI layout consistency ==="
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
if [ ! -f "$blob_path" ]; then
echo "ERROR: Blob not found: $blob_path"
exit 1
fi
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
if [ "$digest" != "$actual_digest" ]; then
echo "ERROR: Digest mismatch for $blob_path"
echo " Index says: $digest"
echo " Actual: $actual_digest"
exit 1
fi
echo "✓ Verified: $digest"
done
# Build the push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Use crane directly for push (avoids OCI->Docker digest mismatch)
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing eagle image: $IMAGE_TAG"
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
# Verify push by checking what's in the registry
echo "=== Verifying push ==="
$CRANE manifest "$IMAGE_TAG" | head -50
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
echo "Registry reports digest: $PUSHED_DIGEST"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
build-shardok:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok Docker image (cross-compile for Linux)
run: bazel build --platforms=//:linux_x86_64 //ci:shardok_server_image
- name: Build Shardok binary (cross-compile for Linux)
run: |
set -ex
# Step 1: Build JUST the binary with cross-compilation
# We need --extra_toolchains to force the Linux toolchain to be used
# because toolchains_llvm registers with dev_dependency=True
echo "=== Building shardok-server binary for linux-x86_64 ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
# Step 2: Check the binary directly from bazel-bin
# bazel-bin is a symlink that points to the correct output directory
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
echo "=== Checking binary at: $LINUX_BIN ==="
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 1
fi
# Debug: show what bazel-bin points to
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
echo "=== Verifying binary format ==="
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
echo "Binary magic bytes: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
echo ""
echo "Debug info:"
echo "- bazel-bin points to: $(readlink bazel-bin)"
file "$LINUX_BIN" || true
exit 1
else
echo "WARNING: Unknown binary format: $MAGIC"
file "$LINUX_BIN" || true
fi
- name: Build Shardok Docker image
id: build-shardok
run: |
set -ex
# Build the OCI image with cross-compilation flags
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:shardok_server_image
# The image is output to bazel-bin which is a symlink.
# Resolve it now before any other bazel commands change where it points.
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Verify the binary inside the tar layer is ELF
echo "=== Verifying binary in image tar ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
if [ -f "$BINARY_TAR" ]; then
echo "Checking binary in $BINARY_TAR"
# Extract just the first 4 bytes of the binary from the tar
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
echo "Binary magic in tar: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary in tar is ELF format (Linux)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
echo "This means pkg_tar is packaging the wrong binary."
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
@@ -66,7 +224,233 @@ jobs:
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Shardok image to DO registry
id: push-shardok
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
run: bazel run --platforms=//:linux_x86_64 //ci:shardok_server_push
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
echo "Using cross-compiled image: $CROSS_IMAGE"
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
exit 1
fi
# Get crane from Eagle push target (which doesn't need cross-compilation)
# This gives us a macOS crane binary we can actually run.
# We can't build shardok_server_push with platform flags because it would
# download a Linux crane that can't run on macOS.
bazel build //ci:eagle_server_push
# Find the Darwin crane binary (may be a symlink)
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
# Fallback to any crane
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found. Listing runfiles:"
find "$RUNFILES" -name crane 2>/dev/null || true
exit 1
fi
echo "Using crane: $CRANE"
# Push the cross-compiled image with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing shardok image: $IMAGE_TAG"
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
build-admin:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-admin.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Admin Server Docker image
id: build-admin
run: |
set -ex
# Build admin server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:admin_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Admin image to DO registry
id: push-admin
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
echo "Using Admin image: $ADMIN_IMAGE"
if [ -z "$ADMIN_IMAGE" ] || [ ! -d "$ADMIN_IMAGE" ]; then
echo "ERROR: Admin image not found at: $ADMIN_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:admin_server_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_admin_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing admin image: $IMAGE_TAG"
$CRANE push "$ADMIN_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
deploy:
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok, build-admin]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
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 }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy config files to droplet
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "docker-compose.prod.yml,nginx/nginx.conf"
target: "/opt/eagle0"
- name: Deploy to production droplet
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY
script: |
set -x
cd /opt/eagle0
# Write env vars to .env file for docker-compose
rm -f .env 2>/dev/null || true
cat > .env << EOF
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:-}
EOF
chmod 600 .env
# Login to registry
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)
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Admin image with crane..."
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
rm ./crane
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
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
# 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
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Cleanup old images
docker image prune -f
+90
View File
@@ -0,0 +1,90 @@
# Deproto Migration Plan
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
## Architectural Decisions
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
## Migration Status
### Fully Protoless (no proto imports)
**Utilities:**
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
**Command Selectors (all use native GameState):**
- [x] `AllianceOfferCommandSelector`
- [x] `AlmsCommandSelector`
- [x] `AttackCommandChooser`
- [x] `ExpandCommandSelector`
- [x] `HeroGiftCommandSelector`
- [x] `ImproveCommandSelector`
- [x] `OrganizeCommandSelector`
- [x] `RansomOfferHelpers`
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
- [x] `TruceOfferCommandSelector`
- [x] `TrustForDiplomacy`
**Quest Command Selectors (all protoless):**
- [x] `AllianceQuestCommandChooser`
- [x] `AlmsAcrossRealmQuestCommandChooser`
- [x] `AlmsToProvinceQuestCommandChooser`
- [x] `DismissSpecificVassalCommandChooser`
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
- [x] `ImproveQuestCommandChooser`
- [x] `QuestCommandChooser`
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Dual (both proto and protoless versions)
- [~] `ProvinceGoldSurplusCalculator` - protoless `provinceGoldSurplus(province: ProvinceT)` + legacy `provinceGoldSurplus(provinceId, gameState)`
- [~] `HeroSelector` - protoless `minimallyFatiguedHeroes` + legacy `minimallyFatiguedHeroesProto`
### Blocked (still uses proto GameState)
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
- Only 1 call to `minimallyFatiguedHeroesProto` (HeroSelector)
- Many calls to proto `provinceGoldSurplus`
- Depends on many Legacy* utils
- [ ] `AttackDecisionCommandChooser` - uses proto types
- [ ] `CommandChooser` - uses proto GameState
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
- Called by `MidGameAIClient` which uses proto GameState
## Key Files
### Protoless Model Types
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
### Proto Converters
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) currently uses proto types extensively
- `PerformUnaffiliatedHeroesAction` already uses protoless `GameState`
- Migration should proceed incrementally: utilities first, then higher-level selectors/choosers
+26 -5
View File
@@ -72,9 +72,8 @@ use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
sysroot(
name = "linux_sysroot",
# TODO: Update sha256 after running sysroot build workflow with version v2
sha256 = "PLACEHOLDER_RUN_SYSROOT_WORKFLOW_FIRST",
urls = ["https://eagle0.sfo3.digitaloceanspaces.com/sysroot/v2/ubuntu_noble_amd64_sysroot.tar.xz"],
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
)
#
@@ -146,7 +145,15 @@ oci.pull(
platforms = ["linux/amd64"],
tag = "24.04",
)
use_repo(oci, "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
# Base image for Admin Server (Alpine for lightweight Go binary)
oci.pull(
name = "alpine_linux",
image = "docker.io/library/alpine",
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")
#
# Java/Scala Dependencies
@@ -206,9 +213,12 @@ maven.install(
"org.reactivestreams:reactive-streams:1.0.4",
"javax.xml.bind:jaxb-api:2.3.1",
# OkHttp (for SSE with read timeout support)
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
"com.squareup.okhttp3:okhttp:4.12.0",
"com.squareup.okhttp3:okhttp-sse:4.12.0",
# JWT (for OAuth token handling)
"com.nimbusds:nimbus-jose-jwt:9.37.3",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -224,6 +234,7 @@ use_repo(maven, "maven", "unpinned_maven")
#
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
# GTL (for parallel_hashmap)
GTL_VERSION = "1.2.0"
@@ -252,6 +263,16 @@ http_archive(
],
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# https://busybox.net/downloads/binaries/
http_file(
name = "busybox_x86_64",
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
downloaded_file_path = "busybox",
executable = True,
)
#
# Toolchain Registration
#
+35 -2
View File
@@ -1293,7 +1293,7 @@
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "BuciKSozbpJMD9EP+j0RG5ZgrYMeDPsQyiOnLUni2V8=",
"usagesDigest": "KXZUVR9ea29hTmhxC4+BG0pTXTijLpsFrDVraHG4OyU=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1360,6 +1360,37 @@
"reproducible": true
}
},
"alpine_linux_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/alpine",
"identifier": "3.21",
"platform": "linux/amd64",
"target_name": "alpine_linux_linux_amd64",
"bazel_tags": []
}
},
"alpine_linux": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "alpine_linux",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/alpine",
"identifier": "3.21",
"platforms": {
"@@platforms//cpu:x86_64": "@alpine_linux_linux_amd64"
},
"bzlmod_repository": "alpine_linux",
"reproducible": true
}
},
"oci_crane_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
@@ -1496,7 +1527,9 @@
"eclipse_temurin_17",
"eclipse_temurin_17_linux_amd64",
"ubuntu_24_04",
"ubuntu_24_04_linux_amd64"
"ubuntu_24_04_linux_amd64",
"alpine_linux",
"alpine_linux_linux_amd64"
],
"explicitRootModuleDirectDevDeps": [],
"useAllRepos": "NO",
+65
View File
@@ -1,6 +1,22 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
#
# Shared utilities layer (busybox for nc, wget, etc.)
#
pkg_tar(
name = "busybox_layer",
srcs = ["@busybox_x86_64//file"],
package_dir = "/usr/local/bin",
remap_paths = {
"file/busybox": "busybox",
},
symlinks = {
"/usr/local/bin/nc": "busybox",
},
)
#
# Eagle Server Docker Image
#
@@ -45,6 +61,7 @@ oci_image(
},
exposed_ports = ["40032/tcp"],
tars = [
":busybox_layer",
":eagle_server_jar_layer",
":eagle_resources_layer",
],
@@ -59,6 +76,9 @@ oci_load(
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "eagle_server_push",
image = ":eagle_server_image",
@@ -106,6 +126,7 @@ oci_image(
"40052/tcp",
],
tars = [
":busybox_layer",
":shardok_binary_layer",
":shardok_resources_layer",
":shardok_maps_layer",
@@ -121,8 +142,52 @@ oci_load(
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "shardok_server_push",
image = ":shardok_server_image",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
#
# Admin Server Docker Image (Go)
#
# Build: bazel build //ci:admin_server_image
# Load: bazel run //ci:admin_server_load
# Push: bazel run //ci:admin_server_push
#
# Package the Go admin binary (explicit Linux x86_64 target)
pkg_tar(
name = "admin_binary_layer",
srcs = ["//src/main/go/net/eagle0/admin_server:admin_server_linux_amd64"],
package_dir = "/app",
)
oci_image(
name = "admin_server_image",
base = "@alpine_linux_linux_amd64",
entrypoint = ["/app/admin_server_linux_amd64"],
exposed_ports = ["8080/tcp"],
tars = [
":busybox_layer",
":admin_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:admin_server_load
oci_load(
name = "admin_server_load",
image = ":admin_server_image",
repo_tags = ["eagle0/admin-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "admin_server_push",
image = ":admin_server_image",
repository = "registry.digitalocean.com/eagle0/admin-server",
)
+86 -15
View File
@@ -1,47 +1,118 @@
# Docker Compose for local testing of production images
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
# Run: docker compose -f docker-compose.prod.yml up
# Docker Compose for production deployment
#
# Local testing:
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
# Run: docker compose -f docker-compose.prod.yml up -d
services:
eagle:
image: eagle0/eagle-server:latest
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-server
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "shardok:40042"
ports:
- "40032:40032"
environment:
# Eagle server configuration
EAGLE_GRPC_PORT: "40032"
SHARDOK_HOST: "shardok"
SHARDOK_PORT: "40042"
# Resource paths (relative to /app in container)
EAGLE_RESOURCES_PATH: "/app/resources"
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:-}"
volumes:
# Mount saves directory for persistence
- ./saves:/app/saves
depends_on:
- shardok
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "40032"]
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
shardok:
image: eagle0/shardok-server:latest
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
container_name: shardok-server
ports:
- "40042:40042"
- "40052:40052"
environment:
# Shardok server configuration
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "40042"]
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
nginx:
image: nginx:alpine
container_name: nginx
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
admin:
image: ${ADMIN_IMAGE:-registry.digitalocean.com/eagle0/admin-server:latest}
container_name: admin-server
command:
- "--eagle-addr"
- "eagle:40032"
- "--http-port"
- "8080"
ports:
- "8080:8080"
depends_on:
- eagle
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
+39 -7
View File
@@ -197,9 +197,9 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
### Remaining Proto Usage in Actions
**Progress: 46 of 52 action files (88%) are fully protoless.**
**Progress: 47 of 52 action files (90%) are fully protoless.**
The following 6 actions still have proto usage:
The following 5 actions still have proto usage:
| Action | Proto Usages | Blocker | Effort |
|--------|--------------|---------|--------|
@@ -208,7 +208,8 @@ The following 6 actions still have proto usage:
| `EndHandleRiotsPhaseAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `PerformVassalDefenseDecisionsAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndVassalCommandsPhaseAction` | 1 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `NewRoundAction` | 1 | `ChronicleEventGenerator` returns proto | Medium |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
@@ -224,16 +225,47 @@ The following 6 actions still have proto usage:
|-----------|-------|------------|--------|
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
| `ChronicleEventGenerator` to Scala | ~400 | Medium | 1 action |
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~3000** | | |
| **Total Remaining** | **~2600** | | |
**Completed:**
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
| File | Status | Notes |
|------|--------|-------|
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ❌ Proto | Main entry point, converts to Scala when calling converted selectors |
| `ProvinceGoldSurplusCalculator.scala` | **Partial** | Has both Scala and proto overloads |
| Other selectors | ❌ Proto | Various proto dependencies |
**Pattern**: `CommandChoiceHelpers` currently uses `GameStateConverter.fromProto(gameState)` when calling already-converted selectors like `AlmsCommandSelector` and `AttackCommandChooser`. This allows incremental migration.
**Next Steps**:
1. ~~Convert `ExpandCommandSelector` to Scala types~~ ✅ Done
2. ~~Convert `ImproveCommandSelector` to Scala types~~ ✅ Done
3. ~~Convert `OrganizeCommandSelector` to Scala types~~ ✅ Done (PR #4812)
4. ~~Convert `RansomOfferHelpers` to Scala types~~ ✅ Done (PR #4821)
5. Convert remaining selectors one at a time
6. Update `CommandChoiceHelpers` to accept Scala `GameState` once all selectors are converted
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 46 / 52 (88%) |
| Proto usages in remaining actions | 33 total |
| Action files fully protoless | 47 / 52 (90%) |
| Proto usages in remaining actions | 32 total |
| Biggest blocker | `ResolveBattleAction` (24 usages) |
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
+189
View File
@@ -0,0 +1,189 @@
# Discord + Google OAuth Implementation Plan
## Overview
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
## Architecture
```
Unity Client Eagle Server
| |
| 1. Click "Login with Discord/Google" |
| -------------------------------------------------> |
| GetOAuthUrl(provider) -> auth_url + state |
| |
| 2. Open system browser -> OAuth consent |
| 3. User authenticates with provider |
| 4. Redirect to eagle0://auth/callback?code=xxx |
| |
| 5. ExchangeCode(code, state) |
| -------------------------------------------------> |
| Exchange code with provider |
| Fetch user info (id, email, avatar) |
| Create/update user record |
| Issue JWT + refresh token |
| <------------------------------------------------- |
| (jwt, refresh_token, user_info, is_new_user) |
| |
| 6. [If new user] SetDisplayName(name) |
| -------------------------------------------------> |
| |
| 7. Subsequent gRPC calls |
| Authorization: Bearer <jwt> |
| -------------------------------------------------> |
```
## Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| OAuth flow | System browser + deep link | Secure, supports password managers |
| Code exchange | Eagle server directly | No separate auth service needed |
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
| User storage | Protobuf file via Persister | Consistent with existing patterns |
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
## Implementation Phases
### Phase 1: Proto Definitions & Infrastructure
**New files:**
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
**Key proto messages:**
```protobuf
// API
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
ExchangeCodeRequest/Response // Exchange auth code for JWT
SetDisplayNameRequest/Response // Set user's display name
RefreshTokenRequest/Response // Refresh expired access token
// Internal storage
User // user_id, display_name, oauth_identities
UserDatabase // All users + indexes for lookup
```
### Phase 2: Eagle Server Auth Services
**New Scala files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
**Modify:**
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
- Replace Basic Auth parsing with JWT validation
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
- Change context keys from `userName` to `userId` + `displayName`
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
- Wire up new auth services and JWT key loading
### Phase 3: Unity Client OAuth Flow
**New C# files:**
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
**Modify:**
- `Assets/EagleConnection.cs`
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
- `Assets/ConnectionHandler/ConnectionHandler.cs`
- Replace username/password UI with Discord/Google login buttons
- Add display name setup flow for new users
### Phase 4: Platform Configuration
**Deep link registration:**
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
- Desktop: Register URL scheme (Windows registry / macOS plist)
**OAuth provider setup:**
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
2. Google Cloud Console: Create OAuth client, add redirect URI
**Environment variables (server):**
```
DISCORD_CLIENT_ID
DISCORD_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
JWT_PRIVATE_KEY_PATH
JWT_PUBLIC_KEY_PATH
```
### Phase 5: Testing
**Unit tests:**
- `JwtServiceSpec.scala` - Token creation/validation
- `UserServiceSpec.scala` - Display name validation, uniqueness
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
**Integration tests:**
- Full OAuth flow with mock provider
- JWT validation in AuthorizationInterceptor
- gRPC calls with valid/invalid tokens
**Manual testing:**
- [ ] Discord login (Windows, macOS)
- [ ] Google login (Windows, macOS)
- [ ] Deep link callback works
- [ ] Display name validation
- [ ] Session persistence across restarts
- [ ] Token refresh
## Files Summary
### Create
| File | Purpose |
|------|---------|
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
| `Assets/Auth/TokenStorage.cs` | Token storage |
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
### Modify
| File | Changes |
|------|---------|
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
| `AuthorizationUtils.scala` | userName -> userId + displayName |
| `Main.scala` | Wire auth services |
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
### Delete
- nginx htpasswd configuration (no longer needed)
## Security Considerations
1. **State parameter** - CSRF protection in OAuth flow
2. **PKCE** - Consider adding for mobile (enhancement)
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
4. **Token refresh** - 7-day access tokens with 30-day refresh
5. **Rate limiting** - Limit login attempts per IP
## Dependencies to Add
**Scala (MODULE.bazel):**
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
- HTTP client (e.g., `sttp` for OAuth requests)
**Unity:**
- Deep linking is built-in (Unity 2021+)
- No additional packages required
## Rollback Plan
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
+62
View File
@@ -348,6 +348,68 @@ services:
---
## Docker Networking & Resource Configuration
### Container Network Binding
In Docker, each container has its own network namespace. When a server binds to `localhost` (127.0.0.1), it only accepts connections from within the same container. Other containers on the Docker bridge network cannot connect, even if they can resolve the hostname.
**Shardok must bind to `0.0.0.0:40042`** to accept connections from Eagle running in a separate container. This is configured in `ServerConfiguration.cpp`:
```cpp
// Default to 0.0.0.0 for container networking (accepts connections from any interface)
{ServerConfiguration::kEagleInterfaceGrpcAddress, "0.0.0.0:40042"}
```
This still works on the local Mac because `0.0.0.0` means "all interfaces", which includes the loopback interface that localhost uses.
### gRPC Plaintext for Internal Communication
By default, gRPC's `ManagedChannelBuilder` attempts TLS connections. Since Shardok runs in plaintext mode, Eagle must use `.usePlaintext()` for internal container-to-container communication:
```scala
// ServerSetupHelpers.scala
val channelBuilder = ManagedChannelBuilder
.forAddress(shardokInterfaceAddress, shardokInterfacePort)
.usePlaintext() // Required for internal Docker communication
```
TLS termination happens at nginx for external clients. Internal traffic between Eagle and Shardok stays within the Docker network and doesn't need encryption.
**Future consideration:** If Shardok moves to a separate droplet, traffic would traverse the network. Options:
1. Enable TLS between Eagle and Shardok (configure Shardok with certificates)
2. Use DigitalOcean VPC (private network, still unencrypted but isolated)
3. Use WireGuard/VPN tunnel between hosts
### Bazel Runfiles in Docker
Bazel's runfiles system locates resources relative to the executable using a manifest or directory structure that Bazel sets up at runtime. This doesn't exist in Docker containers.
**Solution:** Environment variable fallbacks in `FilesystemUtils.cpp`:
```cpp
// Check for Docker deployment env vars first
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) {
return string(resourcesPath) + "/";
}
// Fall back to Bazel runfiles for local development
return rLocation + "/src/main/resources/net/eagle0/shardok/";
```
The Docker Compose configuration sets these:
```yaml
environment:
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
```
This allows the same binary to work in both environments:
- **Local dev:** No env var → uses Bazel runfiles
- **Docker:** Env var set → uses `/app/resources/` directly
---
## Build Pipeline
### Build Artifacts
+54 -2
View File
@@ -1,7 +1,7 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
"__INPUT_ARTIFACTS_HASH": 289080209,
"__RESOLVED_ARTIFACTS_HASH": -131178107,
"__INPUT_ARTIFACTS_HASH": -1064460283,
"__RESOLVED_ARTIFACTS_HASH": -1574144850,
"conflict_resolution": {
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
@@ -48,6 +48,12 @@
},
"version": "2.12.7"
},
"com.github.stephenc.jcip:jcip-annotations": {
"shasums": {
"jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323"
},
"version": "1.0-1"
},
"com.google.android:annotations": {
"shasums": {
"jar": "ba734e1e84c09d615af6a09d33034b4f0442f8772dec120efb376d86a565ae15"
@@ -156,6 +162,12 @@
},
"version": "1.4.2"
},
"com.nimbusds:nimbus-jose-jwt": {
"shasums": {
"jar": "12ae4a3a260095d7aeba2adea7ae396e8b9570db8b7b409e09a824c219cc0444"
},
"version": "9.37.3"
},
"com.squareup.okhttp3:okhttp": {
"shasums": {
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
@@ -810,6 +822,9 @@
"org.checkerframework:checker-qual",
"org.ow2.asm:asm"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.github.stephenc.jcip:jcip-annotations"
],
"com.squareup.okhttp3:okhttp": [
"com.squareup.okio:okio",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
@@ -1355,6 +1370,9 @@
"com.fasterxml.jackson.databind.type",
"com.fasterxml.jackson.databind.util"
],
"com.github.stephenc.jcip:jcip-annotations": [
"net.jcip.annotations"
],
"com.google.android:annotations": [
"android.annotation"
],
@@ -1500,6 +1518,38 @@
"com.google.truth:truth": [
"com.google.common.truth"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.nimbusds.jose",
"com.nimbusds.jose.crypto",
"com.nimbusds.jose.crypto.bc",
"com.nimbusds.jose.crypto.factories",
"com.nimbusds.jose.crypto.impl",
"com.nimbusds.jose.crypto.opts",
"com.nimbusds.jose.crypto.utils",
"com.nimbusds.jose.jca",
"com.nimbusds.jose.jwk",
"com.nimbusds.jose.jwk.gen",
"com.nimbusds.jose.jwk.source",
"com.nimbusds.jose.mint",
"com.nimbusds.jose.proc",
"com.nimbusds.jose.produce",
"com.nimbusds.jose.shaded.gson",
"com.nimbusds.jose.shaded.gson.annotations",
"com.nimbusds.jose.shaded.gson.internal",
"com.nimbusds.jose.shaded.gson.internal.bind",
"com.nimbusds.jose.shaded.gson.internal.bind.util",
"com.nimbusds.jose.shaded.gson.internal.reflect",
"com.nimbusds.jose.shaded.gson.internal.sql",
"com.nimbusds.jose.shaded.gson.reflect",
"com.nimbusds.jose.shaded.gson.stream",
"com.nimbusds.jose.util",
"com.nimbusds.jose.util.cache",
"com.nimbusds.jose.util.events",
"com.nimbusds.jose.util.health",
"com.nimbusds.jwt",
"com.nimbusds.jwt.proc",
"com.nimbusds.jwt.util"
],
"com.squareup.okhttp3:okhttp": [
"okhttp3",
"okhttp3.internal",
@@ -2337,6 +2387,7 @@
"com.fasterxml.jackson.core:jackson-annotations",
"com.fasterxml.jackson.core:jackson-core",
"com.fasterxml.jackson.core:jackson-databind",
"com.github.stephenc.jcip:jcip-annotations",
"com.google.android:annotations",
"com.google.api.grpc:proto-google-common-protos",
"com.google.auth:google-auth-library-credentials",
@@ -2355,6 +2406,7 @@
"com.google.protobuf:protobuf-java",
"com.google.re2j:re2j",
"com.google.truth:truth",
"com.nimbusds:nimbus-jose-jwt",
"com.squareup.okhttp3:okhttp",
"com.squareup.okhttp3:okhttp-sse",
"com.squareup.okhttp:okhttp",
+100
View File
@@ -0,0 +1,100 @@
events {
worker_connections 1024;
}
http {
# Logging
log_format grpc_json escape=json '{'
'"time":"$time_iso8601",'
'"client":"$remote_addr",'
'"uri":"$uri",'
'"status":$status,'
'"grpc_status":"$sent_http_grpc_status",'
'"request_time":$request_time,'
'"upstream_time":"$upstream_response_time"'
'}';
access_log /var/log/nginx/access.log grpc_json;
error_log /var/log/nginx/error.log warn;
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=grpc_limit:10m rate=100r/s;
# Docker DNS resolver - re-resolve hostnames every 10s
# This prevents stale IP caching when containers restart
resolver 127.0.0.11 valid=10s ipv6=off;
# Upstream for Eagle gRPC server
upstream eagle_grpc {
server eagle:40032;
keepalive 100;
}
# HTTP server for Let's Encrypt challenge and redirect
server {
listen 80;
server_name prod.eagle0.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for gRPC
server {
listen 443 ssl;
http2 on;
server_name prod.eagle0.net;
# SSL certificates (managed by certbot)
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 Eagle service
location /net.eagle0.eagle.api.Eagle {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Timeouts for long-running streams
grpc_read_timeout 1200s;
grpc_send_timeout 1200s;
grpc_socket_keepalive on;
# Error handling
error_page 502 = /error502grpc;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# gRPC error handling
location = /error502grpc {
internal;
default_type application/grpc;
add_header grpc-status 14;
add_header grpc-message "unavailable";
return 204;
}
}
}
+149
View File
@@ -0,0 +1,149 @@
#!/bin/bash
#
# Setup script for Eagle0 production droplet
# Run this on a fresh DigitalOcean droplet (Ubuntu 24.04)
#
# Usage: curl -sSL https://raw.githubusercontent.com/nolen777/eagle0/main/scripts/setup_droplet.sh | sudo bash
#
set -euo pipefail
DOMAIN="${DOMAIN:-eagle0.net}"
DEPLOY_USER="${DEPLOY_USER:-deploy}"
APP_DIR="/opt/eagle0"
echo "=== Eagle0 Production Server Setup ==="
echo "Domain: ${DOMAIN}"
echo "Deploy user: ${DEPLOY_USER}"
echo ""
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root (use sudo)"
exit 1
fi
echo "=== Updating system ==="
apt-get update
apt-get upgrade -y
echo "=== Installing Docker ==="
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
else
echo "Docker already installed"
fi
echo "=== Installing Docker Compose plugin ==="
apt-get install -y docker-compose-plugin
echo "=== Installing additional utilities ==="
apt-get install -y \
curl \
wget \
git \
netcat-openbsd \
jq \
htop \
unattended-upgrades
echo "=== Configuring automatic security updates ==="
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
echo "=== Creating deploy user ==="
if ! id "${DEPLOY_USER}" &>/dev/null; then
useradd -m -s /bin/bash -G docker "${DEPLOY_USER}"
mkdir -p "/home/${DEPLOY_USER}/.ssh"
chmod 700 "/home/${DEPLOY_USER}/.ssh"
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
echo ""
echo "*** IMPORTANT: Add your SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys ***"
echo ""
else
echo "User ${DEPLOY_USER} already exists"
# Ensure user is in docker group
usermod -aG docker "${DEPLOY_USER}"
fi
echo "=== Creating application directory ==="
mkdir -p "${APP_DIR}"/{nginx,certbot/conf,certbot/www,saves}
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${APP_DIR}"
echo "=== Configuring Docker registry authentication ==="
echo ""
echo "*** IMPORTANT: Run the following command to authenticate with DigitalOcean Container Registry: ***"
echo " docker login registry.digitalocean.com"
echo ""
echo "=== Creating systemd service ==="
cat > /etc/systemd/system/eagle0.service << EOF
[Unit]
Description=Eagle0 Game Servers
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
User=${DEPLOY_USER}
Group=${DEPLOY_USER}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable eagle0
echo "=== Configuring firewall (UFW) ==="
if ! command -v ufw &> /dev/null; then
apt-get install -y ufw
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
echo "=== Setting up log rotation ==="
cat > /etc/logrotate.d/eagle0 << EOF
/var/log/eagle0/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 ${DEPLOY_USER} ${DEPLOY_USER}
sharedscripts
}
EOF
mkdir -p /var/log/eagle0
chown "${DEPLOY_USER}:${DEPLOY_USER}" /var/log/eagle0
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo "1. Add SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys"
echo "2. Copy docker-compose.prod.yml to ${APP_DIR}/"
echo "3. Copy nginx/nginx.conf to ${APP_DIR}/nginx/"
echo "4. Create .env file in ${APP_DIR}/ with OPENAI_API_KEY"
echo "5. Run: docker login registry.digitalocean.com"
echo "6. Get SSL certificate: (see init_ssl.sh)"
echo "7. Start services: systemctl start eagle0"
echo ""
echo "Server IP: $(curl -s ifconfig.me)"
echo ""
@@ -26,6 +26,13 @@ namespace fs = std::filesystem;
static string rLocation;
auto rloc(const string& execPath) -> string {
// First check for environment variable override for Docker deployment
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) {
return ""; // Return empty so StaticShardokFilesDirectory uses env var directly
}
// Fall back to Bazel runfiles for development
string error;
const std::unique_ptr<Runfiles> runfiles(Runfiles::Create(execPath, &error));
@@ -58,10 +65,14 @@ auto FilesystemUtils::FileExistsAtPath(const string& path) -> bool { return fs::
auto FilesystemUtils::StaticEagle0FilesDirectory() -> string { return "/usr/local/share/eagle0/"; }
auto FilesystemUtils::StaticShardokFilesDirectory() -> string {
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) { return string(resourcesPath) + "/"; }
return rLocation + "/src/main/resources/net/eagle0/shardok/";
}
auto FilesystemUtils::MapFilesDirectory() -> string {
const char* mapsPath = getenv("SHARDOK_MAPS_PATH");
if (mapsPath != nullptr) { return string(mapsPath) + "/"; }
return StaticShardokFilesDirectory() + "maps/";
}
@@ -7,6 +7,7 @@
#include <algorithm>
#include <bit>
#include <cstdint>
#include <cstdlib>
#define ITERABLE_BITSET_INDEX_CHECKS false
@@ -9,6 +9,7 @@
#include "MapUtils.hpp"
#include <algorithm>
#include <stdexcept>
static inline std::string StringForKey(
const std::unordered_map<std::string, std::string>& map,
@@ -9,6 +9,7 @@
#ifndef byte_vector_h
#define byte_vector_h
#include <cstdint>
#include <cstring>
#include <fstream>
#include <sstream>
@@ -4,6 +4,7 @@
#include "TranspositionTable.hpp"
#include <cinttypes>
#include <cstdio>
#include <cstring>
@@ -101,10 +102,10 @@ void TranspositionTable::clear() {
void TranspositionTable::printStats() const {
printf("TranspositionTable Stats:\n");
printf(" Probes: %llu\n", stats.probes.load());
printf(" Hits: %llu (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %llu\n", stats.stores.load());
printf(" Collisions: %llu\n", stats.collisions.load());
printf(" Probes: %" PRIu64 "\n", stats.probes.load());
printf(" Hits: %" PRIu64 " (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %" PRIu64 "\n", stats.stores.load());
printf(" Collisions: %" PRIu64 "\n", stats.collisions.load());
printf(" Table size: %zu entries (%.1f MB)\n",
TABLE_SIZE,
(TABLE_SIZE * sizeof(TTEntry)) / (1024.0 * 1024.0));
@@ -10,7 +10,6 @@
#include "AiBattleConfig.hpp"
#include "AiBattleSimulator.hpp"
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
using shardok::ai_battle_simulator::AiBattleConfigLoader;
using shardok::ai_battle_simulator::AiBattleSimulator;
@@ -109,10 +108,6 @@ int main(int argc, char* argv[]) {
// Set exec path for FilesystemUtils
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
shardok::FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
if (argc < 2) {
PrintUsage(argv[0]);
@@ -16,7 +16,6 @@
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
using namespace shardok;
@@ -84,10 +83,6 @@ int main(int argc, char* argv[]) {
// Set exec path so FilesystemUtils can find resource files
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
std::cout << "Shardok AI Performance Runner\n";
std::cout << "==============================\n";
@@ -29,8 +29,6 @@ thread_local struct {
int localMisses = 0;
int sharedAccesses = 0;
int evictionEvents = 0;
int apdLoadedFromFile = 0;
int apdGeneratedFresh = 0;
std::chrono::steady_clock::time_point lastReportTime = std::chrono::steady_clock::now();
} cacheStats;
@@ -40,16 +38,13 @@ static void MaybePrintCacheStats() {
if (std::chrono::duration_cast<std::chrono::seconds>(now - cacheStats.lastReportTime).count() >=
CACHE_STATS_FREQUENCY_SECONDS_) {
printf("Thread cache stats: %d persistent hits, %d persistent misses, %d local hits, "
"%d local misses, %d shared accesses, %d eviction events, "
"%d APD loaded from file, %d APD generated fresh\n",
"%d local misses, %d shared accesses, %d eviction events\n",
cacheStats.persistentHits,
cacheStats.persistentMisses,
cacheStats.localHits,
cacheStats.localMisses,
cacheStats.sharedAccesses,
cacheStats.evictionEvents,
cacheStats.apdLoadedFromFile,
cacheStats.apdGeneratedFresh);
cacheStats.evictionEvents);
cacheStats.lastReportTime = now;
}
}
@@ -195,25 +190,12 @@ auto ActionPointDistancesCache::GetRaw(
}
// Create new pathfinding result using factory method
auto creationResult = FixedActionPointDistances::Create(
auto result = FixedActionPointDistances::Create(
mapToUse,
mapId.terrainTypesId,
mapId.modifierId,
battalionType,
includeBravingWater,
braveWaterActionPointCost);
#if CACHE_STATS_LOGGING_
// Track whether this was loaded from file or generated fresh
if (creationResult.loadedFromFile) {
cacheStats.apdLoadedFromFile++;
} else {
cacheStats.apdGeneratedFresh++;
}
#endif
auto result = creationResult.apd;
// Store in shared cache
sharedDistances.lazy_emplace_l(
cacheKey,
@@ -22,110 +22,56 @@ static const int ASYNC_COUNT = []() {
namespace shardok {
void FixedActionPointDistances::SetCacheDirectory(const string& newDir) {
cacheDirectory = newDir;
FilesystemUtils::MakeDirectoryIfNecessary(cacheDirectory);
}
static thread_local byte_vector _scratch;
FixedActionPointDistances::FixedActionPointDistances(const HexMap* /*map*/, int columnCount)
: ActionPointDistances(columnCount) {}
auto FixedActionPointDistances::Create(
const HexMap* map,
int64_t terrainTypesHash,
int64_t modifierHash,
const BattalionTypeSPtr& battalionType,
bool includeBravingWater,
int braveWaterActionPointCost) -> CreationResult {
int braveWaterActionPointCost) -> std::shared_ptr<FixedActionPointDistances> {
// Create the object using private constructor
auto apd = std::shared_ptr<FixedActionPointDistances>(
new FixedActionPointDistances(map, map->column_count()));
CreationResult result;
result.apd = apd;
result.loadedFromFile = false;
string path = "";
if (!cacheDirectory.empty()) {
std::stringstream stream;
stream << cacheDirectory;
stream << std::hex << terrainTypesHash << '/';
if (auto directoryPath = stream.str(); !FilesystemUtils::FileExistsAtPath(directoryPath)) {
FilesystemUtils::MakeDirectoryIfNecessary(stream.str());
}
stream << std::hex << modifierHash;
stream << " " << battalionType->typeId;
if (includeBravingWater) { stream << " " << braveWaterActionPointCost; }
stream << ".apd";
path = stream.str();
}
const int indexCount = map->row_count() * map->column_count();
if (!path.empty() && FilesystemUtils::FileExistsAtPath(path)) {
apd->distances.resize(indexCount);
// load from file
const auto& bytes = _scratch.ReplaceWithPath(path);
const auto* ptr = reinterpret_cast<const DIST_T*>(bytes.data());
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
for (int fromIndex = 0; fromIndex < indexCount; fromIndex++) {
apd->distances[fromIndex].insert(
apd->distances[fromIndex].end(),
&(ptr[0]),
&(ptr[indexCount]));
ptr += indexCount;
}
result.loadedFromFile = true;
} else {
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
auto braveWaterPossibleCoords =
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
// Break into chunks for async
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
vector<vector<DIST_T>> chunkVec;
chunkVec.reserve(chunkSize);
const int chunkStartIndex = chunkIdx * chunkSize;
auto braveWaterPossibleCoords =
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
// Break into chunks for async
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
vector<vector<DIST_T>> chunkVec;
chunkVec.reserve(chunkSize);
const int chunkStartIndex = chunkIdx * chunkSize;
for (int i = 0; i < chunkSize; i++) {
const auto fromIndex = chunkStartIndex + i;
if (fromIndex >= indexCount) { continue; }
chunkVec.push_back(ActionPointDistances::GenerateDistances(
fromIndex,
map,
includeBravingWater,
braveWaterActionPointCost,
battalionType,
braveWaterPossibleCoords));
}
return chunkVec;
});
}
apd->distances.reserve(indexCount);
_scratch.clear();
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
auto resultsVec = futures[chunkIdx].get();
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
for (const auto& r : resultsVec) { _scratch.append(r); }
}
if (!path.empty()) { FilesystemUtils::AtomicallySaveToPath(path, _scratch); }
for (int i = 0; i < chunkSize; i++) {
const auto fromIndex = chunkStartIndex + i;
if (fromIndex >= indexCount) { continue; }
chunkVec.push_back(ActionPointDistances::GenerateDistances(
fromIndex,
map,
includeBravingWater,
braveWaterActionPointCost,
battalionType,
braveWaterPossibleCoords));
}
return chunkVec;
});
}
return result;
apd->distances.reserve(indexCount);
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
auto resultsVec = futures[chunkIdx].get();
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
}
return apd;
}
} // namespace shardok
@@ -17,31 +17,19 @@ using std::vector;
using BattalionTypeSPtr = std::shared_ptr<const BattalionType>;
class FixedActionPointDistances final : public ActionPointDistances {
public:
struct CreationResult {
std::shared_ptr<FixedActionPointDistances> apd;
bool loadedFromFile;
};
private:
vector<vector<DIST_T>> distances;
inline static string cacheDirectory = "";
// Private constructor - use Create factory method instead
explicit FixedActionPointDistances(const HexMap *map, int columnCount);
public:
static void SetCacheDirectory(const string &newDir);
// Factory method to create FixedActionPointDistances with metadata
// Factory method to create FixedActionPointDistances
static auto Create(
const HexMap *map,
int64_t terrainTypesHash,
int64_t modifierHash,
const BattalionTypeSPtr &battalionType,
bool includeBravingWater,
int braveWaterActionPointCost = -1) -> CreationResult;
int braveWaterActionPointCost = -1) -> std::shared_ptr<FixedActionPointDistances>;
~FixedActionPointDistances() override = default;
@@ -52,8 +40,6 @@ public:
[[nodiscard]] auto Distance(const Coords &from, const Coords &to) const -> DIST_T override {
return Distance(ToIndex(from), ToIndex(to));
}
friend struct CreationResult;
};
} // namespace shardok
@@ -8,6 +8,7 @@
#include "ServerConfiguration.hpp"
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
@@ -30,9 +31,16 @@ using std::unordered_map;
unordered_map<string, string> DefaultValues();
unordered_map<string, string> DefaultValues() {
// Check environment variables for Docker deployment
// Docker containers should set SHARDOK_EAGLE_INTERFACE_ADDRESS=0.0.0.0:40042
// to listen on all interfaces for container networking
const char* eagleInterfaceAddr = getenv("SHARDOK_EAGLE_INTERFACE_ADDRESS");
const char* shardokAddr = getenv("SHARDOK_GRPC_ADDRESS");
return unordered_map<string, string>{
{ServerConfiguration::kShardokGrpcAddress, "localhost"},
{ServerConfiguration::kEagleInterfaceGrpcAddress, "localhost"}};
{ServerConfiguration::kShardokGrpcAddress, shardokAddr ? shardokAddr : "localhost"},
{ServerConfiguration::kEagleInterfaceGrpcAddress,
eagleInterfaceAddr ? eagleInterfaceAddr : "localhost:40042"}};
}
ServerConfiguration::ServerConfiguration(const string& filePath) {
@@ -15,7 +15,6 @@
#include "ServerConfiguration.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings_loader/SettingsLoader.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
@@ -45,9 +44,6 @@ ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSe
std::cerr << "NOT SETTING" << std::endl;
// setter.SetRaw(key, value);
}
FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
}
auto ShardokGamesManager::GetController(const GameId &gameId)
@@ -77,11 +77,14 @@ public class AuthInterceptor : Interceptor {
};
public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TMP_InputField urlField;
public TMP_Dropdown environmentDropdown;
public TMP_InputField nameField;
public TMP_InputField passwordField;
public TMP_Dropdown resolutionDropdown;
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
public GameObject connectionPanel;
public GameObject gameSelectionPanel;
public GameObject customBattlePanel;
@@ -112,11 +115,22 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public ClientPregeneratedText clientPregeneratedText;
const String DefaultUrl = "eagle0.net";
const String URLKey = "urlKey";
const String BaseDomain = "eagle0.net";
const String EnvironmentKey = "environmentKey";
const String NameKey = "nameKey";
const String PasswordKey = "passwordKey";
// Environment options: index 0 = prod., index 1 = qa., index 2 = (none)
private static readonly List<string> EnvironmentOptions = new() { "prod.", "qa.", "" };
private static readonly List<string> EnvironmentDisplayNames =
new() { "prod.", "qa.", "(none)" };
private string GetUrlFromEnvironment() {
int envIndex = environmentDropdown.value;
string prefix = EnvironmentOptions[envIndex];
return prefix + BaseDomain;
}
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
_handleLobbyResponse(lobbyResponse);
}
@@ -133,7 +147,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
resolutionDropdown.AddOptions(resolutions.Select(r => $"{r.width} x {r.height}").ToList());
resolutionDropdown.value = resolutions.ToList().IndexOf(currentResolution);
urlField.text = PlayerPrefs.GetString(URLKey, DefaultUrl);
// Set up environment dropdown
environmentDropdown.ClearOptions();
environmentDropdown.AddOptions(EnvironmentDisplayNames);
environmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
nameField.text = PlayerPrefs.GetString(NameKey, "sample_name");
passwordField.text = PlayerPrefs.GetString(PasswordKey, "sample_password");
@@ -147,6 +165,78 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
customBattlePanel.gameObject.SetActive(false);
errorHandler.gameObject.SetActive(true);
// Initialize status text
UpdateConnectionStatus();
}
private float _statusUpdateTimer = 0f;
private const float StatusUpdateInterval = 0.5f;
void Update() {
// Only update status periodically and when connection panel is visible
if (!connectionPanel.activeInHierarchy) return;
_statusUpdateTimer += Time.deltaTime;
if (_statusUpdateTimer >= StatusUpdateInterval) {
_statusUpdateTimer = 0f;
UpdateConnectionStatus();
}
}
private void UpdateConnectionStatus() {
if (connectionStatusText == null) return;
// No connection yet - show nothing
if (_persistentClientConnection == null) {
connectionStatusText.text = "";
return;
}
var circuitState = _persistentClientConnection.CircuitBreaker.CurrentState;
var connState = _persistentClientConnection.CurrentState;
// Circuit breaker takes precedence
if (circuitState == eagle.ConnectionCircuitBreaker.State.Open) {
var nextTest = _persistentClientConnection.CircuitBreaker.NextTestAttempt;
if (nextTest.HasValue) {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
connectionStatusText.text =
$"<color=red>●</color> Server unavailable. Retry in {seconds}s";
return;
}
}
connectionStatusText.text = "<color=red>●</color> Server unavailable";
return;
}
if (circuitState == eagle.ConnectionCircuitBreaker.State.HalfOpen) {
connectionStatusText.text = "<color=yellow>●</color> Testing connection...";
return;
}
// Normal connection states
connectionStatusText.text = connState switch {
eagle.ConnectionState.Connected => "<color=green>●</color> Connected",
eagle.ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
eagle.ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
eagle.ConnectionState.Reconnecting => GetReconnectingText(),
eagle.ConnectionState.SubscriptionPending => "<color=yellow>●</color> Subscribing...",
_ => ""
};
}
private string GetReconnectingText() {
var nextAttempt = _persistentClientConnection?.NextReconnectAttempt;
if (!nextAttempt.HasValue) return "<color=yellow>●</color> Reconnecting...";
var timeUntilRetry = nextAttempt.Value - DateTime.UtcNow;
if (timeUntilRetry.TotalSeconds <= 0) { return "<color=yellow>●</color> Reconnecting..."; }
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilRetry.TotalSeconds));
return $"<color=orange>●</color> Retry in {seconds}s";
}
public void EditorButtonClicked() { SceneManager.LoadScene("Map Editor"); }
@@ -267,7 +357,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void _createConnection() {
PlayerPrefs.SetString(URLKey, urlField.text);
PlayerPrefs.SetInt(EnvironmentKey, environmentDropdown.value);
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
@@ -280,7 +370,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize cancellation token for thread management
_cancellationTokenSource = new CancellationTokenSource();
eagleConnection = new EagleConnection(nameField.text, passwordField.text, urlField.text);
string url = GetUrlFromEnvironment();
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
_persistentClientConnection = new PersistentClientConnection(
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
@@ -1,6 +1,8 @@
using System;
using System.Threading;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
public class ConnectionKiller : MonoBehaviour {
@@ -10,6 +12,10 @@ public class ConnectionKiller : MonoBehaviour {
public void OnApplicationQuit() { ShutdownAll(); }
#if UNITY_EDITOR
void OnEnable() { EditorApplication.playModeStateChanged += OnPlayModeStateChanged; }
void OnDisable() { EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; }
void OnPlayModeStateChanged(PlayModeStateChange state) {
if (state == PlayModeStateChange.ExitingPlayMode) { ShutdownAll(); }
}
@@ -405,9 +405,14 @@ namespace eagle {
public void UpdateResultCounts(GameUpdate update) {
switch (update.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
var newCount = update.ActionResultResponse.UnfilteredResultCountAfter;
lock (_resultCountLock) {
_lastUnfilteredResultCount =
update.ActionResultResponse.UnfilteredResultCountAfter;
var oldCount = _lastUnfilteredResultCount;
_lastUnfilteredResultCount = newCount;
if (newCount != oldCount) {
_connectionLogger.LogLine(
$"[RESULT_COUNT] Updated count {oldCount} -> {newCount}");
}
}
break;
@@ -57,7 +57,8 @@ namespace eagle.Notifications {
{ BreakAllianceAcceptedDetails, BreakAllianceAcceptedNotificationGenerator.Generator },
{ VassalExiledDetails, VassalExiledDetailsNotificationGenerator.Generator },
{ WithdrewForTruce, WithdrewForTruceDetailsNotificationGenerator.Generator },
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator }
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator },
{ NewFactionHeadDetails, NewFactionHeadDetailsNotificationGenerator.Generator }
};
public static IEnumerable<Notification> GenerateNotifications(
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications.ARNNotifications {
using ProvinceId = Int32;
using HeroId = Int32;
public static class NewFactionHeadDetailsNotificationGenerator {
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
private static readonly Random Random = new();
private static readonly List<string> NotificationTitles = new() {
"New Leadership",
"Succession",
"A New Era",
"Rise to Power",
"The Mantle Passes",
"Heir Ascendant",
"Crown of Command",
"Leadership Transferred",
"The Torch Passes",
"New Commander"
};
private static string GetNotificationTitle() {
return NotificationTitles[Random.Next(NotificationTitles.Count)];
}
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
foreach (var province in model.Provinces.Values) {
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
return province.Id;
}
}
return null;
}
private static IEnumerable<Notification> GenerateNotifications(
Net.Eagle0.Eagle.Common.Notification notification,
IGameModel currentModel) {
var details = notification.Details.NewFactionHeadDetails;
var newHeadHero = currentModel.Heroes[details.NewHeadHeroId];
var previousHeadHero =
currentModel.Heroes.TryGetValue(details.PreviousHeadHeroId, out var prev)
? prev
: null;
string textTemplate;
List<ProvinceId> affectedProvinces;
if (details.FactionId == currentModel.PlayerId) {
// Player's own faction - their new faction head
string previousHeadDescription =
previousHeadHero != null ? $"Following the death of {{PreviousHead}}, your"
: "Your";
textTemplate =
$"{previousHeadDescription} sworn {DisplayNames.SiblingDescription(newHeadHero.PronounGender)} {{NewHead}} now leads your faction.\n\n";
var heroProvinceId = FindProvinceForHero(details.NewHeadHeroId, currentModel);
affectedProvinces = heroProvinceId.HasValue
? new List<ProvinceId> { heroProvinceId.Value }
: currentModel.ProvincesForFaction(details.FactionId);
} else {
// Another faction
var factionName = currentModel.FactionName(details.FactionId);
if (previousHeadHero != null) {
textTemplate =
$"Following the death of {{PreviousHead}}, {{NewHead}} now leads {factionName}.\n\n";
} else {
textTemplate = $"{{NewHead}} now leads {factionName}.\n\n";
}
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "NewHead", (newHeadHero.NameTextId, "A hero") }
};
if (previousHeadHero != null) {
heroPlaceholders["PreviousHead"] =
(previousHeadHero.NameTextId, "the previous leader");
}
var displayedHeroes = new List<HeroView> { newHeadHero };
if (previousHeadHero != null) { displayedHeroes.Add(previousHeadHero); }
yield return DynamicTextNotification.StreamingDynamicNotification(
title: GetNotificationTitle(),
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: displayedHeroes);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e9afdd2068b294a29993f07b379eb9c3
@@ -503,6 +503,9 @@ namespace eagle {
}
public void Dispose() {
Thread threadToJoin = null;
CancellationTokenSource tokenSourceToDispose = null;
lock (this) {
// Dispose timers first to stop any pending callbacks
StopIdleCheckTimer();
@@ -525,20 +528,25 @@ namespace eagle {
// Clear pending commands
_pendingCommands.Clear();
// Cancel and dispose thread cancellation token
// Cancel thread cancellation token (but don't dispose yet)
_threadCancellationTokenSource?.Cancel();
// Give thread a chance to exit gracefully
if (_streamingCallThread != null) {
if (!_streamingCallThread.Join(TimeSpan.FromSeconds(2))) {
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
}
_streamingCallThread = null;
}
_threadCancellationTokenSource?.Dispose();
// Capture thread and token source references for cleanup outside lock
threadToJoin = _streamingCallThread;
_streamingCallThread = null;
tokenSourceToDispose = _threadCancellationTokenSource;
_threadCancellationTokenSource = null;
}
// Join thread OUTSIDE lock to avoid deadlock - the streaming thread
// may be trying to acquire lock(this) when we're waiting for it
if (threadToJoin != null) {
if (!threadToJoin.Join(TimeSpan.FromSeconds(2))) {
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
}
}
tokenSourceToDispose?.Dispose();
}
public void SetLobbySubscriber(ILobbySubscriber subscriber) {
@@ -974,7 +982,11 @@ namespace eagle {
AutoReset = true,
Interval = 5000 // Check every 5 seconds
};
_idleCheckTimer.Elapsed += (sender, args) => CheckForIdleTimeout();
_idleCheckTimer.Elapsed += (sender, args) => {
// Log BEFORE callback to confirm timer is firing
Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
CheckForIdleTimeout();
};
_idleCheckTimer.Enabled = true;
}
@@ -987,10 +999,16 @@ namespace eagle {
}
private void CheckForIdleTimeout() {
if (_cancellationToken.IsCancellationRequested) { return; }
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
// Log every check when idle > 5s to diagnose timer issues
if (idleTime > 5.0) {
_remoteEagleClientLogger.LogLine(
$"[IDLE_CHECK] idle_time={idleTime:F1}s, cancelled={_cancellationToken.IsCancellationRequested}");
}
if (_cancellationToken.IsCancellationRequested) { return; }
// Early warnings at 10s and 20s to help diagnose slow connections
if (idleTime > 20.0 && _lastIdleWarningLevel < 2) {
_lastIdleWarningLevel = 2;
@@ -1022,7 +1040,11 @@ namespace eagle {
StopHeartbeatTimer();
_heartbeatTimer =
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
_heartbeatTimer.Elapsed += (sender, args) => Task.Run(() => SendHeartbeat());
_heartbeatTimer.Elapsed += (sender, args) => {
// Log BEFORE Task.Run to confirm timer is firing
Console.WriteLine($"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
Task.Run(() => SendHeartbeat());
};
_heartbeatTimer.Enabled = true;
}
@@ -1035,8 +1057,17 @@ namespace eagle {
}
private async Task SendHeartbeat() {
if (_cancellationToken.IsCancellationRequested) { return; }
if (_currentState != ConnectionState.Connected) { return; }
_remoteEagleClientLogger.LogLine($"[HEARTBEAT] Timer fired, state={_currentState}");
if (_cancellationToken.IsCancellationRequested) {
_remoteEagleClientLogger.LogLine("[HEARTBEAT] Skipped - cancellation requested");
return;
}
if (_currentState != ConnectionState.Connected) {
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Skipped - not connected (state={_currentState})");
return;
}
// Build sync status for all subscribed games
var heartbeatRequest = new HeartbeatRequest {
@@ -1066,8 +1097,14 @@ namespace eagle {
var sent = await SendUpdateStreamRequestAsync(request);
if (sent) {
var gameInfo = string.Join(
", ",
heartbeatRequest.GameSyncStatuses.Select(
g => $"{g.GameId}:{g.UnfilteredResultCount}"));
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Sent heartbeat with {heartbeatRequest.GameSyncStatuses.Count} games");
$"[HEARTBEAT] Sent heartbeat with {heartbeatRequest.GameSyncStatuses.Count} games ({gameInfo})");
} else {
_remoteEagleClientLogger.LogLine("[HEARTBEAT] Failed to send - stream unavailable");
}
}
@@ -1126,6 +1163,9 @@ namespace eagle {
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Schedule reconnect to resync
// Stop timers FIRST to prevent them firing during reconnection
StopHeartbeatTimer();
StopIdleCheckTimer();
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
@@ -28,11 +28,7 @@ public class EagleConnection : IDisposable {
_channel = GrpcChannel.ForAddress(
"https://" + url,
new GrpcChannelOptions {
HttpHandler =
new YetAnotherHttpHandler {
Http2Only = true,
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds)
},
HttpHandler = CreateHttpHandler(),
DisposeHttpClient = true,
LoggerFactory = _loggerFactory,
MaxReceiveMessageSize = null
@@ -42,6 +38,25 @@ public class EagleConnection : IDisposable {
return invoker;
}
/// <summary>
/// Create HTTP handler with timeouts configured for reliable connection management.
/// These settings help detect and recover from dead connections, especially on Windows
/// where firewalls may silently drop HTTP/2 keep-alive pings.
/// </summary>
private static YetAnotherHttpHandler CreateHttpHandler() {
return new YetAnotherHttpHandler {
Http2Only = true,
// Send keep-alive pings every 15 seconds
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds),
// Close connection if ping not acknowledged within 5 seconds
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
// Continue pinging even when idle to detect dead connections
Http2KeepAliveWhileIdle = true,
// Don't wait forever for initial connection
ConnectTimeout = TimeSpan.FromSeconds(10)
};
}
public EagleConnection(string playerName, string password, string url) {
this.playerName = playerName;
credentials = new Metadata { { "user", playerName } };
File diff suppressed because it is too large Load Diff
@@ -511,6 +511,7 @@ public class HexGrid : MonoBehaviour {
}
public void ClearCellLabels() {
if (cells == null) return;
for (int i = 0; i < width * height; i++) {
cells[i].UpperUnitInfoLabel.text = "";
cells[i].LowerUnitInfoLabel.text = "";
@@ -6,12 +6,16 @@ using UnityEngine;
namespace common {
public sealed class Logger : IDisposable {
private readonly StreamWriter _sw;
private readonly object _lock = new object();
private static Dictionary<String, Logger> _loggers = new Dictionary<String, Logger>();
private static readonly object _loggersLock = new object();
public static Logger GetLogger(string name) {
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
lock (_loggersLock) {
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
}
}
private string CurrentTimeString() {
@@ -29,8 +33,10 @@ namespace common {
}
public void LogLine(string line) {
_sw.WriteLine(CurrentTimeString() + " " + line);
_sw.Flush();
lock (_lock) {
_sw.WriteLine(CurrentTimeString() + " " + line);
_sw.Flush();
}
}
public void Close() { _sw.Close(); }
@@ -1,6 +1,6 @@
{
"dependencies": {
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
"com.unity.2d.sprite": "1.0.0",
"com.unity.2d.tilemap": "1.0.0",
"com.unity.ai.navigation": "2.0.9",
@@ -1,11 +1,11 @@
{
"dependencies": {
"com.cysharp.yetanotherhttphandler": {
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "ceb2f24f343c93d66c8a784a283be65b9627efe1"
"hash": ""
},
"com.unity.2d.sprite": {
"version": "1.0.0",
@@ -15,5 +15,16 @@ go_library(
go_binary(
name = "admin_server",
embed = [":admin_server_lib"],
pure = "on",
visibility = ["//visibility:public"],
)
# Linux x86_64 target for Docker deployment
go_binary(
name = "admin_server_linux_amd64",
embed = [":admin_server_lib"],
goarch = "amd64",
goos = "linux",
pure = "on",
visibility = ["//visibility:public"],
)
@@ -3,6 +3,22 @@ load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
# Auth service for OAuth login
scala_proto_library(
name = "auth_scala_grpc",
visibility = [
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [":auth_grpc"],
)
proto_library(
name = "auth_grpc",
srcs = ["auth.proto"],
visibility = ["//visibility:public"],
)
scala_proto_library(
name = "eagle_scala_grpc",
visibility = [
@@ -242,6 +258,7 @@ go_proto_library(
compilers = ["@io_bazel_rules_go//proto:go_grpc"], # keep
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle", # keep
protos = [
":auth_grpc",
":available_command_proto",
":command_proto",
":eagle_grpc",
@@ -0,0 +1,108 @@
//
// Copyright 2025 Dan Crosby
//
syntax = "proto3";
package net.eagle0.eagle.api.auth;
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.api.auth";
option java_outer_classname = "AuthProto";
option objc_class_prefix = "E0A";
// Authentication service for OAuth login
service Auth {
// Get OAuth URL to open in system browser
rpc GetOAuthUrl(GetOAuthUrlRequest) returns (GetOAuthUrlResponse) {}
// Exchange authorization code for JWT tokens
rpc ExchangeCode(ExchangeCodeRequest) returns (ExchangeCodeResponse) {}
// Set or update display name (requires valid JWT)
rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse) {}
// Refresh access token using refresh token
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse) {}
// Get current user info (validates JWT)
rpc GetCurrentUser(GetCurrentUserRequest) returns (GetCurrentUserResponse) {}
// Logout - invalidates refresh token
rpc Logout(LogoutRequest) returns (LogoutResponse) {}
}
// OAuth provider enumeration
enum OAuthProvider {
OAUTH_PROVIDER_UNSPECIFIED = 0;
OAUTH_PROVIDER_DISCORD = 1;
OAUTH_PROVIDER_GOOGLE = 2;
}
// 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
}
// 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
}
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
}
// User information returned from auth endpoints
message UserInfo {
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 from OAuth provider
OAuthProvider provider = 4;
}
// Set or update display name
message SetDisplayNameRequest {
string display_name = 1; // 3-20 characters, alphanumeric + underscore
}
message SetDisplayNameResponse {
bool success = 1;
string error_message = 2; // If not success: "Name taken", "Invalid characters", etc.
UserInfo user = 3;
}
// Refresh access token
message RefreshTokenRequest {
string refresh_token = 1;
}
message RefreshTokenResponse {
string access_token = 1;
int64 expires_at = 2;
}
// Get current user info
message GetCurrentUserRequest {}
message GetCurrentUserResponse {
UserInfo user = 1;
}
// Logout
message LogoutRequest {}
message LogoutResponse {}
@@ -284,6 +284,12 @@ message ProfessionGainedDetails {
Profession new_profession = 3;
}
message NewFactionHeadDetails {
int32 new_head_hero_id = 1;
int32 faction_id = 2;
int32 previous_head_hero_id = 3;
}
message NotificationDetails {
oneof sealed_value {
AllianceAcceptedDetails alliance_accepted_details = 30;
@@ -324,5 +330,6 @@ message NotificationDetails {
VassalExiledDetails vassal_exiled_details = 8;
WithdrewForTruceDetails withdrew_for_truce = 3;
ProfessionGainedDetails profession_gained_details = 35;
NewFactionHeadDetails new_faction_head_details = 39;
}
}
@@ -772,6 +772,17 @@ proto_library(
],
)
scala_proto_library(
name = "user_scala_proto",
deps = [":user_proto"],
)
proto_library(
name = "user_proto",
srcs = ["user.proto"],
deps = ["@com_google_protobuf//:timestamp_proto"],
)
go_proto_library(
name = "internal_go_proto",
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal",
@@ -802,6 +813,7 @@ go_proto_library(
":shardok_results_proto",
":supplies_proto",
":unaffiliated_hero_proto",
":user_proto",
],
deps = [
"//src/main/protobuf/net/eagle0/common:common_go_proto",
@@ -53,4 +53,6 @@ message ChangedFaction {
.google.protobuf.Int32Value new_last_acted_province_id = 22;
bool clear_last_acted_province_id = 23;
.google.protobuf.StringValue new_name = 26;
}
@@ -72,6 +72,7 @@ message GeneratedTextRequestDetails {
PrisonerReleasedMessage prisoner_released_message = 32;
PrisonerExiledMessage prisoner_exiled_message = 33;
PrisonerReturnedMessage prisoner_returned_message = 34;
NewFactionHeadMessage new_faction_head_message = 35;
}
}
@@ -334,3 +335,13 @@ message PrisonerReturnedMessage {
int32 to_faction_id = 4;
int32 province_id = 5;
}
message NewFactionHeadMessage {
int32 new_head_hero_id = 1;
int32 faction_id = 2;
string previous_faction_name = 3;
// If present, the faction is being renamed to this
string new_faction_name = 4;
// The previous faction head who was executed
int32 previous_head_hero_id = 5;
}
@@ -58,4 +58,8 @@ message Hero {
repeated EventForHeroBackstory new_backstory_events = 29;
.net.eagle0.eagle.common.Gender pronoun_gender = 27;
// The name of the faction this hero would lead, if they led one.
// Populated for "great person" heroes from the heroes TSV; empty for others.
string led_faction_name = 30;
}
@@ -0,0 +1,65 @@
//
// Copyright 2025 Dan Crosby
//
syntax = "proto3";
package net.eagle0.eagle.internal.user;
import "google/protobuf/timestamp.proto";
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.internal.user";
// Internal user record stored in users.pb
message User {
string user_id = 1; // UUID generated by Eagle
string display_name = 2; // Unique, player-chosen name
string display_name_lower = 3; // Lowercase version for uniqueness checks
// OAuth identities (a user can have multiple linked providers)
repeated OAuthIdentity oauth_identities = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp last_login_at = 6;
string avatar_url = 7;
// Admin flag - allows impersonating other users for debugging
// Set manually in the database, not via API
bool is_admin = 8;
}
// OAuth identity from a provider
message OAuthIdentity {
string provider = 1; // "discord" or "google"
string provider_user_id = 2; // ID from the OAuth provider
string email = 3; // Email from provider (for account recovery)
google.protobuf.Timestamp linked_at = 4;
}
// Container for all users (stored as single file)
message UserDatabase {
repeated User users = 1;
// Indexes for fast lookup (provider:provider_user_id -> user_id)
map<string, string> oauth_index = 2;
// display_name_lower -> user_id
map<string, string> display_name_index = 3;
}
// Refresh token record
message RefreshToken {
string token_id = 1; // UUID
string user_id = 2;
string token_hash = 3; // SHA-256 hash of actual token
google.protobuf.Timestamp created_at = 4;
google.protobuf.Timestamp expires_at = 5;
bool revoked = 6;
}
// Container for refresh tokens
message RefreshTokenDatabase {
repeated RefreshToken tokens = 1;
}
@@ -43,4 +43,6 @@ message FactionViewDiff {
repeated .net.eagle0.eagle.common.DiplomacyOffer removed_incoming_diplomacy_offers = 13;
.google.protobuf.Int32Value new_focus_province_id = 11;
.google.protobuf.StringValue new_name = 14;
}
@@ -7,7 +7,11 @@ object ApiKeys {
private val apiKeyFilePath =
"src/main/scala/net/eagle0/common/llm_integration/api_keys.txt"
// These must have valid values set in api_keys.txt
// Environment variable names
private val openAIEnvVar = "OPENAI_API_KEY"
private val anthropicEnvVar = "ANTHROPIC_API_KEY"
// File key names
private val openAIKeyName = "openai_api_key"
private val anthropicKeyName = "anthropic_api_key"
@@ -23,25 +27,31 @@ object ApiKeys {
)
}.toMap
}.getOrElse {
throw new ApiKeyException(
"Failed to read api_keys.txt. Make sure you have copied api_keys_template.txt to api_keys.txt and set the keys."
)
Map.empty // Return empty map if file doesn't exist; we'll check env vars
}
private val dictionary = readDictionary()
private def getKey(key: String): String =
dictionary.get(key) match {
case None =>
throw new ApiKeyException(s"$key not found")
case Some(value) if value.startsWith("your_") =>
throw new ApiKeyException(
s"API key for $key is not set. Please set it in $apiKeyFilePath."
)
case Some(value) => value
private def getKey(key: String, envVar: String): String =
// First check environment variable
Option(System.getenv(envVar)).filter(_.nonEmpty) match {
case Some(value) => value
case None =>
// Fall back to file-based dictionary
dictionary.get(key) match {
case None =>
throw new ApiKeyException(
s"$key not found. Set $envVar environment variable or add to $apiKeyFilePath."
)
case Some(value) if value.startsWith("your_") =>
throw new ApiKeyException(
s"API key for $key is not set. Set $envVar environment variable or update $apiKeyFilePath."
)
case Some(value) => value
}
}
lazy val openAI: String = getKey(openAIKeyName)
lazy val openAI: String = getKey(openAIKeyName, openAIEnvVar)
lazy val anthropic: String = getKey(anthropicKeyName)
lazy val anthropic: String = getKey(anthropicKeyName, anthropicEnvVar)
}
+8 -1
View File
@@ -7,19 +7,26 @@ scala_binary(
visibility = ["//visibility:public"],
deps = [
":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:user_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle/auth:jwt_service",
"//src/main/scala/net/eagle0/eagle/auth:oauth_service",
"//src/main/scala/net/eagle0/eagle/auth:user_service",
"//src/main/scala/net/eagle0/eagle/service",
"//src/main/scala/net/eagle0/eagle/service:auth_service",
"//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:games_manager",
"//src/main/scala/net/eagle0/eagle/service:server_setup_helpers",
"//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister",
"//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",
# "@maven//:com_thesamet_scalapb_scalapb_runtime_3",
],
)
+30 -1
View File
@@ -6,8 +6,11 @@ import scala.util.Random
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.service.*
import net.eagle0.eagle.service.persistence.{LocalFilePersister, SaveDirectory}
object Main {
import ServerSetupHelpers.*
@@ -92,6 +95,13 @@ object Main {
): Server = {
val serverBuilder = ServerBuilder.forPort(grpcPort)
// Initialize auth services
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(
EagleGrpc
.bindService(
@@ -99,8 +109,27 @@ object Main {
ExecutionContext.global
)
)
// Add Auth service (only if JWT is configured)
jwtService.foreach { jwt =>
serverBuilder.addService(
AuthGrpc
.bindService(
AuthServiceImpl(oauthService, userService, jwt),
ExecutionContext.global
)
)
SimpleTimedLogger.printLogger.logLine("OAuth auth service enabled")
}
if jwtService.isEmpty then {
SimpleTimedLogger.printLogger.logLine(
"OAuth auth service disabled (no JWT keys configured). Using Basic Auth only."
)
}
serverBuilder.intercept(new ExceptionInterceptor)
serverBuilder.intercept(new AuthorizationInterceptor)
serverBuilder.intercept(new AuthorizationInterceptor(jwtService))
serverBuilder.build
}
@@ -195,7 +195,7 @@ case class AIClient(
AlmsCommandSelector
.chosenAlmsForSupportWithMinimumCommandForProvince(
actingFactionId = fid,
gameState = gs,
gameState = GameStateConverter.fromProto(gs),
availableCommands = acs,
minimumFood = 0,
provinceId = opac.provinceId
+18 -6
View File
@@ -74,6 +74,8 @@ scala_library(
"//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",
],
)
@@ -105,7 +107,7 @@ scala_library(
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:province_distances",
"//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",
],
@@ -144,7 +146,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
],
)
@@ -180,6 +182,7 @@ scala_library(
"//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",
@@ -187,14 +190,15 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:expand_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:food_consumption_utils",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:fulfill_quests_command_selector",
"//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",
],
)
@@ -211,6 +215,7 @@ scala_library(
"//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",
],
)
@@ -236,7 +241,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:ransom_offer_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -249,17 +257,21 @@ scala_library(
],
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:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_loyalty_for_swear_brotherhood",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:hero_gift_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:swear_brotherhood_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:sworn_brother_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -5,7 +5,7 @@ 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.ProvinceDistances
import net.eagle0.eagle.library.util.LegacyProvinceDistances
object FactionLeaderProvinceRanker {
private def factionLeaderProvinceOrdering(
@@ -23,7 +23,7 @@ object FactionLeaderProvinceRanker {
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[Province] = Ordering.by((p: Province) =>
ProvinceDistances.distanceThroughFriendliesOption(
LegacyProvinceDistances.distanceThroughFriendliesOption(
fromProvinceId,
p.id,
factionId,
@@ -8,6 +8,7 @@ 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.FactionId
object InvitationCommandSelector {
@@ -22,16 +23,16 @@ 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 = gameState
gameState = nativeGameState
)
}.filter { invitationOption =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
diplomacyAvailableCommand.actingProvinceId,
gameState
nativeGameState.provinces(diplomacyAvailableCommand.actingProvinceId)
) >= invitationOption.goldCost
}.map { invitationOption =>
InvitationOptionWithChance(
@@ -39,7 +40,8 @@ object InvitationCommandSelector {
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
invitationOption.targetFactionId,
gameState
nativeGameState.factions.values.toVector,
nativeGameState.provinces.values.toVector
)
)
}.maxByOption(_.acceptanceChance)
@@ -5,7 +5,7 @@ import net.eagle0.eagle.api.available_command.{MarchAvailableCommand, MarchComma
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.{CommandSelection, LegacyBattalionUtils, ProvinceDistances}
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, LegacyProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
@@ -23,7 +23,7 @@ object MarchTowardProvinceCommandChooser {
.map(dest => (opmc, gs.provinces(dest.provinceId)))
.flatMap {
case (opmc, p) =>
ProvinceDistances
LegacyProvinceDistances
.distanceThroughFriendliesOption(
destinationProvinceId,
p.id,
@@ -19,7 +19,6 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
CommandChoiceHelpers,
CommandChooser,
ExpandCommandSelector,
FoodConsumptionUtils,
FulfillQuestsCommandSelector,
ImproveCommandSelector,
OrganizeCommandSelector,
@@ -29,9 +28,12 @@ import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSele
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.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.LegacyProvinceDistances
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.views.province_view.FullProvinceInfo
@@ -137,7 +139,7 @@ object MidGameAIClient {
) =>
val actingProvince = gameState.provinces(actingProvinceId)
val foodMonthsToHoldBack =
FoodConsumptionUtils.foodConsumptionMonthsToHold(
LegacyFoodConsumptionUtils.foodConsumptionMonthsToHold(
actingProvinceId,
gameState
)
@@ -269,7 +271,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
gameState,
GameStateConverter.fromProto(gameState),
availableCommands
),
functionalRandom
@@ -298,7 +300,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
gameState,
GameStateConverter.fromProto(gameState),
availableCommands
),
functionalRandom
@@ -345,7 +347,18 @@ object MidGameAIClient {
acs = availableCommands,
choosersAndReasons = Vector(
(
ExpandCommandSelector.maybeChosenExpandCommand,
(
fid: FactionId,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
ExpandCommandSelector.maybeChosenExpandCommand(
fid,
GameStateConverter.fromProto(gs),
acs,
fr
),
"no hostile neighbors, expand"
),
(
@@ -362,7 +375,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
gameState,
GameStateConverter.fromProto(gameState),
availableCommands
),
functionalRandom
@@ -450,7 +463,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
gameState,
GameStateConverter.fromProto(gameState),
availableCommands
),
functionalRandom
@@ -505,7 +518,7 @@ object MidGameAIClient {
AttackCommandChooser.chosenAttackCommand(
actingFactionId,
gs,
GameStateConverter.fromProto(gs),
acs,
reconnedHeroCountFor,
reconnedBattalionsFor
@@ -525,13 +538,13 @@ object MidGameAIClient {
AlmsCommandSelector
.chosenAlmsForSupportCommand(
actingFactionId = actingFactionId,
gameState = gameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = availableCommands
)
.orElse {
ImproveCommandSelector.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = gameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = availableCommands
)
}
@@ -564,7 +577,7 @@ object MidGameAIClient {
RandomState(
AttackCommandChooser.chosenSupportAttackCommand(
actingFactionId,
gameState,
GameStateConverter.fromProto(gameState),
availableCommands
),
functionalRandom
@@ -614,7 +627,7 @@ object MidGameAIClient {
RandomState(
SeekMoreLeadersCommandChooser.maybeSeekMoreLeadersCommand(
actingFactionId,
gameState,
GameStateConverter.fromProto(gameState),
availableCommands
),
functionalRandom
@@ -790,7 +803,7 @@ object MidGameAIClient {
OrganizeCommandSelector
.chosenOrganizeCommandWithFoodSurplus(
actingFactionId = fid,
gameState = gs,
gameState = GameStateConverter.fromProto(gs),
availableCommands = acs,
extraFoodSurplus = foodSurplus(
gs,
@@ -892,7 +905,7 @@ object MidGameAIClient {
val furthestOrigin = candidateCommands.flatMap { opmc =>
// distance to the faction leader province we're closest to
factionLeaderProvincesNeedingHeroes.flatMap { flp =>
ProvinceDistances
LegacyProvinceDistances
.distanceThroughFriendliesOption(
p1 = flp.id,
p2 = opmc.originProvinceId,
@@ -4,8 +4,8 @@ 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
object MoveLeaderToBetterProvinceCommandChooser {
@@ -53,7 +53,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
mcfop.originProvinceId,
gs
).flatMap { desiredPid =>
ProvinceDistances
LegacyProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
@@ -39,10 +39,12 @@ import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSele
randomSelectionForType,
selectionForType
}
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.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.province.ProvinceT
import net.eagle0.eagle.FactionId
object ResolveDiplomacyCommandSelector {
@@ -140,6 +142,20 @@ object ResolveDiplomacyCommandSelector {
- LegacyFactionUtils.prestige(invitedFid, gs)
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
/** Protoless version */
def invitationAcceptanceChance(
originatingFid: FactionId,
invitedFid: FactionId,
factions: Vector[FactionT],
provinces: Vector[ProvinceT]
): Int = {
val originatingFaction = factions.find(_.id == originatingFid).get
val invitedFaction = factions.find(_.id == invitedFid).get
(FactionUtils.prestige(originatingFaction, provinces)
- FactionUtils.prestige(invitedFaction, provinces)
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
}
private def resolveTruceOfferSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@@ -4,9 +4,6 @@ import net.eagle0.common.MoreOption
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.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{LoyaltyGainFromFeast, MinimumLoyaltyForSwearBrotherhood}
import net.eagle0.eagle.library.util.{CommandSelection, ProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.{
@@ -15,26 +12,30 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
SwearBrotherhoodCommandSelector,
SwornBrotherChooser
}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.FactionId
/** Protoless version of SeekMoreLeadersCommandChooser. */
object SeekMoreLeadersCommandChooser {
private def needsMoreLeaders(
actingFactionId: FactionId,
gameState: GameState
): Boolean =
gameState.factions(actingFactionId).leaders.size < SwornBrotherChooser
gameState.factions(actingFactionId).leaderIds.size < SwornBrotherChooser
.desiredNumberOfLeaders(
LegacyFactionUtils.provinceCount(actingFactionId, gameState)
FactionUtils.provinces(actingFactionId, gameState.provinces.values.toVector).size
)
case class Candidate(hero: Hero, province: Province)
case class Candidate(hero: HeroT, province: ProvinceT)
private def maybeMoveTowardDestination(
command: MarchAvailableCommand,
hero: Hero,
startingProvince: Province,
factionHeadProvince: Province,
hero: HeroT,
startingProvince: ProvinceT,
factionHeadProvince: ProvinceT,
gameState: GameState
): Option[CommandSelection] = {
val opmc = command.oneProvinceCommands
@@ -50,7 +51,7 @@ object SeekMoreLeadersCommandChooser {
factionHeadProvince.id,
p.id,
factionHeadProvince.rulingFactionId.get,
gameState
gameState.provinces
)
.map(distance => (opmc, p, distance))
}
@@ -83,23 +84,24 @@ object SeekMoreLeadersCommandChooser {
actingFactionId: FactionId,
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] =
LegacyFactionUtils
.provinces(actingFactionId, gameState)
): Option[CommandSelection] = {
val provincesVector = gameState.provinces.values.toVector
FactionUtils
.provinces(actingFactionId, provincesVector)
.find(
_.rulingFactionHeroIds
.contains(gameState.factions(actingFactionId).factionHeadId)
)
.flatMap { provinceWithFactionHead =>
val candidates =
LegacyFactionUtils
.provinces(actingFactionId, gameState)
FactionUtils
.provinces(actingFactionId, provincesVector)
.filter(_.rulingFactionHeroIds.length > 1)
.flatMap { province =>
SwornBrotherChooser
.bestChoice(
province.rulingFactionHeroIds.map(gameState.heroes),
gameState.factions(actingFactionId).leaders.toVector
gameState.factions(actingFactionId).leaderIds
)
.map(hero => Candidate(hero, province))
}
@@ -117,7 +119,7 @@ object SeekMoreLeadersCommandChooser {
}
marchableCandidates
.maxByOption(x => LegacyHeroUtils.power(x._2.hero))
.maxByOption(x => HeroUtils.power(x._2.hero))
.flatMap {
case (command, candidate) =>
maybeMoveTowardDestination(
@@ -129,6 +131,7 @@ object SeekMoreLeadersCommandChooser {
)
}
}
}
def maybeSeekMoreLeadersCommand(
actingFactionId: FactionId,
@@ -144,14 +147,13 @@ object SeekMoreLeadersCommandChooser {
// First try a hero that's already here
val desiredHero = SwornBrotherChooser.bestChoice(
leaderProvince.rulingFactionHeroIds.map(gameState.heroes),
faction.leaders.toVector
faction.leaderIds
)
desiredHero.flatMap { hero =>
SwearBrotherhoodCommandSelector
.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
desiredHeroId = hero.id
)
@@ -161,7 +163,6 @@ object SeekMoreLeadersCommandChooser {
) {
CommandChoiceHelpers.chosenFeastCommandIfAvailable(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
reason = s"want to make hero ${hero.id} a leader"
)
@@ -170,7 +171,6 @@ object SeekMoreLeadersCommandChooser {
.orElse {
HeroGiftCommandSelector.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
heroId = hero.id,
reason = "want to make this hero a leader"
@@ -0,0 +1,51 @@
load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "jwt_service",
srcs = ["JwtService.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"@maven//:com_nimbusds_nimbus_jose_jwt",
],
)
scala_library(
name = "oauth_config",
srcs = ["OAuthConfig.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_library(
name = "oauth_service",
srcs = ["OAuthService.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
":oauth_config",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
],
)
scala_library(
name = "user_service",
srcs = ["UserService.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/eagle/service/persistence:persister",
],
)
@@ -0,0 +1,182 @@
package net.eagle0.eagle.auth
import java.security.MessageDigest
import java.time.Instant
import java.util.{Date, UUID}
import scala.jdk.CollectionConverters.*
import scala.util.Try
import com.nimbusds.jose.{JWSAlgorithm, JWSHeader}
import com.nimbusds.jose.crypto.{RSASSASigner, RSASSAVerifier}
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT}
/** JWT claims extracted from a validated token */
case class JwtClaims(
userId: String,
displayName: String,
isAdmin: Boolean,
issuedAt: Instant,
expiresAt: Instant
)
/** Refresh token with metadata */
case class RefreshTokenInfo(
tokenId: String,
userId: String,
tokenHash: String,
expiresAt: Instant
)
/** Service for creating and validating JWT tokens */
trait JwtService {
/** Create an access token for a user */
def createAccessToken(userId: String, displayName: String, isAdmin: Boolean): String
/** Create a refresh token (returns token string and info for storage) */
def createRefreshToken(userId: String): (String, RefreshTokenInfo)
/** Validate an access token and extract claims */
def validateAccessToken(token: String): Option[JwtClaims]
/** Hash a refresh token for storage */
def hashRefreshToken(token: String): String
}
class JwtServiceImpl(rsaKey: RSAKey) extends JwtService {
private val accessTokenDuration = java.time.Duration.ofDays(7)
private val refreshTokenDuration = java.time.Duration.ofDays(30)
private val issuer = "eagle0"
private val signer = new RSASSASigner(rsaKey)
private val verifier = new RSASSAVerifier(rsaKey.toPublicJWK)
override def createAccessToken(
userId: String,
displayName: String,
isAdmin: Boolean
): String = {
val now = Instant.now()
val expiresAt = now.plus(accessTokenDuration)
val claimsBuilder = new JWTClaimsSet.Builder()
.subject(userId)
.issuer(issuer)
.issueTime(Date.from(now))
.expirationTime(Date.from(expiresAt))
.claim("name", displayName)
// Only include admin claim if true to keep tokens smaller
if isAdmin then {
claimsBuilder.claim("admin", true)
}
val signedJWT = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID).build(),
claimsBuilder.build()
)
signedJWT.sign(signer)
signedJWT.serialize()
}
override def createRefreshToken(userId: String): (String, RefreshTokenInfo) = {
val tokenId = UUID.randomUUID().toString
val tokenSecret = UUID.randomUUID().toString
val token = s"$tokenId.$tokenSecret"
val tokenHash = hashRefreshToken(token)
val expiresAt = Instant.now().plus(refreshTokenDuration)
val info = RefreshTokenInfo(
tokenId = tokenId,
userId = userId,
tokenHash = tokenHash,
expiresAt = expiresAt
)
(token, info)
}
override def validateAccessToken(token: String): Option[JwtClaims] =
Try {
val signedJWT = SignedJWT.parse(token)
// Verify signature
if !signedJWT.verify(verifier) then {
return None
}
val claims = signedJWT.getJWTClaimsSet
// Check issuer
if claims.getIssuer != issuer then {
return None
}
// Check expiration
val expirationTime = claims.getExpirationTime
if expirationTime == null || expirationTime.before(new Date()) then {
return None
}
val userId = claims.getSubject
val displayName = Option(claims.getStringClaim("name")).getOrElse("")
val isAdmin = Option(claims.getBooleanClaim("admin")).map(_.booleanValue()).getOrElse(false)
val issuedAt = claims.getIssueTime.toInstant
val expiresAt = expirationTime.toInstant
Some(
JwtClaims(
userId = userId,
displayName = displayName,
isAdmin = isAdmin,
issuedAt = issuedAt,
expiresAt = expiresAt
)
)
}.toOption.flatten
override def hashRefreshToken(token: String): String = {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(token.getBytes("UTF-8"))
hashBytes.map("%02x".format(_)).mkString
}
}
object JwtServiceImpl {
/** Generate a new RSA key pair for JWT signing */
def generateKey(): RSAKey =
new RSAKeyGenerator(2048)
.keyID(UUID.randomUUID().toString)
.generate()
/** Load RSA key from JSON string (JWK format) */
def loadKey(json: String): RSAKey =
RSAKey.parse(json)
/** Create a JwtService with a new generated key (for testing) */
def withGeneratedKey(): JwtServiceImpl =
new JwtServiceImpl(generateKey())
/** Create a JwtService from a JWK JSON string */
def fromKeyJson(json: String): JwtServiceImpl =
new JwtServiceImpl(loadKey(json))
/**
* Create a JwtService from environment variables.
*
* Looks for JWT_PRIVATE_KEY environment variable containing RSA key in JWK format. If not found, returns None (auth
* will fall back to Basic Auth only).
*/
def fromEnvironment(): Option[JwtServiceImpl] =
sys.env.get("JWT_PRIVATE_KEY").flatMap { keyJson =>
Try {
fromKeyJson(keyJson)
}.toOption
}
}
@@ -0,0 +1,41 @@
package net.eagle0.eagle.auth
/** Configuration for an OAuth provider */
case class OAuthProviderConfig(
clientId: String,
clientSecret: String,
authorizationEndpoint: String,
tokenEndpoint: String,
userInfoEndpoint: String,
scopes: Seq[String]
)
/** OAuth configuration loaded from environment variables */
object OAuthConfig {
def discord: OAuthProviderConfig = OAuthProviderConfig(
clientId = sys.env.getOrElse("DISCORD_CLIENT_ID", ""),
clientSecret = sys.env.getOrElse("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 = Seq("identify", "email")
)
def google: OAuthProviderConfig = OAuthProviderConfig(
clientId = sys.env.getOrElse("GOOGLE_CLIENT_ID", ""),
clientSecret = sys.env.getOrElse("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 = Seq("openid", "email", "profile")
)
/** Check if Discord OAuth is configured */
def isDiscordConfigured: Boolean =
discord.clientId.nonEmpty && discord.clientSecret.nonEmpty
/** Check if Google OAuth is configured */
def isGoogleConfigured: Boolean =
google.clientId.nonEmpty && google.clientSecret.nonEmpty
}
@@ -0,0 +1,213 @@
// Copyright 2025 Dan Crosby
package net.eagle0.eagle.auth
import java.net.{URI, URLEncoder}
import java.net.http.{HttpClient, HttpRequest, HttpResponse}
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util.UUID
import scala.collection.concurrent.TrieMap
import scala.util.Try
import net.eagle0.eagle.api.auth.auth.OAuthProvider
import org.json4s.{DefaultFormats, JObject, JString}
import org.json4s.jvalue2extractable
import org.json4s.jvalue2monadic
import org.json4s.native.Json
/** User info from OAuth provider */
case class ProviderUserInfo(
id: String,
email: String,
avatarUrl: String,
username: String
)
/** Service for OAuth code exchange */
trait OAuthService {
/** Generate OAuth authorization URL */
def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) // (url, state)
/** Exchange authorization code for user info */
def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo]
}
class OAuthServiceImpl extends OAuthService {
private implicit val formats: DefaultFormats.type = DefaultFormats
private val json = new Json(formats)
private val httpClient = HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build()
// State parameter storage with timestamp (for CSRF protection)
private val stateStore = TrieMap[String, Long]()
// State expiration (10 minutes)
private val stateExpirationMs = 600000L
override def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) = {
val config = getConfig(provider)
val state = UUID.randomUUID().toString
stateStore.put(state, System.currentTimeMillis())
// Clean old states
val cutoff = System.currentTimeMillis() - stateExpirationMs
stateStore.filterInPlace((_, timestamp) => timestamp > cutoff)
val params = Map(
"client_id" -> config.clientId,
"redirect_uri" -> redirectUri,
"response_type" -> "code",
"scope" -> config.scopes.mkString(" "),
"state" -> state
)
val queryString = params.map {
case (k, v) =>
s"$k=${URLEncoder.encode(v, StandardCharsets.UTF_8)}"
}
.mkString("&")
val url = s"${config.authorizationEndpoint}?$queryString"
(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")
}
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)
}
}
private def getConfig(provider: OAuthProvider): OAuthProviderConfig =
provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD => OAuthConfig.discord
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => OAuthConfig.google
case _ => throw new IllegalArgumentException(s"Unsupported provider: $provider")
}
private def exchangeCodeForToken(
config: OAuthProviderConfig,
code: String,
redirectUri: String
): Either[String, String] =
Try {
val formData = Map(
"client_id" -> config.clientId,
"client_secret" -> config.clientSecret,
"grant_type" -> "authorization_code",
"code" -> code,
"redirect_uri" -> redirectUri
)
val formBody = formData.map {
case (k, v) =>
s"${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}"
}
.mkString("&")
val request = HttpRequest
.newBuilder()
.uri(URI.create(config.tokenEndpoint))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(formBody))
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
val body = response.body()
if response.statusCode() < 200 || response.statusCode() >= 300 then {
return Left(s"Token exchange failed: ${response.statusCode()} - $body")
}
val parsed = json.parse(body)
val accessToken = (parsed \ "access_token").extract[String]
Right(accessToken)
}.toEither.left.map(e => s"Token exchange error: ${e.getMessage}").flatten
private def fetchUserInfo(
provider: OAuthProvider,
config: OAuthProviderConfig,
accessToken: String
): Either[String, ProviderUserInfo] =
Try {
val request = HttpRequest
.newBuilder()
.uri(URI.create(config.userInfoEndpoint))
.timeout(Duration.ofSeconds(10))
.header("Authorization", s"Bearer $accessToken")
.header("Accept", "application/json")
.GET()
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
val body = response.body()
if response.statusCode() < 200 || response.statusCode() >= 300 then {
return Left(s"User info fetch failed: ${response.statusCode()} - $body")
}
val parsed = json.parse(body)
parseUserInfo(provider, parsed)
}.toEither.left.map(e => s"User info fetch error: ${e.getMessage}").flatten
private def parseUserInfo(
provider: OAuthProvider,
data: org.json4s.JValue
): Either[String, ProviderUserInfo] =
Try {
provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD =>
val id = (data \ "id").extract[String]
val username = (data \ "username").extract[String]
val email = (data \ "email").extractOpt[String].getOrElse("")
val avatarHash = (data \ "avatar").extractOpt[String]
val avatarUrl = avatarHash
.map(hash => s"https://cdn.discordapp.com/avatars/$id/$hash.png")
.getOrElse("")
ProviderUserInfo(id, email, avatarUrl, username)
case OAuthProvider.OAUTH_PROVIDER_GOOGLE =>
val id = (data \ "id").extract[String]
val email = (data \ "email").extractOpt[String].getOrElse("")
val name = (data \ "name").extractOpt[String].getOrElse("")
val avatarUrl = (data \ "picture").extractOpt[String].getOrElse("")
ProviderUserInfo(id, email, avatarUrl, name)
case _ =>
throw new IllegalArgumentException(s"Unsupported provider: $provider")
}
}.toEither.left.map(e => s"Failed to parse user info: ${e.getMessage}")
}
@@ -0,0 +1,176 @@
// Copyright 2025 Dan Crosby
package net.eagle0.eagle.auth
import java.time.Instant
import java.util.UUID
import scala.util.{Success, Using}
import com.google.protobuf.timestamp.Timestamp
import net.eagle0.eagle.internal.user.user.{OAuthIdentity, User, UserDatabase}
import net.eagle0.eagle.service.persistence.Persister
/** Service for managing user records */
trait UserService {
/** Find or create a user from OAuth provider info */
def findOrCreateUser(
provider: String,
providerUserId: String,
email: String,
avatarUrl: String
): User
/** Find user by internal user ID */
def findByUserId(userId: String): Option[User]
/** Find user by display name (case-insensitive) */
def findByDisplayName(displayName: String): Option[User]
/** Set or update display name */
def setDisplayName(userId: String, displayName: String): Either[String, User]
/** Check if display name is available */
def isDisplayNameAvailable(displayName: String): Boolean
}
class UserServiceImpl(persister: Persister) extends UserService {
private val usersPath = "auth/users.pb"
// Thread-safe access to user database
private var database: UserDatabase = loadDatabase()
private val lock = new Object
private def loadDatabase(): UserDatabase =
persister.retrieveAsStream(usersPath) match {
case Success(stream) =>
Using(stream) { s =>
UserDatabase.parseFrom(s)
}.getOrElse(UserDatabase.defaultInstance)
case _ => UserDatabase.defaultInstance
}
private def saveDatabase(): Unit = {
persister.save(usersPath, database.toByteArray)
()
}
private def nowTimestamp: Timestamp = {
val now = Instant.now()
Timestamp(now.getEpochSecond, now.getNano)
}
override def findOrCreateUser(
provider: String,
providerUserId: String,
email: String,
avatarUrl: String
): User = lock.synchronized {
val oauthKey = s"$provider:$providerUserId"
database.oauthIndex.get(oauthKey) match {
case Some(existingUserId) =>
// Update last login, avatar
val user = findByUserIdInternal(existingUserId).get
val updated = user.copy(
lastLoginAt = Some(nowTimestamp),
avatarUrl = avatarUrl
)
updateUser(updated)
updated
case None =>
// Create new user
val userId = UUID.randomUUID().toString
val now = nowTimestamp
val identity = OAuthIdentity(
provider = provider,
providerUserId = providerUserId,
email = email,
linkedAt = Some(now)
)
val user = User(
userId = userId,
displayName = "",
displayNameLower = "",
oauthIdentities = Seq(identity),
createdAt = Some(now),
lastLoginAt = Some(now),
avatarUrl = avatarUrl,
isAdmin = false
)
database = database.copy(
users = database.users :+ user,
oauthIndex = database.oauthIndex + (oauthKey -> userId)
)
saveDatabase()
user
}
}
override def findByUserId(userId: String): Option[User] = lock.synchronized {
findByUserIdInternal(userId)
}
private def findByUserIdInternal(userId: String): Option[User] =
database.users.find(_.userId == userId)
override def findByDisplayName(displayName: String): Option[User] = lock.synchronized {
val lowerName = displayName.toLowerCase
database.displayNameIndex.get(lowerName).flatMap(findByUserIdInternal)
}
override def setDisplayName(userId: String, displayName: String): Either[String, User] =
lock.synchronized {
// Validate: 3-20 chars, alphanumeric + underscore
val validPattern = "^[a-zA-Z0-9_]{3,20}$".r
if !validPattern.matches(displayName) then {
return Left("Display name must be 3-20 characters, alphanumeric and underscore only")
}
val lowerName = displayName.toLowerCase
// Check uniqueness
database.displayNameIndex.get(lowerName) match {
case Some(existingId) if existingId != userId =>
Left("Display name already taken")
case _ =>
findByUserIdInternal(userId) match {
case Some(user) =>
// Remove old name from index if exists
val newIndex = user.displayNameLower match {
case "" => database.displayNameIndex
case old => database.displayNameIndex - old
}
val updated = user.copy(
displayName = displayName,
displayNameLower = lowerName
)
database = database.copy(
users = database.users.filterNot(_.userId == userId) :+ updated,
displayNameIndex = newIndex + (lowerName -> userId)
)
saveDatabase()
Right(updated)
case None =>
Left("User not found")
}
}
}
override def isDisplayNameAvailable(displayName: String): Boolean = lock.synchronized {
!database.displayNameIndex.contains(displayName.toLowerCase)
}
private def updateUser(user: User): Unit = {
database = database.copy(
users = database.users.filterNot(_.userId == user.userId) :+ user
)
saveDatabase()
}
}
@@ -309,14 +309,16 @@ case class EngineImpl(
initialState = this.currentState,
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
functionalRandom = SeededRandom(this.currentState.randomSeed)
).withTCommand { gs =>
commandFactory.makeTCommand(
actingFactionId = factionId,
gameState = gs,
availableCommand = availableCommand,
selectedCommand = selectedCommand
)
}.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
).withTCommandAndLastCommand(
commandGen = gs =>
commandFactory.makeTCommand(
actingFactionId = factionId,
gameState = gs,
availableCommand = availableCommand,
selectedCommand = selectedCommand
),
selectedCommand = selectedCommand
).withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
// Validate that the first result has an acting faction set (required for player commands)
val firstResult = sequencer.actionResults.newValue.headOption
@@ -9,8 +9,6 @@ case class ActionResultWithResultingState(
)
trait ActionResultApplier {
def xpForStatBump(stat: Int): Int
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -56,8 +56,6 @@ class ActionResultApplierImpl(validator: Option[ScalaValidator]) extends ActionR
def validate(province: ProvinceT, roundPhase: RoundPhase): ProvinceT =
validator.fold(province)(_.validate(province, roundPhase))
override def xpForStatBump(stat: Int): Int = GameStateExtensions.xpForStatBump(stat)
override def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -4,8 +4,6 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
trait ActionResultProtoApplier {
def xpForStatBump(stat: Int): Int
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResult]
@@ -47,7 +47,6 @@ import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.run_status.RunStatus
import net.eagle0.eagle.internal.shardok_battle.ShardokBattle
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.settings.{ExtraXpForStatBumpOver100, XpForStatBump}
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.validations.Validator
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
@@ -57,10 +56,6 @@ import net.eagle0.eagle.library.EagleInternalException
class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultProtoApplier {
import validator.validate
override def xpForStatBump(stat: Int): Int =
if stat <= 99 then XpForStatBump.intValue
else XpForStatBump.intValue + ExtraXpForStatBumpOver100.intValue * (stat - 99)
private val trustMax: Int = 100
implicit class _GS(gameState: GameState) {
@@ -9,8 +9,6 @@ case class ActionResultTWithResultingState(
)
trait ActionResultTApplier {
def xpForStatBump(stat: Int): Int
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -6,6 +6,7 @@ import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
object ActionResultTApplierImpl {
/** Creates an ActionResultTApplierImpl with no validation (for tests) */
def apply(): ActionResultTApplierImpl =
new ActionResultTApplierImpl(new ActionResultApplierImpl(None))
@@ -18,8 +19,6 @@ object ActionResultTApplierImpl {
}
class ActionResultTApplierImpl(baseApplier: ActionResultApplier) extends ActionResultTApplier {
override def xpForStatBump(stat: Int): Int = baseApplier.xpForStatBump(stat)
override def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -242,8 +242,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/settings:extra_xp_for_stat_bump_over100",
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:province_event_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
@@ -108,6 +108,7 @@ object GameStateFactionExtensions {
)
val updatedFaction = existingFaction.copy(
name = cf.newName.getOrElse(existingFaction.name),
factionHeadId = cf.newFactionHeadHeroId.getOrElse(existingFaction.factionHeadId),
leaderIds = existingFaction.leaderIds.diff(cf.removedLeaderHeroIds) ++ cf.newLeaderHeroIds,
prestigeModifiers =
@@ -7,9 +7,14 @@ import net.eagle0.eagle.internal.army.Attacking
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MaxCombatUnitCountPerSide, MinVigorForDefend}
import net.eagle0.eagle.library.util.{BattalionSuitability, LegacyBattalionUtils}
import net.eagle0.eagle.library.util.battalion_suitability.BattalionSuitability
import net.eagle0.eagle.library.util.command_choice_helpers.CombatUnitSelector
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.LegacyBattalionUtils
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.proto_converters.SuitableBattalionsConverter
object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
private def sortedFleeProvinces(
@@ -71,9 +76,9 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
val recommendedUnits = CombatUnitSelector.selectedCombatBattalions(
desiredCount = availableHeroes.size.min(MaxCombatUnitCountPerSide.intValue),
heroes = availableHeroes.toVector,
battalions = availableBattalions.toVector,
battalionTypes = gameState.battalionTypes.toVector
heroes = availableHeroes.map(HeroConverter.fromProto).toVector,
battalions = availableBattalions.map(BattalionConverter.fromProto).toVector,
battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
)
val hostileUnits = hostileArmies
.flatMap(_.armies)
@@ -86,10 +91,12 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
.toVector
.map(_.id),
actingProvinceId = province.id,
suitableBattalionsForHeroes = BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.toVector,
bs = availableBattalions.toVector,
bts = gameState.battalionTypes.toVector
suitableBattalionsForHeroes = SuitableBattalionsConverter.toProtoMap(
BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.map(HeroConverter.fromProto).toVector,
bs = availableBattalions.map(BattalionConverter.fromProto).toVector,
bts = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
)
),
availableBattalions = availableBattalionsWithCosts,
availableFleeProvinceIds = sortedFleeProvinces(from = province, gs = gameState),
@@ -11,10 +11,15 @@ import net.eagle0.eagle.api.command.util.battalion_with_food_cost.BattalionWithF
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MaxCombatUnitCountPerSide, MinVigorForMarch}
import net.eagle0.eagle.library.util.{BattalionSuitability, LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.{LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.battalion_suitability.BattalionSuitability
import net.eagle0.eagle.library.util.command_choice_helpers.{CombatUnitSelector, MarchSuppliesHelpers}
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.proto_converters.SuitableBattalionsConverter
object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
@@ -67,14 +72,14 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
Option.when(recommendedSendCount > 0) {
val units = CombatUnitSelector.selectedCombatBattalions(
desiredCount = recommendedSendCount,
heroes = availableHeroes.toVector,
battalions = availableBattalions.toVector,
battalionTypes = gameState.battalionTypes.toVector
heroes = availableHeroes.map(HeroConverter.fromProto).toVector,
battalions = availableBattalions.map(BattalionConverter.fromProto).toVector,
battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
)
RecommendedBattleConfig(
units = units,
food = MarchSuppliesHelpers.foodToTakeOnAttack(
units.flatMap(_.battalionId).map(gameState.battalions),
units.flatMap(_.battalionId).map(gameState.battalions).map(BattalionConverter.fromProto),
originProvince.food
),
gold = MarchSuppliesHelpers.goldToTakeOnAttack(
@@ -97,12 +102,13 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
)
}.toVector,
availableDestinationProvinces = availableDestinations,
suitableBattalionsForHeroes = BattalionSuitability
.suitableBattalionsForHeroes(
hs = availableHeroes.toVector,
bs = availableBattalions.toVector,
bts = gameState.battalionTypes.toVector
),
suitableBattalionsForHeroes = SuitableBattalionsConverter.toProtoMap(
BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.map(HeroConverter.fromProto).toVector,
bs = availableBattalions.map(BattalionConverter.fromProto).toVector,
bts = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
)
),
originProvinceId = originProvince.id,
goldAvailable = originProvince.gold,
foodAvailable = originProvince.food,
@@ -187,10 +187,16 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:max_combat_unit_count_per_side",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_defend",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_suitability",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util/battalion_suitability",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:combat_unit_selector",
"//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/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:suitable_battalions_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
],
)
@@ -446,13 +452,19 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:max_combat_unit_count_per_side",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_march",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_suitability",
"//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/battalion_suitability",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:combat_unit_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:march_supplies_helpers",
"//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:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:suitable_battalions_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
],
)
@@ -1,18 +1,5 @@
load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "action_pkg",
srcs = ["package.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
runtime_deps = [
"@maven//:com_thesamet_scalapb_lenses_3",
],
deps = ["//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto"],
)
scala_library(
name = "check_for_faction_changes_action",
srcs = ["CheckForFactionChangesAction.scala"],
@@ -42,6 +29,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_faction_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:notification_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:faction_destroyed_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:faction_leader_removed_result_type",
@@ -116,7 +104,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/library/settings:portion_of_capacity_for_upgrade_battalion_quest",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util/battalion_type_finder:legacy_battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:quest_fulfillment_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
@@ -149,14 +137,16 @@ scala_library(
exports = [
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:game_history",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/chronicle_event",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -789,7 +779,6 @@ scala_library(
],
deps = [
":hero_backstory_update_action",
"//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/actions/impl/common:protoless_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
@@ -886,7 +875,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/chronicle_event",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:new_round_action_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/llm_request/chronicle_event",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/chronicle_entry",
"//src/main/scala/net/eagle0/eagle/model/state/date",
@@ -6,8 +6,10 @@ import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialRes
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.ransom_validity.RansomValidity
import net.eagle0.eagle.library.util.ReturningHeroes
import net.eagle0.eagle.model.action_result.{NotificationDetails, NotificationT}
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ChangedHeroC}
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ChangedHeroC, NotificationC}
import net.eagle0.eagle.model.action_result.generated_text_request.LlmRequestT.NewFactionHeadMessage
import net.eagle0.eagle.model.action_result.types.{
FactionDestroyedResultType,
FactionLeaderRemovedResultType,
@@ -208,24 +210,47 @@ case class CheckForFactionChangesAction(
revisedFactions match {
case Nil => None
case fs =>
val changedFactionData = fs.map { revisedFaction =>
val originalFaction = factions.find(_.id == revisedFaction.id).get
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
val newFactionName = newHeadHero.flatMap(_.ledFactionName)
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
(
ChangedFactionC(
factionId = revisedFaction.id,
removedLeaderHeroIds = originalFaction.leaderIds.filter(killedHeroIds.contains),
newFactionHeadHeroId = Some(revisedFaction.factionHeadId),
newName = newFactionName
),
NewFactionHeadMessage(
requestId = llmRequestId,
eagleGameId = gameId,
recipientFactionIds = Vector.empty,
alwaysGenerate = false,
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousFactionName = originalFaction.name,
newFactionName = newFactionName,
previousHeadHeroId = originalFaction.factionHeadId
),
NotificationC(
details = NotificationDetails.NewFactionHead(
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousHeadHeroId = originalFaction.factionHeadId
),
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
llm = NotificationT.Llm.Id(llmRequestId),
deferred = true
)
)
}.toVector
Some(
ActionResultC(
actionResultType = FactionLeaderRemovedResultType,
changedFactions = fs
.map(revisedFaction =>
ChangedFactionC(
factionId = revisedFaction.id,
removedLeaderHeroIds = factions
.find(
_.id == revisedFaction.id
)
.get
.leaderIds
.filter(killedHeroIds.contains),
newFactionHeadHeroId = Some(revisedFaction.factionHeadId)
)
)
.toVector
changedFactions = changedFactionData.map(_._1),
newGeneratedTextRequests = changedFactionData.map(_._2),
newNotifications = changedFactionData.map(_._3)
)
)
}
@@ -4,9 +4,9 @@ import net.eagle0.eagle.{BattalionId, FactionId, GameId, HeroId, ProvinceId, Rou
import net.eagle0.eagle.common.battalion_type.BattalionType
import net.eagle0.eagle.library.actions.impl.common.ProtolessSequentialResultsAction
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, PortionOfCapacityForUpgradeBattalionQuest}
import net.eagle0.eagle.library.util.battalion_type_finder.LegacyBattalionTypeFinder
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentUtils
import net.eagle0.eagle.library.util.BattalionTypeFinder
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.date.Date
@@ -49,7 +49,7 @@ case class CheckForFulfilledQuestsAction(
heroBackstoryTextIdLookup: HeroId => String
) extends ProtolessSequentialResultsAction {
private def battalionType(id: BattalionTypeId): BattalionType =
BattalionTypeFinder.battalionType(id, battalionTypes)
LegacyBattalionTypeFinder.battalionType(id, battalionTypes)
private def battalionWithId(id: BattalionId): Option[BattalionT] =
battalions.find(_.id == id)
@@ -28,47 +28,14 @@ import net.eagle0.eagle.common.action_result_notification_details.{
}
import net.eagle0.eagle.common.action_result_notification_details.ShatteredArmyDetails.Reason.{
SHATTERED_ARMY_REASON_BLIZZARD,
SHATTERED_ARMY_REASON_UNKNOWN_UNSPECIFIED,
SHATTERED_ARMY_REASON_WITHDRAW,
Unrecognized
SHATTERED_ARMY_REASON_WITHDRAW
}
import net.eagle0.eagle.common.action_result_type.ActionResultType.FACTION_DESTROYED
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.event_for_chronicle.{
AllianceAcceptedEvent,
BreakAllianceAmbassadorImprisonedEvent,
BreakAllianceEvent,
CapturedHeroExecutedEvent,
CapturedHeroExiledEvent,
CapturedHeroImprisonedEvent,
EventForChronicle,
EventForChronicleDetails,
FactionDestroyedEvent,
HeroDepartedEvent,
InvitationAcceptedEvent,
InvitationAmbassadorImprisonedEvent,
OutlawApprehendedEvent,
PrisonerExecutedEvent,
PrisonersExchangedEvent,
ProvinceConqueredEvent,
ProvinceExpansionEvent,
ProvinceHeldEvent,
RansomPaidEvent,
RansomRejectedEvent,
ShatteredArmyEvent,
SuppressBeastsFailedEvent,
SwearBrotherhoodEvent,
TruceAcceptedEvent,
TruceAmbassadorImprisonedEvent,
VassalExiledEvent
}
import net.eagle0.eagle.internal.event_for_chronicle.ShatteredArmyEvent.Reason.{
SHATTERED_ARMY_EVENT_REASON_BLIZZARD,
SHATTERED_ARMY_EVENT_REASON_UNKNOWN_UNSPECIFIED,
SHATTERED_ARMY_EVENT_REASON_WITHDRAW
}
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.GameHistory
import net.eagle0.eagle.model.action_result.generated_text_request.chronicle_event.*
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.state.date.Date
object ChronicleEventGenerator {
@@ -79,325 +46,341 @@ object ChronicleEventGenerator {
private def notificationEntries(
awrs: ActionWithResultingState
): Vector[EventForChronicle] = {
val detailList: Vector[EventForChronicleDetails] =
relevantNotifications(awrs.actionResult)
.map(_.details)
.collect {
case AllianceAcceptedDetails(
offeringFid,
targetFid,
ambassadorHid,
_ /* unknownFieldSet */
) =>
AllianceAcceptedEvent(
offeringFactionId = offeringFid,
targetFactionId = targetFid,
ambassadorHeroId = ambassadorHid
)
): Vector[ChronicleEvent] = {
val date = DateConverter.fromProto(awrs.gameState.currentDate)
relevantNotifications(awrs.actionResult)
.map(_.details)
.collect {
case AllianceAcceptedDetails(
offeringFid,
targetFid,
ambassadorHid,
_ /* unknownFieldSet */
) =>
AllianceAcceptedChronicleEvent(
date = date,
offeringFactionId = offeringFid,
targetFactionId = targetFid,
ambassadorHeroId = ambassadorHid
)
case CapturedHeroExiledDetails(
capturedFromFactionId,
provinceId,
exiledHeroId,
exilingFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroExiledEvent(
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
exiledHeroId = exiledHeroId,
exilingFactionId = exilingFactionId
)
case CapturedHeroExiledDetails(
capturedFromFactionId,
provinceId,
exiledHeroId,
exilingFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroExiledChronicleEvent(
date = date,
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
exiledHeroId = exiledHeroId,
exilingFactionId = exilingFactionId
)
case CapturedHeroExecutedDetails(
capturedFromFactionId,
provinceId,
executedHeroId,
executingFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroExecutedEvent(
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
executedHeroId = executedHeroId,
executingFactionId = executingFactionId
)
case CapturedHeroExecutedDetails(
capturedFromFactionId,
provinceId,
executedHeroId,
executingFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroExecutedChronicleEvent(
date = date,
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
executedHeroId = executedHeroId,
executingFactionId = executingFactionId
)
case CapturedHeroImprisonedDetails(
capturedFromFactionId,
provinceId,
imprisonedHeroId,
imprisoningFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroImprisonedEvent(
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
imprisonedHeroId = imprisonedHeroId,
imprisoningFactionId = imprisoningFactionId
)
case CapturedHeroImprisonedDetails(
capturedFromFactionId,
provinceId,
imprisonedHeroId,
imprisoningFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroImprisonedChronicleEvent(
date = date,
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
imprisonedHeroId = imprisonedHeroId,
imprisoningFactionId = imprisoningFactionId
)
case HeroDepartureDetails(
departingHeroId,
fromFactionId,
provinceId,
_ /* unknownFieldSet */
) =>
HeroDepartedEvent(
departingHeroId = departingHeroId,
fromFactionId = fromFactionId,
provinceId = provinceId
)
case HeroDepartureDetails(
departingHeroId,
fromFactionId,
provinceId,
_ /* unknownFieldSet */
) =>
HeroDepartedChronicleEvent(
date = date,
departingHeroId = departingHeroId,
fromFactionId = fromFactionId,
provinceId = provinceId
)
case SuppressBeastsFailedDetails(
heroId,
provinceId,
_ /* singularName */,
pluralName,
_ /* unknownFieldSet */
) =>
SuppressBeastsFailedEvent(
heroId = heroId,
provinceId = provinceId,
pluralName = pluralName
)
case SuppressBeastsFailedDetails(
heroId,
provinceId,
singularName,
pluralName,
_ /* unknownFieldSet */
) =>
SuppressBeastsFailedChronicleEvent(
date = date,
heroId = heroId,
provinceId = provinceId,
singularName = singularName,
pluralName = pluralName
)
case PrisonerExecutedDetails(
lastFactionId,
provinceId,
executedHeroId,
executingFactionId,
_ /* unknownFieldSet */
) =>
PrisonerExecutedEvent(
lastFactionId = lastFactionId,
provinceId = provinceId,
executedHeroId = executedHeroId,
executingFactionId = executingFactionId
)
case PrisonerExecutedDetails(
lastFactionId,
provinceId,
executedHeroId,
executingFactionId,
_ /* unknownFieldSet */
) =>
PrisonerExecutedChronicleEvent(
date = date,
lastFactionId = lastFactionId,
provinceId = provinceId,
executedHeroId = executedHeroId,
executingFactionId = executingFactionId
)
case PrisonerExchangeDetails(
hero1Id,
hero1FactionId,
hero1ProvinceId,
hero2Id,
hero2FactionId,
hero2ProvinceId,
_ /* unknownFieldSet */
) =>
PrisonersExchangedEvent(
hero1Id = hero1Id,
hero1FactionId = hero1FactionId,
hero1ProvinceId = hero1ProvinceId,
hero2Id = hero2Id,
hero2FactionId = hero2FactionId,
hero2ProvinceId = hero2ProvinceId
)
case PrisonerExchangeDetails(
hero1Id,
hero1FactionId,
hero1ProvinceId,
hero2Id,
hero2FactionId,
hero2ProvinceId,
_ /* unknownFieldSet */
) =>
PrisonersExchangedChronicleEvent(
date = date,
hero1Id = hero1Id,
hero1FactionId = hero1FactionId,
hero1ProvinceId = hero1ProvinceId,
hero2Id = hero2Id,
hero2FactionId = hero2FactionId,
hero2ProvinceId = hero2ProvinceId
)
case ProvinceConqueredDetails(
provinceId,
conqueringFactionId,
otherAttackingFactionIds,
defendingFactionId,
_ /* unknownFieldSet */
) =>
ProvinceConqueredEvent(
provinceId = provinceId,
conqueringFactionId = conqueringFactionId,
otherAttackingFactionIds = otherAttackingFactionIds,
defendingFactionId = defendingFactionId
)
case ProvinceConqueredDetails(
provinceId,
conqueringFactionId,
otherAttackingFactionIds,
defendingFactionId,
_ /* unknownFieldSet */
) =>
ProvinceConqueredChronicleEvent(
date = date,
provinceId = provinceId,
conqueringFactionId = conqueringFactionId,
otherAttackingFactionIds = otherAttackingFactionIds.toVector,
defendingFactionId = defendingFactionId
)
case ProvinceExpansionDetails(
provinceId,
expandingFactionId,
_ /* unknownFieldSet */
) =>
ProvinceExpansionEvent(
provinceId = provinceId,
expandingFactionId = expandingFactionId
)
case ProvinceExpansionDetails(
provinceId,
expandingFactionId,
_ /* unknownFieldSet */
) =>
ProvinceExpansionChronicleEvent(
date = date,
provinceId = provinceId,
expandingFactionId = expandingFactionId
)
case ProvinceHeldDetails(
provinceId,
attackingFids,
defendingFid,
_ /* unknownFieldSet */
) =>
ProvinceHeldEvent(
provinceId = provinceId,
attackingFactionIds = attackingFids,
defendingFactionId = defendingFid
)
case ProvinceHeldDetails(
provinceId,
attackingFids,
defendingFid,
_ /* unknownFieldSet */
) =>
ProvinceHeldChronicleEvent(
date = date,
provinceId = provinceId,
attackingFactionIds = attackingFids.toVector,
defendingFactionId = defendingFid
)
case SwearBrotherhoodDetails(
factionId,
newBrotherHeroId,
_ /* unknownFieldSet */
) =>
SwearBrotherhoodEvent(
factionId = factionId,
newBrotherHeroId = newBrotherHeroId
)
case SwearBrotherhoodDetails(
factionId,
newBrotherHeroId,
_ /* unknownFieldSet */
) =>
SwearBrotherhoodChronicleEvent(
date = date,
factionId = factionId,
newBrotherHeroId = newBrotherHeroId
)
case InvitationAcceptedDetails(
offeringFactionId,
invitedFactionId,
_ /* unknownFieldSet */
) =>
InvitationAcceptedEvent(
offeringFactionId = offeringFactionId,
invitedFactionId = invitedFactionId
)
case InvitationAcceptedDetails(
offeringFactionId,
invitedFactionId,
_ /* unknownFieldSet */
) =>
InvitationAcceptedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
invitedFactionId = invitedFactionId
)
case InvitationAmbassadorImprisonedDetails(
offeringFactionId,
invitedFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
InvitationAmbassadorImprisonedEvent(
offeringFactionId = offeringFactionId,
invitedFactionId = invitedFactionId,
ambassadorHeroId = ambassadorHeroId
)
case InvitationAmbassadorImprisonedDetails(
offeringFactionId,
invitedFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
InvitationAmbassadorImprisonedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
invitedFactionId = invitedFactionId,
ambassadorHeroId = ambassadorHeroId
)
case TruceAcceptedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
TruceAcceptedEvent(
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case TruceAcceptedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
TruceAcceptedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case TruceAmbassadorImprisonedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
TruceAmbassadorImprisonedEvent(
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case TruceAmbassadorImprisonedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
TruceAmbassadorImprisonedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case BreakAllianceAcceptedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
BreakAllianceEvent(
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case BreakAllianceAcceptedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
BreakAllianceChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case BreakAllianceAmbassadorImprisonedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
BreakAllianceAmbassadorImprisonedEvent(
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case BreakAllianceAmbassadorImprisonedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
BreakAllianceAmbassadorImprisonedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case OutlawApprehendedDetails(
factionId,
provinceId,
apprehendedHeroId,
_ /* unknownFieldSet */
) =>
OutlawApprehendedEvent(
factionId = factionId,
provinceId = provinceId,
apprehendedHeroId = apprehendedHeroId
)
case OutlawApprehendedDetails(
factionId,
provinceId,
apprehendedHeroId,
_ /* unknownFieldSet */
) =>
OutlawApprehendedChronicleEvent(
date = date,
factionId = factionId,
provinceId = provinceId,
apprehendedHeroId = apprehendedHeroId
)
case RansomPaidDetails(
ransomedHeroId,
ransomPaidByFactionId,
ransomPaidToFactionId,
_ /* unknownFieldSet */
) =>
RansomPaidEvent(
ransomedHeroId = ransomedHeroId,
ransomPaidByFactionId = ransomPaidByFactionId,
ransomPaidToFactionId = ransomPaidToFactionId
)
case RansomPaidDetails(
ransomedHeroId,
ransomPaidByFactionId,
ransomPaidToFactionId,
_ /* unknownFieldSet */
) =>
RansomPaidChronicleEvent(
date = date,
ransomedHeroId = ransomedHeroId,
ransomPaidByFactionId = ransomPaidByFactionId,
ransomPaidToFactionId = ransomPaidToFactionId
)
case RansomRejectedDetails(
ransomedHeroId,
ransomRefusedByFactionId,
ransomOfferingFactionId,
_ /* unknownFieldSet */
) =>
RansomRejectedEvent(
ransomedHeroId = ransomedHeroId,
ransomRejectedByFactionId = ransomRefusedByFactionId,
ransomOfferingFactionId = ransomOfferingFactionId
)
case RansomRejectedDetails(
ransomedHeroId,
ransomRefusedByFactionId,
ransomOfferingFactionId,
_ /* unknownFieldSet */
) =>
RansomRejectedChronicleEvent(
date = date,
ransomedHeroId = ransomedHeroId,
ransomRejectedByFactionId = ransomRefusedByFactionId,
ransomOfferingFactionId = ransomOfferingFactionId
)
case ShatteredArmyDetails(
factionId,
provinceId,
reason,
_ /* unknownFieldSet */
) =>
ShatteredArmyEvent(
factionId = factionId,
provinceId = provinceId,
reason = reason match {
case SHATTERED_ARMY_REASON_BLIZZARD =>
SHATTERED_ARMY_EVENT_REASON_BLIZZARD
case SHATTERED_ARMY_REASON_UNKNOWN_UNSPECIFIED =>
SHATTERED_ARMY_EVENT_REASON_UNKNOWN_UNSPECIFIED
case SHATTERED_ARMY_REASON_WITHDRAW =>
SHATTERED_ARMY_EVENT_REASON_WITHDRAW
case Unrecognized(value) =>
ShatteredArmyEvent.Reason.Unrecognized(value)
}
)
case ShatteredArmyDetails(
factionId,
provinceId,
reason,
_ /* unknownFieldSet */
) =>
ShatteredArmyChronicleEvent(
date = date,
factionId = factionId,
provinceId = provinceId,
reason = reason match {
case SHATTERED_ARMY_REASON_BLIZZARD =>
ShatteredArmyChronicleEvent.Blizzard
case SHATTERED_ARMY_REASON_WITHDRAW =>
ShatteredArmyChronicleEvent.Withdraw
case _ =>
ShatteredArmyChronicleEvent.Withdraw // Default fallback
}
)
case VassalExiledDetails(
factionId,
provinceId,
exiledHeroId,
_ /* unknownFieldSet */
) =>
VassalExiledEvent(
factionId = factionId,
provinceId = provinceId,
exiledHeroId = exiledHeroId
)
}
detailList.map { details =>
EventForChronicle(
awrs.gameState.currentDate,
details = details
)
}
case VassalExiledDetails(
factionId,
provinceId,
exiledHeroId,
_ /* unknownFieldSet */
) =>
VassalExiledChronicleEvent(
date = date,
factionId = factionId,
provinceId = provinceId,
exiledHeroId = exiledHeroId
)
}
}
private def basicActionResultEntries(
awrs: ActionWithResultingState
): Vector[EventForChronicle] =
): Vector[ChronicleEvent] =
awrs.actionResult.`type` match {
case FACTION_DESTROYED =>
val date = DateConverter.fromProto(awrs.gameState.currentDate)
awrs.actionResult.removedFactionIds.map { factionId =>
EventForChronicle(
date = awrs.gameState.currentDate,
details = FactionDestroyedEvent(factionId = factionId)
FactionDestroyedChronicleEvent(
date = date,
factionId = factionId
)
}.toVector
@@ -407,7 +390,7 @@ object ChronicleEventGenerator {
def eventTextEntries(
gameHistory: GameHistory,
since: Date
): Vector[EventForChronicle] =
): Vector[ChronicleEvent] =
gameHistory.sinceDate(since).flatMap { (awrs: ActionWithResultingState) =>
notificationEntries(awrs) ++ basicActionResultEntries(awrs)
}
@@ -31,7 +31,6 @@ import net.eagle0.eagle.model.action_result.concrete.{
import net.eagle0.eagle.model.action_result.generated_text_request.{ChronicleUpdatePreviousEntry, LlmRequestT}
import net.eagle0.eagle.model.action_result.types.NewRoundActionResultType
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.generated_text_request.chronicle_event.ChronicleEventConverter
import net.eagle0.eagle.model.state.chronicle_entry.ChronicleEntry
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.faction.FactionRelationship
@@ -117,14 +116,12 @@ case class NewRoundAction(
generatedTextId = entry.generatedTextId
)
},
new_entries = ChronicleEventGenerator
.eventTextEntries(
gameHistory = gameHistory,
since = gs.chronicleEntries.lastOption
.map(_.date)
.getOrElse(Date(year = 0, month = Date.Month.January))
)
.map(ChronicleEventConverter.fromProto)
new_entries = ChronicleEventGenerator.eventTextEntries(
gameHistory = gameHistory,
since = gs.chronicleEntries.lastOption
.map(_.date)
.getOrElse(Date(year = 0, month = Date.Month.January))
)
)
)
else Vector()
@@ -89,7 +89,7 @@ case class PerformVassalCommandsPhaseAction(
commandOptions.collectFirst {
case ac: RestAvailableCommand =>
ac
}.isDefined && shouldRest(heroes)
}.isDefined && shouldRestProto(heroes)
) {
chosenRestCommand(actingFactionId, gsProto, commandOptions, reason)
}
@@ -1,10 +0,0 @@
package net.eagle0.eagle.library.actions.impl
import net.eagle0.eagle.internal.action_result.ActionResult
import scalapb.lenses
import scalapb.lenses.Lens
package object action {
type ActionResultProtoUpdater =
Lens[ActionResult, ActionResult] => lenses.Mutation[ActionResult]
}
@@ -1410,7 +1410,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:train_charisma_xp",
"//src/main/scala/net/eagle0/eagle/library/settings:train_stat_divisor",
"//src/main/scala/net/eagle0/eagle/library/settings:train_strength_xp",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
@@ -189,7 +189,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:vigor_to_constitution_xp_multiplier",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
@@ -1,22 +1,10 @@
package net.eagle0.eagle.library.actions.impl.common
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.library.settings.VigorToConstitutionXpMultiplier
import net.eagle0.eagle.model.action_result.concrete.{ChangedHeroC, StatAbsolute, StatDelta, StatNoChange}
import net.eagle0.eagle.model.action_result.ActionResultT
object VigorXPApplier {
def withVigorXp(actionResult: ActionResult): ActionResult =
actionResult.update(
_.changedHeroes.modify(_.map { ch =>
ch.update(
_.optionalConstitutionXpDelta := ch.vigor.vigorDelta
.filter(_ < 0)
.map(vigor => (vigor * -VigorToConstitutionXpMultiplier.doubleValue).ceil.toInt)
)
})
)
def withVigorXp(actionResult: ActionResultT): ActionResultT =
actionResult.copy(
changedHeroes = actionResult.changedHeroes.map {
@@ -347,6 +347,24 @@ scala_library(
deps = [],
)
scala_library(
name = "new_faction_head_prompt_generator",
srcs = ["NewFactionHeadPromptGenerator.scala"],
visibility = ["//visibility:public"],
deps = [
":faction_info_utilities",
":generator_utilities",
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
],
)
scala_library(
name = "please_recruit_me_prompt_generator",
srcs = ["PleaseRecruitMePromptGenerator.scala"],
@@ -0,0 +1,73 @@
package net.eagle0.eagle.library.actions.llm_prompt_generators
import net.eagle0.eagle.client_text.{ClientTextStore, TextGenerationResult}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.generated_text_request.NewFactionHeadMessage
case class NewFactionHeadPromptGenerator(
newFactionHeadMessage: NewFactionHeadMessage,
gameState: GameState,
clientTextStore: ClientTextStore
) extends LLMPromptGenerator {
private val newHeadHero = HeroInfoUtilities.getHero(
newFactionHeadMessage.newHeadHeroId,
gameState
)
private val previousHeadHero = HeroInfoUtilities.getHero(
newFactionHeadMessage.previousHeadHeroId,
gameState
)
private val faction = FactionInfoUtilities.getFaction(
newFactionHeadMessage.factionId,
gameState
)
override def generate: TextGenerationResult = for {
newHeadHeroDescription <- HeroDescriptionGenerator.description(
heroId = newFactionHeadMessage.newHeadHeroId,
gameState = gameState,
clientTextStore = clientTextStore
)
previousHeadHeroDescription <- HeroDescriptionGenerator.description(
heroId = newFactionHeadMessage.previousHeadHeroId,
gameState = gameState,
clientTextStore = clientTextStore
)
newHeadHeroName <- clientTextStore.getText(newHeadHero.nameTextId)
previousHeadHeroName <- clientTextStore.getText(previousHeadHero.nameTextId)
setup <- GeneratorUtilities.basicSetup(gameState, clientTextStore)
} yield {
val previousFactionName = newFactionHeadMessage.previousFactionName
val newFactionName = Option(newFactionHeadMessage.newFactionName).filter(_.nonEmpty)
val renameClause = newFactionName match {
case Some(newName) =>
s" $newHeadHeroName is also renaming the faction from \"$previousFactionName\" to \"$newName\"."
case None => ""
}
val possP = HeroDescriptionGenerator.possessivePronoun(newHeadHero.pronounGender)
val inputText =
s"""$setup
|
|NEW FACTION HEAD:
|$newHeadHeroDescription
|
|PREVIOUS FACTION HEAD (EXECUTED):
|$previousHeadHeroDescription
|
|$previousHeadHeroName, the previous head of the faction "$previousFactionName", has been executed.
|$newHeadHeroName has now become the new head of the faction.$renameClause
|
|Write a message from $newHeadHeroName to be delivered to the world, declaring $possP new leadership
|in the wake of $previousHeadHeroName's execution. The message should reflect $possP personality and
|ambitions, and may reference the circumstances of $possP rise to power or their relationship with
|the previous leader. If the faction is being renamed, the declaration should explain why the new
|name represents $possP vision for the faction.
|
|${GeneratorUtilities.limitations}""".stripMargin
inputText
}
}
@@ -1,6 +1,7 @@
package net.eagle0.eagle.library.actions.random_state_sequencer
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.actions.applier.{ActionResultApplier, ActionResultWithResultingState}
import net.eagle0.eagle.library.actions.impl.common.{
ProtolessRandomSimpleAction,
@@ -79,6 +80,15 @@ trait RandomStateSequencer {
case TCommand.RandomSimple(action) => withProtolessRandomSimpleAction(_ => action)
case TCommand.Sequential(action) => withProtolessSequentialResultsAction(_ => action)
}
/**
* Executes a TCommand and wraps the first result with lastCommandTypeForActingProvince. This mimics the old
* Command.resultWithLastCommand behavior.
*/
def withTCommandAndLastCommand(
commandGen: GameState => TCommand,
selectedCommand: SelectedCommand
): RandomStateSequencer
}
object RandomStateSequencer {
@@ -176,4 +186,29 @@ private case class RandomStateSequencerImpl(
case (sequencer, t) =>
sequencer.withRandomActionResult((gs, fr) => f(t, gs, fr))
}
override def withTCommandAndLastCommand(
commandGen: GameState => TCommand,
selectedCommand: SelectedCommand
): RandomStateSequencer = {
def wrapWithLastCommand(result: ActionResultT): ActionResultT =
result.withLastCommandTypeForActingProvince(selectedCommand)
commandGen(lastState) match {
case TCommand.Simple(action) =>
withActionResult(_ => wrapWithLastCommand(action.immediateExecute))
case TCommand.RandomSimple(action) =>
withRandomActionResult((gs, fr) => action.immediateExecute(fr).map(wrapWithLastCommand))
case TCommand.Sequential(action) =>
action.results.toVector match {
case head +: tail =>
withActionResult(_ => wrapWithLastCommand(head))
.withActionResults(_ => tail)
case empty =>
this
}
}
}
}
@@ -35,43 +35,6 @@ scala_library(
],
)
scala_library(
name = "battalion_suitability",
srcs = ["BattalionSuitability.scala"],
visibility = [
"__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api/command/util:appropriate_battalions_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
],
)
scala_library(
name = "battalion_type_finder",
srcs = ["BattalionTypeFinder.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
)
scala_library(
name = "battalion_power",
srcs = ["BattalionPower.scala"],
@@ -311,10 +274,24 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
scala_library(
name = "legacy_province_distances",
srcs = ["LegacyProvinceDistances.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
],
deps = [
":province_distances",
"//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:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -194,7 +194,8 @@ object GameStateViewDiffer {
changedFactionRelationships = after.factionRelationships.diff(before.factionRelationships),
addedIncomingDiplomacyOffers = after.incomingDiplomacyOffers.diff(before.incomingDiplomacyOffers),
removedIncomingDiplomacyOffers = before.incomingDiplomacyOffers.diff(after.incomingDiplomacyOffers),
newFocusProvinceId = after.focusProvinceId
newFocusProvinceId = after.focusProvinceId,
newName = optionalDiff(before.name, after.name)
)
)
@@ -0,0 +1,59 @@
package net.eagle0.eagle.library.util
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.ProvinceDistances.{closestProvinceOption, distance, ProvinceAndDistance}
/** Legacy proto-based province distance utilities. Use ProvinceDistances for protoless code. */
object LegacyProvinceDistances {
def distanceThroughFriendliesOption(
p1: ProvinceId,
p2: ProvinceId,
fid: FactionId,
gs: GameState
): Option[Int] = distance(p1, p2, LegacyFactionUtils.ownedNeighbors(gs, fid))
def closestProvinceThroughFriendliesOption(
to: ProvinceId,
candidates: Vector[ProvinceId],
fid: FactionId,
gs: GameState
): Option[ProvinceAndDistance] =
closestProvinceOption(
to,
candidates.filter(pid => gs.provinces(pid).rulingFactionId.contains(fid)),
LegacyFactionUtils.ownedNeighbors(gs, fid)
)
def closestNeighborToFaction(
startingProvinceId: ProvinceId,
factionId: FactionId,
provinces: Map[ProvinceId, Province]
): Option[ProvinceId] = {
val factionProvinceIds = provinces.values
.filter(_.rulingFactionId.contains(factionId))
.map(_.id)
.toVector
if factionProvinceIds.isEmpty then None
else
provinces(startingProvinceId).neighbors
.map(_.provinceId)
.toVector
.minByOption { neighborPid =>
closestProvinceOption(
to = neighborPid,
candidates = factionProvinceIds,
eligibleNeighbors = pid =>
provinces
.get(pid)
.map(_.neighbors.map(_.provinceId).toVector)
.getOrElse(Vector())
).map(_.distance).getOrElse(Int.MaxValue)
}
end if
}
}
@@ -3,9 +3,7 @@ package net.eagle0.eagle.library.util
import scala.annotation.tailrec
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.faction_utils.FactionUtils
import net.eagle0.eagle.model.state.province.ProvinceT
object ProvinceDistances {
@@ -14,8 +12,8 @@ object ProvinceDistances {
p1: ProvinceId,
p2: ProvinceId,
fid: FactionId,
gs: GameState
): Option[Int] = distance(p1, p2, LegacyFactionUtils.ownedNeighbors(gs, fid))
provinces: Map[ProvinceId, ProvinceT]
): Option[Int] = distance(p1, p2, FactionUtils.ownedNeighbors(provinces, fid))
def distance(
p1: ProvinceId,
@@ -34,18 +32,6 @@ object ProvinceDistances {
else go(Vector(Set(p1)))
}
def closestProvinceThroughFriendliesOption(
to: ProvinceId,
candidates: Vector[ProvinceId],
fid: FactionId,
gs: GameState
): Option[ProvinceAndDistance] =
closestProvinceOption(
to,
candidates.filter(pid => gs.provinces(pid).rulingFactionId.contains(fid)),
LegacyFactionUtils.ownedNeighbors(gs, fid)
)
def closestProvinceOption(
to: ProvinceId,
candidates: Vector[ProvinceId],
@@ -80,40 +66,11 @@ object ProvinceDistances {
}.toMap.get
}
def closestNeighborToFaction(
startingProvinceId: ProvinceId,
factionId: FactionId,
provinces: Map[ProvinceId, Province]
): Option[ProvinceId] = {
val factionProvinceIds = provinces.values
.filter(_.rulingFactionId.contains(factionId))
.map(_.id)
.toVector
if factionProvinceIds.isEmpty then None
else
provinces(startingProvinceId).neighbors
.map(_.provinceId)
.toVector
.minByOption { neighborPid =>
closestProvinceOption(
to = neighborPid,
candidates = factionProvinceIds,
eligibleNeighbors = pid =>
provinces
.get(pid)
.map(_.neighbors.map(_.provinceId).toVector)
.getOrElse(Vector())
).map(_.distance).getOrElse(Int.MaxValue)
}
end if
}
def closestNeighborToFaction(
startingProvinceId: ProvinceId,
factionId: FactionId,
provinces: Map[ProvinceId, ProvinceT]
)(using DummyImplicit): Option[ProvinceId] = {
): Option[ProvinceId] = {
val factionProvinceIds = provinces.values
.filter(_.rulingFactionId.contains(factionId))
.map(_.id)
@@ -0,0 +1,38 @@
load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "battalion_suitability",
srcs = ["BattalionSuitability.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__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/model/proto_converters:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/battalion_suitability:__pkg__",
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
],
)
scala_library(
name = "legacy_battalion_suitability",
srcs = ["LegacyBattalionSuitability.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/battalion_suitability:__pkg__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api/command/util:appropriate_battalions_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
],
)
@@ -0,0 +1,61 @@
package net.eagle0.eagle.library.util.battalion_suitability
import net.eagle0.eagle.{BattalionId, HeroId}
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.{Engineer, Mage, Necromancer, Ranger}
import net.eagle0.eagle.model.state.BattalionType
enum SuitabilityLevel:
case Optimal, Suboptimal, Restrictive
case class BattalionIdWithSuitability(
battalionId: BattalionId,
level: SuitabilityLevel
)
case class SuitableBattalions(
battalionIdsWithSuitability: Vector[BattalionIdWithSuitability]
)
object BattalionSuitability {
def suitabilityLevel(h: HeroT, bt: BattalionType): SuitabilityLevel =
h.profession match {
case Mage | Necromancer =>
if !bt.allowsCasting then SuitabilityLevel.Restrictive
else if bt.alwaysArcheryCapable then SuitabilityLevel.Suboptimal
else SuitabilityLevel.Optimal
case Engineer =>
if bt.alwaysArcheryCapable then SuitabilityLevel.Suboptimal
else SuitabilityLevel.Optimal
case Ranger =>
if !bt.allowsStealth then SuitabilityLevel.Restrictive
else SuitabilityLevel.Optimal
case _ => SuitabilityLevel.Optimal
}
def suitableBattalions(
h: HeroT,
bs: Vector[BattalionT],
bts: Vector[BattalionType]
): SuitableBattalions =
SuitableBattalions(
battalionIdsWithSuitability = bs.map(b =>
BattalionIdWithSuitability(
battalionId = b.id,
level = BattalionSuitability
.suitabilityLevel(h, bts.find(_.typeId == b.typeId).get)
)
)
)
def suitableBattalionsForHeroes(
hs: Vector[HeroT],
bs: Vector[BattalionT],
bts: Vector[BattalionType]
): Map[HeroId, SuitableBattalions] =
hs.map(h => h.id -> suitableBattalions(h, bs, bts)).toMap
}
@@ -1,4 +1,4 @@
package net.eagle0.eagle.library.util
package net.eagle0.eagle.library.util.battalion_suitability
import net.eagle0.eagle.api.command.util.appropriate_battalions.SuitableBattalions
import net.eagle0.eagle.api.command.util.appropriate_battalions.SuitableBattalions.{
@@ -10,25 +10,25 @@ import net.eagle0.eagle.api.command.util.appropriate_battalions.SuitableBattalio
RESTRICTIVE,
SUBOPTIMAL
}
import net.eagle0.eagle.common.battalion_type.BattalionType
import net.eagle0.eagle.common.profession.Profession.{ENGINEER, MAGE, NECROMANCER, RANGER}
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.{Engineer, Mage, Necromancer, Ranger}
import net.eagle0.eagle.model.state.BattalionType
import net.eagle0.eagle.HeroId
object BattalionSuitability {
def suitabilityLevel(h: Hero, bt: BattalionType): SuitabilityLevel =
object LegacyBattalionSuitability {
def suitabilityLevel(h: HeroT, bt: BattalionType): SuitabilityLevel =
h.profession match {
case MAGE | NECROMANCER =>
case Mage | Necromancer =>
if !bt.allowsCasting then RESTRICTIVE
else if bt.alwaysArcheryCapable then SUBOPTIMAL
else OPTIMAL
case ENGINEER =>
case Engineer =>
if bt.alwaysArcheryCapable then SUBOPTIMAL
else OPTIMAL
case RANGER =>
case Ranger =>
if !bt.allowsStealth then RESTRICTIVE
else OPTIMAL
@@ -36,23 +36,23 @@ object BattalionSuitability {
}
def suitableBattalions(
h: Hero,
bs: Vector[Battalion],
h: HeroT,
bs: Vector[BattalionT],
bts: Vector[BattalionType]
): SuitableBattalions =
SuitableBattalions(
battalionIdsWithSuitability = bs.map(b =>
BattalionIdWithSuitability(
battalionId = b.id,
level = BattalionSuitability
.suitabilityLevel(h, bts.find(_.typeId == b.`type`).get)
level = LegacyBattalionSuitability
.suitabilityLevel(h, bts.find(_.typeId == b.typeId).get)
)
)
)
def suitableBattalionsForHeroes(
hs: Vector[Hero],
bs: Vector[Battalion],
hs: Vector[HeroT],
bs: Vector[BattalionT],
bts: Vector[BattalionType]
): Map[HeroId, SuitableBattalions] =
hs.map(h => h.id -> suitableBattalions(h, bs, bts)).toMap
@@ -0,0 +1,39 @@
load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "battalion_type_finder",
srcs = ["BattalionTypeFinder.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
deps = [
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
)
scala_library(
name = "legacy_battalion_type_finder",
srcs = ["LegacyBattalionTypeFinder.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
)
@@ -0,0 +1,11 @@
package net.eagle0.eagle.library.util.battalion_type_finder
import net.eagle0.eagle.model.state.{BattalionType, BattalionTypeId}
object BattalionTypeFinder {
def battalionType(
battalionTypeId: BattalionTypeId,
battalionTypes: Vector[BattalionType]
): BattalionType =
battalionTypes.find(_.typeId == battalionTypeId).get
}

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