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