Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 c7358b2252 Wire up ImproveCommandSelector toggle buttons in Unity
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 13:20:33 -08:00
adminandClaude Opus 4.5 ed96430458 Show unavailable improvement types as grayed-out instead of hidden
Uses CanvasGroup alpha to dim unavailable toggles and sets
interactable=false so they cannot be selected.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 11:20:27 -08:00
adminandClaude Opus 4.5 bc76b402fb Remove unused variable in ImproveCommandSelector
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 11:18:22 -08:00
adminandClaude Opus 4.5 8279a1dc61 Replace Improve command dropdown with toggle buttons
- Replace TMP_Dropdown with 4 toggle buttons (Economy, Agriculture,
  Infrastructure, Devastation) showing type name and current value
- Each toggle shows the improvement type and its current/effective value
- Maintains existing default selection logic (Devastation priority, then
  lowest stat)

Note: Unity prefab changes are needed to wire up the new toggle buttons.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 10:47:30 -08:00
580273174b Fix final Shardok battle actions not being displayed to clients (#4898)
When a battle ended, the client would never see the final few actions
that caused the game to end (e.g., the killing blow). This was because
WaitForUpdatesAndPush() would return immediately after calling
OnGameOver() without first sending the pending updates.

The fix moves the update-sending code before the gameOver check,
ensuring all action results are streamed to clients before the
game over notification is sent.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 07:22:24 -08:00
b1fc38343d Remove proto versions from HeroSelector and ProvinceGoldSurplusCalculator (#4899)
- Remove dead minimallyFatiguedHeroesProto from HeroSelector (no callers)
- Convert ProvinceGoldSurplusCalculator to fully protoless
  - Callers now use ProvinceConverter.fromProto() to get protoless province
  - Removed proto GameState dependency entirely
- Update callers in CommandChoiceHelpers, MarchTowardProvinceCommandChooser,
  and MidGameAIClient to use protoless versions
- Update DEPROTO_PLAN.md to reflect progress

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 07:13:06 -08:00
8a981710a8 Add chronicler style tracking to prevent style bleeding between chronicle entries (#4897)
- Add chronicler_style field to ChronicleEntry proto and Scala case class
- Store which chronicler style was used for each entry
- Update ChronicleUpdatePromptGenerator to label previous entries with their chronicler
- Prompt now distinguishes between same/different chronicler:
  - Same chronicler: "Continue in that same style and voice"
  - Different chronicler: "Write in YOUR OWN STYLE, don't imitate previous entries"
- Select chronicler style deterministically in NewRoundAction based on game ID and date

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-29 05:52:02 -08:00
bb111463f1 Fix spurious new faction head notifications (#4894)
Only generate NewFactionHead notification when the faction head actually
changes (i.e., when the head is killed), not when any non-head leader
is killed (e.g., a sworn brother being executed).

The bug was that maybeFactionLeaderRemovedResult was creating notifications
for every faction where any leader died, regardless of whether the faction
head changed. Now it correctly checks if originalFaction.factionHeadId !=
revisedFaction.factionHeadId before generating notifications.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 21:09:39 -08:00
800ac22ff6 Enable JFR profiling for Eagle server in Docker (#4895)
Adds Java Flight Recorder with continuous low-overhead profiling (~1%):
- Keeps 1 hour of data in a 100MB circular buffer
- Auto-dumps on JVM exit
- Stack depth of 256 for detailed traces

To capture a profile:
  docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
  docker cp eagle-server:/app/jfr/profile.jfr .

Analyze with JDK Mission Control (jmc) or IntelliJ's JFR viewer.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 21:07:21 -08:00
152a3b2388 Fix final Shardok battle results not displaying before model removal (#4892)
The ShardokGameModel was being removed from ShardokGameModels BEFORE
UpdateAction.Invoke() was called. This meant the UI callback received
a model that no longer contained the ended battle's final results.

Flow before:
1. Final Shardok results arrive with GameStatus = Victory/Defeat
2. Model updated with final results
3. Model REMOVED from ShardokGameModels
4. UpdateAction.Invoke() - UI doesn't see the model
5. Final results never displayed

Flow after:
1. Final Shardok results arrive with GameStatus = Victory/Defeat
2. Model updated with final results
3. Model STAYS in ShardokGameModels
4. UpdateAction.Invoke() - UI sees model with final results
5. Model removed AFTER callback

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 17:43:15 -08:00
39e0b9bbf8 Fix Unity build deploying from all branches instead of main only (#4893)
The deploy and manifest update steps had the branch check commented out,
causing builds from PRs and feature branches to be published to production.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 17:42:33 -08:00
df711b26eb Add settings management to admin server (Phase 2) (#4891)
* Add settings management to admin server (Phase 2)

- Add GetSettings gRPC endpoint to eagle.proto
- Enhance SettingsLoader generator to include getAllSettings method
- Implement getSettings in EagleServiceImpl
- Add settings page with live search and inline editing
- Uses existing AddSettings endpoint for updates
- Modified settings are highlighted in the UI

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

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

* Make settings table rows more compact

- Reduce cell padding and font sizes
- Make input and button elements more compact
- Override Pico CSS defaults for better density

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

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

* Make settings input fields even more compact

- Use !important to override Pico CSS defaults
- Set fixed height of 22px for input and button
- Reduce padding to 2px

🤖 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-28 17:37:48 -08:00
29c7ad7c58 Complete Phase 1: reverse order history and clickable action details (#4890)
- Add GetActionDetail gRPC endpoint to fetch individual action data
- Display action history in reverse chronological order (most recent first)
- Make action rows clickable to expand and show JSON representation
- Add action_detail.html template for htmx-powered action expansion
- Update CSS for clickable rows and action detail styling
- Mark Phase 1 as complete in enhancement plan

New routes:
- GET /games/{id}/action/{index} - htmx partial for action detail

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 15:17:20 -08:00
89bc355047 Fix missing battle results by always sending count updates (#4887)
When `filteredResults` was empty (e.g., AI turns filtered out as not
visible to the human player), the `update` method returned early
without sending a message to the client. However, it still advanced
the server's tracking of the client's count (`unfilteredKnownHistoryCount`).

This caused sync mismatches: the server thought the client was up-to-date
(so it wouldn't send the "missing" results on subsequent updates), but
the client never received the new count.

The fix: always call `afterSendingResults`, even when `filteredResults`
is empty. This ensures the client receives the count update (plus
`availableCommands` and `serverGameStatus`) even when there are no
visible results.

This was particularly problematic after battles ended, when AI turns
might be filtered out, causing the "last turns of the battle" to never
appear on the client.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 14:53:47 -08:00
9c7c929b0d Add Web UI to admin server with enhancement plan (#4888)
Phase 1 of admin server enhancements:
- Add Go templates with embed for layout, games list, game detail
- Add Pico CSS from CDN for styling, htmx for interactivity
- Implement htmx infinite scroll for action history
- Keep JSON API endpoints for backward compatibility

New routes:
- GET / - redirect to /games
- GET /games - HTML games list page
- GET /games/{id} - HTML game detail page
- GET /games/{id}/history - htmx partial for history rows

Also adds docs/ADMIN_SERVER_ENHANCEMENTS.md with full enhancement plan
including settings management and game rewind features.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 14:49:12 -08:00
8d444c0153 Add missing newline in PrisonerExecuted notification (#4886)
The textTemplate in PrisonerExecutedDetailsNotificationGenerator was
missing the \n\n separator between lead text and LLM-generated text,
causing them to run together. This matches the pattern used in other
notification generators like CapturedHeroExecutedDetailsNotificationGenerator.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 14:14:43 -08:00
85439d7000 Fix commands not retrying after connection drops during post (#4885)
Two bugs were causing commands to be lost when connection dropped
while posting:

1. PostRequest removed commands from pending queue even when
   DoWithStreamingCall returned false (connection dead). Now only
   removes on successful send.

2. TryPendingCommands dropped pending commands when CurrentEagleToken
   was null (which happens when HandleAvailableCommands skips due to
   LastPostedToken match). Now retries the command anyway.

Together these fixes ensure that if you post a command while the
connection is dead or dying, the command will be retried after
reconnect.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 14:10:48 -08:00
7dd9010a74 Add timeout to WriteAsync calls to prevent thread pool exhaustion (#4884)
WriteAsync can block indefinitely if a connection is dead but not yet
detected (e.g., network issues before TCP keepalive kicks in). This can
exhaust the .NET thread pool and cause the client to freeze.

Changes:
- Add 10-second timeout to DoWithStreamingCall using Task.WhenAny
- Add 10-second timeout to SendUpdateStreamRequestAsync
- Fix double PostRequest bug in TryPendingCommands where commands were
  posted twice (once in switch case, once after)
- Add ConfigureAwait(false) to network operations to avoid deadlocks

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 13:48:57 -08:00
cf2caaa992 Use HTTP/2 for headshot fetching to improve reliability on poor networks (#4883)
Switch ResourceFetcher from HttpClientHandler to YetAnotherHttpHandler with
HTTP/2 enabled. This provides:
- Connection multiplexing (all headshots to same host share one connection)
- Keep-alive pings to detect and recover from dead connections
- Same reliability settings as the gRPC connection

This should help headshots load more reliably on high-latency or lossy networks.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 13:43:29 -08:00
18697f0a38 Fix silent connection death from unhandled exceptions (#4882)
Several exception handling issues could cause the gRPC connection to die
silently with no recovery:

1. HandleStreamingCall() is async void but only caught RpcException and
   ObjectDisposedException. Any other exception type (e.g., from Logger,
   protobuf, null refs) would escape and leave the connection dead.
   Added catch-all that logs and schedules reconnect.

2. Connect() catch block logged errors but never called ScheduleReconnect().
   If Connect() failed after creating the streaming call, the connection
   would die with no recovery attempt. Now properly cleans up and reconnects.

3. Timer callbacks (idle check, heartbeat) had no exception handling.
   Exceptions in timer callbacks can stop the timer from firing again.
   Now wrapped in try-catch.

4. Task.Run(() => Connect()) fire-and-forget calls silently swallowed
   exceptions. Created RunConnectAsync() helper that logs exceptions.

5. Task.Run(() => SendHeartbeat()) also silently swallowed exceptions.
   Now properly awaits and catches exceptions inside the Task.Run.

These issues could explain "connection freezes" where the client stops
receiving updates but doesn't recover - an unhandled exception kills
the streaming thread or prevents reconnection.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 13:40:36 -08:00
d9f2c1c216 Make Logger non-blocking with dedicated writer thread (#4859)
The Logger was blocking ThreadPool threads when StreamWriter.Flush()
was slow (due to antivirus, cloud sync like OneDrive, or disk I/O).
This caused timer callbacks to stop firing, which led to heartbeat
failures and connection freezes.

The fix uses a ConcurrentQueue and dedicated background thread:
- LogLine() just enqueues the formatted message and returns immediately
- A dedicated writer thread processes the queue and handles file I/O
- File I/O blocking only affects the writer thread, not callers
- Timestamps are captured immediately when LogLine() is called

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 13:37:54 -08:00
91e5798478 Add protoless overload to FulfillQuestsCommandSelector (#4881)
- Add new overload that takes native GameState and uhsWithQuests directly
- Proto version now delegates to protoless version
- Export unaffiliated_hero_with_quest and game_state for callers
- Extract choosers list to a val for reuse

This is Part B of splitting PR #4876 - add a protoless public API while maintaining backward compatibility with CommandChooser framework.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 13:28:28 -08:00
980b42c94b Deproto UnaffiliatedHeroWithQuest to use native quest types (#4879)
- Change UnaffiliatedHeroWithQuest to use heroId: HeroId and quest: QuestT instead of uh: UnaffiliatedHero and quest: Quest proto
- Update all 9 quest command choosers to pattern match on native quest types directly (no more .quest.details unwrapping)
- Update FulfillQuestsCommandSelector to use QuestConverter.fromProto
- Update all 6 test files to use native quest types
- Add quest/concrete visibility and deps where needed

This is Part A of splitting PR #4876 - deproto the quest command selectors' internal data structures while keeping the public interface unchanged.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 13:19:07 -08:00
180c1e9429 Fix duplicate Shardok updates being sent to clients (#4878)
The shardokGameIdsToCheck list was constructed by concatenating
outstandingBattles and knownShardokResultCounts without deduplication.
When a battle appears in both lists, the same ShardokGameResultResponse
was generated and sent twice.

This caused every Shardok result to appear twice in client logs:
  #197 UPDATE ShardokResult shardok=...49cbefb8 countAfter=7 results=6
  #198 UPDATE ShardokResult shardok=...49cbefb8 countAfter=7 results=6

Fix: Add .distinct to remove duplicate shardokGameIds.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 13:12:40 -08:00
da29d394c5 Add protoless public interface to AttackDecisionCommandChooser (#4877)
- Change attackDecisionSelectedCommand to take native GameState
- Convert to proto internally using GameStateConverter.toProto
- Keep private methods using proto GameStateProto since they receive
  proto from CommandChooser lambdas
- Update callers (AIClient, CommandChoiceHelpers) to convert proto → native
- Update tests to use GameStateConverter.fromProto and add currentPhase

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 11:39:19 -08:00
3b1075069f Remove GetGameStatus RPC (now using streaming) (#4760)
* Remove legacy 5-second polling wait from GetUpdates

The wait in GetUpdates was used for long-polling to make AI turns feel
responsive. With streaming, WaitForUpdatesAndPush already waits for
updates via updateCondition.wait(), making this redundant.

GetGameStatus is deprecated in favor of SubscribeToGame streaming.

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

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

* Remove GetGameStatus RPC (now using streaming)

GetGameStatus polling is no longer needed since Eagle now uses
SubscribeToGame streaming. This also removes the 5-second wait in
GetUpdates that was only needed to support long-polling.

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

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

* Exclude pre-existing font files from LFS tracking

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>

* Fix out-of-bounds access in GetGameStateAtStartOfAction

Add upper bounds check to GetGameStateAtStartOfAction to match
GetGameHistory behavior. When requesting a state at or beyond the
current action count, return the current game state instead of
accessing an invalid array index.

This fixes signal 11 crashes in FilterNewResults called from
SubscribeToGame streaming flow.

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

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

* Revert "Fix out-of-bounds access in GetGameStateAtStartOfAction"

This reverts commit c725b7a4ed87028bdc4860b4e7753497dbb3f040.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 08:11:48 -08:00
85c8f9912b Add sequence logging for Shardok results (#4875)
Log countAfter (NewResultViewCount) for each ShardokGameResponse to
detect missing Shardok battle results. Similar to the ActionResultResponse
logging, this will show gaps in the sequence if messages are lost.

This helps diagnose why clients sometimes miss the last moves of battles -
observed as shardok sync mismatch (e.g., client=54, server=65).

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 07:46:18 -08:00
097870cfd0 Complete deproto conversion of CommandChoiceHelpers (#4872)
* Complete deproto conversion of CommandChoiceHelpers

- Replace all Legacy utility usages with protoless versions:
  - LegacyFactionUtils.isFactionLeader → FactionUtils.isFactionLeader
  - LegacyProvinceUtils.containsFactionLeader → FactionUtils.factionLeaderLocation
  - LegacyProvinceUtils.effectiveInfrastructure → ProvinceUtils.effectiveInfrastructure
  - LegacyBattalionTypeFinder.battalionType → BattalionTypeFinder.battalionType
  - LegacyHeroUtils.power → HeroUtils.power
- Remove unused proto helper methods: suppressBeastsValueProto, beastPowerProto,
  closestLeaderProto, lowLoyaltyHeroesProto, provinceFoodSurplusProto, destinationCloserToProto
- Use targeted object conversion for foodAmountToBuy (convert individual objects
  rather than full GameState)
- Update DateConverter and RoundPhaseConverter to handle missing/default values
  for test compatibility (month=0 → January, UNKNOWN_PHASE → PlayerCommands)
- Remove Legacy dependencies from BUILD.bazel

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

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

* Revert converter defaults, fix tests to set required fields

- Revert DateConverter and RoundPhaseConverter to throw on invalid data
- Update tests to set currentDate and currentPhase in GameState fixtures
- Tests should explicitly set required data rather than relying on defaults

🤖 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-27 22:36:11 -08:00
fa02919503 Add sequence counter to LogFlow for duplicate detection (#4873)
Add incrementing counter (#1, #2, #3...) to each LogFlow call to diagnose
the duplicate logging issue observed in testing. Interpretation:
- Same seq# appears twice: two threads processing same message
- seq# increments but lines doubled: StreamWriter/Logger issue

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 19:44:14 -08:00
4f8668974b Add sequence number logging for ActionResultResponse (#4871)
Log UnfilteredResultCountAfter for each ActionResultResponse to help
diagnose sync mismatches. If we see gaps in the sequence (e.g., 2512
then 2519 with no 2513-2518), we know messages never arrived from the
server. If we see all counts but the client's internal counter doesn't
match, something dropped them internally.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 16:17:18 -08:00
3b0a85425c Add protoless destinationCloserTo using FactionUtils.ownedNeighbors (#4869)
- Add destinationCloserTo(FactionId, Map[ProvinceId, ProvinceT], ProvinceId, Vector[ProvinceId], ProvinceId)
  using FactionUtils.ownedNeighbors
- Rename proto version to destinationCloserToProto
- Add imports for ProvinceT and FactionUtils
- Update preferredSuppliesDestination to use destinationCloserToProto
- Update BUILD.bazel with protoless dependencies

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:04:21 -08:00
3f7b6e7d3c Add protoless beastPower helper to CommandChoiceHelpers (#4870)
- Add protoless beastPower(ProvinceT) using ProvinceUtils.beastCount/beastInfo
- Add proto version beastPowerProto(Province) and update chosenSuppressBeastsCommand
  to use it instead of inline calculation
- Also adds protoless suppressBeastsValue(HeroT) alongside proto version

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:01:17 -08:00
5072bd6f57 Add protoless closestLeader using ProvinceUtils.locationOf and ProvinceDistances (#4868)
- Add closestLeader(FactionId, FactionT, Map[ProvinceId, ProvinceT], ProvinceId)
  using ProvinceUtils.locationOf and ProvinceDistances.distanceThroughFriendliesOption
- Rename proto version to closestLeaderProto
- Add imports for ProvinceT and ProvinceUtils
- Update preferredSuppliesDestination to use closestLeaderProto
- Update BUILD.bazel with protoless dependencies

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 13:00:10 -08:00
334c63d1de Add protoless provinceFoodSurplus using ProvinceUtils.absoluteFoodSurplus (#4867)
- Add provinceFoodSurplus(ProvinceT, GameStateC) using protoless utilities
- Rename proto version to provinceFoodSurplusProto
- Add imports for GameStateC, ProvinceT, and ProvinceUtils
- Update BUILD.bazel with protoless dependencies

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:59:42 -08:00
6767e5d838 Add protoless versions of lowLoyaltyHeroes and suppressBeastsValue (#4866)
- Add lowLoyaltyHeroes(Vector[HeroT], Vector[FactionT]) using HeroUtils.effectiveLoyalty
- Add suppressBeastsValue(HeroT) using protoless Profession enum
- Rename proto versions to lowLoyaltyHeroesProto and suppressBeastsValueProto
- Update callers to use the renamed proto versions
- Add FactionT and NoProfession imports

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 12:33:39 -08:00
caa863dbbf Deproto: Add protoless utility methods to FactionUtils (#4861)
* Add protoless utility methods to FactionUtils

- Add isFactionHead(HeroId, Iterable[FactionT]) - checks if hero is any faction head
- Add leadersBesidesHead(FactionT) - gets leader IDs excluding faction head
- Add hasProvinces(FactionId, Iterable[ProvinceT]) - checks if faction owns provinces
- Add provinceCount(FactionId, Iterable[ProvinceT]) - counts faction's provinces

These mirror the proto versions in LegacyFactionUtils and enable future migration.

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

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

* Add tests for isFactionHead, leadersBesidesHead, hasProvinces, provinceCount

🤖 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-27 12:09:16 -08:00
fc291c795e Deproto: Add protoless utility methods to HeroUtils (#4860)
* Add protoless utility methods to HeroUtils

- Add seniorityOrder(FactionT, HeroId) - returns leader index or Int.MaxValue
- Add archeryCapable(HeroT) - checks if hero meets agility threshold
- Add startFireCapable(HeroT) - checks profession and stats for fire ability

These mirror the proto versions in LegacyHeroUtils and enable future migration
of CommandChoiceHelpers methods.

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

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

* Add tests for seniorityOrder, archeryCapable, and startFireCapable

🤖 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-27 12:08:51 -08:00
7081210aa5 Add comprehensive timestamped logging for connection flow tracing (#4865)
Adds LogFlow() helper for precise HH:mm:ss.fff timestamps throughout
the connection flow to diagnose reconnection issues:

PersistentClientConnection.cs:
- Connect(): state transitions, subscription start
- StreamOneGameAsync(): subscribe request/ack with counts
- HandleGameUpdate(): update types with token, command count, status
- HandleStreamingCall(): start/end with exit reason
- HandleHeartbeatResponse(): heartbeat response, sync mismatch detection
- SendHeartbeat(): heartbeat send/skip with reason
- ScheduleReconnect(): reconnect scheduling with backoff

EagleGameModel.cs:
- ReceiveGameUpdate(): token comparison and skip decision logging

This is logging-only - no behavioral changes.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 11:13:15 -08:00
3bff280712 Add protoless closestProvinceThroughFriendliesOption to ProvinceDistances (#4864)
* Add protoless closestProvinceThroughFriendliesOption to ProvinceDistances

- Find closest province from candidates through friendly territory
- Mirrors LegacyProvinceDistances.closestProvinceThroughFriendliesOption

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

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

* Add tests for closestProvinceThroughFriendliesOption

🤖 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-27 11:10:34 -08:00
78b8b83f79 Add protoless adjacentHostiles and absoluteFoodSurplus to ProvinceUtils (#4863)
* Add protoless adjacentHostiles and absoluteFoodSurplus to ProvinceUtils

- adjacentHostiles: Get provinces ruled by hostile factions
- absoluteFoodSurplus: Compute absolute food surplus for a province

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

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

* Add tests for adjacentHostiles

Note: absoluteFoodSurplus tests skipped due to complex GameState requirements

🤖 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-27 11:08:38 -08:00
28fdcc4672 Add protoless locationOf and containsFactionLeader to ProvinceUtils (#4862)
* Add protoless locationOf and containsFactionLeader to ProvinceUtils

- locationOf: Find the province containing a hero
- containsFactionLeader: Check if a province contains the faction head

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

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

* Add tests for locationOf and containsFactionLeader

🤖 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-27 10:56:55 -08:00
53314e7cb6 Add Console.WriteLine for timer callbacks and make Logger thread-safe (#4857)
The timers stopped logging entirely during the 28-second gap. To determine
if the timers are firing but the Logger is blocked vs timers not firing:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:33:10 -08:00
93 changed files with 7468 additions and 3286 deletions
+2 -2
View File
@@ -52,14 +52,14 @@ jobs:
- name: Persist Library/
run: ./ci/github_actions/persist_library.sh
- name: Deploy Windows unity
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
- name: Update unified manifest
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
+3 -5
View File
@@ -54,16 +54,14 @@ When migrating a file:
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Dual (both proto and protoless versions)
### Fully Protoless
- [~] `ProvinceGoldSurplusCalculator` - protoless `provinceGoldSurplus(province: ProvinceT)` + legacy `provinceGoldSurplus(provinceId, gameState)`
- [~] `HeroSelector` - protoless `minimallyFatiguedHeroes` + legacy `minimallyFatiguedHeroesProto`
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
### Blocked (still uses proto GameState)
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
- Only 1 call to `minimallyFatiguedHeroesProto` (HeroSelector)
- Many calls to proto `provinceGoldSurplus`
- Depends on many Legacy* utils
- [ ] `AttackDecisionCommandChooser` - uses proto types
- [ ] `CommandChooser` - uses proto GameState
+6
View File
@@ -53,6 +53,12 @@ oci_image(
"java",
"-Xmx4g",
"-XX:+UseG1GC",
# JFR: Continuous low-overhead profiling (~1% overhead).
# Keeps 1 hour of data in a 100MB circular buffer.
# Dump recording: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
# Then copy out: docker cp eagle-server:/app/jfr/profile.jfr .
"-XX:StartFlightRecording=disk=true,maxage=1h,maxsize=100m,dumponexit=true,filename=/app/jfr/recording.jfr",
"-XX:FlightRecorderOptions=stackdepth=256",
"-jar",
"/app/eagle_server_deploy.jar",
],
+1
View File
@@ -26,6 +26,7 @@ services:
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
volumes:
- ./saves:/app/saves
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
depends_on:
- shardok
restart: unless-stopped
+428
View File
@@ -0,0 +1,428 @@
# Admin Server Enhancement Plan
## Overview
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
### Current State
The admin server exposes REST endpoints that return JSON:
- `GET /health` - health check
- `GET /games` - list running games
- `GET /games/{id}/history` - paginated action history
### Goals
1. **Web UI**: Replace raw JSON with an interactive HTML interface
2. **Settings Management**: View and modify the 275+ game settings at runtime
3. **Game Rewind**: Restore a game to a previous action count
---
## Architecture
### Technology Choice: Go Templates + htmx
**Rationale:**
- Single binary deployment (no separate frontend build)
- htmx provides interactivity without JavaScript framework complexity
- Familiar HTML/CSS, minimal learning curve
- Excellent for admin tools where SEO and bundle size don't matter
**Alternatives Considered:**
- React/Vue SPA: Adds build complexity, separate deployment artifact
- Server-side only: Less interactive, full page reloads
### Directory Structure
```
src/main/go/net/eagle0/admin_server/
├── admin_server.go # Main entry point, HTTP routes
├── handlers/
│ ├── games.go # Game list and detail handlers
│ ├── settings.go # Settings list and update handlers
│ └── rewind.go # Game rewind handlers
├── templates/
│ ├── layout.html # Base layout with nav, htmx includes
│ ├── games/
│ │ ├── list.html # Game list page
│ │ ├── detail.html # Single game view with history
│ │ └── history.html # Partial for history table (htmx)
│ ├── settings/
│ │ ├── list.html # Settings list with search/filter
│ │ └── edit.html # Inline edit partial (htmx)
│ └── rewind/
│ └── confirm.html # Rewind confirmation modal
├── static/
│ ├── style.css # Minimal CSS (Pico CSS or similar)
│ └── htmx.min.js # htmx library
└── BUILD.bazel
```
---
## Feature 1: Web UI
### Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/` | GET | Redirect to `/games` |
| `/games` | GET | Game list page (HTML) |
| `/games/{id}` | GET | Game detail page with history |
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
| `/api/games/{id}/history` | GET | JSON API (existing) |
### Game List Page
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Settings] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Running Games (3) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game abc123f Round 45 │ │
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
│ │ Actions: 1,234 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game def456a Round 12 │ │
│ │ Players: Test Player (Human) │ │
│ │ Actions: 456 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Game Detail Page
Shows game info and scrollable action history:
- **Reverse chronological order**: Most recent actions displayed first
- Each action shows: index, type, round ID
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
- "Rewind to here" button on each action row
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
### Implementation Notes
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
---
## Feature 2: Settings Management
### New gRPC Endpoints (Eagle Server)
Add to `eagle.proto`:
```protobuf
message Setting {
string name = 1;
string type = 2; // "Int" or "Double"
string value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
string description = 5; // Optional, for UI hints
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message UpdateSettingRequest {
string name = 1;
string value = 2;
}
message UpdateSettingResponse {
Setting setting = 1; // Updated setting
string error = 2; // Empty on success
}
service Eagle {
// ... existing methods ...
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
}
```
### Eagle Server Implementation
Create a settings registry that:
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
2. Provides get/set by name
3. Validates types on update
```scala
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
object SettingsRegistry {
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
"ActionVigorCost" -> Left(ActionVigorCost),
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
// ... register all 275 settings
)
def getAll(filter: Option[String]): Seq[Setting] = ...
def get(name: String): Option[Setting] = ...
def update(name: String, value: String): Either[String, Setting] = ...
}
```
**Alternative: Code generation**
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
### Admin Server Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/settings` | GET | Settings list page with search |
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
| `/settings/{name}` | PUT | Update setting value |
| `/api/settings` | GET | JSON API |
| `/api/settings/{name}` | PUT | JSON API |
### Settings UI
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Games] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Settings [Search: __________ ] │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ActionVigorCost (Int) │ │
│ │ Current: [15 ] Default: 15 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ BaseFoodBuyPrice (Double) │ │
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ... (275 settings, virtualized/paginated) ... │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Considerations
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
4. **Audit log**: Log setting changes with timestamp for debugging
---
## Feature 3: Game Rewind
### Concept
Restore a game to a previous point in its action history. This is useful for:
- Debugging issues that occurred at a specific point
- Testing "what if" scenarios
- Recovering from bugs that corrupted state
### New gRPC Endpoint
Add to `eagle.proto`:
```protobuf
message RewindGameRequest {
int64 game_id = 1;
int32 target_action_count = 2; // Rewind to state after this many actions
}
message RewindGameResponse {
bool success = 1;
string error = 2;
int32 new_action_count = 3;
int32 disconnected_clients = 4; // Number of clients that were disconnected
}
service Eagle {
// ... existing methods ...
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
}
```
### Eagle Server Implementation
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
2. **Get target state**: Retrieve `GameState` at target action count from history
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
5. **Reset AI state**: Clear any cached AI state that depends on current game state
```scala
// GameController.scala (pseudocode)
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
if (targetActionCount < 0 || targetActionCount > engine.history.count)
return Left(s"Invalid action count: $targetActionCount")
// Get state at target point
val targetState = engine.history.stateAt(targetActionCount)
val truncatedHistory = engine.history.truncateTo(targetActionCount)
// Disconnect all human clients
val disconnectedCount = humanClients.length
humanClients.foreach(_.disconnect("Game rewound by admin"))
// Create new engine at target state
val newEngine = EngineImpl(
gameId = engine.gameId,
currentState = targetState,
history = truncatedHistory,
// ... other fields
)
// Replace controller's engine
this.engine = newEngine
Right(RewindResult(targetActionCount, disconnectedCount))
}
```
### GameHistory Enhancement
Add method to get state at a specific action count:
```scala
trait GameHistory {
// ... existing methods ...
def stateAt(actionCount: Int): GameState = {
if (actionCount == 0) initialState
else all(actionCount - 1).resultingState
}
def truncateTo(actionCount: Int): GameHistory = {
GameHistoryImpl(
initialState = initialState,
actions = all.take(actionCount)
)
}
}
```
### Admin Server Route
| Route | Method | Description |
|-------|--------|-------------|
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
### Rewind UI Flow
1. User views game history
2. User clicks "Rewind to here" on an action row
3. Confirmation modal appears via htmx:
```
┌─────────────────────────────────────────┐
│ Rewind Game abc123f? │
│ │
│ This will: │
│ • Restore to action 456 (Round 23) │
│ • Discard 778 subsequent actions │
│ • Disconnect 2 connected players │
│ │
│ This cannot be undone. │
│ │
│ [Cancel] [Rewind] │
└─────────────────────────────────────────┘
```
4. On confirm, POST to `/games/{id}/rewind`
5. Success: redirect to game detail showing new state
6. Error: show error message
### Safety Considerations
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
2. **Client disconnect**: All connected clients are forcibly disconnected.
3. **AI state**: Ensure AI clients restart cleanly after rewind.
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
5. **Authorization**: In production, require admin authentication.
---
## Implementation Phases
### Phase 1: Web UI Foundation
**Status: Complete**
1. ✅ Set up Go templates with `embed`
2. ✅ Add Pico CSS and htmx
3. ✅ Create base layout with navigation
4. ✅ Convert `/games` to HTML with styling
5. ✅ Add game detail page with history table
6. ✅ Implement htmx infinite scroll for history
7. ✅ Reverse history order (most recent first)
8. ✅ Clickable action rows that expand to show JSON representation
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
**Deliverable**: Browsable game list and history in HTML with clickable action details
### Phase 2: Settings Management
**Status: Complete**
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
3. ✅ Implement `getSettings` in `EagleServiceImpl`
4. ✅ Create settings list page with live search
5. ✅ Add inline editing with htmx
6. ✅ Modified settings are highlighted
**Deliverable**: View and edit settings via admin UI
### Phase 3: Game Rewind (2-3 days)
1. Add `RewindGame` to `eagle.proto`
2. Implement `stateAt` and `truncateTo` in `GameHistory`
3. Implement rewind logic in `GameController`
4. Add rewind confirmation modal
5. Handle client disconnection gracefully
6. Add rewind button to history rows
**Deliverable**: Rewind games to any previous action
### Phase 4: Polish (1-2 days)
1. Add loading states and error handling
2. Improve action history display (type icons, summaries)
3. Add settings categories and filtering
4. Add basic auth (optional)
5. Documentation
---
## Security Notes
The admin server is intended for local/trusted network use only. For production:
1. **Do not expose to public internet** without authentication
2. Consider adding HTTP Basic Auth or OAuth
3. Run on internal network or behind VPN
4. Log all admin actions for audit trail
---
## Open Questions
1. **Settings persistence**: Should we add optional persistence to disk/database?
2. **Game snapshots**: Should rewind create a backup first?
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
@@ -24,9 +24,6 @@ using std::scoped_lock;
using std::string;
using std::unique_lock;
using std::weak_ptr;
using std::chrono::duration;
static constexpr duration kWaitForUpdatesDuration = std::chrono::milliseconds(5000);
using net::eagle0::shardok::common::GameStatus;
@@ -278,15 +275,9 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
awrs = engine->GetGameHistory(startingActionId);
incomingRegistrations--;
// If the current player is an AI, wait for some results to post. Otherwise go ahead and
// return, we might be telling the caller about available commands.
if (awrs.empty() && !engine->GameIsOver() &&
engine->GetPlayerInfos()[engine->GetCurrentPlayerId()].is_ai()) {
updateCondition.wait_for(guard, kWaitForUpdatesDuration);
incomingRegistrations++;
awrs = engine->GetGameHistory(startingActionId);
incomingRegistrations--;
}
// Note: Previously this had a 5-second wait for AI players to support long-polling.
// With streaming (WaitForUpdatesAndPush), the caller already waits for updates,
// so this wait is no longer needed. GetGameStatus is deprecated in favor of streaming.
updates.mainResults.reserve(awrs.size());
std::ranges::transform(
@@ -406,12 +397,10 @@ auto ShardokGameController::WaitForUpdatesAndPush(
}
// Lock released - GetUpdates will acquire its own lock
if (gameOver) {
subscriber->OnGameOver(gameOverInfo);
return true;
}
// Get updates outside the lock (GetUpdates acquires masterLock internally)
// IMPORTANT: Send any pending updates FIRST, including the final actions
// that caused the game to end. Previously, we returned early on gameOver
// without sending these final updates, causing the client to never see
// the last few battle actions.
AllUpdates updates = GetUpdates(lastPushedActionId);
lastPushedActionId = updates.newUnfilteredCount;
@@ -422,6 +411,12 @@ auto ShardokGameController::WaitForUpdatesAndPush(
updates.newUnfilteredCount,
updates.currentGameState);
}
// Now send gameOver notification after all updates have been sent
if (gameOver) {
subscriber->OnGameOver(gameOverInfo);
return true;
}
}
return false; // Subscriber disconnected
@@ -322,23 +322,6 @@ void EagleInterfaceImpl::PopulateGameStatusResponse(
}
}
auto EagleInterfaceImpl::GetGameStatus(
ServerContext * /*context*/,
const GameStatusRequest *request,
GameStatusResponse *response) -> Status {
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
} catch (NewGameException &e) { return e.GetStatus(); }
PopulateGameStatusResponse(
controller,
request->game_setup_info().known_result_count(),
response);
return Status::OK;
}
auto EagleInterfaceImpl::GetHexMap(
ServerContext * /*context*/,
const HexMapRequest *request,
@@ -31,7 +31,6 @@ namespace shardok {
using grpc::ServerContext;
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;
@@ -68,10 +67,6 @@ public:
ServerContext* context,
const PlacementCommandsRequest* request,
GameStatusResponse* response) -> Status override;
auto GetGameStatus(
ServerContext* context,
const GameStatusRequest* request,
GameStatusResponse* response) -> Status override;
auto GetHexMap(ServerContext* context, const HexMapRequest* request, HexMapResponse* response)
-> Status override;
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using EagleGUIUtils;
@@ -6,6 +6,7 @@ using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
@@ -14,16 +15,44 @@ namespace eagle {
public class ImproveCommandSelector : CommandSelector {
public HeroDropdownController heroDropdownController;
public TMP_Dropdown typeDropdown;
public Toggle lockToggle;
// Toggle buttons for each improvement type (assign to same ToggleGroup in Unity)
public Toggle economyToggle;
public Toggle agricultureToggle;
public Toggle infrastructureToggle;
public Toggle devastationToggle;
public ToggleGroup improvementToggleGroup;
// Labels showing type name and value
public TMP_Text economyLabel;
public TMP_Text agricultureLabel;
public TMP_Text infrastructureLabel;
public TMP_Text devastationLabel;
List<HeroView> orderedHeroes;
private int SelectedTypeIndex => typeDropdown.value;
private ImproveAvailableCommand ImproveAvailableCommand => _availableCommand.ImproveCommand;
private ProvinceId ActingProvinceId => ImproveAvailableCommand.ActingProvinceId;
private ImprovementType SelectedType =>
ImproveAvailableCommand.AvailableTypes[SelectedTypeIndex];
private ImprovementType SelectedType {
get {
if (economyToggle != null && economyToggle.isOn && economyToggle.interactable)
return ImprovementType.Economy;
if (agricultureToggle != null && agricultureToggle.isOn &&
agricultureToggle.interactable)
return ImprovementType.Agriculture;
if (infrastructureToggle != null && infrastructureToggle.isOn &&
infrastructureToggle.interactable)
return ImprovementType.Infrastructure;
if (devastationToggle != null && devastationToggle.isOn &&
devastationToggle.interactable)
return ImprovementType.Devastation;
// Fallback: return first available type
return ImproveAvailableCommand.AvailableTypes.FirstOrDefault();
}
}
private float OriginalImprovementValueForType(ImprovementType type) {
var province = _model.Provinces[ActingProvinceId];
@@ -57,60 +86,139 @@ namespace eagle {
}
}
private int MinimumImprovementIndex(ProvinceView province) {
var minIndex = 0;
float minValue =
OriginalImprovementValueForType(ImproveAvailableCommand.AvailableTypes[0]);
private ImprovementType GetMinimumImprovementType() {
ImprovementType minType = ImproveAvailableCommand.AvailableTypes[0];
float minValue = OriginalImprovementValueForType(minType);
for (int i = 1; i < ImproveAvailableCommand.AvailableTypes.Count; i++) {
float thisVal =
OriginalImprovementValueForType(ImproveAvailableCommand.AvailableTypes[i]);
var thisType = ImproveAvailableCommand.AvailableTypes[i];
float thisVal = OriginalImprovementValueForType(thisType);
if (thisVal < minValue) {
minIndex = i;
minType = thisType;
minValue = thisVal;
}
}
return minIndex;
return minType;
}
private void ChooseDefaultImprovement(ProvinceView province) {
var devastationIndex =
ImproveAvailableCommand.AvailableTypes.IndexOf(ImprovementType.Devastation);
if (devastationIndex == -1) {
typeDropdown.value = MinimumImprovementIndex(province);
private ImprovementType GetDefaultImprovementType() {
// Prefer devastation if available
if (ImproveAvailableCommand.AvailableTypes.Contains(ImprovementType.Devastation)) {
return ImprovementType.Devastation;
}
// Otherwise pick the lowest stat
return GetMinimumImprovementType();
}
private Toggle GetToggleForType(ImprovementType type) {
switch (type) {
case ImprovementType.Economy: return economyToggle;
case ImprovementType.Agriculture: return agricultureToggle;
case ImprovementType.Infrastructure: return infrastructureToggle;
case ImprovementType.Devastation: return devastationToggle;
default: return null;
}
}
private TMP_Text GetLabelForType(ImprovementType type) {
switch (type) {
case ImprovementType.Economy: return economyLabel;
case ImprovementType.Agriculture: return agricultureLabel;
case ImprovementType.Infrastructure: return infrastructureLabel;
case ImprovementType.Devastation: return devastationLabel;
default: return null;
}
}
private void SelectType(ImprovementType type) {
// Turn on the selected toggle - ToggleGroup handles turning off others
var toggle = GetToggleForType(type);
if (toggle != null && toggle.interactable) { toggle.isOn = true; }
}
private const float DisabledAlpha = 0.35f;
private void ConfigureToggle(ImprovementType type, bool available) {
var toggle = GetToggleForType(type);
var label = GetLabelForType(type);
if (toggle == null) return;
// Always show the toggle, but disable if not available
toggle.gameObject.SetActive(true);
toggle.interactable = available;
// Only add to toggle group if available
toggle.group = available ? improvementToggleGroup : null;
// Gray out unavailable toggles using CanvasGroup
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
// Always show label with current value
if (label != null) { label.text = LabelStringForType(type); }
}
private string LabelStringForType(ImprovementType type) {
var originalStat =
type == ImprovementType.Devastation
? ProvinceStatUtils.RoundedDevastation(
OriginalImprovementValueForType(type))
: ProvinceStatUtils.RoundedStat(OriginalImprovementValueForType(type));
if (type == ImprovementType.Devastation) {
return GUIUtils.ColoredString(Color.red, originalStat.ToString());
}
var devastatedStat =
ProvinceStatUtils.RoundedStat(EffectiveImprovementValueForType(type));
if (devastatedStat == originalStat) {
return $"{devastatedStat}";
} else {
typeDropdown.value = devastationIndex;
return $"{GUIUtils.ColoredString(Color.red, devastatedStat.ToString())} / {originalStat}";
}
}
protected override void SetUpUI() {
var province = _model.Provinces[ImproveAvailableCommand.ActingProvinceId];
var improveCommand = ImproveAvailableCommand;
typeDropdown.ClearOptions();
orderedHeroes = ImproveAvailableCommand.AvailableHeroIds.Select(id => _model.Heroes[id])
.ToList();
heroDropdownController.AvailableHeroes = orderedHeroes;
heroDropdownController.SelectedHeroId = improveCommand.RecommendedHeroId;
typeDropdown.AddOptions(
improveCommand.AvailableTypes.Select(DropdownStringForType).ToList());
// Configure each toggle based on available types
ConfigureToggle(
ImprovementType.Economy,
improveCommand.AvailableTypes.Contains(ImprovementType.Economy));
ConfigureToggle(
ImprovementType.Agriculture,
improveCommand.AvailableTypes.Contains(ImprovementType.Agriculture));
ConfigureToggle(
ImprovementType.Infrastructure,
improveCommand.AvailableTypes.Contains(ImprovementType.Infrastructure));
ConfigureToggle(
ImprovementType.Devastation,
improveCommand.AvailableTypes.Contains(ImprovementType.Devastation));
// Determine which type to select
ImprovementType selectedType;
if (improveCommand.LockedType != ImprovementType.None) {
var lockedType = improveCommand.LockedType;
var lockedTypeIndex = improveCommand.AvailableTypes.IndexOf(lockedType);
if (lockedTypeIndex > -1) {
typeDropdown.value = lockedTypeIndex;
if (improveCommand.AvailableTypes.Contains(lockedType)) {
selectedType = lockedType;
lockToggle.isOn = true;
} else {
ChooseDefaultImprovement(province);
selectedType = GetDefaultImprovementType();
lockToggle.isOn = false;
}
} else {
ChooseDefaultImprovement(province);
selectedType = GetDefaultImprovementType();
lockToggle.isOn = false;
}
SelectType(selectedType);
}
public override AvailableCommand.SealedValueOneofCase CommandType =>
@@ -137,23 +245,5 @@ namespace eagle {
LockType = lockToggle.isOn
}
};
private string DropdownStringForType(ImprovementType type) {
var originalStat =
type == ImprovementType.Devastation
? ProvinceStatUtils.RoundedDevastation(
OriginalImprovementValueForType(type))
: ProvinceStatUtils.RoundedStat(OriginalImprovementValueForType(type));
if (type == ImprovementType.Devastation) {
return $"{type} ({GUIUtils.ColoredString(UnityEngine.Color.red, originalStat.ToString())})";
}
var devastatedStat =
ProvinceStatUtils.RoundedStat(EffectiveImprovementValueForType(type));
if (devastatedStat == originalStat) {
return $"{type} ({devastatedStat})";
} else {
return $"{type} ({GUIUtils.ColoredString(UnityEngine.Color.red, devastatedStat.ToString())} / {originalStat})";
}
}
}
}
}
@@ -311,20 +311,36 @@ namespace eagle {
// Store server-reported game status for UI
if (updateItem.ActionResultResponse.ServerGameStatus != null) {
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
_connectionLogger.LogLine(
$"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
}
// 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 !=
_currentModel.CommandToken) {
var hasResults = updateItem.ActionResultResponse.ActionResultViews.Any();
var hasCommands = updateItem.ActionResultResponse.AvailableCommands != null;
var incomingToken =
hasCommands ? updateItem.ActionResultResponse.AvailableCommands.Token
: -1;
var tokenMatches = hasCommands && incomingToken == _currentModel.CommandToken;
_connectionLogger.LogLine(
$"[UPDATE] hasResults={hasResults}, hasCommands={hasCommands}, " +
$"incomingToken={incomingToken}, currentToken={_currentModel.CommandTokenString}, " +
$"tokenMatches={tokenMatches}");
if (hasResults || !hasCommands || !tokenMatches) {
HandleUpdates(updateItem.ActionResultResponse.ActionResultViews.ToList());
HandleAvailableCommands(updateItem.ActionResultResponse.AvailableCommands);
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
_connectionLogger.LogLine(
"[UPDATE] Processed update and invoked UpdateAction");
} else {
_connectionLogger.LogLine(
"[UPDATE] SKIPPED - no results, has commands, token matches");
}
break;
@@ -332,6 +348,10 @@ namespace eagle {
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
var specResponse = updateItem.ShardokActionResultResponse.ShardokGameResponses;
// Collect ended games to remove AFTER the UI callback, so the UI
// can see the final battle results before the models are removed.
var endedGames = new List<ShardokGameId>();
foreach (var oneResponse in specResponse) {
if (!_currentModel.ShardokGameModels.TryGetValue(
oneResponse.ShardokGameId,
@@ -372,14 +392,22 @@ namespace eagle {
_currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
shardokGameModel;
} else {
// Game ended - remove from active models so UI knows battle is over
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
// Game ended - keep model for UI callback, mark for removal after
_currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
shardokGameModel;
endedGames.Add(oneResponse.ShardokGameId);
}
}
// Invoke UI callback BEFORE removing ended games, so the UI can
// see the final battle results (with GameStatus = Victory/Defeat)
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
// Now remove ended games from active models
foreach (var endedGameId in endedGames) {
_currentModel.ShardokGameModels.TryRemove(endedGameId, out _);
_shardokResultCounts.TryRemove(endedGameId, out _);
}
break;
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
@@ -36,10 +36,10 @@ namespace eagle.Notifications.ARNNotifications {
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s hero {{HeroName}}";
}
textTemplate = $"{executingFactionName} has executed {victimDescription}!";
textTemplate = $"{executingFactionName} has executed {victimDescription}!\n\n";
heroPlaceholders["HeroName"] = (hero.NameTextId, "the hero");
} else {
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!";
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!\n\n";
heroPlaceholders["ExecutedHero"] = (hero.NameTextId, "the prisoner");
}
@@ -44,6 +44,18 @@ namespace eagle {
private readonly Logger _remoteEagleClientLogger = Logger.GetLogger("ConnectionLogger");
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
// Counter to diagnose duplicate logging - if logs show same seq# twice, two threads
// are processing same message. If seq# increments but lines doubled, Logger issue.
private int _logFlowSeq = 0;
/// <summary>Timestamped log for connection flow tracing.</summary>
private void LogFlow(string message) {
var seq = Interlocked.Increment(ref _logFlowSeq);
var ts = DateTime.UtcNow.ToString("HH:mm:ss.fff");
_remoteEagleClientLogger.LogLine($"[FLOW {ts}] #{seq} {message}");
}
private volatile bool _isConnecting = false;
private Timer _idleCheckTimer = null;
private Timer _heartbeatTimer = null;
@@ -77,7 +89,23 @@ namespace eagle {
_retryTimer?.Dispose();
NextReconnectAttempt = null;
LogConnectionEvent("force_reconnect", "User requested immediate reconnect");
Task.Run(() => Connect());
RunConnectAsync();
}
/// <summary>
/// Fire-and-forget wrapper for Connect() that ensures exceptions are logged
/// rather than silently swallowed by Task.Run().
/// </summary>
private void RunConnectAsync() {
Task.Run(async () => {
try {
await Connect();
} catch (Exception e) {
// Log but don't rethrow - this is fire-and-forget
Console.WriteLine($"[CONNECT] Exception in Connect: {e}");
_remoteEagleClientLogger.LogLine($"Exception in Connect: {e}");
}
});
}
private DateTime? GetDeadlineFromNow() {
@@ -122,6 +150,7 @@ namespace eagle {
_currentState = ConnectionState.Reconnecting;
NextReconnectAttempt = DateTime.UtcNow.AddSeconds(backoffSeconds);
LogFlow($"SCHEDULE_RECONNECT reason={reason} backoff={backoffSeconds:F1}s attempt={_consecutiveFailures}");
LogConnectionEvent(
"schedule_reconnect",
$"{reason}, backoff={backoffSeconds:F1}s, attempt={_consecutiveFailures}");
@@ -130,7 +159,7 @@ namespace eagle {
_retryTimer = new Timer();
_retryTimer.AutoReset = false;
_retryTimer.Interval = backoffSeconds * 1000;
_retryTimer.Elapsed += (sender, args) => Task.Run(() => Connect());
_retryTimer.Elapsed += (sender, args) => RunConnectAsync();
_retryTimer.Enabled = true;
}
@@ -149,6 +178,7 @@ namespace eagle {
private readonly Dictionary<EagleGameId, TaskCompletionSource<SubscriptionAck>>
_pendingSubscriptionAcks = new();
private const int SubscriptionAckTimeoutMs = 10000; // 10 seconds
private const int WriteAsyncTimeoutMs = 10000; // 10 seconds for WriteAsync operations
public PersistentClientConnection(
Eagle.EagleClient grpcClient,
@@ -194,6 +224,8 @@ namespace eagle {
var ackTcs = new TaskCompletionSource<SubscriptionAck>();
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks[gameId] = ackTcs; }
var shardokCount = shardokStatuses.Count();
LogFlow($"SUBSCRIBE game={gameId} unfilteredCount={subscriber.LastUnfilteredResultCount} shardokGames={shardokCount}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Sending StreamGameRequest for game {gameId}, " +
$"unfilteredCount={subscriber.LastUnfilteredResultCount}");
@@ -233,6 +265,7 @@ namespace eagle {
var ack = await ackTask;
if (ack.Success) {
LogFlow($"SUBSCRIBE_ACK game={gameId} success=true confirmedCount={ack.ConfirmedResultCount}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Subscription confirmed for game {gameId}, " +
$"confirmedResultCount={ack.ConfirmedResultCount}");
@@ -244,6 +277,7 @@ namespace eagle {
return true;
} else {
LogFlow($"SUBSCRIBE_ACK game={gameId} success=false error={ack.ErrorMessage}");
LogConnectionEvent(
"subscribe_ack_failed",
$"game={gameId}, error={ack.ErrorMessage}");
@@ -268,23 +302,24 @@ namespace eagle {
public async Task Connect() {
// Prevent concurrent connection attempts
if (_isConnecting) {
_remoteEagleClientLogger.LogLine($"Connect() skipped - already connecting");
LogFlow($"Connect() SKIPPED already_connecting=true");
return;
}
// Check circuit breaker
if (!_circuitBreaker.ShouldAttemptConnection()) {
_remoteEagleClientLogger.LogLine(
$"[CIRCUIT] Connection attempt blocked - circuit {_circuitBreaker.CurrentState}");
LogFlow($"Connect() BLOCKED circuit={_circuitBreaker.CurrentState}");
LogConnectionEvent("connect_blocked", $"circuit={_circuitBreaker.CurrentState}");
return;
}
_isConnecting = true;
try {
LogFlow($"Connect() START failures={_consecutiveFailures}");
_lastConnectAttempt = DateTime.UtcNow;
_currentState = _consecutiveFailures > 0 ? ConnectionState.Reconnecting
: ConnectionState.Connecting;
LogFlow($"State -> {_currentState}");
NextReconnectAttempt = null;
LogConnectionEvent("connect_attempt");
@@ -318,8 +353,10 @@ namespace eagle {
// Stream subscriptions OUTSIDE lock (can await)
// Set state to SubscriptionPending while waiting for acks
LogFlow($"Streaming {subscribersToStream.Count} subscriptions");
if (subscribersToStream.Any()) {
_currentState = ConnectionState.SubscriptionPending;
LogFlow($"State -> {_currentState}");
}
bool allSucceeded = true;
@@ -330,6 +367,7 @@ namespace eagle {
if (!allSucceeded) {
// Subscription failed - this is critical because the game won't receive
// updates. Trigger reconnect rather than proceeding to Connected state.
LogFlow("SUBSCRIBE_FAILED triggering reconnect");
LogConnectionEvent(
"subscribe_failed",
"Subscription failed - triggering reconnect");
@@ -353,6 +391,7 @@ namespace eagle {
_lastSuccessfulConnect = DateTime.UtcNow;
_consecutiveFailures = 0; // Reset backoff on successful connection
_currentState = ConnectionState.Connected;
LogFlow($"State -> {_currentState}");
_circuitBreaker.RecordSuccess();
LogConnectionEvent("connect_success");
@@ -387,6 +426,18 @@ namespace eagle {
_pendingCommands.Add(pendingCommands.Dequeue());
}
}
// Clean up resources before reconnecting
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
// Schedule reconnect - without this, the connection dies silently
ScheduleReconnect($"ConnectFailed-{e.GetType().Name}");
}
} finally { _isConnecting = false; }
}
@@ -424,26 +475,35 @@ namespace eagle {
lock (this) { _pendingCommands.Add(nextCommand); }
}
} else {
// CurrentEagleToken is null - server hasn't sent us
// commands yet, or we skipped them. Retry the pending
// command anyway; if the server already processed it, it
// will be ignored as duplicate.
_remoteEagleClientLogger.LogLine(
"No matching command for token " + providedToken);
$"No current token, retrying pending command with token {providedToken}");
await PostRequest(nextCommand);
}
break;
case UniversalCommand.SealedValueOneofCase.ShardokCommand: {
Int64 providedShardokToken =
nextCommand.Command.ShardokCommand.ShardokToken;
if (subscriber.CurrentShardokToken(
nextCommand.Command.ShardokCommand.GameId)
is Int64 shardokToken &&
var currentShardokToken = subscriber.CurrentShardokToken(
nextCommand.Command.ShardokCommand.GameId);
if (currentShardokToken is Int64 shardokToken &&
shardokToken == providedShardokToken) {
_remoteEagleClientLogger.LogLine(
"Found a matching pending command with token " +
shardokToken);
await PostRequest(nextCommand);
} else if (currentShardokToken == null) {
// No current token - retry anyway
_remoteEagleClientLogger.LogLine(
$"No current Shardok token, retrying pending command with token {providedShardokToken}");
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
"No matching command for token " +
providedShardokToken);
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
}
break;
@@ -453,25 +513,27 @@ namespace eagle {
Int64 providedShardokToken =
nextCommand.Command.ShardokPlacementCommands
.ShardokToken;
if (subscriber.CurrentShardokToken(
nextCommand.Command.ShardokCommand.GameId)
is Int64 shardokToken &&
var currentShardokToken = subscriber.CurrentShardokToken(
nextCommand.Command.ShardokPlacementCommands.GameId);
if (currentShardokToken is Int64 shardokToken &&
shardokToken == providedShardokToken) {
_remoteEagleClientLogger.LogLine(
"Found a matching pending command with token " +
"Found a matching pending placement command with token " +
shardokToken);
await PostRequest(nextCommand);
} else if (currentShardokToken == null) {
// No current token - retry anyway
_remoteEagleClientLogger.LogLine(
$"No current Shardok token, retrying pending placement command with token {providedShardokToken}");
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
"No matching command for token " +
providedShardokToken);
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
}
break;
}
}
await PostRequest(nextCommand);
}
}
@@ -590,13 +652,25 @@ namespace eagle {
try {
lock (this) { _pendingCommands.Add(request); }
await DoWithStreamingCall(async (streamingCall) => {
var success = await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream.WriteAsync(
new UpdateStreamRequest { PostCommandRequest = request });
return true;
});
lock (this) { _pendingCommands.Remove(request); }
} catch (Exception e) { Console.WriteLine("Failed to post command: " + e); }
// Only remove from pending if successfully sent.
// If connection was dead, leave in queue for retry after reconnect.
if (success) {
lock (this) { _pendingCommands.Remove(request); }
} else {
_remoteEagleClientLogger.LogLine(
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
}
} catch (Exception e) {
Console.WriteLine("Failed to post command: " + e);
_remoteEagleClientLogger.LogLine($"[POST] Exception posting command: {e.Message}");
// Leave in _pendingCommands for retry after reconnect
}
}
private async Task
@@ -673,9 +747,38 @@ namespace eagle {
private delegate Task<bool> WithStreamingCallDelegate(
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> call);
/// <summary>
/// Execute an action with the streaming call, with a timeout to prevent indefinite
/// blocking. If the action takes longer than WriteAsyncTimeoutMs, we consider the
/// connection dead and return false. This prevents thread pool exhaustion on dead
/// connections.
/// </summary>
private async Task<bool> DoWithStreamingCall(WithStreamingCallDelegate action) {
var sc = _streamingCall;
if (sc == null) return false;
try {
return await action(_streamingCall);
using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
var actionTask = action(sc);
var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);
var completedTask =
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
// WriteAsync timed out - connection is likely dead
LogConnectionEvent(
"write_timeout",
$"WriteAsync timed out after {WriteAsyncTimeoutMs}ms");
_remoteEagleClientLogger.LogLine(
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, connection may be dead");
return false;
}
// Cancel the timeout task since action completed
timeoutCts.Cancel();
return await actionTask.ConfigureAwait(false);
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
@@ -683,9 +786,15 @@ namespace eagle {
} else {
throw;
}
} catch (OperationCanceledException) {
// Timeout was triggered
return false;
}
}
/// <summary>
/// Send a request on the streaming call with a timeout to prevent indefinite blocking.
/// </summary>
public async Task<bool> SendUpdateStreamRequestAsync(UpdateStreamRequest request) {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc = null;
lock (this) {
@@ -694,7 +803,30 @@ namespace eagle {
}
try {
await sc.RequestStream.WriteAsync(request, _cancellationToken);
using var timeoutCts = new CancellationTokenSource(WriteAsyncTimeoutMs);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
_cancellationToken,
timeoutCts.Token);
var writeTask = sc.RequestStream.WriteAsync(request, linkedCts.Token);
var timeoutTask = Task.Delay(WriteAsyncTimeoutMs, timeoutCts.Token);
var completedTask =
await Task.WhenAny(writeTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
// WriteAsync timed out - connection is likely dead
LogConnectionEvent(
"write_timeout",
$"SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
_remoteEagleClientLogger.LogLine(
$"[WRITE_TIMEOUT] SendUpdateStreamRequestAsync timed out after {WriteAsyncTimeoutMs}ms");
return false;
}
// Cancel the timeout task since write completed
timeoutCts.Cancel();
await writeTask.ConfigureAwait(false); // Observe any exception
return true;
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
@@ -703,6 +835,9 @@ namespace eagle {
} else {
throw;
}
} catch (OperationCanceledException) {
// Timeout was triggered
return false;
}
}
@@ -714,6 +849,7 @@ namespace eagle {
private void HandleGameUpdate(GameUpdate gameUpdate, DateTime receivedTime) {
switch (gameUpdate.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
LogFlow($"UPDATE ErrorResponse game={gameUpdate.GameId}");
_remoteEagleClientLogger.LogLine("Got an error response!");
MainQueue.Q.Enqueue(() => {
lock (this) { _subscribers.Remove(gameUpdate.GameId); }
@@ -736,16 +872,63 @@ namespace eagle {
break;
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
var arResp = gameUpdate.ActionResultResponse;
var arCount = arResp.ActionResultViews?.Count ?? 0;
var hasStartingState = gameUpdate.StartingState != null;
var hasCommands = arResp.AvailableCommands != null;
var cmdToken = hasCommands ? arResp.AvailableCommands.Token : -1;
var cmdCount = hasCommands
? arResp.AvailableCommands.CommandsByProvince?.Count ?? 0
: 0;
var status = arResp.ServerGameStatus?.Status.ToString() ?? "null";
LogFlow($"UPDATE ActionResult game={gameUpdate.GameId} countAfter={arResp.UnfilteredResultCountAfter} " +
$"actionResults={arCount} startingState={hasStartingState} hasCommands={hasCommands} " +
$"token={cmdToken} cmdProvinces={cmdCount} status={status}");
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
// This ensures reconnects use accurate counts even when MainQueue is blocked
// (e.g., when Unity is backgrounded).
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub)) {
sub.UpdateResultCounts(gameUpdate);
}
}
MainQueue.Q.Enqueue(async () => {
IClientConnectionSubscriber subscriber;
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out subscriber)) {
subscriber.ReceiveGameUpdate(gameUpdate);
} else {
_subscribers.Remove(gameUpdate.GameId);
}
}
await TryPendingCommands();
var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
if (processTime > 100.0) {
_timingsLogger.LogLine($"PROCESS {processTime} ms");
}
});
break;
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
var shardokResp = gameUpdate.ShardokActionResultResponse;
var shardokGameCount = shardokResp.ShardokGameResponses?.Count ?? 0;
// Log each Shardok game's sequence to detect missing results
foreach (var sgr in shardokResp.ShardokGameResponses) {
var resultCount = sgr.ActionResultViews?.Count ?? 0;
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} " +
$"shardok={sgr.ShardokGameId} countAfter={sgr.NewResultViewCount} " +
$"results={resultCount}");
}
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub2)) {
sub2.UpdateResultCounts(gameUpdate);
}
}
MainQueue.Q.Enqueue(async () => {
IClientConnectionSubscriber subscriber;
lock (this) {
@@ -768,6 +951,7 @@ namespace eagle {
}
private async void HandleStreamingCall() {
LogFlow("HandleStreamingCall STARTED");
try {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc;
CancellationToken threadToken;
@@ -883,6 +1067,10 @@ namespace eagle {
waitStartTime = DateTime.UtcNow;
}
// While loop exited normally (not via exception)
var scNull = sc == null;
var tokenCancelled = _currentThreadToken.IsCancellationRequested;
LogFlow($"HandleStreamingCall ENDED normally: sc_null={scNull} token_cancelled={tokenCancelled}");
_remoteEagleClientLogger.LogLine(
"How did we get here? This is not my beautiful wife!");
} catch (RpcException e) {
@@ -949,6 +1137,19 @@ namespace eagle {
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("ObjectDisposed");
} catch (Exception e) {
// Catch-all for any unexpected exceptions. Without this, exceptions in an
// async void method can crash the process or be silently lost, leaving the
// connection dead with no recovery.
_lastDisconnect = DateTime.UtcNow;
LogConnectionEvent("disconnect", $"UnexpectedException: {e.GetType().Name}");
_remoteEagleClientLogger.LogLine(
$"UNEXPECTED exception in HandleStreamingCall: {e}");
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect($"UnexpectedException-{e.GetType().Name}");
}
return;
@@ -982,7 +1183,17 @@ namespace eagle {
AutoReset = true,
Interval = 5000 // Check every 5 seconds
};
_idleCheckTimer.Elapsed += (sender, args) => CheckForIdleTimeout();
_idleCheckTimer.Elapsed += (sender, args) => {
try {
// Log BEFORE callback to confirm timer is firing
Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
CheckForIdleTimeout();
} catch (Exception e) {
// Timer callbacks must not throw - it can stop the timer from firing again
Console.WriteLine($"[IDLE_TIMER] Exception in callback: {e}");
_remoteEagleClientLogger.LogLine($"Exception in idle check timer: {e}");
}
};
_idleCheckTimer.Enabled = true;
}
@@ -995,10 +1206,16 @@ namespace eagle {
}
private void CheckForIdleTimeout() {
if (_cancellationToken.IsCancellationRequested) { return; }
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
// Log every check when idle > 5s to diagnose timer issues
if (idleTime > 5.0) {
_remoteEagleClientLogger.LogLine(
$"[IDLE_CHECK] idle_time={idleTime:F1}s, cancelled={_cancellationToken.IsCancellationRequested}");
}
if (_cancellationToken.IsCancellationRequested) { return; }
// Early warnings at 10s and 20s to help diagnose slow connections
if (idleTime > 20.0 && _lastIdleWarningLevel < 2) {
_lastIdleWarningLevel = 2;
@@ -1021,7 +1238,7 @@ namespace eagle {
if (toCancel != null) {
toCancel.Dispose();
lock (this) { _streamingCall = null; }
Task.Run(() => Connect());
RunConnectAsync();
}
}
}
@@ -1030,7 +1247,26 @@ namespace eagle {
StopHeartbeatTimer();
_heartbeatTimer =
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
_heartbeatTimer.Elapsed += (sender, args) => Task.Run(() => SendHeartbeat());
_heartbeatTimer.Elapsed += (sender, args) => {
try {
// Log BEFORE Task.Run to confirm timer is firing
Console.WriteLine(
$"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
Task.Run(async () => {
try {
await SendHeartbeat();
} catch (Exception e) {
// Log but don't rethrow - exceptions in Task.Run are silently lost
Console.WriteLine($"[HEARTBEAT] Exception in SendHeartbeat: {e}");
_remoteEagleClientLogger.LogLine($"Exception in SendHeartbeat: {e}");
}
});
} catch (Exception e) {
// Timer callbacks must not throw
Console.WriteLine($"[HEARTBEAT_TIMER] Exception in callback: {e}");
_remoteEagleClientLogger.LogLine($"Exception in heartbeat timer: {e}");
}
};
_heartbeatTimer.Enabled = true;
}
@@ -1043,8 +1279,16 @@ namespace eagle {
}
private async Task SendHeartbeat() {
if (_cancellationToken.IsCancellationRequested) { return; }
if (_currentState != ConnectionState.Connected) { return; }
LogFlow($"HEARTBEAT_START state={_currentState}");
if (_cancellationToken.IsCancellationRequested) {
LogFlow("HEARTBEAT_SKIP reason=cancellation_requested");
return;
}
if (_currentState != ConnectionState.Connected) {
LogFlow($"HEARTBEAT_SKIP reason=not_connected state={_currentState}");
return;
}
// Build sync status for all subscribed games
var heartbeatRequest = new HeartbeatRequest {
@@ -1078,8 +1322,9 @@ namespace eagle {
", ",
heartbeatRequest.GameSyncStatuses.Select(
g => $"{g.GameId}:{g.UnfilteredResultCount}"));
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Sent heartbeat with {heartbeatRequest.GameSyncStatuses.Count} games ({gameInfo})");
LogFlow($"HEARTBEAT_SENT games={heartbeatRequest.GameSyncStatuses.Count} ({gameInfo})");
} else {
LogFlow("HEARTBEAT_FAILED stream_unavailable");
}
}
@@ -1088,6 +1333,7 @@ namespace eagle {
private const double SyncMismatchGracePeriodSeconds = 60.0;
private void HandleHeartbeatResponse(HeartbeatResponse response) {
LogFlow($"HEARTBEAT_RESPONSE server_ts={response.ServerTimestamp}");
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
@@ -1095,6 +1341,7 @@ namespace eagle {
bool hasMismatch = false;
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} server_count={syncResult.ServerUnfilteredResultCount}");
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
@@ -1138,6 +1385,9 @@ namespace eagle {
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Schedule reconnect to resync
// Stop timers FIRST to prevent them firing during reconnection
StopHeartbeatTimer();
StopIdleCheckTimer();
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
File diff suppressed because it is too large Load Diff
@@ -1,24 +1,39 @@
using System;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using UnityEngine;
namespace common {
/// <summary>
/// Non-blocking logger that queues messages and writes them on a dedicated background thread.
/// This ensures that file I/O issues (antivirus, cloud sync, slow disk) never block the caller.
/// </summary>
public sealed class Logger : IDisposable {
private readonly StreamWriter _sw;
private readonly ConcurrentQueue<string> _messageQueue = new ConcurrentQueue<string>();
private readonly Thread _writerThread;
private readonly AutoResetEvent _messageAvailable = new AutoResetEvent(false);
private volatile bool _disposed;
private readonly string _name;
private static Dictionary<String, Logger> _loggers = new Dictionary<String, Logger>();
private static readonly object _loggersLock = new object();
public static Logger GetLogger(string name) {
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
lock (_loggersLock) {
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
}
}
private string CurrentTimeString() {
private static string CurrentTimeString() {
return DateTime.UtcNow.ToString("yyyyMMdd_HHmmss.fff");
}
private Logger(string name) {
_name = name;
var directoryPath =
Path.Combine(Application.persistentDataPath, "eagle0", "Resources", name);
@@ -26,17 +41,94 @@ namespace common {
var logFilePath = Path.Combine(directoryPath, $"{name}_{CurrentTimeString()}.txt");
_sw = new StreamWriter(logFilePath, append: true);
// Start dedicated writer thread
_writerThread = new Thread(WriterLoop) {
Name = $"Logger-{name}",
IsBackground = true // Won't prevent app exit
};
_writerThread.Start();
}
/// <summary>
/// Queue a log message. This method never blocks on file I/O.
/// </summary>
public void LogLine(string line) {
_sw.WriteLine(CurrentTimeString() + " " + line);
_sw.Flush();
if (_disposed) return;
// Format the message with timestamp immediately (captures accurate time)
var formattedMessage = CurrentTimeString() + " " + line;
_messageQueue.Enqueue(formattedMessage);
_messageAvailable.Set(); // Signal the writer thread
}
public void Close() { _sw.Close(); }
/// <summary>
/// Background thread that processes the message queue and writes to file.
/// File I/O blocking only affects this thread, not the callers of LogLine().
/// </summary>
private void WriterLoop() {
while (!_disposed) {
try {
// Wait for messages with timeout so we can check _disposed periodically
_messageAvailable.WaitOne(1000);
// Process all queued messages
while (_messageQueue.TryDequeue(out var message)) {
try {
_sw.WriteLine(message);
} catch (Exception ex) {
// File write failed - log to Console as fallback
Console.WriteLine($"[Logger-{_name}] Write failed: {ex.Message}");
}
}
// Flush after processing batch
try {
_sw.Flush();
} catch (Exception ex) {
Console.WriteLine($"[Logger-{_name}] Flush failed: {ex.Message}");
}
} catch (Exception ex) {
// Don't let any exception kill the writer thread
Console.WriteLine($"[Logger-{_name}] WriterLoop error: {ex.Message}");
}
}
// Drain remaining messages on shutdown
while (_messageQueue.TryDequeue(out var message)) {
try {
_sw.WriteLine(message);
} catch { /* ignore on shutdown */
}
}
try {
_sw.Flush();
} catch { /* ignore on shutdown */
}
}
public void Close() { Dispose(); }
public void Dispose() {
if (_sw != null) { _sw.Dispose(); }
if (_disposed) return;
_disposed = true;
// Signal writer thread to exit and wait for it
_messageAvailable.Set();
if (_writerThread != null && _writerThread.IsAlive) {
_writerThread.Join(5000); // Wait up to 5 seconds for clean shutdown
}
try {
_sw?.Dispose();
} catch { /* ignore */
}
try {
_messageAvailable?.Dispose();
} catch { /* ignore */
}
}
}
}
}
@@ -6,6 +6,7 @@ using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Net.Http;
using eagle;
using Google.Protobuf;
using Net.Eagle0.Eagle.Api;
@@ -55,9 +56,17 @@ namespace common {
relativeLocalPath);
Directory.CreateDirectory(_localPath);
// Create a client that doesn't follow redirects automatically,
// so we can retry each hop independently
var handler = new HttpClientHandler { AllowAutoRedirect = false };
// Use HTTP/2 handler for better connection reuse and multiplexing.
// YetAnotherHttpHandler doesn't follow redirects automatically,
// which is what we want so we can retry each hop independently.
var handler = new YetAnotherHttpHandler {
Http2Only = true,
// Keep connections alive to improve reliability on poor networks
Http2KeepAliveInterval = TimeSpan.FromSeconds(15),
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
Http2KeepAliveWhileIdle = true,
ConnectTimeout = TimeSpan.FromSeconds(RequestTimeoutSeconds)
};
_noRedirectClient = new HttpClient(
handler) { Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds) };
// Store auth header to add per-request only for eagle0.net
@@ -5,7 +5,7 @@
"depth": 0,
"source": "git",
"dependencies": {},
"hash": ""
"hash": "5719afbc6b1d8cfa8729f24f0fe6a0e9dfa70f3a"
},
"com.unity.2d.sprite": {
"version": "1.0.0",
@@ -3,6 +3,10 @@ load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "admin_server_lib",
srcs = ["admin_server.go"],
embedsrcs = glob([
"static/*",
"templates/*.html",
]),
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/admin_server",
visibility = ["//visibility:private"],
deps = [
@@ -1,12 +1,15 @@
// Package main provides an HTTP admin server for Eagle game management.
// It connects to the Eagle gRPC service and exposes REST endpoints.
// It connects to the Eagle gRPC service and exposes a web UI.
package main
import (
"context"
"embed"
"encoding/json"
"flag"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"strconv"
@@ -18,15 +21,59 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
var (
eagleAddr = flag.String("eagle-addr", "localhost:40032", "Eagle gRPC server address")
httpPort = flag.Int("http-port", 8080, "HTTP server port")
grpcClient eagle.EagleClient
eagleAddr = flag.String("eagle-addr", "localhost:40032", "Eagle gRPC server address")
httpPort = flag.Int("http-port", 8080, "HTTP server port")
grpcClient eagle.EagleClient
gamesTemplate *template.Template
gameDetailTemplate *template.Template
historyRowsTemplate *template.Template
actionDetailTemplate *template.Template
settingsTemplate *template.Template
settingsRowsTemplate *template.Template
)
func main() {
flag.Parse()
// Parse templates - each page gets its own template set since they all define "content"
var err error
gamesTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/games.html")
if err != nil {
log.Fatalf("Failed to parse games template: %v", err)
}
gameDetailTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/game_detail.html")
if err != nil {
log.Fatalf("Failed to parse game detail template: %v", err)
}
historyRowsTemplate, err = template.ParseFS(templatesFS, "templates/history_rows.html")
if err != nil {
log.Fatalf("Failed to parse history rows template: %v", err)
}
actionDetailTemplate, err = template.ParseFS(templatesFS, "templates/action_detail.html")
if err != nil {
log.Fatalf("Failed to parse action detail template: %v", err)
}
settingsTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/settings.html")
if err != nil {
log.Fatalf("Failed to parse settings template: %v", err)
}
settingsRowsTemplate, err = template.ParseFS(templatesFS, "templates/settings_rows.html")
if err != nil {
log.Fatalf("Failed to parse settings rows template: %v", err)
}
// Connect to Eagle gRPC server
conn, err := grpc.NewClient(*eagleAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
@@ -38,9 +85,21 @@ func main() {
grpcClient = eagle.NewEagleClient(conn)
// Serve static files
staticContent, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatalf("Failed to get static files: %v", err)
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent))))
// Set up HTTP routes
http.HandleFunc("/games", handleGames)
http.HandleFunc("/games/", handleGameHistory)
http.HandleFunc("/", handleIndex)
http.HandleFunc("/games", handleGamesPage)
http.HandleFunc("/games/", handleGameRoutes)
http.HandleFunc("/settings", handleSettingsPage)
http.HandleFunc("/settings/", handleSettingsRoutes)
http.HandleFunc("/api/games", handleGamesAPI)
http.HandleFunc("/api/games/", handleGameHistoryAPI)
http.HandleFunc("/health", handleHealth)
addr := fmt.Sprintf(":%d", *httpPort)
@@ -50,6 +109,14 @@ func main() {
}
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/games", http.StatusFound)
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
@@ -67,6 +134,7 @@ type PlayerInfo struct {
// GameInfo represents a running game with hex-formatted ID
type GameInfo struct {
GameID string `json:"game_id"`
GameIDInt int64 `json:"-"`
CurrentRound int32 `json:"current_round"`
ActionCount int32 `json:"action_count"`
Players []PlayerInfo `json:"players"`
@@ -83,23 +151,13 @@ func formatGameID(id int64) string {
return fmt.Sprintf("%x", uint64(id))
}
func handleGames(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// fetchGames gets the list of running games from Eagle
func fetchGames(ctx context.Context) ([]GameInfo, error) {
resp, err := grpcClient.GetRunningGames(ctx, &eagle.GetRunningGamesRequest{})
if err != nil {
log.Printf("Failed to get running games: %v", err)
http.Error(w, fmt.Sprintf("Failed to get running games: %v", err), http.StatusInternalServerError)
return
return nil, err
}
// Convert to JSON response with hex game IDs
games := make([]GameInfo, len(resp.Games))
for i, game := range resp.Games {
players := make([]PlayerInfo, len(game.Players))
@@ -114,28 +172,286 @@ func handleGames(w http.ResponseWriter, r *http.Request) {
}
games[i] = GameInfo{
GameID: formatGameID(game.GameId),
GameIDInt: game.GameId,
CurrentRound: game.CurrentRound,
ActionCount: game.ActionCount,
Players: players,
RunStatus: game.RunStatus,
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(GamesResponse{Games: games})
return games, nil
}
func handleGameHistory(w http.ResponseWriter, r *http.Request) {
// Page data structures
type GamesPageData struct {
Title string
Games []GameInfo
}
type GameDetailPageData struct {
Title string
Game GameInfo
}
type HistoryEntry struct {
Index int32
ActionType string
RoundId int32
}
type HistoryRowsData struct {
GameID string
Entries []HistoryEntry
HasMore bool
NextStart int32
}
type ActionDetailData struct {
GameID string
Index int32
ActionType string
RoundId int32
ActionJSON string
}
func handleGamesPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse game ID from URL: /games/{id}/history
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
games, err := fetchGames(ctx)
if err != nil {
log.Printf("Failed to get running games: %v", err)
http.Error(w, fmt.Sprintf("Failed to get running games: %v", err), http.StatusInternalServerError)
return
}
data := GamesPageData{
Title: "Games",
Games: games,
}
w.Header().Set("Content-Type", "text/html")
if err := gamesTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleGameRoutes(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/games/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Redirect(w, r, "/games", http.StatusFound)
return
}
gameIDHex := parts[0]
gameIDUint, err := strconv.ParseUint(gameIDHex, 16, 64)
if err != nil {
http.Error(w, "Invalid game ID (expected hex format)", http.StatusBadRequest)
return
}
gameID := int64(gameIDUint)
if len(parts) == 1 {
// /games/{id} - game detail page
handleGameDetailPage(w, r, gameIDHex, gameID)
} else if parts[1] == "history" {
// /games/{id}/history - history rows (htmx partial)
handleHistoryRows(w, r, gameIDHex, gameID)
} else if parts[1] == "action" && len(parts) >= 3 {
// /games/{id}/action/{index} - action detail (htmx partial)
actionIndex, err := strconv.Atoi(parts[2])
if err != nil {
http.Error(w, "Invalid action index", http.StatusBadRequest)
return
}
handleActionDetail(w, r, gameIDHex, gameID, int32(actionIndex))
} else {
http.NotFound(w, r)
}
}
func handleGameDetailPage(w http.ResponseWriter, r *http.Request, gameIDHex string, gameID int64) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Fetch games to get game info
games, err := fetchGames(ctx)
if err != nil {
log.Printf("Failed to get games: %v", err)
http.Error(w, fmt.Sprintf("Failed to get games: %v", err), http.StatusInternalServerError)
return
}
var game *GameInfo
for i := range games {
if games[i].GameIDInt == gameID {
game = &games[i]
break
}
}
if game == nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
data := GameDetailPageData{
Title: fmt.Sprintf("Game %s", gameIDHex),
Game: *game,
}
w.Header().Set("Content-Type", "text/html")
if err := gameDetailTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleHistoryRows(w http.ResponseWriter, r *http.Request, gameIDHex string, gameID int64) {
// Parse query parameters - "start" is now offset from the end (for reverse order)
offset := 0
limit := 50
if s := r.URL.Query().Get("start"); s != "" {
if v, err := strconv.Atoi(s); err == nil {
offset = v
}
}
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
limit = v
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// First, get total count by fetching with limit 0
countResp, err := grpcClient.GetGameHistory(ctx, &eagle.GetGameHistoryRequest{
GameId: gameID,
Limit: 0,
})
if err != nil {
log.Printf("Failed to get game history: %v", err)
http.Error(w, fmt.Sprintf("Failed to get game history: %v", err), http.StatusInternalServerError)
return
}
totalCount := int(countResp.TotalActionCount)
// Calculate start index for reverse order (fetch from end minus offset)
startIndex := totalCount - offset - limit
if startIndex < 0 {
limit = limit + startIndex // Reduce limit if we'd go before index 0
startIndex = 0
}
resp, err := grpcClient.GetGameHistory(ctx, &eagle.GetGameHistoryRequest{
GameId: gameID,
StartIndex: int32(startIndex),
Limit: int32(limit),
})
if err != nil {
log.Printf("Failed to get game history: %v", err)
http.Error(w, fmt.Sprintf("Failed to get game history: %v", err), http.StatusInternalServerError)
return
}
// Reverse the entries so most recent is first
entries := make([]HistoryEntry, len(resp.Entries))
for i, e := range resp.Entries {
entries[len(resp.Entries)-1-i] = HistoryEntry{
Index: e.Index,
ActionType: e.ActionType,
RoundId: e.RoundId,
}
}
nextOffset := offset + len(entries)
hasMore := nextOffset < totalCount
data := HistoryRowsData{
GameID: gameIDHex,
Entries: entries,
HasMore: hasMore,
NextStart: int32(nextOffset),
}
w.Header().Set("Content-Type", "text/html")
if err := historyRowsTemplate.Execute(w, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleActionDetail(w http.ResponseWriter, r *http.Request, gameIDHex string, gameID int64, actionIndex int32) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := grpcClient.GetActionDetail(ctx, &eagle.GetActionDetailRequest{
GameId: gameID,
ActionIndex: actionIndex,
})
if err != nil {
log.Printf("Failed to get action detail: %v", err)
http.Error(w, fmt.Sprintf("Failed to get action detail: %v", err), http.StatusInternalServerError)
return
}
data := ActionDetailData{
GameID: gameIDHex,
Index: resp.ActionIndex,
ActionType: resp.ActionType,
RoundId: resp.RoundId,
ActionJSON: resp.ActionJson,
}
w.Header().Set("Content-Type", "text/html")
if err := actionDetailTemplate.Execute(w, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
// API handlers (keep JSON endpoints for programmatic access)
func handleGamesAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
games, err := fetchGames(ctx)
if err != nil {
log.Printf("Failed to get running games: %v", err)
http.Error(w, fmt.Sprintf("Failed to get running games: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(GamesResponse{Games: games})
}
func handleGameHistoryAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse game ID from URL: /api/games/{id}/history
path := strings.TrimPrefix(r.URL.Path, "/api/games/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[1] != "history" {
http.Error(w, "Invalid path. Use /games/{id}/history", http.StatusBadRequest)
http.Error(w, "Invalid path. Use /api/games/{id}/history", http.StatusBadRequest)
return
}
@@ -177,3 +493,209 @@ func handleGameHistory(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// Settings data structures
type SettingInfo struct {
Name string `json:"name"`
SettingType string `json:"setting_type"`
CurrentValue string `json:"current_value"`
DefaultValue string `json:"default_value"`
IsModified bool `json:"is_modified"`
}
type SettingsPageData struct {
Title string
Settings []SettingInfo
Filter string
}
type SettingsRowsData struct {
Settings []SettingInfo
Filter string
}
type SettingUpdateResult struct {
Success bool `json:"success"`
Name string `json:"name"`
Value string `json:"value"`
Error string `json:"error,omitempty"`
}
func fetchSettings(ctx context.Context, filter string) ([]SettingInfo, error) {
resp, err := grpcClient.GetSettings(ctx, &eagle.GetSettingsRequest{
Filter: filter,
})
if err != nil {
return nil, err
}
settings := make([]SettingInfo, len(resp.Settings))
for i, s := range resp.Settings {
settings[i] = SettingInfo{
Name: s.Name,
SettingType: s.SettingType,
CurrentValue: s.CurrentValue,
DefaultValue: s.DefaultValue,
IsModified: s.CurrentValue != s.DefaultValue,
}
}
return settings, nil
}
func handleSettingsPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
filter := r.URL.Query().Get("filter")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
settings, err := fetchSettings(ctx, filter)
if err != nil {
log.Printf("Failed to get settings: %v", err)
http.Error(w, fmt.Sprintf("Failed to get settings: %v", err), http.StatusInternalServerError)
return
}
data := SettingsPageData{
Title: "Settings",
Settings: settings,
Filter: filter,
}
w.Header().Set("Content-Type", "text/html")
if err := settingsTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleSettingsRoutes(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/settings/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Redirect(w, r, "/settings", http.StatusFound)
return
}
if parts[0] == "search" {
// /settings/search - return filtered settings rows (htmx partial)
handleSettingsSearch(w, r)
} else if parts[0] == "update" {
// /settings/update - update a setting (htmx POST)
handleSettingUpdate(w, r)
} else {
http.NotFound(w, r)
}
}
func handleSettingsSearch(w http.ResponseWriter, r *http.Request) {
filter := r.URL.Query().Get("filter")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
settings, err := fetchSettings(ctx, filter)
if err != nil {
log.Printf("Failed to get settings: %v", err)
http.Error(w, fmt.Sprintf("Failed to get settings: %v", err), http.StatusInternalServerError)
return
}
data := SettingsRowsData{
Settings: settings,
Filter: filter,
}
w.Header().Set("Content-Type", "text/html")
if err := settingsRowsTemplate.Execute(w, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleSettingUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
name := r.FormValue("name")
value := r.FormValue("value")
if name == "" {
http.Error(w, "Setting name is required", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Use AddSettings to update the setting
_, err := grpcClient.AddSettings(ctx, &eagle.AddSettingsRequest{
Settings: []*eagle.AddSettingsKeyValue{
{Key: name, Value: value},
},
})
if err != nil {
log.Printf("Failed to update setting %s: %v", name, err)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `<span class="error">Error: %v</span>`, err)
return
}
// Return the updated row
settings, err := fetchSettings(ctx, "")
if err != nil {
log.Printf("Failed to refetch settings: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<span class="success">Updated %s to %s</span>`, name, value)
return
}
// Find the updated setting
var updated *SettingInfo
for _, s := range settings {
if s.Name == name {
updated = &s
break
}
}
if updated == nil {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<span class="success">Updated %s to %s</span>`, name, value)
return
}
// Return the single updated row HTML
w.Header().Set("Content-Type", "text/html")
modifiedClass := ""
if updated.IsModified {
modifiedClass = " modified"
}
fmt.Fprintf(w, `<tr class="setting-row%s" id="setting-%s">
<td class="setting-name">%s</td>
<td class="setting-type">%s</td>
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-%s" hx-swap="outerHTML">
<input type="hidden" name="name" value="%s">
<input type="text" name="value" value="%s" class="setting-input">
<button type="submit" class="btn-small">Save</button>
</form>
</td>
<td class="setting-default">%s</td>
</tr>`, modifiedClass, updated.Name, updated.Name, updated.SettingType, updated.Name, updated.Name, updated.CurrentValue, updated.DefaultValue)
}
@@ -0,0 +1,326 @@
/* Admin Server Styles - supplements Pico CSS */
:root {
--pico-font-size: 16px;
}
/* Navigation */
nav {
background: var(--pico-card-background-color);
padding: 1rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
nav ul {
display: flex;
gap: 1.5rem;
list-style: none;
margin: 0;
padding: 0;
align-items: center;
}
nav .brand {
font-weight: bold;
font-size: 1.25rem;
margin-right: auto;
}
/* Game cards */
.game-card {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.game-card h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.game-card .meta {
color: var(--pico-muted-color);
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
.game-card .players {
margin-bottom: 1rem;
}
.game-card .actions {
display: flex;
gap: 0.5rem;
}
/* Player badges */
.player-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
margin-right: 0.5rem;
margin-bottom: 0.25rem;
}
.player-badge.human {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.player-badge.ai {
background: var(--pico-secondary-background);
color: var(--pico-secondary-inverse);
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--pico-border-radius);
font-size: 0.75rem;
text-transform: uppercase;
}
.status-badge.running {
background: #22c55e;
color: white;
}
.status-badge.finished {
background: var(--pico-muted-color);
color: white;
}
/* History table */
.history-table {
width: 100%;
}
.history-table th,
.history-table td {
text-align: left;
padding: 0.75rem;
}
.history-table tr:hover {
background: var(--pico-card-background-color);
}
.action-type {
font-family: monospace;
font-size: 0.875rem;
}
/* Loading indicator */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--pico-muted-color);
}
/* Page header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
}
/* Breadcrumbs */
.breadcrumb {
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
margin-bottom: 1rem;
color: var(--pico-muted-color);
}
.breadcrumb a {
color: var(--pico-muted-color);
}
.breadcrumb .separator {
color: var(--pico-muted-color);
}
/* Clickable action rows */
.action-row {
cursor: pointer;
transition: background-color 0.15s ease;
}
.action-row:hover {
background: var(--pico-primary-background) !important;
color: var(--pico-primary-inverse);
}
/* Action detail panel */
.action-detail-row {
background: var(--pico-card-background-color);
}
.action-detail {
padding: 1rem;
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
margin: 0.5rem 0;
}
.action-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.action-detail .close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0 0.5rem;
color: var(--pico-muted-color);
line-height: 1;
}
.action-detail .close-btn:hover {
color: var(--pico-del-color);
}
.action-json {
background: var(--pico-code-background-color);
padding: 1rem;
border-radius: var(--pico-border-radius);
overflow-x: auto;
font-size: 0.875rem;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Settings page */
.settings-search {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.settings-search input {
flex: 1;
max-width: 400px;
margin: 0;
}
.settings-count {
color: var(--pico-muted-color);
font-size: 0.875rem;
}
.settings-table {
width: 100%;
border-collapse: collapse;
}
.settings-table th,
.settings-table td {
text-align: left;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--pico-muted-border-color);
vertical-align: middle;
}
.settings-table th {
background: var(--pico-card-background-color);
font-weight: 600;
font-size: 0.75rem;
}
.setting-name {
font-family: monospace;
font-size: 0.8rem;
}
.setting-type {
font-size: 0.7rem;
color: var(--pico-muted-color);
text-transform: uppercase;
}
.setting-value form {
display: flex;
gap: 0.25rem;
margin: 0;
align-items: center;
}
.setting-input {
width: 100px;
padding: 2px 6px !important;
margin: 0 !important;
font-size: 0.75rem;
height: 22px !important;
line-height: 1 !important;
border-width: 1px;
}
.setting-default {
font-size: 0.8rem;
color: var(--pico-muted-color);
}
.setting-row.modified {
background: rgba(255, 193, 7, 0.1);
}
.setting-row.modified .setting-name {
font-weight: bold;
}
.btn-small {
padding: 2px 8px !important;
font-size: 0.7rem;
margin: 0 !important;
height: 22px !important;
line-height: 1 !important;
}
.settings-note {
margin-top: 2rem;
padding: 1rem;
background: var(--pico-card-background-color);
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
color: var(--pico-muted-color);
}
.error {
color: var(--pico-del-color);
}
.success {
color: var(--pico-ins-color);
}
@@ -0,0 +1,11 @@
<tr class="action-detail-row">
<td colspan="3">
<div class="action-detail">
<div class="action-detail-header">
<strong>Action #{{.Index}}</strong> - {{.ActionType}} (Round {{.RoundId}})
<button class="close-btn" onclick="this.closest('tr').remove()">&times;</button>
</div>
<pre class="action-json">{{.ActionJSON}}</pre>
</div>
</td>
</tr>
@@ -0,0 +1,47 @@
{{define "content"}}
<nav class="breadcrumb">
<a href="/">Games</a>
<span class="separator">/</span>
<span>{{.Game.GameID}}</span>
</nav>
<div class="page-header">
<h1>Game {{.Game.GameID}}</h1>
<span class="status-badge {{if eq .Game.RunStatus "Running"}}running{{else}}finished{{end}}">
{{.Game.RunStatus}}
</span>
</div>
<article>
<header>
<strong>Round {{.Game.CurrentRound}}</strong> &bull; {{.Game.ActionCount}} total actions
</header>
<div class="players">
{{range .Game.Players}}
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
{{.LeaderName}} - {{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
</span>
{{end}}
</div>
</article>
<h2>Action History</h2>
<table class="history-table">
<thead>
<tr>
<th style="width: 80px">#</th>
<th>Action Type</th>
<th style="width: 120px">Round</th>
</tr>
</thead>
<tbody id="history-body"
hx-get="/games/{{.Game.GameID}}/history?start=0&limit=50"
hx-trigger="load"
hx-swap="innerHTML">
<tr>
<td colspan="3" class="htmx-indicator">Loading history...</td>
</tr>
</tbody>
</table>
{{end}}
@@ -0,0 +1,37 @@
{{define "content"}}
<div class="page-header">
<h1>Running Games</h1>
<span>{{len .Games}} game{{if ne (len .Games) 1}}s{{end}}</span>
</div>
{{if .Games}}
{{range .Games}}
<article class="game-card">
<h3>
<a href="/games/{{.GameID}}">Game {{.GameID}}</a>
<span class="status-badge {{if eq .RunStatus "Running"}}running{{else}}finished{{end}}">
{{.RunStatus}}
</span>
</h3>
<div class="meta">
Round {{.CurrentRound}} &bull; {{.ActionCount}} actions
</div>
<div class="players">
{{range .Players}}
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
{{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
</span>
{{end}}
</div>
<div class="actions">
<a href="/games/{{.GameID}}" role="button" class="outline">View History</a>
</div>
</article>
{{end}}
{{else}}
<div class="empty-state">
<p>No games currently running.</p>
<p>Start a game from the Unity client to see it here.</p>
</div>
{{end}}
{{end}}
@@ -0,0 +1,23 @@
{{range .Entries}}
<tr class="action-row"
hx-get="/games/{{$.GameID}}/action/{{.Index}}"
hx-trigger="click"
hx-target="this"
hx-swap="afterend">
<td>{{.Index}}</td>
<td class="action-type">{{.ActionType}}</td>
<td>{{.RoundId}}</td>
</tr>
{{end}}
{{if .HasMore}}
<tr hx-get="/games/{{.GameID}}/history?start={{.NextStart}}&limit=50"
hx-trigger="revealed"
hx-swap="outerHTML">
<td colspan="3" class="htmx-indicator">Loading more...</td>
</tr>
{{end}}
{{if and (not .Entries) (not .HasMore)}}
<tr>
<td colspan="3" class="empty-state">No actions recorded yet.</td>
</tr>
{{end}}
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Eagle Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<nav>
<ul>
<li class="brand">Eagle Admin</li>
<li><a href="/">Games</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/health">Health</a></li>
</ul>
</nav>
<main class="container">
{{template "content" .}}
</main>
</body>
</html>
@@ -0,0 +1,52 @@
{{define "content"}}
<h1>Settings</h1>
<div class="settings-search">
<input type="text"
id="settings-filter"
name="filter"
placeholder="Search settings..."
value="{{.Filter}}"
hx-get="/settings/search"
hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
hx-target="#settings-body"
hx-swap="innerHTML">
<span class="settings-count">{{len .Settings}} settings</span>
</div>
<table class="settings-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Current Value</th>
<th>Default</th>
</tr>
</thead>
<tbody id="settings-body">
{{range .Settings}}
<tr class="setting-row{{if .IsModified}} modified{{end}}" id="setting-{{.Name}}">
<td class="setting-name">{{.Name}}</td>
<td class="setting-type">{{.SettingType}}</td>
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
<input type="hidden" name="name" value="{{.Name}}">
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
<button type="submit" class="btn-small">Save</button>
</form>
</td>
<td class="setting-default">{{.DefaultValue}}</td>
</tr>
{{end}}
{{if not .Settings}}
<tr>
<td colspan="4" class="empty-state">No settings found.</td>
</tr>
{{end}}
</tbody>
</table>
<p class="settings-note">
<strong>Note:</strong> Settings changes are in-memory only. A server restart will reset all values to defaults.
</p>
{{end}}
@@ -0,0 +1,19 @@
{{range .Settings}}
<tr class="setting-row{{if .IsModified}} modified{{end}}" id="setting-{{.Name}}">
<td class="setting-name">{{.Name}}</td>
<td class="setting-type">{{.SettingType}}</td>
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
<input type="hidden" name="name" value="{{.Name}}">
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
<button type="submit" class="btn-small">Save</button>
</form>
</td>
<td class="setting-default">{{.DefaultValue}}</td>
</tr>
{{end}}
{{if not .Settings}}
<tr>
<td colspan="4" class="empty-state">No settings match "{{.Filter}}"</td>
</tr>
{{end}}
@@ -15,8 +15,9 @@ func usage() {
}
type Setting struct {
Name string
Type string
Name string
Type string
DefaultValue string
}
func parseSettings(buildFile string) ([]Setting, error) {
@@ -32,30 +33,38 @@ func parseSettings(buildFile string) ([]Setting, error) {
// Regex patterns to extract setting info
settingNameRegex := regexp.MustCompile(`setting_name = "([^"]+)"`)
settingTypeRegex := regexp.MustCompile(`setting_type = "([^"]+)"`)
var currentName, currentType string
settingValueRegex := regexp.MustCompile(`setting_value = "([^"]+)"`)
var currentName, currentType, currentValue string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Look for setting_name
if matches := settingNameRegex.FindStringSubmatch(line); matches != nil {
currentName = matches[1]
}
// Look for setting_type
if matches := settingTypeRegex.FindStringSubmatch(line); matches != nil {
currentType = matches[1]
}
// Look for setting_value
if matches := settingValueRegex.FindStringSubmatch(line); matches != nil {
currentValue = matches[1]
}
// When we hit the end of a scala_setting_library block, save the setting
if strings.Contains(line, ")") && currentName != "" && currentType != "" {
settings = append(settings, Setting{
Name: currentName,
Type: currentType,
Name: currentName,
Type: currentType,
DefaultValue: currentValue,
})
currentName = ""
currentType = ""
currentValue = ""
}
}
@@ -92,6 +101,30 @@ func generateSettingsLoader(settings []Setting) string {
sb.WriteString(" case _ => throw NoSuchSettingException(key)\n")
sb.WriteString(" }\n\n")
// Generate the getAllSettings method that returns current values and metadata
sb.WriteString(" case class SettingInfo(\n")
sb.WriteString(" name: String,\n")
sb.WriteString(" settingType: String,\n")
sb.WriteString(" currentValue: String,\n")
sb.WriteString(" defaultValue: String\n")
sb.WriteString(" )\n\n")
sb.WriteString(" def getAllSettings: Vector[SettingInfo] = Vector(\n")
for i, setting := range settings {
if setting.Type == "Int" {
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Int\", %s.intValue.toString, \"%s\")",
setting.Name, setting.Name, setting.DefaultValue))
} else {
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Double\", %s.doubleValue.toString, \"%s\")",
setting.Name, setting.Name, setting.DefaultValue))
}
if i < len(settings)-1 {
sb.WriteString(",")
}
sb.WriteString("\n")
}
sb.WriteString(" )\n\n")
// Add the rest of the class
sb.WriteString(` case class SettingLine(
key: String,
@@ -21,15 +21,12 @@ option java_outer_classname = "EagleInterface";
option objc_class_prefix = "E0G";
service ShardokInternalInterface {
// Server-side streaming for game updates - replaces polling via GetGameStatus
// Server-side streaming for game updates
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) {}
}
@@ -58,11 +55,6 @@ message PlacementCommandsRequest {
GameSetupInfo game_setup_info = 6;
}
message GameStatusRequest {
string game_id = 1;
GameSetupInfo game_setup_info = 2;
}
message GameStatusResponse {
string game_id = 1;
@@ -32,6 +32,8 @@ service Eagle {
// Admin endpoints
rpc GetRunningGames(GetRunningGamesRequest) returns (GetRunningGamesResponse) {}
rpc GetGameHistory(GetGameHistoryRequest) returns (GetGameHistoryResponse) {}
rpc GetActionDetail(GetActionDetailRequest) returns (GetActionDetailResponse) {}
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse) {}
}
message PostCommandRequest {
@@ -413,3 +415,31 @@ message GameHistoryEntry {
.google.protobuf.Int32Value province_id = 5;
string summary = 6; // human-readable summary of the action
}
message GetActionDetailRequest {
int64 game_id = 1;
int32 action_index = 2;
}
message GetActionDetailResponse {
int64 game_id = 1;
int32 action_index = 2;
string action_type = 3;
int32 round_id = 4;
string action_json = 5; // Full action result as JSON
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message Setting {
string name = 1;
string setting_type = 2; // "Int" or "Double"
string current_value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
}
@@ -16,4 +16,7 @@ option objc_class_prefix = "E0G";
message ChronicleEntry {
string generated_text_id = 1;
.net.eagle0.eagle.common.Date date = 2;
// The style/chronicler used when generating this entry (e.g., "J.R.R. Tolkien", "Homer")
// Used to label entries so the LLM knows which chronicler wrote each one
string chronicler_style = 3;
}
@@ -143,10 +143,15 @@ message ChronicleUpdateMessage {
message PreviousChronicleEntry {
.net.eagle0.eagle.common.Date date = 1;
string generated_text_id = 2;
// The chronicler style used for this entry (e.g., "J.R.R. Tolkien", "Homer")
string chronicler_style = 3;
}
repeated PreviousChronicleEntry previous_entries = 2;
repeated EventForChronicle new_entries = 3;
// The chronicler style to use for the new entry
string chronicler_style = 4;
}
message DivineMessage {
@@ -151,7 +151,7 @@ case class AIClient(
(actingFactionId, gameState, availableCommands, functionalRandom) =>
AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId,
gameState,
GameStateConverter.fromProto(gameState),
availableCommands,
functionalRandom
),
@@ -148,6 +148,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
],
)
@@ -199,6 +200,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
],
)
@@ -8,6 +8,7 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, LegacyProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
object MarchTowardProvinceCommandChooser {
def marchTowardProvinceCommand(
@@ -78,7 +79,7 @@ object MarchTowardProvinceCommandChooser {
takenBattalions,
gs.battalionTypes.toVector
)
val goldToTake = provinceGoldSurplus(originProvince.id, gs)
val goldToTake = provinceGoldSurplus(ProvinceConverter.fromProto(originProvince))
CommandSelection(
actingFactionId = fid,
actingProvinceId = actingProvinceId,
@@ -34,6 +34,7 @@ import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.LegacyProvinceDistances
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.views.province_view.FullProvinceInfo
@@ -152,7 +153,7 @@ object MidGameAIClient {
Math.min(actingProvince.food - foodToHoldBack, foodAvailable)
val maxGoldToSend = Math.min(
ProvinceGoldSurplusCalculator
.provinceGoldSurplus(actingProvinceId, gameState),
.provinceGoldSurplus(ProvinceConverter.fromProto(actingProvince)),
goldAvailable
)
@@ -851,6 +851,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:game_history",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:chronicle_update_prompt_generator",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/settings:empty_province_monthly_devastation_delta",
"//src/main/scala/net/eagle0/eagle/library/settings:faction_bias_minimum_adjustment_per_round",
@@ -75,9 +75,14 @@ case class CheckForFactionChangesAction(
val newLeaders =
faction.leaderIds.diff(killedHeroIds)
// Only change faction head if the current head was killed
val newFactionHeadId =
if newLeaders.contains(faction.factionHeadId) then faction.factionHeadId
else newLeaders.headOption.getOrElse(0)
faction.copy(
leaderIds = newLeaders,
factionHeadId = newLeaders.headOption.getOrElse(0)
factionHeadId = newFactionHeadId
)
}
}
@@ -211,46 +216,65 @@ case class CheckForFactionChangesAction(
case Nil => None
case fs =>
val changedFactionData = fs.map { revisedFaction =>
val originalFaction = factions.find(_.id == revisedFaction.id).get
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
val newFactionName = newHeadHero.flatMap(_.ledFactionName)
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
val originalFaction = factions.find(_.id == revisedFaction.id).get
val factionHeadChanged = originalFaction.factionHeadId != revisedFaction.factionHeadId
// Only include new head info if the head actually changed
val (newFactionHeadHeroId, newFactionName, llmRequest, notification) =
if factionHeadChanged then {
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
val newName = newHeadHero.flatMap(_.ledFactionName)
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
(
Some(revisedFaction.factionHeadId),
newName,
Some(
NewFactionHeadMessage(
requestId = llmRequestId,
eagleGameId = gameId,
recipientFactionIds = Vector.empty,
alwaysGenerate = false,
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousFactionName = originalFaction.name,
newFactionName = newName,
previousHeadHeroId = originalFaction.factionHeadId
)
),
Some(
NotificationC(
details = NotificationDetails.NewFactionHead(
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousHeadHeroId = originalFaction.factionHeadId
),
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
llm = NotificationT.Llm.Id(llmRequestId),
deferred = true
)
)
)
} else {
(None, None, None, None)
}
(
ChangedFactionC(
factionId = revisedFaction.id,
removedLeaderHeroIds = originalFaction.leaderIds.filter(killedHeroIds.contains),
newFactionHeadHeroId = Some(revisedFaction.factionHeadId),
newFactionHeadHeroId = newFactionHeadHeroId,
newName = newFactionName
),
NewFactionHeadMessage(
requestId = llmRequestId,
eagleGameId = gameId,
recipientFactionIds = Vector.empty,
alwaysGenerate = false,
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousFactionName = originalFaction.name,
newFactionName = newFactionName,
previousHeadHeroId = originalFaction.factionHeadId
),
NotificationC(
details = NotificationDetails.NewFactionHead(
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousHeadHeroId = originalFaction.factionHeadId
),
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
llm = NotificationT.Llm.Id(llmRequestId),
deferred = true
)
llmRequest,
notification
)
}.toVector
Some(
ActionResultC(
actionResultType = FactionLeaderRemovedResultType,
changedFactions = changedFactionData.map(_._1),
newGeneratedTextRequests = changedFactionData.map(_._2),
newNotifications = changedFactionData.map(_._3)
newGeneratedTextRequests = changedFactionData.flatMap(_._2),
newNotifications = changedFactionData.flatMap(_._3)
)
)
}
@@ -4,6 +4,7 @@ import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.llm_prompt_generators.ChronicleOptions
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.settings.{
EmptyProvinceMonthlyDevastationDelta,
@@ -85,12 +86,18 @@ case class NewRoundAction(
initialSequencer.withActionResult(_ => NewYearAction(gameState).immediateExecute)
else initialSequencer
// Select chronicler style using a hash that doesn't cycle predictably
val chroniclerStyle = ChronicleOptions.styles(
(gameState.gameId, newDate.year, newDate.month.value).hashCode.abs % ChronicleOptions.styles.size
)
// Add the main new round result
val afterNewRoundSequencer = afterNewYearSequencer.withActionResult { currentState =>
newRoundResult(
gs = currentState,
newRoundId = newRoundId,
newDate = newDate
newDate = newDate,
chroniclerStyle = chroniclerStyle
)
}
@@ -102,7 +109,8 @@ case class NewRoundAction(
private def chronicleLlmRequests(
newDate: Date,
gs: GameState
gs: GameState,
chroniclerStyle: String
): Vector[LlmRequestT] =
if isChronicleMonth(newDate) then
Vector(
@@ -113,7 +121,8 @@ case class NewRoundAction(
previous_entries = gs.chronicleEntries.map { entry =>
ChronicleUpdatePreviousEntry(
date = entry.date,
generatedTextId = entry.generatedTextId
generatedTextId = entry.generatedTextId,
chroniclerStyle = entry.chroniclerStyle
)
},
new_entries = ChronicleEventGenerator.eventTextEntries(
@@ -121,7 +130,8 @@ case class NewRoundAction(
since = gs.chronicleEntries.lastOption
.map(_.date)
.getOrElse(Date(year = 0, month = Date.Month.January))
)
),
chronicler_style = chroniclerStyle
)
)
else Vector()
@@ -132,7 +142,8 @@ case class NewRoundAction(
private def newRoundResult(
gs: GameState,
newRoundId: RoundId,
newDate: Date
newDate: Date,
chroniclerStyle: String
): ActionResultT = {
case class Changes(
changedProvince: ChangedProvinceC,
@@ -254,7 +265,7 @@ case class NewRoundAction(
)
}.toVector
val llmRequests = chronicleLlmRequests(newDate, gs)
val llmRequests = chronicleLlmRequests(newDate, gs, chroniclerStyle)
ActionResultC(
actionResultType = NewRoundActionResultType,
@@ -268,7 +279,8 @@ case class NewRoundAction(
newChronicleEntry = llmRequests.headOption.map { request =>
ChronicleEntry(
date = newDate,
generatedTextId = request.requestId
generatedTextId = request.requestId,
chroniclerStyle = Some(chroniclerStyle)
)
}
)
@@ -41,35 +41,51 @@ case class ChronicleUpdatePromptGenerator(
chronicleUpdateMessage: ChronicleUpdateMessage,
clientTextStore: ClientTextStore,
gameState: GameState,
randomInt: Int
randomInt: Int // Kept for backwards compatibility but no longer used
) extends LLMPromptGenerator {
private val chronicleWordCount: Int = ChronicleWordCount.intValue
private val randomStyle: String =
ChronicleOptions.styles(randomInt.abs % ChronicleOptions.styles.size)
// Use the style from the message (selected by NewRoundAction)
private val chroniclerStyle: String = chronicleUpdateMessage.chroniclerStyle
// Check if this chronicler has written any previous entries
private val previousChroniclerStyles: Set[String] =
chronicleUpdateMessage.previousEntries.map(_.chroniclerStyle).filter(_.nonEmpty).toSet
private val isSameChroniclerAsPrevious: Boolean =
previousChroniclerStyles.contains(chroniclerStyle)
private def introString(currentDate: Date): String = {
val intro =
s"""This is a fantasy setting with a medieval technology level. Multiple factions are engaged in a civil war for
|control of the kingdom. Write a $chronicleWordCount-word chronicle of the state of the war, as told by an
|in-universe chronicler, who writes in the style and format of $randomStyle (but without reproducing any
|in-universe chronicler, who writes in the style and format of $chroniclerStyle (but without reproducing any
|copyrighted material), as of ${GeneratorUtilities
.monthNames(
currentDate.month
)} of the Year of the Realm ${currentDate.year}.""".stripMargin
.replaceAll("\n", " ")
val styleGuidance =
if isSameChroniclerAsPrevious then
s"""You are the SAME chronicler who wrote some of the previous entries (marked as "Written by $chroniclerStyle").
|Continue in that same style and voice. Use the information from all previous entries.""".stripMargin
.replaceAll("\n", " ")
else
s"""Write in the style of $chroniclerStyle. Use the INFORMATION from the previous entries (names, events,
|factions, outcomes) but do not imitate the writing style of entries written by other chroniclers.""".stripMargin
.replaceAll("\n", " ")
val sampleDescription =
"""The first block of text below is the list of previous chronicle entries, with dates. These were probably
|written by different chroniclers, so the information in them is relevant, but the style may differ. The
|second block of text lists major events
|that happened since that last story. The third block of text lists the major surviving factions and what
|provinces they control. The update should be roughly chronological for the events of the last year, but more
|thematic, focusing on major trends, for the previous history. Not all the events need to be included, focus on
|the most important developments during the time period covered. The chronicler's description should not be
|separated into blocks, but told in medieval terms. Do not include any other context beyond the in-world text
|of the response itself. Start with a title of no more than five words, then "=====" on a separate line, then
|the main text of the chronicle, like this:""".stripMargin
s"""The first block of text below is the list of previous chronicle entries, with dates. Each entry is labeled
|with who wrote it (e.g., "Written by Homer:"). $styleGuidance The second block of text lists major events
|that happened since the last story. The third block of text lists the major surviving factions and what
|provinces they control. The update should be roughly chronological for the events of the last year, but more
|thematic, focusing on major trends, for the previous history. Not all the events need to be included, focus
|on the most important developments during the time period covered. The chronicler's description should not
|be separated into blocks, but told in medieval terms. Do not include any other context beyond the in-world
|text of the response itself. Start with a title of no more than five words, then "=====" on a separate line,
|then the main text of the chronicle, like this:""".stripMargin
.replaceAll("\n", " ")
s"""$intro
@@ -153,7 +169,10 @@ case class ChronicleUpdatePromptGenerator(
private def previousEntryText: Vector[TextGenerationResult] =
chronicleUpdateMessage.previousEntries.toVector.map { entry =>
clientTextStore.getText(entry.generatedTextId).map { text =>
s"${GeneratorUtilities.dateString(entry.date.get)}\n$text"
val chroniclerLabel =
if entry.chroniclerStyle.nonEmpty then s"Written by ${entry.chroniclerStyle}:"
else "Written by an unknown chronicler:"
s"${GeneratorUtilities.dateString(entry.date.get)}\n$chroniclerLabel\n$text"
}
}
@@ -165,7 +184,7 @@ case class ChronicleUpdatePromptGenerator(
factionText <- factionDescriptions(gameState)
eventsText <- eventsString(chronicleUpdateMessage.newEntries.toVector)
} yield {
SimpleTimedLogger.printLogger.logLine(s"random style is $randomStyle")
SimpleTimedLogger.printLogger.logLine(s"chronicler style is $chroniclerStyle")
s"""${introString(chronicleUpdateMessage.currentDate.get)}
|---
|FIRST SECTION
@@ -15,6 +15,18 @@ object ProvinceDistances {
provinces: Map[ProvinceId, ProvinceT]
): Option[Int] = distance(p1, p2, FactionUtils.ownedNeighbors(provinces, fid))
def closestProvinceThroughFriendliesOption(
to: ProvinceId,
candidates: Vector[ProvinceId],
fid: FactionId,
provinces: Map[ProvinceId, ProvinceT]
): Option[ProvinceAndDistance] =
closestProvinceOption(
to,
candidates.filter(pid => provinces(pid).rulingFactionId.contains(fid)),
FactionUtils.ownedNeighbors(provinces, fid)
)
def distance(
p1: ProvinceId,
p2: ProvinceId,
@@ -11,11 +11,13 @@ 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.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object AttackDecisionCommandChooser {
@@ -24,44 +26,45 @@ object AttackDecisionCommandChooser {
gameState: GameState,
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
): RandomState[Option[CommandSelection]] = {
val protoGameState = GameStateConverter.toProto(gameState)
CommandChooser.choose(
actingFactionId = actingFactionId,
gameState = gameState,
gameState = protoGameState,
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
RandomState(
withdrawArmySelectedCommand(
actingFactionId,
gameState,
protoGs,
availableCommands
),
functionalRandom
),
(actingFactionId, gameState, availableCommands, functionalRandom) =>
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
RandomState(
demandTributeSelectedCommand(
actingFactionId,
gameState,
protoGs,
availableCommands
),
functionalRandom
),
(actingFactionId, gameState, availableCommands, functionalRandom) =>
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
RandomState(
advanceArmySelectedCommand(
actingFactionId,
gameState,
protoGs,
availableCommands
),
functionalRandom
),
(actingFactionId, gameState, availableCommands, functionalRandom) =>
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
RandomState(
safePassageSelectedCommand(
actingFactionId,
gameState,
protoGs,
availableCommands
),
functionalRandom
@@ -69,10 +72,11 @@ object AttackDecisionCommandChooser {
),
functionalRandom = functionalRandom
)
}
private def withdrawArmySelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { withdrawAc =>
@@ -98,7 +102,7 @@ object AttackDecisionCommandChooser {
private def demandTributeSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { availableCommand =>
@@ -129,7 +133,7 @@ object AttackDecisionCommandChooser {
private def okToAttack(
availableCommand: AttackDecisionAvailableCommand,
gameState: GameState
gameState: GameStateProto
): Boolean = {
val armies = availableCommand.armies
val province = gameState.provinces(availableCommand.actingProvinceId)
@@ -174,7 +178,7 @@ object AttackDecisionCommandChooser {
private def advanceArmySelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { availableCommand =>
@@ -202,7 +206,7 @@ object AttackDecisionCommandChooser {
private def safePassageSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
gameState: GameStateProto,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { availableCommand =>
@@ -87,6 +87,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",
@@ -99,6 +102,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:battalion_power",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -188,7 +193,6 @@ scala_library(
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:battalion_with_food_cost_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_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",
@@ -206,21 +210,32 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:vassal_executes_on_capture_chance",
"//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_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:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/battalion_type_finder:legacy_battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption:legacy_food_consumption_utils",
"//src/main/scala/net/eagle0/eagle/library/util/battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:round_phase_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -280,6 +295,8 @@ scala_library(
],
exports = [
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:unaffiliated_hero_with_quest",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
@@ -300,6 +317,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:truce_with_faction_quest_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:unaffiliated_hero_with_quest",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:quest_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
@@ -331,10 +349,7 @@ 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:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
],
)
@@ -415,10 +430,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
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:gold_per_hero_held_back",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -14,7 +14,6 @@ import net.eagle0.eagle.api.command.util.diplomacy_option.RansomOfferOption
import net.eagle0.eagle.api.selected_command.*
import net.eagle0.eagle.api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}
import net.eagle0.eagle.common.battalion_type.{BattalionType, BattalionTypeId}
import net.eagle0.eagle.common.battalion_type.BattalionTypeId.HEAVY_CAVALRY
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.improvement_type.ImprovementType.INFRASTRUCTURE
import net.eagle0.eagle.common.recruitment_info.RecruitmentStatus.{
@@ -23,7 +22,6 @@ 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.hero.Hero
import net.eagle0.eagle.internal.province.Province
@@ -41,23 +39,29 @@ import net.eagle0.eagle.library.settings.{
VassalMinimumFoodToSendSupplies,
VassalMinimumGoldToSendSupplies
}
import net.eagle0.eagle.library.util.{CommandSelection, IncomingArmyUtils, LegacyProvinceDistances, ProvinceDistances}
import net.eagle0.eagle.library.util.battalion_type_finder.LegacyBattalionTypeFinder
import net.eagle0.eagle.library.util.{BattalionUtils, CommandSelection, IncomingArmyUtils, ProvinceDistances}
import net.eagle0.eagle.library.util.battalion_type_finder.BattalionTypeFinder
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.{
goldToHoldBack,
provinceGoldSurplus
}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.food_consumption.LegacyFoodConsumptionUtils.foodConsumptionMonthsToHold
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.food_consumption.FoodConsumptionUtils
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.model.proto_converters.{BattalionConverter, BattalionTypeConverter, RoundPhaseConverter}
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.model.state.{BattalionType as BattalionTypeC, BattalionTypeId as BattalionTypeIdC}
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState as GameStateC
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.NoProfession
import net.eagle0.eagle.model.state.province.ProvinceT
object CommandChoiceHelpers {
import AvailableCommandSelector.{flatSelectionForType, selectionForType}
@@ -88,29 +92,25 @@ object CommandChoiceHelpers {
actingProvinceId: ProvinceId,
gameState: GameState
): Option[Supplies] = {
def battalionType(battalionTypeId: BattalionTypeId): BattalionType =
LegacyBattalionTypeFinder.battalionType(
battalionTypeId,
gameState.battalionTypes.toVector
)
val gs = GameStateConverter.fromProto(gameState)
val province = gameState.provinces(actingProvinceId)
def battalionType(battalionTypeId: BattalionTypeIdC): BattalionTypeC =
BattalionTypeFinder.battalionType(battalionTypeId, gs.battalionTypes)
val province = gameState.provinces(actingProvinceId)
val provinceC = gs.provinces(actingProvinceId)
MoreOption.flatWhen(province.support >= MinSupportForTaxes.doubleValue) {
val heroCount = province.rulingFactionHeroIds.size
val battalionCount = province.battalionIds.size
val neededNewBattalions: Int = (heroCount - battalionCount).max(0)
val existingBattalions = province.battalionIds.map(gameState.battalions)
val newBattalions = (1 to neededNewBattalions).map { _ =>
Battalion(`type` = HEAVY_CAVALRY)
}
val existingBattalions = provinceC.battalionIds.map(gs.battalions)
val hcType = battalionType(HEAVY_CAVALRY)
val hcType = battalionType(BattalionTypeIdC.HeavyCavalry)
val fillUpBattalionsCost = existingBattalions.map { b =>
(battalionType(b.`type`).capacity - b.size) * battalionType(
b.`type`
).baseCost * province.priceIndex
val bt = battalionType(b.typeId)
(bt.capacity - b.size) * bt.baseCost * province.priceIndex
}.sum
val newBattalionsCost =
@@ -118,7 +118,7 @@ object CommandChoiceHelpers {
// THIS IS WRONG it assumes all troops are at existing armament
val maximizeExistingBattalionArmamentCost = existingBattalions.map { b =>
val bt = battalionType(b.`type`)
val bt = battalionType(b.typeId)
bt.baseArmamentCostPerTroop * bt.capacity * (100.0 - b.armament) * province.priceIndex
}.sum
@@ -127,18 +127,21 @@ object CommandChoiceHelpers {
val goldExcess =
(province.gold - (fillUpBattalionsCost + newBattalionsCost + maximizeExistingBattalionArmamentCost + newBattalionArmamentCost + goldToHoldBack(
actingProvinceId,
gameState
provinceC
)))
.max(0)
.floor
.toInt
val maxMonthlyFoodCost =
(existingBattalions ++ newBattalions).map { b =>
val bt = battalionType(b.`type`)
bt.monthlyFoodCost * bt.capacity
}.sum
// Calculate food cost for existing battalions + hypothetical new heavy cavalry battalions
val existingBattalionsFoodCost = existingBattalions.map { b =>
val bt = battalionType(b.typeId)
bt.monthlyFoodCost * bt.capacity
}.sum
val newBattalionsFoodCost = neededNewBattalions * hcType.monthlyFoodCost * hcType.capacity
val maxMonthlyFoodCost = existingBattalionsFoodCost + newBattalionsFoodCost
val riotFoodHeldBack = 1000
val foodExcess =
@@ -182,7 +185,13 @@ object CommandChoiceHelpers {
availableCommands = availableCommands,
rankedChoosers = Vector[CommandChooser](
chosenCommandWhileTraveling,
AttackDecisionCommandChooser.attackDecisionSelectedCommand,
(actingFactionId, gameState, availableCommands, functionalRandom) =>
AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
availableCommands,
functionalRandom
),
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
chosenUnderAttackCommand(
@@ -387,34 +396,42 @@ object CommandChoiceHelpers {
functionalRandom = functionalRandom
)
private def suppressBeastsValue(hero: Hero): Double =
(hero.strength + hero.agility) / 2.0 + (if hero.profession.isNoProfession
/** Protoless version */
private def suppressBeastsValue(hero: HeroT): Double =
(hero.strength + hero.agility) / 2.0 + (if hero.profession == NoProfession
then 0.0
else 50.0)
/** Protoless version - calculates beast power for a province */
private def beastPower(province: ProvinceT): Int = {
val beastCount = ProvinceUtils.beastCount(province)
val beastInfo = ProvinceUtils.beastInfo(province).get
// Use P90 power for AI decision-making (quartic distribution: 0.9^4 = 0.6561)
val p90RelativePower = beastInfo.minRelativePower +
0.6561 * (beastInfo.maxRelativePower - beastInfo.minRelativePower)
(beastCount * p90RelativePower).ceil.toInt
}
def chosenSuppressBeastsCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
flatSelectionForType[SuppressBeastsAvailableCommand](availableCommands) { suppressAc =>
val actingPid = suppressAc.actingProvinceId
val province = gameState.provinces(actingPid)
val beastCount = LegacyProvinceUtils.beastCount(province)
val beastInfo = LegacyProvinceUtils.beastInfo(province).get
// Use P90 power for AI decision-making (quartic distribution: 0.9^4 = 0.6561)
val p90RelativePower = beastInfo.minRelativePower +
0.6561 * (beastInfo.maxRelativePower - beastInfo.minRelativePower)
val beastPower = (beastCount * p90RelativePower).ceil.toInt
val province = gameState.provinces(actingPid)
val provinceC = gs.provinces(actingPid)
val beastPwr = beastPower(provinceC)
if province.battalionIds
.exists(b => gameState.battalions(b).size >= beastPower)
.exists(b => gameState.battalions(b).size >= beastPwr)
then {
val leaderId = HeroSelector
.minimallyFatiguedHeroesProto(
.minimallyFatiguedHeroes(
suppressAc.availableHeroIds
.map(gameState.heroes)
.map(gs.heroes)
.toVector
)
.maxBy(suppressBeastsValue(_))
@@ -440,10 +457,11 @@ object CommandChoiceHelpers {
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
beastPower = beastPower
beastPower = beastPwr
)
end if
}
}
private def chosenMobilizeIfAdjacentEnemyCommand(
actingFactionId: FactionId,
@@ -451,9 +469,11 @@ object CommandChoiceHelpers {
availableCommands: Vector[AvailableCommand],
actingProvinceId: ProvinceId,
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
if LegacyProvinceUtils
.adjacentHostiles(actingProvinceId, gameState)
): RandomState[Option[CommandSelection]] = {
val gs = GameStateConverter.fromProto(gameState)
val province = gs.provinces(actingProvinceId)
if ProvinceUtils
.adjacentHostiles(province, gs.provinces)
.nonEmpty
then {
chosenMobilizeCommand(
@@ -463,6 +483,7 @@ object CommandChoiceHelpers {
functionalRandom = functionalRandom
)
} else RandomState(None, functionalRandom)
}
def chosenExpandCommand(
actingFactionId: FactionId,
@@ -532,7 +553,8 @@ object CommandChoiceHelpers {
gs: GameState,
acs: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
): Option[CommandSelection] = {
val gsc = GameStateConverter.fromProto(gs)
chosenRestCommand(
actingFactionId = actingFactionId,
gameState = gs,
@@ -540,13 +562,16 @@ object CommandChoiceHelpers {
reason = reason
).filter {
case CommandSelection(_, _, ac, _, _) =>
shouldRestProto(
gs.provinces(
ac.asInstanceOf[RestAvailableCommand].actingProvinceId
).rulingFactionHeroIds
.map(gs.heroes)
shouldRest(
gsc
.provinces(
ac.asInstanceOf[RestAvailableCommand].actingProvinceId
)
.rulingFactionHeroIds
.map(gsc.heroes)
)
}
}
/** Protoless version */
def shouldRest(heroes: Iterable[HeroT]): Boolean =
@@ -920,9 +945,20 @@ object CommandChoiceHelpers {
goldAvailable: Int,
foodBuyPrice: Double
): Option[Int] = {
val foodToFeedTroops =
foodConsumptionMonthsToHold(province.id, gameState) * LegacyProvinceUtils
.monthlyFoodConsumption(province.id, gameState)
val provinceC = ProvinceConverter.fromProto(province)
val battalions = province.battalionIds.map(gameState.battalions).map(BattalionConverter.fromProto).toVector
val battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
val date = DateConverter.fromProto(gameState.currentDate)
val roundPhase = RoundPhaseConverter.fromProto(gameState.currentPhase)
val monthsRemainingInYear = FoodConsumptionUtils.foodConsumptionMonthsRemainingInYear(date, roundPhase)
val monthsToHold =
if provinceC.support >= MinSupportForTaxes.doubleValue
then monthsRemainingInYear
else monthsRemainingInYear.max(6)
val monthlyConsumption = BattalionUtils.monthlyConsumedFood(battalions, battalionTypes)
val foodToFeedTroops = monthsToHold * monthlyConsumption
Option
.when(foodToFeedTroops > province.food) {
@@ -986,66 +1022,68 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
flatSelectionForType[ImproveAvailableCommand](availableCommands) { availableCommand =>
val provinceC = gs.provinces(availableCommand.actingProvinceId)
// If we don't have enough gold that we would arm troops anyway, bail
if provinceGoldSurplus(
availableCommand.actingProvinceId,
gameState
) < MinGoldSurplusForArm.intValue
if provinceGoldSurplus(provinceC) < MinGoldSurplusForArm.intValue
then None
else {
val province =
gameState.provinces(availableCommand.actingProvinceId)
val battalions = province.battalionIds.map(gameState.battalions)
val battalions = provinceC.battalionIds.map(gs.battalions)
val battalionCount = battalions.size
// If fewer than half the battalions have training > infrastructure, raise the infrastructure so that we can arm
MoreOption.flatWhen(
battalions.count(
_.training > LegacyProvinceUtils.effectiveInfrastructure(province)
_.training > ProvinceUtils.effectiveInfrastructure(provinceC)
) < battalionCount / 2
) {
ImproveCommandSelector.chosenSpecificImprovementCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gs,
availableCommands,
INFRASTRUCTURE
)
}
}
end if
}
}
def suppliesToSend(provinceId: ProvinceId, gameState: GameState): Supplies =
def suppliesToSend(provinceId: ProvinceId, gameState: GameState): Supplies = {
val gs = GameStateConverter.fromProto(gameState)
val province = gs.provinces(provinceId)
Supplies(
food = provinceFoodSurplus(provinceId, gameState),
gold = provinceGoldSurplus(provinceId, gameState)
food = provinceFoodSurplus(province, gs),
gold = provinceGoldSurplus(province)
)
}
/** Protoless version */
private def closestLeader(
actingFactionId: FactionId,
gameState: GameState,
faction: FactionT,
provinces: Map[ProvinceId, ProvinceT],
actingProvinceId: ProvinceId
): Option[(HeroId, ProvinceId)] =
gameState
.factions(actingFactionId)
.leaders
faction.leaderIds
.flatMap(leaderId =>
LegacyProvinceUtils
.locationOf(leaderId, gameState.provinces.values)
ProvinceUtils
.locationOf(leaderId, provinces.values)
.map(pid => (leaderId, pid))
)
.filter {
case (_, pid) =>
gameState.provinces(pid).rulingFactionId.contains(actingFactionId)
provinces(pid).rulingFactionId.contains(actingFactionId)
}
.flatMap {
case (leaderId, pid) =>
LegacyProvinceDistances
ProvinceDistances
.distanceThroughFriendliesOption(
p1 = actingProvinceId,
p2 = pid,
fid = actingFactionId,
gs = gameState
provinces = provinces
)
.map(distance => (leaderId, pid, distance))
}
@@ -1056,26 +1094,29 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
ac: SendSuppliesAvailableCommand
): Option[ProvinceId] =
gameState
.factions(actingFactionId)
.focusProvinceId
): Option[ProvinceId] = {
val gs = GameStateConverter.fromProto(gameState)
val faction = gs.factions(actingFactionId)
val provinces = gs.provinces
faction.focusProvinceId
.orElse(
closestLeader(
actingFactionId = actingFactionId,
gameState = gameState,
faction = faction,
provinces = provinces,
actingProvinceId = ac.actingProvinceId
).map(_._2)
)
.flatMap { pid =>
destinationCloserTo(
actingFactionId = actingFactionId,
gs = gameState,
provinces = provinces,
actingProvinceId = ac.actingProvinceId,
availableProvinceIds = ac.availableDestinationProvinceIds.toVector,
pid = pid
)
}
}
def chosenSendSuppliesCommandWithSuppliesAndDestination(
actingFactionId: FactionId,
@@ -1083,7 +1124,8 @@ object CommandChoiceHelpers {
ac: SendSuppliesAvailableCommand,
supplies: Supplies,
destination: ProvinceId
): CommandSelection =
): CommandSelection = {
val gs = GameStateConverter.fromProto(gameState)
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ac.actingProvinceId,
@@ -1093,10 +1135,11 @@ object CommandChoiceHelpers {
food = supplies.food,
gold = supplies.gold,
actingHeroId = ac.availableHeroIds
.minBy(hid => LegacyHeroUtils.fatigue(gameState.heroes(hid)))
.minBy(hid => HeroUtils.fatigue(gs.heroes(hid)))
),
reason = "chosenSendSuppliesCommandWithSuppliesAndDestination"
)
}
def chosenSendSuppliesCommandWithSupplies(
actingFactionId: FactionId,
@@ -1128,20 +1171,17 @@ object CommandChoiceHelpers {
)
)
/** Protoless version */
private def lowLoyaltyHeroes(
provinceId: ProvinceId,
heroIds: Vector[HeroId],
gameState: GameState
): Vector[Hero] =
heroIds
.filter(hid =>
LegacyHeroUtils.effectiveLoyalty(
hid,
gameState
) < LoyaltyThreshold.doubleValue + DesiredLoyaltyOverThreshold.doubleValue
heroes: Vector[HeroT],
factions: Vector[FactionT]
): Vector[HeroT] =
heroes
.filter(hero =>
HeroUtils.effectiveLoyalty(hero, factions) <
LoyaltyThreshold.doubleValue + DesiredLoyaltyOverThreshold.doubleValue
)
.sortBy(hid => LegacyHeroUtils.effectiveLoyalty(hid, gameState))
.map(gameState.heroes)
.sortBy(hero => HeroUtils.effectiveLoyalty(hero, factions))
def chosenFeastCommandIfAvailable(
actingFactionId: FactionId,
@@ -1174,32 +1214,35 @@ object CommandChoiceHelpers {
gameState: GameState,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
flatSelectionForType[FeastAvailableCommand](availableCommands) { feastAvailableCommand =>
val actingProvinceId = feastAvailableCommand.actingProvinceId
val province = gs.provinces(actingProvinceId)
val heroes = lowLoyaltyHeroes(
provinceId = actingProvinceId,
heroIds = gameState.provinces(actingProvinceId).rulingFactionHeroIds.toVector,
gameState = gameState
heroes = province.rulingFactionHeroIds.map(gs.heroes),
factions = gs.factions.values.toVector
)
Option.when(heroes.length > 1)(
chosenFeastCommand(actingFactionId, feastAvailableCommand, reason)
)
}
}
private def maybeChosenGiftCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
flatSelectionForType[HeroGiftAvailableCommand](availableCommands) { heroGiftAvailableCommand =>
val heroIds = heroGiftAvailableCommand.eligibleGifts
.map(_.recipientHeroId)
.toVector
lowLoyaltyHeroes(
provinceId = heroGiftAvailableCommand.actingProvinceId,
heroIds = heroGiftAvailableCommand.eligibleGifts
.map(_.recipientHeroId)
.toVector,
gameState = gameState
heroes = heroIds.map(gs.heroes),
factions = gs.factions.values.toVector
).headOption.flatMap { hero =>
HeroGiftCommandSelector.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
@@ -1209,6 +1252,7 @@ object CommandChoiceHelpers {
)
}
}
}
private def maybeSpreadIntoOwnedProvince(
opmc: MarchCommandFromOneProvince,
@@ -1216,25 +1260,22 @@ object CommandChoiceHelpers {
gameState: GameState,
ac: MarchAvailableCommand
): Option[CommandSelection] = {
val originProvince = gameState.provinces(opmc.originProvinceId)
val gs = GameStateConverter.fromProto(gameState)
val originProvince = gameState.provinces(opmc.originProvinceId)
val originProvinceC = gs.provinces(opmc.originProvinceId)
opmc.availableDestinationProvinces
.map(dest => gameState.provinces(dest.provinceId))
.filter(_.rulingFactionId.contains(actingFactionId))
.filter(p => p.rulingFactionHeroIds.length < p.heroCap)
.maxByOption(p => p.rulingFactionHeroIds.length - p.heroCap)
.map { destProvince =>
val foodToHoldBack = LegacyProvinceUtils.monthlyFoodConsumption(
originProvince.id,
gameState
) * foodConsumptionMonthsToHold(originProvince.id, gameState)
val foodToHoldBack = ProvinceUtils.monthlyFoodConsumption(originProvinceC, gs) *
FoodConsumptionUtils.foodConsumptionMonthsToHold(originProvinceC, gs)
val foodToTake = Math.max(0, (originProvince.food - foodToHoldBack) / 2)
val goldToTake = Math.max(
0,
(originProvince.gold - goldToHoldBack(
originProvince.id,
gameState
)) / 2
(originProvince.gold - provinceGoldSurplus(originProvinceC).max(0)) / 2
)
CommandSelection(
@@ -1247,7 +1288,7 @@ object CommandChoiceHelpers {
originProvince = opmc.originProvinceId,
destinationProvinceId = destProvince.id,
marchingUnits = opmc.availableHeroIds
.sortBy(hid => LegacyHeroUtils.power(gameState.heroes(hid)))
.sortBy(hid => HeroUtils.power(gs.heroes(hid)))
.take(
originProvince.rulingFactionHeroIds.length - originProvince.heroCap
)
@@ -1271,6 +1312,7 @@ object CommandChoiceHelpers {
ac: MarchAvailableCommand,
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val gs = GameStateConverter.fromProto(gameState)
val originProvince = gameState.provinces(opmc.originProvinceId)
val sendingHeroCount = (originProvince.rulingFactionHeroIds.length -
originProvince.heroCap).min(opmc.availableHeroIds.length)
@@ -1307,7 +1349,7 @@ object CommandChoiceHelpers {
originProvince = opmc.originProvinceId,
destinationProvinceId = destProvince.id,
marchingUnits = opmc.availableHeroIds
.sortBy(hid => LegacyHeroUtils.power(gameState.heroes(hid)))
.sortBy(hid => HeroUtils.power(gs.heroes(hid)))
.take(sendingHeroCount)
.zipAll(
sendingBattalions.map(batt => Some(batt.battalionId)),
@@ -1382,12 +1424,14 @@ object CommandChoiceHelpers {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] = {
val gs = GameStateConverter.fromProto(gameState)
// To avoid multiple ransoms going out from the same faction, make this dependent on the current round ID
val myProvinces = gameState.provinces.values
.filter(_.rulingFactionId.contains(actingFactionId))
.toVector
val chosenIndex = gameState.currentRoundId % myProvinces.length
val chosenPid = myProvinces(chosenIndex).id
val faction = gs.factions(actingFactionId)
randomSelectionForType[DiplomacyAvailableCommand](
availableCommands,
@@ -1398,8 +1442,8 @@ object CommandChoiceHelpers {
case roo: RansomOfferOption =>
roo
}.sortBy { roo =>
LegacyHeroUtils.seniorityOrder(
gameState.factions(actingFactionId),
HeroUtils.seniorityOrder(
faction,
roo.getRansomOffer.getPrisonerToBeRansomed.prisonerHeroId
)
}
@@ -1637,11 +1681,12 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
flatSelectionForType[DivineAvailableCommand](availableCommands) { availableCommand =>
val divinableHeroes = availableCommand.divinableHeroes
val availableGold =
provinceGoldSurplus(availableCommand.actingProvinceId, gameState)
val provinceC = gs.provinces(availableCommand.actingProvinceId)
val availableGold = provinceGoldSurplus(provinceC)
val heroCountToDivine = availableGold / availableCommand.costPerHero
Option.unless(divinableHeroes.isEmpty || heroCountToDivine < 1) {
@@ -1651,7 +1696,7 @@ object CommandChoiceHelpers {
actingProvinceId = availableCommand.actingProvinceId,
selected = DivineSelectedCommand(
heroIds = divinableHeroes
.sortBy(uh => LegacyHeroUtils.power(gameState.heroes(uh.getHero.id)))
.sortBy(uh => HeroUtils.power(gs.heroes(uh.getHero.id)))
.takeRight(heroCountToDivine)
.map(_.getHero.id)
),
@@ -1659,6 +1704,7 @@ object CommandChoiceHelpers {
)
}
}
}
private def chosenReturnCommand(
actingFactionId: FactionId,
@@ -1679,30 +1725,28 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
flatSelectionForType[TravelAvailableCommand](availableCommands) { availableCommand =>
val actingProvinceId = availableCommand.actingProvinceId
val provinceC = gs.provinces(actingProvinceId)
// Don't travel if you don't have much extra gold to spend anyway
if provinceGoldSurplus(
actingProvinceId,
gameState
) < MinGoldSurplusForArm.intValue
if provinceGoldSurplus(provinceC) < MinGoldSurplusForArm.intValue
then None
else {
val province = gameState.provinces(actingProvinceId)
val battalions = province.battalionIds.map(gameState.battalions)
val battalions = provinceC.battalionIds.map(gs.battalions)
val effectiveInfra = ProvinceUtils.effectiveInfrastructure(provinceC)
val highestArmamentWeShouldUpgrade =
if LegacyProvinceUtils.effectiveInfrastructure(province) > 90 then
LegacyProvinceUtils.effectiveInfrastructure(province) - 3
else LegacyProvinceUtils.effectiveInfrastructure(province) - 10
if effectiveInfra > 90 then effectiveInfra - 3
else effectiveInfra - 10
Option.when(
battalions.exists(_.armament < highestArmamentWeShouldUpgrade)
)(chosenTravelCommand(actingFactionId, availableCommand))
}
end if
}
}
def maybeMoveToRecruitCommand(
actingFactionId: FactionId,
@@ -1712,11 +1756,13 @@ object CommandChoiceHelpers {
): RandomState[Option[CommandSelection]] =
randomSelectionForType[MarchAvailableCommand](acs, functionalRandom) {
case (availableCommand, fr) =>
val gsc = GameStateConverter.fromProto(gs)
val factionsVector = gsc.factions.values.toVector
val presentLeaders = gs
.provinces(availableCommand.actingProvinceId)
.rulingFactionHeroIds
.map(gs.heroes)
.filter(h => LegacyFactionUtils.isFactionLeader(h.id, gs))
.filter(h => FactionUtils.isFactionLeader(h.id, factionsVector))
// Only faction leaders march to recruit, since only they can recruit
if presentLeaders.isEmpty then RandomState(None, fr)
@@ -1737,7 +1783,7 @@ object CommandChoiceHelpers {
fromHere.get.availableDestinationProvinces
.map(dest => gs.provinces(dest.provinceId))
.filter(_.rulingFactionId.contains(actingFactionId))
.filterNot(p => LegacyProvinceUtils.containsFactionLeader(p.id, gs))
.filterNot(p => ProvinceUtils.containsFactionLeader(gsc.provinces(p.id), factionsVector))
.filter(p => hasInterestingUH(p.id, gs))
val moveableLeaders = fromHere.get.availableHeroIds
@@ -1754,7 +1800,7 @@ object CommandChoiceHelpers {
(
interestingUHs.length,
interestingUHs
.map(uh => LegacyHeroUtils.power(gs.heroes(uh.heroId)))
.map(uh => HeroUtils.power(gsc.heroes(uh.heroId)))
.sum
)
}
@@ -1802,16 +1848,16 @@ object CommandChoiceHelpers {
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
flatSelectionForType[TravelAvailableCommand](availableCommands) { availableCommand =>
val actingProvinceId = availableCommand.actingProvinceId
Option.when(
LegacyProvinceUtils
.containsFactionLeader(
actingProvinceId,
gameState
) && hasInterestingUH(
ProvinceUtils.containsFactionLeader(
gs.provinces(actingProvinceId),
gs.factions.values
) && hasInterestingUH(
actingProvinceId,
gameState
)
@@ -1820,6 +1866,7 @@ object CommandChoiceHelpers {
}
}
.map(_.withReason("travel to recruit"))
}
private def chosenTravelCommand(
actingFactionId: FactionId,
@@ -1891,19 +1938,18 @@ object CommandChoiceHelpers {
gameState: GameState,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
flatSelectionForType[ArmTroopsAvailableCommand](availableCommands) { availableCommand =>
val provinceId = availableCommand.actingProvinceId
val goldSurplus = provinceGoldSurplus(provinceId, gameState)
val provinceC = gs.provinces(provinceId)
val goldSurplus = provinceGoldSurplus(provinceC)
val lowestBattalion = availableCommand.availableBattalions
.map(gameState.battalions)
.minBy(_.armament)
val provinceInfrastructure =
LegacyProvinceUtils
.effectiveInfrastructure(gameState.provinces(provinceId))
.floor
.toInt
ProvinceUtils.effectiveInfrastructure(provinceC).floor.toInt
if lowestBattalion.armament >= provinceInfrastructure then None
else {
val costPerPointPerTroop = availableCommand.armamentCosts
@@ -1937,24 +1983,24 @@ object CommandChoiceHelpers {
}
end if
}
}
private def provinceFoodSurplus(provinceId: ProvinceId, gs: GameState): Int =
(LegacyProvinceUtils.absoluteFoodSurplus(
provinceId,
gs
) - FoodPerProvinceHeldBack.intValue)
/** Protoless version */
private def provinceFoodSurplus(province: ProvinceT, gs: GameStateC): Int =
(ProvinceUtils.absoluteFoodSurplus(province, gs) - FoodPerProvinceHeldBack.intValue)
.max(0)
/** Protoless version */
private def destinationCloserTo(
actingFactionId: FactionId,
gs: GameState,
provinces: Map[ProvinceId, ProvinceT],
actingProvinceId: ProvinceId,
availableProvinceIds: Vector[ProvinceId],
pid: ProvinceId
): Option[ProvinceId] = {
val distances = ProvinceDistances.distancesFrom(
pid,
LegacyFactionUtils.ownedNeighbors(gs, actingFactionId)
FactionUtils.ownedNeighbors(provinces, actingFactionId)
)
distances(actingProvinceId).flatMap { startingDistance =>
@@ -1974,6 +2020,8 @@ object CommandChoiceHelpers {
costs: Map[BattalionTypeId, Double],
beastPower: Int
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
val topUpCandidates = province.battalionIds
.map(gameState.battalions)
.filter(batt =>
@@ -1994,12 +2042,11 @@ object CommandChoiceHelpers {
Option.when(lowestCost._3 <= province.gold) {
// Hire up to at least beastsCount, but if there's surplus gold, hire more.
val batt = lowestCost._1
val battC = gs.battalions(batt.id)
val capacity =
LegacyBattalionTypeFinder
.battalionType(batt.`type`, gameState.battalionTypes.toVector)
.capacity
BattalionTypeFinder.battalionType(battC.typeId, gs.battalionTypes).capacity
val maxDesired = batt.size +
(provinceGoldSurplus(province.id, gameState).toDouble / costs(
(provinceGoldSurplus(gs.provinces(province.id)).toDouble / costs(
batt.`type`
)).floor.toInt
@@ -2032,18 +2079,18 @@ object CommandChoiceHelpers {
costs: Map[BattalionTypeId, Double],
beastPower: Int
): Option[CommandSelection] = {
val gs = GameStateConverter.fromProto(gameState)
val lowestCost = costs.minBy(_._2)
val costForMinimalBattalion = (lowestCost._2 * beastPower).ceil.toInt
Option.when(costForMinimalBattalion <= province.gold) {
val goldSurplus = provinceGoldSurplus(province.id, gameState)
val battalionType =
LegacyBattalionTypeFinder.battalionType(
lowestCost._1,
gameState.battalionTypes.toVector
)
val couldAfford = (goldSurplus / lowestCost._2).floor.toInt
.min(battalionType.capacity)
val countToBuy = couldAfford.max(beastPower).min(battalionType.capacity)
val goldSurplus = provinceGoldSurplus(gs.provinces(province.id))
val protolessTypeId = BattalionTypeIdC.fromInt(lowestCost._1.value)
val battalionTypeC =
BattalionTypeFinder.battalionType(protolessTypeId, gs.battalionTypes)
val couldAfford = (goldSurplus / lowestCost._2).floor.toInt
.min(battalionTypeC.capacity)
val countToBuy = couldAfford.max(beastPower).min(battalionTypeC.capacity)
CommandSelection(
actingFactionId = actingFactionId,
@@ -19,10 +19,23 @@ import net.eagle0.eagle.library.util.command_choice_helpers.quest_command_select
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.QuestConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object FulfillQuestsCommandSelector {
private val choosers: Vector[QuestCommandChooser] = Vector(
AllianceQuestCommandChooser,
TruceWithFactionQuestCommandChooser,
ImproveQuestCommandChooser,
AlmsToProvinceQuestCommandChooser,
GiveToHeroesInProvinceQuestCommandChooser,
AlmsAcrossRealmQuestCommandChooser,
GiveToHeroesAcrossRealmQuestCommandChooser,
TruceCountQuestCommandChooser,
DismissSpecificVassalCommandChooser
)
private def choiceFrom(
choosers: Vector[QuestCommandChooser],
actingFactionId: FactionId,
@@ -43,6 +56,24 @@ object FulfillQuestsCommandSelector {
)
}
/** Protoless version - takes native GameState and uhsWithQuests directly */
def chosenFulfillEasyQuestsCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
choiceFrom(
choosers = choosers,
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
uhsWithQuests = uhsWithQuests,
functionalRandom = functionalRandom
)
/** Proto version - converts to native and extracts uhsWithQuests */
def chosenFulfillEasyQuestsCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
@@ -58,24 +89,15 @@ object FulfillQuestsCommandSelector {
uh <- province.unaffiliatedHeroes
recruitmentInfo <- uh.recruitmentInfo
quest <- recruitmentInfo.quest
} yield UnaffiliatedHeroWithQuest(province.id, uh, quest)).toVector
} yield UnaffiliatedHeroWithQuest(
province.id,
uh.heroId,
QuestConverter.fromProto(quest)
)).toVector
val nativeGameState = GameStateConverter.fromProto(gameState)
choiceFrom(
choosers = Vector[QuestCommandChooser](
AllianceQuestCommandChooser,
TruceWithFactionQuestCommandChooser,
ImproveQuestCommandChooser,
AlmsToProvinceQuestCommandChooser,
GiveToHeroesInProvinceQuestCommandChooser,
AlmsAcrossRealmQuestCommandChooser,
GiveToHeroesAcrossRealmQuestCommandChooser,
TruceCountQuestCommandChooser,
DismissSpecificVassalCommandChooser
),
chosenFulfillEasyQuestsCommand(
actingFactionId = actingFactionId,
gameState = nativeGameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = availableCommands,
uhsWithQuests = uhsWithQuests,
functionalRandom = functionalRandom
@@ -1,22 +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.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.hero.HeroT
object HeroSelector {
/** Protoless version */
def minimallyFatiguedHeroes(heroes: Vector[HeroT]): Vector[HeroT] = {
val minimumFatigue = heroes.map(HeroUtils.fatigue).min
heroes
.filter(HeroUtils.fatigue(_) == minimumFatigue)
}
/** Legacy proto version */
def minimallyFatiguedHeroesProto(heroes: Vector[Hero]): Vector[Hero] = {
val minimumFatigue = heroes.map(LegacyHeroUtils.fatigue).min
heroes
.filter(LegacyHeroUtils.fatigue(_) == minimumFatigue)
}
}
@@ -1,36 +1,12 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.settings.GoldPerHeroHeldBack
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.ProvinceId
object ProvinceGoldSurplusCalculator {
// Protoless version
def goldToHoldBack(province: ProvinceT): Int =
(GoldPerHeroHeldBack.doubleValue * province.rulingFactionHeroIds.length).ceil.toInt
def provinceGoldSurplus(province: ProvinceT): Int =
(province.gold - goldToHoldBack(province)).max(0)
// Legacy proto version for backward compatibility
def goldToHoldBack(
provinceId: ProvinceId,
gameState: GameStateProto
): Int =
(GoldPerHeroHeldBack.doubleValue * gameState
.provinces(provinceId)
.rulingFactionHeroIds
.length).ceil.toInt
def provinceGoldSurplus(
provinceId: ProvinceId,
gameState: GameStateProto
): Int =
(gameState.provinces(provinceId).gold - goldToHoldBack(
provinceId,
gameState
)).max(0)
}
@@ -3,11 +3,11 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
import net.eagle0.eagle.api.command.util.diplomacy_option.AllianceOption
import net.eagle0.eagle.common.unaffiliated_hero_quest.AllianceQuest
import net.eagle0.eagle.library.util.command_choice_helpers.{AllianceOfferCommandSelector, TrustForDiplomacy}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Ally
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.AllianceQuest
import net.eagle0.eagle.FactionId
object AllianceQuestCommandChooser extends QuestCommandChooser {
@@ -27,8 +27,8 @@ object AllianceQuestCommandChooser extends QuestCommandChooser {
}.flatten
functionalRandom
.nextFlatCollectFirst(uhsWithQuests.map(_.quest.details)) {
case _: AllianceQuest =>
.nextFlatCollectFirst(uhsWithQuests.map(_.quest)) {
case AllianceQuest =>
fr =>
val actingFaction = gameState.factions(actingFactionId)
val factionChoices =
@@ -1,10 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.{AlmsAcrossRealmQuest, Quest}
import net.eagle0.eagle.library.util.command_choice_helpers.AlmsCommandSelector
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.AlmsAcrossRealmQuest
import net.eagle0.eagle.FactionId
object AlmsAcrossRealmQuestCommandChooser extends DeterministicQuestCommandChooser {
@@ -13,13 +13,8 @@ object AlmsAcrossRealmQuestCommandChooser extends DeterministicQuestCommandChoos
): Option[Int] = uhsWithQuests
.map(uhwq => uhwq.quest)
.collect {
case Quest(
_,
componentsFulfilled,
AlmsAcrossRealmQuest(amount: Int, _ /* unknownFieldSet */ ),
_ /* unknownFieldSet */
) =>
amount - componentsFulfilled
case q: AlmsAcrossRealmQuest =>
q.totalFood - q.componentsFulfilled
}
.maxOption
@@ -1,10 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.AlmsToProvinceQuest
import net.eagle0.eagle.library.util.command_choice_helpers.AlmsCommandSelector
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.AlmsToProvinceQuest
object AlmsToProvinceQuestCommandChooser extends DeterministicQuestCommandChooser {
override def chosenDeterministicCommand(
@@ -14,17 +14,10 @@ object AlmsToProvinceQuestCommandChooser extends DeterministicQuestCommandChoose
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest]
): Option[CommandSelection] =
uhsWithQuests
.map(uhwq => (uhwq.pid, uhwq.quest.details))
.map(uhwq => (uhwq.pid, uhwq.quest))
.collect {
case (
pid,
AlmsToProvinceQuest(
_: ProvinceId,
amount: Int,
_ /* unknownFieldSet */
)
) =>
(pid, amount)
case (pid, q: AlmsToProvinceQuest) =>
(pid, q.totalFood - q.componentsFulfilled)
}
.groupBy(_._1)
.map { case (pid, amts) => (pid, amts.map(_._2).max) }
@@ -21,6 +21,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//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/quest/concrete",
],
)
@@ -44,6 +45,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -66,6 +68,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -92,6 +95,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:exile_vassal_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -115,6 +119,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:hero_gift_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -137,6 +142,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:hero_gift_command_selector",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -159,6 +165,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:improve_command_selector",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -204,6 +211,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -226,6 +234,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:truce_offer_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -236,9 +245,11 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/quest",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
],
)
@@ -3,12 +3,12 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.common.MoreOption
import net.eagle0.common.MoreSeq.flatCollectFirst
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.{DismissSpecificVassalQuest, Quest}
import net.eagle0.eagle.library.settings.RequiredPowerMultiplierForDismiss
import net.eagle0.eagle.library.util.command_choice_helpers.ExileVassalCommandSelector
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.DismissSpecificVassalQuest
import net.eagle0.eagle.FactionId
object DismissSpecificVassalCommandChooser extends DeterministicQuestCommandChooser {
@@ -22,22 +22,14 @@ object DismissSpecificVassalCommandChooser extends DeterministicQuestCommandChoo
uhsWithQuests.flatCollectFirst {
case UnaffiliatedHeroWithQuest(
pid,
uh,
Quest(
_,
_,
DismissSpecificVassalQuest(
targetHeroId,
_ /* unknownFieldSet */
),
_ /* unknownFieldSet */
)
heroId,
DismissSpecificVassalQuest(targetHeroId)
) =>
MoreOption.flatWhen(
// Don't exile a vassal that would leave the faction leader here by themselves
gameState.provinces(pid).rulingFactionHeroIds.size > 2 &&
// Don't exile a vassal unless they're much less powerful than the hero that would replace them
HeroUtils.power(gameState.heroes(uh.heroId)) >=
HeroUtils.power(gameState.heroes(heroId)) >=
RequiredPowerMultiplierForDismiss.doubleValue * HeroUtils
.power(
gameState.heroes(targetHeroId)
@@ -1,10 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.{GiveToHeroesAcrossRealmQuest, Quest}
import net.eagle0.eagle.library.util.command_choice_helpers.HeroGiftCommandSelector
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.GiveToHeroesAcrossRealmQuest
import net.eagle0.eagle.FactionId
object GiveToHeroesAcrossRealmQuestCommandChooser extends DeterministicQuestCommandChooser {
@@ -13,13 +13,8 @@ object GiveToHeroesAcrossRealmQuestCommandChooser extends DeterministicQuestComm
): Option[Int] = uhsWithQuests
.map(uhwq => uhwq.quest)
.collect {
case Quest(
_,
componentsFulfilled,
GiveToHeroesAcrossRealmQuest(amount: Int, _ /* unknownFieldSet */ ),
_ /* unknownFieldSet */
) =>
amount - componentsFulfilled
case q: GiveToHeroesAcrossRealmQuest =>
q.totalGold - q.componentsFulfilled
}
.maxOption
@@ -2,10 +2,10 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.GiveToHeroesInProvinceQuest
import net.eagle0.eagle.library.util.command_choice_helpers.HeroGiftCommandSelector
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.GiveToHeroesInProvinceQuest
object GiveToHeroesInProvinceQuestCommandChooser extends DeterministicQuestCommandChooser {
override def chosenDeterministicCommand(
@@ -15,18 +15,10 @@ object GiveToHeroesInProvinceQuestCommandChooser extends DeterministicQuestComma
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest]
): Option[CommandSelection] =
uhsWithQuests
.map(uhwq => (uhwq.pid, uhwq.quest.details, uhwq.quest.componentsFulfilled))
.map(uhwq => (uhwq.pid, uhwq.quest))
.collect {
case (
pid,
GiveToHeroesInProvinceQuest(
_: ProvinceId,
amount: Int,
_ /* unknownFieldSet */
),
amountCompleted
) =>
(pid, amount - amountCompleted)
case (pid, q: GiveToHeroesInProvinceQuest) =>
(pid, q.totalGold - q.componentsFulfilled)
}
.groupBy(_._1) // Group all the quests of this type for this province
.map {
@@ -2,14 +2,14 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.improvement_type.ImprovementType.{AGRICULTURE, ECONOMY, INFRASTRUCTURE}
import net.eagle0.eagle.common.unaffiliated_hero_quest.{
import net.eagle0.eagle.library.util.command_choice_helpers.ImproveCommandSelector
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.{
ImproveAgricultureQuest,
ImproveEconomyQuest,
ImproveInfrastructureQuest
}
import net.eagle0.eagle.library.util.command_choice_helpers.ImproveCommandSelector
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object ImproveQuestCommandChooser extends DeterministicQuestCommandChooser {
@@ -20,7 +20,7 @@ object ImproveQuestCommandChooser extends DeterministicQuestCommandChooser {
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest]
): Option[CommandSelection] =
uhsWithQuests
.map(uhwq => (uhwq.pid, uhwq.quest.details))
.map(uhwq => (uhwq.pid, uhwq.quest))
.collect {
case (pid, _: ImproveEconomyQuest) => (pid, ECONOMY)
case (pid, _: ImproveAgricultureQuest) => (pid, AGRICULTURE)
@@ -2,11 +2,11 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.TruceCountQuest
import net.eagle0.eagle.library.util.command_choice_helpers.{TruceOfferCommandSelector, TrustForDiplomacy}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.TruceCountQuest
import net.eagle0.eagle.FactionId
object TruceCountQuestCommandChooser extends QuestCommandChooser {
@@ -18,7 +18,7 @@ object TruceCountQuestCommandChooser extends QuestCommandChooser {
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
functionalRandom
.nextFlatCollectFirst(uhsWithQuests.map(_.quest.details)) {
.nextFlatCollectFirst(uhsWithQuests.map(_.quest)) {
case _: TruceCountQuest =>
fr =>
val actingFaction = gameState.factions(actingFactionId)
@@ -1,10 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.TruceWithFactionQuest
import net.eagle0.eagle.library.util.command_choice_helpers.{TruceOfferCommandSelector, TrustForDiplomacy}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.TruceWithFactionQuest
import net.eagle0.eagle.FactionId
object TruceWithFactionQuestCommandChooser extends DeterministicQuestCommandChooser {
@@ -15,7 +15,7 @@ object TruceWithFactionQuestCommandChooser extends DeterministicQuestCommandChoo
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest]
): Option[CommandSelection] =
uhsWithQuests
.map(_.quest.details)
.map(_.quest)
.collect { case twfq: TruceWithFactionQuest => twfq }
.filter { twfq =>
TrustForDiplomacy.meetsConditionsForTruce(
@@ -1,11 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.common.unaffiliated_hero_quest.Quest
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.ProvinceId
import net.eagle0.eagle.{HeroId, ProvinceId}
import net.eagle0.eagle.model.state.quest.QuestT
case class UnaffiliatedHeroWithQuest(
pid: ProvinceId,
uh: UnaffiliatedHero,
quest: Quest
heroId: HeroId,
quest: QuestT
)
@@ -10,6 +10,12 @@ object FactionUtils {
def isFactionLeader(heroId: HeroId, factions: Vector[FactionT]): Boolean =
factions.flatMap(_.leaderIds).contains(heroId)
def isFactionHead(heroId: HeroId, factions: Iterable[FactionT]): Boolean =
factions.map(_.factionHeadId).exists(_ == heroId)
def leadersBesidesHead(faction: FactionT): Vector[HeroId] =
faction.leaderIds.filterNot(_ == faction.factionHeadId)
def factionRelationship(
by: FactionId,
of: FactionId,
@@ -124,6 +130,12 @@ object FactionUtils {
provinces
.filter(_.rulingFactionId.contains(factionId))
def hasProvinces(factionId: FactionId, provinces: Iterable[ProvinceT]): Boolean =
provinces.exists(_.rulingFactionId.contains(factionId))
def provinceCount(factionId: FactionId, provinces: Iterable[ProvinceT]): Int =
provinces.count(_.rulingFactionId.contains(factionId))
def ownedNeighbors(
provinces: Map[ProvinceId, ProvinceT],
fid: FactionId
@@ -12,6 +12,9 @@ scala_library(
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_agility_for_archery",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_agility_for_start_fire",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_wisdom_for_start_fire",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
@@ -2,10 +2,15 @@ package net.eagle0.eagle.library.util.hero
import scala.annotation.tailrec
import net.eagle0.eagle.library.settings.{
MinimumAgilityForArchery,
MinimumAgilityForStartFire,
MinimumWisdomForStartFire
}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.NoProfession
import net.eagle0.eagle.model.state.hero.Profession.{Mage, NoProfession}
import net.eagle0.eagle.HeroId
object HeroUtils {
@@ -83,4 +88,18 @@ object HeroUtils {
def sortOrdering(factionLeaderIds: Vector[HeroId]): Ordering[HeroT] =
Ordering.fromLessThan(sortOrderer(factionLeaderIds))
def seniorityOrder(faction: FactionT, heroId: HeroId): Int =
faction.leaderIds.indexOf(heroId) match {
case -1 => Int.MaxValue
case idx => idx
}
def archeryCapable(hero: HeroT): Boolean =
hero.agility >= MinimumAgilityForArchery.doubleValue
def startFireCapable(hero: HeroT): Boolean =
hero.profession == Mage ||
(hero.agility >= MinimumAgilityForStartFire.doubleValue &&
hero.wisdom >= MinimumWisdomForStartFire.doubleValue)
}
@@ -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,
@@ -158,6 +158,30 @@ object ProvinceUtils {
.exists(_ != factionId)
)
def adjacentHostiles(
province: ProvinceT,
provinces: Map[ProvinceId, ProvinceT]
): Vector[ProvinceT] =
province.rulingFactionId.toVector.flatMap { rfid =>
province.neighbors
.map(_.provinceId)
.map(provinces)
.filter(_.rulingFactionId.exists(_ != rfid))
}
def absoluteFoodSurplus(
province: ProvinceT,
gs: GameState
): Int =
if annualFoodProduction(province).getOrElse(0) / 12 < monthlyFoodConsumption(
province,
gs
)
then 0
else
(province.food - monthlyFoodConsumption(province, gs) * (12 - gs.currentDate.get.month.value))
.max(0)
def beastCount(p: ProvinceT): Int =
p.activeEvents.collect { case be: BeastsEvent => be.count }.sum
@@ -284,6 +308,27 @@ object ProvinceUtils {
province.rulingFactionId.nonEmpty && province.rulingFactionHeroIds.isEmpty
)(abandonedProvinceCP(province))
def locationOf(
heroId: HeroId,
provinces: Iterable[ProvinceT]
): Option[ProvinceId] =
provinces
.find(p =>
(p.rulingFactionHeroIds ++ p.unaffiliatedHeroes.map(_.heroId))
.contains(heroId)
)
.map(_.id)
def containsFactionLeader(
province: ProvinceT,
factions: Iterable[FactionT]
): Boolean =
province.rulingFactionId.exists { fid =>
factions
.find(_.id == fid)
.exists(faction => province.rulingFactionHeroIds.contains(faction.factionHeadId))
}
def afterHeroDeparture(
cp: ChangedProvinceT,
province: ProvinceT
@@ -10,7 +10,8 @@ import net.eagle0.eagle.model.state.quest.QuestT
case class ChronicleUpdatePreviousEntry(
date: Date,
generatedTextId: String
generatedTextId: String,
chroniclerStyle: Option[String] = None
)
sealed trait GeneratedTextRequestT {
@@ -130,7 +131,8 @@ enum LlmRequestT extends GeneratedTextRequestT:
alwaysGenerate: Boolean = false,
current_date: Date,
previous_entries: Vector[ChronicleUpdatePreviousEntry],
new_entries: Vector[ChronicleEvent]
new_entries: Vector[ChronicleEvent],
chronicler_style: String
)
case DivineMessage(
@@ -374,6 +374,7 @@ scala_library(
srcs = ["QuestConverter.scala"],
visibility = [
"//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/quest_fulfillment:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/llm_request:__pkg__",
@@ -10,12 +10,14 @@ object ChronicleEntryConverter {
def toProto(chronicleEntry: ChronicleEntry): ChronicleEntryProto =
ChronicleEntryProto(
generatedTextId = chronicleEntry.generatedTextId,
date = Some(DateConverter.toProto(chronicleEntry.date))
date = Some(DateConverter.toProto(chronicleEntry.date)),
chroniclerStyle = chronicleEntry.chroniclerStyle.getOrElse("")
)
def fromProto(chronicleEntryProto: ChronicleEntryProto): ChronicleEntry =
ChronicleEntry(
generatedTextId = chronicleEntryProto.generatedTextId,
date = DateConverter.fromProto(chronicleEntryProto.date)
date = DateConverter.fromProto(chronicleEntryProto.date),
chroniclerStyle = Option.when(chronicleEntryProto.chroniclerStyle.nonEmpty)(chronicleEntryProto.chroniclerStyle)
)
}
@@ -228,21 +228,25 @@ object LlmRequestConverter {
previousEntries: Vector[
ChronicleUpdatePreviousEntry
],
newEntries: Vector[ChronicleEvent]
newEntries: Vector[ChronicleEvent],
chroniclerStyle: String
) =>
ChronicleUpdateMessageProto(
currentDate = Some(DateConverter.toProto(currentDate)),
previousEntries = previousEntries.map {
case ChronicleUpdatePreviousEntry(
date,
generatedTextId
generatedTextId,
entryChroniclerStyle
) =>
ChronicleUpdateMessageProto.PreviousChronicleEntry(
date = Some(DateConverter.toProto(date)),
generatedTextId = generatedTextId
generatedTextId = generatedTextId,
chroniclerStyle = entryChroniclerStyle.getOrElse("")
)
},
newEntries = newEntries.map(ChronicleEventConverter.toProto)
newEntries = newEntries.map(ChronicleEventConverter.toProto),
chroniclerStyle = chroniclerStyle
)
case DivineMessage(
@@ -4,6 +4,7 @@ scala_library(
name = "province",
srcs = ["ProvinceConverter.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//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:__pkg__",
@@ -4,5 +4,6 @@ import net.eagle0.eagle.model.state.date.Date
case class ChronicleEntry(
generatedTextId: String,
date: Date
date: Date,
chroniclerStyle: Option[String] = None
)
@@ -6,6 +6,7 @@ scala_library(
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/quest_creation:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment/prisoner_management:__pkg__",
@@ -46,7 +46,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:eagle_client_exception",
"//src/main/scala/net/eagle0/eagle/library:engine",
"//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/library/settings:max_supported_players",
"//src/main/scala/net/eagle0/eagle/library/settings/loaders:settings_loader",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:game_parameters_utils",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
@@ -13,6 +13,8 @@ import net.eagle0.eagle.api.eagle.EagleGrpc.Eagle
import net.eagle0.eagle.api.eagle.HexMapResponse.OneMapResponseInfo
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest
import net.eagle0.eagle.api.pregenerated_text.PregeneratedText
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.settings.loaders.SettingsLoader
import net.eagle0.eagle.library.settings.MaxSupportedPlayers
import net.eagle0.eagle.library.EagleClientException
import net.eagle0.eagle.service.new_game_creation.GameParametersUtils
@@ -359,79 +361,86 @@ class EagleServiceImpl(
responseObserver: SyncResponseObserver
): Unit = {
lockAndDoWithUserName { userName =>
println(
s"got heartbeat from $userName. Client timestamp ${request.clientTimestamp}, server timestamp ${System.currentTimeMillis()}"
)
// Synchronize on gamesManager to ensure heartbeat response is sent AFTER
// any pending game updates have been queued. Without this, there's a race
// condition where the heartbeat response can interleave with game updates,
// causing clients to see a sync mismatch before receiving all updates.
// This particularly affects slower/remote connections (e.g., Windows clients).
gamesManager.synchronized {
println(
s"got heartbeat from $userName. Client timestamp ${request.clientTimestamp}, server timestamp ${System.currentTimeMillis()}"
)
// Verify sync status for each game the client reports
val gameSyncResults = request.gameSyncStatuses.flatMap { clientGameStatus =>
gamesManager.gameControllerInfos.get(clientGameStatus.gameId).flatMap { controllerInfo =>
val controller = controllerInfo.controller
controller.userNameToFactionId.get(userName).map { factionId =>
val serverUnfilteredCount = controller.engine.history.count
// Verify sync status for each game the client reports
val gameSyncResults = request.gameSyncStatuses.flatMap { clientGameStatus =>
gamesManager.gameControllerInfos.get(clientGameStatus.gameId).flatMap { controllerInfo =>
val controller = controllerInfo.controller
controller.userNameToFactionId.get(userName).map { factionId =>
val serverUnfilteredCount = controller.engine.history.count
// Check Shardok sync status
val shardokSyncResults = clientGameStatus.shardokSyncStatuses.map { clientShardokStatus =>
val serverShardokCount = controller.engine.history.shardokPlayerCount(
clientShardokStatus.shardokGameId,
factionId
)
ShardokSyncResult(
shardokGameId = clientShardokStatus.shardokGameId,
inSync = clientShardokStatus.filteredResultCount == serverShardokCount,
serverFilteredResultCount = serverShardokCount
// Check Shardok sync status
val shardokSyncResults = clientGameStatus.shardokSyncStatuses.map { clientShardokStatus =>
val serverShardokCount = controller.engine.history.shardokPlayerCount(
clientShardokStatus.shardokGameId,
factionId
)
ShardokSyncResult(
shardokGameId = clientShardokStatus.shardokGameId,
inSync = clientShardokStatus.filteredResultCount == serverShardokCount,
serverFilteredResultCount = serverShardokCount
)
}
GameSyncResult(
gameId = clientGameStatus.gameId,
eagleInSync = clientGameStatus.unfilteredResultCount == serverUnfilteredCount,
serverUnfilteredResultCount = serverUnfilteredCount,
shardokSyncResults = shardokSyncResults
)
}
GameSyncResult(
gameId = clientGameStatus.gameId,
eagleInSync = clientGameStatus.unfilteredResultCount == serverUnfilteredCount,
serverUnfilteredResultCount = serverUnfilteredCount,
shardokSyncResults = shardokSyncResults
)
}
}
}
// Only include sync results if there are mismatches (to reduce message size)
val mismatchedResults = gameSyncResults.filter { result =>
!result.eagleInSync || result.shardokSyncResults.exists(!_.inSync)
}
if mismatchedResults.nonEmpty then {
// Include both client and server counts for debugging
val mismatchDetails = mismatchedResults.zip(request.gameSyncStatuses).map {
case (result, clientStatus) =>
val eagleDetail =
if !result.eagleInSync then
s"eagle: client=${clientStatus.unfilteredResultCount} server=${result.serverUnfilteredResultCount}"
else "eagle: in_sync"
val shardokDetails = result.shardokSyncResults.filter(!_.inSync).map { sr =>
val clientCount = clientStatus.shardokSyncStatuses
.find(_.shardokGameId == sr.shardokGameId)
.map(_.filteredResultCount)
.getOrElse(-1)
s"shardok[${sr.shardokGameId}]: client=$clientCount server=${sr.serverFilteredResultCount}"
}
s"game ${result.gameId}: $eagleDetail${
if shardokDetails.nonEmpty then ", " + shardokDetails.mkString(", ") else ""
}"
// Only include sync results if there are mismatches (to reduce message size)
val mismatchedResults = gameSyncResults.filter { result =>
!result.eagleInSync || result.shardokSyncResults.exists(!_.inSync)
}
println(s"[HEARTBEAT] Detected sync mismatches for $userName: ${mismatchDetails.mkString("; ")}")
}
val _ = responseObserver.onNext(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.HeartbeatResponse(
HeartbeatResponse(
serverTimestamp = System.currentTimeMillis(),
gameSyncResults = mismatchedResults
if mismatchedResults.nonEmpty then {
// Include both client and server counts for debugging
val mismatchDetails = mismatchedResults.zip(request.gameSyncStatuses).map {
case (result, clientStatus) =>
val eagleDetail =
if !result.eagleInSync then
s"eagle: client=${clientStatus.unfilteredResultCount} server=${result.serverUnfilteredResultCount}"
else "eagle: in_sync"
val shardokDetails = result.shardokSyncResults.filter(!_.inSync).map { sr =>
val clientCount = clientStatus.shardokSyncStatuses
.find(_.shardokGameId == sr.shardokGameId)
.map(_.filteredResultCount)
.getOrElse(-1)
s"shardok[${sr.shardokGameId}]: client=$clientCount server=${sr.serverFilteredResultCount}"
}
s"game ${result.gameId}: $eagleDetail${
if shardokDetails.nonEmpty then ", " + shardokDetails.mkString(", ") else ""
}"
}
println(s"[HEARTBEAT] Detected sync mismatches for $userName: ${mismatchDetails.mkString("; ")}")
}
val _ = responseObserver.onNext(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.HeartbeatResponse(
HeartbeatResponse(
serverTimestamp = System.currentTimeMillis(),
gameSyncResults = mismatchedResults
)
)
)
)
)
}
}
()
}
@@ -653,6 +662,99 @@ class EagleServiceImpl(
)
}
}
override def getActionDetail(
request: GetActionDetailRequest
): Future[GetActionDetailResponse] = Future {
gamesManager.gameControllerInfos.get(request.gameId) match {
case Some(controllerInfo) =>
val history = controllerInfo.controller.engine.history
val allResults = history.all
if request.actionIndex >= 0 && request.actionIndex < allResults.length then {
val actionWithState = allResults(request.actionIndex)
val actionType = actionWithState.actionResult.`type`.name
// Build a simple JSON representation of the action
val json = buildActionJson(actionType, actionWithState)
GetActionDetailResponse(
gameId = request.gameId,
actionIndex = request.actionIndex,
actionType = actionType,
roundId = actionWithState.gameState.currentRoundId,
actionJson = json
)
} else {
GetActionDetailResponse(
gameId = request.gameId,
actionIndex = request.actionIndex,
actionType = "INVALID_INDEX",
roundId = 0,
actionJson =
s"""{"error": "Action index ${request.actionIndex} out of range (0-${allResults.length - 1})"}"""
)
}
case None =>
GetActionDetailResponse(
gameId = request.gameId,
actionIndex = request.actionIndex,
actionType = "GAME_NOT_FOUND",
roundId = 0,
actionJson = s"""{"error": "Game ${request.gameId} not found"}"""
)
}
}
private def buildActionJson(actionType: String, ar: ActionWithResultingState): String = {
import scala.collection.mutable.ListBuffer
val result = ar.actionResult
val fields = ListBuffer[String]()
fields += s""""type": "$actionType""""
fields += s""""roundId": ${ar.gameState.currentRoundId}"""
result.player.foreach(id => fields += s""""actingFactionId": $id""")
result.province.foreach(id => fields += s""""provinceId": $id""")
result.leader.foreach(id => fields += s""""actingHeroId": $id""")
result.newRoundId.foreach(id => fields += s""""newRoundId": $id""")
if result.newBattle.isDefined then fields += s""""hasBattle": true"""
result.gameEnded.foreach(ended => fields += s""""gameEnded": $ended""")
result.newVictor.foreach(id => fields += s""""victorFactionId": $id""")
if result.changedBattalions.nonEmpty then
fields += s""""changedBattalionCount": ${result.changedBattalions.length}"""
if result.changedFactions.nonEmpty then fields += s""""changedFactionCount": ${result.changedFactions.length}"""
if result.changedHeroes.nonEmpty then fields += s""""changedHeroCount": ${result.changedHeroes.length}"""
if result.changedProvinces.nonEmpty then fields += s""""changedProvinceCount": ${result.changedProvinces.length}"""
if result.newBattalions.nonEmpty then fields += s""""newBattalionCount": ${result.newBattalions.length}"""
if result.newHeroes.nonEmpty then fields += s""""newHeroCount": ${result.newHeroes.length}"""
if result.notificationsToDeliver.nonEmpty then
fields += s""""notificationCount": ${result.notificationsToDeliver.length}"""
s"{${fields.mkString(", ")}}"
}
override def getSettings(
request: GetSettingsRequest
): Future[GetSettingsResponse] = Future {
val allSettings = SettingsLoader.getAllSettings
val filteredSettings =
if request.filter.nonEmpty then allSettings.filter(_.name.toLowerCase.contains(request.filter.toLowerCase))
else allSettings
GetSettingsResponse(
settings = filteredSettings.map { info =>
Setting(
name = info.name,
settingType = info.settingType,
currentValue = info.currentValue,
defaultValue = info.defaultValue
)
}
)
}
}
object EagleServiceImpl {
@@ -85,6 +85,10 @@ object GameController {
case x: StatusRuntimeException if x.getStatus.getCode == Status.Code.CANCELLED =>
None
case _: IllegalStateException =>
// Stream is already completed (client disconnected)
None
case x: StatusRuntimeException =>
SimpleTimedLogger.printLogger.logLine(
s"fid ${client.factionId}: Caught exception $x"
@@ -112,9 +116,9 @@ object GameController {
client.unfilteredKnownHistoryCount
)
val shardokGameIdsToCheck =
engine.currentState.outstandingBattles.map(
(engine.currentState.outstandingBattles.map(
_.shardokGameId
) ++ client.knownShardokResultCounts.map(_.shardokGameId)
) ++ client.knownShardokResultCounts.map(_.shardokGameId)).distinct
val shardokUpdates =
shardokGameIdsToCheck.map { shardokGameId =>
@@ -196,6 +200,10 @@ object GameController {
case x: StatusRuntimeException if x.getStatus.getCode == Status.Code.CANCELLED =>
None
case _: IllegalStateException =>
// Stream is already completed (client disconnected)
None
case x: StatusRuntimeException =>
SimpleTimedLogger.printLogger.logLine(
s"fid ${client.factionId}: Caught exception $x"
@@ -188,20 +188,19 @@ case class HumanPlayerClientConnectionState(
if knownShardokResults.isEmpty then this
else this.enqueueShardokResults(knownShardokResults)
val newHistoryClient = afterShardok.withNewCount(
unfilteredCountAfter = unfilteredCountAfter
// Always send the count update to the client, even when filteredResults is empty.
// Previously, we returned early when filteredResults.isEmpty, which advanced the
// server's tracking without notifying the client. This caused sync mismatches:
// the server thought the client was up-to-date, but the client never received
// the new count. This was particularly problematic after battles ended, when
// AI turns might be filtered out (not visible to the human player).
afterShardok.afterSendingResults(
availableCommands = availableCommands,
startingState = None,
filteredResults = filteredResults,
unfilteredCountAfter = unfilteredCountAfter,
serverGameStatus = serverGameStatus
)
if filteredResults.isEmpty then newHistoryClient
else
newHistoryClient
.afterSendingResults(
availableCommands = availableCommands,
startingState = None,
filteredResults = filteredResults,
unfilteredCountAfter = unfilteredCountAfter,
serverGameStatus = serverGameStatus
)
}
private def withNewCount(
@@ -13,8 +13,10 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_faction_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:faction_destroyed_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:faction_leader_removed_result_type",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province:orders",
@@ -1,7 +1,8 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, SeededRandom}
import net.eagle0.eagle.model.action_result.types.FactionDestroyedResultType
import net.eagle0.eagle.model.action_result.concrete.ChangedFactionC
import net.eagle0.eagle.model.action_result.types.{FactionDestroyedResultType, FactionLeaderRemovedResultType}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.{Army, MovingArmy, Supplies}
import net.eagle0.eagle.model.state.faction.concrete.FactionC
@@ -12,6 +13,7 @@ import net.eagle0.eagle.GameId
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.Inside.inside
import org.scalatest.LoneElement.convertToCollectionLoneElementWrapper
class CheckForFactionChangesActionTest extends AnyFlatSpec with Matchers {
@@ -123,6 +125,100 @@ class CheckForFactionChangesActionTest extends AnyFlatSpec with Matchers {
ar.removedFactionIds shouldBe Vector(fid)
}
}
it should "generate a new faction head notification when the faction head is killed" in {
// Kill hero 13 who is the faction head
val results =
CheckForFactionChangesAction(
gameId = gameId,
factions = factions,
provinces = provinces,
heroes = allHeroes,
killedHeroIds = Vector(13) // The faction head
).randomResults(staticFr).newValue
val factionLeaderRemovedResult = results.find(_.actionResultType == FactionLeaderRemovedResultType)
factionLeaderRemovedResult shouldBe defined
inside(factionLeaderRemovedResult.get) {
case ar: ActionResultT =>
inside(ar.changedFactions.loneElement) {
case cf: ChangedFactionC =>
cf.newFactionHeadHeroId shouldBe Some(12) // New head is next leader
}
ar.newNotifications.loneElement // Should have exactly one notification
ar.newGeneratedTextRequests.loneElement // Should have exactly one LLM request
}
}
it should "not generate a new faction head notification when a non-head leader is killed" in {
// Kill hero 12 who is NOT the faction head (hero 13 is)
val results =
CheckForFactionChangesAction(
gameId = gameId,
factions = factions,
provinces = provinces,
heroes = allHeroes,
killedHeroIds = Vector(12) // A non-head leader (sworn brother)
).randomResults(staticFr).newValue
val factionLeaderRemovedResult = results.find(_.actionResultType == FactionLeaderRemovedResultType)
factionLeaderRemovedResult shouldBe defined
inside(factionLeaderRemovedResult.get) {
case ar: ActionResultT =>
inside(ar.changedFactions.loneElement) {
case cf: ChangedFactionC =>
cf.removedLeaderHeroIds shouldBe Vector(12)
cf.newFactionHeadHeroId shouldBe None // Head didn't change
}
ar.newNotifications shouldBe empty // No notification
ar.newGeneratedTextRequests shouldBe empty // No LLM request
}
}
it should "preserve faction head when first leader in list is killed but head is not first" in {
// Test case where faction head is NOT first in leaderIds list
// This ensures we check the actual factionHeadId, not list position
val factionWithHeadNotFirst = FactionC(
id = 99,
name = "headNotFirst",
factionHeadId = 42, // Head is hero 42
leaderIds = Vector(41, 42, 43) // But 41 is first in list
)
val factionProvince99 = ProvinceC(
id = 99,
rulingFactionId = Some(99),
provinceOrders = Entrust,
rulingHeroId = Some(42),
rulingFactionHeroIds = Vector(41, 42, 43)
)
val heroes99 = Vector(41, 42, 43).map(hid => HeroC(id = hid, factionId = Some(99)))
// Kill hero 41 who is first in leaderIds but NOT the faction head
val results =
CheckForFactionChangesAction(
gameId = gameId,
factions = Vector(factionWithHeadNotFirst),
provinces = Vector(factionProvince99),
heroes = heroes99,
killedHeroIds = Vector(41)
).randomResults(staticFr).newValue
val factionLeaderRemovedResult = results.find(_.actionResultType == FactionLeaderRemovedResultType)
factionLeaderRemovedResult shouldBe defined
inside(factionLeaderRemovedResult.get) {
case ar: ActionResultT =>
inside(ar.changedFactions.loneElement) {
case cf: ChangedFactionC =>
cf.removedLeaderHeroIds shouldBe Vector(41)
cf.newFactionHeadHeroId shouldBe None // Head (42) is still alive, shouldn't change
}
ar.newNotifications shouldBe empty // No notification since head didn't change
ar.newGeneratedTextRequests shouldBe empty
}
}
//
// it should "return any ambassadors to their home provinces" in {
// val factionWithIncomingAmbassadors = factions(fid).update(
@@ -844,12 +844,10 @@ class NewRoundActionTest
val results = runNewRoundAction(GameStateConverter.fromProto(gameState))
val relevantAction = results.find(_.`type` == NEW_ROUND_ACTION).get
relevantAction.newChronicleEntry should contain(
ChronicleEntry(
date = Some(DateProto(year = 252, month = 11)),
generatedTextId = "chronicle_update_252_11"
)
)
val entry = relevantAction.newChronicleEntry.get
entry.date shouldBe Some(DateProto(year = 252, month = 11))
entry.generatedTextId shouldBe "chronicle_update_252_11"
entry.chroniclerStyle should not be empty
}
it should "include a chronicle LLM request if in November" in {
@@ -104,7 +104,8 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
previousEntries = Vector(
ChronicleUpdateMessage.PreviousChronicleEntry(
date = Some(Date(month = 1, year = 342)),
generatedTextId = "previous_chronicle_entry"
generatedTextId = "previous_chronicle_entry",
chroniclerStyle = "" // Unknown chronicler for this older entry
)
),
newEntries = Vector(
@@ -120,11 +121,12 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
)
)
),
currentDate = Some(currentDate)
currentDate = Some(currentDate),
chroniclerStyle = "a modern journalist"
),
gameState = gameStateWithFactions,
clientTextStore = mockClientTextStore,
randomInt = 17
randomInt = 17 // No longer used, kept for backwards compatibility
)
val generatedText = generator.generate match {
@@ -136,13 +138,14 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
|
|${MapDescription.mapDescription}
|
|The first block of text below is the list of previous chronicle entries, with dates. These were probably written by different chroniclers, so the information in them is relevant, but the style may differ. The second block of text lists major events that happened since that last story. The third block of text lists the major surviving factions and what provinces they control. The update should be roughly chronological for the events of the last year, but more thematic, focusing on major trends, for the previous history. Not all the events need to be included, focus on the most important developments during the time period covered. The chronicler's description should not be separated into blocks, but told in medieval terms. Do not include any other context beyond the in-world text of the response itself. Start with a title of no more than five words, then \"=====\" on a separate line, then the main text of the chronicle, like this:
|The first block of text below is the list of previous chronicle entries, with dates. Each entry is labeled with who wrote it (e.g., "Written by Homer:"). Write in the style of a modern journalist. Use the INFORMATION from the previous entries (names, events, factions, outcomes) but do not imitate the writing style of entries written by other chroniclers. The second block of text lists major events that happened since the last story. The third block of text lists the major surviving factions and what provinces they control. The update should be roughly chronological for the events of the last year, but more thematic, focusing on major trends, for the previous history. Not all the events need to be included, focus on the most important developments during the time period covered. The chronicler's description should not be separated into blocks, but told in medieval terms. Do not include any other context beyond the in-world text of the response itself. Start with a title of no more than five words, then \"=====\" on a separate line, then the main text of the chronicle, like this:
|This is a title
|=====
|This is the chronicle text
|---
|FIRST SECTION
|January 342
|Written by an unknown chronicler:
|Last year's title
|=====
|Last year's text
@@ -147,6 +147,9 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero/concrete",
],
)
@@ -104,6 +104,75 @@ class ProvinceDistancesTest extends AnyFlatSpec with Matchers {
) should contain(ProvinceAndDistance(4, 2))
}
"closestProvinceThroughFriendliesOption" should "find closest candidate through friendly territory" in {
val provinces = mapifyProvinces(
makeProvince(1, Some(factionId), NeighborsFor(Vector(2, 3))),
makeProvince(2, Some(factionId), NeighborsFor(Vector(1, 4))),
makeProvince(3, Some(factionId), NeighborsFor(Vector(1, 5))),
makeProvince(4, Some(factionId), NeighborsFor(Vector(2, 6))),
makeProvince(5, Some(factionId), NeighborsFor(Vector(3, 6))),
makeProvince(6, Some(factionId), NeighborsFor(Vector(4, 5)))
)
ProvinceDistances.closestProvinceThroughFriendliesOption(
to = 1,
candidates = Vector(4, 6),
fid = factionId,
provinces = provinces
) should contain(ProvinceAndDistance(4, 2))
}
it should "filter out non-friendly candidates" in {
val otherFaction = 99
val provinces = mapifyProvinces(
makeProvince(1, Some(factionId), NeighborsFor(Vector(2, 3))),
makeProvince(2, Some(factionId), NeighborsFor(Vector(1, 4))),
makeProvince(3, Some(factionId), NeighborsFor(Vector(1, 5))),
makeProvince(4, Some(otherFaction), NeighborsFor(Vector(2))), // owned by other faction
makeProvince(5, Some(factionId), NeighborsFor(Vector(3)))
)
// Candidate 4 is owned by another faction, so should not be returned
ProvinceDistances.closestProvinceThroughFriendliesOption(
to = 1,
candidates = Vector(4, 5),
fid = factionId,
provinces = provinces
) should contain(ProvinceAndDistance(5, 2))
}
it should "return None when no friendly candidates exist" in {
val otherFaction = 99
val provinces = mapifyProvinces(
makeProvince(1, Some(factionId), NeighborsFor(Vector(2))),
makeProvince(2, Some(factionId), NeighborsFor(Vector(1, 3))),
makeProvince(3, Some(otherFaction), NeighborsFor(Vector(2)))
)
ProvinceDistances.closestProvinceThroughFriendliesOption(
to = 1,
candidates = Vector(3),
fid = factionId,
provinces = provinces
) shouldBe empty
}
it should "return None when route is blocked by enemy territory" in {
val otherFaction = 99
val provinces = mapifyProvinces(
makeProvince(1, Some(factionId), NeighborsFor(Vector(2))),
makeProvince(2, Some(otherFaction), NeighborsFor(Vector(1, 3))), // enemy blocks route
makeProvince(3, Some(factionId), NeighborsFor(Vector(2)))
)
ProvinceDistances.closestProvinceThroughFriendliesOption(
to = 1,
candidates = Vector(3),
fid = factionId,
provinces = provinces
) shouldBe empty
}
"closestNeighborToFaction" should "find the neighbor closest to faction territory" in {
val provinces = mapifyProvinces(
makeProvince(1, None, NeighborsFor(Vector(2, 3))),
@@ -1,6 +1,6 @@
package net.eagle0.eagle.library.util
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.common.battalion_type.{BattalionType, BattalionTypeId}
import net.eagle0.eagle.library.settings.{
ActionVigorCost,
@@ -19,8 +19,10 @@ import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.NoProfession
import net.eagle0.eagle.model.state.province.{BlizzardEvent, ImminentRiotEvent}
import net.eagle0.eagle.model.state.province.{BlizzardEvent, ImminentRiotEvent, Neighbor}
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.unaffiliated_hero.concrete.UnaffiliatedHeroC
import net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroType.Traveler
import net.eagle0.eagle.model.state.BattalionTypeId.{
HeavyCavalry,
HeavyInfantry,
@@ -284,4 +286,170 @@ class ProvinceUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
ProvinceUtils.hasImminentRiot(province) shouldBe false
}
// Tests for locationOf
"locationOf" should "find hero in ruling faction heroes" in {
val provinces = Vector(
ProvinceC(id = 1, rulingFactionHeroIds = Vector(10, 11)),
ProvinceC(id = 2, rulingFactionHeroIds = Vector(20, 21)),
ProvinceC(id = 3, rulingFactionHeroIds = Vector(30))
)
ProvinceUtils.locationOf(20, provinces) shouldBe Some(2)
}
it should "find hero in unaffiliated heroes" in {
val provinces = Vector(
ProvinceC(id = 1, rulingFactionHeroIds = Vector(10)),
ProvinceC(
id = 2,
unaffiliatedHeroes = Vector(
UnaffiliatedHeroC(heroId = 50, unaffiliatedHeroType = Traveler)
)
)
)
ProvinceUtils.locationOf(50, provinces) shouldBe Some(2)
}
it should "return None when hero is not found" in {
val provinces = Vector(
ProvinceC(id = 1, rulingFactionHeroIds = Vector(10)),
ProvinceC(id = 2, rulingFactionHeroIds = Vector(20))
)
ProvinceUtils.locationOf(99, provinces) shouldBe None
}
it should "return None for empty provinces" in {
ProvinceUtils.locationOf(10, Vector.empty) shouldBe None
}
// Tests for containsFactionLeader
"containsFactionLeader" should "return true when faction head is present" in {
val province = ProvinceC(
id = 1,
rulingFactionId = Some(factionId),
rulingFactionHeroIds = Vector(factionHeadId, leaderIdLow)
)
ProvinceUtils.containsFactionLeader(province, Vector(actingFaction)) shouldBe true
}
it should "return false when faction head is not present" in {
val province = ProvinceC(
id = 1,
rulingFactionId = Some(factionId),
rulingFactionHeroIds = Vector(leaderIdLow, vassalIdLow)
)
ProvinceUtils.containsFactionLeader(province, Vector(actingFaction)) shouldBe false
}
it should "return false when province has no ruling faction" in {
val province = ProvinceC(
id = 1,
rulingFactionId = None,
rulingFactionHeroIds = Vector(factionHeadId)
)
ProvinceUtils.containsFactionLeader(province, Vector(actingFaction)) shouldBe false
}
it should "return false when faction is not in provided factions" in {
val otherFactionId: FactionId = 99
val province = ProvinceC(
id = 1,
rulingFactionId = Some(otherFactionId),
rulingFactionHeroIds = Vector(factionHeadId)
)
ProvinceUtils.containsFactionLeader(province, Vector(actingFaction)) shouldBe false
}
// Tests for adjacentHostiles
"adjacentHostiles" should "return provinces ruled by different factions" in {
val myFactionId: FactionId = 1
val enemyFactionId: FactionId = 2
val myProvince = ProvinceC(
id = 10,
rulingFactionId = Some(myFactionId),
neighbors = Vector(
Neighbor(provinceId = 20, startingPositionIndex = 0),
Neighbor(provinceId = 30, startingPositionIndex = 1)
)
)
val enemyProvince = ProvinceC(
id = 20,
rulingFactionId = Some(enemyFactionId)
)
val neutralProvince = ProvinceC(
id = 30,
rulingFactionId = None
)
val provincesMap = Map(
myProvince.id -> myProvince,
enemyProvince.id -> enemyProvince,
neutralProvince.id -> neutralProvince
)
val result = ProvinceUtils.adjacentHostiles(myProvince, provincesMap)
result should have length 1
result.head.id shouldBe 20
}
it should "not include unruled provinces" in {
val myFactionId: FactionId = 1
val myProvince = ProvinceC(
id = 10,
rulingFactionId = Some(myFactionId),
neighbors = Vector(Neighbor(provinceId = 20, startingPositionIndex = 0))
)
val unruledProvince = ProvinceC(
id = 20,
rulingFactionId = None
)
val provincesMap = Map(
myProvince.id -> myProvince,
unruledProvince.id -> unruledProvince
)
ProvinceUtils.adjacentHostiles(myProvince, provincesMap) shouldBe empty
}
it should "not include own provinces" in {
val myFactionId: FactionId = 1
val myProvince = ProvinceC(
id = 10,
rulingFactionId = Some(myFactionId),
neighbors = Vector(Neighbor(provinceId = 20, startingPositionIndex = 0))
)
val friendlyProvince = ProvinceC(
id = 20,
rulingFactionId = Some(myFactionId)
)
val provincesMap = Map(
myProvince.id -> myProvince,
friendlyProvince.id -> friendlyProvince
)
ProvinceUtils.adjacentHostiles(myProvince, provincesMap) shouldBe empty
}
it should "return empty for province with no neighbors" in {
val myProvince = ProvinceC(
id = 10,
rulingFactionId = Some(1),
neighbors = Vector.empty
)
ProvinceUtils.adjacentHostiles(myProvince, Map(myProvince.id -> myProvince)) shouldBe empty
}
}
@@ -9,6 +9,7 @@ import net.eagle0.eagle.api.selected_command.AttackDecisionSelectedCommand
import net.eagle0.eagle.common.battalion_type.BattalionTypeId.LIGHT_INFANTRY
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.date.Date
import net.eagle0.eagle.common.round_phase.RoundPhase
import net.eagle0.eagle.internal.army.{Army, AwaitingDecision, HostileArmyGroup, MovingArmy}
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.internal.faction.Faction
@@ -18,6 +19,7 @@ import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.MinimumPercentageOfEnemyToAttack
import net.eagle0.eagle.library.util.BattalionTypesTestData
import net.eagle0.eagle.library.util.IDable.{mapifyBattalions, mapifyFactions, mapifyHeroes, mapifyProvinces}
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
@@ -111,6 +113,7 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
// Create GameState
val gameState = GameState(
currentDate = Some(Date(year = 501, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
battalionTypes = battalionTypes,
provinces = mapifyProvinces(province),
battalions = mapifyBattalions(battalion1, battalion2, battalion3),
@@ -146,14 +149,14 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
// Test the actual AttackDecisionCommandChooser logic
val result = AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId = 1,
gameState = gameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = Vector(availableCommand),
functionalRandom = SeededRandom(42)
)
val decision = result.newValue.get
decision.available should be(availableCommand)
decision.available.should(be(availableCommand))
inside(decision.selected) {
case AttackDecisionSelectedCommand(selectedDecision, _) =>
selectedDecision shouldBe a[AdvanceDecision]
@@ -224,6 +227,7 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
// Create GameState
val gameState = GameState(
currentDate = Some(Date(year = 501, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
battalionTypes = battalionTypes,
provinces = mapifyProvinces(province),
battalions = mapifyBattalions(weakBattalion, strongBattalion),
@@ -258,7 +262,7 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
// Test the actual decision
val result = AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId = 1,
gameState = gameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = Vector(availableCommand),
functionalRandom = SeededRandom(42)
)
@@ -266,7 +270,7 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
val decision = result.newValue.get
// With weak friendly vs strong enemy, it should withdraw
decision.available should be(availableCommand)
decision.available.should(be(availableCommand))
inside(decision.selected) {
case AttackDecisionSelectedCommand(selectedDecision, _) =>
selectedDecision shouldBe a[WithdrawDecision]
@@ -354,6 +358,7 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
val gameState = GameState(
currentDate = Some(Date(year = 501, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
battalionTypes = battalionTypes,
provinces = mapifyProvinces(province),
battalions = mapifyBattalions(battalion1, battalion2, battalion3, enemyBattalion),
@@ -388,14 +393,14 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
val result = AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId = 1,
gameState = gameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = Vector(availableCommand),
functionalRandom = SeededRandom(42)
)
val decision = result.newValue.get
decision.available should be(availableCommand)
decision.available.should(be(availableCommand))
inside(decision.selected) {
case AttackDecisionSelectedCommand(selected, _) =>
selected shouldBe a[AdvanceDecision]
@@ -484,6 +489,7 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
val gameState = GameState(
currentDate = Some(Date(year = 501, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
battalionTypes = battalionTypes,
provinces = mapifyProvinces(province),
battalions = mapifyBattalions(
@@ -523,7 +529,7 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
val result = AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId = 1,
gameState = gameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = Vector(availableCommand),
functionalRandom = SeededRandom(42)
)
@@ -535,7 +541,7 @@ class AttackDecisionCommandChooserTest extends AnyFlatSpec with Matchers with Be
// - Enemy power (baseline): 2000 × 1.5 = 3000
// - Threshold: 0.6 × 3000 = 1800
// - 600 < 1800, so should withdraw
decision.available should be(availableCommand)
decision.available.should(be(availableCommand))
inside(decision.selected) {
case AttackDecisionSelectedCommand(selectedDecision, _) =>
selectedDecision shouldBe a[WithdrawDecision]
@@ -96,6 +96,7 @@ scala_test(
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto",
@@ -107,6 +108,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:idable",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/test/scala/net/eagle0/eagle/library/util:battalion_types_test_data",
],
)
@@ -168,6 +168,7 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
Battalion(id = 85, `type` = BattalionTypeId.HEAVY_CAVALRY, size = 600)
),
currentDate = Some(Date(year = 353, month = 10)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
battalionTypes = battalionTypes
)
@@ -219,6 +220,7 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
Battalion(id = 85, `type` = BattalionTypeId.HEAVY_CAVALRY, size = 600)
),
currentDate = Some(Date(year = 353, month = 10)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
battalionTypes = battalionTypes
)
@@ -270,6 +272,7 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
Battalion(id = 85, `type` = BattalionTypeId.HEAVY_CAVALRY, size = 600)
),
currentDate = Some(Date(year = 353, month = 10)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
battalionTypes = battalionTypes
)
@@ -310,6 +313,7 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
Battalion(id = 85, `type` = BattalionTypeId.HEAVY_CAVALRY, size = 600)
),
currentDate = Some(Date(year = 353, month = 10)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
battalionTypes = battalionTypes
)
@@ -324,6 +328,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
"excessBeyondMaximumSupplies" should "return the available gold if battalions are already full" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -354,6 +360,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "return less gold if a battalion needs armament" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -384,6 +392,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "return less gold if battalions are not full" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -409,6 +419,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "return less gold for a more expensive battalion" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -439,6 +451,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "return less gold if we have heroes to support another battalion" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -469,6 +483,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "hold back 12 months worth of food for our battalions" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -499,6 +515,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "hold back as if battalions are full" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -529,6 +547,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "hold back for an extra heavy cavalry if there's another hero available" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -559,6 +579,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "send nothing if there is not enough gold or food" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -590,6 +612,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "send nothing if support is not up" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
@@ -620,7 +644,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
}
private val getUnderHeroCapGameState = GameState(
currentDate = Some(Date()),
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
heroes = mapifyHeroes((1 to 100).map(hid => Hero(id = hid)).toVector*),
provinces = mapifyProvinces(
Province(
@@ -812,6 +837,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
)
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
factions = mapifyFactions(Faction(id = 17, leaders = Vector(1))),
provinces = mapifyProvinces(
Province(
@@ -857,6 +884,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
)
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
factions = mapifyFactions(Faction(id = 17, leaders = Vector(1))),
provinces = mapifyProvinces(
Province(
@@ -919,6 +948,8 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
)
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 1)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
factions = mapifyFactions(Faction(id = 17, leaders = Vector(1))),
provinces = mapifyProvinces(
Province(
@@ -974,6 +1005,7 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
"foodAmountToBuy" should "buy in December if we're out of food" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 12)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
factions = mapifyFactions(Faction(id = 17, leaders = Vector(1))),
provinces = mapifyProvinces(
Province(
@@ -1005,6 +1037,7 @@ class CommandChoiceHelpersTest extends AnyFlatSpec with Matchers with BeforeAndA
it should "buy more if support is not up" in {
val gameState = GameState(
currentDate = Some(Date(year = 323, month = 12)),
currentPhase = RoundPhase.PLAYER_COMMANDS,
factions = mapifyFactions(Faction(id = 17, leaders = Vector(1))),
provinces = mapifyProvinces(
Province(
@@ -5,8 +5,6 @@ import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.api.available_command.DiplomacyAvailableCommand
import net.eagle0.eagle.api.command.util.diplomacy_option.{AllianceOption, TruceOption}
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.{AllianceQuest, Quest as QuestProto}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.library.settings.MinimumTrustToOfferAlliance
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.faction.concrete.FactionC
@@ -14,6 +12,7 @@ import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.{Ally, Hostile}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.quest.concrete.AllianceQuest
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -113,10 +112,8 @@ class AllianceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAfterEac
private val uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = 19,
uh = UnaffiliatedHeroProto(
heroId = 918
),
quest = QuestProto(details = AllianceQuest())
heroId = 918,
quest = AllianceQuest
)
)
@@ -3,9 +3,6 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.AlmsAvailableCommand
import net.eagle0.eagle.api.selected_command.AlmsSelectedCommand
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{AlmsAcrossRealmQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.library.settings.{AlmsSupportIncreasePerFood, MinSupportForTaxes}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.date.Date
@@ -14,6 +11,7 @@ import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{AlmsAcrossRealmQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -31,30 +29,16 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
private val uh1Id: HeroId = 57
private val uh2Id: HeroId = 67
private val unaffiliatedHeroSameProvinceProto = UnaffiliatedHeroProto(
heroId = uh1Id,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = AlmsAcrossRealmQuest(totalFood = 597)
)
)
)
)
private val almsAcrossRealmQuest597 = AlmsAcrossRealmQuest(
componentCount = 1,
componentsFulfilled = 0,
totalFood = 597
)
private val unaffiliatedHeroDifferentProvinceProto = UnaffiliatedHeroProto(
heroId = uh2Id,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = AlmsAcrossRealmQuest(totalFood = 1565)
)
)
)
)
private val almsAcrossRealmQuest1565 = AlmsAcrossRealmQuest(
componentCount = 1,
componentsFulfilled = 0,
totalFood = 1565
)
private def createGameState(
@@ -143,19 +127,21 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroSameProvinceProto,
quest = unaffiliatedHeroSameProvinceProto.getRecruitmentInfo.getQuest
heroId = uh1Id,
quest = almsAcrossRealmQuest597
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
)
)
)
}
@@ -169,30 +155,19 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroSameProvinceProto,
quest = unaffiliatedHeroSameProvinceProto.getRecruitmentInfo.getQuest
heroId = uh1Id,
quest = almsAcrossRealmQuest597
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
it should "return nothing if the hero has a different quest" in {
val newQuest = Quest(
details = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
)
val differentQuestHero = UnaffiliatedHeroProto(
heroId = uh1Id,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(newQuest)
)
)
val differentQuest = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val selectedCommand =
@@ -203,13 +178,13 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = differentQuestHero,
quest = newQuest
heroId = uh1Id,
quest = differentQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
it should "return an alms command with more food for an unaffiliated hero with that quest in another province" in {
@@ -221,24 +196,26 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroSameProvinceProto,
quest = unaffiliatedHeroSameProvinceProto.getRecruitmentInfo.getQuest
heroId = uh1Id,
quest = almsAcrossRealmQuest597
),
UnaffiliatedHeroWithQuest(
pid = ownedProvinceId,
uh = unaffiliatedHeroDifferentProvinceProto,
quest = unaffiliatedHeroDifferentProvinceProto.getRecruitmentInfo.getQuest
heroId = uh2Id,
quest = almsAcrossRealmQuest1565
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 1000, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 1000, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
)
)
)
}
@@ -252,24 +229,26 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroSameProvinceProto,
quest = unaffiliatedHeroSameProvinceProto.getRecruitmentInfo.getQuest
heroId = uh1Id,
quest = almsAcrossRealmQuest597
),
UnaffiliatedHeroWithQuest(
pid = ownedProvinceId,
uh = unaffiliatedHeroDifferentProvinceProto,
quest = Quest(details = AlmsAcrossRealmQuest(totalFood = 221))
heroId = uh2Id,
quest = AlmsAcrossRealmQuest(componentCount = 1, componentsFulfilled = 0, totalFood = 221)
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
)
)
)
}
@@ -3,9 +3,6 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.AlmsAvailableCommand
import net.eagle0.eagle.api.selected_command.AlmsSelectedCommand
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{AlmsToProvinceQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.library.settings.{AlmsSupportIncreasePerFood, MinSupportForTaxes}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.date.Date
@@ -14,6 +11,7 @@ import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{AlmsToProvinceQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -27,21 +25,11 @@ class AlmsToProvinceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAf
private val factionLeaderId: HeroId = 5
private val heroId: HeroId = 57
// Proto types for quest data (still needed for the API)
private val unaffiliatedHeroProto = UnaffiliatedHeroProto(
heroId = heroId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = AlmsToProvinceQuest(
totalFood = 597,
provinceId = actingProvinceId
)
)
)
)
)
private val almsToProvinceQuest = AlmsToProvinceQuest(
componentCount = 1,
componentsFulfilled = 0,
provinceId = actingProvinceId,
totalFood = 597
)
private def createGameState(
@@ -119,19 +107,21 @@ class AlmsToProvinceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAf
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = unaffiliatedHeroProto.getRecruitmentInfo.getQuest
heroId = heroId,
quest = almsToProvinceQuest
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsToProvince quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsToProvince quest"
)
)
)
}
@@ -145,30 +135,19 @@ class AlmsToProvinceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAf
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = unaffiliatedHeroProto.getRecruitmentInfo.getQuest
heroId = heroId,
quest = almsToProvinceQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
it should "return nothing if the hero has a different quest" in {
val newQuest = Quest(
details = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
)
val differentQuestHero = UnaffiliatedHeroProto(
heroId = heroId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(newQuest)
)
)
val differentQuest = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val selectedCommand =
@@ -179,12 +158,12 @@ class AlmsToProvinceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAf
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = differentQuestHero,
quest = newQuest
heroId = heroId,
quest = differentQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
}
@@ -17,6 +17,7 @@ scala_test(
"//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/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -39,6 +40,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -61,6 +63,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -82,6 +85,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -102,6 +106,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
"//src/test/scala/net/eagle0/common:proto_matchers",
],
@@ -123,6 +128,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
"//src/test/scala/net/eagle0/common:proto_matchers",
],
@@ -4,9 +4,6 @@ import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{ExileVassalAvailableCommand, ImproveAvailableCommand}
import net.eagle0.eagle.api.selected_command.ExileVassalSelectedCommand
import net.eagle0.eagle.common.improvement_type.ImprovementType.{AGRICULTURE, ECONOMY}
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{DismissSpecificVassalQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.library.settings.RequiredPowerMultiplierForDismiss
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.faction.concrete.FactionC
@@ -14,6 +11,7 @@ import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.hero.Profession.{Engineer, Mage, NoProfession}
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{DismissSpecificVassalQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -60,18 +58,7 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
profession = Mage
)
private val unaffiliatedHeroProto = UnaffiliatedHeroProto(
heroId = strongFreeHeroId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = DismissSpecificVassalQuest(targetHeroId = weakVassalId)
)
)
)
)
)
private val dismissWeakVassalQuest = DismissSpecificVassalQuest(targetHeroId = weakVassalId)
private def createGameState(
rulingFactionHeroIds: Vector[HeroId] = Vector(factionLeaderId, weakVassalId, strongVassalId)
@@ -137,21 +124,21 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = DismissSpecificVassalQuest(weakVassalId)
)
heroId = strongFreeHeroId,
quest = dismissWeakVassalQuest
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = provinceId,
available = exileVassalsCommand,
selected = ExileVassalSelectedCommand(exiledHeroId = weakVassalId),
reason = "fulfill DismissSpecificVassal quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = provinceId,
available = exileVassalsCommand,
selected = ExileVassalSelectedCommand(exiledHeroId = weakVassalId),
reason = "fulfill DismissSpecificVassal quest"
)
)
)
}
@@ -165,10 +152,8 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = ImproveAgricultureQuest(provinceId = provinceId, desiredValue = 32)
)
heroId = strongFreeHeroId,
quest = ImproveAgricultureQuest(provinceId = provinceId, desiredValue = 32)
)
)
)
@@ -187,10 +172,8 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = DismissSpecificVassalQuest(weakVassalId)
)
heroId = strongFreeHeroId,
quest = dismissWeakVassalQuest
)
)
)
@@ -207,10 +190,8 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = DismissSpecificVassalQuest(strongVassalId)
)
heroId = strongFreeHeroId,
quest = DismissSpecificVassalQuest(targetHeroId = strongVassalId)
)
)
)
@@ -229,10 +210,8 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = DismissSpecificVassalQuest(weakVassalId)
)
heroId = strongFreeHeroId,
quest = dismissWeakVassalQuest
)
)
)
@@ -4,13 +4,11 @@ import net.eagle0.common.ProtoMatchers.equalProto
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{EligibleGift, HeroGiftAvailableCommand}
import net.eagle0.eagle.api.selected_command.HeroGiftSelectedCommand
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{GiveToHeroesAcrossRealmQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{GiveToHeroesAcrossRealmQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -30,30 +28,10 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
private val freeHeroInProvinceId: HeroId = 57
private val freeHeroOtherProvinceId: HeroId = 58
private val unaffiliatedHeroInProvinceProto = UnaffiliatedHeroProto(
heroId = freeHeroInProvinceId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = GiveToHeroesAcrossRealmQuest(totalGold = 397)
)
)
)
)
)
private val unaffiliatedHeroOtherProvinceProto = UnaffiliatedHeroProto(
heroId = freeHeroInProvinceId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = GiveToHeroesAcrossRealmQuest(totalGold = 397)
)
)
)
)
private val giveToHeroesQuest = GiveToHeroesAcrossRealmQuest(
componentCount = 1,
componentsFulfilled = 0,
totalGold = 397
)
private def createGameState(): GameState = {
@@ -144,8 +122,8 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroInProvinceProto,
quest = unaffiliatedHeroInProvinceProto.getRecruitmentInfo.getQuest
heroId = freeHeroInProvinceId,
quest = giveToHeroesQuest
)
)
)
@@ -176,9 +154,8 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroInProvinceProto,
quest = unaffiliatedHeroInProvinceProto.getRecruitmentInfo.getQuest
.withComponentsFulfilled(350)
heroId = freeHeroInProvinceId,
quest = giveToHeroesQuest.copy(componentsFulfilled = 350)
)
)
)
@@ -200,11 +177,9 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
}
it should "return nothing if the hero has a different quest" in {
val newQuest = Quest(
details = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val differentQuest = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val selectedCommand =
@@ -215,13 +190,13 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroInProvinceProto,
quest = newQuest
heroId = freeHeroInProvinceId,
quest = differentQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
it should "still give for a UH in another province" in {
@@ -234,8 +209,8 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = ownedProvinceId,
uh = unaffiliatedHeroOtherProvinceProto,
quest = unaffiliatedHeroOtherProvinceProto.getRecruitmentInfo.getQuest
heroId = freeHeroOtherProvinceId,
quest = giveToHeroesQuest
)
)
)
@@ -4,13 +4,11 @@ import net.eagle0.common.ProtoMatchers.equalProto
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{EligibleGift, HeroGiftAvailableCommand}
import net.eagle0.eagle.api.selected_command.HeroGiftSelectedCommand
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{GiveToHeroesInProvinceQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{GiveToHeroesInProvinceQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -27,20 +25,11 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
private val vassalId: HeroId = 10
private val freeHeroId: HeroId = 57
private val unaffiliatedHeroProto = UnaffiliatedHeroProto(
heroId = freeHeroId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = GiveToHeroesInProvinceQuest(
totalGold = 397,
provinceId = actingProvinceId
)
)
)
)
)
private val giveToHeroesQuest = GiveToHeroesInProvinceQuest(
componentCount = 1,
componentsFulfilled = 0,
provinceId = actingProvinceId,
totalGold = 397
)
private def createGameState(): GameState = {
@@ -117,8 +106,8 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = unaffiliatedHeroProto.getRecruitmentInfo.getQuest
heroId = freeHeroId,
quest = giveToHeroesQuest
)
)
)
@@ -149,9 +138,8 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = unaffiliatedHeroProto.getRecruitmentInfo.getQuest
.withComponentsFulfilled(350)
heroId = freeHeroId,
quest = giveToHeroesQuest.copy(componentsFulfilled = 350)
)
)
)
@@ -173,11 +161,9 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
}
it should "return nothing if the hero has a different quest" in {
val newQuest = Quest(
details = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val differentQuest = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val selectedCommand =
@@ -188,12 +174,12 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = newQuest
heroId = freeHeroId,
quest = differentQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
}
@@ -8,6 +8,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//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/state/province/concrete",
],
)
@@ -1,7 +1,9 @@
package net.eagle0.eagle.library.util.faction_utils
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
@@ -250,4 +252,129 @@ class FactionUtilsTest extends AnyFlatSpec with Matchers {
FactionUtils.hasTruceOrAlliance(1, 2, factions) shouldBe false
}
// Tests for isFactionHead
"isFactionHead" should "return true when hero is a faction head" in {
val factionHeadId: HeroId = 10
val factions = Vector(
FactionC(id = 1, factionHeadId = factionHeadId, name = "Faction 1", leaderIds = Vector(factionHeadId, 20))
)
FactionUtils.isFactionHead(factionHeadId, factions) shouldBe true
}
it should "return false when hero is a leader but not the head" in {
val factionHeadId: HeroId = 10
val swornBrotherId: HeroId = 20
val factions = Vector(
FactionC(
id = 1,
factionHeadId = factionHeadId,
name = "Faction 1",
leaderIds = Vector(factionHeadId, swornBrotherId)
)
)
FactionUtils.isFactionHead(swornBrotherId, factions) shouldBe false
}
it should "return false when hero is not in any faction" in {
val factions = Vector(
FactionC(id = 1, factionHeadId = 10, name = "Faction 1", leaderIds = Vector(10))
)
FactionUtils.isFactionHead(99, factions) shouldBe false
}
it should "check across multiple factions" in {
val factions = Vector(
FactionC(id = 1, factionHeadId = 10, name = "Faction 1", leaderIds = Vector(10)),
FactionC(id = 2, factionHeadId = 20, name = "Faction 2", leaderIds = Vector(20))
)
FactionUtils.isFactionHead(20, factions) shouldBe true
}
// Tests for leadersBesidesHead
"leadersBesidesHead" should "return sworn brothers excluding the faction head" in {
val faction = FactionC(
id = 1,
factionHeadId = 10,
name = "Faction 1",
leaderIds = Vector(10, 20, 30)
)
FactionUtils.leadersBesidesHead(faction) shouldBe Vector(20, 30)
}
it should "return empty vector when faction head is the only leader" in {
val faction = FactionC(
id = 1,
factionHeadId = 10,
name = "Faction 1",
leaderIds = Vector(10)
)
FactionUtils.leadersBesidesHead(faction) shouldBe empty
}
it should "return empty vector when there are no leaders" in {
val faction = FactionC(
id = 1,
factionHeadId = 10,
name = "Faction 1",
leaderIds = Vector.empty
)
FactionUtils.leadersBesidesHead(faction) shouldBe empty
}
// Tests for hasProvinces
"hasProvinces" should "return true when faction has provinces" in {
val factionId: FactionId = 1
val provinces = Vector(
ProvinceC(id = 10, rulingFactionId = Some(factionId)),
ProvinceC(id = 20, rulingFactionId = Some(2))
)
FactionUtils.hasProvinces(factionId, provinces) shouldBe true
}
it should "return false when faction has no provinces" in {
val provinces = Vector(
ProvinceC(id = 10, rulingFactionId = Some(2)),
ProvinceC(id = 20, rulingFactionId = None)
)
FactionUtils.hasProvinces(1, provinces) shouldBe false
}
it should "return false for empty provinces" in {
FactionUtils.hasProvinces(1, Vector.empty) shouldBe false
}
// Tests for provinceCount
"provinceCount" should "return correct count of faction's provinces" in {
val factionId: FactionId = 1
val provinces = Vector(
ProvinceC(id = 10, rulingFactionId = Some(factionId)),
ProvinceC(id = 20, rulingFactionId = Some(factionId)),
ProvinceC(id = 30, rulingFactionId = Some(2)),
ProvinceC(id = 40, rulingFactionId = None)
)
FactionUtils.provinceCount(factionId, provinces) shouldBe 2
}
it should "return 0 when faction has no provinces" in {
val provinces = Vector(
ProvinceC(id = 10, rulingFactionId = Some(2))
)
FactionUtils.provinceCount(1, provinces) shouldBe 0
}
it should "return 0 for empty provinces" in {
FactionUtils.provinceCount(1, Vector.empty) shouldBe 0
}
}
@@ -1,5 +1,20 @@
load("@rules_scala//scala:scala.bzl", "scala_test")
scala_test(
name = "hero_utils_test",
srcs = ["HeroUtilsTest.scala"],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_agility_for_archery",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_agility_for_start_fire",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_wisdom_for_start_fire",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
],
)
scala_test(
name = "legacy_hero_utils_test",
srcs = ["LegacyHeroUtilsTest.scala"],
@@ -0,0 +1,95 @@
package net.eagle0.eagle.library.util.hero
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.library.settings.{
MinimumAgilityForArchery,
MinimumAgilityForStartFire,
MinimumWisdomForStartFire
}
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.hero.Profession.{Mage, NoProfession}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
class HeroUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAfterEach {
override def beforeEach(): Unit = {
MinimumAgilityForArchery.setDoubleValue(60.0)
MinimumAgilityForStartFire.setDoubleValue(70.0)
MinimumWisdomForStartFire.setDoubleValue(70.0)
}
val factionId: FactionId = 1
val factionHeadId: HeroId = 10
val swornBrother1Id: HeroId = 20
val swornBrother2Id: HeroId = 30
val vassalId: HeroId = 40
val faction: FactionC = FactionC(
id = factionId,
name = "Test Faction",
factionHeadId = factionHeadId,
leaderIds = Vector(factionHeadId, swornBrother1Id, swornBrother2Id)
)
// Tests for seniorityOrder
"seniorityOrder" should "return 0 for the faction head" in {
HeroUtils.seniorityOrder(faction, factionHeadId) shouldBe 0
}
it should "return 1 for the first sworn brother" in {
HeroUtils.seniorityOrder(faction, swornBrother1Id) shouldBe 1
}
it should "return 2 for the second sworn brother" in {
HeroUtils.seniorityOrder(faction, swornBrother2Id) shouldBe 2
}
it should "return Int.MaxValue for a non-leader" in {
HeroUtils.seniorityOrder(faction, vassalId) shouldBe Int.MaxValue
}
// Tests for archeryCapable
"archeryCapable" should "return true when agility meets minimum" in {
val hero = HeroC(id = 1, agility = 60, profession = NoProfession)
HeroUtils.archeryCapable(hero) shouldBe true
}
it should "return true when agility exceeds minimum" in {
val hero = HeroC(id = 1, agility = 80, profession = NoProfession)
HeroUtils.archeryCapable(hero) shouldBe true
}
it should "return false when agility is below minimum" in {
val hero = HeroC(id = 1, agility = 59, profession = NoProfession)
HeroUtils.archeryCapable(hero) shouldBe false
}
// Tests for startFireCapable
"startFireCapable" should "return true for a Mage regardless of stats" in {
val hero = HeroC(id = 1, agility = 10, wisdom = 10, profession = Mage)
HeroUtils.startFireCapable(hero) shouldBe true
}
it should "return true for non-Mage with sufficient agility and wisdom" in {
val hero = HeroC(id = 1, agility = 70, wisdom = 70, profession = NoProfession)
HeroUtils.startFireCapable(hero) shouldBe true
}
it should "return false for non-Mage with insufficient agility" in {
val hero = HeroC(id = 1, agility = 60, wisdom = 80, profession = NoProfession)
HeroUtils.startFireCapable(hero) shouldBe false
}
it should "return false for non-Mage with insufficient wisdom" in {
val hero = HeroC(id = 1, agility = 80, wisdom = 60, profession = NoProfession)
HeroUtils.startFireCapable(hero) shouldBe false
}
it should "return false for non-Mage with both stats below minimum" in {
val hero = HeroC(id = 1, agility = 50, wisdom = 50, profession = NoProfession)
HeroUtils.startFireCapable(hero) shouldBe false
}
}