Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 a56567449a Remove legacy polling wait and add exponential backoff to reconnect
Changes:
- Remove 5-second wait in GetUpdates that was used for long-polling.
  With streaming, WaitForUpdatesAndPush already waits for updates before
  calling GetUpdates, making this redundant. GetGameStatus is deprecated.
- Add exponential backoff to Eagle's stream reconnection logic, starting
  at 1 second and doubling up to 30 seconds max.

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

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

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

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

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

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

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

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

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

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

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

Also added currentGameState to AllUpdates struct.

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

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

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

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

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

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

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

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

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

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

* Update DEPROTO_PLAN with ProvinceViewFilter progress

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

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 16:36:31 -08:00
1e019c533a Add force reconnect button when server is down (#4748)
- Adds a "Retry" button next to connection status that appears when:
  - Circuit breaker is in Open state (server down)
  - Connection is counting down to a retry attempt
- Clicking the button forces an immediate reconnection attempt,
  bypassing timeouts
- Button is hidden when connected or actively connecting

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

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

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

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

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

This reduces lock hold time from seconds to milliseconds.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Improve comments explaining Shardok polling logic

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-21 10:39:04 -08:00
6402a8c283 Remove OnApplicationPause debug logging (#4744)
Also includes UI layout adjustments in Gameplay.unity.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Add lastCommand field to ProvinceT/ProvinceC

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

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

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

* Fix ProvinceConverter to use typed lastCommand

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

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

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

* Remove unnecessary default arguments from view case classes

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

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

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

* Add lastCommandTypeForActingProvince to ActionResultT and apply in ActionResultApplierImpl

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

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

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

* Fix ActionResultProtoConverter to include lastCommandTypeForActingProvince

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

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 20:04:11 -08:00
f73798ae6e Register ErrorHandler in Awake to catch startup exceptions (#4739)
Move Application.logMessageReceivedThreaded registration from Start()
to Awake() so exceptions during initialization are captured.

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

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

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

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

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

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

  MainQueue → ReceiveGameUpdate → HandleUpdates → UpdateAction → ModelUpdated

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

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

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

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

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

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

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

Now checks RowCount before accessing to skip stale indices.

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

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

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

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

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

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

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

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

Added logging to track pause/resume events for debugging.

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

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

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

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

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

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

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

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

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

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

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

The fix adds scoped_lock protection to prevent concurrent access.

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

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

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

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

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 16:59:55 -08:00
75 changed files with 2741 additions and 599 deletions
+23 -17
View File
@@ -201,30 +201,31 @@ The following actions still have proto usage, blocked by utility dependencies:
| Action | Proto Usage | Blocker |
|--------|-------------|---------|
| `EndBattleAftermathPhaseAction` | 1 `toProto` call | `ProvinceViewFilter` needs Scala types |
| `EndBattleAftermathPhaseAction` | **Unblocked** | `ProvinceViewFilter` Scala overload added (PR #4752) |
| `NewRoundAction` | 1 `fromProto` call | `ChronicleEventGenerator` returns proto |
| `EndHandleRiotsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `PerformVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `PerformVassalDefenseDecisionsAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `EndVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `PerformReconResolutionAction` | Uses `ProvinceViewFilter` | `ProvinceViewFilter` needs Scala types |
| `PerformReconResolutionAction` | **Unblocked** | `ProvinceViewFilter` Scala overload added (PR #4752) |
| `ResolveBattleAction` | Heavy proto usage | Large refactor needed |
### Estimated Effort (Remaining)
| Component | Lines | Complexity |
|-----------|-------|------------|
| `ProvinceViewFilter` to Scala | ~150 | Medium |
| `ProvinceViewFilter` faction-filtered views | ~100 | Medium (PR #4752 completed server-side) |
| `CommandChoiceHelpers` to Scala | ~2000 | High |
| `ChronicleEventGenerator` to Scala | ~400 | Medium |
| History API updates | ~100 | Low |
| **Total Remaining** | **~2650** | |
| **Total Remaining** | **~2600** | |
### Validation
- [x] `ActionResultApplier` created and tested
- [x] `RandomStateSequencer` threads Scala GameState throughout
- [x] `RoundPhaseAdvancer` uses T-types internally
- [ ] `ProvinceViewFilter` uses Scala types
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] `CommandChoiceHelpers` uses Scala types
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
@@ -248,22 +249,27 @@ Remove remaining direct proto imports from utility classes.
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
### View Filters (Blocking Full Deproto)
### View Filters (Partially Complete)
The `ProvinceViewFilter` utility currently works entirely with proto types, blocking full deproto of actions that generate province views:
The view filter utilities now have Scala overloads for server-side use:
| File | Issue | Needed |
|------|-------|--------|
| `ProvinceViewFilter.scala` | Takes proto `Province`/`GameState`, returns proto `ProvinceView` | Scala `ProvinceViewT` model |
| `GameStateViewFilter.scala` | Uses proto types throughout | Depends on `ProvinceViewT` |
| `GameStateViewDiffer.scala` | Works with view protos | Depends on `ProvinceViewT` |
| File | Status | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
**Blocked Actions**:
- `EndBattleAftermathPhaseAction` - uses `ProvinceViewFilter` for `revelationChange`, requires lazy proto conversion
- `PerformReconResolutionAction` - uses `ProvinceViewFilter` for reconned provinces
- `GameStateFactionExtensions` - uses `ProvinceViewFilter` for `updatedReconnedProvinces`
**Unblocked Actions** (PR #4752):
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
- `PerformReconResolutionAction` - can now use Scala overload
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
**Solution**: Create Scala `ProvinceViewT` (and possibly `ProvinceViewC`) that mirrors the proto `ProvinceView`. Then create a protoless `ProvinceViewFilter` that operates on Scala types. The proto version can delegate to the Scala version + convert, or we maintain both during transition.
**Remaining Work**:
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
- `withdrawnFromProvinceView` still uses proto types
- These are needed for client-facing views with visibility restrictions
---
@@ -65,11 +65,6 @@ private:
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
public:
explicit ShardokAIClient(
PlayerId playerId,
@@ -86,6 +81,12 @@ public:
[[nodiscard]] auto ChooseCommandIndex(const ShardokEngine& engine) const
-> CommandChoiceResults;
// Overload that works on copies of state - allows caller to release lock during AI thinking
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
// MCTS configuration methods (only relevant when using MCTS algorithm)
[[nodiscard]] auto GetMCTSConfig() const -> const mcts::MCTSConfig& { return mctsConfig; }
void SetMCTSConfig(const mcts::MCTSConfig& config) { mctsConfig = config; }
@@ -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;
@@ -111,17 +108,67 @@ void ShardokGameController::DoAIThread() {
if (aiClients.empty()) { printf("No AI players, exiting AI thread.\n"); }
while (aiThreadKeepGoing) {
while (incomingRegistrations > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
// Phase 1: Gather data for AI decision (brief lock)
shared_ptr<ShardokAIClient> aiClient;
PlayerId playerId;
GameSettingsSPtr settings;
net::eagle0::shardok::api::GameStateView gsv;
CommandListSPtr availableCommands;
size_t expectedHistoryCount;
{
unique_lock lk(masterLock);
if (engine->GameIsOver()) {
aiThreadKeepGoing = false;
continue;
}
playerId = engine->GetCurrentPlayerId();
aiClient = LockedAIClientForPid(playerId);
if (!aiClient) {
// Not an AI player's turn - wait for signal
aiCondition.wait(lk);
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
continue;
}
// Get copies of everything the AI needs
settings = engine->GetGameSettings();
gsv = engine->GetGameStateView(playerId);
availableCommands = engine->GetAvailableCommandsForAIPlayer(playerId);
expectedHistoryCount = engine->GetUnfilteredHistoryCount();
}
// Lock released - polls can now get through
if (availableCommands->empty()) {
printf("no commands for player %d\n", playerId);
continue;
}
unique_lock lk(masterLock);
if (LockedCheckOneAICommand()) {
// Phase 2: AI thinks (NO LOCK - this is the slow part)
const auto results = aiClient->ChooseCommandIndex(settings, gsv, availableCommands);
// Phase 3: Post the command (brief lock)
{
unique_lock lk(masterLock);
// Verify state hasn't changed while we were thinking
if (engine->GetUnfilteredHistoryCount() != expectedHistoryCount) {
// State changed (e.g., human posted command) - re-evaluate
printf("AI: State changed while thinking, re-evaluating\n");
continue;
}
if (engine->GameIsOver()) {
aiThreadKeepGoing = false;
continue;
}
engine->PostCommand(playerId, results.chosenIndex);
LockedNotifyClients();
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
} else {
aiCondition.wait(lk);
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
aiThreadKeepGoing = !engine->GameIsOver();
}
}
printf("Exiting AI thread.\n");
@@ -136,24 +183,6 @@ ShardokGameController::~ShardokGameController() {
aiThread.join();
}
auto ShardokGameController::LockedCheckOneAICommand() -> bool {
if (engine->GameIsOver()) {
printf("Game is over!\n");
return false;
}
const PlayerId currentPid = engine->GetCurrentPlayerId();
if (const shared_ptr<ShardokAIClient> currentPlayerClient = LockedAIClientForPid(currentPid)) {
const int index = currentPlayerClient->ChooseCommandIndex(*engine).chosenIndex;
engine->PostCommand(currentPid, index);
LockedNotifyClients();
return true;
}
return false;
}
void CheckFactionId(
const unique_ptr<ShardokEngine> &engine,
const PlayerId shardokPlayerId,
@@ -229,6 +258,7 @@ void ShardokGameController::PostPlacementCommands(
}
auto ShardokGameController::GetCurrentGameStateBytes() -> byte_vector {
scoped_lock<mutex> guard(masterLock);
return engine->GetCurrentGameStateBytes();
}
@@ -245,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(
@@ -292,6 +316,9 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
-1,
engine->FilterNewResults(-1, startingActionId),
nullptr);
auto gameStateBytes = engine->GetCurrentGameStateBytes();
updates.currentGameState.swap(gameStateBytes);
}
return updates;
@@ -321,4 +348,74 @@ auto ShardokGameController::ResolvedPlayerInfos()
return engine->GetPlayerInfos();
}
void ShardokGameController::RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber) {
scoped_lock<mutex> guard(subscriberLock);
subscribers.push_back(subscriber);
}
void ShardokGameController::UnregisterSubscriber(const StreamSubscriber *subscriber) {
scoped_lock<mutex> guard(subscriberLock);
subscribers.erase(
std::remove_if(
subscribers.begin(),
subscribers.end(),
[subscriber](const std::weak_ptr<StreamSubscriber> &weakSub) {
auto sub = weakSub.lock();
return !sub || sub.get() == subscriber;
}),
subscribers.end());
}
auto ShardokGameController::WaitForUpdatesAndPush(
std::shared_ptr<StreamSubscriber> subscriber,
int64_t startingActionId) -> bool {
int64_t lastPushedActionId = startingActionId;
while (subscriber->IsActive()) {
bool gameOver = false;
GameOverInfo gameOverInfo{};
{
unique_lock<mutex> guard(masterLock);
// Wait for updates or game over
updateCondition.wait(guard, [this, lastPushedActionId] {
return engine->GetUnfilteredHistoryCount() >
static_cast<size_t>(lastPushedActionId) ||
engine->GameIsOver();
});
if (!subscriber->IsActive()) { return false; }
gameOver = engine->GameIsOver();
if (gameOver) {
gameOverInfo.gameStatus = fb::ToProto(engine->GetGameStatus());
gameOverInfo.playerInfos = engine->GetPlayerInfos();
gameOverInfo.endGameUnits = engine->EndGameUnits();
}
}
// Lock released - GetUpdates will acquire its own lock
if (gameOver) {
subscriber->OnGameOver(gameOverInfo);
return true;
}
// Get updates outside the lock (GetUpdates acquires masterLock internally)
AllUpdates updates = GetUpdates(lastPushedActionId);
lastPushedActionId = updates.newUnfilteredCount;
if (!updates.mainResults.empty()) {
subscriber->OnUpdate(
updates.mainResults,
updates.filteredResults,
updates.newUnfilteredCount,
updates.currentGameState);
}
}
return false; // Subscriber disconnected
}
} // namespace shardok
@@ -10,6 +10,7 @@
#define ShardokGameController_hpp
#include <functional>
#include <memory>
#include <mutex>
#include <utility>
#include <vector>
@@ -29,6 +30,50 @@ using std::shared_ptr;
using std::unique_ptr;
using std::weak_ptr;
// Forward declaration
class ShardokGameController;
/// Info about a game that has ended, for notifying subscribers
struct GameOverInfo {
vector<net::eagle0::shardok::common::PlayerInfo> playerInfos;
vector<net::eagle0::shardok::storage::ResolvedUnit> endGameUnits;
net::eagle0::shardok::common::GameStatus gameStatus;
};
/// Updates for a single player (includes faction ID for client routing)
struct OnePlayerUpdates {
int32_t eagleFactionId;
vector<ActionResultView> resultViews;
shared_ptr<AvailableCommands> availableCommands;
OnePlayerUpdates(
const int32_t fid,
const vector<ActionResultView>& arvs,
const shared_ptr<AvailableCommands>& acs)
: eagleFactionId(fid),
resultViews(arvs),
availableCommands(acs) {}
};
/// Interface for subscribers that receive streaming updates from a game
class StreamSubscriber {
public:
virtual ~StreamSubscriber() = default;
/// Called when new game updates are available
virtual void OnUpdate(
const vector<ActionResult>& mainResults,
const vector<OnePlayerUpdates>& filteredResults,
int32_t newUnfilteredCount,
const byte_vector& currentGameState) = 0;
/// Called when the game ends
virtual void OnGameOver(const GameOverInfo& info) = 0;
/// Returns true if this subscriber is still active and should receive updates
[[nodiscard]] virtual auto IsActive() const -> bool = 0;
};
class ShardokGameController {
private:
// This lock should be held any time we call into engine or modify clients.
@@ -39,10 +84,17 @@ private:
// Fires whenever there is a new game state update.
mutable std::condition_variable updateCondition{};
// Stream subscribers - protected by separate lock to avoid deadlock with masterLock
mutable std::mutex subscriberLock{};
std::vector<std::weak_ptr<StreamSubscriber>> subscribers{};
string serializedRequest;
unique_ptr<ShardokEngine> engine;
// Cached immutable data - safe to access without lock since it never changes after construction
const GameId cachedGameId;
std::atomic_int incomingRegistrations = 0;
const string mapName;
@@ -60,11 +112,9 @@ private:
void LockedNotifyClients() const;
auto LockedCheckOneAICommand() -> bool;
auto LockedAIClientForPid(PlayerId pid) const -> shared_ptr<ShardokAIClient>;
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine> &e);
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine>& e);
void DoAIThread();
@@ -75,6 +125,7 @@ public:
string serializedRequest = "")
: serializedRequest(std::move(serializedRequest)),
engine(std::move(e)),
cachedGameId(engine->GetGameId()),
mapName(std::move(mapName)),
logFilePath(MakeLogFilePath()),
aiClients(MakeAIClients(engine)),
@@ -98,26 +149,13 @@ public:
PlayerId shardokPlayerId,
int eagleFactionId,
int64_t token,
const vector<UnitPlacementInfo> &infos);
struct OnePlayerUpdates {
int32_t eagleFactionId;
vector<ActionResultView> resultViews;
shared_ptr<AvailableCommands> availableCommands;
OnePlayerUpdates(
const int32_t fid,
const vector<ActionResultView> &arvs,
const shared_ptr<AvailableCommands> &acs)
: eagleFactionId(fid),
resultViews(arvs),
availableCommands(acs) {}
};
const vector<UnitPlacementInfo>& infos);
struct AllUpdates {
vector<ActionResult> mainResults;
vector<OnePlayerUpdates> filteredResults;
int32_t newUnfilteredCount;
byte_vector currentGameState;
};
auto GetUpdates(int64_t startingActionId) -> AllUpdates;
auto GetCurrentGameStateBytes() -> byte_vector;
@@ -127,13 +165,23 @@ public:
auto ResolvedPlayerInfos() -> vector<net::eagle0::shardok::common::PlayerInfo>;
auto EndGameUnits() -> vector<net::eagle0::shardok::storage::ResolvedUnit>;
[[nodiscard]] auto GetGameId() const -> GameId { return engine->GetGameId(); }
[[nodiscard]] auto GetHexMap() const -> const HexMap * {
return engine->GetCurrentGameState()->hex_map();
}
[[nodiscard]] auto GetGameId() const -> GameId { return cachedGameId; }
[[nodiscard]] auto GetLogFilePath() const -> string { return logFilePath; }
/// Register a subscriber to receive streaming updates for this game.
/// The subscriber will receive updates until it becomes inactive or is unregistered.
void RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber);
/// Unregister a subscriber. Safe to call even if the subscriber was never registered.
void UnregisterSubscriber(const StreamSubscriber* subscriber);
/// Wait for game updates, pushing them to the given subscriber.
/// Blocks until the game ends or the subscriber becomes inactive.
/// Returns true if the game ended normally, false if subscriber disconnected.
auto WaitForUpdatesAndPush(
std::shared_ptr<StreamSubscriber> subscriber,
int64_t startingActionId) -> bool;
};
} // namespace shardok
@@ -529,6 +529,125 @@ auto FromInternalStatus(
throw ShardokInternalErrorException("Bad unit status on resolved unit");
}
auto EagleInterfaceImpl::SubscribeToGame(
ServerContext *context,
const GameSubscriptionRequest *request,
grpc::ServerWriter<GameStatusResponse> *writer) -> Status {
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
} catch (NewGameException &e) { return e.GetStatus(); }
if (!controller) { return Status(StatusCode::NOT_FOUND, "Game not found"); }
// Send initial state
GameStatusResponse initialResponse;
PopulateGameStatusResponse(
controller,
request->game_setup_info().known_result_count(),
&initialResponse);
if (!writer->Write(initialResponse)) {
return Status::OK; // Client disconnected
}
// If game was already over, we're done
if (initialResponse.has_game_over_response()) { return Status::OK; }
// Create a subscriber that writes to the gRPC stream
class GrpcStreamSubscriber : public StreamSubscriber {
private:
grpc::ServerWriter<GameStatusResponse> *writer_;
ServerContext *context_;
std::atomic<bool> active_{true};
std::string gameId_;
public:
GrpcStreamSubscriber(
grpc::ServerWriter<GameStatusResponse> *w,
ServerContext *ctx,
std::string gameId)
: writer_(w),
context_(ctx),
gameId_(std::move(gameId)) {}
void OnUpdate(
const vector<ActionResult> &mainResults,
const vector<OnePlayerUpdates> &filteredResults,
int32_t newUnfilteredCount,
const byte_vector &currentGameState) override {
if (!active_) return;
GameStatusResponse response;
response.set_game_id(gameId_);
response.mutable_game_update_response()->mutable_update_responses()->Add(
begin(mainResults),
end(mainResults));
// Add filtered results for each player with their faction IDs
for (const auto &playerUpdate : filteredResults) {
auto *filtered =
response.mutable_game_update_response()->add_filtered_update_responses();
filtered->set_eagle_faction_id(playerUpdate.eagleFactionId);
filtered->mutable_action_result_views()->Add(
begin(playerUpdate.resultViews),
end(playerUpdate.resultViews));
if (playerUpdate.availableCommands) {
*filtered->mutable_available_commands() = *playerUpdate.availableCommands;
}
}
response.mutable_game_update_response()->set_total_action_result_count(
newUnfilteredCount);
*response.mutable_game_update_response()->mutable_current_game_state() =
std::string(currentGameState.begin(), currentGameState.end());
if (!writer_->Write(response)) { active_ = false; }
}
void OnGameOver(const GameOverInfo &info) override {
if (!active_) return;
GameStatusResponse response;
response.set_game_id(gameId_);
PopulateGameOverResponse(
gameId_,
info.gameStatus,
info.playerInfos,
info.endGameUnits,
response.mutable_game_over_response());
writer_->Write(response);
active_ = false;
}
[[nodiscard]] auto IsActive() const -> bool override {
return active_ && !context_->IsCancelled();
}
};
auto subscriber =
std::make_shared<GrpcStreamSubscriber>(writer, context, controller->GetGameId());
controller->RegisterSubscriber(subscriber);
// Wait for updates and push them until game ends or subscriber disconnects
bool gameEnded = controller->WaitForUpdatesAndPush(
subscriber,
request->game_setup_info().known_result_count());
controller->UnregisterSubscriber(subscriber.get());
if (gameEnded) {
printf("SubscribeToGame: Game ended normally\n");
} else {
printf("SubscribeToGame: Subscriber disconnected\n");
}
return Status::OK;
}
} // namespace shardok
#ifndef NDEBUG
@@ -33,6 +33,7 @@ using grpc::Status;
using net::eagle0::common::GameSetupInfo;
using net::eagle0::common::GameStatusRequest;
using net::eagle0::common::GameStatusResponse;
using net::eagle0::common::GameSubscriptionRequest;
using net::eagle0::common::HexMapNamesRequest;
using net::eagle0::common::HexMapNamesResponse;
using net::eagle0::common::HexMapRequest;
@@ -78,6 +79,11 @@ public:
ServerContext* context,
const HexMapNamesRequest* request,
HexMapNamesResponse* response) -> Status override;
auto SubscribeToGame(
ServerContext* context,
const GameSubscriptionRequest* request,
grpc::ServerWriter<GameStatusResponse>* writer) -> Status override;
};
} // namespace shardok
@@ -120,6 +120,19 @@ namespace eagle {
}
}
/// <summary>
/// Force the circuit breaker to allow an immediate reconnect attempt.
/// Resets the state to HalfOpen so the next connection will be a test.
/// </summary>
public void ForceReconnect() {
lock (this) {
if (_state == State.Open) {
_state = State.HalfOpen;
_logger.LogLine("[CIRCUIT] OPEN → HALF_OPEN (forced by user)");
}
}
}
/// <summary>
/// Get human-readable status for UI display.
/// </summary>
@@ -138,7 +151,7 @@ namespace eagle {
var timeUntilTest = OpenTimeoutSeconds -
(DateTime.UtcNow - _openedAt.Value).TotalSeconds;
if (timeUntilTest > 0) {
return $"Server unavailable. Retrying in {(int)timeUntilTest}s";
return $"Server unavailable. Retrying in {Math.Min(10, (int)timeUntilTest)}s";
}
}
return "Server unavailable. Testing...";
@@ -2,6 +2,7 @@ using System;
using Net.Eagle0.Eagle.Api;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
/// <summary>
@@ -26,6 +27,9 @@ namespace eagle {
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
[Tooltip("Optional button to force immediate reconnection when server is down")]
public Button retryButton;
// Update interval in seconds
private const float UpdateInterval = 0.5f;
private float _timeSinceLastUpdate = 0f;
@@ -39,9 +43,10 @@ namespace eagle {
return;
}
// Find the PersistentClientConnection - this assumes it's accessible
// In production, this would need proper dependency injection
// For now, the connection will be set externally or found via another method
if (retryButton != null) {
retryButton.onClick.AddListener(OnRetryClicked);
retryButton.gameObject.SetActive(false);
}
}
/// <summary>
@@ -73,11 +78,12 @@ namespace eagle {
// Check circuit breaker state first - it takes precedence
var circuitState = _connection.CircuitBreaker.CurrentState;
if (circuitState == ConnectionCircuitBreaker.State.Open) {
SetRetryButtonVisible(true);
var nextTest = _connection.CircuitBreaker.NextTestAttempt;
if (nextTest.HasValue) {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = (int)Math.Ceiling(timeUntilTest.TotalSeconds);
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
_textComponent.text =
$"<color=red>●</color> Server down. Retry in {seconds}s";
return;
@@ -86,6 +92,7 @@ namespace eagle {
_textComponent.text = "<color=red>●</color> Server unavailable";
return;
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
SetRetryButtonVisible(false);
_textComponent.text = "<color=yellow>●</color> Testing connection...";
return;
}
@@ -94,6 +101,11 @@ namespace eagle {
var state = _connection.CurrentState;
var nextAttempt = _connection.NextReconnectAttempt;
// Show retry button if we're counting down to a reconnect attempt
bool isCountingDown = state == ConnectionState.Reconnecting && nextAttempt.HasValue &&
(nextAttempt.Value - DateTime.UtcNow).TotalSeconds > 0;
SetRetryButtonVisible(isCountingDown);
string statusText = state switch {
ConnectionState.Connected => GetConnectedStatusText(),
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
@@ -142,8 +154,16 @@ namespace eagle {
return "<color=yellow>●</color> Reconnecting...";
}
int secondsRemaining = (int)Math.Ceiling(timeUntilRetry.TotalSeconds);
int secondsRemaining = Math.Min(10, (int)Math.Ceiling(timeUntilRetry.TotalSeconds));
return $"<color=orange>●</color> Retry in {secondsRemaining}s";
}
private void SetRetryButtonVisible(bool visible) {
if (retryButton != null) { retryButton.gameObject.SetActive(visible); }
}
private void OnRetryClicked() {
if (_connection != null) { _connection.ForceReconnect(); }
}
}
}
@@ -229,12 +229,11 @@ namespace eagle {
void OnApplicationPause(bool pause) {
if (ModelUpdater == null) { return; }
if (pause) {
ModelUpdater.StopListeningForUpdates();
} else {
// Fire-and-forget - subscription is awaited internally and failures are logged
_ = ModelUpdater.StartListeningForUpdates();
}
// Don't unsubscribe on pause - this caused a race condition where the subscriber
// could be lost if pause happened before the async StartListeningForUpdates completed.
// With MainQueue rate-limiting, keeping the subscription during pause is safe.
// Reconnects will continue to work, and updates will queue up and be processed on
// resume.
}
#if UNITY_EDITOR
@@ -43,7 +43,7 @@ namespace eagle {
TokenId? CommandToken { get; }
TokenId LastPostedToken { get; }
Dictionary<String, ShardokGameModel> ShardokGameModels { get; }
ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; }
private bool ShardokGameModelIsRunning(ShardokGameModel sgm) =>
sgm.GameStatus.State == GameStatus.Types.State.GameRunning
@@ -105,10 +105,9 @@ namespace eagle {
_currentModel.ShardokGameModels
.Select(sgm => {
var needsResync = _shardokNeedsResync.GetValueOrDefault(sgm.Key, false);
// Use thread-safe count from gRPC thread updates, fall back to model
var count = _shardokResultCounts.GetValueOrDefault(
sgm.Key,
sgm.Value.History.Count());
// Use thread-safe count from gRPC thread updates. Fall back to 0 if
// not yet tracked (avoids accessing non-thread-safe History.Count).
var count = _shardokResultCounts.GetValueOrDefault(sgm.Key, 0);
return new IClientConnectionSubscriber.ShardokViewStatus {
shardokGameId = sgm.Key,
filteredResultCount = needsResync ? 0 : count,
@@ -199,7 +198,8 @@ namespace eagle {
return new List<AvailableCommand>();
}
public Dictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
// Thread-safe: accessed from heartbeat timer thread via ShardokViewStatuses
public ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
public FactionView MaybeDestroyedFaction(FactionId factionId) {
if (ActiveFactions.TryGetValue(factionId, out var factionView)) {
@@ -241,7 +241,8 @@ namespace eagle {
new Dictionary<ProvinceId, OneProvinceAvailableCommands>();
_currentModel.GsView = new GameStateView();
_currentModel.ShardokGameModels = new Dictionary<ShardokGameId, ShardokGameModel>();
_currentModel.ShardokGameModels =
new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();
_currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
@@ -312,8 +313,10 @@ namespace eagle {
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
}
_lastUnfilteredResultCount =
updateItem.ActionResultResponse.UnfilteredResultCountAfter;
// Note: _lastUnfilteredResultCount is updated on the gRPC thread in
// UpdateResultCounts() before enqueueing. We don't update it here to avoid
// race conditions where a backlogged MainQueue update overwrites a newer count.
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
updateItem.ActionResultResponse.AvailableCommands == null ||
updateItem.ActionResultResponse.AvailableCommands.Token !=
@@ -339,7 +342,9 @@ namespace eagle {
// Battle may have ended before we could create the model - remove
// any stale reference and skip this update
if (shardokGameModel == null) {
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
continue;
}
@@ -368,7 +373,9 @@ namespace eagle {
shardokGameModel;
} else {
// Game ended - remove from active models so UI knows battle is over
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
}
}
@@ -478,6 +485,31 @@ namespace eagle {
_currentModel.BattalionTypes =
startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
// For any outstanding battles not in ShardokGameModels, create models and mark for
// resync. This ensures fresh clients get Shardok state for ongoing battles.
bool needsResubscribe = false;
foreach (var battle in _currentModel.ShardokBattles) {
if (!_currentModel.ShardokGameModels.ContainsKey(battle.ShardokGameId)) {
var model = MakeGameModel(battle.ShardokGameId);
if (model != null) {
_currentModel.ShardokGameModels[battle.ShardokGameId] = model;
MarkShardokForResync(battle.ShardokGameId);
_connectionLogger.LogLine(
$"[STATE_RESYNC] Created ShardokGameModel for outstanding battle {battle.ShardokGameId}");
needsResubscribe = true;
}
}
}
// If we created new ShardokGameModels, re-subscribe to request their full state.
// The original subscribe didn't include these battles since we didn't know about them
// yet.
if (needsResubscribe) {
_connectionLogger.LogLine(
"[STATE_RESYNC] Re-subscribing to request full Shardok state for new battles");
_ = StartListeningForUpdates();
}
}
private void HandleUpdates(List<ActionResultView> results) {
@@ -780,7 +812,7 @@ namespace eagle {
// the Eagle update removing the battle arrives before the Shardok Victory update.
if (_currentModel.ShardokGameModels.TryGetValue(rb, out var sgm)) {
sgm.MarkBattleEnded("Battle has ended.");
_currentModel.ShardokGameModels.Remove(rb);
_currentModel.ShardokGameModels.TryRemove(rb, out _);
}
_shardokResultCounts.TryRemove(rb, out _);
@@ -132,7 +132,10 @@ namespace eagle {
public void ProvinceHovered(ProvinceId? pid) {
// Highlight moving armies table
// Check row count to avoid index out of range if data changed after table was built
var rowCount = movingArmiesTable.RowCount;
MovingArmies.Each((army, i) => {
if (i >= rowCount) return;
var row = movingArmiesTable.ComponentAt<MovingArmyTableRow>(i);
if (army.DestinationProvinceId == pid || army.OriginProvinceId == pid) {
row.ShadeOn();
@@ -1,8 +1,12 @@
using System.Collections.Generic;
using System.Linq;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications.ARNNotifications {
using ProvinceId = System.Int32;
using HeroId = System.Int32;
public static class ProfessionGainedDetailsNotificationGenerator {
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
@@ -44,19 +48,46 @@ namespace eagle.Notifications.ARNNotifications {
: "Profession Gained";
}
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
foreach (var province in model.Provinces.Values) {
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
return province.Id;
}
}
return null;
}
private static IEnumerable<Notification> GenerateNotifications(
Net.Eagle0.Eagle.Common.Notification notification,
IGameModel currentModel) {
var details = notification.Details.ProfessionGainedDetails;
var hero = currentModel.Heroes[details.HeroId];
var factionName = currentModel.FactionName(details.FactionId);
var professionName = GetProfessionName(details.NewProfession);
var article = GetArticle(professionName);
var affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
string textTemplate;
List<ProvinceId> affectedProvinces;
string textTemplate =
$"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
if (details.FactionId == currentModel.PlayerId) {
// Player's own hero - vary text based on faction leader status
string heroDescription;
if (hero.IsFactionLeader) {
heroDescription =
$"Your sworn {DisplayNames.SiblingDescription(hero.PronounGender)}";
} else {
heroDescription = "Your vassal";
}
textTemplate = $"{heroDescription} {{Hero}} became {article} {professionName}.\n\n";
var heroProvince = FindProvinceForHero(details.HeroId, currentModel);
affectedProvinces = heroProvince.HasValue
? new List<ProvinceId> { heroProvince.Value }
: currentModel.ProvincesForFaction(details.FactionId);
} else {
// Another faction's hero
var factionName = currentModel.FactionName(details.FactionId);
textTemplate = $"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "Hero", (hero.NameTextId, "A hero") }
@@ -31,7 +31,13 @@ namespace eagle.Notifications.ARNNotifications {
};
if (playerId.HasValue && playerId.Value == paidToFactionId) {
// no notification
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Accepted",
textTemplate: $"We have accepted the ransom from {currentModel.FactionName(paidByFactionId)} for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { ransomedHero, offeringFactionHead });
} else if (playerId.HasValue && playerId.Value == paidByFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Accepted",
@@ -9,6 +9,7 @@ namespace eagle.Notifications {
private List<GeneratedTextListener> textListeners = new();
private string textTemplate;
private Dictionary<string, string> placeholderValues = new();
private Dictionary<string, string> fallbackValues = new();
public DynamicTextNotification(
string title,
@@ -72,6 +73,9 @@ namespace eagle.Notifications {
string nameTextId = kvp.Value.nameTextId;
string fallback = kvp.Value.fallback;
// Store fallback so UpdateText can use it for missing placeholders
fallbackValues[placeholder] = fallback;
if (!string.IsNullOrEmpty(nameTextId)) {
var listener = new GeneratedTextListener(
nameTextId,
@@ -95,6 +99,15 @@ namespace eagle.Notifications {
private void UpdateText() {
string result = textTemplate;
// First apply fallbacks for any placeholder without a loaded value
foreach (var kvp in fallbackValues) {
if (!placeholderValues.ContainsKey(kvp.Key)) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
}
// Then apply actual loaded values
foreach (var kvp in placeholderValues) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
@@ -69,6 +69,17 @@ namespace eagle {
private ConnectionCircuitBreaker _circuitBreaker = new ConnectionCircuitBreaker();
public ConnectionCircuitBreaker CircuitBreaker => _circuitBreaker;
/// <summary>
/// Force an immediate reconnection attempt, bypassing the circuit breaker timeout.
/// </summary>
public void ForceReconnect() {
_circuitBreaker.ForceReconnect();
_retryTimer?.Dispose();
NextReconnectAttempt = null;
LogConnectionEvent("force_reconnect", "User requested immediate reconnect");
Task.Run(() => Connect());
}
private DateTime? GetDeadlineFromNow() {
return DateTime.UtcNow.AddSeconds(TimeoutSeconds);
}
@@ -1060,51 +1071,69 @@ namespace eagle {
}
}
// Grace period after connect before sync mismatch triggers reconnect.
// Allows time to receive and process historical results after fresh subscribe.
private const double SyncMismatchGracePeriodSeconds = 60.0;
private void HandleHeartbeatResponse(HeartbeatResponse response) {
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
// Check for sync mismatches reported by server
if (response.GameSyncResults.Count > 0) {
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
bool hasMismatch = false;
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_eagle",
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
hasMismatch = true;
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_eagle",
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_shardok",
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
$"server_count={shardokResult.ServerFilteredResultCount}");
}
"sync_mismatch_shardok",
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
$"server_count={shardokResult.ServerFilteredResultCount}");
hasMismatch = true;
}
}
// Trigger resync by reconnecting - this will re-subscribe with current counts
// and the server will send missing updates
_remoteEagleClientLogger.LogLine(
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Schedule reconnect to resync
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("SyncMismatch");
}
if (!hasMismatch) return;
// Grace period after connect - allow time to receive historical results
if (_lastSuccessfulConnect.HasValue) {
var timeSinceConnect =
(DateTime.UtcNow - _lastSuccessfulConnect.Value).TotalSeconds;
if (timeSinceConnect < SyncMismatchGracePeriodSeconds) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Ignoring mismatch during grace period ({timeSinceConnect:F1}s < {SyncMismatchGracePeriodSeconds}s)");
return;
}
}
// Trigger resync by reconnecting - this will re-subscribe with current counts
// and the server will send missing updates
_remoteEagleClientLogger.LogLine(
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Schedule reconnect to resync
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("SyncMismatch");
}
}
}
@@ -32467,7 +32467,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 2147483647
m_IsActive: 0
m_IsActive: 1
--- !u!224 &300036923
RectTransform:
m_ObjectHideFlags: 0
@@ -33904,6 +33904,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 727a520b2deac4a28ae9c6a00e54c2e7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.ConnectionStatusUI
retryButton: {fileID: 0}
--- !u!114 &320759869
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -50851,6 +50852,148 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 464655241}
m_CullTransparentMesh: 0
--- !u!1 &465641331
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 465641332}
- component: {fileID: 465641336}
- component: {fileID: 465641335}
- component: {fileID: 465641334}
- component: {fileID: 465641333}
m_Layer: 0
m_Name: Button
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
--- !u!224 &465641332
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1405807865}
m_Father: {fileID: 1012849519}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
m_AnchoredPosition: {x: 210.00002, y: -27.5}
m_SizeDelta: {x: 80, y: 25}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &465641333
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: 80
m_MinHeight: 25
m_PreferredWidth: 80
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &465641334
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 465641335}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!114 &465641335
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &465641336
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 465641331}
m_CullTransparentMesh: 1
--- !u!1 &466028234
GameObject:
m_ObjectHideFlags: 0
@@ -56271,9 +56414,9 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 176
m_MinWidth: 250
m_MinHeight: -1
m_PreferredWidth: 176
m_PreferredWidth: 250
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: 1
@@ -121559,6 +121702,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1453223014}
- {fileID: 465641332}
m_Father: {fileID: 512260365}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@@ -170015,6 +170159,144 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1405279533}
m_CullTransparentMesh: 0
--- !u!1 &1405807864
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1405807865}
- component: {fileID: 1405807867}
- component: {fileID: 1405807866}
m_Layer: 0
m_Name: Text (TMP)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1405807865
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1405807864}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 465641332}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1405807866
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1405807864}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Reconnect
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4281479730
m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 12
m_fontSizeBase: 12
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!222 &1405807867
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1405807864}
m_CullTransparentMesh: 1
--- !u!1 &1406178529
GameObject:
m_ObjectHideFlags: 0
@@ -174532,7 +174814,7 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: 3440
m_text: Server down. Reconnecting in 10s
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
@@ -174560,8 +174842,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontSize: 12
m_fontSizeBase: 12
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -174645,6 +174927,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: 727a520b2deac4a28ae9c6a00e54c2e7, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.ConnectionStatusUI
retryButton: {fileID: 465641334}
--- !u!1 &1453601446
GameObject:
m_ObjectHideFlags: 0
@@ -190489,7 +190772,7 @@ GameObject:
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 2147483647
m_IsActive: 1
m_IsActive: 0
--- !u!224 &1566150938
RectTransform:
m_ObjectHideFlags: 0
@@ -385,98 +385,99 @@ namespace Shardok {
Model.MyUncommittedUnits.Where(uv => uv.Location.Row == -1).ToList();
}
/// <summary>
/// Called when the ShardokGameModel is updated. This is invoked from UpdateAction,
/// which is called from ShardokGameModel.HandleUpdates, which runs on MainQueue.
/// No need to re-enqueue - we're already on the main thread.
/// </summary>
void ModelUpdated() {
MainQueue.Q.Enqueue(() => {
if (Model == null) { return; }
SetHeroLabels();
SetModifiers();
UpdateReserves();
if (Model == null) { return; }
SetHeroLabels();
SetModifiers();
UpdateReserves();
HandleEnemyStartingPositionOverlays();
HandleEnemyStartingPositionOverlays();
endTurnButton.interactable = false;
endTurnButton.interactable = false;
if (Model.GameStatus != null &&
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
turnStatusLabel.text = "Game Over!";
if (Model.GameStatus != null &&
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
turnStatusLabel.text = "Game Over!";
gameOverText.text = Model.GameStatus.Description;
gameOverCanvas.gameObject.SetActive(true);
gameOverText.text = Model.GameStatus.Description;
gameOverCanvas.gameObject.SetActive(true);
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
endTurnButton.interactable = true;
} else if (Model.GameStatus != null && Model.MyTurn) {
gameOverCanvas.gameObject.SetActive(false);
turnStatusLabel.text = "Your Turn";
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
endTurnButton.interactable = true;
} else if (Model.GameStatus != null && Model.MyTurn) {
gameOverCanvas.gameObject.SetActive(false);
turnStatusLabel.text = "Your Turn";
if (Model.InSetUp) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
if (Model.InSetUp) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
var unplacedUnitsWithLocations =
Model.MyUncommittedUnits
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
.ToList();
var unplacedUnitsWithLocations =
Model.MyUncommittedUnits
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
.ToList();
if (!Model.MyTurn) {
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() < 1) {
endTurnButton.GetComponentInChildren<TMP_Text>().text =
"No units placed";
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() > 10) {
endTurnButton.GetComponentInChildren<TMP_Text>().text =
"Too many units";
endTurnButton.interactable = false;
} else {
endTurnButton.interactable = true;
}
SetDisplayedCommandGroup(0);
if (!Model.MyTurn) {
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() < 1) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "No units placed";
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() > 10) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Too many units";
endTurnButton.interactable = false;
} else {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
}
if (Model.HasAvailableCommandWhere(
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
CommandTypeUIManager.EndTurnCommandGroup)) {
endTurnButton.interactable = true;
}
SelectAppropriateDefaultCommand();
SetDisplayedCommandGroup(0);
} else {
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
}
locationNameText.text = Model.LocationName;
if (Model.History.Count > 0) {
string monthString = new DateTime(777, Model.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
Weather weather = Model.Weather;
if (weather != null) {
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
}
if (Model.HasAvailableCommandWhere(
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
CommandTypeUIManager.EndTurnCommandGroup)) {
endTurnButton.interactable = true;
}
if (Model.History.Count == 0) {
turnHistoryButtonText.text = NoHistoryText;
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
SelectAppropriateDefaultCommand();
} else {
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
}
ActionType type = historyEntry.Type;
var thisSound = soundManager.SoundForType(type);
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
}
_lastRetrievedHistoryCount = Model.History.Count;
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
SetModifiers();
locationNameText.text = Model.LocationName;
if (Model.History.Count > 0) {
string monthString = new DateTime(777, Model.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
Weather weather = Model.Weather;
if (weather != null) {
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
}
}
SetupArmiesTable();
});
if (Model.History.Count == 0) {
turnHistoryButtonText.text = NoHistoryText;
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
ActionType type = historyEntry.Type;
var thisSound = soundManager.SoundForType(type);
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
}
_lastRetrievedHistoryCount = Model.History.Count;
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
SetModifiers();
}
SetupArmiesTable();
}
private void SetupArmiesTable() {
@@ -707,34 +708,33 @@ namespace Shardok {
}
void SetModifiers() {
MainQueue.Q.Enqueue(() => {
hexGrid.ClearCellModifierImages();
// Called from ModelUpdated which runs on MainQueue - no need to re-enqueue
hexGrid.ClearCellModifierImages();
for (byte row = 0; row < Model.Map.RowCount; row++) {
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
Coords coords = new Coords();
coords.Row = row;
coords.Column = column;
for (byte row = 0; row < Model.Map.RowCount; row++) {
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
Coords coords = new Coords();
coords.Row = row;
coords.Column = column;
var terrain = Model.Map.TerrainAt(coords);
int cellIndex = MapCoordsToGridIndex(coords);
int numberForCell = _randomNumberForCellIndex[cellIndex];
hexGrid.SetCellTerrainImage(
cellIndex,
_imageForTerrainTracker
.GetImageForTerrain(terrain, numberForCell, Model.Month));
if (terrain.Modifier?.Fire != null) {
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
} else {
hexGrid.SetCellModifierEffect(cellIndex, null);
}
var terrain = Model.Map.TerrainAt(coords);
int cellIndex = MapCoordsToGridIndex(coords);
int numberForCell = _randomNumberForCellIndex[cellIndex];
hexGrid.SetCellTerrainImage(
cellIndex,
_imageForTerrainTracker
.GetImageForTerrain(terrain, numberForCell, Model.Month));
if (terrain.Modifier?.Fire != null) {
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
} else {
hexGrid.SetCellModifierEffect(cellIndex, null);
}
if (terrain.Modifier?.Bridge != null) {
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
}
if (terrain.Modifier?.Bridge != null) {
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
}
}
});
}
}
void HandleButton() {
@@ -82,7 +82,7 @@ public class ShardokGameModel {
public int Month { get; private set; }
private List<PlayerTotals> PlayerTotals { get; set; }
public List<CommandDescriptor> AvailableCommands { get; private set; }
public List<CommandDescriptor> AvailableCommands { get; private set; } = new();
public Action UpdateAction { get; set; }
public List<PlayerWithHostility> players;
public String GetPlayerName(PlayerId playerId) {
@@ -217,6 +217,8 @@ public class ShardokGameModel {
? newCommands.CurrentCommand.ToList()
: newCommands.PreviewCommand.ToList();
}
// Trigger UI refresh after commands are updated so unit action indicators reflect new state
UpdateAction?.Invoke();
}
public bool HasTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
@@ -138,9 +138,9 @@ namespace common {
// Use TMP's calculated preferred height - this is the actual text size
contentHeight = tmpText.preferredHeight;
// Ensure Content RectTransform is tall enough to contain the text
// This is necessary for ScrollRect to scroll properly
if (scrollRect.content.rect.height < contentHeight) {
// Sync Content RectTransform height with actual text size.
// Must both grow AND shrink to prevent blank space when scrolling to bottom.
if (Mathf.Abs(scrollRect.content.rect.height - contentHeight) > 1f) {
scrollRect.content.SetSizeWithCurrentAnchors(
RectTransform.Axis.Vertical,
contentHeight);
@@ -203,6 +203,11 @@ namespace common {
return;
}
// Hold Shift to pause scrolling (lets user read at their own pace)
bool isPausedByUser =
Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift);
if (isPausedByUser) return;
// Handle pause at bottom
if (_isPausedAtBottom) {
_pauseTimer += Time.deltaTime;
@@ -54,18 +54,43 @@ namespace common.GUIUtils {
Type = type,
Time = DateTime.UtcNow
});
MainQueue.Q.Enqueue(() => {
errorTextField.text = AllMessageText;
panel.gameObject.SetActive(true);
});
// Try to display immediately if possible, otherwise queue for later
try {
if (MainQueue.Q != null) {
MainQueue.Q.Enqueue(ShowErrorPanel);
} else {
_pendingShow = true;
}
} catch {
// MainQueue not ready yet - will show in Update
_pendingShow = true;
}
}
}
private bool _pendingShow = false;
private void ShowErrorPanel() {
if (errorTextField != null && panel != null) {
errorTextField.text = AllMessageText;
panel.gameObject.SetActive(true);
}
}
// Register for log messages as early as possible
void Awake() { Application.logMessageReceivedThreaded += HandleLog; }
void Update() {
// Handle errors that occurred before MainQueue was ready
if (_pendingShow) {
_pendingShow = false;
ShowErrorPanel();
}
}
// Use this for initialization
void Start() {
panel.gameObject.SetActive(false);
Application.logMessageReceivedThreaded += HandleLog;
}
void Start() { panel.gameObject.SetActive(false); }
public void DismissClicked() {
lock (_messages) {
@@ -21,14 +21,24 @@ option java_outer_classname = "EagleInterface";
option objc_class_prefix = "E0G";
service ShardokInternalInterface {
// Server-side streaming for game updates - replaces polling via GetGameStatus
rpc SubscribeToGame(GameSubscriptionRequest) returns (stream GameStatusResponse) {}
rpc PostCommand(PostCommandRequest) returns (GameStatusResponse) {}
rpc PostPlacementCommands(PlacementCommandsRequest) returns (GameStatusResponse) {}
// Deprecated: Use SubscribeToGame for streaming updates instead
rpc GetGameStatus(GameStatusRequest) returns (GameStatusResponse) {}
rpc GetHexMap(HexMapRequest) returns (HexMapResponse) {}
rpc GetHexMapNames(HexMapNamesRequest) returns (HexMapNamesResponse) {}
}
message GameSubscriptionRequest {
string game_id = 1;
GameSetupInfo game_setup_info = 2;
}
message PostCommandRequest {
string game_id = 1;
int32 player_id = 2;
@@ -114,6 +114,7 @@ class ActionResultApplierImpl(validator: Option[ScalaValidator]) extends ActionR
// Apply all remaining entity changes using extension methods
val finalState = stateAfterChangedBattalions
.applyProvinceActed(result.provinceIdActed)
.applyLastCommand(result.provinceId, result.lastCommandTypeForActingProvince)
.applyNewBattalions(result.newBattalions, result.provinceId)
.applyDestroyedBattalionIds(result.destroyedBattalionIds)
.applyNewHeroes(result.newHeroes)
@@ -54,6 +54,7 @@ scala_library(
deps = [
":province_update_helpers",
":province_update_helpers2",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
@@ -154,6 +155,7 @@ scala_library(
":game_state_hero_extensions",
":game_state_misc_extensions",
":game_state_province_extensions",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -169,6 +171,7 @@ scala_library(
":game_state_hero_extensions",
":game_state_misc_extensions",
":game_state_province_extensions",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:extra_xp_for_stat_bump_over100",
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
@@ -1,6 +1,7 @@
package net.eagle0.eagle.library.actions.applier
import net.eagle0.eagle.*
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
@@ -75,5 +76,22 @@ object GameStateProvinceExtensions {
}
.getOrElse(gameState)
}
def applyLastCommand(
provinceId: Option[ProvinceId],
lastCommand: Option[SelectedCommand]
): GameState =
provinceId
.filter(_ => lastCommand.exists(!_.isEmpty))
.map { pid =>
val province = gameState.provinces(pid) match {
case p: ProvinceC => p
case p => throw new EagleInternalException(s"Unknown ProvinceT type: ${p.getClass}")
}
gameState.copy(
provinces = gameState.provinces.updated(pid, province.copy(lastCommand = lastCommand))
)
}
.getOrElse(gameState)
}
}
@@ -209,8 +209,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_aftermath_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battle_revelation_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:notification_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/llm_request",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/state:battle_revelation",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/faction:faction_relationship",
@@ -1288,7 +1290,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_recon_resolution_phase_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:recon_succeeded_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province:incoming_end_turn_action",
@@ -22,7 +22,9 @@ import net.eagle0.eagle.model.action_result.{ActionResultT, NotificationDetails,
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ChangedHeroC, NotificationC}
import net.eagle0.eagle.model.action_result.types.{CapturedHeroResolvedResultType, EndAftermathPhaseResultType}
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.view.province.ProvinceViewConverter
import net.eagle0.eagle.model.state.{BattleRevelation, RoundPhase}
import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.game_state.GameState
@@ -350,12 +352,12 @@ case class EndBattleAftermathPhaseAction(
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
// Revelation changes still need proto for ProvinceViewFilter
// Withdrew case still needs proto for withdrawnFromProvinceView (no Scala overload yet)
private def revelationChange(
battleRevelation: BattleRevelation,
gs: GameState
): RevelationChange = {
// Lazy conversion to proto for ProvinceViewFilter calls
// Lazy conversion to proto only for withdrawnFromProvinceView
lazy val gsProto = GameStateConverter.toProto(gs)
lazy val provinceProto = gsProto.provinces(battleRevelation.provinceId)
@@ -367,27 +369,28 @@ case class EndBattleAftermathPhaseAction(
ChangedFactionC(
factionId = battleRevelation.revealedToFactionId,
updatedReconnedProvinces = Vector(
ProvinceViewFilter
.withdrawnFromProvinceView(
province = provinceProto,
gs = gsProto,
factionId = battleRevelation.revealedToFactionId
)
.withAsOf(gsProto.currentDate.get)
ProvinceViewConverter.toProto(
ProvinceViewFilter
.withdrawnFromProvinceView(
province = provinceProto,
gs = gsProto,
factionId = battleRevelation.revealedToFactionId
)
.copy(asOf = gsProto.currentDate.map(d => DateConverter.fromProto(Some(d))))
)
)
)
case DidBattle =>
ChangedFactionC(
factionId = battleRevelation.revealedToFactionId,
updatedReconnedProvinces = Vector(
ProvinceViewFilter
.filteredProvinceView(
provinceProto,
gsProto
// FIXME: setting factionId to None right now to grab the full info. This
// probably isn't exactly what we want.
)
.withAsOf(gsProto.currentDate.get)
ProvinceViewConverter.toProto(
ProvinceViewFilter
.filteredProvinceView(
gs.provinces(battleRevelation.provinceId),
gs
)
)
)
)
},
@@ -1,7 +1,6 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
@@ -11,7 +10,7 @@ import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedPro
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ClientTextVisibilityExtensionC}
import net.eagle0.eagle.model.action_result.types.{EndReconResolutionPhaseResultType, ReconSucceededResultType}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.view.province.ProvinceViewConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.{IncomingEndTurnAction, IncomingRecon}
import net.eagle0.eagle.model.state.RoundPhase
@@ -22,9 +21,6 @@ case class PerformReconResolutionAction(
actionResultApplier: ActionResultApplier
) extends ProtolessRandomSequentialResultsAction {
// Proto GameState still needed for ProvinceViewFilter.filteredProvinceView
private lazy val startingGameStateProto: GameStateProto = GameStateConverter.toProto(gameState)
override protected def randomResults(
functionalRandom: FunctionalRandom
): RandomState[Vector[ActionResultT]] =
@@ -94,14 +90,13 @@ case class PerformReconResolutionAction(
ChangedFactionC(
factionId = fromFactionId,
updatedReconnedProvinces = Vector(
ProvinceViewFilter
.filteredProvinceView(
startingGameStateProto.provinces(provinceId),
startingGameStateProto
// FIXME: setting factionId to None right now to grab the full info. This
// probably isn't exactly what we want.
)
.withAsOf(startingGameStateProto.currentDate.get)
ProvinceViewConverter.toProto(
ProvinceViewFilter
.filteredProvinceView(
gameState.provinces(provinceId),
gameState
)
)
)
)
),
@@ -119,8 +119,8 @@ object ResolveRansomOfferCommand {
case Accepted =>
NotificationDetails.RansomPaid(
ransomedHeroId = ransomOffer.prisonerToBeRansomed.prisonerHeroId,
ransomPaidByFactionId = actingFactionId,
ransomPaidToFactionId = ransomOffer.originatingFactionId
ransomPaidByFactionId = ransomOffer.originatingFactionId,
ransomPaidToFactionId = actingFactionId
)
case Rejected =>
@@ -386,8 +386,12 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:stat_with_condition_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
],
)
@@ -1,5 +1,6 @@
package net.eagle0.eagle.library.util
import net.eagle0.eagle.model.view.stat_with_condition.{Condition, StatWithCondition as StatWithConditionT}
import net.eagle0.eagle.views.stat_with_condition.StatWithCondition
import net.eagle0.eagle.views.stat_with_condition.StatWithCondition.Condition.{HIGH, LOW, MEDIUM}
@@ -11,6 +12,13 @@ object StatWithConditionUtils {
minHigh = 55
)
def supportScala(support: Double): StatWithConditionT =
scWithRangesScala(
support.floor.toInt,
minMedium = 40,
minHigh = 55
)
def scWithRanges(
stat: Int,
minMedium: Int,
@@ -23,4 +31,17 @@ object StatWithConditionUtils {
else if stat >= minMedium then MEDIUM
else LOW
)
def scWithRangesScala(
stat: Int,
minMedium: Int,
minHigh: Int
): StatWithConditionT =
StatWithConditionT(
stat = stat,
condition =
if stat >= minHigh then Condition.High
else if stat >= minMedium then Condition.Medium
else Condition.Low
)
}
@@ -56,6 +56,13 @@ object FactionUtils {
): Boolean =
hasTruce(fid1, fid2, factions) || hasAlliance(fid1, fid2, factions)
def factionsAreHostile(
fid1: FactionId,
fid2: FactionId,
factions: Vector[FactionT]
): Boolean =
fid1 != fid2 && !hasTruceOrAlliance(fid1, fid2, factions)
private def incomingHids(province: ProvinceT): Vector[HeroId] =
province.incomingArmies
.map(_.army)
@@ -42,11 +42,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:gold_increase_per_economy",
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/library/settings:per_development_resource_limit",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state:beast_info",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -9,11 +9,13 @@ import net.eagle0.eagle.library.settings.{
MinSupportForTaxes,
PerDevelopmentResourceLimit
}
import net.eagle0.eagle.library.util.BattalionUtils
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.changed_province.ChangedProvinceT
import net.eagle0.eagle.model.state.{BattalionTypeId, BeastInfo, MovingArmy}
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.{
BeastsEvent,
@@ -74,6 +76,15 @@ object ProvinceUtils {
) * FoodIncreasePerAgriculture.doubleValue).toInt
)
def monthlyFoodConsumption(
province: ProvinceT,
gs: GameState
): Int =
BattalionUtils.monthlyConsumedFood(
province.battalionIds.map(gs.battalions),
gs.battalionTypes
)
def provinceLeader(
startingProvince: ProvinceT,
rulingFactionOpt: Option[FactionT],
@@ -1,14 +1,34 @@
package net.eagle0.eagle.library.util.view_filters
import net.eagle0.eagle.{BattalionId, FactionId}
import net.eagle0.eagle.internal.army.Army
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.internal.army.Army as ProtoArmy
import net.eagle0.eagle.internal.battalion.Battalion as ProtoBattalion
import net.eagle0.eagle.model.proto_converters.CombatUnitConverter
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.Army as ScalaArmy
import net.eagle0.eagle.model.view.army.ArmyView as ArmyViewT
import net.eagle0.eagle.views.army_view.ArmyView
object ArmyFilter {
// Pure Scala version
def filterArmy(
army: Army,
battalions: Map[BattalionId, Battalion],
army: ScalaArmy,
battalions: Map[BattalionId, BattalionT],
factionId: Option[FactionId]
): ArmyViewT =
ArmyViewT(
factionId = army.factionId,
units =
if factionId.forall(_ == army.factionId) then army.units
else Vector.empty,
unitCount = army.units.length,
troopCount = army.units.flatMap(_.battalionId).map(bid => battalions(bid)).map(_.size).sum
)
// Proto version (legacy)
def filterArmy(
army: ProtoArmy,
battalions: Map[BattalionId, ProtoBattalion],
factionId: Option[FactionId]
): ArmyView =
ArmyView(
@@ -19,4 +39,18 @@ object ArmyFilter {
unitCount = army.units.length,
troopCount = army.units.flatMap(_.battalionId).map(battalions).map(_.size).sum
)
def filterArmyScala(
army: ProtoArmy,
battalions: Map[BattalionId, ProtoBattalion],
factionId: Option[FactionId]
): ArmyViewT =
ArmyViewT(
factionId = army.factionId,
units =
if factionId.forall(_ == army.factionId) then army.units.map(CombatUnitConverter.fromProto).toVector
else Vector.empty,
unitCount = army.units.length,
troopCount = army.units.flatMap(_.battalionId).map(battalions).map(_.size).sum
)
}
@@ -8,11 +8,18 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/view/army:army_view",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:army_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:combat_unit_converter",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/view/army:army_view",
],
)
@@ -116,6 +123,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:faction_view_filter",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:hero_view_filter",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:province_view_filter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
],
)
@@ -146,10 +154,15 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_id_converter",
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
],
)
@@ -161,20 +174,40 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
deps = [
":army_filter",
":battalion_view_filter",
":legacy_battalion_view_filter",
":visibility",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:beast_utils",
"//src/main/scala/net/eagle0/eagle/library/util:date_utils",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/library/util:province_event_utils",
"//src/main/scala/net/eagle0/eagle/library/util:stat_with_condition_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:unaffiliated_hero_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:order_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/view/incoming_army:incoming_army_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:unaffiliated_hero_basics",
],
)
@@ -1,6 +1,7 @@
package net.eagle0.eagle.library.util.view_filters
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.model.proto_converters.view.province.ProvinceViewConverter
import net.eagle0.eagle.views.faction_view.FactionView
import net.eagle0.eagle.views.game_state_view.GameStateView
import net.eagle0.eagle.FactionId
@@ -16,9 +17,11 @@ object GameStateViewFilter {
currentDate = gs.currentDate,
provinces = gs.provinces.map {
case (pid, prov) =>
pid -> factionId
.map(fid => ProvinceViewFilter.filteredProvinceView(prov, gs, fid))
.getOrElse(ProvinceViewFilter.filteredProvinceView(prov, gs))
pid -> ProvinceViewConverter.toProto(
factionId
.map(fid => ProvinceViewFilter.filteredProvinceView(prov, gs, fid))
.getOrElse(ProvinceViewFilter.filteredProvinceView(prov, gs))
)
},
heroes = (gs.heroes ++ gs.killedHeroes).map {
case (hid, hero) =>
@@ -1,6 +1,8 @@
package net.eagle0.eagle.library.util.view_filters
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.model.proto_converters.BattalionTypeIdConverter
import net.eagle0.eagle.model.view.battalion.BattalionView as BattalionViewT
import net.eagle0.eagle.views.battalion_view.BattalionView
object LegacyBattalionViewFilter {
@@ -14,6 +16,16 @@ object LegacyBattalionViewFilter {
name = batt.name
)
def filteredBattalionViewBelongingToPlayerScala(batt: Battalion): BattalionViewT =
BattalionViewT(
id = batt.id,
typeId = BattalionTypeIdConverter.fromProto(batt.`type`),
size = batt.size,
training = Some(batt.training),
armament = Some(batt.armament),
name = batt.name
)
def limitedBattalionView(batt: Battalion): BattalionView =
BattalionView(
id = batt.id,
@@ -23,4 +35,14 @@ object LegacyBattalionViewFilter {
armament = None,
name = batt.name
)
def limitedBattalionViewScala(batt: Battalion): BattalionViewT =
BattalionViewT(
id = batt.id,
typeId = BattalionTypeIdConverter.fromProto(batt.`type`),
size = batt.size,
training = None,
armament = None,
name = batt.name
)
}
@@ -8,14 +8,77 @@ import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.library.util.{IncomingArmyUtils, ProvinceEventUtils}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.StatWithConditionUtils.supportSc
import net.eagle0.eagle.views.incoming_army_view.IncomingArmyView
import net.eagle0.eagle.views.province_view.{FullProvinceInfo, ProvinceView, UnaffiliatedHeroBasics}
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.StatWithConditionUtils.supportScala
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.hero.ProfessionConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceOrderTypeConverter
import net.eagle0.eagle.model.proto_converters.view.province.{FullProvinceInfoConverter, ProvinceViewConverter}
import net.eagle0.eagle.model.proto_converters.UnaffiliatedHeroConverter
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.unaffiliated_hero.RecruitmentInfo
import net.eagle0.eagle.model.view.incoming_army.{IncomingArmyView, UnitDetails}
import net.eagle0.eagle.model.view.province.{FullProvinceInfo, ProvinceView, UnaffiliatedHeroBasics}
import net.eagle0.eagle.FactionId
object ProvinceViewFilter {
// =============== Scala GameState overloads ===============
/** Filtered view for server-side use (no faction visibility restrictions) */
def filteredProvinceView(
province: ProvinceT,
gs: ScalaGameState
): ProvinceView = {
val factions = gs.factions.values.toVector
val factionLeaderIds = factions.flatMap(_.leaderIds)
val info: FullProvinceInfo = FullProvinceInfo(
rulingHeroId = province.rulingHeroId,
rulingFactionHeroIds = province.rulingFactionHeroIds
.map(hid => gs.heroes(hid))
.sortWith(HeroUtils.sortOrderer(factionLeaderIds))
.map(_.id),
battalions = province.battalionIds
.map(bid => gs.battalions(bid))
.map(BattalionViewFilter.filteredBattalionViewBelongingToPlayer)
.sortBy(_.typeId.value),
defendingArmy = province.defendingArmy
.map(army => ArmyFilter.filterArmy(army, gs.battalions, None)),
economy = province.economy.toFloat,
agriculture = province.agriculture.toFloat,
infrastructure = province.infrastructure.toFloat,
economyDevastation = province.economyDevastation.toFloat,
agricultureDevastation = province.agricultureDevastation.toFloat,
infrastructureDevastation = province.infrastructureDevastation.toFloat,
gold = province.gold,
food = province.food,
priceIndex = province.priceIndex,
goldCap = ProvinceUtils.goldCap(province),
foodCap = ProvinceUtils.foodCap(province),
support = Some(supportScala(province.support)),
foodConsumption = ProvinceUtils.monthlyFoodConsumption(province, gs),
provinceOrders = ProvinceOrderTypeConverter.toProto(province.provinceOrders),
hexMapName = province.hexMapName,
castleCount = province.castleCount,
heroCap = province.heroCap,
unaffiliatedHeroes = Vector.empty,
rulerIsTraveling = province.rulerIsTraveling
)
ProvinceView(
id = province.id,
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = Some(info),
knownEvents = Vector.empty,
asOf = gs.currentDate
)
}
private val universallyVisibleCheckers: List[ProvinceEvent => Boolean] = List(
ProvinceEventUtils.isBlizzardEvent,
ProvinceEventUtils.isFestivalEvent,
@@ -36,21 +99,19 @@ object ProvinceViewFilter {
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = Some(fullProvinceInfo(province, gs, None)),
knownEvents = province.activeEvents,
asOf = Some(gs.currentDate.get)
knownEvents = province.activeEvents.toVector,
asOf = Some(DateConverter.fromProto(gs.currentDate))
)
private def deadHeroesFilteredOut(
fullProvinceInfo: FullProvinceInfo,
gameState: GameState
): FullProvinceInfo =
fullProvinceInfo.update(
_.rulingFactionHeroIds.modify(
_.filterNot(hid => gameState.killedHeroes.contains(hid))
),
_.unaffiliatedHeroes.modify(
_.filterNot(uh => gameState.killedHeroes.contains(uh.heroId))
)
fullProvinceInfo.copy(
rulingFactionHeroIds =
fullProvinceInfo.rulingFactionHeroIds.filterNot(hid => gameState.killedHeroes.contains(hid)),
unaffiliatedHeroes =
fullProvinceInfo.unaffiliatedHeroes.filterNot(uh => gameState.killedHeroes.contains(uh.heroId))
)
def filteredProvinceView(
@@ -65,11 +126,13 @@ object ProvinceViewFilter {
) ++ gs.factions.get(factionId)
// Take the most recent reconned view from this faction or any allies
// (stored as proto, convert to Scala)
val reconnedView =
relevantFactions
.flatMap(_.reconnedProvinces)
.filter(_.id == province.id)
.maxByOption(_.getAsOf)
.map(ProvinceViewConverter.fromProto)
val showAll = province.rulingFactionId.exists { rfid =>
Visibility.hasFullVisibility(factionId, rfid, gs)
@@ -103,7 +166,7 @@ object ProvinceViewFilter {
food = shipment.supplies.map(_.food).getOrElse(0),
gold = shipment.supplies.map(_.gold).getOrElse(0)
)
}
}.toVector
else Vector()
val fullInfo = Option
@@ -117,9 +180,9 @@ object ProvinceViewFilter {
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = fullInfo,
knownEvents = knownEvents,
knownEvents = knownEvents.toVector,
asOf =
if showAll then Some(gs.currentDate.get)
if showAll then Some(DateConverter.fromProto(gs.currentDate))
else reconnedView.flatMap(_.asOf),
knownIncomingArmies = incomingAttackers ++ incomingSupplies
)
@@ -131,10 +194,11 @@ object ProvinceViewFilter {
gs: GameState,
factionId: FactionId
): ProvinceView = {
// Stored as proto, convert to Scala
val reconnedView = for {
faction <- gs.factions.get(factionId)
pv <- faction.reconnedProvinces.find(_.id == province.id)
} yield pv
} yield ProvinceViewConverter.fromProto(pv)
val knownEvents = province.activeEvents.filter { pe =>
if reconnedView.exists(_.knownEvents.contains(pe)) then true
@@ -148,10 +212,13 @@ object ProvinceViewFilter {
val fullInfo = reconnedView
.flatMap(_.fullInfo)
.getOrElse(fullProvinceInfo(province, gs, Some(factionId)))
.update(
_.battalions := province.battalionIds.map(gs.battalions).map {
LegacyBattalionViewFilter.limitedBattalionView
}
.copy(
battalions = province.battalionIds
.map(gs.battalions)
.map {
LegacyBattalionViewFilter.limitedBattalionViewScala
}
.toVector
)
ProvinceView(
@@ -159,7 +226,7 @@ object ProvinceViewFilter {
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = Some(fullInfo),
knownEvents = knownEvents,
knownEvents = knownEvents.toVector,
asOf = reconnedView.flatMap(_.asOf),
knownIncomingArmies = incomingAttackers ++ incomingSupplies
)
@@ -172,13 +239,15 @@ object ProvinceViewFilter {
): FullProvinceInfo = FullProvinceInfo(
rulingHeroId = province.rulingHeroId,
rulingFactionHeroIds = province.rulingFactionHeroIds
.sortWith(LegacyHeroUtils.sortOrderer(gs)),
.sortWith(LegacyHeroUtils.sortOrderer(gs))
.toVector,
battalions = province.battalionIds
.map(gs.battalions)
.map(LegacyBattalionViewFilter.filteredBattalionViewBelongingToPlayer)
.sortBy(_.`type`.index),
.map(LegacyBattalionViewFilter.filteredBattalionViewBelongingToPlayerScala)
.sortBy(_.typeId.value)
.toVector,
defendingArmy = province.defendingArmy
.map(ArmyFilter.filterArmy(_, gs.battalions, factionId)),
.map(ArmyFilter.filterArmyScala(_, gs.battalions, factionId)),
economy = province.economy.toFloat,
agriculture = province.agriculture.toFloat,
infrastructure = province.infrastructure.toFloat,
@@ -190,13 +259,13 @@ object ProvinceViewFilter {
priceIndex = province.priceIndex,
goldCap = LegacyProvinceUtils.goldCap(province),
foodCap = LegacyProvinceUtils.foodCap(province),
support = Some(supportSc(province.support)),
support = Some(supportScala(province.support)),
foodConsumption = LegacyProvinceUtils.monthlyFoodConsumption(province.id, gs),
provinceOrders = province.provinceOrders,
hexMapName = province.hexMapName,
castleCount = province.castleCount,
heroCap = province.heroCap,
unaffiliatedHeroes = province.unaffiliatedHeroes.map(unaffiliatedHeroInfo(_, gs)),
unaffiliatedHeroes = province.unaffiliatedHeroes.map(unaffiliatedHeroInfo(_, gs)).toVector,
rulerIsTraveling = province.rulerIsTraveling
)
@@ -224,6 +293,7 @@ object ProvinceViewFilter {
)
)
.map(army => incomingArmyInfo(ma = army, gs = gs, showDetails = false))
.toVector
private def incomingArmyInfo(
ma: MovingArmy,
@@ -246,14 +316,14 @@ object ProvinceViewFilter {
unitDetails =
if showDetails then
ma.getArmy.units.map { cu =>
IncomingArmyView.UnitDetails(
UnitDetails(
heroId = cu.heroId,
battalion = cu.battalionId.map(gs.battalions).map { b =>
LegacyBattalionViewFilter
.filteredBattalionViewBelongingToPlayer(b)
.filteredBattalionViewBelongingToPlayerScala(b)
}
)
}
}.toVector
else Vector()
)
@@ -265,10 +335,11 @@ object ProvinceViewFilter {
UnaffiliatedHeroBasics(
heroId = hero.id,
nameTextId = hero.nameTextId,
profession = hero.profession,
status = uh.`type`,
profession = ProfessionConverter.fromProto(hero.profession),
status = UnaffiliatedHeroConverter.unaffiliatedHeroTypeFromProto(uh.`type`),
isFactionLeader = LegacyFactionUtils.isFactionLeader(hero.id, gs),
recruitmentInfo = uh.recruitmentInfo
recruitmentInfo =
uh.recruitmentInfo.fold(RecruitmentInfo.Unknown)(UnaffiliatedHeroConverter.recruitmentInfoFromProto)
)
}
}
@@ -1,6 +1,7 @@
package net.eagle0.eagle.model.action_result
import net.eagle0.eagle.{BattalionId, FactionId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.model.action_result.changed_province.ChangedProvinceT
import net.eagle0.eagle.model.action_result.generated_text_request.GeneratedTextRequestT
import net.eagle0.eagle.model.action_result.types.base.ActionResultType
@@ -27,7 +28,7 @@ trait ActionResultT {
def newRoundId: Option[RoundId]
def newDate: Option[Date]
// def lastCommandTypeForActingProvince: Option[CommandType]
def lastCommandTypeForActingProvince: Option[SelectedCommand]
def newBattle: Option[ShardokBattle]
// def resolvedBattle: Option[String]
@@ -73,7 +74,7 @@ trait ActionResultT {
newRoundPhase: Option[RoundPhase] = newRoundPhase,
newRoundId: Option[RoundId] = newRoundId,
newDate: Option[Date] = newDate,
// lastCommandTypeForActingProvince: Option[CommandType] = None,
lastCommandTypeForActingProvince: Option[SelectedCommand] = lastCommandTypeForActingProvince,
newBattle: Option[ShardokBattle] = newBattle,
// resolvedBattle: Option[String] = None,
// resolvedImminentBattle: Option[Int] = None,
@@ -51,6 +51,7 @@ scala_library(
":changed_hero_trait",
":client_text_visibility_extension_trait",
":notification_trait",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
@@ -1,6 +1,7 @@
package net.eagle0.eagle.model.action_result.concrete
import net.eagle0.eagle.{BattalionId, FactionId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.model.action_result.{
ActionResultT,
ChangedBattalionT,
@@ -34,6 +35,7 @@ case class ActionResultC(
newRoundPhase: Option[RoundPhase] = None,
newRoundId: Option[RoundId] = None,
newDate: Option[Date] = None,
lastCommandTypeForActingProvince: Option[SelectedCommand] = None,
newBattle: Option[ShardokBattle] = None,
newChronicleEntry: Option[ChronicleEntry] = None,
changedBattalions: Vector[ChangedBattalionT] = Vector.empty,
@@ -68,6 +70,7 @@ case class ActionResultC(
newRoundPhase: Option[RoundPhase] = newRoundPhase,
newRoundId: Option[RoundId] = newRoundId,
newDate: Option[Date] = newDate,
lastCommandTypeForActingProvince: Option[SelectedCommand] = lastCommandTypeForActingProvince,
newBattle: Option[ShardokBattle] = newBattle,
newChronicleEntry: Option[ChronicleEntry] = newChronicleEntry,
changedBattalions: Vector[ChangedBattalionT] = changedBattalions,
@@ -99,6 +102,7 @@ case class ActionResultC(
newRoundPhase = newRoundPhase,
newRoundId = newRoundId,
newDate = newDate,
lastCommandTypeForActingProvince = lastCommandTypeForActingProvince,
newBattle = newBattle,
newChronicleEntry = newChronicleEntry,
changedBattalions = changedBattalions,
@@ -16,6 +16,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/types/base:action_result_type",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
@@ -1,6 +1,7 @@
package net.eagle0.eagle.model.proto_converters
import net.eagle0.eagle.{BattalionId, FactionId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.common.action_result_type.ActionResultType as ActionResultTypeProto
import net.eagle0.eagle.common.round_phase.NewRoundPhase as NewRoundPhaseProto
import net.eagle0.eagle.internal.action_result.ActionResult as ActionResultProto
@@ -53,6 +54,7 @@ object ActionResultProtoConverter {
newRoundPhase: Option[RoundPhase],
newRoundId: Option[RoundId],
newDate: Option[Date],
lastCommandTypeForActingProvince: Option[SelectedCommand],
newBattle: Option[ShardokBattle],
newChronicleEntry: Option[ChronicleEntry],
changedBattalions: Vector[ChangedBattalionT],
@@ -86,6 +88,7 @@ object ActionResultProtoConverter {
newRoundPhase = newRoundPhase.map(rp => NewRoundPhaseProto(RoundPhaseConverter.toProto(rp))),
newRoundId = newRoundId,
newDate = newDate.map(DateConverter.toProto),
lastCommandTypeForActingProvince = lastCommandTypeForActingProvince.getOrElse(SelectedCommand.Empty),
newBattle = newBattle.map(ShardokBattleConverter.toProto),
newChronicleEntry = newChronicleEntry.map(ChronicleEntryConverter.toProto),
changedBattalions = changedBattalions.map {
@@ -24,6 +24,7 @@ scala_library(
":changed_province_converter",
":notification_converter",
":round_phase_converter",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -110,6 +111,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/battalion:__pkg__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -257,10 +261,14 @@ scala_library(
srcs = ["CombatUnitConverter.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions/impl/command:__pkg__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state:combat_unit",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -426,11 +434,15 @@ scala_library(
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -31,6 +31,11 @@ object UnaffiliatedHeroConverter {
UNAFFILIATED_HERO_RETURNING_PRISONER -> UnaffiliatedHeroType.ReturningPrisoner
)
def unaffiliatedHeroTypeFromProto(
protoType: UnaffiliatedHeroTypeProto
): UnaffiliatedHeroType =
unaffiliatedHeroTypeMap.getOrElse(protoType, UnaffiliatedHeroType.Unknown)
def recruitmentInfoFromProto(
recruitmentInfoProto: RecruitmentInfoProto
): RecruitmentInfo = recruitmentInfoProto.status match {
@@ -7,6 +7,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:__pkg__",
@@ -85,6 +85,7 @@ scala_library(
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
@@ -45,6 +45,7 @@ scala_library(
srcs = ["ProvinceEventConverter.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
],
@@ -65,6 +66,7 @@ scala_library(
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
],
deps = [
@@ -100,7 +100,7 @@ object ProvinceConverter {
hexMapName: String,
castleCount: Int,
heroCap: Int,
lastCommand: Any
lastCommand: Option[SelectedCommand]
) =>
ProvinceProto(
id = id,
@@ -131,15 +131,7 @@ object ProvinceConverter {
activeEvents = activeEvents.map(ProvinceEventConverter.toProto),
lastBeastsDate = lastBeastsDate.map(DateConverter.toProto),
lastRiotDate = lastRiotDate.map(DateConverter.toProto),
lastCommand = lastCommand match {
case Some(sc: SelectedCommand) =>
sc
case Some(x) =>
throw new ProtoConversionException(
s"Unknown lastCommand type: ${x.getClass}"
)
case None => SelectedCommand.Empty
},
lastCommand = lastCommand.getOrElse(SelectedCommand.Empty),
incomingArmies = incomingArmies.map(ArmyConverter.toProto),
hostileArmies = hostileArmies.map(ArmyConverter.toProto),
defendingArmy = defendingArmy.map(ArmyConverter.toProto),
@@ -228,7 +220,7 @@ object ProvinceConverter {
activeEvents = activeEvents.map(ProvinceEventConverter.fromProto).toVector,
lastBeastsDate = lastBeastsDate.map(date => DateConverter.fromProto(Some(date))),
lastRiotDate = lastRiotDate.map(date => DateConverter.fromProto(Some(date))),
lastCommand = Some(lastCommand),
lastCommand = Option.when(!lastCommand.isEmpty)(lastCommand),
incomingArmies = incomingArmies.map(ArmyConverter.fromProto).toVector,
hostileArmies = hostileArmies.map(ArmyConverter.fromProto).toVector,
defendingArmy = defendingArmy.map(ArmyConverter.fromProto),
@@ -12,7 +12,7 @@ import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType.{
import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType as UnaffiliatedHeroTypeProto
import net.eagle0.eagle.model.proto_converters.hero.ProfessionConverter
import net.eagle0.eagle.model.proto_converters.UnaffiliatedHeroConverter
import net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroType
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType}
import net.eagle0.eagle.model.view.province.UnaffiliatedHeroBasics
import net.eagle0.eagle.views.province_view.UnaffiliatedHeroBasics as UnaffiliatedHeroBasicsProto
@@ -50,6 +50,6 @@ object UnaffiliatedHeroBasicsConverter {
isFactionLeader = proto.isFactionLeader,
recruitmentInfo = proto.recruitmentInfo
.map(ri => UnaffiliatedHeroConverter.recruitmentInfoFromProto(ri))
.getOrElse(throw new IllegalArgumentException("RecruitmentInfo is required"))
.getOrElse(RecruitmentInfo.Unknown)
)
}
@@ -9,6 +9,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/province:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/validations:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/action_result:__subpackages__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/shardok_battle:__pkg__",
@@ -41,6 +42,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/view/battalion:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
],
deps = [
],
@@ -121,6 +123,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/view:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -84,6 +84,7 @@ scala_library(
"//src/main/scala/net/eagle0/util/hero_generation:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
"//src/test/scala/net/eagle0/eagle/service/new_game_creation:__pkg__",
],
deps = [],
@@ -63,6 +63,7 @@ scala_library(
":event",
":incoming_end_turn_action",
":orders",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
@@ -1,6 +1,7 @@
package net.eagle0.eagle.model.state.province
import net.eagle0.eagle.{BattalionId, FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.model.state.{
Army,
BattalionTypeId,
@@ -86,6 +87,8 @@ trait ProvinceT {
def castleCount: Int
def heroCap: Int
def lastCommand: Option[SelectedCommand]
def updateWith(
name: String = name,
neighbors: Vector[Neighbor] = neighbors,
@@ -122,7 +125,8 @@ trait ProvinceT {
battleRevelations: Vector[BattleRevelation] = battleRevelations,
hexMapName: String = hexMapName,
castleCount: Int = castleCount,
heroCap: Int = heroCap
heroCap: Int = heroCap,
lastCommand: Option[SelectedCommand] = lastCommand
): ProvinceT
def withRulingFactionId(rulingFactionId: FactionId): ProvinceT =
@@ -18,6 +18,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/province:orders",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state:army",
"//src/main/scala/net/eagle0/eagle/model/state:battle_revelation",
@@ -1,6 +1,7 @@
package net.eagle0.eagle.model.state.province.concrete
import net.eagle0.eagle.{BattalionId, FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.model.state.{
Army,
BattalionTypeId,
@@ -61,10 +62,7 @@ case class ProvinceC(
hexMapName: String = "",
castleCount: Int = 0,
heroCap: Int = 0,
// This is super gross, but I don't want to import this proto here when only one use case needs it,
// so let's just match for it when we need it until I can replace the command proto with something
// more lightweight.
lastCommand: Option[Any] = None
lastCommand: Option[SelectedCommand] = None
) extends ProvinceT {
override def updateWith(
name: String = name,
@@ -102,7 +100,8 @@ case class ProvinceC(
battleRevelations: Vector[BattleRevelation] = battleRevelations,
hexMapName: String = hexMapName,
castleCount: Int = castleCount,
heroCap: Int = heroCap
heroCap: Int = heroCap,
lastCommand: Option[SelectedCommand] = lastCommand
): ProvinceT =
ProvinceC(
id = this.id,
@@ -8,6 +8,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -10,6 +10,11 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
"//src/main/scala/net/eagle0/eagle/model/state/hero:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/view:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/test/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -15,7 +15,7 @@ case class IncomingArmyView(
originProvinceId: ProvinceId,
destinationProvinceId: ProvinceId,
arrivalRoundId: RoundId,
food: Int,
gold: Int,
food: Int = 0,
gold: Int = 0,
unitDetails: Vector[UnitDetails] = Vector.empty
)
@@ -4,6 +4,10 @@ scala_library(
name = "unaffiliated_hero_basics",
srcs = ["UnaffiliatedHeroBasics.scala"],
visibility = ["//visibility:public"],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state/hero:profession",
@@ -378,8 +378,14 @@ class GamesManager(
shardokGameId: ShardokGameId
): Boolean =
this.synchronized {
gameControllerInfos(eagleGameId).controller.engine.history
.hasShardokPlayerAvailableCommands(shardokGameId)
val controller = gameControllerInfos(eagleGameId).controller
val humanFactionIds = controller.humanClients.map(_.factionId)
val history = controller.engine.history
humanFactionIds.exists { fid =>
history
.shardokPlayerAvailableCommands(shardokGameId, fid)
.exists(_.currentCommand.nonEmpty)
}
}
def gamesFor(name: String): Vector[GamePlayerInfo] = this.synchronized {
@@ -4,6 +4,7 @@ import scala.collection.mutable
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
import io.grpc.stub.StreamObserver
import net.eagle0.common.common_unit.*
import net.eagle0.common.game_setup_info.{GameSetupInfo, NewGameRequest}
import net.eagle0.common.player_info.{AlliedPlayer, PlayerSetupInfo}
@@ -48,13 +49,113 @@ class ShardokInterfaceGrpcClient(
resolveNewGameRequest(battle, newGameRequest)
}
private def waitingForHumanPlayer(battle: ShardokBattle): Boolean =
resolutionReceiver.hasHumanPlayerCommands(
eagleGameId = battle.eagleGameId,
shardokGameId = battle.shardokGameId
private def playerInfo(
shardokPlayer: ShardokPlayer,
eagleGameState: GameState,
playerToUserMap: Map[FactionId, String]
): PlayerSetupInfo =
PlayerSetupInfo(
`type` = if playerToUserMap.contains(shardokPlayer.eagleFid) then HUMAN else AI,
eagleFactionId = shardokPlayer.eagleFid,
userName = playerToUserMap.getOrElse(shardokPlayer.eagleFid, ""),
units = toCommon(
shardokPlayer.getArmyGroup.armies.toVector,
eagleGameState
),
defender = shardokPlayer.isDefender,
victoryConditions = shardokPlayer.victoryConditions,
food = shardokPlayer.food,
allies = eagleGameState
.factions(shardokPlayer.eagleFid)
.factionRelationships
.filter(_.relationshipLevel != HOSTILE)
.map(_.targetFactionId)
.map(fid => AlliedPlayer(eagleFactionId = fid))
)
private def handleBattleResponse(update: GameStatusResponse): Unit = {
/**
* Subscribes to game updates using server-side streaming.
*
* This replaces the previous polling approach (gameStatusRunner) with a push-based model. The server pushes updates
* as they occur, reducing latency and eliminating polling overhead.
*/
private def subscribeToGame(
eagleGameId: GameId,
shardokGameId: ShardokGameId,
newGameRequest: Option[NewGameRequest]
): Unit = {
val request = GameSubscriptionRequest(
gameId = shardokGameId,
gameSetupInfo = Some(
GameSetupInfo(
knownResultCount = resolutionReceiver.knownResultCount(
eagleGameId = eagleGameId,
shardokGameId = shardokGameId
),
currentGameState = resolutionReceiver.currentGameState(
eagleGameId = eagleGameId,
shardokGameId = shardokGameId
),
newGameRequest = newGameRequest
)
)
)
val responseObserver = new StreamObserver[GameStatusResponse] {
override def onNext(response: GameStatusResponse): Unit =
try handleStreamingResponse(response, eagleGameId, shardokGameId)
catch {
case e: Exception =>
println(s"Exception processing stream response for $shardokGameId: $e")
e.printStackTrace()
}
override def onError(t: Throwable): Unit = {
println(s"Stream error for $shardokGameId: $t")
// Reconnect after a delay if the battle is still pending
scheduleReconnect(eagleGameId, shardokGameId)
}
override def onCompleted(): Unit =
println(s"Stream completed for $shardokGameId")
}
grpcClient.subscribeToGame(request, responseObserver)
}
/** Schedules a reconnection attempt after stream disconnection with exponential backoff. */
private def scheduleReconnect(
eagleGameId: GameId,
shardokGameId: ShardokGameId,
delayMs: Long = 1000
): Unit =
Future {
Thread.sleep(delayMs)
pendingBattles.synchronized {
if pendingBattles.contains(shardokGameId) then
println(s"Reconnecting stream for $shardokGameId (delay was ${delayMs}ms)")
try subscribeToGame(eagleGameId, shardokGameId, None)
catch {
case e: Exception =>
println(s"Reconnect failed for $shardokGameId: $e")
// Exponential backoff with max 30 seconds
val nextDelay = Math.min(delayMs * 2, 30000)
scheduleReconnect(eagleGameId, shardokGameId, nextDelay)
}
}
}
/**
* Handles responses from the streaming subscription.
*
* Unlike handleBattleResponse, this does not trigger further polling since updates are pushed automatically via the
* stream.
*/
private def handleStreamingResponse(
update: GameStatusResponse,
eagleGameId: GameId,
shardokGameId: ShardokGameId
): Unit = {
val maybeBattle = pendingBattles.synchronized {
pendingBattles.get(update.gameId)
}
@@ -102,85 +203,15 @@ class ShardokInterfaceGrpcClient(
currentGameStateBytes = gameUpdateResponse.currentGameState.toByteArray
)
)
// Only do the gameStatusRunner if there are no available commands. If there are available commands, just wait
// for the next client post.
if !waitingForHumanPlayer(battle) then
gameStatusRunner(
eagleGameId = battle.eagleGameId,
shardokGameId = battle.shardokGameId,
newGameRequest = None
)
// Note: No polling needed - updates are pushed via stream
case Specific.Empty =>
// FIXME: maybe a delay
if !waitingForHumanPlayer(battle) then
gameStatusRunner(
eagleGameId = battle.eagleGameId,
shardokGameId = battle.shardokGameId,
newGameRequest = None
)
// Empty response - nothing to process
}
case None =>
println(s"No pending battle found for ${update.gameId}")
}
}
private def playerInfo(
shardokPlayer: ShardokPlayer,
eagleGameState: GameState,
playerToUserMap: Map[FactionId, String]
): PlayerSetupInfo =
PlayerSetupInfo(
`type` = if playerToUserMap.contains(shardokPlayer.eagleFid) then HUMAN else AI,
eagleFactionId = shardokPlayer.eagleFid,
userName = playerToUserMap.getOrElse(shardokPlayer.eagleFid, ""),
units = toCommon(
shardokPlayer.getArmyGroup.armies.toVector,
eagleGameState
),
defender = shardokPlayer.isDefender,
victoryConditions = shardokPlayer.victoryConditions,
food = shardokPlayer.food,
allies = eagleGameState
.factions(shardokPlayer.eagleFid)
.factionRelationships
.filter(_.relationshipLevel != HOSTILE)
.map(_.targetFactionId)
.map(fid => AlliedPlayer(eagleFactionId = fid))
)
private def gameStatusRunner(
eagleGameId: GameId,
shardokGameId: ShardokGameId,
newGameRequest: Option[NewGameRequest]
): Unit =
retrier
.withRetries(
_.getGameStatus(
GameStatusRequest(
gameId = shardokGameId,
gameSetupInfo = Some(
GameSetupInfo(
knownResultCount = resolutionReceiver.knownResultCount(
eagleGameId = eagleGameId,
shardokGameId = shardokGameId
),
currentGameState = resolutionReceiver.currentGameState(
eagleGameId = eagleGameId,
shardokGameId = shardokGameId
),
newGameRequest = newGameRequest
)
)
)
)
)
.onComplete {
case Success(gameStatus) => handleBattleResponse(gameStatus)
case Failure(e) =>
print(s"Caught exception $e\n")
gameStatusRunner(eagleGameId, shardokGameId, newGameRequest)
}
override def resolveNewGameRequest(
battle: ShardokBattle,
newGameRequest: NewGameRequest
@@ -189,7 +220,7 @@ class ShardokInterfaceGrpcClient(
pendingBattles(newGameRequest.gameId) = (battle, newGameRequest)
)
gameStatusRunner(
subscribeToGame(
eagleGameId = newGameRequest.eagleGameId,
shardokGameId = newGameRequest.gameId,
newGameRequest = Some(newGameRequest)
@@ -264,10 +295,10 @@ class ShardokInterfaceGrpcClient(
)
)
.onComplete {
case Success(gameStatusResponse) =>
handleBattleResponse(gameStatusResponse)
case Failure(e) =>
print("failed to post a command\n")
case Success(_) =>
// Response acknowledged - actual updates come via stream
case Failure(e) =>
println(s"Failed to post command: $e")
throw e
}
else print("Invalid token\n")
@@ -311,10 +342,10 @@ class ShardokInterfaceGrpcClient(
)
)
.onComplete {
case Success(gameStatusResponse) =>
handleBattleResponse(gameStatusResponse)
case Failure(e) =>
print("failed to post a placement command")
case Success(_) =>
// Response acknowledged - actual updates come via stream
case Failure(e) =>
println(s"Failed to post placement command: $e")
throw e
}
else print("Invalid token\n")
@@ -8,7 +8,7 @@ import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.recruitment_info.RecruitmentStatus.*
import net.eagle0.eagle.common.round_phase.RoundPhase
import net.eagle0.eagle.common.round_phase.RoundPhase.PROVINCE_EVENTS
import net.eagle0.eagle.common.unaffiliated_hero_quest.Quest
import net.eagle0.eagle.common.unaffiliated_hero_quest.{Quest, WealthQuest}
import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType.{
UNAFFILIATED_HERO_OUTLAW,
UNAFFILIATED_HERO_PRISONER,
@@ -113,7 +113,13 @@ class GameStateViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with M
recruitmentInfo = Some(
RecruitmentInfo(
status = RECRUITMENT_STATUS_HAS_QUEST,
quest = Some(Quest())
quest = Some(
Quest(
componentCount = 1,
componentsFulfilled = 0,
details = WealthQuest(gold = 100, food = 50)
)
)
)
)
),
@@ -293,7 +299,8 @@ class GameStateViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with M
recruitmentInfo = Some(
RecruitmentInfo(
status = RECRUITMENT_STATUS_HAS_QUEST,
quest = Some(Quest())
// Note: componentCount is 0 because Scala WealthQuest doesn't preserve it
quest = Some(Quest(details = WealthQuest(gold = 100, food = 50)))
)
)
),
@@ -34,13 +34,21 @@ scala_test(
name = "province_view_filter_test",
srcs = ["ProvinceViewFilterTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:base_resource_limit",
"//src/main/scala/net/eagle0/eagle/library/settings:per_development_resource_limit",
"//src/main/scala/net/eagle0/eagle/library/util:idable",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:province_view_filter",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
"//src/main/scala/net/eagle0/eagle/model/view/incoming_army:incoming_army_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:unaffiliated_hero_basics",
"//src/test/scala/net/eagle0/common:proto_matchers",
],
)
@@ -1,7 +1,6 @@
package net.eagle0.eagle.library.util.view_filters
import net.eagle0.common.ProtoMatchers.equalProto
import net.eagle0.eagle.common.battalion_type.BattalionTypeId
import net.eagle0.eagle.common.battalion_type.BattalionTypeId as BattalionTypeIdProto
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.date.Date
import net.eagle0.eagle.internal.army.{Army, MovingArmy}
@@ -16,9 +15,17 @@ import net.eagle0.eagle.internal.supplies.{MovingSupplies, Supplies}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.library.settings.{BaseResourceLimit, PerDevelopmentResourceLimit}
import net.eagle0.eagle.library.util.IDable.{mapifyFactions, mapifyHeroes, mapifyProvinces}
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.views.incoming_army_view.IncomingArmyView
import net.eagle0.eagle.views.province_view.{FullProvinceInfo, ProvinceView, UnaffiliatedHeroBasics}
import net.eagle0.eagle.model.state.hero.Profession
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType}
import net.eagle0.eagle.model.state.BattalionTypeId
import net.eagle0.eagle.model.view.battalion.BattalionView
import net.eagle0.eagle.model.view.incoming_army.{IncomingArmyView, UnitDetails}
import net.eagle0.eagle.model.view.province.{FullProvinceInfo, ProvinceView, UnaffiliatedHeroBasics}
import net.eagle0.eagle.views.province_view.{
FullProvinceInfo as FullProvinceInfoProto,
ProvinceView as ProvinceViewProto,
UnaffiliatedHeroBasics as UnaffiliatedHeroBasicsProto
}
import net.eagle0.eagle.HeroId
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
@@ -36,14 +43,14 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
19 -> Battalion(
id = 19,
size = 253,
`type` = BattalionTypeId.HEAVY_CAVALRY,
`type` = BattalionTypeIdProto.HEAVY_CAVALRY,
training = 51.0,
armament = 75.0
),
21 -> Battalion(
id = 21,
size = 101,
`type` = BattalionTypeId.LONGBOWMEN,
`type` = BattalionTypeIdProto.LONGBOWMEN,
training = 12.0,
armament = 15.0
)
@@ -63,7 +70,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
factionId = 3
)
provinceView.getFullInfo.economyDevastation shouldBe 0
provinceView.fullInfo.get.economyDevastation shouldBe 0
}
it should "put a devastation of 2.1 at 2.1" in {
@@ -80,7 +87,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
factionId = 3
)
provinceView.getFullInfo.economyDevastation shouldBe 2.1f
provinceView.fullInfo.get.economyDevastation shouldBe 2.1f
}
it should "put an economy of 0.1 at 0.1" in {
@@ -97,7 +104,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
factionId = 3
)
provinceView.getFullInfo.economy shouldBe 0.1f
provinceView.fullInfo.get.economy shouldBe 0.1f
}
it should "show ruler is traveling if they are" in {
@@ -114,7 +121,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
factionId = 3
)
provinceView.getFullInfo.rulerIsTraveling shouldBe true
provinceView.fullInfo.get.rulerIsTraveling shouldBe true
}
it should "not show ruler is traveling if they are not" in {
@@ -131,7 +138,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
factionId = 3
)
provinceView.getFullInfo.rulerIsTraveling shouldBe false
provinceView.fullInfo.get.rulerIsTraveling shouldBe false
}
it should "show your own incoming armies into your own province" in {
@@ -168,38 +175,37 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
ProvinceViewFilter.filteredProvinceView(province, gameState, 3)
filtered.knownIncomingArmies should have size 1
filtered.knownIncomingArmies.head should equalProto(
IncomingArmyView(
factionId = 3,
heroCount = 2,
troopCount = 354,
originProvinceId = 1,
destinationProvinceId = 5,
arrivalRoundId = 4,
unitDetails = Vector(
IncomingArmyView
.UnitDetails(
heroId = 5,
battalion = Some(
BattalionView(
id = 19,
size = 253,
`type` = BattalionTypeId.HEAVY_CAVALRY,
training = Some(51),
armament = Some(75)
)
)
),
IncomingArmyView.UnitDetails(
heroId = 9,
battalion = Some(
BattalionView(
id = 21,
size = 101,
`type` = BattalionTypeId.LONGBOWMEN,
training = Some(12),
armament = Some(15)
)
filtered.knownIncomingArmies.head shouldBe IncomingArmyView(
factionId = 3,
heroCount = 2,
troopCount = 354,
originProvinceId = 1,
destinationProvinceId = 5,
arrivalRoundId = 4,
unitDetails = Vector(
UnitDetails(
heroId = 5,
battalion = Some(
BattalionView(
id = 19,
typeId = BattalionTypeId.HeavyCavalry,
size = 253,
training = Some(51),
armament = Some(75),
name = ""
)
)
),
UnitDetails(
heroId = 9,
battalion = Some(
BattalionView(
id = 21,
typeId = BattalionTypeId.Longbowmen,
size = 101,
training = Some(12),
armament = Some(15),
name = ""
)
)
)
@@ -277,15 +283,13 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
ProvinceViewFilter.filteredProvinceView(province, gameState, 3)
filtered.knownIncomingArmies should have size 1
filtered.knownIncomingArmies.head should equalProto(
IncomingArmyView(
factionId = 8,
heroCount = 2,
troopCount = 354,
originProvinceId = 1,
destinationProvinceId = 5,
arrivalRoundId = 4
)
filtered.knownIncomingArmies.head shouldBe IncomingArmyView(
factionId = 8,
heroCount = 2,
troopCount = 354,
originProvinceId = 1,
destinationProvinceId = 5,
arrivalRoundId = 4
)
}
@@ -323,38 +327,37 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
ProvinceViewFilter.filteredProvinceView(province, gameState, 3)
filtered.knownIncomingArmies should have size 1
filtered.knownIncomingArmies.head should equalProto(
IncomingArmyView(
factionId = 3,
heroCount = 2,
troopCount = 354,
originProvinceId = 1,
destinationProvinceId = 5,
arrivalRoundId = 4,
unitDetails = Vector(
IncomingArmyView
.UnitDetails(
heroId = 5,
battalion = Some(
BattalionView(
id = 19,
size = 253,
`type` = BattalionTypeId.HEAVY_CAVALRY,
training = Some(51),
armament = Some(75)
)
)
),
IncomingArmyView.UnitDetails(
heroId = 9,
battalion = Some(
BattalionView(
id = 21,
size = 101,
`type` = BattalionTypeId.LONGBOWMEN,
training = Some(12),
armament = Some(15)
)
filtered.knownIncomingArmies.head shouldBe IncomingArmyView(
factionId = 3,
heroCount = 2,
troopCount = 354,
originProvinceId = 1,
destinationProvinceId = 5,
arrivalRoundId = 4,
unitDetails = Vector(
UnitDetails(
heroId = 5,
battalion = Some(
BattalionView(
id = 19,
typeId = BattalionTypeId.HeavyCavalry,
size = 253,
training = Some(51),
armament = Some(75),
name = ""
)
)
),
UnitDetails(
heroId = 9,
battalion = Some(
BattalionView(
id = 21,
typeId = BattalionTypeId.Longbowmen,
size = 101,
training = Some(12),
armament = Some(15),
name = ""
)
)
)
@@ -432,38 +435,37 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
ProvinceViewFilter.filteredProvinceView(province, gameState, 3)
filtered.knownIncomingArmies should have size 1
filtered.knownIncomingArmies.head should equalProto(
IncomingArmyView(
factionId = 3,
heroCount = 2,
troopCount = 354,
originProvinceId = 1,
destinationProvinceId = 5,
arrivalRoundId = 4,
unitDetails = Vector(
IncomingArmyView
.UnitDetails(
heroId = 5,
battalion = Some(
BattalionView(
id = 19,
size = 253,
`type` = BattalionTypeId.HEAVY_CAVALRY,
training = Some(51),
armament = Some(75)
)
)
),
IncomingArmyView.UnitDetails(
heroId = 9,
battalion = Some(
BattalionView(
id = 21,
size = 101,
`type` = BattalionTypeId.LONGBOWMEN,
training = Some(12),
armament = Some(15)
)
filtered.knownIncomingArmies.head shouldBe IncomingArmyView(
factionId = 3,
heroCount = 2,
troopCount = 354,
originProvinceId = 1,
destinationProvinceId = 5,
arrivalRoundId = 4,
unitDetails = Vector(
UnitDetails(
heroId = 5,
battalion = Some(
BattalionView(
id = 19,
typeId = BattalionTypeId.HeavyCavalry,
size = 253,
training = Some(51),
armament = Some(75),
name = ""
)
)
),
UnitDetails(
heroId = 9,
battalion = Some(
BattalionView(
id = 21,
typeId = BattalionTypeId.Longbowmen,
size = 101,
training = Some(12),
armament = Some(15),
name = ""
)
)
)
@@ -531,16 +533,24 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val filtered =
ProvinceViewFilter.filteredProvinceView(province, gameState, 11)
inside(filtered.getFullInfo.unaffiliatedHeroes) {
inside(filtered.fullInfo.get.unaffiliatedHeroes) {
case uhs: Vector[UnaffiliatedHeroBasics] =>
uhs should contain theSameElementsAs Vector(
UnaffiliatedHeroBasics(
heroId = 1,
nameTextId = "hn_1"
nameTextId = "hn_1",
profession = Profession.NoProfession,
status = UnaffiliatedHeroType.Unknown,
isFactionLeader = false,
recruitmentInfo = RecruitmentInfo.Unknown
),
UnaffiliatedHeroBasics(
heroId = 2,
nameTextId = "hn_2"
nameTextId = "hn_2",
profession = Profession.NoProfession,
status = UnaffiliatedHeroType.Unknown,
isFactionLeader = false,
recruitmentInfo = RecruitmentInfo.Unknown
)
)
}
@@ -625,10 +635,10 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val askingFaction = Faction(
id = 4,
reconnedProvinces = Vector(
ProvinceView(
ProvinceViewProto(
id = 5,
fullInfo = Some(
FullProvinceInfo(
FullProvinceInfoProto(
rulingFactionHeroIds = Vector(2, 3)
)
)
@@ -647,7 +657,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val filtered =
ProvinceViewFilter.filteredProvinceView(province, gameState, 4)
inside(filtered.getFullInfo.rulingFactionHeroIds) {
inside(filtered.fullInfo.get.rulingFactionHeroIds) {
case ids: Vector[HeroId] =>
ids should contain theSameElementsAs Vector(2, 3)
}
@@ -683,11 +693,11 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
)
),
reconnedProvinces = Vector(
ProvinceView(
ProvinceViewProto(
id = 5,
asOf = Some(Date(year = 2024, month = 1)),
fullInfo = Some(
FullProvinceInfo(
FullProvinceInfoProto(
rulingFactionHeroIds = Vector(2, 3)
)
)
@@ -706,7 +716,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val filtered =
ProvinceViewFilter.filteredProvinceView(province, gameState, 4)
inside(filtered.getFullInfo.rulingFactionHeroIds) {
inside(filtered.fullInfo.get.rulingFactionHeroIds) {
case ids: Vector[HeroId] =>
ids should contain theSameElementsAs Vector(2, 3)
}
@@ -730,11 +740,11 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
)
),
reconnedProvinces = Vector(
ProvinceView(
ProvinceViewProto(
id = 5,
asOf = Some(Date(year = 2024, month = 4)),
fullInfo = Some(
FullProvinceInfo(
FullProvinceInfoProto(
rulingFactionHeroIds = Vector(2, 3, 4)
)
)
@@ -753,11 +763,11 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
)
),
reconnedProvinces = Vector(
ProvinceView(
ProvinceViewProto(
id = 5,
asOf = Some(Date(year = 2024, month = 1)),
fullInfo = Some(
FullProvinceInfo(
FullProvinceInfoProto(
rulingFactionHeroIds = Vector(2, 3)
)
)
@@ -776,7 +786,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val filtered =
ProvinceViewFilter.filteredProvinceView(province, gameState, 4)
inside(filtered.getFullInfo.rulingFactionHeroIds) {
inside(filtered.fullInfo.get.rulingFactionHeroIds) {
case ids: Vector[HeroId] =>
ids should contain theSameElementsAs Vector(2, 3, 4)
}
@@ -800,11 +810,11 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
)
),
reconnedProvinces = Vector(
ProvinceView(
ProvinceViewProto(
id = 5,
asOf = Some(Date(year = 2024, month = 4)),
fullInfo = Some(
FullProvinceInfo(
FullProvinceInfoProto(
rulingFactionHeroIds = Vector(2, 3, 4)
)
)
@@ -823,11 +833,11 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
)
),
reconnedProvinces = Vector(
ProvinceView(
ProvinceViewProto(
id = 5,
asOf = Some(Date(year = 2024, month = 6)),
fullInfo = Some(
FullProvinceInfo(
FullProvinceInfoProto(
rulingFactionHeroIds = Vector(2, 3)
)
)
@@ -846,7 +856,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val filtered =
ProvinceViewFilter.filteredProvinceView(province, gameState, 4)
inside(filtered.getFullInfo.rulingFactionHeroIds) {
inside(filtered.fullInfo.get.rulingFactionHeroIds) {
case ids: Vector[HeroId] =>
ids should contain theSameElementsAs Vector(2, 3)
}
@@ -862,10 +872,10 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val askingFaction = Faction(
id = 4,
reconnedProvinces = Vector(
ProvinceView(
ProvinceViewProto(
id = 5,
fullInfo = Some(
FullProvinceInfo(
FullProvinceInfoProto(
rulingFactionHeroIds = Vector(2, 3)
)
)
@@ -885,7 +895,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val filtered =
ProvinceViewFilter.filteredProvinceView(province, gameState, 4)
inside(filtered.getFullInfo.rulingFactionHeroIds) {
inside(filtered.fullInfo.get.rulingFactionHeroIds) {
case ids: Vector[HeroId] =>
ids should contain theSameElementsAs Vector(3)
}
@@ -906,16 +916,16 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val askingFaction = Faction(
id = 4,
reconnedProvinces = Vector(
ProvinceView(
ProvinceViewProto(
id = 5,
fullInfo = Some(
FullProvinceInfo(
FullProvinceInfoProto(
unaffiliatedHeroes = Vector(
UnaffiliatedHeroBasics(
UnaffiliatedHeroBasicsProto(
heroId = 4,
nameTextId = "hn_4"
),
UnaffiliatedHeroBasics(
UnaffiliatedHeroBasicsProto(
heroId = 6,
nameTextId = "hn_6"
)
@@ -941,16 +951,24 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val filtered =
ProvinceViewFilter.filteredProvinceView(province, gameState, 4)
inside(filtered.getFullInfo.unaffiliatedHeroes) {
inside(filtered.fullInfo.get.unaffiliatedHeroes) {
case uhs: Vector[UnaffiliatedHeroBasics] =>
uhs should contain theSameElementsAs Vector(
UnaffiliatedHeroBasics(
heroId = 4,
nameTextId = "hn_4"
nameTextId = "hn_4",
profession = Profession.NoProfession,
status = UnaffiliatedHeroType.Unknown,
isFactionLeader = false,
recruitmentInfo = RecruitmentInfo.Unknown
),
UnaffiliatedHeroBasics(
heroId = 6,
nameTextId = "hn_6"
nameTextId = "hn_6",
profession = Profession.NoProfession,
status = UnaffiliatedHeroType.Unknown,
isFactionLeader = false,
recruitmentInfo = RecruitmentInfo.Unknown
)
)
}
@@ -971,13 +989,13 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val askingFaction = Faction(
id = 4,
reconnedProvinces = Vector(
ProvinceView(
ProvinceViewProto(
id = 5,
fullInfo = Some(
FullProvinceInfo(
FullProvinceInfoProto(
unaffiliatedHeroes = Vector(
UnaffiliatedHeroBasics(heroId = 4),
UnaffiliatedHeroBasics(heroId = 6)
UnaffiliatedHeroBasicsProto(heroId = 4),
UnaffiliatedHeroBasicsProto(heroId = 6)
)
)
)
@@ -997,7 +1015,7 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
val filtered =
ProvinceViewFilter.filteredProvinceView(province, gameState, 4)
inside(filtered.getFullInfo.unaffiliatedHeroes) {
inside(filtered.fullInfo.get.unaffiliatedHeroes) {
case uhs: Vector[UnaffiliatedHeroBasics] =>
uhs.map(_.heroId) should contain theSameElementsAs Vector(6)
}
@@ -0,0 +1,101 @@
package net.eagle0.eagle.model.proto_converters.view.army
import net.eagle0.eagle.common.combat_unit.CombatUnit as CombatUnitProto
import net.eagle0.eagle.model.state.CombatUnit
import net.eagle0.eagle.model.view.army.ArmyView
import net.eagle0.eagle.views.army_view.ArmyView as ArmyViewProto
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class ArmyViewConverterTest extends AnyFlatSpec with Matchers {
"toProto" should "convert all fields correctly" in {
val scala = ArmyView(
factionId = 5,
units = Vector(
CombatUnit(factionId = 5, heroId = 10, battalionId = Some(20)),
CombatUnit(factionId = 5, heroId = 11, battalionId = None)
),
unitCount = 2,
troopCount = 150
)
val proto = ArmyViewConverter.toProto(scala)
// Match on full case class to catch new fields
proto match {
case ArmyViewProto(
factionId,
units,
unitCount,
troopCount,
_ /* unknownFields */
) =>
factionId shouldBe 5
units.size shouldBe 2
unitCount shouldBe 2
troopCount shouldBe 150
// Verify unit conversion
units.head match {
case CombatUnitProto(cuFactionId, cuHeroId, cuBattalionId, _) =>
cuFactionId shouldBe 5
cuHeroId shouldBe 10
cuBattalionId shouldBe Some(20)
}
}
}
"fromProto" should "convert all fields correctly" in {
val proto = ArmyViewProto(
factionId = 7,
units = Seq(
CombatUnitProto(factionId = 7, heroId = 15, battalionId = Some(25))
),
unitCount = 1,
troopCount = 75
)
val scala = ArmyViewConverter.fromProto(proto)
// Match on full case class to catch new fields
scala match {
case ArmyView(
factionId,
units,
unitCount,
troopCount
) =>
factionId shouldBe 7
units.size shouldBe 1
unitCount shouldBe 1
troopCount shouldBe 75
// Verify unit conversion
units.head match {
case CombatUnit(cuFactionId, cuHeroId, cuBattalionId) =>
cuFactionId shouldBe 7
cuHeroId shouldBe 15
cuBattalionId shouldBe Some(25)
}
}
}
"round trip" should "preserve all values" in {
val original = ArmyView(
factionId = 3,
units = Vector(
CombatUnit(factionId = 3, heroId = 100, battalionId = Some(200)),
CombatUnit(factionId = 3, heroId = 101, battalionId = None)
),
unitCount = 2,
troopCount = 500
)
val roundTripped = ArmyViewConverter.fromProto(
ArmyViewConverter.toProto(original)
)
roundTripped shouldBe original
}
}
@@ -0,0 +1,15 @@
load("@rules_scala//scala:scala.bzl", "scala_test")
scala_test(
name = "army_view_converter_test",
srcs = ["ArmyViewConverterTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:army_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/army",
"//src/main/scala/net/eagle0/eagle/model/state:combat_unit",
"//src/main/scala/net/eagle0/eagle/model/view/army:army_view",
"@rules_scala//scala/scalatest",
],
)
@@ -0,0 +1,17 @@
load("@rules_scala//scala:scala.bzl", "scala_test")
scala_test(
name = "incoming_army_view_converter_test",
srcs = ["IncomingArmyViewConverterTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:incoming_army_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/incoming_army",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
"//src/main/scala/net/eagle0/eagle/model/view/incoming_army:incoming_army_view",
"@rules_scala//scala/scalatest",
],
)
@@ -0,0 +1,172 @@
package net.eagle0.eagle.model.proto_converters.view.incoming_army
import net.eagle0.eagle.{BattalionId, FactionId, HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.common.battalion_type.BattalionTypeId as BattalionTypeIdProto
import net.eagle0.eagle.model.state.BattalionTypeId
import net.eagle0.eagle.model.view.battalion.BattalionView
import net.eagle0.eagle.model.view.incoming_army.{IncomingArmyView, UnitDetails}
import net.eagle0.eagle.views.battalion_view.BattalionView as BattalionViewProto
import net.eagle0.eagle.views.incoming_army_view.IncomingArmyView as IncomingArmyViewProto
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class IncomingArmyViewConverterTest extends AnyFlatSpec with Matchers {
"toProto" should "convert all fields correctly" in {
val scala = IncomingArmyView(
factionId = 1: FactionId,
heroCount = 3,
troopCount = 500,
originProvinceId = 10: ProvinceId,
destinationProvinceId = 20: ProvinceId,
arrivalRoundId = 15: RoundId,
food = 100,
gold = 200,
unitDetails = Vector(
UnitDetails(
heroId = 50: HeroId,
battalion = Some(
BattalionView(
id = 100: BattalionId,
typeId = BattalionTypeId.LightInfantry,
size = 50,
training = Some(0.8),
armament = Some(0.9),
name = "Test Battalion"
)
)
),
UnitDetails(heroId = 51: HeroId, battalion = None)
)
)
val proto = IncomingArmyViewConverter.toProto(scala)
// Match on full case class to catch new fields
proto match {
case IncomingArmyViewProto(
factionId,
heroCount,
troopCount,
originProvinceId,
destinationProvinceId,
arrivalRoundId,
food,
gold,
unitDetails,
_ /* unknownFields */
) =>
factionId shouldBe 1
heroCount shouldBe 3
troopCount shouldBe 500
originProvinceId shouldBe 10
destinationProvinceId shouldBe 20
arrivalRoundId shouldBe 15
food shouldBe 100
gold shouldBe 200
unitDetails.size shouldBe 2
// Verify unit details conversion
unitDetails.head match {
case IncomingArmyViewProto.UnitDetails(heroId, battalion, _) =>
heroId shouldBe 50
battalion shouldBe defined
}
}
}
"fromProto" should "convert all fields correctly" in {
val proto = IncomingArmyViewProto(
factionId = 2,
heroCount = 5,
troopCount = 750,
originProvinceId = 30,
destinationProvinceId = 40,
arrivalRoundId = 25,
food = 300,
gold = 400,
unitDetails = Seq(
IncomingArmyViewProto.UnitDetails(
heroId = 60,
battalion = Some(
BattalionViewProto(
id = 200,
`type` = BattalionTypeIdProto.HEAVY_CAVALRY,
size = 30,
training = Some(0.7),
armament = Some(0.6),
name = "Proto Battalion"
)
)
)
)
)
val scala = IncomingArmyViewConverter.fromProto(proto)
// Match on full case class to catch new fields
scala match {
case IncomingArmyView(
factionId,
heroCount,
troopCount,
originProvinceId,
destinationProvinceId,
arrivalRoundId,
food,
gold,
unitDetails
) =>
factionId shouldBe (2: FactionId)
heroCount shouldBe 5
troopCount shouldBe 750
originProvinceId shouldBe (30: ProvinceId)
destinationProvinceId shouldBe (40: ProvinceId)
arrivalRoundId shouldBe (25: RoundId)
food shouldBe 300
gold shouldBe 400
unitDetails.size shouldBe 1
// Verify unit details conversion
unitDetails.head match {
case UnitDetails(heroId, battalion) =>
heroId shouldBe (60: HeroId)
battalion shouldBe defined
}
}
}
"round trip" should "preserve all values" in {
val original = IncomingArmyView(
factionId = 3: FactionId,
heroCount = 2,
troopCount = 100,
originProvinceId = 5: ProvinceId,
destinationProvinceId = 6: ProvinceId,
arrivalRoundId = 7: RoundId,
food = 50,
gold = 75,
unitDetails = Vector(
UnitDetails(
heroId = 70: HeroId,
battalion = Some(
BattalionView(
id = 300: BattalionId,
typeId = BattalionTypeId.Longbowmen,
size = 25,
training = Some(0.5),
armament = Some(0.4),
name = "Round Trip Battalion"
)
)
)
)
)
val roundTripped = IncomingArmyViewConverter.fromProto(
IncomingArmyViewConverter.toProto(original)
)
roundTripped shouldBe original
}
}
@@ -0,0 +1,64 @@
load("@rules_scala//scala:scala.bzl", "scala_test")
scala_test(
name = "unaffiliated_hero_basics_converter_test",
srcs = ["UnaffiliatedHeroBasicsConverterTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:profession_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:recruitment_info_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:unaffiliated_hero_basics",
"//src/main/scala/net/eagle0/eagle/model/state/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
"//src/main/scala/net/eagle0/eagle/model/view/province:unaffiliated_hero_basics",
"@rules_scala//scala/scalatest",
],
)
scala_test(
name = "full_province_info_converter_test",
srcs = ["FullProvinceInfoConverterTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state:combat_unit",
"//src/main/scala/net/eagle0/eagle/model/state/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
"//src/main/scala/net/eagle0/eagle/model/view/army:army_view",
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/view/province:unaffiliated_hero_basics",
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
"@rules_scala//scala/scalatest",
],
)
scala_test(
name = "province_view_converter_test",
srcs = ["ProvinceViewConverterTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:province_event_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
"//src/main/scala/net/eagle0/eagle/model/state:combat_unit",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
"//src/main/scala/net/eagle0/eagle/model/view/army:army_view",
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
"//src/main/scala/net/eagle0/eagle/model/view/incoming_army:incoming_army_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:unaffiliated_hero_basics",
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
"@rules_scala//scala/scalatest",
],
)
@@ -0,0 +1,251 @@
package net.eagle0.eagle.model.proto_converters.view.province
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.common.province_order_type.ProvinceOrderType
import net.eagle0.eagle.model.state.hero.Profession
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType}
import net.eagle0.eagle.model.state.BattalionTypeId
import net.eagle0.eagle.model.state.CombatUnit
import net.eagle0.eagle.model.view.army.ArmyView
import net.eagle0.eagle.model.view.battalion.BattalionView
import net.eagle0.eagle.model.view.province.{FullProvinceInfo, UnaffiliatedHeroBasics}
import net.eagle0.eagle.model.view.stat_with_condition.{Condition, StatWithCondition}
import net.eagle0.eagle.views.province_view.FullProvinceInfo as FullProvinceInfoProto
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class FullProvinceInfoConverterTest extends AnyFlatSpec with Matchers {
private val sampleBattalion = BattalionView(
id = 100,
typeId = BattalionTypeId.LightInfantry,
size = 50,
training = Some(0.8),
armament = Some(0.9),
name = "Test Battalion"
)
private val sampleArmy = ArmyView(
factionId = 1: FactionId,
units = Vector(CombatUnit(factionId = 1: FactionId, heroId = 10: HeroId, battalionId = Some(100))),
unitCount = 1,
troopCount = 50
)
private val sampleUnaffiliatedHero = UnaffiliatedHeroBasics(
heroId = 42: HeroId,
nameTextId = "hero_42",
profession = Profession.Paladin,
status = UnaffiliatedHeroType.Resident,
isFactionLeader = false,
recruitmentInfo = RecruitmentInfo.WouldJoin
)
private val sampleSupport = StatWithCondition(stat = 75, condition = Condition.High)
"toProto" should "convert all fields correctly" in {
val scala = FullProvinceInfo(
rulingHeroId = Some(5: HeroId),
rulingFactionHeroIds = Vector(5: HeroId, 10: HeroId, 15: HeroId),
battalions = Vector(sampleBattalion),
defendingArmy = Some(sampleArmy),
economy = 100.0f,
agriculture = 80.0f,
infrastructure = 60.0f,
economyDevastation = 0.1f,
agricultureDevastation = 0.2f,
infrastructureDevastation = 0.05f,
gold = 500,
food = 300,
priceIndex = 1.5,
goldCap = 1000,
foodCap = 800,
support = Some(sampleSupport),
foodConsumption = 25,
provinceOrders = ProvinceOrderType.DEVELOP,
hexMapName = "test_map",
castleCount = 2,
heroCap = 5,
unaffiliatedHeroes = Vector(sampleUnaffiliatedHero),
rulerIsTraveling = true
)
val proto = FullProvinceInfoConverter.toProto(scala)
// Verify proto fields using named access
proto.rulingHeroId shouldBe Some(5)
proto.rulingFactionHeroIds shouldBe Seq(5, 10, 15)
proto.battalions.size shouldBe 1
proto.defendingArmy shouldBe defined
proto.economy shouldBe 100.0f
proto.agriculture shouldBe 80.0f
proto.infrastructure shouldBe 60.0f
proto.economyDevastation shouldBe 0.1f
proto.agricultureDevastation shouldBe 0.2f
proto.infrastructureDevastation shouldBe 0.05f
proto.gold shouldBe 500
proto.food shouldBe 300
proto.priceIndex shouldBe 1.5
proto.goldCap shouldBe 1000
proto.foodCap shouldBe 800
proto.support shouldBe defined
proto.foodConsumption shouldBe 25
proto.provinceOrders shouldBe ProvinceOrderType.DEVELOP
proto.hexMapName shouldBe "test_map"
proto.castleCount shouldBe 2
proto.heroCap shouldBe 5
proto.unaffiliatedHeroes.size shouldBe 1
proto.rulerIsTraveling shouldBe true
}
"fromProto" should "convert all fields correctly" in {
val original = FullProvinceInfo(
rulingHeroId = Some(7: HeroId),
rulingFactionHeroIds = Vector(7: HeroId, 8: HeroId),
battalions = Vector(sampleBattalion),
defendingArmy = Some(sampleArmy),
economy = 90.0f,
agriculture = 70.0f,
infrastructure = 50.0f,
economyDevastation = 0.0f,
agricultureDevastation = 0.0f,
infrastructureDevastation = 0.0f,
gold = 400,
food = 250,
priceIndex = 1.2,
goldCap = 900,
foodCap = 700,
support = Some(sampleSupport),
foodConsumption = 20,
provinceOrders = ProvinceOrderType.EXPAND,
hexMapName = "another_map",
castleCount = 1,
heroCap = 3,
unaffiliatedHeroes = Vector(sampleUnaffiliatedHero),
rulerIsTraveling = false
)
val proto = FullProvinceInfoConverter.toProto(original)
val result = FullProvinceInfoConverter.fromProto(proto)
// Match on full Scala case class to catch new fields
result match {
case FullProvinceInfo(
rulingHeroId,
rulingFactionHeroIds,
battalions,
defendingArmy,
economy,
agriculture,
infrastructure,
economyDevastation,
agricultureDevastation,
infrastructureDevastation,
gold,
food,
priceIndex,
goldCap,
foodCap,
support,
foodConsumption,
provinceOrders,
hexMapName,
castleCount,
heroCap,
unaffiliatedHeroes,
rulerIsTraveling
) =>
rulingHeroId shouldBe Some(7: HeroId)
rulingFactionHeroIds shouldBe Vector(7: HeroId, 8: HeroId)
battalions.size shouldBe 1
defendingArmy shouldBe defined
economy shouldBe 90.0f
agriculture shouldBe 70.0f
infrastructure shouldBe 50.0f
economyDevastation shouldBe 0.0f
agricultureDevastation shouldBe 0.0f
infrastructureDevastation shouldBe 0.0f
gold shouldBe 400
food shouldBe 250
priceIndex shouldBe 1.2
goldCap shouldBe 900
foodCap shouldBe 700
support shouldBe defined
foodConsumption shouldBe 20
provinceOrders shouldBe ProvinceOrderType.EXPAND
hexMapName shouldBe "another_map"
castleCount shouldBe 1
heroCap shouldBe 3
unaffiliatedHeroes.size shouldBe 1
rulerIsTraveling shouldBe false
}
}
"round trip" should "preserve all values" in {
val original = FullProvinceInfo(
rulingHeroId = None,
rulingFactionHeroIds = Vector.empty,
battalions = Vector.empty,
defendingArmy = None,
economy = 50.0f,
agriculture = 50.0f,
infrastructure = 50.0f,
economyDevastation = 0.0f,
agricultureDevastation = 0.0f,
infrastructureDevastation = 0.0f,
gold = 0,
food = 0,
priceIndex = 1.0,
goldCap = 500,
foodCap = 500,
support = None,
foodConsumption = 0,
provinceOrders = ProvinceOrderType.UNKNOWN_ORDERS,
hexMapName = "empty_map",
castleCount = 0,
heroCap = 0,
unaffiliatedHeroes = Vector.empty,
rulerIsTraveling = false
)
val roundTripped = FullProvinceInfoConverter.fromProto(
FullProvinceInfoConverter.toProto(original)
)
roundTripped shouldBe original
}
"round trip with all optional fields populated" should "preserve all values" in {
val original = FullProvinceInfo(
rulingHeroId = Some(5: HeroId),
rulingFactionHeroIds = Vector(5: HeroId, 10: HeroId, 15: HeroId),
battalions = Vector(sampleBattalion),
defendingArmy = Some(sampleArmy),
economy = 100.0f,
agriculture = 80.0f,
infrastructure = 60.0f,
economyDevastation = 0.1f,
agricultureDevastation = 0.2f,
infrastructureDevastation = 0.05f,
gold = 500,
food = 300,
priceIndex = 1.5,
goldCap = 1000,
foodCap = 800,
support = Some(sampleSupport),
foodConsumption = 25,
provinceOrders = ProvinceOrderType.DEVELOP,
hexMapName = "test_map",
castleCount = 2,
heroCap = 5,
unaffiliatedHeroes = Vector(sampleUnaffiliatedHero),
rulerIsTraveling = true
)
val roundTripped = FullProvinceInfoConverter.fromProto(
FullProvinceInfoConverter.toProto(original)
)
roundTripped shouldBe original
}
}
@@ -0,0 +1,211 @@
package net.eagle0.eagle.model.proto_converters.view.province
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.common.province_order_type.ProvinceOrderType
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.hero.Profession
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType}
import net.eagle0.eagle.model.state.BattalionTypeId
import net.eagle0.eagle.model.state.CombatUnit
import net.eagle0.eagle.model.view.army.ArmyView
import net.eagle0.eagle.model.view.battalion.BattalionView
import net.eagle0.eagle.model.view.incoming_army.{IncomingArmyView, UnitDetails}
import net.eagle0.eagle.model.view.province.{FullProvinceInfo, ProvinceView, UnaffiliatedHeroBasics}
import net.eagle0.eagle.model.view.stat_with_condition.{Condition, StatWithCondition}
import net.eagle0.eagle.views.province_view.ProvinceView as ProvinceViewProto
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class ProvinceViewConverterTest extends AnyFlatSpec with Matchers {
private val sampleBattalion = BattalionView(
id = 100,
typeId = BattalionTypeId.LightInfantry,
size = 50,
training = Some(0.8),
armament = Some(0.9),
name = "Test Battalion"
)
private val sampleArmy = ArmyView(
factionId = 1: FactionId,
units = Vector(CombatUnit(factionId = 1: FactionId, heroId = 10: HeroId, battalionId = Some(100))),
unitCount = 1,
troopCount = 50
)
private val sampleUnaffiliatedHero = UnaffiliatedHeroBasics(
heroId = 42: HeroId,
nameTextId = "hero_42",
profession = Profession.Paladin,
status = UnaffiliatedHeroType.Resident,
isFactionLeader = false,
recruitmentInfo = RecruitmentInfo.WouldJoin
)
private val sampleSupport = StatWithCondition(stat = 75, condition = Condition.High)
private val sampleFullInfo = FullProvinceInfo(
rulingHeroId = Some(5: HeroId),
rulingFactionHeroIds = Vector(5: HeroId, 10: HeroId),
battalions = Vector(sampleBattalion),
defendingArmy = Some(sampleArmy),
economy = 100.0f,
agriculture = 80.0f,
infrastructure = 60.0f,
economyDevastation = 0.1f,
agricultureDevastation = 0.2f,
infrastructureDevastation = 0.05f,
gold = 500,
food = 300,
priceIndex = 1.5,
goldCap = 1000,
foodCap = 800,
support = Some(sampleSupport),
foodConsumption = 25,
provinceOrders = ProvinceOrderType.DEVELOP,
hexMapName = "test_map",
castleCount = 2,
heroCap = 5,
unaffiliatedHeroes = Vector(sampleUnaffiliatedHero),
rulerIsTraveling = false
)
private val sampleIncomingArmy = IncomingArmyView(
factionId = 2: FactionId,
heroCount = 1,
troopCount = 100,
originProvinceId = 5: ProvinceId,
destinationProvinceId = 10: ProvinceId,
arrivalRoundId = 15,
food = 50,
gold = 100,
unitDetails = Vector(UnitDetails(heroId = 20: HeroId, battalion = None))
)
private val sampleDate = Date(year = 1000, month = Date.Month.March)
"toProto" should "convert all fields correctly" in {
val scala = ProvinceView(
id = 42: ProvinceId,
name = "Test Province",
rulingFactionId = Some(1: FactionId),
fullInfo = Some(sampleFullInfo),
knownEvents = Vector.empty,
knownIncomingArmies = Vector(sampleIncomingArmy),
asOf = Some(sampleDate)
)
val proto = ProvinceViewConverter.toProto(scala)
// Match on full case class to catch new fields
proto match {
case ProvinceViewProto(
id,
name,
rulingFactionId,
fullInfo,
knownEvents,
knownIncomingArmies,
asOf,
_ /* unknownFields */
) =>
id shouldBe 42
name shouldBe "Test Province"
rulingFactionId shouldBe Some(1)
fullInfo shouldBe defined
knownEvents.size shouldBe 0
knownIncomingArmies.size shouldBe 1
asOf shouldBe defined
}
}
"fromProto" should "convert all fields correctly" in {
val original = ProvinceView(
id = 55: ProvinceId,
name = "Another Province",
rulingFactionId = Some(3: FactionId),
fullInfo = Some(sampleFullInfo),
knownEvents = Vector.empty,
knownIncomingArmies = Vector(sampleIncomingArmy),
asOf = Some(sampleDate)
)
val proto = ProvinceViewConverter.toProto(original)
val result = ProvinceViewConverter.fromProto(proto)
// Match on full case class to catch new fields
result match {
case ProvinceView(
id,
name,
rulingFactionId,
fullInfo,
knownEvents,
knownIncomingArmies,
asOf
) =>
id shouldBe (55: ProvinceId)
name shouldBe "Another Province"
rulingFactionId shouldBe Some(3: FactionId)
fullInfo shouldBe defined
knownEvents.size shouldBe 0
knownIncomingArmies.size shouldBe 1
asOf shouldBe defined
}
}
"round trip" should "preserve all values with empty collections" in {
val original = ProvinceView(
id = 1: ProvinceId,
name = "Empty Province",
rulingFactionId = None,
fullInfo = None,
knownEvents = Vector.empty,
knownIncomingArmies = Vector.empty,
asOf = None
)
val roundTripped = ProvinceViewConverter.fromProto(
ProvinceViewConverter.toProto(original)
)
roundTripped shouldBe original
}
"round trip" should "preserve all values with populated collections" in {
val original = ProvinceView(
id = 42: ProvinceId,
name = "Test Province",
rulingFactionId = Some(1: FactionId),
fullInfo = Some(sampleFullInfo),
knownEvents = Vector.empty,
knownIncomingArmies = Vector(sampleIncomingArmy),
asOf = Some(sampleDate)
)
val roundTripped = ProvinceViewConverter.fromProto(
ProvinceViewConverter.toProto(original)
)
roundTripped shouldBe original
}
"round trip" should "preserve province without fullInfo but with other data" in {
val original = ProvinceView(
id = 77: ProvinceId,
name = "Partial Province",
rulingFactionId = Some(2: FactionId),
fullInfo = None,
knownEvents = Vector.empty,
knownIncomingArmies = Vector(sampleIncomingArmy),
asOf = Some(sampleDate)
)
val roundTripped = ProvinceViewConverter.fromProto(
ProvinceViewConverter.toProto(original)
)
roundTripped shouldBe original
}
}
@@ -0,0 +1,126 @@
package net.eagle0.eagle.model.proto_converters.view.province
import net.eagle0.eagle.common.profession.Profession as ProfessionProto
import net.eagle0.eagle.common.recruitment_info.{
RecruitmentInfo as RecruitmentInfoProto,
RecruitmentStatus as RecruitmentStatusProto
}
import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType as UnaffiliatedHeroTypeProto
import net.eagle0.eagle.model.state.hero.Profession
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType}
import net.eagle0.eagle.model.view.province.UnaffiliatedHeroBasics
import net.eagle0.eagle.views.province_view.UnaffiliatedHeroBasics as UnaffiliatedHeroBasicsProto
import net.eagle0.eagle.HeroId
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class UnaffiliatedHeroBasicsConverterTest extends AnyFlatSpec with Matchers {
"toProto" should "convert all fields correctly" in {
val scala = UnaffiliatedHeroBasics(
heroId = 42: HeroId,
nameTextId = "hero_name_42",
profession = Profession.Paladin,
status = UnaffiliatedHeroType.Resident,
isFactionLeader = true,
recruitmentInfo = RecruitmentInfo.WouldJoin
)
val proto = UnaffiliatedHeroBasicsConverter.toProto(scala)
// Match on full case class to catch new fields
proto match {
case UnaffiliatedHeroBasicsProto(
heroId,
nameTextId,
profession,
status,
isFactionLeader,
recruitmentInfo,
_ /* unknownFields */
) =>
heroId shouldBe 42
nameTextId shouldBe "hero_name_42"
profession shouldBe ProfessionProto.PALADIN
status shouldBe UnaffiliatedHeroTypeProto.UNAFFILIATED_HERO_RESIDENT
isFactionLeader shouldBe true
recruitmentInfo shouldBe defined
}
}
"fromProto" should "convert all fields correctly" in {
val proto = UnaffiliatedHeroBasicsProto(
heroId = 55,
nameTextId = "hero_name_55",
profession = ProfessionProto.MAGE,
status = UnaffiliatedHeroTypeProto.UNAFFILIATED_HERO_TRAVELER,
isFactionLeader = false,
recruitmentInfo = Some(
RecruitmentInfoProto(status = RecruitmentStatusProto.RECRUITMENT_STATUS_TRAVELER)
)
)
val scala = UnaffiliatedHeroBasicsConverter.fromProto(proto)
// Match on full case class to catch new fields
scala match {
case UnaffiliatedHeroBasics(
heroId,
nameTextId,
profession,
status,
isFactionLeader,
recruitmentInfo
) =>
heroId shouldBe (55: HeroId)
nameTextId shouldBe "hero_name_55"
profession shouldBe Profession.Mage
status shouldBe UnaffiliatedHeroType.Traveler
isFactionLeader shouldBe false
recruitmentInfo shouldBe RecruitmentInfo.Traveler
}
}
"round trip" should "preserve all values" in {
val original = UnaffiliatedHeroBasics(
heroId = 77: HeroId,
nameTextId = "hero_name_77",
profession = Profession.Champion,
status = UnaffiliatedHeroType.Outlaw,
isFactionLeader = false,
recruitmentInfo = RecruitmentInfo.Outlaw
)
val roundTripped = UnaffiliatedHeroBasicsConverter.fromProto(
UnaffiliatedHeroBasicsConverter.toProto(original)
)
roundTripped shouldBe original
}
"status conversion" should "handle all status types" in {
val statuses = List(
UnaffiliatedHeroType.Unknown -> UnaffiliatedHeroTypeProto.UNAFFILIATED_HERO_UNKNOWN,
UnaffiliatedHeroType.Prisoner -> UnaffiliatedHeroTypeProto.UNAFFILIATED_HERO_PRISONER,
UnaffiliatedHeroType.Traveler -> UnaffiliatedHeroTypeProto.UNAFFILIATED_HERO_TRAVELER,
UnaffiliatedHeroType.Resident -> UnaffiliatedHeroTypeProto.UNAFFILIATED_HERO_RESIDENT,
UnaffiliatedHeroType.Outlaw -> UnaffiliatedHeroTypeProto.UNAFFILIATED_HERO_OUTLAW,
UnaffiliatedHeroType.MovingPrisoner -> UnaffiliatedHeroTypeProto.UNAFFILIATED_HERO_MOVING_PRISONER,
UnaffiliatedHeroType.ReturningPrisoner -> UnaffiliatedHeroTypeProto.UNAFFILIATED_HERO_RETURNING_PRISONER
)
statuses.foreach {
case (scalaStatus, protoStatus) =>
val scala = UnaffiliatedHeroBasics(
heroId = 1: HeroId,
nameTextId = "test",
profession = Profession.NoProfession,
status = scalaStatus,
isFactionLeader = false,
recruitmentInfo = RecruitmentInfo.Unknown
)
val proto = UnaffiliatedHeroBasicsConverter.toProto(scala)
proto.status shouldBe protoStatus
}
}
}
@@ -0,0 +1,12 @@
load("@rules_scala//scala:scala.bzl", "scala_test")
scala_test(
name = "stat_with_condition_converter_test",
srcs = ["StatWithConditionConverterTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:stat_with_condition_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/stat_with_condition",
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
"@rules_scala//scala/scalatest",
],
)
@@ -0,0 +1,83 @@
package net.eagle0.eagle.model.proto_converters.view.stat_with_condition
import net.eagle0.eagle.model.view.stat_with_condition.{Condition, StatWithCondition}
import net.eagle0.eagle.views.stat_with_condition.StatWithCondition.Condition as ConditionProto
import net.eagle0.eagle.views.stat_with_condition.StatWithCondition as StatWithConditionProto
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class StatWithConditionConverterTest extends AnyFlatSpec with Matchers {
"toProto" should "convert all fields correctly" in {
val scala = StatWithCondition(
stat = 75,
condition = Condition.High
)
val proto = StatWithConditionConverter.toProto(scala)
// Match on full case class to catch new fields
proto match {
case StatWithConditionProto(
stat,
condition,
_ /* unknownFields */
) =>
stat shouldBe 75
condition shouldBe ConditionProto.HIGH
}
}
"fromProto" should "convert all fields correctly" in {
val proto = StatWithConditionProto(
stat = 42,
condition = ConditionProto.VERY_LOW
)
val scala = StatWithConditionConverter.fromProto(proto)
// Match on full case class to catch new fields
scala match {
case StatWithCondition(
stat,
condition
) =>
stat shouldBe 42
condition shouldBe Condition.VeryLow
}
}
"round trip" should "preserve all values" in {
val original = StatWithCondition(
stat = 100,
condition = Condition.Medium
)
val roundTripped = StatWithConditionConverter.fromProto(
StatWithConditionConverter.toProto(original)
)
roundTripped shouldBe original
}
"condition conversion" should "handle all condition values" in {
val conditions = List(
Condition.Unknown -> ConditionProto.UNKNOWN,
Condition.VeryHigh -> ConditionProto.VERY_HIGH,
Condition.High -> ConditionProto.HIGH,
Condition.Medium -> ConditionProto.MEDIUM,
Condition.Low -> ConditionProto.LOW,
Condition.VeryLow -> ConditionProto.VERY_LOW
)
conditions.foreach {
case (scalaCondition, protoCondition) =>
val scala = StatWithCondition(stat = 50, condition = scalaCondition)
val proto = StatWithConditionConverter.toProto(scala)
proto.condition shouldBe protoCondition
val backToScala = StatWithConditionConverter.fromProto(proto)
backToScala.condition shouldBe scalaCondition
}
}
}