Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 c5d636a94c Convert command_choice_helpers to use Scala types instead of proto
Migrate command_choice_helpers package to use native Scala types internally:
- CommandChooser, FoodConsumptionUtils, ProvinceGoldSurplusCalculator
- OrganizeCommandSelector, ExpandCommandSelector, AttackCommandChooser
- AvailableMarchCommandFactory, AttackDecisionCommandChooser
- Supporting utilities: HeroUtils, ProvinceUtils, BattalionUtils, FactionUtils

Add proto-accepting overloads for backward compatibility with callers
still using proto types. Update tests to use RoundPhase.PLAYER_COMMANDS
since GameStateConverter now validates the phase field.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 07:10:56 -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
d0a63f1c61 Fix sysroot for cross-compilation (#4778)
Issues fixed:
1. Sysroot hosted on GitHub releases but repo is private, causing 404s
2. Sysroot missing GCC directories that clang needs to find libstdc++ headers

Changes:
- Add GCC installation directories to sysroot (clang uses these to locate C++ headers)
- Update workflow to upload sysroot to DO Spaces instead of GitHub releases
- Versioned sysroot paths (v2, v3, etc.) for easier updates

NOTE: After merging, run the "Build Linux Sysroot" workflow with version "v2",
then update MODULE.bazel with the sha256 from the workflow output.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 07:34:02 -08:00
97df984189 Add BLUF section to changelog emails (#4777)
- Add a "Bottom Line Up Front" section after the title with a short prose
  paragraph highlighting the most important changes and what to look for
  when testing
- Wrap HTML output in proper document with UTF-8 charset declaration to
  fix Unicode character rendering (em-dashes, etc.)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 07:30:20 -08:00
6c771df84f Add PR links section to changelog emails (#4776)
Update the Claude prompt to generate a "PR Details" section after the synopsis,
with the same thematic groupings but listing actual PR numbers, titles, and
clickable GitHub links.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 07:23:32 -08:00
7e40420fe1 Update changelog script to use Fastmail JMAP API for HTML emails (#4774)
- Switch from Mac Mail AppleScript to Fastmail JMAP API
- Generate HTML synopsis instead of plain text for better formatting
- Auto-fetch account ID, identity ID, and drafts mailbox from API
- Support config files in ~/.config/eagle0/:
  - fastmail_token: API token (required)
  - changelog_recipient: Email recipients, one per line (optional)
- Support multiple recipients (one email address per line, # for comments)
- Fall back to FASTMAIL_API_TOKEN environment variable for token
- Only require token when not in --dry-run mode

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 07:16:22 -08:00
2ed6ad3c4c Enable Shardok cross-compilation with sysroot (#4775)
* Enable Shardok cross-compilation with sysroot

- Update sha256 with actual value from sysroot release
- Re-enable build-shardok job in docker_build.yml

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

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

* Add DigitalOcean registry authentication for image push

The oci_push rule needs credentials to push to the registry.
Creates ~/.docker/config.json with the auth token before pushing.

Requires DO_REGISTRY_TOKEN_BASE64 secret to be configured:
  echo -n "username:token" | base64

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

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

* Use existing DO_REGISTRY_TOKEN secret for registry auth

Base64 encode the token on the fly instead of requiring a
separate pre-encoded secret.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-23 07:09:59 -08:00
844e2b0407 Add cross-compilation support for Shardok Docker builds (#4772)
* Add cross-compilation support for Shardok Docker builds

This enables building Shardok on the self-hosted Mac runner while
targeting Linux x86_64, avoiding the need for slow GitHub-hosted
Ubuntu runners.

Changes:
- Add Ubuntu 24.04 (Noble) sysroot generation scripts
- Add GitHub Actions workflow to build and release the sysroot
- Configure toolchains_llvm for cross-compilation with sysroot
- Update docker_build.yml to use cross-compilation
- Add linux_x86_64 platform definition

The sysroot contains libstdc++-13 which provides C++23 support
needed by the codebase.

To complete setup:
1. Run the "Build Linux Sysroot" workflow to create the sysroot
2. Update MODULE.bazel with the actual sha256 from the release

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

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

* Temporarily disable Shardok build until sysroot is ready

The cross-compilation sysroot needs to be built and uploaded before
Shardok can be built. Steps to re-enable:
1. Run "Build Linux Sysroot" workflow
2. Update sha256 in MODULE.bazel
3. Uncomment build-shardok job

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-23 06:58:23 -08:00
1617867c60 Add Scala withdrawnFromProvinceView overload, making EndBattleAftermathPhaseAction fully protoless (#4773)
- Add withdrawnFromProvinceView(ProvinceT, ScalaGameState, FactionId) Scala overload
- Add supporting helpers: myIncomingArmiesScala, incomingArmyInfoScala
- Add BattalionViewFilter.limitedBattalionView for Scala BattalionT
- Update EndBattleAftermathPhaseAction to use Scala overload
- Remove unused proto converter imports and BUILD deps

Progress: 46/52 action files (88%) are now fully protoless

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 06:43:58 -08:00
7c49746c37 Add weekly changelog generator script (#4771)
Creates scripts/generate_changelog.sh that:
- Fetches merged PRs since last run (tracked via git tag) or previous Friday 4pm
- Uses Claude CLI to generate a themed synopsis of changes
- Opens an email draft in Mac Mail with the synopsis
- Updates the changelog-last-run tag for next run

Usage: ./scripts/generate_changelog.sh [--dry-run]

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 22:24:52 -08:00
711c6606bb Fix Docker build workflow: use direct OCI push, build Shardok on Linux (#4770)
- Remove Docker dependency by using `bazel run //ci:*_push` instead of
  oci_load + docker tag + docker push
- Build Shardok on ubuntu-latest to produce Linux binary for container
- Add Bazel caching for GitHub-hosted runner

The self-hosted Mac runner doesn't have Docker running, and even if it
did, the Shardok binary would be macOS, not Linux.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-22 21:47:32 -08:00
e142da7c57 Fix InvalidTokenException in custom battles (#4767)
* Fix custom battle shardokGameId collision

Previously, all custom battles for the same eagleGameId used the same
shardokGameId ("${eagleGameId}_1"), causing token mismatch exceptions
when starting a second custom battle while another was running.

The Shardok server would return the OLD game's controller (with its
higher token count), while the Eagle client had a fresh controller
(token=0), resulting in InvalidTokenException.

Fix: Add a counter to generate unique shardokGameIds for each custom
battle: "custom_${eagleGameId}_${counter}".

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

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

* Fix duplicate streaming updates causing InvalidTokenException

The SubscribeToGame RPC was sending results twice:
1. Initial response sent results from known_result_count onwards
2. WaitForUpdatesAndPush started from known_result_count, immediately
   satisfying the wait condition and re-sending the same results

This caused Eagle to receive duplicate results, inflating its count
above Shardok's actual count, leading to token > expectedToken.

Fix: Track total_action_result_count from the initial response and
use that as the starting point for WaitForUpdatesAndPush.

🤖 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-22 21:40:22 -08:00
c8906b1c04 Migrate reconnedProvinces to Scala ProvinceView, making PerformReconResolutionAction fully protoless (#4769)
- Change FactionT.reconnedProvinces from proto ProvinceView to Scala ProvinceView
- Change FactionC.reconnedProvinces from proto ProvinceView to Scala ProvinceView
- Change ChangedFactionC.updatedReconnedProvinces from proto to Scala ProvinceView
- Update FactionConverter and ChangedFactionConverter to convert at boundary
- Remove ProvinceViewConverter.toProto calls from PerformReconResolutionAction
  and EndBattleAftermathPhaseAction
- Update GameStateFactionExtensions import to use Scala ProvinceView
- Update test assertions to use Scala Date type

Progress: 45/52 action files (87%) are now fully protoless

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 21:33:42 -08:00
1c8f328c58 Add Docker image builds for Eagle and Shardok servers (#4768)
- Add rules_oci to MODULE.bazel for OCI container support
- Create ci/BUILD.bazel with oci_image targets for both servers
- Add docker-compose.prod.yml for local testing
- Add GitHub Actions workflow for building and pushing images
- Update resource BUILD files with //ci visibility

Build images: bazel build //ci:eagle_server_image //ci:shardok_server_image
Load locally: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
Push to DO: bazel run //ci:eagle_server_push && bazel run //ci:shardok_server_push

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 19:00:16 -08:00
7ebca60863 Add exponential backoff to stream reconnection (#4763)
When the streaming connection to Shardok fails, Eagle now uses
exponential backoff for reconnection attempts, starting at 1 second
and doubling up to 10 seconds max. This is more robust for handling
transient network issues.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 18:13:59 -08:00
ce22b59a8a Exclude pre-existing font files from LFS tracking (#4765)
These font files were committed as regular blobs before LFS tracking
was set up for *.ttf files. Adding explicit exclusions prevents the
"files that should have been pointers" warning.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 17:15:41 -08:00
1fe062baaa Add productionization plan for cloud deployment (#4764)
Documents the architecture and migration plan to move Eagle and Shardok
servers from home Mac to DigitalOcean cloud infrastructure:

- On-demand Shardok with Eagle lifecycle management
- Docker containerization strategy
- GitHub Actions CI/CD pipeline
- Cost estimates and scaling options
- Migration phases and rollback procedures

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-22 17:08:39 -08:00
6b2ab53574 Remove dead proto code: UnaffiliatedHeroMovedAction and fromGameState (#4762)
- Delete UnaffiliatedHeroMovedAction: was never called from production code;
  PerformUnaffiliatedHeroesAction.heroMovedResult constructs ActionResultC directly
- Delete HeroBackstoryUpdateActionGenerator.fromGameState: dead method that
  converted proto to Scala; only apply(GameState) is used
- Update DEPROTO_PLAN.md: now 44/52 (85%) 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-22 17:04:35 -08:00
6208d2cf10 Include province name in profession gained notification for player's heroes (#4755)
Shows "Your vassal {name} in {province} became a {profession}" instead
of just "Your vassal {name} became a {profession}".

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 22:43:15 -08:00
e105461692 Use Scala ProvinceViewFilter overloads in actions (#4754)
Update PerformReconResolutionAction and EndBattleAftermathPhaseAction
to use the new Scala ProvinceViewFilter.filteredProvinceView overload,
eliminating the need for lazy proto conversion.

- PerformReconResolutionAction: Remove proto GameState conversion entirely
- EndBattleAftermathPhaseAction: Use Scala overload for DidBattle case
  (Withdrew case still needs proto for withdrawnFromProvinceView)
- Remove unused deps from BUILD.bazel

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 22:33:19 -08:00
2e03352dea Replace Eagle polling with streaming subscription (#4756)
Replaces the polling-based gameStatusRunner with server-side streaming via
SubscribeToGame. Updates are now pushed immediately by Shardok instead of
being polled, reducing latency and eliminating polling overhead.

- Replace gameStatusRunner with subscribeToGame using StreamObserver
- Add handleStreamingResponse to process pushed updates
- Add scheduleReconnect for automatic reconnection on stream errors
- Update postCommand/postPlacementCommands to not handle responses
  (updates come via stream)
- Remove dead code: handleBattleResponse, waitingForHumanPlayer

Requires: PR #4753 (server-side streaming implementation)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 22:31:00 -08:00
8c19a93f3c Fix deadlock and missing eagle_faction_id in streaming (#4757)
Two issues fixed:

1. Deadlock: WaitForUpdatesAndPush was calling GetUpdates while holding
   masterLock, but GetUpdates also tries to acquire masterLock.
   Fix: Release lock before calling GetUpdates.

2. Missing eagle_faction_id: Streaming OnUpdate wasn't setting the faction
   ID on filtered responses, so clients couldn't route updates correctly.
   Fix: Move OnePlayerUpdates struct before StreamSubscriber, update OnUpdate
   to take vector<OnePlayerUpdates>, and properly iterate to set faction IDs.

Also added currentGameState to AllUpdates struct.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 22:30:26 -08:00
9b2bce6537 Add server-side streaming RPC for game updates (#4753)
Adds SubscribeToGame streaming RPC to Shardok server, replacing the need for
Eagle to poll via GetGameStatus. Updates are pushed to subscribers when the
game state changes, reducing latency and eliminating continuous polling.

- Add GameSubscriptionRequest message and SubscribeToGame streaming RPC
- Add StreamSubscriber interface for push-based update delivery
- Implement subscriber registration in ShardokGameController
- Add WaitForUpdatesAndPush loop that blocks until updates are available
- Implement GrpcStreamSubscriber to write updates to gRPC stream
- Use separate subscriberLock to avoid deadlock with masterLock

Eagle client-side changes will be in a follow-up PR.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 16:43:07 -08:00
ee4914dcc8 Add Scala overloads for ProvinceViewFilter and dependencies (#4752)
* Add Scala overloads for ProvinceViewFilter and dependencies

Enable ProvinceViewFilter to accept Scala ProvinceT and GameState types
instead of proto types, supporting the ongoing deproto migration for
internal logic. This unblocks dependent actions like EndBattleAftermathPhaseAction.

Changes:
- Add pure Scala filterArmy overload to ArmyFilter
- Add filteredProvinceView(ProvinceT, ScalaGameState) to ProvinceViewFilter
- Add monthlyFoodConsumption Scala overload to ProvinceUtils
- Update BUILD.bazel files with required deps, exports, and visibility

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

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

* Update DEPROTO_PLAN with ProvinceViewFilter progress

- Mark ProvinceViewFilter server-side overload as complete (PR #4752)
- Update View Filters section to show partial completion status
- Mark EndBattleAftermathPhaseAction and PerformReconResolutionAction as unblocked
- Add validation checkbox for server-side ProvinceViewFilter
- Update estimated remaining effort

🤖 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-21 16:36:31 -08:00
1e019c533a Add force reconnect button when server is down (#4748)
- Adds a "Retry" button next to connection status that appears when:
  - Circuit breaker is in Open state (server down)
  - Connection is counting down to a retry attempt
- Clicking the button forces an immediate reconnection attempt,
  bypassing timeouts
- Button is hidden when connected or actively connecting

The button must be wired up in the Unity scene to the ConnectionStatusUI
component's retryButton field.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 15:14:06 -08:00
3d092e580f Fix lock contention by releasing lock during AI thinking (#4749)
The AI thread was holding the master lock for the entire duration of
AI decision-making (which can take seconds). This blocked all polls
from getting updates, causing batching.

New architecture with three phases:
1. Phase 1 (brief lock): Get copies of game state, settings, commands
2. Phase 2 (NO LOCK): AI thinks on the copies - polls can get through
3. Phase 3 (brief lock): Verify state unchanged, post command

If the state changed while thinking (e.g., human posted a command),
we discard the AI decision and re-evaluate with fresh data.

This reduces lock hold time from seconds to milliseconds.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 15:01:36 -08:00
85e530c5a4 Improve profession gained notification for player's own heroes (#4751)
- For faction leaders: "Your sworn {sibling} {name} became a {profession}"
- For vassals: "Your vassal {name} became a {profession}"
- For other factions: "{name} of {faction} became a {profession}" (unchanged)
- Only highlights the province where the hero is located (falls back to
  all faction provinces if hero not found)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 15:00:19 -08:00
486960a6aa Fix unit action indicators not refreshing until clicked (#4750)
- Added UpdateAction?.Invoke() to HandleAvailableCommands so the UI
  refreshes when available commands are updated
- Initialize AvailableCommands to empty list to prevent null reference
  when UpdateAction triggers before first HandleAvailableCommands call

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 14:50:56 -08:00
1cb2dd7b6a Fix GetGameId race condition by caching immutable game_id (#4747)
The previous fix (PR #4732) added mutex protection to GetGameId() and
GetHexMap(), but this caused stalls because GetUpdates() holds the lock
for extended periods while waiting for AI updates.

This fix takes a different approach: since game_id never changes after
game creation, we cache it at construction time. This eliminates the
race condition without any locking overhead.

Also removes the unused GetHexMap() method which had the same thread
safety issue.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 11:13:23 -08:00
5483c732cc Add exception handling to Shardok polling to prevent client freeze (#4745)
* Add exception handling to Shardok polling to prevent client freeze

When handleBattleResponse throws an exception, the polling loop would
stop completely, causing both clients to freeze at the same point.
Exceptions were silently swallowed by the async Future callback.

This fix:
- Wraps the processing in try-catch
- Logs exception details to console for debugging
- Continues polling even on error to prevent freeze

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

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

* Improve comments explaining Shardok polling logic

🤖 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-21 10:39:04 -08:00
6402a8c283 Remove OnApplicationPause debug logging (#4744)
Also includes UI layout adjustments in Gameplay.unity.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 10:38:34 -08:00
adminandGitHub eb762e1bae Revert "Fix additional race conditions in ShardokGameController (#4732)" (#4746)
This reverts commit f3e44fb9cf.
2025-12-21 10:38:16 -08:00
be31464e99 Fix ransom paid notification payer/payee swap (#4743)
The server was setting ransomPaidByFactionId and ransomPaidToFactionId
backwards in ResolveRansomOfferCommand. The acting faction (captor)
should receive the payment, and the originating faction (offering)
should pay.

Also added missing notification for the accepting faction (captor)
in the client, matching the pattern from RansomRejected.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 21:07:16 -08:00
e6f9d4e4ac Fix autoscroll showing blank space past end of text (#4742)
The content RectTransform was only grown to fit text, never shrunk.
If a previous text was longer, scrolling to the bottom would show
blank space past where the text ends. Now the content height is
synced in both directions.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 20:23:56 -08:00
4d3b2ddb36 Cap retry countdown display at 10 seconds (#4740)
The actual retry timeout remains 60 seconds, but the UI now shows
a maximum of "10s" to avoid overwhelming users with long countdowns.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 20:10:01 -08:00
6edb4de0dc Fix Shardok updates batching by only checking human player commands (#4741)
The hasHumanPlayerCommands method was checking if ANY player (human or AI)
had available Shardok commands. When an AI player had commands that Shardok
handles internally, this would return true, causing Eagle to stop polling
Shardok for updates until a human posted a command.

This caused Shardok battle updates to batch up and arrive all at once
instead of streaming in real-time.

Fix: Only check human faction IDs when determining if we should wait for
a command to be posted.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 20:09:50 -08:00
72a0f84105 Update ProvinceViewFilter to return Scala types instead of proto (#4728)
* Update ProvinceViewFilter to return Scala types instead of proto

- ProvinceViewFilter now returns Scala ProvinceView instead of proto
- Added Scala helper methods in ArmyFilter, LegacyBattalionViewFilter, StatWithConditionUtils
- Updated callers (EndBattleAftermathPhaseAction, PerformReconResolutionAction,
  GameStateViewFilter) to convert back to proto using ProvinceViewConverter.toProto()
- Added default values to Scala case classes (ProvinceView, FullProvinceInfo,
  IncomingArmyView, UnaffiliatedHeroBasics)
- Fixed recruitmentInfo handling to use fold/getOrElse with RecruitmentInfo.Unknown
- Updated ProvinceViewFilterTest to use proto type aliases for Faction.reconnedProvinces
- Added tests for view converters

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

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

* Add lastCommand field to ProvinceT/ProvinceC

Add the lastCommand field that was present in province.proto but missing
from the Scala types. Uses the proto SelectedCommand type directly.

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

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

* Fix ProvinceConverter to use typed lastCommand

Update ProvinceConverter to use Option[SelectedCommand] instead of Any,
and properly convert Empty to None in fromProto.

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

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

* Remove unnecessary default arguments from view case classes

Defaults can mask missing fields at compile time. Removed defaults from
FullProvinceInfo, ProvinceView, and UnaffiliatedHeroBasics. Updated tests
to explicitly provide all required fields.

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

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

* Add lastCommandTypeForActingProvince to ActionResultT and apply in ActionResultApplierImpl

This adds the equivalent of applyLastCommand from ActionResultProtoApplierImpl
to the Scala-based action result applier, ensuring lastCommand is properly
persisted when using Scala GameState types.

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

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

* Fix ActionResultProtoConverter to include lastCommandTypeForActingProvince

Added the new field to the pattern match and proto conversion.

🤖 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-20 20:04:11 -08:00
f73798ae6e Register ErrorHandler in Awake to catch startup exceptions (#4739)
Move Application.logMessageReceivedThreaded registration from Start()
to Awake() so exceptions during initialization are captured.

Also add fallback for when MainQueue isn't ready yet - errors are
queued and displayed in Update() once the UI is available.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 18:57:08 -08:00
19691682e0 Add grace period before sync mismatch triggers reconnect (#4736)
When subscribing with count=0 (fresh start), the server sends thousands of
historical results. Before the client can process them all, heartbeat runs
and detects a sync mismatch, triggering reconnect. This creates an endless
loop where the client never catches up.

Added a 60-second grace period after successful connect during which sync
mismatches are logged but don't trigger reconnects. This allows time to
receive and process historical results.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 18:52:52 -08:00
0e51bece68 Remove unnecessary MainQueue enqueues in ShardokGameController (#4738)
ModelUpdated() and SetModifiers() were wrapping their work in
MainQueue.Q.Enqueue(), but they're already called from the MainQueue
via the update processing chain:

  MainQueue → ReceiveGameUpdate → HandleUpdates → UpdateAction → ModelUpdated

This double/triple-enqueuing caused UI updates to be pushed to the end
of the queue during rapid updates (like AI turns), making moves appear
delayed or batched instead of in real-time.

By removing the unnecessary enqueues, UI updates now happen immediately
when the update is processed, restoring real-time display of moves.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 18:51:36 -08:00
a89f740b3b Make ShardokGameModels thread-safe for heartbeat access (#4737)
ShardokViewStatuses was accessed from the heartbeat timer thread while
ShardokGameModels (a regular Dictionary) could be modified on the
MainQueue thread. This race condition could cause enumeration errors
or incorrect sync status being reported.

Changes:
- Convert ShardokGameModels from Dictionary to ConcurrentDictionary
- Replace Remove() calls with TryRemove() for ConcurrentDictionary API
- Remove non-thread-safe History.Count fallback in ShardokViewStatuses,
  now falls back to 0 if count not yet tracked

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 18:43:46 -08:00
7959da0a5a Fix index out of range in MovingArmiesTableController.ProvinceHovered (#4735)
ProvinceHovered iterated over MovingArmies and accessed table rows by
index. If the data changed after the table was built (e.g., due to a
game update), this could throw ArgumentOutOfRangeException.

Now checks RowCount before accessing to skip stale indices.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 18:13:02 -08:00
bc119b2aab Request full Shardok state for battles discovered in StartingState (#4734)
On fresh client start with an ongoing battle:
1. Client subscribes with no ShardokViewStatuses (doesn't know about battles)
2. Server sends StartingState with OutstandingBattles
3. Server sends ShardokActionResultResponses but may start from recent point
4. Client has partial battle history

The fix:
- After receiving StartingState, check for battles not in ShardokGameModels
- Create ShardokGameModel for each new battle
- Mark for resync (requestFullResync=true)
- Re-subscribe to request full state with the correct ShardokViewStatuses

This ensures fresh clients get complete Shardok state for ongoing battles.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 18:11:59 -08:00
25c7788254 Fix race condition where subscriber could be lost on app pause/resume (#4733)
OnApplicationPause had an asymmetry:
- Pause: StopListeningForUpdates() synchronously removed the subscriber
- Resume: StartListeningForUpdates() was fire-and-forget async

If the app paused again before the async subscribe completed, or if the
subscribe failed, the subscriber was permanently lost. This caused
"heartbeat with 0 games" even though data was still being received on
the stream.

The fix is to not unsubscribe on pause at all. With MainQueue rate-limiting
(from #4659), keeping the subscription during pause is safe - updates will
queue up and be processed on resume. Reconnects will continue to work
since the subscriber stays in the dictionary.

Added logging to track pause/resume events for debugging.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 18:11:33 -08:00
1246f8bcf6 Fix notification showing raw {placeholder} text when hero names load partially (#4731)
When UpdateText() was called after a listener fired for one hero name,
it would start from the raw template and only replace placeholders that
were in placeholderValues. Other placeholders that hadn't loaded yet
would appear as literal "{HeroName}" text.

Now UpdateText() applies fallback values for any placeholder that hasn't
been loaded yet, ensuring the notification always shows either the actual
hero name or a readable fallback like "the hero".

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 18:00:05 -08:00
f3e44fb9cf Fix additional race conditions in ShardokGameController (#4732)
Make masterLock mutable and add lock protection to GetGameId() and
GetHexMap() which were accessing the engine without synchronization.

This fixes crashes where the game state buffer was being read while
another thread was modifying it, resulting in invalid memory access
(address 0x9a0 = offset from null pointer).

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-20 17:59:55 -08:00
50aa61b77c Fix race condition in unfiltered result count tracking (#4730)
When MainQueue has a backlog (e.g., after resuming from background),
the main thread could overwrite the gRPC thread's accurate result count
with a stale value from an older queued action. This caused sync
mismatches where the client's reported count was behind the server's,
triggering repeated reconnection loops.

The count is already updated on the gRPC thread in UpdateResultCounts()
before enqueueing, so the redundant update in ReceiveGameUpdate() is
removed.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:41:48 -08:00
facfcf9ac9 Fix race condition in GetCurrentGameStateBytes (#4729)
GetCurrentGameStateBytes was reading from the game engine without
acquiring masterLock, causing crashes when the AI thread was
simultaneously modifying the game state through PostCommand.

The fix adds scoped_lock protection to prevent concurrent access.

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-20 17:30:13 -08:00
4c21368f96 Add hold-shift-to-pause for autoscroll (#4727)
Hold either Shift key to pause auto-scrolling, letting the user
read at their own pace. Releasing Shift resumes from current position.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:07:00 -08:00
7eccd69a01 Re-enable settings panel in Gameplay scene (#4726)
Accidentally disabled in previous layout adjustments.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 16:59:55 -08:00
161 changed files with 7096 additions and 1910 deletions
+3
View File
@@ -6,4 +6,7 @@
*.bytes filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
# Exclude pre-existing font files that were committed as blobs (not LFS pointers)
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
*.herodata filter=lfs diff=lfs merge=lfs -text
+66
View File
@@ -0,0 +1,66 @@
name: Build Linux Sysroot
on:
workflow_dispatch:
inputs:
version:
description: 'Sysroot version (e.g., v2, v3)'
required: true
default: 'v2'
type: string
permissions:
contents: read
jobs:
build-sysroot:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build sysroot
run: ./tools/sysroot/build_sysroot.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot
path: tools/sysroot/output/
- name: Install AWS CLI
run: |
if ! command -v aws &> /dev/null; then
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install
fi
- name: Upload to DigitalOcean Spaces
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces (using eagle0-windows bucket, same as other workflows)
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== Sysroot uploaded ==="
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
+72
View File
@@ -0,0 +1,72 @@
name: Docker Build and Push
on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- '.github/workflows/docker_build.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to container registry'
required: true
default: 'false'
type: boolean
permissions:
contents: read
jobs:
build-eagle:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Eagle Docker image
run: bazel build //ci:eagle_server_image
- 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
- name: Push Eagle image to DO registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
run: bazel run //ci:eagle_server_push
build-shardok:
runs-on: self-hosted
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: 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
- name: Push Shardok image to DO registry
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
+9
View File
@@ -3,6 +3,15 @@ load("@io_bazel_rules_go//go:def.bzl", "nogo")
package(default_visibility = ["//visibility:public"])
# Platform for cross-compiling to Linux x86_64
platform(
name = "linux_x86_64",
constraint_values = [
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
)
gazelle(name = "gazelle")
# gazelle:proto file
+60 -17
View File
@@ -26,50 +26,68 @@ scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
"scala_config",
)
scala_config.settings(scala_version = SCALA_VERSION)
scala_deps = use_extension(
"@rules_scala//scala/extensions:deps.bzl",
"scala_deps",
)
scala_deps.scala()
scala_deps.scalatest()
scala_deps.scala_proto()
#
# Language Support - C++
#
bazel_dep(name = "toolchains_llvm", version = "1.4.0")
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
# Native toolchain (macOS -> macOS, Linux -> Linux)
llvm.toolchain(
name = "llvm_toolchain",
llvm_version = "20.1.2",
)
use_repo(llvm, "llvm_toolchain")
# Cross-compilation toolchain (macOS -> Linux x86_64)
# Uses the same LLVM distribution but with a Linux sysroot
llvm.toolchain(
name = "llvm_toolchain_linux",
llvm_version = "20.1.2",
)
# Linux sysroot for cross-compilation (Chromium's Debian sysroot)
llvm.sysroot(
name = "llvm_toolchain_linux",
label = "@linux_sysroot//sysroot",
targets = ["linux-x86_64"],
)
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
# Download the Linux sysroot (Ubuntu 24.04 Noble for C++23 support)
# Built by: .github/workflows/build_sysroot.yml
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
sysroot(
name = "linux_sysroot",
sha256 = "aadb60a2e2c776ac000bb2e29f3039fd81b5b031500cf4a1651b365712819d1f",
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v2/ubuntu_noble_amd64_sysroot.tar.xz"],
)
#
# Language Support - Go
#
bazel_dep(name = "rules_go", repo_name = "io_bazel_rules_go", version = "0.56.1")
bazel_dep(name = "gazelle", repo_name = "bazel_gazelle", version = "0.45.0")
bazel_dep(name = "rules_go", version = "0.56.1", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.45.0", repo_name = "bazel_gazelle")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(
go_deps,
"com_github_aws_aws_sdk_go_v2",
@@ -84,15 +102,15 @@ use_repo(
# Platform Support - Apple/iOS
#
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support", version = "1.21.1")
bazel_dep(name = "rules_apple", repo_name = "build_bazel_rules_apple", version = "3.16.1")
bazel_dep(name = "rules_swift", repo_name = "build_bazel_rules_swift", version = "2.3.1")
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
#
# Protocol Buffers & RPC
#
bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "29.2")
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.2.10")
@@ -103,6 +121,32 @@ bazel_dep(name = "flatbuffers", version = "25.2.10")
bazel_dep(name = "googletest", version = "1.17.0")
#
# Container Images (OCI)
#
bazel_dep(name = "rules_oci", version = "2.2.6")
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
# Base image for Eagle (Java 17)
oci.pull(
name = "eclipse_temurin_17",
digest = "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
)
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
oci.pull(
name = "ubuntu_24_04",
image = "docker.io/library/ubuntu",
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")
#
# Java/Scala Dependencies
#
@@ -110,7 +154,6 @@ bazel_dep(name = "googletest", version = "1.17.0")
bazel_dep(name = "rules_jvm_external", version = "6.3")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
artifacts = [
# Netty
@@ -173,7 +216,6 @@ maven.install(
"https://repo1.maven.org/maven2",
],
)
use_repo(maven, "maven", "unpinned_maven")
#
@@ -221,5 +263,6 @@ register_toolchains(
# Set dev_dependency so we can turn this off for swift MacOS builds
register_toolchains(
"@llvm_toolchain//:all",
"@llvm_toolchain_linux//:all",
dev_dependency = True,
)
+275 -88
View File
@@ -26,7 +26,11 @@
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.38.0/MODULE.bazel": "6307fec451ba9962c1c969eb516ebfe1e46528f7fa92e1c9ac8646bef4cdaa3f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/source.json": "92494d5aa43b96665397dd13ee16023097470fa85e276b93674d62a244de47ee",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.16.0/MODULE.bazel": "852f9ebbda017572a7c113a2434592dd3b2f55cd9a0faea3d4be5a09a59e4900",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.9.3/MODULE.bazel": "66baf724dbae7aff4787bf2245cc188d50cb08e07789769730151c0943587c14",
@@ -51,8 +55,11 @@
"https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/source.json": "ed8cf0ef05c858dce3661689d0a2b110ff398e63994e178e4f1f7555a8067fed",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
@@ -69,7 +76,8 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
@@ -98,6 +106,8 @@
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
"https://bcr.bazel.build/modules/gazelle/0.30.0/MODULE.bazel": "f888a1effe338491f35f0e0e85003b47bb9d8295ccba73c37e07702d8d31c65b",
"https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8",
@@ -137,6 +147,10 @@
"https://bcr.bazel.build/modules/grpc/1.70.1/MODULE.bazel": "b800cd8e3e7555c1e61cba2e02d3a2fcf0e91f66e800db286d965d3b7a6a721a",
"https://bcr.bazel.build/modules/grpc/1.71.0/MODULE.bazel": "7fcab2c05530373f1a442c362b17740dd0c75b6a2a975eec8f5bf4c70a37928a",
"https://bcr.bazel.build/modules/grpc/1.71.0/source.json": "60ef8c4c72c8280ae94c05b4f38bf67785acb25477ab8dbac096a9604449ff90",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/MODULE.bazel": "3a4be20f6fc13be32ad44643b8252ef5af09eee936f1d943cd4fd7867fa92826",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/source.json": "b129ab1828492de2c163785bbeb4065c166de52d932524b4317beb5b7f917994",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d",
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
@@ -161,6 +175,7 @@
"https://bcr.bazel.build/modules/opentelemetry-proto/1.5.0/source.json": "046b721ce203e88cdaad44d7dd17a86b7200eab9388b663b234e72e13ff7b143",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
@@ -225,12 +240,14 @@
"https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
"https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/source.json": "4db99b3f55c90ab28d14552aa0632533e3e8e5e9aea0f5c24ac0014282c2a7c5",
"https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c",
"https://bcr.bazel.build/modules/rules_cc/0.0.5/MODULE.bazel": "be41f87587998fe8890cd82ea4e848ed8eb799e053c224f78f3ff7fe1a1d9b74",
"https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f",
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/source.json": "9300e71df0cdde0952f10afff1401fa664e9fc5d9ae6204660ba1b158d90d6a6",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
@@ -289,6 +306,8 @@
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
@@ -321,7 +340,8 @@
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a",
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
@@ -339,14 +359,19 @@
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/MODULE.bazel": "05239402b7374293359c2f22806f420b75aa5d6f4b15a2eaa809a2c214d58b31",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/source.json": "229a516d282b17a82be54c6e3ae220a1b750fb55a8495567e5c7a9d09423f3e2",
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/source.json": "6bd3ef95a288dd2bb1582eca332af850c9a5428a23bb92cb1c57c2dfe6cb7369",
"https://bcr.bazel.build/modules/upb/0.0.0-20211020-160625a/MODULE.bazel": "6cced416be2dc5b9c05efd5b997049ba795e5e4e6fafbe1624f4587767638928",
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
"https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9",
"https://bcr.bazel.build/modules/upb/0.0.0-20230907-e7430e6/MODULE.bazel": "3a7dedadf70346e678dc059dbe44d05cbf3ab17f1ce43a1c7a42edc7cbf93fd9",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/MODULE.bazel": "cea509976a77e34131411684ef05a1d6ad194dd71a8d5816643bc5b0af16dc0f",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/source.json": "7227e1fcad55f3f3cab1a08691ecd753cb29cc6380a47bc650851be9f9ad6d20",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/source.json": "2d2bad780a9f2b9195a4a370314d2c17ae95eaa745cefc2e12fbc49759b15aa3",
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
"https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
"https://bcr.bazel.build/modules/zlib/1.2.13/MODULE.bazel": "aa6deb1b83c18ffecd940c4119aff9567cd0a671d7bba756741cb2ef043a29d5",
@@ -388,7 +413,7 @@
},
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
"general": {
"bzlTransitiveDigest": "8iOqbPY5ve3DvjzaI1mJZ8XTiJypN2PeWvcKOvmZLy8=",
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -1265,6 +1290,247 @@
"recordedRepoMappingEntries": []
}
},
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "BuciKSozbpJMD9EP+j0RG5ZgrYMeDPsQyiOnLUni2V8=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"eclipse_temurin_17_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
"platform": "linux/amd64",
"target_name": "eclipse_temurin_17_linux_amd64",
"bazel_tags": []
}
},
"eclipse_temurin_17": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "eclipse_temurin_17",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
"platforms": {
"@@platforms//cpu:x86_64": "@eclipse_temurin_17_linux_amd64"
},
"bzlmod_repository": "eclipse_temurin_17",
"reproducible": true
}
},
"ubuntu_24_04_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platform": "linux/amd64",
"target_name": "ubuntu_24_04_linux_amd64",
"bazel_tags": []
}
},
"ubuntu_24_04": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "ubuntu_24_04",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platforms": {
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64"
},
"bzlmod_repository": "ubuntu_24_04",
"reproducible": true
}
},
"oci_crane_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_i386": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_i386",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_s390x",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:crane_toolchain_type",
"toolchain": "@oci_crane_{platform}//:crane_toolchain"
}
},
"oci_regctl_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_amd64"
}
},
"oci_regctl_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_arm64"
}
},
"oci_regctl_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_arm64"
}
},
"oci_regctl_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_s390x"
}
},
"oci_regctl_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_amd64"
}
},
"oci_regctl_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "windows_amd64"
}
},
"oci_regctl_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:regctl_toolchain_type",
"toolchain": "@oci_regctl_{platform}//:regctl_toolchain"
}
}
},
"moduleExtensionMetadata": {
"explicitRootModuleDirectDeps": [
"eclipse_temurin_17",
"eclipse_temurin_17_linux_amd64",
"ubuntu_24_04",
"ubuntu_24_04_linux_amd64"
],
"explicitRootModuleDirectDevDeps": [],
"useAllRepos": "NO",
"reproducible": false
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_features~",
"bazel_tools",
"bazel_tools"
],
[
"rules_oci~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"rules_oci~",
"bazel_features",
"bazel_features~"
],
[
"rules_oci~",
"bazel_skylib",
"bazel_skylib~"
]
]
}
},
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
"general": {
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
@@ -1293,7 +1559,7 @@
},
"@@rules_scala~//scala/extensions:deps.bzl%scala_deps": {
"general": {
"bzlTransitiveDigest": "F2PMm61fmZ/IE+VSw1rigJ71hBDD7k3vqyYR1/GgXeA=",
"bzlTransitiveDigest": "5SDZrXQHW6tI/VEw+La2OPOK4ZWm0LGTxnChXOXBCag=",
"usagesDigest": "kwo8oolISmSSITnit4b4S0vBiUtHlHK0WLDUwScxmOg=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -4904,85 +5170,6 @@
]
]
}
},
"@@toolchains_llvm~//toolchain/extensions:llvm.bzl%llvm": {
"general": {
"bzlTransitiveDigest": "afRF0aFOIUrkYl3o040WQ606ep1qciEXzjnAxT3Kek8=",
"usagesDigest": "sYVuhiCAQehFTnGTv0bNtTBR4WorebpWBNxF0mRusyw=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"llvm_toolchain_llvm": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "llvm",
"attributes": {
"alternative_llvm_sources": [],
"auth_patterns": {},
"distribution": "auto",
"exec_arch": "",
"exec_os": "",
"libclang_rt": {},
"llvm_mirror": "",
"llvm_version": "20.1.2",
"llvm_versions": {},
"netrc": "",
"sha256": {},
"strip_prefix": {},
"urls": {}
}
},
"llvm_toolchain": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "toolchain",
"attributes": {
"absolute_paths": false,
"archive_flags": {},
"compile_flags": {},
"conly_flags": {},
"coverage_compile_flags": {},
"coverage_link_flags": {},
"cxx_builtin_include_directories": {},
"cxx_flags": {},
"cxx_standard": {},
"dbg_compile_flags": {},
"exec_arch": "",
"exec_os": "",
"extra_exec_compatible_with": {},
"extra_target_compatible_with": {},
"link_flags": {},
"link_libs": {},
"llvm_versions": {
"": "20.1.2"
},
"opt_compile_flags": {},
"opt_link_flags": {},
"stdlib": {},
"target_settings": {},
"unfiltered_compile_flags": {},
"toolchain_roots": {},
"sysroot": {}
}
}
},
"recordedRepoMappingEntries": [
[
"toolchains_llvm~",
"bazel_skylib",
"bazel_skylib~"
],
[
"toolchains_llvm~",
"bazel_tools",
"bazel_tools"
],
[
"toolchains_llvm~",
"toolchains_llvm",
"toolchains_llvm~"
]
]
}
}
}
}
+128
View File
@@ -0,0 +1,128 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
#
# Eagle Server Docker Image
#
# Build: bazel build //ci:eagle_server_image
# Load: bazel run //ci:eagle_server_load
# Push: bazel run //ci:eagle_server_push
#
# Package the deploy JAR
pkg_tar(
name = "eagle_server_jar_layer",
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
package_dir = "/app",
)
# Package the game resources needed at runtime
pkg_tar(
name = "eagle_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/eagle:beasts",
"//src/main/resources/net/eagle0/eagle:game_parameters",
"//src/main/resources/net/eagle0/eagle:headshots",
"//src/main/resources/net/eagle0/eagle:heroes",
"//src/main/resources/net/eagle0/eagle:province_map",
"//src/main/resources/net/eagle0/eagle:settings",
],
package_dir = "/app/resources",
)
oci_image(
name = "eagle_server_image",
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = [
"java",
"-Xmx4g",
"-XX:+UseG1GC",
"-jar",
"/app/eagle_server_deploy.jar",
],
env = {
"JAVA_OPTS": "-Xmx4g -XX:+UseG1GC",
},
exposed_ports = ["40032/tcp"],
tars = [
":eagle_server_jar_layer",
":eagle_resources_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:eagle_server_load
oci_load(
name = "eagle_server_load",
image = ":eagle_server_image",
repo_tags = ["eagle0/eagle-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "eagle_server_push",
image = ":eagle_server_image",
repository = "registry.digitalocean.com/eagle0/eagle-server",
)
#
# Shardok Server Docker Image
#
# Build: bazel build //ci:shardok_server_image
# Load: bazel run //ci:shardok_server_load
# Push: bazel run //ci:shardok_server_push
#
# Package the Shardok binary
pkg_tar(
name = "shardok_binary_layer",
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
package_dir = "/app",
)
# Package the Shardok resources (battalion types, settings)
pkg_tar(
name = "shardok_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/shardok:battalion_types",
"//src/main/resources/net/eagle0/shardok:settings",
],
package_dir = "/app/resources",
)
# Package the converted maps
pkg_tar(
name = "shardok_maps_layer",
srcs = ["//src/main/resources/net/eagle0/shardok/maps"],
package_dir = "/app/resources/maps",
)
oci_image(
name = "shardok_server_image",
base = "@ubuntu_24_04_linux_amd64",
entrypoint = ["/app/shardok-server"],
exposed_ports = [
"40042/tcp",
"40052/tcp",
],
tars = [
":shardok_binary_layer",
":shardok_resources_layer",
":shardok_maps_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:shardok_server_load
oci_load(
name = "shardok_server_load",
image = ":shardok_server_image",
repo_tags = ["eagle0/shardok-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "shardok_server_push",
image = ":shardok_server_image",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
+47
View File
@@ -0,0 +1,47 @@
# 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
services:
eagle:
image: eagle0/eagle-server:latest
container_name: eagle-server
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"
volumes:
# Mount saves directory for persistence
- ./saves:/app/saves
depends_on:
- shardok
restart: unless-stopped
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "40032"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
shardok:
image: 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"
restart: unless-stopped
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "40042"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
+59 -31
View File
@@ -197,34 +197,57 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
### Remaining Proto Usage in Actions
The following actions still have proto usage, blocked by utility dependencies:
**Progress: 47 of 52 action files (90%) are fully protoless.**
| Action | Proto Usage | Blocker |
|--------|-------------|---------|
| `EndBattleAftermathPhaseAction` | 1 `toProto` call | `ProvinceViewFilter` needs Scala types |
| `NewRoundAction` | 1 `fromProto` call | `ChronicleEventGenerator` returns proto |
| `EndHandleRiotsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `PerformVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `PerformVassalDefenseDecisionsAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `EndVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `PerformReconResolutionAction` | Uses `ProvinceViewFilter` | `ProvinceViewFilter` needs Scala types |
| `ResolveBattleAction` | Heavy proto usage | Large refactor needed |
The following 5 actions still have proto usage:
| Action | Proto Usages | Blocker | Effort |
|--------|--------------|---------|--------|
| `ResolveBattleAction` | 24 | Shardok interface, complex battle logic | High |
| `PerformVassalCommandsPhaseAction` | 3 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndHandleRiotsPhaseAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `PerformVassalDefenseDecisionsAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndVassalCommandsPhaseAction` | 1 | `CommandChoiceHelpers` takes proto GameState | Medium |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
### Estimated Effort (Remaining)
| Component | Lines | Complexity |
|-----------|-------|------------|
| `ProvinceViewFilter` to Scala | ~150 | Medium |
| `CommandChoiceHelpers` to Scala | ~2000 | High |
| `ChronicleEventGenerator` to Scala | ~400 | Medium |
| History API updates | ~100 | Low |
| **Total Remaining** | **~2650** | |
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~2600** | | |
**Completed:**
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 47 / 52 (90%) |
| Proto usages in remaining actions | 32 total |
| Biggest blocker | `ResolveBattleAction` (24 usages) |
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
### Validation
- [x] `ActionResultApplier` created and tested
- [x] `RandomStateSequencer` threads Scala GameState throughout
- [x] `RoundPhaseAdvancer` uses T-types internally
- [ ] `ProvinceViewFilter` uses Scala types
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] `CommandChoiceHelpers` uses Scala types
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
@@ -248,22 +271,27 @@ Remove remaining direct proto imports from utility classes.
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
### View Filters (Blocking Full Deproto)
### View Filters (Partially Complete)
The `ProvinceViewFilter` utility currently works entirely with proto types, blocking full deproto of actions that generate province views:
The view filter utilities now have Scala overloads for server-side use:
| File | Issue | Needed |
|------|-------|--------|
| `ProvinceViewFilter.scala` | Takes proto `Province`/`GameState`, returns proto `ProvinceView` | Scala `ProvinceViewT` model |
| `GameStateViewFilter.scala` | Uses proto types throughout | Depends on `ProvinceViewT` |
| `GameStateViewDiffer.scala` | Works with view protos | Depends on `ProvinceViewT` |
| File | Status | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
**Blocked Actions**:
- `EndBattleAftermathPhaseAction` - uses `ProvinceViewFilter` for `revelationChange`, requires lazy proto conversion
- `PerformReconResolutionAction` - uses `ProvinceViewFilter` for reconned provinces
- `GameStateFactionExtensions` - uses `ProvinceViewFilter` for `updatedReconnedProvinces`
**Unblocked Actions** (PR #4752):
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
- `PerformReconResolutionAction` - can now use Scala overload
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
**Solution**: Create Scala `ProvinceViewT` (and possibly `ProvinceViewC`) that mirrors the proto `ProvinceView`. Then create a protoless `ProvinceViewFilter` that operates on Scala types. The proto version can delegate to the Scala version + convert, or we maintain both during transition.
**Remaining Work**:
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
- `withdrawnFromProvinceView` still uses proto types
- These are needed for client-facing views with visibility restrictions
---
+940
View File
@@ -0,0 +1,940 @@
# Eagle0 Productionization Plan
## Executive Summary
This document outlines a plan to move Eagle0's Eagle and Shardok servers from a home Mac to cloud infrastructure while maintaining a QA environment on the Mac. The architecture uses DigitalOcean (already integrated via Spaces) with containerized deployments and GitHub Actions CI/CD.
## Current Architecture
```
Unity Client
│ gRPC/TLS (eagle0.net:443)
nginx (home Mac, via router port forward)
├─► Eagle Server (Scala/JVM, port 40032)
│ │
│ │ internal gRPC (port 40042)
│ ▼
└─► Shardok Server (C++, port 40042/40052)
Storage: DigitalOcean Spaces (sfo3.digitaloceanspaces.com)
DNS: eagle0.net → home IP
```
**Key observations:**
- Already using DigitalOcean Spaces for S3-compatible storage
- Eagle server is a deployable JAR (`eagle_server_deploy.jar`)
- Shardok server is a native C++ binary
- nginx handles TLS termination and gRPC routing
- Self-hosted GitHub Actions runner on Mac
---
## Target Architecture
### Production Environment (DigitalOcean)
```
Unity Client
├─► eagle0.net (Production)
│ │
│ ▼
│ DigitalOcean Load Balancer (TLS termination)
│ │
│ ▼
│ ┌─────────────────────────────────────┐
│ │ DigitalOcean Droplet(s) │
│ │ ┌─────────────┬─────────────────┐ │
│ │ │ Eagle │ Shardok │ │
│ │ │ (Docker) │ (Docker) │ │
│ │ │ :40032 │ :40042 │ │
│ │ └─────────────┴─────────────────┘ │
│ └─────────────────────────────────────┘
└─► qa.eagle0.net (QA - home Mac, unchanged)
nginx → Eagle/Shardok (current setup)
```
### Environment Switching
The Unity client already supports configurable server URLs via the connection screen:
- **Production:** `eagle0.net` (default)
- **QA:** `qa.eagle0.net`
No client code changes needed - users can simply type the desired URL.
---
## Cloud Provider: DigitalOcean
### Why DigitalOcean
1. **Already integrated** - S3 Spaces storage at `sfo3.digitaloceanspaces.com` with credentials configured
2. **Simple pricing** - Predictable monthly costs, no surprise bills
3. **Good performance** - SFO3 datacenter is geographically close
4. **Managed services** - Load balancers, managed databases if needed later
5. **Not AWS/GCP** - Per your requirements
### Alternative Considered: Hetzner
- Cheaper for compute ($3.29/mo for 2 vCPU/4GB vs DO's $24/mo)
- Good EU presence but less US coverage
- No managed load balancer (need to run HAProxy/nginx yourself)
- **Recommendation:** Start with DigitalOcean for simplicity; migrate to Hetzner later if cost becomes a concern
---
## Infrastructure Components
### 1. Compute: DigitalOcean Droplets
#### Resource Characteristics
- **Eagle server:** CPU-light, possibly RAM-heavy (JVM). Should be always available.
- **Shardok server:** Very CPU-heavy (AI algorithms). Only needed during tactical battles. Startup time <1 second.
#### Recommended: On-Demand Shardok (Same Droplet)
For current low player count, optimize for cost while maintaining availability:
| Component | Config | Monthly Cost |
|-----------|--------|--------------|
| Droplet | s-2vcpu-4gb | $24/mo |
| Eagle | Always running | - |
| Shardok | Started on-demand by Eagle, stopped after idle | - |
**How it works:**
1. Eagle server runs 24/7 - game is always available
2. When battle starts, Eagle launches Shardok container/process (<1s startup)
3. After battle ends, idle timer starts (e.g., 5 minutes)
4. On timeout, Eagle stops Shardok to free CPU
5. If new battle starts during idle period, Shardok is already warm
**Shardok lifecycle management** (implement in Eagle):
```scala
// Pseudocode for Eagle's Shardok management
object ShardokManager {
private var process: Option[Process] = None
private var idleTimer: Option[Timer] = None
def ensureRunning(): Unit = {
cancelIdleTimer()
if (process.isEmpty) {
process = Some(startShardokProcess())
waitForHealthCheck()
}
}
def onBattleEnd(): Unit = {
idleTimer = Some(scheduleShutdown(5.minutes))
}
private def shutdown(): Unit = {
process.foreach(_.destroy())
process = None
}
}
```
#### Future Scaling Options
As player count grows and Shardok needs more CPU:
**Option A: Upgrade Droplet (Simplest)**
| Droplet | vCPU | RAM | Cost | Use Case |
|---------|------|-----|------|----------|
| s-2vcpu-4gb | 2 shared | 4 GB | $24/mo | Current (few players) |
| s-4vcpu-8gb | 4 shared | 8 GB | $48/mo | Moderate usage |
| c-4 | 4 dedicated | 8 GB | $84/mo | CPU-intensive battles |
| c-8 | 8 dedicated | 16 GB | $168/mo | Multiple concurrent battles |
**Option B: Separate Shardok Droplet (API-driven)**
For heavy Shardok usage with cost optimization:
- **Eagle droplet:** s-1vcpu-2gb ($12/mo) - always on
- **Shardok droplet:** Created on-demand via DigitalOcean API
- c-4 CPU-optimized: $0.125/hour
- c-8 CPU-optimized: $0.25/hour
- Created when battle starts, destroyed after idle
- 30-60s droplet creation time (acceptable if battles are requested in advance)
**Option C: Fly.io for Shardok (Scale-to-Zero)**
For true pay-per-use with fast cold starts:
- **Eagle:** DigitalOcean or Fly.io (~$10/mo)
- **Shardok:** Fly.io with auto-scaling
- Performance VMs: ~$0.0000022/second when running
- Cold start: 2-5 seconds
- Scales to zero when idle
Requires learning Fly.io but offers best cost efficiency for sporadic usage.
**Option D: Multiple Shardok Instances (High Scale)**
For many concurrent battles:
- **Eagle:** Dedicated droplet with more RAM
- **Shardok pool:** Multiple Shardok containers/droplets
- Eagle routes battles to available Shardok instances
- Could use Kubernetes or Docker Swarm for orchestration
This is overkill for now but documented for future reference.
### 2. Load Balancer
**DigitalOcean Load Balancer:** $12/mo
- TLS termination with Let's Encrypt
- Health checks
- Sticky sessions (if needed)
- Can add more droplets later for HA
**Alternative:** Run nginx on the droplet for $0 extra, but lose automatic failover.
### 3. Storage
**Already configured:** DigitalOcean Spaces
- Bucket: `eagle0`
- Region: `sfo3`
- Used for game saves and assets
- Cost: $5/mo base + $0.02/GB storage + $0.01/GB transfer
### 4. DNS
**Option A: DigitalOcean DNS (Recommended)**
- Free with droplets
- Easy integration
- API for automated updates
**Option B: Keep current DNS provider**
- Update A records manually or via script
**DNS Records:**
```
eagle0.net A <DO Load Balancer IP>
qa.eagle0.net A <Home IP> (unchanged)
*.eagle0.net A <DO Load Balancer IP> (wildcard for future)
```
### 5. Firewall
**DigitalOcean Cloud Firewall:** Free
```
Inbound Rules:
- TCP 443 (HTTPS/gRPC) from anywhere → Load Balancer
- TCP 22 (SSH) from your IP only → Droplets
- TCP 40032 (Eagle) from Load Balancer only
- TCP 40042 (Shardok) from Load Balancer only
Outbound Rules:
- All traffic allowed (for external APIs, Spaces, etc.)
```
---
## Container Strategy
### Dockerfiles
**Eagle Server** (`ci/eagle_run.Dockerfile` - already exists, needs enhancement):
```dockerfile
FROM eclipse-temurin:17-jre-alpine
# Add non-root user
RUN addgroup -S eagle && adduser -S eagle -G eagle
WORKDIR /app
# Copy the deploy JAR
COPY --chown=eagle:eagle deploy/eagle_server_deploy.jar ./
# Copy game resources
COPY --chown=eagle:eagle src/main/resources/net/eagle0/eagle/ ./resources/
USER eagle
# Health check
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD wget -q --spider http://localhost:40032/health || exit 1
EXPOSE 40032
ENTRYPOINT ["java", "-Xmx4g", "-jar", "eagle_server_deploy.jar"]
CMD ["--eagle-grpc-port", "40032", "--shardok-interface-remote-address", "shardok:40042", "--gpt-model-name", "gpt-4"]
```
**Shardok Server** (new: `ci/shardok_run.Dockerfile`):
```dockerfile
FROM ubuntu:22.04
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
libstdc++6 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Add non-root user
RUN useradd -r -s /bin/false shardok
WORKDIR /app
# Copy the Shardok binary and dependencies
COPY --chown=shardok:shardok bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server ./
COPY --chown=shardok:shardok src/main/resources/net/eagle0/shardok/maps/ ./maps/
USER shardok
# Health check (need to implement gRPC health endpoint)
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD ./shardok-server --health-check || exit 1
EXPOSE 40042 40052
ENTRYPOINT ["./shardok-server"]
```
**Docker Compose** (new: `docker-compose.prod.yml`):
```yaml
version: '3.8'
services:
eagle:
build:
context: .
dockerfile: ci/eagle_run.Dockerfile
image: eagle0/eagle-server:${VERSION:-latest}
ports:
- "40032:40032"
environment:
- SHARDOK_ADDRESS=shardok:40042
- GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4}
- JAVA_OPTS=-Xmx4g -XX:+UseG1GC
depends_on:
- shardok
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
shardok:
build:
context: .
dockerfile: ci/shardok_run.Dockerfile
image: eagle0/shardok-server:${VERSION:-latest}
ports:
- "40042:40042"
- "40052:40052"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
```
---
## Build Pipeline
### Build Artifacts
The build process produces:
1. **Eagle JAR:** `bazel-bin/src/main/scala/net/eagle0/eagle/eagle_server_deploy.jar`
2. **Shardok binary:** `bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server`
### Cross-Compilation for Linux
Current builds target macOS (self-hosted runner). For production Linux deployment:
**Option A: Build in Docker (Recommended)**
```bash
# Build Eagle (JVM - platform independent)
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
# Build Shardok in Linux container
docker run --rm -v $(pwd):/workspace -w /workspace \
ubuntu:22.04 \
bash -c "apt-get update && apt-get install -y build-essential && bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server"
```
**Option B: Use GitHub-hosted Linux runner**
- Add `runs-on: ubuntu-latest` workflow
- Builds directly on Linux
- May need to cache Bazel to avoid long build times
**Option C: Cross-compile on Mac**
- Configure Bazel for Linux cross-compilation
- More complex setup but faster iteration
**Recommendation:** Option A (Docker build) for Shardok, since Eagle's JAR is platform-independent.
---
## CI/CD Pipeline
### GitHub Actions Workflows
**New workflow:** `.github/workflows/deploy_production.yml`
```yaml
name: Deploy to Production
on:
push:
branches: [main]
paths:
- 'src/main/scala/**'
- 'src/main/cpp/**'
- 'src/main/protobuf/**'
- 'ci/*.Dockerfile'
- 'docker-compose.prod.yml'
workflow_dispatch:
inputs:
environment:
description: 'Deployment environment'
required: true
default: 'production'
type: choice
options:
- production
- staging
env:
REGISTRY: registry.digitalocean.com
EAGLE_IMAGE: eagle0/eagle-server
SHARDOK_IMAGE: eagle0/shardok-server
jobs:
build-eagle:
runs-on: self-hosted
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v4
with:
lfs: false
- name: Set version
id: version
run: echo "version=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Build Eagle server JAR
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
- name: Copy artifacts
run: |
mkdir -p deploy
cp bazel-bin/src/main/scala/net/eagle0/eagle/eagle_server_deploy.jar deploy/
- name: Build Docker image
run: |
docker build -f ci/eagle_run.Dockerfile -t ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }} .
docker tag ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }} ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:latest
- name: Push to registry
run: |
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.DO_REGISTRY_TOKEN }} --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }}
docker push ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:latest
build-shardok:
runs-on: ubuntu-latest
needs: []
steps:
- uses: actions/checkout@v4
with:
lfs: false
- name: Set version
id: version
run: echo "version=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v2
- name: Cache Bazel
uses: actions/cache@v3
with:
path: ~/.cache/bazel
key: bazel-linux-${{ hashFiles('MODULE.bazel', 'WORKSPACE') }}
- name: Build Shardok server
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
- name: Build Docker image
run: |
mkdir -p bazel-bin/src/main/cpp/net/eagle0/shardok/
cp bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server bazel-bin/src/main/cpp/net/eagle0/shardok/
docker build -f ci/shardok_run.Dockerfile -t ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }} .
docker tag ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }} ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:latest
- name: Push to registry
run: |
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.DO_REGISTRY_TOKEN }} --password-stdin
docker push ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }}
docker push ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:latest
deploy:
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok]
environment: production
steps:
- uses: actions/checkout@v4
- name: Deploy to DigitalOcean
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script: |
cd /opt/eagle0
# Pull latest images
docker-compose -f docker-compose.prod.yml pull
# Rolling restart (zero-downtime if load balancer configured)
docker-compose -f docker-compose.prod.yml up -d --remove-orphans
# Wait for health checks
sleep 30
docker-compose -f docker-compose.prod.yml ps
# Cleanup old images
docker image prune -f
- name: Verify deployment
run: |
# Health check endpoint
curl -f https://eagle0.net/health || exit 1
- name: Notify on failure
if: failure()
run: |
# Add Slack/Discord notification here
echo "Deployment failed!"
```
### Deployment Steps
1. **On push to main:**
- Build Eagle JAR (self-hosted Mac runner - for consistency)
- Build Shardok binary (GitHub-hosted Ubuntu runner)
- Build Docker images
- Push to DigitalOcean Container Registry
2. **Deploy to droplet:**
- SSH to production server
- Pull new images
- `docker-compose up -d` (rolling update)
- Verify health checks
3. **Rollback:**
```bash
# On server
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml pull eagle0/eagle-server:<previous-version>
docker-compose -f docker-compose.prod.yml up -d
```
---
## Server Setup
### Initial Droplet Setup
```bash
#!/bin/bash
# Run on fresh DigitalOcean droplet (Ubuntu 22.04)
# Update system
apt-get update && apt-get upgrade -y
# Install Docker
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
# Install Docker Compose
apt-get install -y docker-compose-plugin
# Create deploy user
useradd -m -s /bin/bash -G docker deploy
mkdir -p /home/deploy/.ssh
# Add your SSH public key to /home/deploy/.ssh/authorized_keys
# Create app directory
mkdir -p /opt/eagle0
chown deploy:deploy /opt/eagle0
# Configure Docker to use DigitalOcean Container Registry
docker login registry.digitalocean.com
# Create systemd service for auto-start
cat > /etc/systemd/system/eagle0.service << 'EOF'
[Unit]
Description=Eagle0 Game Servers
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/eagle0
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
Group=deploy
[Install]
WantedBy=multi-user.target
EOF
systemctl enable eagle0
```
### Load Balancer Configuration
**DigitalOcean Load Balancer settings:**
- **Forwarding Rules:**
- HTTPS 443 → HTTP 40032 (Eagle)
- gRPC is HTTP/2, handled automatically
- **Health Checks:**
- Protocol: HTTP
- Port: 40032
- Path: `/health` (need to implement)
- **SSL:**
- Let's Encrypt certificate for `eagle0.net`
- **Settings:**
- Sticky sessions: Disabled (gRPC streams handle this)
- Proxy protocol: Disabled
### nginx on Droplet (Alternative to LB)
If using nginx instead of managed LB:
```nginx
# /etc/nginx/sites-available/eagle0
upstream eagle_backend {
server 127.0.0.1:40032;
keepalive 100;
}
server {
listen 443 ssl http2;
server_name eagle0.net;
ssl_certificate /etc/letsencrypt/live/eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/eagle0.net/privkey.pem;
# gRPC settings
location /net.eagle0.eagle.api.Eagle {
grpc_pass grpc://eagle_backend;
grpc_read_timeout 1200s;
grpc_send_timeout 1200s;
grpc_socket_keepalive on;
# Rate limiting
limit_req zone=eagle burst=50 nodelay;
}
# Health check endpoint
location /health {
proxy_pass http://127.0.0.1:40032/health;
}
}
# Rate limit zone
limit_req_zone $binary_remote_addr zone=eagle:10m rate=100r/s;
```
---
## Environment Configuration
### Secrets Management
**GitHub Secrets (for CI/CD):**
- `DO_REGISTRY_TOKEN` - DigitalOcean Container Registry token
- `DO_DROPLET_IP` - Production server IP
- `DO_SSH_KEY` - SSH private key for deployment
- `OPENAI_API_KEY` - For LLM integration (if used in production)
- `DO_SPACES_KEY` - Already exists for S3
**On Server (environment variables):**
```bash
# /opt/eagle0/.env
GPT_MODEL_NAME=gpt-4
OPENAI_API_KEY=sk-...
DO_SPACES_KEY=...
DO_SPACES_SECRET=...
JAVA_OPTS=-Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
```
### Configuration Files
**Production config** (`/opt/eagle0/config/eagle0.conf`):
```
monteCarloIterations = 150000
monteCarloThreads = 4
grpcAddress = 0.0.0.0:40042
```
---
## Monitoring & Observability
### Logging
**Docker logging driver:** json-file with rotation
- Logs stored in `/var/lib/docker/containers/<id>/`
- Max 5 files of 100MB each
**Log aggregation options:**
1. **DigitalOcean Logs** - $0 for basic, integrates with Droplets
2. **Papertrail** - $7/mo for 1GB, good search
3. **Self-hosted Loki** - Free, more complex
### Metrics
**Prometheus + Grafana** (optional, for advanced monitoring):
```yaml
# Add to docker-compose.prod.yml
prometheus:
image: prom/prometheus
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
grafana:
image: grafana/grafana
ports:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
```
**Key metrics to track:**
- Connection count
- Request latency (p50, p95, p99)
- Error rate
- CPU/memory usage
- Shardok AI search depth/time
### Alerting
**DigitalOcean Monitoring Alerts:**
- CPU > 80% for 5 minutes
- Memory > 90%
- Disk > 85%
- Droplet unreachable
**Uptime monitoring:**
- Use UptimeRobot (free tier) or Better Uptime
- Check `https://eagle0.net/health` every minute
---
## Cost Estimate
### Recommended Starting Configuration
| Component | Monthly Cost | Notes |
|-----------|-------------|-------|
| Droplet (s-2vcpu-4gb) | $24 | Eagle always-on, Shardok on-demand |
| Spaces | ~$5 | Already paying |
| Container Registry | $5 | For Docker images |
| DNS | $0 | Included |
| Bandwidth | ~$0 | 1TB free, then $0.01/GB |
| **Total** | **~$34/mo** | |
### Optional Add-ons
| Component | Monthly Cost | Notes |
|-----------|-------------|-------|
| Load Balancer | +$12 | Only if need HA/failover |
| Monitoring (Papertrail) | +$7 | Better log search |
### Scaling Costs
| Scenario | Droplet | Monthly Cost |
|----------|---------|--------------|
| Current (few players) | s-2vcpu-4gb | $24 |
| Growing usage | s-4vcpu-8gb | $48 |
| CPU-intensive battles | c-4 (dedicated) | $84 |
| High concurrency | c-8 (dedicated) | $168 |
| Separate Shardok (on-demand) | s-1vcpu-2gb + c-4 hourly | $12 + usage |
---
## Migration Plan
### Phase 1: Infrastructure Setup (Day 1)
1. Create DigitalOcean resources:
- Droplet in SFO3 region
- Container Registry
- (Optional) Load Balancer
2. Configure DNS:
- Point `eagle0.net` to new infrastructure
- Keep `qa.eagle0.net` pointing to home IP
3. Set up server:
- Run initial setup script
- Configure Docker and docker-compose
- Test SSH access
### Phase 2: Build Pipeline (Day 2)
1. Create Dockerfiles:
- Enhance `ci/eagle_run.Dockerfile`
- Create `ci/shardok_run.Dockerfile`
2. Create `docker-compose.prod.yml`
3. Set up GitHub Actions:
- Add deployment workflow
- Configure secrets
- Test build pipeline
### Phase 3: Deployment (Day 3)
1. Deploy to production:
- Push first images
- Start containers
- Verify health checks
2. Configure TLS:
- Set up Let's Encrypt
- Update nginx/LB configuration
3. Update DNS:
- Switch `eagle0.net` to production
- Verify client can connect
### Phase 4: Validation (Day 4)
1. Test gameplay:
- Connect from Unity client
- Play through Eagle gameplay
- Test Shardok combat
2. Monitor:
- Check logs for errors
- Verify resource usage
- Test reconnection behavior
3. Document:
- Update runbooks
- Document rollback procedures
### Phase 5: QA Environment (Day 5)
1. Configure `qa.eagle0.net`:
- Keep pointing to home Mac
- Ensure nginx routes correctly
2. Test environment switching:
- Connect to production
- Switch to QA
- Verify different game states
---
## Rollback Procedure
### Quick Rollback (< 5 minutes)
```bash
# SSH to production server
ssh deploy@<droplet-ip>
# Roll back to previous version
cd /opt/eagle0
docker-compose -f docker-compose.prod.yml down
docker pull registry.digitalocean.com/eagle0/eagle-server:<previous-tag>
docker pull registry.digitalocean.com/eagle0/shardok-server:<previous-tag>
VERSION=<previous-tag> docker-compose -f docker-compose.prod.yml up -d
```
### Full Rollback to Home Mac
1. Update DNS: Point `eagle0.net` back to home IP
2. Ensure home Mac servers are running
3. Wait for DNS propagation (5-30 minutes)
---
## Security Checklist
- [ ] SSH key authentication only (disable password)
- [ ] Firewall configured (only 443, 22 from trusted IPs)
- [ ] TLS 1.3 enforced
- [ ] Secrets in environment variables, not in code
- [ ] Container runs as non-root user
- [ ] Rate limiting on gRPC endpoints
- [ ] Regular security updates (`unattended-upgrades`)
- [ ] Remove Shardok internal interface from public nginx (per CONNECTION_ARCHITECTURE.md)
---
## Resolved Questions
1. **Game state migration:** No migration needed. Saves are currently local only. The codebase has a `Persister` pattern (with existing AWS support) that could be adapted to save to DO Spaces in the future.
2. **LLM API keys:** Yes, OpenAI API keys are needed on the Eagle server. Add `OPENAI_API_KEY` to the `.env` file on the production droplet.
3. **Multiple regions:** Not needed for now. Single SFO3 region is sufficient.
## Remaining Open Questions
1. **Backup strategy:** Should we add automated backups for local persistence, or migrate to DO Spaces persistence first?
2. **Autoscaling:** Is traffic predictable enough to use fixed instance, or need autoscaling later?
---
## Next Steps
### Phase 1: Infrastructure
1. **Approve this plan** - Review and discuss any changes
2. **Create DigitalOcean resources** - Droplet (s-2vcpu-4gb), Container Registry
3. **Set up droplet** - Docker, deploy user, firewall, nginx with TLS
### Phase 2: Containerization
4. **Implement Dockerfiles** - Eagle and Shardok containers
5. **Implement Shardok lifecycle management** - Eagle starts/stops Shardok on-demand
- Add `ShardokProcessManager` to Eagle server
- Start Shardok when battle requested
- Stop after idle timeout (5 min)
- Health check before routing traffic
### Phase 3: CI/CD
6. **Set up GitHub Actions** - Build and push Docker images
7. **Add deployment workflow** - SSH deploy to droplet
### Phase 4: Migration
8. **Deploy to production** - Push images, start services
9. **Update DNS** - Point eagle0.net to droplet
10. **Validate** - Test gameplay, monitor logs
11. **Configure QA** - Ensure qa.eagle0.net still works (home Mac)
+473
View File
@@ -0,0 +1,473 @@
#!/bin/bash
#
# generate_changelog.sh
#
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
# and sends an HTML email via Fastmail JMAP API.
#
# Usage: ./scripts/generate_changelog.sh [--dry-run]
#
# Configuration files (in ~/.config/eagle0/):
# fastmail_token - API token (required)
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
#
# To set up:
# mkdir -p ~/.config/eagle0
# echo 'your-token' > ~/.config/eagle0/fastmail_token
# chmod 600 ~/.config/eagle0/fastmail_token
#
# # Optional: configure recipients (one per line, # for comments)
# cat > ~/.config/eagle0/changelog_recipient << EOF
# alice@example.com
# bob@example.com
# EOF
#
# The script tracks its last run using a git tag 'changelog-last-run'.
# On first run (no tag), it defaults to the previous Friday at 4pm.
set -euo pipefail
# Ensure homebrew binaries are in PATH
export PATH="/opt/homebrew/bin:$PATH"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TAG_NAME="changelog-last-run"
DRY_RUN=false
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
CONFIG_DIR="$HOME/.config/eagle0"
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
# Load API token from file or environment
load_api_token() {
# Environment variable takes precedence
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
return 0
fi
# Try loading from config file
if [[ -f "$TOKEN_FILE" ]]; then
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
echo "Loaded API token from $TOKEN_FILE"
export FASTMAIL_API_TOKEN
return 0
fi
fi
return 1
}
# Load recipient emails from config file (one per line)
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
load_recipients_json() {
local recipients=""
if [[ -f "$RECIPIENT_FILE" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip empty lines and comments
line=$(echo "$line" | tr -d '[:space:]')
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ -n "$recipients" ]]; then
recipients="$recipients, "
fi
recipients="$recipients{\"email\": \"$line\"}"
done < "$RECIPIENT_FILE"
fi
echo "$recipients"
}
# Get human-readable list of recipients
load_recipients_display() {
if [[ -f "$RECIPIENT_FILE" ]]; then
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
fi
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--dry-run]"
exit 1
;;
esac
done
cd "$REPO_ROOT"
# Get the cutoff date - either from tag or previous Friday 4pm
get_cutoff_date() {
# Try to get the date from the tag
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
# Get the commit date of the tagged commit
git log -1 --format="%aI" "$TAG_NAME"
else
# Calculate previous Friday at 4pm
# Get current day of week (1=Monday, 7=Sunday)
local dow=$(date +%u)
local days_since_friday
if [[ $dow -ge 5 ]]; then
# Friday (5), Saturday (6), or Sunday (7)
days_since_friday=$((dow - 5))
else
# Monday (1) through Thursday (4)
days_since_friday=$((dow + 2))
fi
# Get previous Friday at 4pm in ISO format
if [[ "$(uname)" == "Darwin" ]]; then
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
else
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
fi
fi
}
# Fetch merged PRs since the cutoff date
fetch_merged_prs() {
local since_date="$1"
local output_file="$2"
echo "Fetching PRs merged since: $since_date"
# Use gh to search for merged PRs
gh pr list \
--state merged \
--base main \
--json number,title,body,mergedAt,author \
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
> "$output_file.json"
# Format the output nicely
echo "# Merged PRs since $since_date" > "$output_file"
echo "" >> "$output_file"
# Process each PR
jq -r '
"## PR #\(.number): \(.title)\n" +
"Author: \(.author.login)\n" +
"Merged: \(.mergedAt)\n\n" +
"### Description\n" +
(.body // "(No description)") +
"\n\n---\n"
' "$output_file.json" >> "$output_file"
# Count PRs
local pr_count=$(jq -s 'length' "$output_file.json")
echo "Found $pr_count merged PRs"
rm -f "$output_file.json"
if [[ $pr_count -eq 0 ]]; then
echo "No PRs found since $since_date"
return 1
fi
return 0
}
# Generate synopsis using Claude
generate_synopsis() {
local input_file="$1"
local output_file="$2"
echo "Generating synopsis with Claude..."
# Create a prompt file to avoid shell escaping issues
local prompt_file="/tmp/eagle0_prompt_$$.txt"
# Get repo URL for PR links
local repo_url=$(gh repo view --json url -q '.url')
cat > "$prompt_file" <<PROMPT_HEADER
You are summarizing changes for a weekly engineering update email.
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
Structure:
1. <h1> title (e.g., "Eagle0 Weekly Update")
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
3. Synopsis sections (<h2> headings with bullet point summaries)
4. <hr> divider
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
Guidelines for the SYNOPSIS sections:
- Group related changes together under clear headings (use <h2> tags)
- Use bullet points (<ul><li>) for individual changes
- Highlight any significant new features, breaking changes, or important fixes
- Keep the tone professional but accessible
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
Here are the merged PRs:
PROMPT_HEADER
cat "$input_file" >> "$prompt_file"
echo "" >> "$prompt_file"
echo "Generate the synopsis now:" >> "$prompt_file"
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
local raw_output="/tmp/eagle0_raw_$$.html"
cat "$prompt_file" | claude --print > "$raw_output"
# Wrap in HTML document with UTF-8 charset
cat > "$output_file" <<'HTML_HEAD'
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
HTML_HEAD
cat "$raw_output" >> "$output_file"
echo "</body></html>" >> "$output_file"
rm -f "$prompt_file" "$raw_output"
echo "Synopsis generated at: $output_file"
}
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
get_fastmail_session() {
echo "Fetching Fastmail session info..." >&2
# Get session
local session=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
"https://api.fastmail.com/jmap/session")
# Extract account ID (first account)
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
return 1
fi
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
# Get identity ID
local identity_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
\"methodCalls\": [
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
echo "Error: Could not get Fastmail identity ID." >&2
return 1
fi
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
# Get drafts mailbox ID
local mailbox_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
\"methodCalls\": [
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
return 1
fi
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
return 0
}
# Send email via Fastmail JMAP API
send_email_fastmail() {
local synopsis_file="$1"
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
local html_body=$(cat "$synopsis_file" | jq -Rs .)
echo "Sending email via Fastmail JMAP API..."
# Create the email and send it in one request
local response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [
\"urn:ietf:params:jmap:core\",
\"urn:ietf:params:jmap:mail\",
\"urn:ietf:params:jmap:submission\"
],
\"methodCalls\": [
[\"Email/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"create\": {
\"draft\": {
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
\"to\": [$recipients_json],
\"subject\": \"$subject\",
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
\"keywords\": {\"\$draft\": true},
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
\"bodyValues\": {
\"body\": {
\"charset\": \"utf-8\",
\"value\": $html_body
}
}
}
}
}, \"0\"],
[\"EmailSubmission/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"onSuccessDestroyEmail\": [\"#sendIt\"],
\"create\": {
\"sendIt\": {
\"emailId\": \"#draft\",
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
}
}
}, \"1\"]
]
}" \
"$FASTMAIL_API")
# Check for errors
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
if [[ -n "$error" ]]; then
echo "Error creating email: $error" >&2
echo "Full response: $response" >&2
return 1
fi
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
if [[ -n "$send_error" ]]; then
echo "Error sending email: $send_error" >&2
echo "Full response: $response" >&2
return 1
fi
echo "Email sent successfully"
}
# Update the tag to mark this run
update_tag() {
echo "Updating $TAG_NAME tag..."
# Delete existing tag if present
git tag -d "$TAG_NAME" 2>/dev/null || true
git push origin --delete "$TAG_NAME" 2>/dev/null || true
# Create new tag at HEAD
git tag "$TAG_NAME"
git push origin "$TAG_NAME"
echo "Tag updated to current HEAD"
}
# Main
main() {
echo "=== Eagle0 Weekly Changelog Generator ==="
echo ""
# Load API token (only required for actual send)
if [[ "$DRY_RUN" != "true" ]]; then
if ! load_api_token; then
echo "Error: No Fastmail API token found."
echo ""
echo "To create a token:"
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
echo "2. Create a new token with 'Email submission' scope"
echo "3. Save it using one of these methods:"
echo ""
echo " Option A (recommended): Store in config file"
echo " mkdir -p ~/.config/eagle0"
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
echo " chmod 600 ~/.config/eagle0/fastmail_token"
echo ""
echo " Option B: Set environment variable"
echo " export FASTMAIL_API_TOKEN='your-token'"
exit 1
fi
fi
# Get cutoff date
local cutoff_date=$(get_cutoff_date)
echo "Cutoff date: $cutoff_date"
# Create temp files
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
# Fetch PRs
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
echo "No changes to report. Exiting."
exit 0
fi
echo ""
echo "PR details saved to: $pr_file"
# Generate synopsis
generate_synopsis "$pr_file" "$synopsis_file"
if [[ "$DRY_RUN" == "true" ]]; then
echo ""
echo "=== DRY RUN - Synopsis content ==="
cat "$synopsis_file"
echo ""
echo "=== DRY RUN - Skipping email send and tag update ==="
else
# Get Fastmail session info
if ! get_fastmail_session; then
echo "Failed to get Fastmail session info. Exiting."
exit 1
fi
# Determine recipients (from config file, or default to sender)
local recipients_json=$(load_recipients_json)
if [[ -z "$recipients_json" ]]; then
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
else
local recipients_display=$(load_recipients_display)
echo "Sending to: $recipients_display"
fi
# Send email
send_email_fastmail "$synopsis_file" "$recipients_json"
# Update tag for next run
update_tag
fi
echo ""
echo "Done!"
echo "PR details: $pr_file"
echo "Synopsis: $synopsis_file"
}
main
@@ -65,11 +65,6 @@ private:
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
public:
explicit ShardokAIClient(
PlayerId playerId,
@@ -86,6 +81,12 @@ public:
[[nodiscard]] auto ChooseCommandIndex(const ShardokEngine& engine) const
-> CommandChoiceResults;
// Overload that works on copies of state - allows caller to release lock during AI thinking
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
// MCTS configuration methods (only relevant when using MCTS algorithm)
[[nodiscard]] auto GetMCTSConfig() const -> const mcts::MCTSConfig& { return mctsConfig; }
void SetMCTSConfig(const mcts::MCTSConfig& config) { mctsConfig = config; }
@@ -111,17 +111,67 @@ void ShardokGameController::DoAIThread() {
if (aiClients.empty()) { printf("No AI players, exiting AI thread.\n"); }
while (aiThreadKeepGoing) {
while (incomingRegistrations > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
// Phase 1: Gather data for AI decision (brief lock)
shared_ptr<ShardokAIClient> aiClient;
PlayerId playerId;
GameSettingsSPtr settings;
net::eagle0::shardok::api::GameStateView gsv;
CommandListSPtr availableCommands;
size_t expectedHistoryCount;
{
unique_lock lk(masterLock);
if (engine->GameIsOver()) {
aiThreadKeepGoing = false;
continue;
}
playerId = engine->GetCurrentPlayerId();
aiClient = LockedAIClientForPid(playerId);
if (!aiClient) {
// Not an AI player's turn - wait for signal
aiCondition.wait(lk);
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
continue;
}
// Get copies of everything the AI needs
settings = engine->GetGameSettings();
gsv = engine->GetGameStateView(playerId);
availableCommands = engine->GetAvailableCommandsForAIPlayer(playerId);
expectedHistoryCount = engine->GetUnfilteredHistoryCount();
}
// Lock released - polls can now get through
if (availableCommands->empty()) {
printf("no commands for player %d\n", playerId);
continue;
}
unique_lock lk(masterLock);
if (LockedCheckOneAICommand()) {
// Phase 2: AI thinks (NO LOCK - this is the slow part)
const auto results = aiClient->ChooseCommandIndex(settings, gsv, availableCommands);
// Phase 3: Post the command (brief lock)
{
unique_lock lk(masterLock);
// Verify state hasn't changed while we were thinking
if (engine->GetUnfilteredHistoryCount() != expectedHistoryCount) {
// State changed (e.g., human posted command) - re-evaluate
printf("AI: State changed while thinking, re-evaluating\n");
continue;
}
if (engine->GameIsOver()) {
aiThreadKeepGoing = false;
continue;
}
engine->PostCommand(playerId, results.chosenIndex);
LockedNotifyClients();
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
} else {
aiCondition.wait(lk);
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
aiThreadKeepGoing = !engine->GameIsOver();
}
}
printf("Exiting AI thread.\n");
@@ -136,24 +186,6 @@ ShardokGameController::~ShardokGameController() {
aiThread.join();
}
auto ShardokGameController::LockedCheckOneAICommand() -> bool {
if (engine->GameIsOver()) {
printf("Game is over!\n");
return false;
}
const PlayerId currentPid = engine->GetCurrentPlayerId();
if (const shared_ptr<ShardokAIClient> currentPlayerClient = LockedAIClientForPid(currentPid)) {
const int index = currentPlayerClient->ChooseCommandIndex(*engine).chosenIndex;
engine->PostCommand(currentPid, index);
LockedNotifyClients();
return true;
}
return false;
}
void CheckFactionId(
const unique_ptr<ShardokEngine> &engine,
const PlayerId shardokPlayerId,
@@ -229,6 +261,7 @@ void ShardokGameController::PostPlacementCommands(
}
auto ShardokGameController::GetCurrentGameStateBytes() -> byte_vector {
scoped_lock<mutex> guard(masterLock);
return engine->GetCurrentGameStateBytes();
}
@@ -292,6 +325,9 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
-1,
engine->FilterNewResults(-1, startingActionId),
nullptr);
auto gameStateBytes = engine->GetCurrentGameStateBytes();
updates.currentGameState.swap(gameStateBytes);
}
return updates;
@@ -321,4 +357,74 @@ auto ShardokGameController::ResolvedPlayerInfos()
return engine->GetPlayerInfos();
}
void ShardokGameController::RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber) {
scoped_lock<mutex> guard(subscriberLock);
subscribers.push_back(subscriber);
}
void ShardokGameController::UnregisterSubscriber(const StreamSubscriber *subscriber) {
scoped_lock<mutex> guard(subscriberLock);
subscribers.erase(
std::remove_if(
subscribers.begin(),
subscribers.end(),
[subscriber](const std::weak_ptr<StreamSubscriber> &weakSub) {
auto sub = weakSub.lock();
return !sub || sub.get() == subscriber;
}),
subscribers.end());
}
auto ShardokGameController::WaitForUpdatesAndPush(
std::shared_ptr<StreamSubscriber> subscriber,
int64_t startingActionId) -> bool {
int64_t lastPushedActionId = startingActionId;
while (subscriber->IsActive()) {
bool gameOver = false;
GameOverInfo gameOverInfo{};
{
unique_lock<mutex> guard(masterLock);
// Wait for updates or game over
updateCondition.wait(guard, [this, lastPushedActionId] {
return engine->GetUnfilteredHistoryCount() >
static_cast<size_t>(lastPushedActionId) ||
engine->GameIsOver();
});
if (!subscriber->IsActive()) { return false; }
gameOver = engine->GameIsOver();
if (gameOver) {
gameOverInfo.gameStatus = fb::ToProto(engine->GetGameStatus());
gameOverInfo.playerInfos = engine->GetPlayerInfos();
gameOverInfo.endGameUnits = engine->EndGameUnits();
}
}
// Lock released - GetUpdates will acquire its own lock
if (gameOver) {
subscriber->OnGameOver(gameOverInfo);
return true;
}
// Get updates outside the lock (GetUpdates acquires masterLock internally)
AllUpdates updates = GetUpdates(lastPushedActionId);
lastPushedActionId = updates.newUnfilteredCount;
if (!updates.mainResults.empty()) {
subscriber->OnUpdate(
updates.mainResults,
updates.filteredResults,
updates.newUnfilteredCount,
updates.currentGameState);
}
}
return false; // Subscriber disconnected
}
} // namespace shardok
@@ -10,6 +10,7 @@
#define ShardokGameController_hpp
#include <functional>
#include <memory>
#include <mutex>
#include <utility>
#include <vector>
@@ -29,6 +30,50 @@ using std::shared_ptr;
using std::unique_ptr;
using std::weak_ptr;
// Forward declaration
class ShardokGameController;
/// Info about a game that has ended, for notifying subscribers
struct GameOverInfo {
vector<net::eagle0::shardok::common::PlayerInfo> playerInfos;
vector<net::eagle0::shardok::storage::ResolvedUnit> endGameUnits;
net::eagle0::shardok::common::GameStatus gameStatus;
};
/// Updates for a single player (includes faction ID for client routing)
struct OnePlayerUpdates {
int32_t eagleFactionId;
vector<ActionResultView> resultViews;
shared_ptr<AvailableCommands> availableCommands;
OnePlayerUpdates(
const int32_t fid,
const vector<ActionResultView>& arvs,
const shared_ptr<AvailableCommands>& acs)
: eagleFactionId(fid),
resultViews(arvs),
availableCommands(acs) {}
};
/// Interface for subscribers that receive streaming updates from a game
class StreamSubscriber {
public:
virtual ~StreamSubscriber() = default;
/// Called when new game updates are available
virtual void OnUpdate(
const vector<ActionResult>& mainResults,
const vector<OnePlayerUpdates>& filteredResults,
int32_t newUnfilteredCount,
const byte_vector& currentGameState) = 0;
/// Called when the game ends
virtual void OnGameOver(const GameOverInfo& info) = 0;
/// Returns true if this subscriber is still active and should receive updates
[[nodiscard]] virtual auto IsActive() const -> bool = 0;
};
class ShardokGameController {
private:
// This lock should be held any time we call into engine or modify clients.
@@ -39,10 +84,17 @@ private:
// Fires whenever there is a new game state update.
mutable std::condition_variable updateCondition{};
// Stream subscribers - protected by separate lock to avoid deadlock with masterLock
mutable std::mutex subscriberLock{};
std::vector<std::weak_ptr<StreamSubscriber>> subscribers{};
string serializedRequest;
unique_ptr<ShardokEngine> engine;
// Cached immutable data - safe to access without lock since it never changes after construction
const GameId cachedGameId;
std::atomic_int incomingRegistrations = 0;
const string mapName;
@@ -60,11 +112,9 @@ private:
void LockedNotifyClients() const;
auto LockedCheckOneAICommand() -> bool;
auto LockedAIClientForPid(PlayerId pid) const -> shared_ptr<ShardokAIClient>;
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine> &e);
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine>& e);
void DoAIThread();
@@ -75,6 +125,7 @@ public:
string serializedRequest = "")
: serializedRequest(std::move(serializedRequest)),
engine(std::move(e)),
cachedGameId(engine->GetGameId()),
mapName(std::move(mapName)),
logFilePath(MakeLogFilePath()),
aiClients(MakeAIClients(engine)),
@@ -98,26 +149,13 @@ public:
PlayerId shardokPlayerId,
int eagleFactionId,
int64_t token,
const vector<UnitPlacementInfo> &infos);
struct OnePlayerUpdates {
int32_t eagleFactionId;
vector<ActionResultView> resultViews;
shared_ptr<AvailableCommands> availableCommands;
OnePlayerUpdates(
const int32_t fid,
const vector<ActionResultView> &arvs,
const shared_ptr<AvailableCommands> &acs)
: eagleFactionId(fid),
resultViews(arvs),
availableCommands(acs) {}
};
const vector<UnitPlacementInfo>& infos);
struct AllUpdates {
vector<ActionResult> mainResults;
vector<OnePlayerUpdates> filteredResults;
int32_t newUnfilteredCount;
byte_vector currentGameState;
};
auto GetUpdates(int64_t startingActionId) -> AllUpdates;
auto GetCurrentGameStateBytes() -> byte_vector;
@@ -127,13 +165,23 @@ public:
auto ResolvedPlayerInfos() -> vector<net::eagle0::shardok::common::PlayerInfo>;
auto EndGameUnits() -> vector<net::eagle0::shardok::storage::ResolvedUnit>;
[[nodiscard]] auto GetGameId() const -> GameId { return engine->GetGameId(); }
[[nodiscard]] auto GetHexMap() const -> const HexMap * {
return engine->GetCurrentGameState()->hex_map();
}
[[nodiscard]] auto GetGameId() const -> GameId { return cachedGameId; }
[[nodiscard]] auto GetLogFilePath() const -> string { return logFilePath; }
/// Register a subscriber to receive streaming updates for this game.
/// The subscriber will receive updates until it becomes inactive or is unregistered.
void RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber);
/// Unregister a subscriber. Safe to call even if the subscriber was never registered.
void UnregisterSubscriber(const StreamSubscriber* subscriber);
/// Wait for game updates, pushing them to the given subscriber.
/// Blocks until the game ends or the subscriber becomes inactive.
/// Returns true if the game ended normally, false if subscriber disconnected.
auto WaitForUpdatesAndPush(
std::shared_ptr<StreamSubscriber> subscriber,
int64_t startingActionId) -> bool;
};
} // namespace shardok
@@ -529,6 +529,130 @@ auto FromInternalStatus(
throw ShardokInternalErrorException("Bad unit status on resolved unit");
}
auto EagleInterfaceImpl::SubscribeToGame(
ServerContext *context,
const GameSubscriptionRequest *request,
grpc::ServerWriter<GameStatusResponse> *writer) -> Status {
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
} catch (NewGameException &e) { return e.GetStatus(); }
if (!controller) { return Status(StatusCode::NOT_FOUND, "Game not found"); }
// Send initial state
GameStatusResponse initialResponse;
PopulateGameStatusResponse(
controller,
request->game_setup_info().known_result_count(),
&initialResponse);
if (!writer->Write(initialResponse)) {
return Status::OK; // Client disconnected
}
// If game was already over, we're done
if (initialResponse.has_game_over_response()) { return Status::OK; }
// Track the actual count after initial response to avoid duplicate sends
const int64_t countAfterInitialResponse =
initialResponse.has_game_update_response()
? initialResponse.game_update_response().total_action_result_count()
: request->game_setup_info().known_result_count();
// Create a subscriber that writes to the gRPC stream
class GrpcStreamSubscriber : public StreamSubscriber {
private:
grpc::ServerWriter<GameStatusResponse> *writer_;
ServerContext *context_;
std::atomic<bool> active_{true};
std::string gameId_;
public:
GrpcStreamSubscriber(
grpc::ServerWriter<GameStatusResponse> *w,
ServerContext *ctx,
std::string gameId)
: writer_(w),
context_(ctx),
gameId_(std::move(gameId)) {}
void OnUpdate(
const vector<ActionResult> &mainResults,
const vector<OnePlayerUpdates> &filteredResults,
int32_t newUnfilteredCount,
const byte_vector &currentGameState) override {
if (!active_) return;
GameStatusResponse response;
response.set_game_id(gameId_);
response.mutable_game_update_response()->mutable_update_responses()->Add(
begin(mainResults),
end(mainResults));
// Add filtered results for each player with their faction IDs
for (const auto &playerUpdate : filteredResults) {
auto *filtered =
response.mutable_game_update_response()->add_filtered_update_responses();
filtered->set_eagle_faction_id(playerUpdate.eagleFactionId);
filtered->mutable_action_result_views()->Add(
begin(playerUpdate.resultViews),
end(playerUpdate.resultViews));
if (playerUpdate.availableCommands) {
*filtered->mutable_available_commands() = *playerUpdate.availableCommands;
}
}
response.mutable_game_update_response()->set_total_action_result_count(
newUnfilteredCount);
*response.mutable_game_update_response()->mutable_current_game_state() =
std::string(currentGameState.begin(), currentGameState.end());
if (!writer_->Write(response)) { active_ = false; }
}
void OnGameOver(const GameOverInfo &info) override {
if (!active_) return;
GameStatusResponse response;
response.set_game_id(gameId_);
PopulateGameOverResponse(
gameId_,
info.gameStatus,
info.playerInfos,
info.endGameUnits,
response.mutable_game_over_response());
writer_->Write(response);
active_ = false;
}
[[nodiscard]] auto IsActive() const -> bool override {
return active_ && !context_->IsCancelled();
}
};
auto subscriber =
std::make_shared<GrpcStreamSubscriber>(writer, context, controller->GetGameId());
controller->RegisterSubscriber(subscriber);
// Wait for updates and push them until game ends or subscriber disconnects
// Use countAfterInitialResponse to avoid re-sending results already in initial response
bool gameEnded = controller->WaitForUpdatesAndPush(subscriber, countAfterInitialResponse);
controller->UnregisterSubscriber(subscriber.get());
if (gameEnded) {
printf("SubscribeToGame: Game ended normally\n");
} else {
printf("SubscribeToGame: Subscriber disconnected\n");
}
return Status::OK;
}
} // namespace shardok
#ifndef NDEBUG
@@ -33,6 +33,7 @@ using grpc::Status;
using net::eagle0::common::GameSetupInfo;
using net::eagle0::common::GameStatusRequest;
using net::eagle0::common::GameStatusResponse;
using net::eagle0::common::GameSubscriptionRequest;
using net::eagle0::common::HexMapNamesRequest;
using net::eagle0::common::HexMapNamesResponse;
using net::eagle0::common::HexMapRequest;
@@ -78,6 +79,11 @@ public:
ServerContext* context,
const HexMapNamesRequest* request,
HexMapNamesResponse* response) -> Status override;
auto SubscribeToGame(
ServerContext* context,
const GameSubscriptionRequest* request,
grpc::ServerWriter<GameStatusResponse>* writer) -> Status override;
};
} // namespace shardok
@@ -120,6 +120,19 @@ namespace eagle {
}
}
/// <summary>
/// Force the circuit breaker to allow an immediate reconnect attempt.
/// Resets the state to HalfOpen so the next connection will be a test.
/// </summary>
public void ForceReconnect() {
lock (this) {
if (_state == State.Open) {
_state = State.HalfOpen;
_logger.LogLine("[CIRCUIT] OPEN → HALF_OPEN (forced by user)");
}
}
}
/// <summary>
/// Get human-readable status for UI display.
/// </summary>
@@ -138,7 +151,7 @@ namespace eagle {
var timeUntilTest = OpenTimeoutSeconds -
(DateTime.UtcNow - _openedAt.Value).TotalSeconds;
if (timeUntilTest > 0) {
return $"Server unavailable. Retrying in {(int)timeUntilTest}s";
return $"Server unavailable. Retrying in {Math.Min(10, (int)timeUntilTest)}s";
}
}
return "Server unavailable. Testing...";
@@ -2,6 +2,7 @@ using System;
using Net.Eagle0.Eagle.Api;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
/// <summary>
@@ -26,6 +27,9 @@ namespace eagle {
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
[Tooltip("Optional button to force immediate reconnection when server is down")]
public Button retryButton;
// Update interval in seconds
private const float UpdateInterval = 0.5f;
private float _timeSinceLastUpdate = 0f;
@@ -39,9 +43,10 @@ namespace eagle {
return;
}
// Find the PersistentClientConnection - this assumes it's accessible
// In production, this would need proper dependency injection
// For now, the connection will be set externally or found via another method
if (retryButton != null) {
retryButton.onClick.AddListener(OnRetryClicked);
retryButton.gameObject.SetActive(false);
}
}
/// <summary>
@@ -73,11 +78,12 @@ namespace eagle {
// Check circuit breaker state first - it takes precedence
var circuitState = _connection.CircuitBreaker.CurrentState;
if (circuitState == ConnectionCircuitBreaker.State.Open) {
SetRetryButtonVisible(true);
var nextTest = _connection.CircuitBreaker.NextTestAttempt;
if (nextTest.HasValue) {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = (int)Math.Ceiling(timeUntilTest.TotalSeconds);
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
_textComponent.text =
$"<color=red>●</color> Server down. Retry in {seconds}s";
return;
@@ -86,6 +92,7 @@ namespace eagle {
_textComponent.text = "<color=red>●</color> Server unavailable";
return;
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
SetRetryButtonVisible(false);
_textComponent.text = "<color=yellow>●</color> Testing connection...";
return;
}
@@ -94,6 +101,11 @@ namespace eagle {
var state = _connection.CurrentState;
var nextAttempt = _connection.NextReconnectAttempt;
// Show retry button if we're counting down to a reconnect attempt
bool isCountingDown = state == ConnectionState.Reconnecting && nextAttempt.HasValue &&
(nextAttempt.Value - DateTime.UtcNow).TotalSeconds > 0;
SetRetryButtonVisible(isCountingDown);
string statusText = state switch {
ConnectionState.Connected => GetConnectedStatusText(),
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
@@ -142,8 +154,16 @@ namespace eagle {
return "<color=yellow>●</color> Reconnecting...";
}
int secondsRemaining = (int)Math.Ceiling(timeUntilRetry.TotalSeconds);
int secondsRemaining = Math.Min(10, (int)Math.Ceiling(timeUntilRetry.TotalSeconds));
return $"<color=orange>●</color> Retry in {secondsRemaining}s";
}
private void SetRetryButtonVisible(bool visible) {
if (retryButton != null) { retryButton.gameObject.SetActive(visible); }
}
private void OnRetryClicked() {
if (_connection != null) { _connection.ForceReconnect(); }
}
}
}
@@ -229,12 +229,11 @@ namespace eagle {
void OnApplicationPause(bool pause) {
if (ModelUpdater == null) { return; }
if (pause) {
ModelUpdater.StopListeningForUpdates();
} else {
// Fire-and-forget - subscription is awaited internally and failures are logged
_ = ModelUpdater.StartListeningForUpdates();
}
// Don't unsubscribe on pause - this caused a race condition where the subscriber
// could be lost if pause happened before the async StartListeningForUpdates completed.
// With MainQueue rate-limiting, keeping the subscription during pause is safe.
// Reconnects will continue to work, and updates will queue up and be processed on
// resume.
}
#if UNITY_EDITOR
@@ -43,7 +43,7 @@ namespace eagle {
TokenId? CommandToken { get; }
TokenId LastPostedToken { get; }
Dictionary<String, ShardokGameModel> ShardokGameModels { get; }
ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; }
private bool ShardokGameModelIsRunning(ShardokGameModel sgm) =>
sgm.GameStatus.State == GameStatus.Types.State.GameRunning
@@ -105,10 +105,9 @@ namespace eagle {
_currentModel.ShardokGameModels
.Select(sgm => {
var needsResync = _shardokNeedsResync.GetValueOrDefault(sgm.Key, false);
// Use thread-safe count from gRPC thread updates, fall back to model
var count = _shardokResultCounts.GetValueOrDefault(
sgm.Key,
sgm.Value.History.Count());
// Use thread-safe count from gRPC thread updates. Fall back to 0 if
// not yet tracked (avoids accessing non-thread-safe History.Count).
var count = _shardokResultCounts.GetValueOrDefault(sgm.Key, 0);
return new IClientConnectionSubscriber.ShardokViewStatus {
shardokGameId = sgm.Key,
filteredResultCount = needsResync ? 0 : count,
@@ -199,7 +198,8 @@ namespace eagle {
return new List<AvailableCommand>();
}
public Dictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
// Thread-safe: accessed from heartbeat timer thread via ShardokViewStatuses
public ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
public FactionView MaybeDestroyedFaction(FactionId factionId) {
if (ActiveFactions.TryGetValue(factionId, out var factionView)) {
@@ -241,7 +241,8 @@ namespace eagle {
new Dictionary<ProvinceId, OneProvinceAvailableCommands>();
_currentModel.GsView = new GameStateView();
_currentModel.ShardokGameModels = new Dictionary<ShardokGameId, ShardokGameModel>();
_currentModel.ShardokGameModels =
new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();
_currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
@@ -312,8 +313,10 @@ namespace eagle {
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
}
_lastUnfilteredResultCount =
updateItem.ActionResultResponse.UnfilteredResultCountAfter;
// Note: _lastUnfilteredResultCount is updated on the gRPC thread in
// UpdateResultCounts() before enqueueing. We don't update it here to avoid
// race conditions where a backlogged MainQueue update overwrites a newer count.
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
updateItem.ActionResultResponse.AvailableCommands == null ||
updateItem.ActionResultResponse.AvailableCommands.Token !=
@@ -339,7 +342,9 @@ namespace eagle {
// Battle may have ended before we could create the model - remove
// any stale reference and skip this update
if (shardokGameModel == null) {
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
continue;
}
@@ -368,7 +373,9 @@ namespace eagle {
shardokGameModel;
} else {
// Game ended - remove from active models so UI knows battle is over
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
}
}
@@ -478,6 +485,31 @@ namespace eagle {
_currentModel.BattalionTypes =
startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
// For any outstanding battles not in ShardokGameModels, create models and mark for
// resync. This ensures fresh clients get Shardok state for ongoing battles.
bool needsResubscribe = false;
foreach (var battle in _currentModel.ShardokBattles) {
if (!_currentModel.ShardokGameModels.ContainsKey(battle.ShardokGameId)) {
var model = MakeGameModel(battle.ShardokGameId);
if (model != null) {
_currentModel.ShardokGameModels[battle.ShardokGameId] = model;
MarkShardokForResync(battle.ShardokGameId);
_connectionLogger.LogLine(
$"[STATE_RESYNC] Created ShardokGameModel for outstanding battle {battle.ShardokGameId}");
needsResubscribe = true;
}
}
}
// If we created new ShardokGameModels, re-subscribe to request their full state.
// The original subscribe didn't include these battles since we didn't know about them
// yet.
if (needsResubscribe) {
_connectionLogger.LogLine(
"[STATE_RESYNC] Re-subscribing to request full Shardok state for new battles");
_ = StartListeningForUpdates();
}
}
private void HandleUpdates(List<ActionResultView> results) {
@@ -780,7 +812,7 @@ namespace eagle {
// the Eagle update removing the battle arrives before the Shardok Victory update.
if (_currentModel.ShardokGameModels.TryGetValue(rb, out var sgm)) {
sgm.MarkBattleEnded("Battle has ended.");
_currentModel.ShardokGameModels.Remove(rb);
_currentModel.ShardokGameModels.TryRemove(rb, out _);
}
_shardokResultCounts.TryRemove(rb, out _);
@@ -132,7 +132,10 @@ namespace eagle {
public void ProvinceHovered(ProvinceId? pid) {
// Highlight moving armies table
// Check row count to avoid index out of range if data changed after table was built
var rowCount = movingArmiesTable.RowCount;
MovingArmies.Each((army, i) => {
if (i >= rowCount) return;
var row = movingArmiesTable.ComponentAt<MovingArmyTableRow>(i);
if (army.DestinationProvinceId == pid || army.OriginProvinceId == pid) {
row.ShadeOn();
@@ -1,30 +1,106 @@
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 ProfessionGainedDetailsNotificationGenerator {
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
private static readonly Dictionary<Profession, string> ProfessionNames =
new Dictionary<Profession, string> {
{ Profession.Mage, "Mage" },
{ Profession.Necromancer, "Necromancer" },
{ Profession.Engineer, "Engineer" },
{ Profession.Paladin, "Paladin" },
{ Profession.Ranger, "Ranger" },
{ Profession.Champion, "Champion" }
};
private static readonly Random Random = new();
private static readonly Dictionary<Profession, string> ProfessionTitles =
new Dictionary<Profession, string> {
{ Profession.Mage, "Arcane Awakening" },
{ Profession.Necromancer, "Dark Pact Sealed" },
{ Profession.Engineer, "Genius Unleashed" },
{ Profession.Paladin, "Divine Calling" },
{ Profession.Ranger, "One with the Wild" },
{ Profession.Champion, "Born for Battle" }
};
private static readonly Dictionary<Profession, string> ProfessionNames = new() {
{ Profession.Mage, "Mage" },
{ Profession.Necromancer, "Necromancer" },
{ Profession.Engineer, "Engineer" },
{ Profession.Paladin, "Paladin" },
{ Profession.Ranger, "Ranger" },
{ Profession.Champion, "Champion" }
};
private static readonly Dictionary<Profession, List<string>> ProfessionTitles = new() {
{ Profession.Mage,
new List<string> {
"Arcane Awakening",
"The Gift Revealed",
"Touched by Magic",
"Mystical Ascension",
"Power Unbound",
"The Arcane Path",
"Secrets of the Weave",
"Spellborn",
"Wielder of the Unseen",
"Flames of Knowledge"
} },
{ Profession.Necromancer,
new List<string> {
"Dark Pact Sealed",
"Beyond the Veil",
"Death's Apprentice",
"Whispers from Beyond",
"The Forbidden Art",
"Shadow Covenant",
"Secrets of the Grave",
"Communion with Darkness",
"The Deathless Path",
"Embrace of Shadow"
} },
{ Profession.Engineer,
new List<string> {
"Genius Unleashed",
"Master of Mechanisms",
"The Inventor's Spark",
"Gears of Progress",
"Mind of Innovation",
"Builder of Wonders",
"The Tinkerer's Art",
"Siege Mastery",
"Architect of War",
"Mechanical Brilliance"
} },
{ Profession.Paladin,
new List<string> {
"Divine Calling",
"Holy Vows Taken",
"Blessed Champion",
"The Righteous Path",
"Shield of the Faith",
"Anointed Warrior",
"Oath of Light",
"Heaven's Chosen",
"Sacred Duty",
"Defender of the Realm"
} },
{ Profession.Ranger,
new List<string> {
"One with the Wild",
"Voice of the Forest",
"The Untamed Path",
"Nature's Guardian",
"Shadow of the Woods",
"Hunter's Instinct",
"Wild Heart",
"The Wanderer's Way",
"Beast Companion",
"Eyes of the Hawk"
} },
{ Profession.Champion,
new List<string> {
"Born for Battle",
"Blade Mastery",
"The Warrior's Edge",
"Forged in Combat",
"Unmatched Prowess",
"Heart of Steel",
"The Victor's Path",
"Legend in the Making",
"Master of Arms",
"Glory Awaits"
} }
};
private static string GetProfessionName(Profession profession) {
return ProfessionNames.TryGetValue(profession, out var name) ? name : "Unknown";
@@ -40,8 +116,19 @@ namespace eagle.Notifications.ARNNotifications {
}
private static string GetProfessionTitle(Profession profession) {
return ProfessionTitles.TryGetValue(profession, out var title) ? title
: "Profession Gained";
if (ProfessionTitles.TryGetValue(profession, out var titles) && titles.Count > 0) {
return titles[Random.Next(titles.Count)];
}
return "Profession Gained";
}
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(
@@ -49,14 +136,38 @@ namespace eagle.Notifications.ARNNotifications {
IGameModel currentModel) {
var details = notification.Details.ProfessionGainedDetails;
var hero = currentModel.Heroes[details.HeroId];
var factionName = currentModel.FactionName(details.FactionId);
var professionName = GetProfessionName(details.NewProfession);
var article = GetArticle(professionName);
var affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
string textTemplate;
List<ProvinceId> affectedProvinces;
string textTemplate =
$"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
if (details.FactionId == currentModel.PlayerId) {
// Player's own hero - vary text based on faction leader status
string heroDescription;
if (hero.IsFactionLeader) {
heroDescription =
$"Your sworn {DisplayNames.SiblingDescription(hero.PronounGender)}";
} else {
heroDescription = "Your vassal";
}
var heroProvinceId = FindProvinceForHero(details.HeroId, currentModel);
if (heroProvinceId.HasValue) {
var provinceName = currentModel.Provinces[heroProvinceId.Value].Name;
textTemplate =
$"{heroDescription} {{Hero}} in {provinceName} became {article} {professionName}.\n\n";
affectedProvinces = new List<ProvinceId> { heroProvinceId.Value };
} else {
textTemplate =
$"{heroDescription} {{Hero}} became {article} {professionName}.\n\n";
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
} else {
// Another faction's hero
var factionName = currentModel.FactionName(details.FactionId);
textTemplate = $"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "Hero", (hero.NameTextId, "A hero") }
@@ -31,7 +31,13 @@ namespace eagle.Notifications.ARNNotifications {
};
if (playerId.HasValue && playerId.Value == paidToFactionId) {
// no notification
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Accepted",
textTemplate: $"We have accepted the ransom from {currentModel.FactionName(paidByFactionId)} for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { ransomedHero, offeringFactionHead });
} else if (playerId.HasValue && playerId.Value == paidByFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Accepted",
@@ -9,6 +9,7 @@ namespace eagle.Notifications {
private List<GeneratedTextListener> textListeners = new();
private string textTemplate;
private Dictionary<string, string> placeholderValues = new();
private Dictionary<string, string> fallbackValues = new();
public DynamicTextNotification(
string title,
@@ -72,6 +73,9 @@ namespace eagle.Notifications {
string nameTextId = kvp.Value.nameTextId;
string fallback = kvp.Value.fallback;
// Store fallback so UpdateText can use it for missing placeholders
fallbackValues[placeholder] = fallback;
if (!string.IsNullOrEmpty(nameTextId)) {
var listener = new GeneratedTextListener(
nameTextId,
@@ -95,6 +99,15 @@ namespace eagle.Notifications {
private void UpdateText() {
string result = textTemplate;
// First apply fallbacks for any placeholder without a loaded value
foreach (var kvp in fallbackValues) {
if (!placeholderValues.ContainsKey(kvp.Key)) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
}
// Then apply actual loaded values
foreach (var kvp in placeholderValues) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
@@ -69,6 +69,17 @@ namespace eagle {
private ConnectionCircuitBreaker _circuitBreaker = new ConnectionCircuitBreaker();
public ConnectionCircuitBreaker CircuitBreaker => _circuitBreaker;
/// <summary>
/// Force an immediate reconnection attempt, bypassing the circuit breaker timeout.
/// </summary>
public void ForceReconnect() {
_circuitBreaker.ForceReconnect();
_retryTimer?.Dispose();
NextReconnectAttempt = null;
LogConnectionEvent("force_reconnect", "User requested immediate reconnect");
Task.Run(() => Connect());
}
private DateTime? GetDeadlineFromNow() {
return DateTime.UtcNow.AddSeconds(TimeoutSeconds);
}
@@ -1060,51 +1071,69 @@ namespace eagle {
}
}
// Grace period after connect before sync mismatch triggers reconnect.
// Allows time to receive and process historical results after fresh subscribe.
private const double SyncMismatchGracePeriodSeconds = 60.0;
private void HandleHeartbeatResponse(HeartbeatResponse response) {
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
// Check for sync mismatches reported by server
if (response.GameSyncResults.Count > 0) {
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
bool hasMismatch = false;
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_eagle",
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
hasMismatch = true;
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_eagle",
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_shardok",
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
$"server_count={shardokResult.ServerFilteredResultCount}");
}
"sync_mismatch_shardok",
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
$"server_count={shardokResult.ServerFilteredResultCount}");
hasMismatch = true;
}
}
// Trigger resync by reconnecting - this will re-subscribe with current counts
// and the server will send missing updates
_remoteEagleClientLogger.LogLine(
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Schedule reconnect to resync
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("SyncMismatch");
}
if (!hasMismatch) return;
// Grace period after connect - allow time to receive historical results
if (_lastSuccessfulConnect.HasValue) {
var timeSinceConnect =
(DateTime.UtcNow - _lastSuccessfulConnect.Value).TotalSeconds;
if (timeSinceConnect < SyncMismatchGracePeriodSeconds) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Ignoring mismatch during grace period ({timeSinceConnect:F1}s < {SyncMismatchGracePeriodSeconds}s)");
return;
}
}
// Trigger resync by reconnecting - this will re-subscribe with current counts
// and the server will send missing updates
_remoteEagleClientLogger.LogLine(
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Schedule reconnect to resync
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("SyncMismatch");
}
}
}
@@ -32467,7 +32467,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 2147483647
m_IsActive: 0
m_IsActive: 1
--- !u!224 &300036923
RectTransform:
m_ObjectHideFlags: 0
@@ -33904,6 +33904,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 727a520b2deac4a28ae9c6a00e54c2e7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.ConnectionStatusUI
retryButton: {fileID: 0}
--- !u!114 &320759869
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -50851,6 +50852,148 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 464655241}
m_CullTransparentMesh: 0
--- !u!1 &465641331
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 465641332}
- component: {fileID: 465641336}
- component: {fileID: 465641335}
- component: {fileID: 465641334}
- component: {fileID: 465641333}
m_Layer: 0
m_Name: Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
--- !u!224 &465641332
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1405807865}
m_Father: {fileID: 1012849519}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 210.00002, y: -27.5}
m_SizeDelta: {x: 80, y: 25}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &465641333
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: 80
m_MinHeight: 25
m_PreferredWidth: 80
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &465641334
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 465641335}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!114 &465641335
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &465641336
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_CullTransparentMesh: 1
--- !u!1 &466028234
GameObject:
m_ObjectHideFlags: 0
@@ -56271,9 +56414,9 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 176
m_MinWidth: 250
m_MinHeight: -1
m_PreferredWidth: 176
m_PreferredWidth: 250
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: 1
@@ -121559,6 +121702,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1453223014}
- {fileID: 465641332}
m_Father: {fileID: 512260365}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -170015,6 +170159,144 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1405279533}
m_CullTransparentMesh: 0
--- !u!1 &1405807864
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1405807865}
- component: {fileID: 1405807867}
- component: {fileID: 1405807866}
m_Layer: 0
m_Name: Text (TMP)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1405807865
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1405807864}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 465641332}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1405807866
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1405807864}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Reconnect
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4281479730
m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 12
m_fontSizeBase: 12
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!222 &1405807867
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1405807864}
m_CullTransparentMesh: 1
--- !u!1 &1406178529
GameObject:
m_ObjectHideFlags: 0
@@ -174532,7 +174814,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: 3440
m_text: Server down. Reconnecting in 10s
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
@@ -174560,8 +174842,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontSize: 12
m_fontSizeBase: 12
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -174645,6 +174927,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 727a520b2deac4a28ae9c6a00e54c2e7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.ConnectionStatusUI
retryButton: {fileID: 465641334}
--- !u!1 &1453601446
GameObject:
m_ObjectHideFlags: 0
@@ -190489,7 +190772,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 2147483647
m_IsActive: 1
m_IsActive: 0
--- !u!224 &1566150938
RectTransform:
m_ObjectHideFlags: 0
@@ -385,98 +385,99 @@ namespace Shardok {
Model.MyUncommittedUnits.Where(uv => uv.Location.Row == -1).ToList();
}
/// <summary>
/// Called when the ShardokGameModel is updated. This is invoked from UpdateAction,
/// which is called from ShardokGameModel.HandleUpdates, which runs on MainQueue.
/// No need to re-enqueue - we're already on the main thread.
/// </summary>
void ModelUpdated() {
MainQueue.Q.Enqueue(() => {
if (Model == null) { return; }
SetHeroLabels();
SetModifiers();
UpdateReserves();
if (Model == null) { return; }
SetHeroLabels();
SetModifiers();
UpdateReserves();
HandleEnemyStartingPositionOverlays();
HandleEnemyStartingPositionOverlays();
endTurnButton.interactable = false;
endTurnButton.interactable = false;
if (Model.GameStatus != null &&
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
turnStatusLabel.text = "Game Over!";
if (Model.GameStatus != null &&
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
turnStatusLabel.text = "Game Over!";
gameOverText.text = Model.GameStatus.Description;
gameOverCanvas.gameObject.SetActive(true);
gameOverText.text = Model.GameStatus.Description;
gameOverCanvas.gameObject.SetActive(true);
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
endTurnButton.interactable = true;
} else if (Model.GameStatus != null && Model.MyTurn) {
gameOverCanvas.gameObject.SetActive(false);
turnStatusLabel.text = "Your Turn";
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
endTurnButton.interactable = true;
} else if (Model.GameStatus != null && Model.MyTurn) {
gameOverCanvas.gameObject.SetActive(false);
turnStatusLabel.text = "Your Turn";
if (Model.InSetUp) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
if (Model.InSetUp) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
var unplacedUnitsWithLocations =
Model.MyUncommittedUnits
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
.ToList();
var unplacedUnitsWithLocations =
Model.MyUncommittedUnits
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
.ToList();
if (!Model.MyTurn) {
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() < 1) {
endTurnButton.GetComponentInChildren<TMP_Text>().text =
"No units placed";
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() > 10) {
endTurnButton.GetComponentInChildren<TMP_Text>().text =
"Too many units";
endTurnButton.interactable = false;
} else {
endTurnButton.interactable = true;
}
SetDisplayedCommandGroup(0);
if (!Model.MyTurn) {
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() < 1) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "No units placed";
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() > 10) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Too many units";
endTurnButton.interactable = false;
} else {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
}
if (Model.HasAvailableCommandWhere(
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
CommandTypeUIManager.EndTurnCommandGroup)) {
endTurnButton.interactable = true;
}
SelectAppropriateDefaultCommand();
SetDisplayedCommandGroup(0);
} else {
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
}
locationNameText.text = Model.LocationName;
if (Model.History.Count > 0) {
string monthString = new DateTime(777, Model.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
Weather weather = Model.Weather;
if (weather != null) {
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
}
if (Model.HasAvailableCommandWhere(
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
CommandTypeUIManager.EndTurnCommandGroup)) {
endTurnButton.interactable = true;
}
if (Model.History.Count == 0) {
turnHistoryButtonText.text = NoHistoryText;
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
SelectAppropriateDefaultCommand();
} else {
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
}
ActionType type = historyEntry.Type;
var thisSound = soundManager.SoundForType(type);
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
}
_lastRetrievedHistoryCount = Model.History.Count;
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
SetModifiers();
locationNameText.text = Model.LocationName;
if (Model.History.Count > 0) {
string monthString = new DateTime(777, Model.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
Weather weather = Model.Weather;
if (weather != null) {
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
}
}
SetupArmiesTable();
});
if (Model.History.Count == 0) {
turnHistoryButtonText.text = NoHistoryText;
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
ActionType type = historyEntry.Type;
var thisSound = soundManager.SoundForType(type);
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
}
_lastRetrievedHistoryCount = Model.History.Count;
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
SetModifiers();
}
SetupArmiesTable();
}
private void SetupArmiesTable() {
@@ -707,34 +708,33 @@ namespace Shardok {
}
void SetModifiers() {
MainQueue.Q.Enqueue(() => {
hexGrid.ClearCellModifierImages();
// Called from ModelUpdated which runs on MainQueue - no need to re-enqueue
hexGrid.ClearCellModifierImages();
for (byte row = 0; row < Model.Map.RowCount; row++) {
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
Coords coords = new Coords();
coords.Row = row;
coords.Column = column;
for (byte row = 0; row < Model.Map.RowCount; row++) {
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
Coords coords = new Coords();
coords.Row = row;
coords.Column = column;
var terrain = Model.Map.TerrainAt(coords);
int cellIndex = MapCoordsToGridIndex(coords);
int numberForCell = _randomNumberForCellIndex[cellIndex];
hexGrid.SetCellTerrainImage(
cellIndex,
_imageForTerrainTracker
.GetImageForTerrain(terrain, numberForCell, Model.Month));
if (terrain.Modifier?.Fire != null) {
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
} else {
hexGrid.SetCellModifierEffect(cellIndex, null);
}
var terrain = Model.Map.TerrainAt(coords);
int cellIndex = MapCoordsToGridIndex(coords);
int numberForCell = _randomNumberForCellIndex[cellIndex];
hexGrid.SetCellTerrainImage(
cellIndex,
_imageForTerrainTracker
.GetImageForTerrain(terrain, numberForCell, Model.Month));
if (terrain.Modifier?.Fire != null) {
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
} else {
hexGrid.SetCellModifierEffect(cellIndex, null);
}
if (terrain.Modifier?.Bridge != null) {
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
}
if (terrain.Modifier?.Bridge != null) {
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
}
}
});
}
}
void HandleButton() {
@@ -82,7 +82,7 @@ public class ShardokGameModel {
public int Month { get; private set; }
private List<PlayerTotals> PlayerTotals { get; set; }
public List<CommandDescriptor> AvailableCommands { get; private set; }
public List<CommandDescriptor> AvailableCommands { get; private set; } = new();
public Action UpdateAction { get; set; }
public List<PlayerWithHostility> players;
public String GetPlayerName(PlayerId playerId) {
@@ -217,6 +217,8 @@ public class ShardokGameModel {
? newCommands.CurrentCommand.ToList()
: newCommands.PreviewCommand.ToList();
}
// Trigger UI refresh after commands are updated so unit action indicators reflect new state
UpdateAction?.Invoke();
}
public bool HasTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
@@ -138,9 +138,9 @@ namespace common {
// Use TMP's calculated preferred height - this is the actual text size
contentHeight = tmpText.preferredHeight;
// Ensure Content RectTransform is tall enough to contain the text
// This is necessary for ScrollRect to scroll properly
if (scrollRect.content.rect.height < contentHeight) {
// Sync Content RectTransform height with actual text size.
// Must both grow AND shrink to prevent blank space when scrolling to bottom.
if (Mathf.Abs(scrollRect.content.rect.height - contentHeight) > 1f) {
scrollRect.content.SetSizeWithCurrentAnchors(
RectTransform.Axis.Vertical,
contentHeight);
@@ -203,6 +203,11 @@ namespace common {
return;
}
// Hold Shift to pause scrolling (lets user read at their own pace)
bool isPausedByUser =
Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
if (isPausedByUser) return;
// Handle pause at bottom
if (_isPausedAtBottom) {
_pauseTimer += Time.deltaTime;
@@ -54,18 +54,43 @@ namespace common.GUIUtils {
Type = type,
Time = DateTime.UtcNow
});
MainQueue.Q.Enqueue(() => {
errorTextField.text = AllMessageText;
panel.gameObject.SetActive(true);
});
// Try to display immediately if possible, otherwise queue for later
try {
if (MainQueue.Q != null) {
MainQueue.Q.Enqueue(ShowErrorPanel);
} else {
_pendingShow = true;
}
} catch {
// MainQueue not ready yet - will show in Update
_pendingShow = true;
}
}
}
private bool _pendingShow = false;
private void ShowErrorPanel() {
if (errorTextField != null && panel != null) {
errorTextField.text = AllMessageText;
panel.gameObject.SetActive(true);
}
}
// Register for log messages as early as possible
void Awake() { Application.logMessageReceivedThreaded += HandleLog; }
void Update() {
// Handle errors that occurred before MainQueue was ready
if (_pendingShow) {
_pendingShow = false;
ShowErrorPanel();
}
}
// Use this for initialization
void Start() {
panel.gameObject.SetActive(false);
Application.logMessageReceivedThreaded += HandleLog;
}
void Start() { panel.gameObject.SetActive(false); }
public void DismissClicked() {
lock (_messages) {
@@ -21,14 +21,24 @@ option java_outer_classname = "EagleInterface";
option objc_class_prefix = "E0G";
service ShardokInternalInterface {
// Server-side streaming for game updates - replaces polling via GetGameStatus
rpc SubscribeToGame(GameSubscriptionRequest) returns (stream GameStatusResponse) {}
rpc PostCommand(PostCommandRequest) returns (GameStatusResponse) {}
rpc PostPlacementCommands(PlacementCommandsRequest) returns (GameStatusResponse) {}
// Deprecated: Use SubscribeToGame for streaming updates instead
rpc GetGameStatus(GameStatusRequest) returns (GameStatusResponse) {}
rpc GetHexMap(HexMapRequest) returns (HexMapResponse) {}
rpc GetHexMapNames(HexMapNamesRequest) returns (HexMapNamesResponse) {}
}
message GameSubscriptionRequest {
string game_id = 1;
GameSetupInfo game_setup_info = 2;
}
message PostCommandRequest {
string game_id = 1;
int32 player_id = 2;
@@ -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;
}
@@ -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;
}
@@ -4,6 +4,7 @@ filegroup(
name = "settings",
srcs = ["settings.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle:__pkg__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
],
@@ -13,6 +14,7 @@ filegroup(
name = "game_parameters",
srcs = ["game_parameters.json"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test:__subpackages__",
],
@@ -22,6 +24,7 @@ filegroup(
name = "beasts",
srcs = ["beasts.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test:__subpackages__",
@@ -32,6 +35,7 @@ filegroup(
name = "headshots",
srcs = ["headshots.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator/image_paths:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator/random_hero_generator:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
@@ -46,6 +50,7 @@ filegroup(
"heroes.tsv",
],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test:__subpackages__",
@@ -56,6 +61,7 @@ filegroup(
name = "province_map",
srcs = ["province_map.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test:__subpackages__",
],
@@ -5,6 +5,7 @@ filegroup(
name = "settings",
srcs = ["settings.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/cpp/net/eagle0/shardok:__subpackages__",
"//src/test/cpp/net/eagle0/shardok:__subpackages__",
],
@@ -44,6 +45,7 @@ filegroup(
name = "battalion_types",
srcs = ["battalionTypes.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/cpp/net/eagle0/shardok:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
@@ -114,6 +114,7 @@ class ActionResultApplierImpl(validator: Option[ScalaValidator]) extends ActionR
// Apply all remaining entity changes using extension methods
val finalState = stateAfterChangedBattalions
.applyProvinceActed(result.provinceIdActed)
.applyLastCommand(result.provinceId, result.lastCommandTypeForActingProvince)
.applyNewBattalions(result.newBattalions, result.provinceId)
.applyDestroyedBattalionIds(result.destroyedBattalionIds)
.applyNewHeroes(result.newHeroes)
@@ -54,6 +54,7 @@ scala_library(
deps = [
":province_update_helpers",
":province_update_helpers2",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
@@ -106,7 +107,6 @@ scala_library(
srcs = ["GameStateFactionExtensions.scala"],
visibility = ["//visibility:private"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -114,6 +114,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
)
@@ -154,6 +155,7 @@ scala_library(
":game_state_hero_extensions",
":game_state_misc_extensions",
":game_state_province_extensions",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -169,6 +171,7 @@ scala_library(
":game_state_hero_extensions",
":game_state_misc_extensions",
":game_state_province_extensions",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:extra_xp_for_stat_bump_over100",
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
@@ -9,7 +9,7 @@ import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.faction.FactionT.OutgoingOfferRound
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.views.province_view.ProvinceView
import net.eagle0.eagle.model.view.province.ProvinceView
object GameStateFactionExtensions {
@@ -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 =
@@ -1,6 +1,7 @@
package net.eagle0.eagle.library.actions.applier
import net.eagle0.eagle.*
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
@@ -75,5 +76,22 @@ object GameStateProvinceExtensions {
}
.getOrElse(gameState)
}
def applyLastCommand(
provinceId: Option[ProvinceId],
lastCommand: Option[SelectedCommand]
): GameState =
provinceId
.filter(_ => lastCommand.exists(!_.isEmpty))
.map { pid =>
val province = gameState.provinces(pid) match {
case p: ProvinceC => p
case p => throw new EagleInternalException(s"Unknown ProvinceT type: ${p.getClass}")
}
gameState.copy(
provinces = gameState.provinces.updated(pid, province.copy(lastCommand = lastCommand))
)
}
.getOrElse(gameState)
}
}
@@ -8,24 +8,27 @@ import net.eagle0.eagle.api.available_command.{
RecommendedBattleConfig
}
import net.eagle0.eagle.api.command.util.battalion_with_food_cost.BattalionWithFoodCost
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.settings.{MaxCombatUnitCountPerSide, MinVigorForMarch}
import net.eagle0.eagle.library.util.{BattalionSuitability, LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.{BattalionSuitability, BattalionUtils, ShardokMapInfo}
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.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
private def oneOriginProvinceCommand(
gameState: GameState,
originProvince: Province
originProvince: ProvinceT
): Option[MarchCommandFromOneProvince] = {
val availableDestinations = originProvince.neighbors
.map(_.provinceId)
.flatMap(gameState.provinces.get)
.filterNot(LegacyProvinceUtils.hasBlizzard)
.map(gameState.provinces)
.filterNot(ProvinceUtils.hasBlizzard)
.map { destinationProvince =>
val startingPositionIndex = destinationProvince.neighbors
.find(n => n.provinceId == originProvince.id)
@@ -49,14 +52,14 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
.map(heroId => gameState.heroes(heroId))
.filter(_.vigor >= MinVigorForMarch.doubleValue)
if LegacyProvinceUtils.hasBlizzard(originProvince) then return None
if ProvinceUtils.hasBlizzard(originProvince) then return None
if availableDestinations.isEmpty then return None
if availableHeroes.isEmpty then return None
val availableBattalions = originProvince.battalionIds
.map(battId => gameState.battalions(battId))
.sortBy(_.`type`.index)
.sortBy(_.typeId.value)
val recommendedSendCount =
availableHeroes.size
@@ -74,7 +77,7 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
RecommendedBattleConfig(
units = units,
food = MarchSuppliesHelpers.foodToTakeOnAttack(
units.flatMap(_.battalionId).map(gameState.battalions),
units.flatMap(_.battalionId).size,
originProvince.food
),
gold = MarchSuppliesHelpers.goldToTakeOnAttack(
@@ -90,9 +93,9 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
availableBattalions = availableBattalions.map { batt =>
BattalionWithFoodCost(
battalionId = batt.id,
monthlyFood = LegacyBattalionUtils.monthlyConsumedFood(
monthlyFood = BattalionUtils.monthlyConsumedFood(
battalion = batt,
bts = gameState.battalionTypes.toVector
bts = gameState.battalionTypes
)
)
}.toVector,
@@ -112,11 +115,12 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
}
override def availableCommand(
gameState: GameState,
gameStateProto: GameStateProto,
factionId: FactionId,
provinceId: ProvinceId
): Option[MarchAvailableCommand] =
LegacyHeroUtils
): Option[MarchAvailableCommand] = {
val gameState = GameStateConverter.fromProto(gameStateProto)
HeroUtils
.consideredProvinces(gameState, factionId, provinceId)
.flatMap(p => oneOriginProvinceCommand(gameState = gameState, originProvince = p)) match {
case items if items.isEmpty => None
@@ -125,8 +129,9 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
MarchAvailableCommand(
actingProvinceId = provinceId,
oneProvinceCommands = opcs,
availableTypes = gameState.battalionTypes
availableTypes = gameState.battalionTypes.map(BattalionTypeConverter.toProto)
)
)
}
}
}
@@ -447,12 +447,16 @@ scala_library(
"//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:battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:shardok_map_info",
"//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/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -149,14 +149,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",
],
)
@@ -207,9 +209,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:captured_hero_resolved_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_aftermath_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battle_revelation_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:notification_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/llm_request",
"//src/main/scala/net/eagle0/eagle/model/state:battle_revelation",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
@@ -803,7 +802,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
@@ -890,7 +888,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",
@@ -1288,7 +1285,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_recon_resolution_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:recon_succeeded_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province:incoming_end_turn_action",
@@ -1837,35 +1833,6 @@ scala_library(
],
)
scala_library(
name = "unaffiliated_hero_moved_action",
srcs = ["UnaffiliatedHeroMovedAction.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action",
"//src/main/scala/net/eagle0/eagle/library/settings:free_hero_move_vigor_cost",
"//src/main/scala/net/eagle0/eagle/library/util/unaffiliated_hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:unaffiliated_hero_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
scala_library(
name = "unaffiliated_hero_rejoined_action",
srcs = ["UnaffiliatedHeroRejoinedAction.scala"],
@@ -8,6 +8,7 @@ import net.eagle0.eagle.library.util.ransom_validity.RansomValidity
import net.eagle0.eagle.library.util.ReturningHeroes
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.generated_text_request.LlmRequestT.NewFactionHeadMessage
import net.eagle0.eagle.model.action_result.types.{
FactionDestroyedResultType,
FactionLeaderRemovedResultType,
@@ -208,24 +209,35 @@ 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)
(
ChangedFactionC(
factionId = revisedFaction.id,
removedLeaderHeroIds = originalFaction.leaderIds.filter(killedHeroIds.contains),
newFactionHeadHeroId = Some(revisedFaction.factionHeadId),
newName = newFactionName
),
NewFactionHeadMessage(
requestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}",
eagleGameId = gameId,
recipientFactionIds = Vector.empty,
alwaysGenerate = false,
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousFactionName = originalFaction.name,
newFactionName = newFactionName,
previousHeadHeroId = originalFaction.factionHeadId
)
)
}.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)
)
)
}
@@ -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)
}
@@ -22,7 +22,6 @@ import net.eagle0.eagle.model.action_result.{ActionResultT, NotificationDetails,
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ChangedHeroC, NotificationC}
import net.eagle0.eagle.model.action_result.types.{CapturedHeroResolvedResultType, EndAftermathPhaseResultType}
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.{BattleRevelation, RoundPhase}
import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.game_state.GameState
@@ -350,15 +349,10 @@ case class EndBattleAftermathPhaseAction(
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
// Revelation changes still need proto for ProvinceViewFilter
private def revelationChange(
battleRevelation: BattleRevelation,
gs: GameState
): RevelationChange = {
// Lazy conversion to proto for ProvinceViewFilter calls
lazy val gsProto = GameStateConverter.toProto(gs)
lazy val provinceProto = gsProto.provinces(battleRevelation.provinceId)
): RevelationChange =
RevelationChange(
changedFaction = battleRevelation.revelationType match {
case Unknown =>
@@ -369,25 +363,21 @@ case class EndBattleAftermathPhaseAction(
updatedReconnedProvinces = Vector(
ProvinceViewFilter
.withdrawnFromProvinceView(
province = provinceProto,
gs = gsProto,
province = gs.provinces(battleRevelation.provinceId),
gs = gs,
factionId = battleRevelation.revealedToFactionId
)
.withAsOf(gsProto.currentDate.get)
.copy(asOf = gs.currentDate)
)
)
case DidBattle =>
ChangedFactionC(
factionId = battleRevelation.revealedToFactionId,
updatedReconnedProvinces = Vector(
ProvinceViewFilter
.filteredProvinceView(
provinceProto,
gsProto
// FIXME: setting factionId to None right now to grab the full info. This
// probably isn't exactly what we want.
)
.withAsOf(gsProto.currentDate.get)
ProvinceViewFilter.filteredProvinceView(
gs.provinces(battleRevelation.provinceId),
gs
)
)
)
},
@@ -396,7 +386,6 @@ case class EndBattleAftermathPhaseAction(
removedBattleRevelations = Vector(battleRevelation)
)
)
}
def revelationChanges(gs: GameState): Vector[RevelationChange] =
for {
@@ -1,14 +1,11 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.impl.common.ProtolessSequentialResultsAction
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
object HeroBackstoryUpdateActionGenerator {
/** Creates action from Scala GameState (preferred) */
def apply(gameState: GameState): ProtolessSequentialResultsAction = {
val factions = gameState.factions.values.toVector
HeroBackstoryUpdateAction(
@@ -25,8 +22,4 @@ object HeroBackstoryUpdateActionGenerator {
.flatMap(_.rulingFactionId)
)
}
/** Creates action from proto GameState (for sequencer callbacks) */
def fromGameState(gameState: GameStateProto): ProtolessSequentialResultsAction =
apply(GameStateConverter.fromProto(gameState))
}
@@ -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()
@@ -1,7 +1,6 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
@@ -11,7 +10,6 @@ import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedPro
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ClientTextVisibilityExtensionC}
import net.eagle0.eagle.model.action_result.types.{EndReconResolutionPhaseResultType, ReconSucceededResultType}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.{IncomingEndTurnAction, IncomingRecon}
import net.eagle0.eagle.model.state.RoundPhase
@@ -22,9 +20,6 @@ case class PerformReconResolutionAction(
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
// Proto GameState still needed for ProvinceViewFilter.filteredProvinceView
private lazy val startingGameStateProto: GameStateProto = GameStateConverter.toProto(gameState)
override protected def randomResults(
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] =
@@ -94,14 +89,10 @@ case class PerformReconResolutionAction(
ChangedFactionC(
factionId = fromFactionId,
updatedReconnedProvinces = Vector(
ProvinceViewFilter
.filteredProvinceView(
startingGameStateProto.provinces(provinceId),
startingGameStateProto
// FIXME: setting factionId to None right now to grab the full info. This
// probably isn't exactly what we want.
)
.withAsOf(startingGameStateProto.currentDate.get)
ProvinceViewFilter.filteredProvinceView(
gameState.provinces(provinceId),
gameState
)
)
)
),
@@ -1,84 +0,0 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.common.action_result_notification_details.{Notification, OutlawSpottedDetails}
import net.eagle0.eagle.common.action_result_type.ActionResultType.HERO_MOVED
import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType.UNAFFILIATED_HERO_OUTLAW
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.changed_hero.ChangedHero
import net.eagle0.eagle.internal.changed_hero.ChangedHero.Vigor
import net.eagle0.eagle.internal.changed_province.ChangedProvince
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.library.settings.FreeHeroMoveVigorCost
import net.eagle0.eagle.library.util.unaffiliated_hero.UnaffiliatedHeroUtils
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.UnaffiliatedHeroConverter
import net.eagle0.eagle.ProvinceId
case class UnaffiliatedHeroMovedAction(
uh: UnaffiliatedHero,
fromProvinceId: ProvinceId,
toProvinceId: ProvinceId,
gameState: GameState
) {
private val affectedProvinces =
Vector(fromProvinceId, toProvinceId).map(gameState.provinces)
private val affectedFactions = affectedProvinces.flatMap(_.rulingFactionId)
private val notifications = Option
.when(uh.`type` == UNAFFILIATED_HERO_OUTLAW && affectedFactions.nonEmpty) {
Notification(
details = OutlawSpottedDetails(
outlawHeroId = uh.heroId,
provinceId = toProvinceId
),
targetFactions = affectedFactions
.map(fid => Notification.TargetFaction(factionId = fid))
)
}
.toVector
def immediateExecute(
functionalRandom: FunctionalRandom
): RandomState[ActionResult] =
computeNewRecruitmentInfo(functionalRandom).map { newRecruitmentInfo =>
ActionResult(
`type` = HERO_MOVED,
changedHeroes = Vector(
ChangedHero(
id = uh.heroId,
vigor = Vigor.VigorDelta(-FreeHeroMoveVigorCost.doubleValue)
)
),
changedProvinces = Vector(
ChangedProvince(
id = fromProvinceId,
removedUnaffiliatedHeroIds = Vector(uh.heroId)
),
ChangedProvince(
id = toProvinceId,
newUnaffiliatedHeroes = Vector(
uh.update(
_.recruitmentInfo := UnaffiliatedHeroConverter.recruitmentInfoToProto(newRecruitmentInfo)
)
)
)
),
notificationsToDeliver = notifications
)
}
private def computeNewRecruitmentInfo(
functionalRandom: FunctionalRandom
): RandomState[net.eagle0.eagle.model.state.unaffiliated_hero.RecruitmentInfo] = {
val scalaGameState = GameStateConverter.fromProto(gameState)
UnaffiliatedHeroUtils.newRecruitmentInfo(
gameState = scalaGameState,
provinceId = toProvinceId,
unaffiliatedHero = UnaffiliatedHeroConverter.fromProto(uh),
hero = scalaGameState.heroes(uh.heroId),
functionalRandom = functionalRandom
)
}
}
@@ -119,8 +119,8 @@ object ResolveRansomOfferCommand {
case Accepted =>
NotificationDetails.RansomPaid(
ransomedHeroId = ransomOffer.prisonerToBeRansomed.prisonerHeroId,
ransomPaidByFactionId = actingFactionId,
ransomPaidToFactionId = ransomOffer.originatingFactionId
ransomPaidByFactionId = ransomOffer.originatingFactionId,
ransomPaidToFactionId = actingFactionId
)
case Rejected =>
@@ -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
}
}
@@ -45,11 +45,10 @@ scala_library(
],
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",
"//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",
],
)
@@ -64,10 +63,12 @@ scala_library(
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//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",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
)
@@ -97,6 +98,7 @@ scala_library(
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_type_id",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
],
)
@@ -386,8 +388,12 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:stat_with_condition_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
],
)
@@ -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 =
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,8 +36,8 @@ object BattalionSuitability {
}
def suitableBattalions(
h: Hero,
bs: Vector[Battalion],
h: HeroT,
bs: Vector[BattalionT],
bts: Vector[BattalionType]
): SuitableBattalions =
SuitableBattalions(
@@ -45,14 +45,14 @@ object BattalionSuitability {
BattalionIdWithSuitability(
battalionId = b.id,
level = BattalionSuitability
.suitabilityLevel(h, bts.find(_.typeId == b.`type`).get)
.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
@@ -4,7 +4,7 @@ import net.eagle0.eagle.common.battalion_type.{
BattalionType as BattalionTypeProto,
BattalionTypeId as BattalionTypeIdProto
}
import net.eagle0.eagle.model.state.BattalionTypeId
import net.eagle0.eagle.model.state.{BattalionType, BattalionTypeId}
object BattalionTypeFinder {
def battalionType(
@@ -18,4 +18,10 @@ object BattalionTypeFinder {
battalionTypes: Vector[BattalionTypeProto]
): BattalionTypeProto =
battalionTypes.find(_.typeId == battalionTypeId).get
def findBattalionType(
battalionTypeId: BattalionTypeId,
battalionTypes: Vector[BattalionType]
): BattalionType =
battalionTypes.find(_.typeId == battalionTypeId).get
}
@@ -1,9 +1,24 @@
package net.eagle0.eagle.library.util
import net.eagle0.eagle.model.state.{BattalionType, BattalionTypeId}
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.BattalionType
import net.eagle0.eagle.model.state.BattalionTypeId.{
HeavyCavalry,
HeavyInfantry,
LightCavalry,
LightInfantry,
Longbowmen
}
object BattalionUtils {
val powerMultiplier: Map[BattalionTypeId, Double] = Map(
LightInfantry -> 1.0,
HeavyInfantry -> 2.0,
LightCavalry -> 2.0,
HeavyCavalry -> 4.0,
Longbowmen -> 1.5
)
def monthlyConsumedFood(
battalions: Iterable[BattalionT],
bts: Vector[BattalionType]
@@ -21,4 +36,9 @@ object BattalionUtils {
val battalionType = bts.find(_.typeId == battalion.typeId).get
battalion.size.toDouble * battalionType.monthlyFoodCost
}
def power(batt: BattalionT): Double =
powerMultiplier(
batt.typeId
) * batt.size * (1.0 + batt.armament / 100.0) + (1.0 + batt.training / 100.0)
}
@@ -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)
)
)
@@ -1,5 +1,6 @@
package net.eagle0.eagle.library.util
import net.eagle0.eagle.model.view.stat_with_condition.{Condition, StatWithCondition as StatWithConditionT}
import net.eagle0.eagle.views.stat_with_condition.StatWithCondition
import net.eagle0.eagle.views.stat_with_condition.StatWithCondition.Condition.{HIGH, LOW, MEDIUM}
@@ -11,6 +12,13 @@ object StatWithConditionUtils {
minHigh = 55
)
def supportScala(support: Double): StatWithConditionT =
scWithRangesScala(
support.floor.toInt,
minMedium = 40,
minHigh = 55
)
def scWithRanges(
stat: Int,
minMedium: Int,
@@ -23,4 +31,17 @@ object StatWithConditionUtils {
else if stat >= minMedium then MEDIUM
else LOW
)
def scWithRangesScala(
stat: Int,
minMedium: Int,
minHigh: Int
): StatWithConditionT =
StatWithConditionT(
stat = stat,
condition =
if stat >= minHigh then Condition.High
else if stat >= minMedium then Condition.Medium
else Condition.Low
)
}
@@ -3,41 +3,42 @@ package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AlmsAvailableCommand, AvailableCommand}
import net.eagle0.eagle.api.selected_command.AlmsSelectedCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.impl.command.AlmsCommand
import net.eagle0.eagle.library.settings.MinSupportForTaxes
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.FoodConsumptionUtils.foodConsumptionMonthsToHold
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
object AlmsCommandSelector {
private def bestHeroWithSupport(
availableHeroes: Vector[Hero]
): (Hero, Double) =
availableHeroes: Vector[HeroT]
): (HeroT, Double) =
availableHeroes
.map(h => (h, AlmsCommand.supportIncreasePerFood(HeroConverter.fromProto(h))))
.map(h => (h, AlmsCommand.supportIncreasePerFood(h)))
.maxBy {
case (hero, inc) =>
(inc, -LegacyHeroUtils.fatigue(hero))
(inc, -HeroUtils.fatigue(hero))
}
private def neededSupport(province: Province): Double =
private def neededSupport(province: ProvinceT): Double =
MinSupportForTaxes.doubleValue - province.support
def surplusFood(province: Province, gameState: GameState): Int =
def surplusFood(province: ProvinceT, gameState: GameState): Int =
Math.max(
0,
province.food - foodConsumptionMonthsToHold(
province.food.toInt - foodConsumptionMonthsToHold(
provinceId = province.id,
gameState = gameState
) * LegacyProvinceUtils
.monthlyFoodConsumption(province.id, gameState)
) * ProvinceUtils
.monthlyFoodConsumption(province, gameState)
)
def chosenAlmsForSupportCommand(
@@ -97,7 +98,7 @@ object AlmsCommandSelector {
gameState: GameState,
availableCommand: AlmsAvailableCommand,
minimumFood: Int,
province: Province
province: ProvinceT
): Option[CommandSelection] = {
val foodAfterFeedingTroops = surplusFood(province, gameState)
val effectiveMinimumFood = minimumFood.min(foodAfterFeedingTroops)
@@ -109,7 +110,7 @@ object AlmsCommandSelector {
)
val neededSupportPerMonth =
neededSupport(province) / (13 - gameState.currentDate.get.month)
neededSupport(province) / (13 - gameState.currentDate.get.month.value)
val neededFoodForSupport =
neededSupport(province) / supportPerFood
val neededFoodPerMonth =
@@ -123,7 +124,7 @@ object AlmsCommandSelector {
val isPartialGift =
neededFoodForSupport > availableCommand.foodAvailable
val isDecember = gameState.currentDate.get.month == 12
val isDecember = gameState.currentDate.get.month.value == 12
val haveExcessToQuellRiot =
(foodAfterFeedingTroops - neededFoodForSupport > 1000) || province.gold > 700
@@ -152,4 +153,50 @@ object AlmsCommandSelector {
)
}
}
// Proto-accepting overloads for backward compatibility
def surplusFood(province: net.eagle0.eagle.internal.province.Province, gameState: GameStateProto): Int =
surplusFood(
net.eagle0.eagle.model.proto_converters.province.ProvinceConverter.fromProto(province),
GameStateConverter.fromProto(gameState)
)
def chosenAlmsForSupportCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
chosenAlmsForSupportCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands
)
def chosenAlmsForSupportWithMinimumCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
minimumFood: Int
): Option[CommandSelection] =
chosenAlmsForSupportWithMinimumCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands,
minimumFood
)
def chosenAlmsForSupportWithMinimumCommandForProvince(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
minimumFood: Int,
provinceId: ProvinceId
): Option[CommandSelection] =
chosenAlmsForSupportWithMinimumCommandForProvince(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands,
minimumFood,
provinceId
)
}
@@ -4,22 +4,23 @@ import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.*
import net.eagle0.eagle.api.selected_command.*
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.profession.Profession.{ENGINEER, MAGE}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.settings.{
MinVigorForMarch,
RequiredRatioToAttack,
RequiredRatioToAttackWithoutTakingCastles
}
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.{BattalionUtils, CommandSelection, LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.{Engineer, Mage}
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.views.battalion_view.BattalionView
object AttackCommandChooser {
import net.eagle0.eagle.library.util.DateProtoUtils._Date
import AvailableCommandSelector.flatSelectionForType
private val winterMonths = Set(12, 1, 2)
@@ -27,7 +28,7 @@ object AttackCommandChooser {
private def heroesWithSufficientVigorForAttack(
provinceId: ProvinceId,
gs: GameState
): Vector[Hero] =
): Vector[HeroT] =
gs.provinces(provinceId)
.rulingFactionHeroIds
.map(gs.heroes)
@@ -41,10 +42,13 @@ object AttackCommandChooser {
gameState: GameState
): Boolean =
destinationFactionId.exists(dfid =>
dfid != actingFactionId && !LegacyFactionUtils
.hasAlliance(actingFactionId, dfid, gameState) && !LegacyFactionUtils
.truceExpirationDate(dfid, actingFactionId, gameState)
.exists(_ > gameState.currentDate.get)
dfid != actingFactionId && !FactionUtils
.hasAlliance(actingFactionId, dfid, gameState.factions.values.toVector) && !FactionUtils
.factionRelationship(dfid, actingFactionId, gameState.factions.values)
.resetDate
.exists(d =>
d.year > gameState.currentDate.get.year || (d.year == gameState.currentDate.get.year && d.month.value > gameState.currentDate.get.month.value)
)
)
// Calculates the best attack for a particular destination, not taking into account whether it's a good idea --
@@ -56,7 +60,7 @@ object AttackCommandChooser {
goldAvailable: Int,
foodAvailable: Int,
originProvinceId: ProvinceId,
destinationProvince: Province,
destinationProvince: ProvinceT,
gameState: GameState
): MarchSelectedCommand = MarchSelectedCommand(
marchingUnits = selectedUnits,
@@ -65,7 +69,7 @@ object AttackCommandChooser {
gold = MarchSuppliesHelpers
.goldToTakeOnAttack(selectedUnits.size, goldAvailable),
food = MarchSuppliesHelpers.foodToTakeOnAttack(
selectedUnits.flatMap(_.battalionId).map(gameState.battalions),
selectedUnits.flatMap(_.battalionId).size,
foodAvailable
)
)
@@ -81,7 +85,7 @@ object AttackCommandChooser {
goldAvailable: Int,
foodAvailable: Int,
originProvinceId: ProvinceId,
destinationProvince: Province,
destinationProvince: ProvinceT,
requiresRiverCrossing: Boolean,
totalBattalionPower: Double
)
@@ -124,7 +128,7 @@ object AttackCommandChooser {
desiredCount = unitCount,
heroes = heroes,
battalions = battalions.toVector,
battalionTypes = gameState.battalionTypes.toVector
battalionTypes = gameState.battalionTypes
)
// Get river crossing requirement from the available destination
@@ -140,7 +144,7 @@ object AttackCommandChooser {
totalBattalionPower = selectedUnits
.flatMap(_.battalionId)
.map(gameState.battalions)
.map(LegacyBattalionUtils.power)
.map(BattalionUtils.power)
.sum
)
}).toVector
@@ -162,7 +166,7 @@ object AttackCommandChooser {
.filter {
_.destinationProvince.incomingArmies
.exists(incomingArmy =>
incomingArmy.getArmy.factionId == actingFactionId && incomingArmy.arrivalRound > gameState.currentRoundId
incomingArmy.army.factionId == actingFactionId && incomingArmy.arrivalRound > gameState.currentRoundId
)
}
.map {
@@ -258,12 +262,12 @@ object AttackCommandChooser {
.map(u => u.heroId)
.map(gameState.heroes)
.map(_.profession)
.exists(prof => prof == ENGINEER || prof == MAGE)
.exists(prof => prof == Engineer || prof == Mage)
// If it's winter or we have a river crossing profession, take everything.
// Otherwise, only take those that don't require a river crossing.
val isWinter =
winterMonths.contains(gameState.currentDate.get.month)
winterMonths.contains(gameState.currentDate.get.month.value)
val allMarchingCIs =
if isWinter || hasRiverCrossingProfession then commandInfos
@@ -316,4 +320,31 @@ object AttackCommandChooser {
)
}
}
// Proto-accepting overloads for backward compatibility
def possibleAttackCommands(
actingFactionId: FactionId,
gameState: GameStateProto,
ac: MarchAvailableCommand
): Vector[PossibleAttackCommandInfo] =
possibleAttackCommands(
actingFactionId,
GameStateConverter.fromProto(gameState),
ac
)
def chosenAttackCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
acs: Vector[AvailableCommand],
knownEnemyHeroCount: ProvinceId => Int,
knownEnemyBattalions: ProvinceId => Vector[BattalionView]
): Option[CommandSelection] =
chosenAttackCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
acs,
knownEnemyHeroCount,
knownEnemyBattalions
)
}
@@ -11,11 +11,14 @@ import net.eagle0.eagle.api.command.util.attack_decision_type.{
}
import net.eagle0.eagle.api.selected_command.AttackDecisionSelectedCommand
import net.eagle0.eagle.common.tribute_amount.TributeAmount
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.settings.MinimumPercentageOfEnemyToAttack
import net.eagle0.eagle.library.util.{BattalionPower, CommandSelection, IncomingArmyUtils}
import net.eagle0.eagle.library.util.{BattalionPower, CommandSelection}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.Army
import net.eagle0.eagle.FactionId
object AttackDecisionCommandChooser {
@@ -127,6 +130,11 @@ object AttackDecisionCommandChooser {
}.flatten
}
private def armyPower(army: Army, gs: GameState): Double = {
val battalions = army.units.flatMap(_.battalionId).map(gs.battalions)
battalions.map(BattalionPower.power).sum
}
private def okToAttack(
availableCommand: AttackDecisionAvailableCommand,
gameState: GameState
@@ -143,7 +151,7 @@ object AttackDecisionCommandChooser {
.find(hag => hag.factionId == armyStats.factionId)
.map(_.armies)
.getOrElse(Vector.empty)
.map(ia => IncomingArmyUtils.armyPower(ia.getArmy, gameState))
.map(ia => armyPower(ia.army, gameState))
.sum
)
.sum
@@ -219,4 +227,18 @@ object AttackDecisionCommandChooser {
)
}
}
// Proto-accepting overload for backward compatibility
def attackDecisionSelectedCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
attackDecisionSelectedCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands,
functionalRandom
)
}
@@ -26,19 +26,26 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":available_command_selector",
":food_consumption_utils",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:alms_command",
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//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/hero",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//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",
],
)
@@ -49,25 +56,30 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":available_command_selector",
":combat_unit_selector",
":march_supplies_helpers",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_march",
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack",
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack_without_taking_castles",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:date_utils",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:shardok_map_info",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -78,6 +90,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":available_command_selector",
":command_chooser",
@@ -89,7 +104,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_percentage_of_enemy_to_attack",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_power",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -118,12 +135,13 @@ scala_library(
deps = [
"//src/main/protobuf/net/eagle0/eagle/api/command/util:appropriate_battalions_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_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/library/util:battalion_suitability",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
"//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/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//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",
],
)
@@ -143,6 +161,8 @@ scala_library(
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -198,13 +218,22 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:vassal_minimum_food_to_send_supplies",
"//src/main/scala/net/eagle0/eagle/library/settings:vassal_minimum_gold_to_send_supplies",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:beast_utils",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//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",
],
)
@@ -233,6 +262,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
deps = [
":available_command_selector",
":food_consumption_utils",
@@ -242,12 +275,15 @@ scala_library(
"//src/main/scala/net/eagle0/common:functional_random",
"//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/util:battalion_utils",
"//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/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/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -258,10 +294,18 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -323,9 +367,9 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
],
)
@@ -337,16 +381,21 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":available_command_selector",
":hero_selector",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:hero_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/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -358,9 +407,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
],
deps = [],
)
scala_library(
@@ -380,8 +427,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//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/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_id_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -396,6 +448,8 @@ 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/settings:gold_per_hero_held_back",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -4,19 +4,19 @@ import scala.annotation.tailrec
import net.eagle0.eagle.api.command.util.appropriate_battalions.SuitableBattalions.SuitabilityLevel
import net.eagle0.eagle.api.command.util.appropriate_battalions.SuitableBattalions.SuitabilityLevel.RESTRICTIVE
import net.eagle0.eagle.common.battalion_type.BattalionType
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.library.util.{BattalionSuitability, LegacyBattalionUtils}
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.{BattalionSuitability, BattalionUtils}
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.BattalionType
object CombatUnitSelector {
def selectedCombatBattalions(
desiredCount: Int,
heroes: Vector[Hero],
battalions: Vector[Battalion],
heroes: Vector[HeroT],
battalions: Vector[BattalionT],
battalionTypes: Vector[BattalionType]
): Vector[CombatUnit] = {
val suitableBattalionsMap =
@@ -26,18 +26,18 @@ object CombatUnitSelector {
bts = battalionTypes
)
def suitability(h: Hero, b: Battalion): SuitabilityLevel =
def suitability(h: HeroT, b: BattalionT): SuitabilityLevel =
suitableBattalionsMap(h.id).battalionIdsWithSuitability
.find(_.battalionId == b.id)
.get
.level
def chooseOne(hs: Vector[Hero], bs: Vector[Battalion]): CombatUnit = {
def chooseOne(hs: Vector[HeroT], bs: Vector[BattalionT]): CombatUnit = {
val bestBattalionOpt = bs
.filter(b => hs.exists(h => suitability(h, b) != RESTRICTIVE))
.maxByOption(LegacyBattalionUtils.power)
.maxByOption(BattalionUtils.power)
bestBattalionOpt.map { bestBattalion =>
val bestHero = hs.minBy(h => (suitability(h, bestBattalion).index, -LegacyHeroUtils.power(h)))
val bestHero = hs.minBy(h => (suitability(h, bestBattalion).index, -HeroUtils.power(h)))
CombatUnit(
heroId = bestHero.id,
battalionId = Some(bestBattalion.id),
@@ -46,8 +46,8 @@ object CombatUnitSelector {
}
.getOrElse(
CombatUnit(
heroId = hs.maxBy(LegacyHeroUtils.power).id,
battalionId = bs.maxByOption(LegacyBattalionUtils.power).map(_.id),
heroId = hs.maxBy(HeroUtils.power).id,
battalionId = bs.maxByOption(BattalionUtils.power).map(_.id),
factionId = hs.head.factionId.get
)
)
@@ -56,8 +56,8 @@ object CombatUnitSelector {
@tailrec
def go(
c: Int,
hs: Vector[Hero],
bs: Vector[Battalion],
hs: Vector[HeroT],
bs: Vector[BattalionT],
acc: Vector[CombatUnit]
): Vector[CombatUnit] =
if c == 0 then acc
@@ -24,7 +24,7 @@ import net.eagle0.eagle.common.recruitment_info.RecruitmentStatus.{
import net.eagle0.eagle.common.tribute_amount.TributeAmount
import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType.UNAFFILIATED_HERO_PRISONER
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
@@ -41,24 +41,32 @@ import net.eagle0.eagle.library.settings.{
VassalMinimumFoodToSendSupplies,
VassalMinimumGoldToSendSupplies
}
import net.eagle0.eagle.library.util.{BattalionTypeFinder, CommandSelection, IncomingArmyUtils, ProvinceDistances}
import net.eagle0.eagle.library.util.{
BattalionTypeFinder,
BattalionUtils,
CommandSelection,
IncomingArmyUtils,
ProvinceDistances
}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserProtoImplicits.*
import net.eagle0.eagle.library.util.command_choice_helpers.FoodConsumptionUtils.foodConsumptionMonthsToHold
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.{
goldToHoldBack,
provinceGoldSurplus
}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.library.util.province.{LegacyProvinceUtils, ProvinceUtils}
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.hero.{HeroT, Profession}
object CommandChoiceHelpers {
import AvailableCommandSelector.{flatSelectionForType, selectionForType}
def chosenUnderAttackCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[OrganizeTroopsAvailableCommand](availableCommands) { organizeCommand =>
@@ -80,7 +88,7 @@ object CommandChoiceHelpers {
def excessBeyondMaximumSupplies(
actingProvinceId: ProvinceId,
gameState: GameState
gameState: GameStateProto
): Option[Supplies] = {
def battalionType(battalionTypeId: BattalionTypeId): BattalionType =
BattalionTypeFinder.battalionType(
@@ -149,7 +157,7 @@ object CommandChoiceHelpers {
private def chosenShipExcessSuppliesCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[SendSuppliesAvailableCommand](availableCommands) { ac =>
@@ -166,7 +174,7 @@ object CommandChoiceHelpers {
// Commands that should be done first regardless of the province's orders.
def chosenUniversalCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -174,7 +182,7 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
chosenCommandWhileTraveling,
AttackDecisionCommandChooser.attackDecisionSelectedCommand,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
@@ -231,7 +239,7 @@ object CommandChoiceHelpers {
def chosenDevelopCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -239,7 +247,7 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
chosenUniversalCommand,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
@@ -275,7 +283,7 @@ object CommandChoiceHelpers {
def chosenMobilizeCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -283,7 +291,7 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
chosenUniversalCommand,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
@@ -352,7 +360,7 @@ object CommandChoiceHelpers {
def chosenEntrustCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
actingProvinceId: ProvinceId,
functionalRandom: FunctionalRandom
@@ -361,11 +369,11 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
chosenUniversalCommand,
(
fid: FactionId,
gs: GameState,
gs: GameStateProto,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
@@ -381,14 +389,14 @@ object CommandChoiceHelpers {
functionalRandom = functionalRandom
)
private def suppressBeastsValue(hero: Hero): Double =
(hero.strength + hero.agility) / 2.0 + (if hero.profession.isNoProfession
private def suppressBeastsValue(hero: HeroT): Double =
(hero.strength + hero.agility) / 2.0 + (if hero.profession == Profession.NoProfession
then 0.0
else 50.0)
def chosenSuppressBeastsCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[SuppressBeastsAvailableCommand](availableCommands) { suppressAc =>
@@ -405,10 +413,11 @@ object CommandChoiceHelpers {
if province.battalionIds
.exists(b => gameState.battalions(b).size >= beastPower)
then {
val scalaGs = GameStateConverter.fromProto(gameState)
val leaderId = HeroSelector
.minimallyFatiguedHeroes(
suppressAc.availableHeroIds
.map(gameState.heroes)
.map(scalaGs.heroes)
.toVector
)
.maxBy(suppressBeastsValue(_))
@@ -441,7 +450,7 @@ object CommandChoiceHelpers {
private def chosenMobilizeIfAdjacentEnemyCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
actingProvinceId: ProvinceId,
functionalRandom: FunctionalRandom
@@ -460,7 +469,7 @@ object CommandChoiceHelpers {
def chosenExpandCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -468,12 +477,12 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
chosenUniversalCommand,
ExpandCommandSelector.maybeChosenExpandCommand,
(
fid: FactionId,
gs: GameState,
gs: GameStateProto,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
@@ -512,7 +521,7 @@ object CommandChoiceHelpers {
private def chosenShouldRestCommand(
actingFactionId: FactionId,
gs: GameState,
gs: GameStateProto,
acs: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
@@ -540,7 +549,7 @@ object CommandChoiceHelpers {
def chosenRestCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
@@ -556,7 +565,7 @@ object CommandChoiceHelpers {
def handleCapturedHeroesSelectedCommand(
actingFactionId: FactionId,
gs: GameState,
gs: GameStateProto,
acs: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -599,7 +608,7 @@ object CommandChoiceHelpers {
def resolvePleaseRecruitMeSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[PleaseRecruitMeAvailableCommand](availableCommands) { pleaseRecruitMeAc =>
@@ -618,7 +627,7 @@ object CommandChoiceHelpers {
private def handleRiotGiveSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[HandleRiotGiveAvailableCommand](availableCommands) { availableCommand =>
@@ -643,7 +652,7 @@ object CommandChoiceHelpers {
private def handleRiotCrackDownSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[HandleRiotCrackDownAvailableCommand](availableCommands) { availableCommand =>
@@ -663,7 +672,7 @@ object CommandChoiceHelpers {
private def handleRiotDoNothingSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[HandleRiotDoNothingAvailableCommand](availableCommands) { availableCommand =>
@@ -678,7 +687,7 @@ object CommandChoiceHelpers {
def handleRiotSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -686,7 +695,7 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
handleRiotGiveSelectedCommand(
@@ -720,7 +729,7 @@ object CommandChoiceHelpers {
def freeForAllDecisionSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -728,7 +737,7 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
advanceFFASelectedCommand(
@@ -753,7 +762,7 @@ object CommandChoiceHelpers {
private def withdrawFFASelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[FreeForAllDecisionAvailableCommand](
@@ -777,7 +786,7 @@ object CommandChoiceHelpers {
private def advanceFFASelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[FreeForAllDecisionAvailableCommand](
@@ -801,7 +810,7 @@ object CommandChoiceHelpers {
def resolveTributeSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -836,7 +845,7 @@ object CommandChoiceHelpers {
def defendSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -873,23 +882,25 @@ object CommandChoiceHelpers {
}
def defendingUnits(
gs: GameState,
gs: GameStateProto,
availableCommand: DefendAvailableCommand
): Vector[CombatUnit] =
): Vector[CombatUnit] = {
val scalaGs = GameStateConverter.fromProto(gs)
CombatUnitSelector.selectedCombatBattalions(
desiredCount = availableCommand.availableHeroIds.size
.min(MaxCombatUnitCountPerSide.intValue),
heroes = availableCommand.availableHeroIds.map(gs.heroes).toVector,
heroes = availableCommand.availableHeroIds.map(scalaGs.heroes).toVector,
battalions = availableCommand.availableBattalions
.map(_.battalionId)
.map(gs.battalions)
.map(scalaGs.battalions)
.toVector,
battalionTypes = gs.battalionTypes.toVector
battalionTypes = scalaGs.battalionTypes
)
}
def foodAmountToBuy(
province: Province,
gameState: GameState,
gameState: GameStateProto,
goldAvailable: Int,
foodBuyPrice: Double
): Option[Int] = {
@@ -910,7 +921,7 @@ object CommandChoiceHelpers {
def maybeChosenTravelToBuyFoodCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[TravelAvailableCommand](availableCommands) { availableCommand =>
@@ -929,7 +940,7 @@ object CommandChoiceHelpers {
def chosenBuyFoodCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[TradeAvailableCommand](availableCommands) { availableCommand =>
@@ -957,7 +968,7 @@ object CommandChoiceHelpers {
def chosenImproveInfrastructureIfBelowTrainingCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[ImproveAvailableCommand](availableCommands) { availableCommand =>
@@ -988,7 +999,7 @@ object CommandChoiceHelpers {
}
}
def suppliesToSend(provinceId: ProvinceId, gameState: GameState): Supplies =
def suppliesToSend(provinceId: ProvinceId, gameState: GameStateProto): Supplies =
Supplies(
food = provinceFoodSurplus(provinceId, gameState),
gold = provinceGoldSurplus(provinceId, gameState)
@@ -996,7 +1007,7 @@ object CommandChoiceHelpers {
private def closestLeader(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
actingProvinceId: ProvinceId
): Option[(HeroId, ProvinceId)] =
gameState
@@ -1027,7 +1038,7 @@ object CommandChoiceHelpers {
def preferredSuppliesDestination(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
ac: SendSuppliesAvailableCommand
): Option[ProvinceId] =
gameState
@@ -1052,7 +1063,7 @@ object CommandChoiceHelpers {
def chosenSendSuppliesCommandWithSuppliesAndDestination(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
ac: SendSuppliesAvailableCommand,
supplies: Supplies,
destination: ProvinceId
@@ -1073,7 +1084,7 @@ object CommandChoiceHelpers {
def chosenSendSuppliesCommandWithSupplies(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
ac: SendSuppliesAvailableCommand,
supplies: Supplies
): Option[CommandSelection] =
@@ -1104,7 +1115,7 @@ object CommandChoiceHelpers {
private def lowLoyaltyHeroes(
provinceId: ProvinceId,
heroIds: Vector[HeroId],
gameState: GameState
gameState: GameStateProto
): Vector[Hero] =
heroIds
.filter(hid =>
@@ -1118,7 +1129,7 @@ object CommandChoiceHelpers {
def chosenFeastCommandIfAvailable(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
@@ -1145,7 +1156,7 @@ object CommandChoiceHelpers {
private def maybeChosenFeastCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
@@ -1163,7 +1174,7 @@ object CommandChoiceHelpers {
private def maybeChosenGiftCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
@@ -1188,7 +1199,7 @@ object CommandChoiceHelpers {
private def maybeSpreadIntoOwnedProvince(
opmc: MarchCommandFromOneProvince,
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
ac: MarchAvailableCommand
): Option[CommandSelection] = {
val originProvince = gameState.provinces(opmc.originProvinceId)
@@ -1242,7 +1253,7 @@ object CommandChoiceHelpers {
private def maybeSpreadIntoEmptyProvince(
opmc: MarchCommandFromOneProvince,
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
ac: MarchAvailableCommand,
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
@@ -1308,7 +1319,7 @@ object CommandChoiceHelpers {
def maybeChosenGetUnderHeroCapCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -1347,13 +1358,13 @@ object CommandChoiceHelpers {
private def maybeChosenAttackToSaveLeaderCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] = None
def maybeRansomLeaderCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
@@ -1419,7 +1430,7 @@ object CommandChoiceHelpers {
private def chosenRescueLeaderIfAllPrisonersCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -1445,7 +1456,7 @@ object CommandChoiceHelpers {
def chosenRescueLeaderCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -1454,7 +1465,7 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
maybeChosenAttackToSaveLeaderCommand(
@@ -1472,7 +1483,7 @@ object CommandChoiceHelpers {
private def chosenLoyaltyManagementCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -1481,7 +1492,7 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
maybeChosenGetUnderHeroCapCommand,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
@@ -1510,7 +1521,7 @@ object CommandChoiceHelpers {
def maybeChosenSendSuppliesCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[SendSuppliesAvailableCommand](availableCommands) { ac =>
@@ -1527,7 +1538,7 @@ object CommandChoiceHelpers {
def chosenCommandWhileTraveling(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -1536,7 +1547,7 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
rankedChoosers = Vector[CommandChooserProto](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
maybeChosenRecruitCommand(
@@ -1590,7 +1601,7 @@ object CommandChoiceHelpers {
private def maybeChosenRecruitCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[RecruitHeroesAvailableCommand](availableCommands) { availableCommand =>
@@ -1610,7 +1621,7 @@ object CommandChoiceHelpers {
private def maybeChosenDivineCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[DivineAvailableCommand](availableCommands) { availableCommand =>
@@ -1637,7 +1648,7 @@ object CommandChoiceHelpers {
private def chosenReturnCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[ReturnAvailableCommand](availableCommands) { availableCommand =>
@@ -1652,7 +1663,7 @@ object CommandChoiceHelpers {
def maybeChosenTravelToArmTroopsCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[TravelAvailableCommand](availableCommands) { availableCommand =>
@@ -1681,7 +1692,7 @@ object CommandChoiceHelpers {
def maybeMoveToRecruitCommand(
actingFactionId: FactionId,
gs: GameState,
gs: GameStateProto,
acs: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
@@ -1768,14 +1779,14 @@ object CommandChoiceHelpers {
private def isInterestingUH(uh: UnaffiliatedHero): Boolean =
uh.recruitmentInfo.get.status == RECRUITMENT_STATUS_WOULD_JOIN || uh.recruitmentInfo.get.status == RECRUITMENT_STATUS_NOT_DIVINED
private def hasInterestingUH(provinceId: ProvinceId, gs: GameState): Boolean =
private def hasInterestingUH(provinceId: ProvinceId, gs: GameStateProto): Boolean =
gs.provinces(provinceId)
.unaffiliatedHeroes
.exists(isInterestingUH)
def maybeChosenTravelToRecruitCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[TravelAvailableCommand](availableCommands) { availableCommand =>
@@ -1810,7 +1821,7 @@ object CommandChoiceHelpers {
def chosenTrainCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[TrainAvailableCommand](availableCommands) { availableCommand =>
@@ -1827,7 +1838,7 @@ object CommandChoiceHelpers {
private def organizeForFightingBeastsCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
beastPower: Int
): Option[CommandSelection] =
@@ -1863,7 +1874,7 @@ object CommandChoiceHelpers {
private def maybeChosenArmTroopsCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
@@ -1913,7 +1924,7 @@ object CommandChoiceHelpers {
end if
}
private def provinceFoodSurplus(provinceId: ProvinceId, gs: GameState): Int =
private def provinceFoodSurplus(provinceId: ProvinceId, gs: GameStateProto): Int =
(LegacyProvinceUtils.absoluteFoodSurplus(
provinceId,
gs
@@ -1922,7 +1933,7 @@ object CommandChoiceHelpers {
private def destinationCloserTo(
actingFactionId: FactionId,
gs: GameState,
gs: GameStateProto,
actingProvinceId: ProvinceId,
availableProvinceIds: Vector[ProvinceId],
pid: ProvinceId
@@ -1943,7 +1954,7 @@ object CommandChoiceHelpers {
private def topUpBattalionToFightBeastsCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
organizeCommand: OrganizeTroopsAvailableCommand,
province: Province,
costs: Map[BattalionTypeId, Double],
@@ -2001,7 +2012,7 @@ object CommandChoiceHelpers {
private def newBattalionToFightBeastsCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
organizeCommand: AvailableCommand,
province: Province,
costs: Map[BattalionTypeId, Double],
@@ -4,8 +4,10 @@ import scala.annotation.tailrec
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
@FunctionalInterface
@@ -35,6 +37,34 @@ object CommandChooser {
}
case _ => RandomState(None, functionalRandom)
}
// Proto-accepting overload for backward compatibility with files still using proto GameState
@tailrec
def choose(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
rankedChoosers: Vector[CommandChooserProto],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = rankedChoosers match {
case h +: t =>
h(actingFactionId, gameState, availableCommands, functionalRandom) match {
case rss @ RandomState(Some(_), _) => rss
case RandomState(None, nextFr) =>
choose(actingFactionId, gameState, availableCommands, t, nextFr)
}
case _ => RandomState(None, functionalRandom)
}
}
@FunctionalInterface
trait CommandChooserProto {
def apply(
factionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]]
}
object CommandChooserImplicits {
@@ -74,3 +104,41 @@ object CommandChooserImplicits {
randomChooser(factionId, gameState, availableCommands, functionalRandom)
}
}
object CommandChooserProtoImplicits {
implicit class DeterministicCommandChooserProto(
deterministicChooser: (
FactionId,
GameStateProto,
Vector[AvailableCommand]
) => Option[CommandSelection]
) extends CommandChooserProto {
override def apply(
factionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
RandomState(
deterministicChooser(factionId, gameState, availableCommands),
functionalRandom
)
}
implicit class RandomCommandChooserProto(
randomChooser: (
FactionId,
GameStateProto,
Vector[AvailableCommand],
FunctionalRandom
) => RandomState[Option[CommandSelection]]
) extends CommandChooserProto {
override def apply(
factionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomChooser(factionId, gameState, availableCommands, functionalRandom)
}
}
@@ -7,15 +7,17 @@ import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
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.province.Province
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.settings.MaxCombatUnitCountPerSide
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils}
import net.eagle0.eagle.library.util.{BattalionUtils, CommandSelection}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
object ExpandCommandSelector {
private case class MarchUnitsWithFood(units: Vector[CombatUnit], food: Int)
@@ -37,7 +39,7 @@ object ExpandCommandSelector {
if (rulingFactionHeroIds.size < 2) // Don't abandon this province
|| (rulingFactionHeroIds.size - rulingFactionHeroIds.count(
LegacyFactionUtils.isFactionLeader(_, gameState)
FactionUtils.isFactionLeader(_, gameState.factions.values)
) < 2) // Don't leave a faction leader alone
then randomNone
else
@@ -52,7 +54,7 @@ object ExpandCommandSelector {
.map(_.provinceId)
.toVector
).partition(p =>
LegacyProvinceUtils
ProvinceUtils
.isAdjacentEnemy(p.id, actingFactionId, gameState)
)
@@ -90,20 +92,16 @@ object ExpandCommandSelector {
gameState: GameState,
fromProvince: ProvinceId,
eligibleDestinations: Vector[ProvinceId]
): Vector[Province] =
): Vector[ProvinceT] =
eligibleDestinations
.map(gameState.provinces)
.filterNot(_.rulingFactionId.isDefined)
.filterNot(
_.incomingArmies.exists(ma =>
ma.army
.map(_.factionId)
.contains(
gameState
.provinces(fromProvince)
.rulingFactionId
.get
)
gameState
.provinces(fromProvince)
.rulingFactionId
.contains(ma.army.factionId)
)
)
@@ -113,7 +111,7 @@ object ExpandCommandSelector {
gameState: GameState,
availableCommand: MarchAvailableCommand,
oneProvinceCommand: MarchCommandFromOneProvince,
safeExpansionCandidateProvinces: Vector[Province],
safeExpansionCandidateProvinces: Vector[ProvinceT],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val province = gameState.provinces(availableCommand.actingProvinceId)
@@ -157,7 +155,7 @@ object ExpandCommandSelector {
gameState: GameState,
availableCommand: MarchAvailableCommand,
oneProvinceCommand: MarchCommandFromOneProvince,
adjacentEnemyCandidateProvinces: Vector[Province],
adjacentEnemyCandidateProvinces: Vector[ProvinceT],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val province = gameState.provinces(availableCommand.actingProvinceId)
@@ -175,7 +173,7 @@ object ExpandCommandSelector {
else {
// If the originating province is safe, only leave two heroes behind
val heroCountToSend = (
if LegacyProvinceUtils.isAdjacentEnemy(
if ProvinceUtils.isAdjacentEnemy(
oneProvinceCommand.originProvinceId,
actingFactionId,
gameState
@@ -251,8 +249,8 @@ object ExpandCommandSelector {
actingFactionId: FactionId,
heroCountToSend: Int,
goldToSend: Int,
originatingProvince: Province,
candidateDestinationProvinces: Vector[Province],
originatingProvince: ProvinceT,
candidateDestinationProvinces: Vector[ProvinceT],
oneProvinceCommand: MarchCommandFromOneProvince,
availableCommand: MarchAvailableCommand,
gameState: GameState,
@@ -268,8 +266,8 @@ object ExpandCommandSelector {
rulingFactionHeroIds.diff(oneProvinceCommand.availableHeroIds)
// Don't leave a faction leader alone in the origin province
if heroCountLeftBehind == 1 && notAvailableHeroes.size == 1 && LegacyFactionUtils
.isFactionLeader(notAvailableHeroes.head, gameState)
if heroCountLeftBehind == 1 && notAvailableHeroes.size == 1 && FactionUtils
.isFactionLeader(notAvailableHeroes.head, gameState.factions.values)
then {
return randomNone
}
@@ -297,9 +295,8 @@ object ExpandCommandSelector {
fid = actingFactionId,
factionLeaders = gameState
.factions(actingFactionId)
.leaders
.intersect(originatingProvince.rulingFactionHeroIds)
.toVector,
.leaderIds
.intersect(originatingProvince.rulingFactionHeroIds),
otherCountLeftBehind =
originatingProvince.rulingFactionHeroIds.length - oneProvinceCommand.availableHeroIds.length,
functionalRandom = innerFr
@@ -310,19 +307,19 @@ object ExpandCommandSelector {
rulingFactionHeroIds.filterNot(takenHeroes.contains)
internalRequire(
takenHeroes.length > 1 || !LegacyFactionUtils
takenHeroes.length > 1 || !FactionUtils
.isFactionLeader(
heroId = takenHeroes.head,
gameState = gameState
factions = gameState.factions.values
),
"trying to take only a faction leader!"
)
internalRequire(
leftHeroes.length > 1 || !LegacyFactionUtils
leftHeroes.length > 1 || !FactionUtils
.isFactionLeader(
heroId = leftHeroes.head,
gameState = gameState
factions = gameState.factions.values
),
"trying to leave a faction leader alone!"
)
@@ -335,9 +332,9 @@ object ExpandCommandSelector {
)
.map(gameState.battalions)
val monthlyFoodConsumptionInOrigin =
LegacyBattalionUtils.monthlyConsumedFood(
BattalionUtils.monthlyConsumedFood(
battalionsHeldBack,
gameState.battalionTypes.toVector
gameState.battalionTypes
)
val foodToHoldBack = monthlyFoodConsumptionInOrigin *
FoodConsumptionUtils.foodConsumptionMonthsToHold(
@@ -424,7 +421,7 @@ object ExpandCommandSelector {
heroCount = heroCountToSend,
battalionCount = battalionCountToSend,
fid = actingFactionId,
factionLeaders = gameState.factions(actingFactionId).leaders.toVector,
factionLeaders = gameState.factions(actingFactionId).leaderIds,
otherCountLeftBehind = originProvince.rulingFactionHeroIds.length - opmc.availableHeroIds.length,
functionalRandom = functionalRandom
).map { marchingUnitsWithFood =>
@@ -451,7 +448,7 @@ object ExpandCommandSelector {
gameState: GameState,
fromProvince: ProvinceId,
eligibleDestinations: Vector[ProvinceId]
): Vector[Province] = {
): Vector[ProvinceT] = {
val p = gameState.provinces(fromProvince)
eligibleDestinations
.map(gameState.provinces)
@@ -533,4 +530,29 @@ object ExpandCommandSelector {
end if
}
}
// Proto-accepting overloads for backward compatibility
def expansionCandidateProvinces(
gameState: GameStateProto,
fromProvince: ProvinceId,
eligibleDestinations: Vector[ProvinceId]
): Vector[ProvinceT] =
expansionCandidateProvinces(
GameStateConverter.fromProto(gameState),
fromProvince,
eligibleDestinations
)
def maybeChosenExpandCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
maybeChosenExpandCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands,
functionalRandom
)
}
@@ -1,10 +1,11 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.common.date.Date
import net.eagle0.eagle.common.round_phase.RoundPhase
import net.eagle0.eagle.common.round_phase.RoundPhase.FOOD_CONSUMPTION
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.settings.MinSupportForTaxes
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.RoundPhase
import net.eagle0.eagle.ProvinceId
object FoodConsumptionUtils {
@@ -12,8 +13,8 @@ object FoodConsumptionUtils {
date: Date,
roundPhase: RoundPhase
): Int =
if roundPhase.value < FOOD_CONSUMPTION.value then 13 - date.month
else 12 - date.month
if roundPhase.value < RoundPhase.FoodConsumption.value then 13 - date.month.value
else 12 - date.month.value
private def foodConsumptionMonthsRemainingInYear(gameState: GameState): Int =
foodConsumptionMonthsRemainingInYear(
@@ -28,4 +29,10 @@ object FoodConsumptionUtils {
if gameState.provinces(provinceId).support >= MinSupportForTaxes.doubleValue
then foodConsumptionMonthsRemainingInYear(gameState)
else foodConsumptionMonthsRemainingInYear(gameState).max(6)
def foodConsumptionMonthsToHold(
provinceId: ProvinceId,
gameState: GameStateProto
): Int =
foodConsumptionMonthsToHold(provinceId, GameStateConverter.fromProto(gameState))
}
@@ -1,12 +1,12 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.hero.HeroT
object HeroSelector {
def minimallyFatiguedHeroes(heroes: Vector[Hero]): Vector[Hero] = {
val minimumFatigue = heroes.map(LegacyHeroUtils.fatigue).min
def minimallyFatiguedHeroes(heroes: Vector[HeroT]): Vector[HeroT] = {
val minimumFatigue = heroes.map(HeroUtils.fatigue).min
heroes
.filter(LegacyHeroUtils.fatigue(_) == minimumFatigue)
.filter(HeroUtils.fatigue(_) == minimumFatigue)
}
}
@@ -5,21 +5,23 @@ import net.eagle0.eagle.api.available_command.{AvailableCommand, ImproveAvailabl
import net.eagle0.eagle.api.selected_command.ImproveSelectedCommand
import net.eagle0.eagle.common.improvement_type.ImprovementType
import net.eagle0.eagle.common.improvement_type.ImprovementType.DEVASTATION
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.{
flatSelectionForType,
selectionForType
}
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
object ImproveCommandSelector {
import net.eagle0.eagle.library.util.command_choice_helpers.HeroSelector.minimallyFatiguedHeroes
private def provinceValueForType(
improvementType: ImprovementType,
province: Province
province: ProvinceT
): Double = improvementType match {
case ImprovementType.ECONOMY => province.economy
case ImprovementType.AGRICULTURE => province.agriculture
@@ -31,7 +33,7 @@ object ImproveCommandSelector {
private def chosenType(
availableTypes: Vector[ImprovementType],
province: Province
province: ProvinceT
): ImprovementType =
if availableTypes.contains(DEVASTATION) then DEVASTATION
else availableTypes.minBy(provinceValueForType(_, province))
@@ -80,7 +82,7 @@ object ImproveCommandSelector {
improveCommand.availableHeroIds.toVector.map(
gameState.heroes(_)
)
).maxBy(LegacyHeroUtils.power(_)).id
).maxBy(HeroUtils.power(_)).id
),
reason = "chosenSpecificImprovementCommand"
)
@@ -105,7 +107,7 @@ object ImproveCommandSelector {
improveAvailableCommand.availableHeroIds.toVector.map(
gameState.heroes(_)
)
).maxBy(LegacyHeroUtils.power(_)).id
).maxBy(HeroUtils.power(_)).id
),
reason = "ImproveCommandSelector.commandSelection"
)
@@ -118,4 +120,29 @@ object ImproveCommandSelector {
selectionForType[ImproveAvailableCommand](availableCommands) { improveCommand =>
commandSelection(gameState, actingFactionId, improveCommand)
}
// Proto-accepting overloads for backward compatibility
def chosenImproveCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands
)
def chosenSpecificImprovementCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
improvementType: ImprovementType
): Option[CommandSelection] =
chosenSpecificImprovementCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands,
improvementType
)
}
@@ -1,14 +1,12 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.internal.battalion.Battalion
object MarchSuppliesHelpers {
def goldToTakeOnAttack(unitCount: Int, goldAvailable: Int): Int =
goldAvailable.min(200 * unitCount)
def foodToTakeOnAttack(
battalions: Vector[Battalion],
battalionCount: Int,
foodAvailable: Int
): Int =
foodAvailable.min(300 * battalions.size)
foodAvailable.min(300 * battalionCount)
}
@@ -5,14 +5,17 @@ import scala.annotation.tailrec
import net.eagle0.eagle.api.available_command.{AvailableCommand, OrganizeTroopsAvailableCommand}
import net.eagle0.eagle.api.selected_command.OrganizeTroopsSelectedCommand
import net.eagle0.eagle.api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}
import net.eagle0.eagle.common.battalion_type.{BattalionType, BattalionTypeId}
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.util.{BattalionTypeFinder, CommandSelection}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.BattalionTypeFinder.battalionType
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeIdConverter
import net.eagle0.eagle.model.state.{BattalionType, BattalionTypeId}
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object OrganizeCommandSelector {
@@ -32,7 +35,7 @@ object OrganizeCommandSelector {
gold: Int,
foodSurplus: Int,
costs: Map[BattalionTypeId, Double],
existingBattalions: Vector[Battalion],
existingBattalions: Vector[BattalionT],
battalionTypes: Vector[BattalionType],
eligibleBattalionTypeIds: Vector[BattalionTypeId]
): ChangedBattalionsResult = {
@@ -40,17 +43,19 @@ object OrganizeCommandSelector {
def go(
gold: Int,
foodSurplus: Int,
battalionsInNeed: Vector[Battalion],
battalionsInNeed: Vector[BattalionT],
acc: Vector[ChangedBattalion]
): ChangedBattalionsResult = battalionsInNeed match {
case b +: bs =>
val missing = battalionType(b.`type`, battalionTypes).capacity - b.size
val goldCost = (missing * costs(b.`type`)).ceil.toInt
val missing = BattalionTypeFinder.findBattalionType(b.typeId, battalionTypes).capacity - b.size
val goldCost = (missing * costs(b.typeId)).ceil.toInt
val foodCost =
(missing * battalionType(
b.`type`,
battalionTypes
).monthlyFoodCost).ceil.toInt
(missing * BattalionTypeFinder
.findBattalionType(
b.typeId,
battalionTypes
)
.monthlyFoodCost).ceil.toInt
if goldCost <= gold && foodCost <= foodSurplus then
go(
gold - goldCost,
@@ -77,9 +82,9 @@ object OrganizeCommandSelector {
gold = gold,
foodSurplus = foodSurplus,
battalionsInNeed = existingBattalions
.filter(b => eligibleBattalionTypeIds.contains(b.`type`))
.filter(b => costs.contains(b.`type`))
.filter(b => b.size < battalionType(b.`type`, battalionTypes).capacity),
.filter(b => eligibleBattalionTypeIds.contains(b.typeId))
.filter(b => costs.contains(b.typeId))
.filter(b => b.size < BattalionTypeFinder.findBattalionType(b.typeId, battalionTypes).capacity),
acc = Vector()
)
}
@@ -90,7 +95,7 @@ object OrganizeCommandSelector {
costs: Map[BattalionTypeId, Double],
allBattalionTypes: Vector[BattalionType],
eligibleBattalionTypeIds: Vector[BattalionTypeId],
existingBattalions: Vector[Battalion],
existingBattalions: Vector[BattalionT],
maxCount: Int = 100
): NewBattalionsResult = {
val eligibleCosts = costs.filter {
@@ -120,13 +125,13 @@ object OrganizeCommandSelector {
eligibleCosts.toVector.map {
case (tid, costPerTroop) =>
val battalionType =
BattalionTypeFinder.battalionType(tid, allBattalionTypes)
val cap = battalionType.capacity
val bt =
BattalionTypeFinder.findBattalionType(tid, allBattalionTypes)
val cap = bt.capacity
FoodAndGoldCosts(
battalionTypeId = tid,
goldCost = (costPerTroop * cap).ceil.toInt,
monthlyFoodCost = (cap * battalionType.monthlyFoodCost).ceil.toInt
monthlyFoodCost = (cap * bt.monthlyFoodCost).ceil.toInt
)
}
.filter(_.goldCost <= gold)
@@ -142,8 +147,8 @@ object OrganizeCommandSelector {
.headOption match {
case Some(x) =>
val newBattalion = NewBattalion(
`type` = x.battalionTypeId,
newTroops = battalionType(x.battalionTypeId, allBattalionTypes).capacity
`type` = BattalionTypeIdConverter.toProto(x.battalionTypeId),
newTroops = BattalionTypeFinder.findBattalionType(x.battalionTypeId, allBattalionTypes).capacity
)
go(
gold = gold - x.goldCost,
@@ -166,7 +171,7 @@ object OrganizeCommandSelector {
gold = gold,
foodSurplus = foodSurplus,
acc = Vector(),
existingBattalionTypeCounts = existingBattalions.groupBy(_.`type`).map { case (k, v) => k -> v.size }
existingBattalionTypeCounts = existingBattalions.groupBy(_.typeId).map { case (k, v) => k -> v.size }
)
}
@@ -178,7 +183,7 @@ object OrganizeCommandSelector {
reason: String
): Option[CommandSelection] = {
val foodSurplus =
LegacyProvinceUtils.monthlyFoodSurplus(
ProvinceUtils.monthlyFoodSurplus(
gameState = gameState,
provinceId = availableCommand.actingProvinceId
) + extraFoodSurplus
@@ -187,7 +192,7 @@ object OrganizeCommandSelector {
if foodSurplus <= 0 || goldSurplus <= 0 then None
else {
val costs = availableCommand.troopCosts
.map(tc => tc.`type` -> tc.costPerTroop)
.map(tc => BattalionTypeId.fromInt(tc.`type`.value) -> tc.costPerTroop)
.toMap
val toppedBattalionResults = changedBattalions(
@@ -199,11 +204,11 @@ object OrganizeCommandSelector {
.battalionIds
.toVector
.map(gameState.battalions(_)),
battalionTypes = gameState.battalionTypes.toVector,
battalionTypes = gameState.battalionTypes,
eligibleBattalionTypeIds = availableCommand.availableBattalionTypes
.filter(_.meetsRequirements)
.toVector
.map(_.typeId)
.map(tid => BattalionTypeId.fromInt(tid.typeId.value))
)
val newBattalionResults = newBattalions(
@@ -217,14 +222,14 @@ object OrganizeCommandSelector {
.provinces(availableCommand.actingProvinceId)
.battalionIds
.size,
allBattalionTypes = gameState.battalionTypes.toVector.filter(bt =>
allBattalionTypes = gameState.battalionTypes.filter(bt =>
availableCommand.availableBattalionTypes
.exists(tp => tp.typeId == bt.typeId && tp.meetsRequirements)
.exists(tp => tp.typeId.value == bt.typeId.value && tp.meetsRequirements)
),
eligibleBattalionTypeIds = availableCommand.availableBattalionTypes
.filter(_.meetsRequirements)
.toVector
.map(_.typeId),
.map(tid => BattalionTypeId.fromInt(tid.typeId.value)),
existingBattalions = gameState
.provinces(availableCommand.actingProvinceId)
.battalionIds
@@ -279,4 +284,33 @@ object OrganizeCommandSelector {
extraFoodSurplus = 0,
reason = reason
)
// Proto-accepting overloads for backward compatibility
def selectedOrganizeCommand(
actingFactionId: FactionId,
availableCommand: OrganizeTroopsAvailableCommand,
gameState: GameStateProto,
extraFoodSurplus: Int,
reason: String
): Option[CommandSelection] =
selectedOrganizeCommand(
actingFactionId,
availableCommand,
GameStateConverter.fromProto(gameState),
extraFoodSurplus,
reason
)
def chosenOrganizeCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
chosenOrganizeCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands,
reason
)
}
@@ -1,7 +1,9 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.settings.GoldPerHeroHeldBack
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.ProvinceId
object ProvinceGoldSurplusCalculator {
@@ -15,6 +17,12 @@ object ProvinceGoldSurplusCalculator {
.rulingFactionHeroIds
.length).ceil.toInt
def goldToHoldBack(
provinceId: ProvinceId,
gameState: GameStateProto
): Int =
goldToHoldBack(provinceId, GameStateConverter.fromProto(gameState))
def provinceGoldSurplus(
provinceId: ProvinceId,
gameState: GameState
@@ -23,4 +31,10 @@ object ProvinceGoldSurplusCalculator {
provinceId,
gameState
)).max(0)
def provinceGoldSurplus(
provinceId: ProvinceId,
gameState: GameStateProto
): Int =
provinceGoldSurplus(provinceId, GameStateConverter.fromProto(gameState))
}
@@ -1,14 +1,14 @@
package net.eagle0.eagle.library.util.faction_utils
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, PrestigePerSupportedProvince}
import net.eagle0.eagle.model.state.faction.{FactionRelationship, FactionT}
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Ally
import net.eagle0.eagle.model.state.province.ProvinceT
object FactionUtils {
def isFactionLeader(heroId: HeroId, factions: Vector[FactionT]): Boolean =
factions.flatMap(_.leaderIds).contains(heroId)
def isFactionLeader(heroId: HeroId, factions: Iterable[FactionT]): Boolean =
factions.flatMap(_.leaderIds).exists(_ == heroId)
def factionRelationship(
by: FactionId,
@@ -56,6 +56,13 @@ object FactionUtils {
): Boolean =
hasTruce(fid1, fid2, factions) || hasAlliance(fid1, fid2, factions)
def factionsAreHostile(
fid1: FactionId,
fid2: FactionId,
factions: Vector[FactionT]
): Boolean =
fid1 != fid2 && !hasTruceOrAlliance(fid1, fid2, factions)
private def incomingHids(province: ProvinceT): Vector[HeroId] =
province.incomingArmies
.map(_.army)
@@ -136,4 +143,38 @@ object FactionUtils {
allProvinces
.filter(_.rulingFactionId.contains(factionId))
.count(_.support >= MinSupportForTaxes.doubleValue)
def isFactionHead(heroId: HeroId, factions: Iterable[FactionT]): Boolean =
factions.exists(_.factionHeadId == heroId)
def leadersBesidesHead(factionId: FactionId, factions: Iterable[FactionT]): Vector[HeroId] =
factions
.find(_.id == factionId)
.map(f => f.leaderIds.filterNot(_ == f.factionHeadId))
.getOrElse(Vector.empty)
def factionHeadId(factionId: FactionId, factions: Iterable[FactionT]): Option[HeroId] =
factions.find(_.id == factionId).map(_.factionHeadId)
def ownedNeighbors(
provinces: ProvinceId => ProvinceT,
fid: FactionId
): ProvinceId => Vector[ProvinceId] =
pid =>
provinces(pid).neighbors
.map(_.provinceId)
.filter(n => provinces(n).rulingFactionId.contains(fid))
def hostileNeighbors(
province: ProvinceT,
fid: FactionId,
factions: Vector[FactionT],
provinces: ProvinceId => ProvinceT
): Vector[ProvinceT] =
province.neighbors
.map(_.provinceId)
.map(provinces)
.filter(_.rulingFactionId.isDefined)
.filterNot(_.rulingFactionId.contains(fid))
.filterNot(neighbor => hasTruceOrAlliance(fid, neighbor.rulingFactionId.get, factions))
}
@@ -14,8 +14,10 @@ scala_library(
"//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/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -2,11 +2,13 @@ package net.eagle0.eagle.library.util.hero
import scala.annotation.tailrec
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.NoProfession
import net.eagle0.eagle.HeroId
import net.eagle0.eagle.model.state.province.ProvinceT
object HeroUtils {
@@ -81,4 +83,39 @@ object HeroUtils {
def sortOrdering(factionLeaderIds: Vector[HeroId]): Ordering[HeroT] =
Ordering.fromLessThan(sortOrderer(factionLeaderIds))
def fatigue(hero: HeroT): Double = hero.constitution - hero.vigor
def seniorityOrder(faction: FactionT, heroId: HeroId): Int =
faction.leaderIds.indexOf(heroId) match {
case -1 => Int.MaxValue
case idx => idx
}
// For commands where the faction leader can act on other provinces; returns the list of provinces to consider
// for this hero. For a vassal, considers just the one province, but for faction leader returns all owned provinces
// that did not contain other faction leaders. Always returns the provinceId as the first element.
def consideredProvinces(
gameState: GameState,
factionId: FactionId,
provinceId: ProvinceId
): Vector[ProvinceT] =
if gameState
.provinces(provinceId)
.rulingHeroId
.exists(hid => FactionUtils.isFactionHead(hid, gameState.factions.values))
then
gameState.provinces.values
.filter(_.rulingFactionId.contains(factionId))
.filterNot(p =>
p.rulingFactionHeroIds.exists(
FactionUtils.leadersBesidesHead(factionId, gameState.factions.values).contains
) && !p.rulingFactionHeroIds
.exists(
FactionUtils.factionHeadId(factionId, gameState.factions.values).contains
)
)
.toVector
.sortBy(p => if p.id == provinceId then 0 else p.id)
else Vector(gameState.provinces(provinceId))
}
@@ -42,11 +42,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:gold_increase_per_economy",
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/library/settings:per_development_resource_limit",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state:beast_info",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -1,6 +1,6 @@
package net.eagle0.eagle.library.util.province
import net.eagle0.eagle.{FactionId, HeroId, RoundId}
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.common.battalion_type.BattalionType as BattalionTypeProto
import net.eagle0.eagle.library.settings.{
BaseResourceLimit,
@@ -9,11 +9,13 @@ import net.eagle0.eagle.library.settings.{
MinSupportForTaxes,
PerDevelopmentResourceLimit
}
import net.eagle0.eagle.library.util.BattalionUtils
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.changed_province.ChangedProvinceT
import net.eagle0.eagle.model.state.{BattalionTypeId, BeastInfo, MovingArmy}
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.{
BeastsEvent,
@@ -74,6 +76,15 @@ object ProvinceUtils {
) * FoodIncreasePerAgriculture.doubleValue).toInt
)
def monthlyFoodConsumption(
province: ProvinceT,
gs: GameState
): Int =
BattalionUtils.monthlyConsumedFood(
province.battalionIds.map(gs.battalions),
gs.battalionTypes
)
def provinceLeader(
startingProvince: ProvinceT,
rulingFactionOpt: Option[FactionT],
@@ -247,6 +258,72 @@ object ProvinceUtils {
province.rulingFactionId.nonEmpty && province.rulingFactionHeroIds.isEmpty
)(abandonedProvinceCP(province))
def isAdjacentEnemy(
provinceId: ProvinceId,
factionId: FactionId,
gameState: GameState
): Boolean =
gameState
.provinces(provinceId)
.neighbors
.map(_.provinceId)
.map(gameState.provinces)
.exists(
_.rulingFactionId.exists(_ != factionId)
)
def adjacentHostiles(
provinceId: ProvinceId,
gameState: GameState
): Vector[ProvinceT] = {
val baseProvince = gameState.provinces(provinceId)
baseProvince.neighbors
.map(_.provinceId)
.map(gameState.provinces)
.filter(_.rulingFactionId.isDefined)
.filter(_.rulingFactionId != baseProvince.rulingFactionId)
}
def locationOf(
heroId: HeroId,
provinces: Iterable[ProvinceT]
): Option[ProvinceId] =
provinces
.find(p =>
(p.rulingFactionHeroIds ++ p.unaffiliatedHeroes.map(_.heroId))
.contains(heroId)
)
.map(_.id)
def absoluteFoodSurplus(provinceId: ProvinceId, gameState: GameState): Int = {
val province = gameState.provinces(provinceId)
val monthlyConsumption = monthlyFoodConsumption(province, gameState)
val annualProduction = annualFoodProduction(province).getOrElse(0)
if annualProduction / 12 < monthlyConsumption then 0
else {
val currentMonth = gameState.currentDate.map(_.month.value).getOrElse(1)
(province.food - monthlyConsumption * (12 - currentMonth)).max(0).toInt
}
}
def containsFactionLeader(pid: ProvinceId, gs: GameState): Boolean =
gs.provinces(pid).rulingFactionId.exists { fid =>
gs.provinces(pid)
.rulingFactionHeroIds
.contains(gs.factions(fid).factionHeadId)
}
def monthlyFoodSurplus(gameState: GameState, provinceId: ProvinceId): Int = {
val province = gameState.provinces(provinceId)
val consumption = monthlyFoodConsumption(province, gameState)
val monthsRemaining = 12 - gameState.currentDate.get.month.value
val monthlyAvailable =
if monthsRemaining == 0 then Int.MaxValue
else (province.food / monthsRemaining).toInt
val monthlyProduction = annualFoodProduction(province).getOrElse(0) / 12
monthlyProduction.min(monthlyAvailable) - consumption
}
def afterHeroDeparture(
cp: ChangedProvinceT,
province: ProvinceT
@@ -1,14 +1,34 @@
package net.eagle0.eagle.library.util.view_filters
import net.eagle0.eagle.{BattalionId, FactionId}
import net.eagle0.eagle.internal.army.Army
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.internal.army.Army as ProtoArmy
import net.eagle0.eagle.internal.battalion.Battalion as ProtoBattalion
import net.eagle0.eagle.model.proto_converters.CombatUnitConverter
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.Army as ScalaArmy
import net.eagle0.eagle.model.view.army.ArmyView as ArmyViewT
import net.eagle0.eagle.views.army_view.ArmyView
object ArmyFilter {
// Pure Scala version
def filterArmy(
army: Army,
battalions: Map[BattalionId, Battalion],
army: ScalaArmy,
battalions: Map[BattalionId, BattalionT],
factionId: Option[FactionId]
): ArmyViewT =
ArmyViewT(
factionId = army.factionId,
units =
if factionId.forall(_ == army.factionId) then army.units
else Vector.empty,
unitCount = army.units.length,
troopCount = army.units.flatMap(_.battalionId).map(bid => battalions(bid)).map(_.size).sum
)
// Proto version (legacy)
def filterArmy(
army: ProtoArmy,
battalions: Map[BattalionId, ProtoBattalion],
factionId: Option[FactionId]
): ArmyView =
ArmyView(
@@ -19,4 +39,18 @@ object ArmyFilter {
unitCount = army.units.length,
troopCount = army.units.flatMap(_.battalionId).map(battalions).map(_.size).sum
)
def filterArmyScala(
army: ProtoArmy,
battalions: Map[BattalionId, ProtoBattalion],
factionId: Option[FactionId]
): ArmyViewT =
ArmyViewT(
factionId = army.factionId,
units =
if factionId.forall(_ == army.factionId) then army.units.map(CombatUnitConverter.fromProto).toVector
else Vector.empty,
unitCount = army.units.length,
troopCount = army.units.flatMap(_.battalionId).map(battalions).map(_.size).sum
)
}
@@ -8,11 +8,18 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/view/army:army_view",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:army_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:combat_unit_converter",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/view/army:army_view",
],
)
@@ -116,6 +123,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:faction_view_filter",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:hero_view_filter",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:province_view_filter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
],
)
@@ -146,10 +154,15 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_id_converter",
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
],
)
@@ -161,20 +174,41 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
deps = [
":army_filter",
":battalion_view_filter",
":legacy_battalion_view_filter",
":visibility",
"//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:beast_utils",
"//src/main/scala/net/eagle0/eagle/library/util:date_utils",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/library/util:province_event_utils",
"//src/main/scala/net/eagle0/eagle/library/util:stat_with_condition_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:unaffiliated_hero_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:event",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:order_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/view/incoming_army:incoming_army_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:unaffiliated_hero_basics",
],
)
@@ -13,4 +13,15 @@ object BattalionViewFilter {
armament = Some(battT.armament),
name = battT.name
)
/** Limited view that hides training and armament (for withdrawn faction views) */
def limitedBattalionView(battT: BattalionT): BattalionView =
BattalionView(
id = battT.id,
typeId = battT.typeId,
size = battT.size,
training = None,
armament = None,
name = battT.name
)
}
@@ -1,6 +1,7 @@
package net.eagle0.eagle.library.util.view_filters
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.model.proto_converters.view.province.ProvinceViewConverter
import net.eagle0.eagle.views.faction_view.FactionView
import net.eagle0.eagle.views.game_state_view.GameStateView
import net.eagle0.eagle.FactionId
@@ -16,9 +17,11 @@ object GameStateViewFilter {
currentDate = gs.currentDate,
provinces = gs.provinces.map {
case (pid, prov) =>
pid -> factionId
.map(fid => ProvinceViewFilter.filteredProvinceView(prov, gs, fid))
.getOrElse(ProvinceViewFilter.filteredProvinceView(prov, gs))
pid -> ProvinceViewConverter.toProto(
factionId
.map(fid => ProvinceViewFilter.filteredProvinceView(prov, gs, fid))
.getOrElse(ProvinceViewFilter.filteredProvinceView(prov, gs))
)
},
heroes = (gs.heroes ++ gs.killedHeroes).map {
case (hid, hero) =>
@@ -1,6 +1,8 @@
package net.eagle0.eagle.library.util.view_filters
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.model.proto_converters.BattalionTypeIdConverter
import net.eagle0.eagle.model.view.battalion.BattalionView as BattalionViewT
import net.eagle0.eagle.views.battalion_view.BattalionView
object LegacyBattalionViewFilter {
@@ -14,6 +16,16 @@ object LegacyBattalionViewFilter {
name = batt.name
)
def filteredBattalionViewBelongingToPlayerScala(batt: Battalion): BattalionViewT =
BattalionViewT(
id = batt.id,
typeId = BattalionTypeIdConverter.fromProto(batt.`type`),
size = batt.size,
training = Some(batt.training),
armament = Some(batt.armament),
name = batt.name
)
def limitedBattalionView(batt: Battalion): BattalionView =
BattalionView(
id = batt.id,
@@ -23,4 +35,14 @@ object LegacyBattalionViewFilter {
armament = None,
name = batt.name
)
def limitedBattalionViewScala(batt: Battalion): BattalionViewT =
BattalionViewT(
id = batt.id,
typeId = BattalionTypeIdConverter.fromProto(batt.`type`),
size = batt.size,
training = None,
armament = None,
name = batt.name
)
}
@@ -8,14 +8,191 @@ import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.library.util.{IncomingArmyUtils, ProvinceEventUtils}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.StatWithConditionUtils.supportSc
import net.eagle0.eagle.views.incoming_army_view.IncomingArmyView
import net.eagle0.eagle.views.province_view.{FullProvinceInfo, ProvinceView, UnaffiliatedHeroBasics}
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.StatWithConditionUtils.supportScala
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.hero.ProfessionConverter
import net.eagle0.eagle.model.proto_converters.province.{ProvinceEventConverter, ProvinceOrderTypeConverter}
import net.eagle0.eagle.model.proto_converters.view.province.{FullProvinceInfoConverter, ProvinceViewConverter}
import net.eagle0.eagle.model.proto_converters.UnaffiliatedHeroConverter
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.unaffiliated_hero.RecruitmentInfo
import net.eagle0.eagle.model.view.incoming_army.{IncomingArmyView, UnitDetails}
import net.eagle0.eagle.model.view.province.{FullProvinceInfo, ProvinceView, UnaffiliatedHeroBasics}
import net.eagle0.eagle.FactionId
object ProvinceViewFilter {
// =============== Scala GameState overloads ===============
/** Filtered view for server-side use (no faction visibility restrictions) */
def filteredProvinceView(
province: ProvinceT,
gs: ScalaGameState
): ProvinceView = {
val factions = gs.factions.values.toVector
val factionLeaderIds = factions.flatMap(_.leaderIds)
val info: FullProvinceInfo = FullProvinceInfo(
rulingHeroId = province.rulingHeroId,
rulingFactionHeroIds = province.rulingFactionHeroIds
.map(hid => gs.heroes(hid))
.sortWith(HeroUtils.sortOrderer(factionLeaderIds))
.map(_.id),
battalions = province.battalionIds
.map(bid => gs.battalions(bid))
.map(BattalionViewFilter.filteredBattalionViewBelongingToPlayer)
.sortBy(_.typeId.value),
defendingArmy = province.defendingArmy
.map(army => ArmyFilter.filterArmy(army, gs.battalions, None)),
economy = province.economy.toFloat,
agriculture = province.agriculture.toFloat,
infrastructure = province.infrastructure.toFloat,
economyDevastation = province.economyDevastation.toFloat,
agricultureDevastation = province.agricultureDevastation.toFloat,
infrastructureDevastation = province.infrastructureDevastation.toFloat,
gold = province.gold,
food = province.food,
priceIndex = province.priceIndex,
goldCap = ProvinceUtils.goldCap(province),
foodCap = ProvinceUtils.foodCap(province),
support = Some(supportScala(province.support)),
foodConsumption = ProvinceUtils.monthlyFoodConsumption(province, gs),
provinceOrders = ProvinceOrderTypeConverter.toProto(province.provinceOrders),
hexMapName = province.hexMapName,
castleCount = province.castleCount,
heroCap = province.heroCap,
unaffiliatedHeroes = Vector.empty,
rulerIsTraveling = province.rulerIsTraveling
)
ProvinceView(
id = province.id,
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = Some(info),
knownEvents = Vector.empty,
asOf = gs.currentDate
)
}
/** View shown to a player who withdraws from a province they do not own. (Scala overload) */
def withdrawnFromProvinceView(
province: ProvinceT,
gs: ScalaGameState,
factionId: FactionId
): ProvinceView = {
val reconnedView = for {
faction <- gs.factions.get(factionId)
pv <- faction.reconnedProvinces.find(_.id == province.id)
} yield pv // Already Scala ProvinceView, no conversion needed
// Convert Scala events to proto for filtering (ProvinceView uses proto events)
val activeEventsProto = province.activeEvents.map(ProvinceEventConverter.toProto)
val knownEvents = activeEventsProto.filter { pe =>
if reconnedView.exists(_.knownEvents.contains(pe)) then true
else eventIsUniversallyVisible(pe)
}
val incomingAttackers = myIncomingArmiesScala(province, gs, factionId)
val factions = gs.factions.values.toVector
val factionLeaderIds = factions.flatMap(_.leaderIds)
val fullInfo = reconnedView
.flatMap(_.fullInfo)
.getOrElse(
FullProvinceInfo(
rulingHeroId = province.rulingHeroId,
rulingFactionHeroIds = province.rulingFactionHeroIds
.map(hid => gs.heroes(hid))
.sortWith(HeroUtils.sortOrderer(factionLeaderIds))
.map(_.id),
battalions = Vector.empty, // Will be replaced below
defendingArmy = province.defendingArmy
.map(army => ArmyFilter.filterArmy(army, gs.battalions, Some(factionId))),
economy = province.economy.toFloat,
agriculture = province.agriculture.toFloat,
infrastructure = province.infrastructure.toFloat,
economyDevastation = province.economyDevastation.toFloat,
agricultureDevastation = province.agricultureDevastation.toFloat,
infrastructureDevastation = province.infrastructureDevastation.toFloat,
gold = province.gold,
food = province.food,
priceIndex = province.priceIndex,
goldCap = ProvinceUtils.goldCap(province),
foodCap = ProvinceUtils.foodCap(province),
support = Some(supportScala(province.support)),
foodConsumption = ProvinceUtils.monthlyFoodConsumption(province, gs),
provinceOrders = ProvinceOrderTypeConverter.toProto(province.provinceOrders),
hexMapName = province.hexMapName,
castleCount = province.castleCount,
heroCap = province.heroCap,
unaffiliatedHeroes = Vector.empty,
rulerIsTraveling = province.rulerIsTraveling
)
)
.copy(
battalions = province.battalionIds
.map(bid => gs.battalions(bid))
.map(BattalionViewFilter.limitedBattalionView)
.toVector
)
ProvinceView(
id = province.id,
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = Some(fullInfo),
knownEvents = knownEvents.toVector,
asOf = reconnedView.flatMap(_.asOf),
knownIncomingArmies = incomingAttackers
)
}
private def myIncomingArmiesScala(
province: ProvinceT,
gs: ScalaGameState,
factionId: FactionId
): Vector[IncomingArmyView] =
province.incomingArmies
.filter(_.army.factionId == factionId)
.map(army => incomingArmyInfoScala(ma = army, gs = gs, showDetails = true))
private def incomingArmyInfoScala(
ma: net.eagle0.eagle.model.state.MovingArmy,
gs: ScalaGameState,
showDetails: Boolean
): IncomingArmyView =
IncomingArmyView(
factionId = ma.army.factionId,
heroCount = ma.army.units.size,
troopCount = ma.army.units
.flatMap(_.battalionId)
.map(gs.battalions)
.map(_.size)
.sum,
originProvinceId = ma.originProvinceId,
destinationProvinceId = ma.destinationProvinceId,
arrivalRoundId = ma.arrivalRound,
food = ma.supplies.food,
gold = ma.supplies.gold,
unitDetails =
if showDetails then
ma.army.units.map { cu =>
UnitDetails(
heroId = cu.heroId,
battalion = cu.battalionId.map(gs.battalions).map { b =>
BattalionViewFilter.filteredBattalionViewBelongingToPlayer(b)
}
)
}
else Vector()
)
private val universallyVisibleCheckers: List[ProvinceEvent => Boolean] = List(
ProvinceEventUtils.isBlizzardEvent,
ProvinceEventUtils.isFestivalEvent,
@@ -36,21 +213,19 @@ object ProvinceViewFilter {
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = Some(fullProvinceInfo(province, gs, None)),
knownEvents = province.activeEvents,
asOf = Some(gs.currentDate.get)
knownEvents = province.activeEvents.toVector,
asOf = Some(DateConverter.fromProto(gs.currentDate))
)
private def deadHeroesFilteredOut(
fullProvinceInfo: FullProvinceInfo,
gameState: GameState
): FullProvinceInfo =
fullProvinceInfo.update(
_.rulingFactionHeroIds.modify(
_.filterNot(hid => gameState.killedHeroes.contains(hid))
),
_.unaffiliatedHeroes.modify(
_.filterNot(uh => gameState.killedHeroes.contains(uh.heroId))
)
fullProvinceInfo.copy(
rulingFactionHeroIds =
fullProvinceInfo.rulingFactionHeroIds.filterNot(hid => gameState.killedHeroes.contains(hid)),
unaffiliatedHeroes =
fullProvinceInfo.unaffiliatedHeroes.filterNot(uh => gameState.killedHeroes.contains(uh.heroId))
)
def filteredProvinceView(
@@ -65,11 +240,13 @@ object ProvinceViewFilter {
) ++ gs.factions.get(factionId)
// Take the most recent reconned view from this faction or any allies
// (stored as proto, convert to Scala)
val reconnedView =
relevantFactions
.flatMap(_.reconnedProvinces)
.filter(_.id == province.id)
.maxByOption(_.getAsOf)
.map(ProvinceViewConverter.fromProto)
val showAll = province.rulingFactionId.exists { rfid =>
Visibility.hasFullVisibility(factionId, rfid, gs)
@@ -103,7 +280,7 @@ object ProvinceViewFilter {
food = shipment.supplies.map(_.food).getOrElse(0),
gold = shipment.supplies.map(_.gold).getOrElse(0)
)
}
}.toVector
else Vector()
val fullInfo = Option
@@ -117,9 +294,9 @@ object ProvinceViewFilter {
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = fullInfo,
knownEvents = knownEvents,
knownEvents = knownEvents.toVector,
asOf =
if showAll then Some(gs.currentDate.get)
if showAll then Some(DateConverter.fromProto(gs.currentDate))
else reconnedView.flatMap(_.asOf),
knownIncomingArmies = incomingAttackers ++ incomingSupplies
)
@@ -131,10 +308,11 @@ object ProvinceViewFilter {
gs: GameState,
factionId: FactionId
): ProvinceView = {
// Stored as proto, convert to Scala
val reconnedView = for {
faction <- gs.factions.get(factionId)
pv <- faction.reconnedProvinces.find(_.id == province.id)
} yield pv
} yield ProvinceViewConverter.fromProto(pv)
val knownEvents = province.activeEvents.filter { pe =>
if reconnedView.exists(_.knownEvents.contains(pe)) then true
@@ -148,10 +326,13 @@ object ProvinceViewFilter {
val fullInfo = reconnedView
.flatMap(_.fullInfo)
.getOrElse(fullProvinceInfo(province, gs, Some(factionId)))
.update(
_.battalions := province.battalionIds.map(gs.battalions).map {
LegacyBattalionViewFilter.limitedBattalionView
}
.copy(
battalions = province.battalionIds
.map(gs.battalions)
.map {
LegacyBattalionViewFilter.limitedBattalionViewScala
}
.toVector
)
ProvinceView(
@@ -159,7 +340,7 @@ object ProvinceViewFilter {
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = Some(fullInfo),
knownEvents = knownEvents,
knownEvents = knownEvents.toVector,
asOf = reconnedView.flatMap(_.asOf),
knownIncomingArmies = incomingAttackers ++ incomingSupplies
)
@@ -172,13 +353,15 @@ object ProvinceViewFilter {
): FullProvinceInfo = FullProvinceInfo(
rulingHeroId = province.rulingHeroId,
rulingFactionHeroIds = province.rulingFactionHeroIds
.sortWith(LegacyHeroUtils.sortOrderer(gs)),
.sortWith(LegacyHeroUtils.sortOrderer(gs))
.toVector,
battalions = province.battalionIds
.map(gs.battalions)
.map(LegacyBattalionViewFilter.filteredBattalionViewBelongingToPlayer)
.sortBy(_.`type`.index),
.map(LegacyBattalionViewFilter.filteredBattalionViewBelongingToPlayerScala)
.sortBy(_.typeId.value)
.toVector,
defendingArmy = province.defendingArmy
.map(ArmyFilter.filterArmy(_, gs.battalions, factionId)),
.map(ArmyFilter.filterArmyScala(_, gs.battalions, factionId)),
economy = province.economy.toFloat,
agriculture = province.agriculture.toFloat,
infrastructure = province.infrastructure.toFloat,
@@ -190,13 +373,13 @@ object ProvinceViewFilter {
priceIndex = province.priceIndex,
goldCap = LegacyProvinceUtils.goldCap(province),
foodCap = LegacyProvinceUtils.foodCap(province),
support = Some(supportSc(province.support)),
support = Some(supportScala(province.support)),
foodConsumption = LegacyProvinceUtils.monthlyFoodConsumption(province.id, gs),
provinceOrders = province.provinceOrders,
hexMapName = province.hexMapName,
castleCount = province.castleCount,
heroCap = province.heroCap,
unaffiliatedHeroes = province.unaffiliatedHeroes.map(unaffiliatedHeroInfo(_, gs)),
unaffiliatedHeroes = province.unaffiliatedHeroes.map(unaffiliatedHeroInfo(_, gs)).toVector,
rulerIsTraveling = province.rulerIsTraveling
)
@@ -224,6 +407,7 @@ object ProvinceViewFilter {
)
)
.map(army => incomingArmyInfo(ma = army, gs = gs, showDetails = false))
.toVector
private def incomingArmyInfo(
ma: MovingArmy,
@@ -246,14 +430,14 @@ object ProvinceViewFilter {
unitDetails =
if showDetails then
ma.getArmy.units.map { cu =>
IncomingArmyView.UnitDetails(
UnitDetails(
heroId = cu.heroId,
battalion = cu.battalionId.map(gs.battalions).map { b =>
LegacyBattalionViewFilter
.filteredBattalionViewBelongingToPlayer(b)
.filteredBattalionViewBelongingToPlayerScala(b)
}
)
}
}.toVector
else Vector()
)
@@ -265,10 +449,11 @@ object ProvinceViewFilter {
UnaffiliatedHeroBasics(
heroId = hero.id,
nameTextId = hero.nameTextId,
profession = hero.profession,
status = uh.`type`,
profession = ProfessionConverter.fromProto(hero.profession),
status = UnaffiliatedHeroConverter.unaffiliatedHeroTypeFromProto(uh.`type`),
isFactionLeader = LegacyFactionUtils.isFactionLeader(hero.id, gs),
recruitmentInfo = uh.recruitmentInfo
recruitmentInfo =
uh.recruitmentInfo.fold(RecruitmentInfo.Unknown)(UnaffiliatedHeroConverter.recruitmentInfoFromProto)
)
}
}
@@ -1,6 +1,7 @@
package net.eagle0.eagle.model.action_result
import net.eagle0.eagle.{BattalionId, FactionId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.model.action_result.changed_province.ChangedProvinceT
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
import net.eagle0.eagle.model.action_result.types.base.ActionResultType
@@ -27,7 +28,7 @@ trait ActionResultT {
def newRoundId: Option[RoundId]
def newDate: Option[Date]
// def lastCommandTypeForActingProvince: Option[CommandType]
def lastCommandTypeForActingProvince: Option[SelectedCommand]
def newBattle: Option[ShardokBattle]
// def resolvedBattle: Option[String]
@@ -73,7 +74,7 @@ trait ActionResultT {
newRoundPhase: Option[RoundPhase] = newRoundPhase,
newRoundId: Option[RoundId] = newRoundId,
newDate: Option[Date] = newDate,
// lastCommandTypeForActingProvince: Option[CommandType] = None,
lastCommandTypeForActingProvince: Option[SelectedCommand] = lastCommandTypeForActingProvince,
newBattle: Option[ShardokBattle] = newBattle,
// resolvedBattle: Option[String] = None,
// resolvedImminentBattle: Option[Int] = None,
@@ -51,6 +51,7 @@ scala_library(
":changed_hero_trait",
":client_text_visibility_extension_trait",
":notification_trait",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
@@ -1,6 +1,7 @@
package net.eagle0.eagle.model.action_result.concrete
import net.eagle0.eagle.{BattalionId, FactionId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.model.action_result.{
ActionResultT,
ChangedBattalionT,
@@ -34,6 +35,7 @@ case class ActionResultC(
newRoundPhase: Option[RoundPhase] = None,
newRoundId: Option[RoundId] = None,
newDate: Option[Date] = None,
lastCommandTypeForActingProvince: Option[SelectedCommand] = None,
newBattle: Option[ShardokBattle] = None,
newChronicleEntry: Option[ChronicleEntry] = None,
changedBattalions: Vector[ChangedBattalionT] = Vector.empty,
@@ -68,6 +70,7 @@ case class ActionResultC(
newRoundPhase: Option[RoundPhase] = newRoundPhase,
newRoundId: Option[RoundId] = newRoundId,
newDate: Option[Date] = newDate,
lastCommandTypeForActingProvince: Option[SelectedCommand] = lastCommandTypeForActingProvince,
newBattle: Option[ShardokBattle] = newBattle,
newChronicleEntry: Option[ChronicleEntry] = newChronicleEntry,
changedBattalions: Vector[ChangedBattalionT] = changedBattalions,
@@ -99,6 +102,7 @@ case class ActionResultC(
newRoundPhase = newRoundPhase,
newRoundId = newRoundId,
newDate = newDate,
lastCommandTypeForActingProvince = lastCommandTypeForActingProvince,
newBattle = newBattle,
newChronicleEntry = newChronicleEntry,
changedBattalions = changedBattalions,
@@ -16,6 +16,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/types/base:action_result_type",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
@@ -71,12 +72,12 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
"//src/main/scala/net/eagle0/eagle/model/state/diplomacy_offer",
"//src/main/scala/net/eagle0/eagle/model/state/faction:faction_relationship",
"//src/main/scala/net/eagle0/eagle/model/state/faction:prestige_modifier",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
)
@@ -10,7 +10,7 @@ import net.eagle0.eagle.model.action_result.concrete.ChangedFactionC.{
import net.eagle0.eagle.model.action_result.ChangedFactionT
import net.eagle0.eagle.model.state.diplomacy_offer.DiplomacyOffer
import net.eagle0.eagle.model.state.faction.{FactionRelationship, PrestigeModifier}
import net.eagle0.eagle.views.province_view.ProvinceView
import net.eagle0.eagle.model.view.province.ProvinceView
case class TrustLevelUpdate(
targetFactionId: FactionId,
@@ -46,5 +46,6 @@ case class ChangedFactionC(
removedReconnedProvinceIds: Vector[ProvinceId] = Vector.empty,
trustLevelUpdates: Vector[TrustLevelUpdate] = Vector.empty,
newLastActedProvinceId: Option[ProvinceId] = None,
clearLastActedProvinceId: Boolean = false
clearLastActedProvinceId: Boolean = false,
newName: Option[String] = None
) extends ChangedFactionT
@@ -406,4 +406,16 @@ enum LlmRequestT extends GeneratedTextRequestT:
toFactionId: FactionId,
provinceId: ProvinceId
)
case NewFactionHeadMessage(
requestId: String,
eagleGameId: GameId,
recipientFactionIds: Vector[FactionId] = Vector.empty,
alwaysGenerate: Boolean = false,
newHeadHeroId: HeroId,
factionId: FactionId,
previousFactionName: String,
newFactionName: Option[String],
previousHeadHeroId: HeroId
)
end LlmRequestT
@@ -1,6 +1,7 @@
package net.eagle0.eagle.model.proto_converters
import net.eagle0.eagle.{BattalionId, FactionId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.common.action_result_type.ActionResultType as ActionResultTypeProto
import net.eagle0.eagle.common.round_phase.NewRoundPhase as NewRoundPhaseProto
import net.eagle0.eagle.internal.action_result.ActionResult as ActionResultProto
@@ -53,6 +54,7 @@ object ActionResultProtoConverter {
newRoundPhase: Option[RoundPhase],
newRoundId: Option[RoundId],
newDate: Option[Date],
lastCommandTypeForActingProvince: Option[SelectedCommand],
newBattle: Option[ShardokBattle],
newChronicleEntry: Option[ChronicleEntry],
changedBattalions: Vector[ChangedBattalionT],
@@ -86,6 +88,7 @@ object ActionResultProtoConverter {
newRoundPhase = newRoundPhase.map(rp => NewRoundPhaseProto(RoundPhaseConverter.toProto(rp))),
newRoundId = newRoundId,
newDate = newDate.map(DateConverter.toProto),
lastCommandTypeForActingProvince = lastCommandTypeForActingProvince.getOrElse(SelectedCommand.Empty),
newBattle = newBattle.map(ShardokBattleConverter.toProto),
newChronicleEntry = newChronicleEntry.map(ChronicleEntryConverter.toProto),
changedBattalions = changedBattalions.map {
@@ -24,6 +24,7 @@ scala_library(
":changed_province_converter",
":notification_converter",
":round_phase_converter",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -105,11 +106,15 @@ scala_library(
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/battalion:__pkg__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
],
exports = [
"//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:eagle_pkg",
@@ -123,6 +128,7 @@ scala_library(
srcs = ["BattalionTypeConverter.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
@@ -211,10 +217,13 @@ scala_library(
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:changed_faction_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_faction_concrete",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/diplomacy_offer",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
)
@@ -257,10 +266,14 @@ scala_library(
srcs = ["CombatUnitConverter.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions/impl/command:__pkg__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state:combat_unit",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -426,11 +439,15 @@ scala_library(
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -18,9 +18,11 @@ import net.eagle0.eagle.model.action_result.concrete.ChangedFactionC.{
import net.eagle0.eagle.model.action_result.ChangedFactionT
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.DiplomacyOfferConverter
import net.eagle0.eagle.model.proto_converters.faction.FactionConverter
import net.eagle0.eagle.model.proto_converters.view.province.ProvinceViewConverter
import net.eagle0.eagle.model.state.diplomacy_offer.DiplomacyOffer
import net.eagle0.eagle.model.state.faction.{FactionRelationship, PrestigeModifier}
import net.eagle0.eagle.views.province_view.ProvinceView
import net.eagle0.eagle.model.view.province.ProvinceView
import net.eagle0.eagle.views.province_view.ProvinceView as ProvinceViewProto
object ChangedFactionConverter {
def toProto(changedFaction: ChangedFactionT): ChangedFactionProto =
@@ -54,7 +56,8 @@ object ChangedFactionConverter {
removedReconnedProvinceIds: Vector[ProvinceId],
trustLevelUpdates: Vector[TrustLevelUpdate],
newLastActedProvinceId: Option[ProvinceId],
clearLastActedProvinceId: Boolean
clearLastActedProvinceId: Boolean,
newName: Option[String]
) =>
ChangedFactionProto(
id = factionId,
@@ -73,7 +76,7 @@ object ChangedFactionConverter {
addedOutgoingRansomOfferPartners = newOutgoingRansomOfferFactionIds.map(_.fid),
newFocusProvinceId = newFocusProvinceId,
clearFocusProvinceId = clearFocusProvinceId,
updatedReconnedProvinces = updatedReconnedProvinces,
updatedReconnedProvinces = updatedReconnedProvinces.map(ProvinceViewConverter.toProto),
removedReconnedProvinceIds = removedReconnedProvinceIds,
trustLevelUpdates = trustLevelUpdates.map { tlu =>
TrustLevelUpdateProto(
@@ -82,7 +85,8 @@ object ChangedFactionConverter {
)
},
newLastActedProvinceId = newLastActedProvinceId,
clearLastActedProvinceId = clearLastActedProvinceId
clearLastActedProvinceId = clearLastActedProvinceId,
newName = newName
)
}
@@ -105,11 +109,12 @@ object ChangedFactionConverter {
addedOutgoingRansomOfferPartners: Seq[FactionId],
newFocusProvinceId: Option[ProvinceId],
clearFocusProvinceId: Boolean,
updatedReconnedProvinces: Seq[ProvinceView],
updatedReconnedProvinces: Seq[ProvinceViewProto],
removedReconnedProvinceIds: Seq[ProvinceId],
trustLevelUpdates: Seq[TrustLevelUpdateProto],
newLastActedProvinceId: Option[ProvinceId],
clearLastActedProvinceId: Boolean,
newName: Option[String],
_ /* unknownFields */
) =>
ChangedFactionC(
@@ -141,7 +146,7 @@ object ChangedFactionConverter {
.toVector,
newFocusProvinceId = newFocusProvinceId,
clearFocusProvinceId = clearFocusProvinceId,
updatedReconnedProvinces = updatedReconnedProvinces.toVector,
updatedReconnedProvinces = updatedReconnedProvinces.map(ProvinceViewConverter.fromProto).toVector,
removedReconnedProvinceIds = removedReconnedProvinceIds.toVector,
trustLevelUpdates = trustLevelUpdates.map { tlu =>
TrustLevelUpdate(
@@ -150,7 +155,8 @@ object ChangedFactionConverter {
)
}.toVector,
newLastActedProvinceId = newLastActedProvinceId,
clearLastActedProvinceId = clearLastActedProvinceId
clearLastActedProvinceId = clearLastActedProvinceId,
newName = newName
)
}
}
@@ -31,6 +31,11 @@ object UnaffiliatedHeroConverter {
UNAFFILIATED_HERO_RETURNING_PRISONER -> UnaffiliatedHeroType.ReturningPrisoner
)
def unaffiliatedHeroTypeFromProto(
protoType: UnaffiliatedHeroTypeProto
): UnaffiliatedHeroType =
unaffiliatedHeroTypeMap.getOrElse(protoType, UnaffiliatedHeroType.Unknown)
def recruitmentInfoFromProto(
recruitmentInfoProto: RecruitmentInfoProto
): RecruitmentInfo = recruitmentInfoProto.status match {
@@ -7,6 +7,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:__pkg__",
@@ -15,14 +15,17 @@ scala_library(
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:proto_conversion_exception",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/diplomacy_offer",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/diplomacy_offer",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/faction:faction_relationship",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
)
@@ -7,13 +7,15 @@ import net.eagle0.eagle.internal.faction.Faction.OutgoingOfferRound as OutgoingO
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship as FactionRelationshipProto
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.DiplomacyOfferConverter
import net.eagle0.eagle.model.proto_converters.view.province.ProvinceViewConverter
import net.eagle0.eagle.model.proto_converters.ProtoConversionException
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.diplomacy_offer.DiplomacyOffer
import net.eagle0.eagle.model.state.faction.{FactionRelationship, FactionT, PrestigeModifier}
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.faction.FactionT.OutgoingOfferRound
import net.eagle0.eagle.views.province_view.ProvinceView
import net.eagle0.eagle.model.view.province.ProvinceView
import net.eagle0.eagle.views.province_view.ProvinceView as ProvinceViewProto
object FactionConverter {
def toProto(faction: FactionT): FactionProto = faction match {
@@ -43,7 +45,7 @@ object FactionConverter {
factionRelationships = factionRelationships.map(toProto),
incomingDiplomacyOffers = incomingDiplomacyOffers.map(DiplomacyOfferConverter.toProto),
focusProvinceId = focusProvinceId,
reconnedProvinces = reconnedProvinces,
reconnedProvinces = reconnedProvinces.map(ProvinceViewConverter.toProto),
lastActedProvinceIdThisRound = lastActedProvinceIdThisRound,
lastOutgoingTruceOfferRounds = lastOutgoingTruceOfferRounds.map(toProto),
lastOutgoingAllianceOfferRounds = lastOutgoingAllianceOfferRounds.map(toProto),
@@ -117,7 +119,7 @@ object FactionConverter {
factionRelationships: Seq[FactionRelationshipProto],
incomingDiplomacyOffers: Seq[DiplomacyOfferProto],
focusProvinceId: Option[ProvinceId],
reconnedProvinces: Seq[ProvinceView],
reconnedProvinces: Seq[ProvinceViewProto],
lastActedProvinceIdThisRound: ProvinceId,
lastOutgoingTruceOfferRounds: Seq[OutgoingOfferRoundProto],
lastOutgoingAllianceOfferRounds: Seq[OutgoingOfferRoundProto],
@@ -137,7 +139,7 @@ object FactionConverter {
.map(DiplomacyOfferConverter.fromProto)
.toVector,
focusProvinceId = focusProvinceId,
reconnedProvinces = reconnedProvinces.toVector,
reconnedProvinces = reconnedProvinces.map(ProvinceViewConverter.fromProto).toVector,
lastActedProvinceIdThisRound = lastActedProvinceIdThisRound,
lastOutgoingTruceOfferRounds = lastOutgoingTruceOfferRounds.map(fromProto).toVector,
lastOutgoingAllianceOfferRounds = lastOutgoingAllianceOfferRounds.map(fromProto).toVector,
@@ -9,6 +9,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/applier:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__pkg__",
@@ -85,6 +85,7 @@ scala_library(
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",

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