Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Make settings table rows more compact

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

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

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

* Make settings input fields even more compact

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

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 17:37:48 -08:00
29c7ad7c58 Complete Phase 1: reverse order history and clickable action details (#4890)
- Add GetActionDetail gRPC endpoint to fetch individual action data
- Display action history in reverse chronological order (most recent first)
- Make action rows clickable to expand and show JSON representation
- Add action_detail.html template for htmx-powered action expansion
- Update CSS for clickable rows and action detail styling
- Mark Phase 1 as complete in enhancement plan

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 13:19:07 -08:00
72 changed files with 6364 additions and 2960 deletions
+2 -2
View File
@@ -52,14 +52,14 @@ jobs:
- name: Persist Library/
run: ./ci/github_actions/persist_library.sh
- name: Deploy Windows unity
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
- name: Update unified manifest
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
+3 -5
View File
@@ -54,16 +54,14 @@ When migrating a file:
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Dual (both proto and protoless versions)
### Fully Protoless
- [~] `ProvinceGoldSurplusCalculator` - protoless `provinceGoldSurplus(province: ProvinceT)` + legacy `provinceGoldSurplus(provinceId, gameState)`
- [~] `HeroSelector` - protoless `minimallyFatiguedHeroes` + legacy `minimallyFatiguedHeroesProto`
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
### Blocked (still uses proto GameState)
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
- Only 1 call to `minimallyFatiguedHeroesProto` (HeroSelector)
- Many calls to proto `provinceGoldSurplus`
- Depends on many Legacy* utils
- [ ] `AttackDecisionCommandChooser` - uses proto types
- [ ] `CommandChooser` - uses proto GameState
+6
View File
@@ -53,6 +53,12 @@ oci_image(
"java",
"-Xmx4g",
"-XX:+UseG1GC",
# JFR: Continuous low-overhead profiling (~1% overhead).
# Keeps 1 hour of data in a 100MB circular buffer.
# Dump recording: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
# Then copy out: docker cp eagle-server:/app/jfr/profile.jfr .
"-XX:StartFlightRecording=disk=true,maxage=1h,maxsize=100m,dumponexit=true,filename=/app/jfr/recording.jfr",
"-XX:FlightRecorderOptions=stackdepth=256",
"-jar",
"/app/eagle_server_deploy.jar",
],
+1
View File
@@ -26,6 +26,7 @@ services:
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
volumes:
- ./saves:/app/saves
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
depends_on:
- shardok
restart: unless-stopped
+428
View File
@@ -0,0 +1,428 @@
# Admin Server Enhancement Plan
## Overview
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
### Current State
The admin server exposes REST endpoints that return JSON:
- `GET /health` - health check
- `GET /games` - list running games
- `GET /games/{id}/history` - paginated action history
### Goals
1. **Web UI**: Replace raw JSON with an interactive HTML interface
2. **Settings Management**: View and modify the 275+ game settings at runtime
3. **Game Rewind**: Restore a game to a previous action count
---
## Architecture
### Technology Choice: Go Templates + htmx
**Rationale:**
- Single binary deployment (no separate frontend build)
- htmx provides interactivity without JavaScript framework complexity
- Familiar HTML/CSS, minimal learning curve
- Excellent for admin tools where SEO and bundle size don't matter
**Alternatives Considered:**
- React/Vue SPA: Adds build complexity, separate deployment artifact
- Server-side only: Less interactive, full page reloads
### Directory Structure
```
src/main/go/net/eagle0/admin_server/
├── admin_server.go # Main entry point, HTTP routes
├── handlers/
│ ├── games.go # Game list and detail handlers
│ ├── settings.go # Settings list and update handlers
│ └── rewind.go # Game rewind handlers
├── templates/
│ ├── layout.html # Base layout with nav, htmx includes
│ ├── games/
│ │ ├── list.html # Game list page
│ │ ├── detail.html # Single game view with history
│ │ └── history.html # Partial for history table (htmx)
│ ├── settings/
│ │ ├── list.html # Settings list with search/filter
│ │ └── edit.html # Inline edit partial (htmx)
│ └── rewind/
│ └── confirm.html # Rewind confirmation modal
├── static/
│ ├── style.css # Minimal CSS (Pico CSS or similar)
│ └── htmx.min.js # htmx library
└── BUILD.bazel
```
---
## Feature 1: Web UI
### Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/` | GET | Redirect to `/games` |
| `/games` | GET | Game list page (HTML) |
| `/games/{id}` | GET | Game detail page with history |
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
| `/api/games/{id}/history` | GET | JSON API (existing) |
### Game List Page
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Settings] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Running Games (3) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game abc123f Round 45 │ │
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
│ │ Actions: 1,234 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game def456a Round 12 │ │
│ │ Players: Test Player (Human) │ │
│ │ Actions: 456 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Game Detail Page
Shows game info and scrollable action history:
- **Reverse chronological order**: Most recent actions displayed first
- Each action shows: index, type, round ID
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
- "Rewind to here" button on each action row
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
### Implementation Notes
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
---
## Feature 2: Settings Management
### New gRPC Endpoints (Eagle Server)
Add to `eagle.proto`:
```protobuf
message Setting {
string name = 1;
string type = 2; // "Int" or "Double"
string value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
string description = 5; // Optional, for UI hints
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message UpdateSettingRequest {
string name = 1;
string value = 2;
}
message UpdateSettingResponse {
Setting setting = 1; // Updated setting
string error = 2; // Empty on success
}
service Eagle {
// ... existing methods ...
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
}
```
### Eagle Server Implementation
Create a settings registry that:
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
2. Provides get/set by name
3. Validates types on update
```scala
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
object SettingsRegistry {
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
"ActionVigorCost" -> Left(ActionVigorCost),
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
// ... register all 275 settings
)
def getAll(filter: Option[String]): Seq[Setting] = ...
def get(name: String): Option[Setting] = ...
def update(name: String, value: String): Either[String, Setting] = ...
}
```
**Alternative: Code generation**
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
### Admin Server Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/settings` | GET | Settings list page with search |
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
| `/settings/{name}` | PUT | Update setting value |
| `/api/settings` | GET | JSON API |
| `/api/settings/{name}` | PUT | JSON API |
### Settings UI
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Games] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Settings [Search: __________ ] │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ActionVigorCost (Int) │ │
│ │ Current: [15 ] Default: 15 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ BaseFoodBuyPrice (Double) │ │
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ... (275 settings, virtualized/paginated) ... │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Considerations
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
4. **Audit log**: Log setting changes with timestamp for debugging
---
## Feature 3: Game Rewind
### Concept
Restore a game to a previous point in its action history. This is useful for:
- Debugging issues that occurred at a specific point
- Testing "what if" scenarios
- Recovering from bugs that corrupted state
### New gRPC Endpoint
Add to `eagle.proto`:
```protobuf
message RewindGameRequest {
int64 game_id = 1;
int32 target_action_count = 2; // Rewind to state after this many actions
}
message RewindGameResponse {
bool success = 1;
string error = 2;
int32 new_action_count = 3;
int32 disconnected_clients = 4; // Number of clients that were disconnected
}
service Eagle {
// ... existing methods ...
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
}
```
### Eagle Server Implementation
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
2. **Get target state**: Retrieve `GameState` at target action count from history
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
5. **Reset AI state**: Clear any cached AI state that depends on current game state
```scala
// GameController.scala (pseudocode)
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
if (targetActionCount < 0 || targetActionCount > engine.history.count)
return Left(s"Invalid action count: $targetActionCount")
// Get state at target point
val targetState = engine.history.stateAt(targetActionCount)
val truncatedHistory = engine.history.truncateTo(targetActionCount)
// Disconnect all human clients
val disconnectedCount = humanClients.length
humanClients.foreach(_.disconnect("Game rewound by admin"))
// Create new engine at target state
val newEngine = EngineImpl(
gameId = engine.gameId,
currentState = targetState,
history = truncatedHistory,
// ... other fields
)
// Replace controller's engine
this.engine = newEngine
Right(RewindResult(targetActionCount, disconnectedCount))
}
```
### GameHistory Enhancement
Add method to get state at a specific action count:
```scala
trait GameHistory {
// ... existing methods ...
def stateAt(actionCount: Int): GameState = {
if (actionCount == 0) initialState
else all(actionCount - 1).resultingState
}
def truncateTo(actionCount: Int): GameHistory = {
GameHistoryImpl(
initialState = initialState,
actions = all.take(actionCount)
)
}
}
```
### Admin Server Route
| Route | Method | Description |
|-------|--------|-------------|
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
### Rewind UI Flow
1. User views game history
2. User clicks "Rewind to here" on an action row
3. Confirmation modal appears via htmx:
```
┌─────────────────────────────────────────┐
│ Rewind Game abc123f? │
│ │
│ This will: │
│ • Restore to action 456 (Round 23) │
│ • Discard 778 subsequent actions │
│ • Disconnect 2 connected players │
│ │
│ This cannot be undone. │
│ │
│ [Cancel] [Rewind] │
└─────────────────────────────────────────┘
```
4. On confirm, POST to `/games/{id}/rewind`
5. Success: redirect to game detail showing new state
6. Error: show error message
### Safety Considerations
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
2. **Client disconnect**: All connected clients are forcibly disconnected.
3. **AI state**: Ensure AI clients restart cleanly after rewind.
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
5. **Authorization**: In production, require admin authentication.
---
## Implementation Phases
### Phase 1: Web UI Foundation
**Status: Complete**
1. ✅ Set up Go templates with `embed`
2. ✅ Add Pico CSS and htmx
3. ✅ Create base layout with navigation
4. ✅ Convert `/games` to HTML with styling
5. ✅ Add game detail page with history table
6. ✅ Implement htmx infinite scroll for history
7. ✅ Reverse history order (most recent first)
8. ✅ Clickable action rows that expand to show JSON representation
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
**Deliverable**: Browsable game list and history in HTML with clickable action details
### Phase 2: Settings Management
**Status: Complete**
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
3. ✅ Implement `getSettings` in `EagleServiceImpl`
4. ✅ Create settings list page with live search
5. ✅ Add inline editing with htmx
6. ✅ Modified settings are highlighted
**Deliverable**: View and edit settings via admin UI
### Phase 3: Game Rewind (2-3 days)
1. Add `RewindGame` to `eagle.proto`
2. Implement `stateAt` and `truncateTo` in `GameHistory`
3. Implement rewind logic in `GameController`
4. Add rewind confirmation modal
5. Handle client disconnection gracefully
6. Add rewind button to history rows
**Deliverable**: Rewind games to any previous action
### Phase 4: Polish (1-2 days)
1. Add loading states and error handling
2. Improve action history display (type icons, summaries)
3. Add settings categories and filtering
4. Add basic auth (optional)
5. Documentation
---
## Security Notes
The admin server is intended for local/trusted network use only. For production:
1. **Do not expose to public internet** without authentication
2. Consider adding HTTP Basic Auth or OAuth
3. Run on internal network or behind VPN
4. Log all admin actions for audit trail
---
## Open Questions
1. **Settings persistence**: Should we add optional persistence to disk/database?
2. **Game snapshots**: Should rewind create a backup first?
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
@@ -397,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;
@@ -413,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
@@ -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})";
}
}
}
}
}
@@ -348,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,
@@ -388,14 +392,22 @@ namespace eagle {
_currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
shardokGameModel;
} else {
// Game ended - remove from active models so UI knows battle is over
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
// Game ended - keep model for UI callback, mark for removal after
_currentModel.ShardokGameModels[oneResponse.ShardokGameId] =
shardokGameModel;
endedGames.Add(oneResponse.ShardokGameId);
}
}
// Invoke UI callback BEFORE removing ended games, so the UI can
// see the final battle results (with GameStatus = Victory/Defeat)
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
// Now remove ended games from active models
foreach (var endedGameId in endedGames) {
_currentModel.ShardokGameModels.TryRemove(endedGameId, out _);
_shardokResultCounts.TryRemove(endedGameId, out _);
}
break;
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
@@ -36,10 +36,10 @@ namespace eagle.Notifications.ARNNotifications {
$"{DisplayNames.ResolvedFactionString(lastFaction, currentModel)}'s hero {{HeroName}}";
}
textTemplate = $"{executingFactionName} has executed {victimDescription}!";
textTemplate = $"{executingFactionName} has executed {victimDescription}!\n\n";
heroPlaceholders["HeroName"] = (hero.NameTextId, "the hero");
} else {
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!";
textTemplate = $"{executingFactionName} has executed {{ExecutedHero}}!\n\n";
heroPlaceholders["ExecutedHero"] = (hero.NameTextId, "the prisoner");
}
@@ -89,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() {
@@ -143,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;
}
@@ -162,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,
@@ -409,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; }
}
@@ -446,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;
@@ -475,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);
}
}
@@ -612,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
@@ -695,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.
@@ -705,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) {
@@ -716,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) {
@@ -725,6 +835,9 @@ namespace eagle {
} else {
throw;
}
} catch (OperationCanceledException) {
// Timeout was triggered
return false;
}
}
@@ -1024,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;
@@ -1058,9 +1184,15 @@ namespace eagle {
Interval = 5000 // Check every 5 seconds
};
_idleCheckTimer.Elapsed += (sender, args) => {
// Log BEFORE callback to confirm timer is firing
Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
CheckForIdleTimeout();
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;
}
@@ -1106,7 +1238,7 @@ namespace eagle {
if (toCancel != null) {
toCancel.Dispose();
lock (this) { _streamingCall = null; }
Task.Run(() => Connect());
RunConnectAsync();
}
}
}
@@ -1116,9 +1248,24 @@ namespace eagle {
_heartbeatTimer =
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
_heartbeatTimer.Elapsed += (sender, args) => {
// Log BEFORE Task.Run to confirm timer is firing
Console.WriteLine($"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
Task.Run(() => SendHeartbeat());
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;
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,22 @@
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 object _lock = new object();
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();
@@ -18,11 +28,12 @@ namespace common {
}
}
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);
@@ -30,19 +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) {
lock (_lock) {
_sw.WriteLine(CurrentTimeString() + " " + line);
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
}
/// <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() { _sw.Close(); }
public void Close() { Dispose(); }
public void Dispose() {
if (_sw != null) { _sw.Dispose(); }
if (_disposed) return;
_disposed = true;
// Signal writer thread to exit and wait for it
_messageAvailable.Set();
if (_writerThread != null && _writerThread.IsAlive) {
_writerThread.Join(5000); // Wait up to 5 seconds for clean shutdown
}
try {
_sw?.Dispose();
} catch { /* ignore */
}
try {
_messageAvailable?.Dispose();
} catch { /* ignore */
}
}
}
}
}
@@ -6,6 +6,7 @@ using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Net.Http;
using eagle;
using Google.Protobuf;
using Net.Eagle0.Eagle.Api;
@@ -55,9 +56,17 @@ namespace common {
relativeLocalPath);
Directory.CreateDirectory(_localPath);
// Create a client that doesn't follow redirects automatically,
// so we can retry each hop independently
var handler = new HttpClientHandler { AllowAutoRedirect = false };
// Use HTTP/2 handler for better connection reuse and multiplexing.
// YetAnotherHttpHandler doesn't follow redirects automatically,
// which is what we want so we can retry each hop independently.
var handler = new YetAnotherHttpHandler {
Http2Only = true,
// Keep connections alive to improve reliability on poor networks
Http2KeepAliveInterval = TimeSpan.FromSeconds(15),
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
Http2KeepAliveWhileIdle = true,
ConnectTimeout = TimeSpan.FromSeconds(RequestTimeoutSeconds)
};
_noRedirectClient = new HttpClient(
handler) { Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds) };
// Store auth header to add per-request only for eagle0.net
@@ -5,7 +5,7 @@
"depth": 0,
"source": "git",
"dependencies": {},
"hash": ""
"hash": "5719afbc6b1d8cfa8729f24f0fe6a0e9dfa70f3a"
},
"com.unity.2d.sprite": {
"version": "1.0.0",
@@ -3,6 +3,10 @@ load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "admin_server_lib",
srcs = ["admin_server.go"],
embedsrcs = glob([
"static/*",
"templates/*.html",
]),
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/admin_server",
visibility = ["//visibility:private"],
deps = [
@@ -1,12 +1,15 @@
// Package main provides an HTTP admin server for Eagle game management.
// It connects to the Eagle gRPC service and exposes REST endpoints.
// It connects to the Eagle gRPC service and exposes a web UI.
package main
import (
"context"
"embed"
"encoding/json"
"flag"
"fmt"
"html/template"
"io/fs"
"log"
"net/http"
"strconv"
@@ -18,15 +21,59 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
//go:embed templates/*.html
var templatesFS embed.FS
//go:embed static/*
var staticFS embed.FS
var (
eagleAddr = flag.String("eagle-addr", "localhost:40032", "Eagle gRPC server address")
httpPort = flag.Int("http-port", 8080, "HTTP server port")
grpcClient eagle.EagleClient
eagleAddr = flag.String("eagle-addr", "localhost:40032", "Eagle gRPC server address")
httpPort = flag.Int("http-port", 8080, "HTTP server port")
grpcClient eagle.EagleClient
gamesTemplate *template.Template
gameDetailTemplate *template.Template
historyRowsTemplate *template.Template
actionDetailTemplate *template.Template
settingsTemplate *template.Template
settingsRowsTemplate *template.Template
)
func main() {
flag.Parse()
// Parse templates - each page gets its own template set since they all define "content"
var err error
gamesTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/games.html")
if err != nil {
log.Fatalf("Failed to parse games template: %v", err)
}
gameDetailTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/game_detail.html")
if err != nil {
log.Fatalf("Failed to parse game detail template: %v", err)
}
historyRowsTemplate, err = template.ParseFS(templatesFS, "templates/history_rows.html")
if err != nil {
log.Fatalf("Failed to parse history rows template: %v", err)
}
actionDetailTemplate, err = template.ParseFS(templatesFS, "templates/action_detail.html")
if err != nil {
log.Fatalf("Failed to parse action detail template: %v", err)
}
settingsTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/settings.html")
if err != nil {
log.Fatalf("Failed to parse settings template: %v", err)
}
settingsRowsTemplate, err = template.ParseFS(templatesFS, "templates/settings_rows.html")
if err != nil {
log.Fatalf("Failed to parse settings rows template: %v", err)
}
// Connect to Eagle gRPC server
conn, err := grpc.NewClient(*eagleAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
@@ -38,9 +85,21 @@ func main() {
grpcClient = eagle.NewEagleClient(conn)
// Serve static files
staticContent, err := fs.Sub(staticFS, "static")
if err != nil {
log.Fatalf("Failed to get static files: %v", err)
}
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticContent))))
// Set up HTTP routes
http.HandleFunc("/games", handleGames)
http.HandleFunc("/games/", handleGameHistory)
http.HandleFunc("/", handleIndex)
http.HandleFunc("/games", handleGamesPage)
http.HandleFunc("/games/", handleGameRoutes)
http.HandleFunc("/settings", handleSettingsPage)
http.HandleFunc("/settings/", handleSettingsRoutes)
http.HandleFunc("/api/games", handleGamesAPI)
http.HandleFunc("/api/games/", handleGameHistoryAPI)
http.HandleFunc("/health", handleHealth)
addr := fmt.Sprintf(":%d", *httpPort)
@@ -50,6 +109,14 @@ func main() {
}
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/games", http.StatusFound)
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
@@ -67,6 +134,7 @@ type PlayerInfo struct {
// GameInfo represents a running game with hex-formatted ID
type GameInfo struct {
GameID string `json:"game_id"`
GameIDInt int64 `json:"-"`
CurrentRound int32 `json:"current_round"`
ActionCount int32 `json:"action_count"`
Players []PlayerInfo `json:"players"`
@@ -83,23 +151,13 @@ func formatGameID(id int64) string {
return fmt.Sprintf("%x", uint64(id))
}
func handleGames(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// fetchGames gets the list of running games from Eagle
func fetchGames(ctx context.Context) ([]GameInfo, error) {
resp, err := grpcClient.GetRunningGames(ctx, &eagle.GetRunningGamesRequest{})
if err != nil {
log.Printf("Failed to get running games: %v", err)
http.Error(w, fmt.Sprintf("Failed to get running games: %v", err), http.StatusInternalServerError)
return
return nil, err
}
// Convert to JSON response with hex game IDs
games := make([]GameInfo, len(resp.Games))
for i, game := range resp.Games {
players := make([]PlayerInfo, len(game.Players))
@@ -114,28 +172,286 @@ func handleGames(w http.ResponseWriter, r *http.Request) {
}
games[i] = GameInfo{
GameID: formatGameID(game.GameId),
GameIDInt: game.GameId,
CurrentRound: game.CurrentRound,
ActionCount: game.ActionCount,
Players: players,
RunStatus: game.RunStatus,
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(GamesResponse{Games: games})
return games, nil
}
func handleGameHistory(w http.ResponseWriter, r *http.Request) {
// Page data structures
type GamesPageData struct {
Title string
Games []GameInfo
}
type GameDetailPageData struct {
Title string
Game GameInfo
}
type HistoryEntry struct {
Index int32
ActionType string
RoundId int32
}
type HistoryRowsData struct {
GameID string
Entries []HistoryEntry
HasMore bool
NextStart int32
}
type ActionDetailData struct {
GameID string
Index int32
ActionType string
RoundId int32
ActionJSON string
}
func handleGamesPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse game ID from URL: /games/{id}/history
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
games, err := fetchGames(ctx)
if err != nil {
log.Printf("Failed to get running games: %v", err)
http.Error(w, fmt.Sprintf("Failed to get running games: %v", err), http.StatusInternalServerError)
return
}
data := GamesPageData{
Title: "Games",
Games: games,
}
w.Header().Set("Content-Type", "text/html")
if err := gamesTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleGameRoutes(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/games/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Redirect(w, r, "/games", http.StatusFound)
return
}
gameIDHex := parts[0]
gameIDUint, err := strconv.ParseUint(gameIDHex, 16, 64)
if err != nil {
http.Error(w, "Invalid game ID (expected hex format)", http.StatusBadRequest)
return
}
gameID := int64(gameIDUint)
if len(parts) == 1 {
// /games/{id} - game detail page
handleGameDetailPage(w, r, gameIDHex, gameID)
} else if parts[1] == "history" {
// /games/{id}/history - history rows (htmx partial)
handleHistoryRows(w, r, gameIDHex, gameID)
} else if parts[1] == "action" && len(parts) >= 3 {
// /games/{id}/action/{index} - action detail (htmx partial)
actionIndex, err := strconv.Atoi(parts[2])
if err != nil {
http.Error(w, "Invalid action index", http.StatusBadRequest)
return
}
handleActionDetail(w, r, gameIDHex, gameID, int32(actionIndex))
} else {
http.NotFound(w, r)
}
}
func handleGameDetailPage(w http.ResponseWriter, r *http.Request, gameIDHex string, gameID int64) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Fetch games to get game info
games, err := fetchGames(ctx)
if err != nil {
log.Printf("Failed to get games: %v", err)
http.Error(w, fmt.Sprintf("Failed to get games: %v", err), http.StatusInternalServerError)
return
}
var game *GameInfo
for i := range games {
if games[i].GameIDInt == gameID {
game = &games[i]
break
}
}
if game == nil {
http.Error(w, "Game not found", http.StatusNotFound)
return
}
data := GameDetailPageData{
Title: fmt.Sprintf("Game %s", gameIDHex),
Game: *game,
}
w.Header().Set("Content-Type", "text/html")
if err := gameDetailTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleHistoryRows(w http.ResponseWriter, r *http.Request, gameIDHex string, gameID int64) {
// Parse query parameters - "start" is now offset from the end (for reverse order)
offset := 0
limit := 50
if s := r.URL.Query().Get("start"); s != "" {
if v, err := strconv.Atoi(s); err == nil {
offset = v
}
}
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil {
limit = v
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// First, get total count by fetching with limit 0
countResp, err := grpcClient.GetGameHistory(ctx, &eagle.GetGameHistoryRequest{
GameId: gameID,
Limit: 0,
})
if err != nil {
log.Printf("Failed to get game history: %v", err)
http.Error(w, fmt.Sprintf("Failed to get game history: %v", err), http.StatusInternalServerError)
return
}
totalCount := int(countResp.TotalActionCount)
// Calculate start index for reverse order (fetch from end minus offset)
startIndex := totalCount - offset - limit
if startIndex < 0 {
limit = limit + startIndex // Reduce limit if we'd go before index 0
startIndex = 0
}
resp, err := grpcClient.GetGameHistory(ctx, &eagle.GetGameHistoryRequest{
GameId: gameID,
StartIndex: int32(startIndex),
Limit: int32(limit),
})
if err != nil {
log.Printf("Failed to get game history: %v", err)
http.Error(w, fmt.Sprintf("Failed to get game history: %v", err), http.StatusInternalServerError)
return
}
// Reverse the entries so most recent is first
entries := make([]HistoryEntry, len(resp.Entries))
for i, e := range resp.Entries {
entries[len(resp.Entries)-1-i] = HistoryEntry{
Index: e.Index,
ActionType: e.ActionType,
RoundId: e.RoundId,
}
}
nextOffset := offset + len(entries)
hasMore := nextOffset < totalCount
data := HistoryRowsData{
GameID: gameIDHex,
Entries: entries,
HasMore: hasMore,
NextStart: int32(nextOffset),
}
w.Header().Set("Content-Type", "text/html")
if err := historyRowsTemplate.Execute(w, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleActionDetail(w http.ResponseWriter, r *http.Request, gameIDHex string, gameID int64, actionIndex int32) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := grpcClient.GetActionDetail(ctx, &eagle.GetActionDetailRequest{
GameId: gameID,
ActionIndex: actionIndex,
})
if err != nil {
log.Printf("Failed to get action detail: %v", err)
http.Error(w, fmt.Sprintf("Failed to get action detail: %v", err), http.StatusInternalServerError)
return
}
data := ActionDetailData{
GameID: gameIDHex,
Index: resp.ActionIndex,
ActionType: resp.ActionType,
RoundId: resp.RoundId,
ActionJSON: resp.ActionJson,
}
w.Header().Set("Content-Type", "text/html")
if err := actionDetailTemplate.Execute(w, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
// API handlers (keep JSON endpoints for programmatic access)
func handleGamesAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
games, err := fetchGames(ctx)
if err != nil {
log.Printf("Failed to get running games: %v", err)
http.Error(w, fmt.Sprintf("Failed to get running games: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(GamesResponse{Games: games})
}
func handleGameHistoryAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse game ID from URL: /api/games/{id}/history
path := strings.TrimPrefix(r.URL.Path, "/api/games/")
parts := strings.Split(path, "/")
if len(parts) < 2 || parts[1] != "history" {
http.Error(w, "Invalid path. Use /games/{id}/history", http.StatusBadRequest)
http.Error(w, "Invalid path. Use /api/games/{id}/history", http.StatusBadRequest)
return
}
@@ -177,3 +493,209 @@ func handleGameHistory(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// Settings data structures
type SettingInfo struct {
Name string `json:"name"`
SettingType string `json:"setting_type"`
CurrentValue string `json:"current_value"`
DefaultValue string `json:"default_value"`
IsModified bool `json:"is_modified"`
}
type SettingsPageData struct {
Title string
Settings []SettingInfo
Filter string
}
type SettingsRowsData struct {
Settings []SettingInfo
Filter string
}
type SettingUpdateResult struct {
Success bool `json:"success"`
Name string `json:"name"`
Value string `json:"value"`
Error string `json:"error,omitempty"`
}
func fetchSettings(ctx context.Context, filter string) ([]SettingInfo, error) {
resp, err := grpcClient.GetSettings(ctx, &eagle.GetSettingsRequest{
Filter: filter,
})
if err != nil {
return nil, err
}
settings := make([]SettingInfo, len(resp.Settings))
for i, s := range resp.Settings {
settings[i] = SettingInfo{
Name: s.Name,
SettingType: s.SettingType,
CurrentValue: s.CurrentValue,
DefaultValue: s.DefaultValue,
IsModified: s.CurrentValue != s.DefaultValue,
}
}
return settings, nil
}
func handleSettingsPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
filter := r.URL.Query().Get("filter")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
settings, err := fetchSettings(ctx, filter)
if err != nil {
log.Printf("Failed to get settings: %v", err)
http.Error(w, fmt.Sprintf("Failed to get settings: %v", err), http.StatusInternalServerError)
return
}
data := SettingsPageData{
Title: "Settings",
Settings: settings,
Filter: filter,
}
w.Header().Set("Content-Type", "text/html")
if err := settingsTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleSettingsRoutes(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/settings/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Redirect(w, r, "/settings", http.StatusFound)
return
}
if parts[0] == "search" {
// /settings/search - return filtered settings rows (htmx partial)
handleSettingsSearch(w, r)
} else if parts[0] == "update" {
// /settings/update - update a setting (htmx POST)
handleSettingUpdate(w, r)
} else {
http.NotFound(w, r)
}
}
func handleSettingsSearch(w http.ResponseWriter, r *http.Request) {
filter := r.URL.Query().Get("filter")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
settings, err := fetchSettings(ctx, filter)
if err != nil {
log.Printf("Failed to get settings: %v", err)
http.Error(w, fmt.Sprintf("Failed to get settings: %v", err), http.StatusInternalServerError)
return
}
data := SettingsRowsData{
Settings: settings,
Filter: filter,
}
w.Header().Set("Content-Type", "text/html")
if err := settingsRowsTemplate.Execute(w, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleSettingUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
name := r.FormValue("name")
value := r.FormValue("value")
if name == "" {
http.Error(w, "Setting name is required", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Use AddSettings to update the setting
_, err := grpcClient.AddSettings(ctx, &eagle.AddSettingsRequest{
Settings: []*eagle.AddSettingsKeyValue{
{Key: name, Value: value},
},
})
if err != nil {
log.Printf("Failed to update setting %s: %v", name, err)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `<span class="error">Error: %v</span>`, err)
return
}
// Return the updated row
settings, err := fetchSettings(ctx, "")
if err != nil {
log.Printf("Failed to refetch settings: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<span class="success">Updated %s to %s</span>`, name, value)
return
}
// Find the updated setting
var updated *SettingInfo
for _, s := range settings {
if s.Name == name {
updated = &s
break
}
}
if updated == nil {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<span class="success">Updated %s to %s</span>`, name, value)
return
}
// Return the single updated row HTML
w.Header().Set("Content-Type", "text/html")
modifiedClass := ""
if updated.IsModified {
modifiedClass = " modified"
}
fmt.Fprintf(w, `<tr class="setting-row%s" id="setting-%s">
<td class="setting-name">%s</td>
<td class="setting-type">%s</td>
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-%s" hx-swap="outerHTML">
<input type="hidden" name="name" value="%s">
<input type="text" name="value" value="%s" class="setting-input">
<button type="submit" class="btn-small">Save</button>
</form>
</td>
<td class="setting-default">%s</td>
</tr>`, modifiedClass, updated.Name, updated.Name, updated.SettingType, updated.Name, updated.Name, updated.CurrentValue, updated.DefaultValue)
}
@@ -0,0 +1,326 @@
/* Admin Server Styles - supplements Pico CSS */
:root {
--pico-font-size: 16px;
}
/* Navigation */
nav {
background: var(--pico-card-background-color);
padding: 1rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
nav ul {
display: flex;
gap: 1.5rem;
list-style: none;
margin: 0;
padding: 0;
align-items: center;
}
nav .brand {
font-weight: bold;
font-size: 1.25rem;
margin-right: auto;
}
/* Game cards */
.game-card {
background: var(--pico-card-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
padding: 1.5rem;
margin-bottom: 1rem;
}
.game-card h3 {
margin-top: 0;
margin-bottom: 0.5rem;
}
.game-card .meta {
color: var(--pico-muted-color);
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
.game-card .players {
margin-bottom: 1rem;
}
.game-card .actions {
display: flex;
gap: 0.5rem;
}
/* Player badges */
.player-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
margin-right: 0.5rem;
margin-bottom: 0.25rem;
}
.player-badge.human {
background: var(--pico-primary-background);
color: var(--pico-primary-inverse);
}
.player-badge.ai {
background: var(--pico-secondary-background);
color: var(--pico-secondary-inverse);
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--pico-border-radius);
font-size: 0.75rem;
text-transform: uppercase;
}
.status-badge.running {
background: #22c55e;
color: white;
}
.status-badge.finished {
background: var(--pico-muted-color);
color: white;
}
/* History table */
.history-table {
width: 100%;
}
.history-table th,
.history-table td {
text-align: left;
padding: 0.75rem;
}
.history-table tr:hover {
background: var(--pico-card-background-color);
}
.action-type {
font-family: monospace;
font-size: 0.875rem;
}
/* Loading indicator */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline;
}
.htmx-request.htmx-indicator {
display: inline;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: var(--pico-muted-color);
}
/* Page header */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
}
/* Breadcrumbs */
.breadcrumb {
display: flex;
gap: 0.5rem;
font-size: 0.875rem;
margin-bottom: 1rem;
color: var(--pico-muted-color);
}
.breadcrumb a {
color: var(--pico-muted-color);
}
.breadcrumb .separator {
color: var(--pico-muted-color);
}
/* Clickable action rows */
.action-row {
cursor: pointer;
transition: background-color 0.15s ease;
}
.action-row:hover {
background: var(--pico-primary-background) !important;
color: var(--pico-primary-inverse);
}
/* Action detail panel */
.action-detail-row {
background: var(--pico-card-background-color);
}
.action-detail {
padding: 1rem;
border: 1px solid var(--pico-muted-border-color);
border-radius: var(--pico-border-radius);
margin: 0.5rem 0;
}
.action-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.action-detail .close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0 0.5rem;
color: var(--pico-muted-color);
line-height: 1;
}
.action-detail .close-btn:hover {
color: var(--pico-del-color);
}
.action-json {
background: var(--pico-code-background-color);
padding: 1rem;
border-radius: var(--pico-border-radius);
overflow-x: auto;
font-size: 0.875rem;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Settings page */
.settings-search {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.settings-search input {
flex: 1;
max-width: 400px;
margin: 0;
}
.settings-count {
color: var(--pico-muted-color);
font-size: 0.875rem;
}
.settings-table {
width: 100%;
border-collapse: collapse;
}
.settings-table th,
.settings-table td {
text-align: left;
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--pico-muted-border-color);
vertical-align: middle;
}
.settings-table th {
background: var(--pico-card-background-color);
font-weight: 600;
font-size: 0.75rem;
}
.setting-name {
font-family: monospace;
font-size: 0.8rem;
}
.setting-type {
font-size: 0.7rem;
color: var(--pico-muted-color);
text-transform: uppercase;
}
.setting-value form {
display: flex;
gap: 0.25rem;
margin: 0;
align-items: center;
}
.setting-input {
width: 100px;
padding: 2px 6px !important;
margin: 0 !important;
font-size: 0.75rem;
height: 22px !important;
line-height: 1 !important;
border-width: 1px;
}
.setting-default {
font-size: 0.8rem;
color: var(--pico-muted-color);
}
.setting-row.modified {
background: rgba(255, 193, 7, 0.1);
}
.setting-row.modified .setting-name {
font-weight: bold;
}
.btn-small {
padding: 2px 8px !important;
font-size: 0.7rem;
margin: 0 !important;
height: 22px !important;
line-height: 1 !important;
}
.settings-note {
margin-top: 2rem;
padding: 1rem;
background: var(--pico-card-background-color);
border-radius: var(--pico-border-radius);
font-size: 0.875rem;
color: var(--pico-muted-color);
}
.error {
color: var(--pico-del-color);
}
.success {
color: var(--pico-ins-color);
}
@@ -0,0 +1,11 @@
<tr class="action-detail-row">
<td colspan="3">
<div class="action-detail">
<div class="action-detail-header">
<strong>Action #{{.Index}}</strong> - {{.ActionType}} (Round {{.RoundId}})
<button class="close-btn" onclick="this.closest('tr').remove()">&times;</button>
</div>
<pre class="action-json">{{.ActionJSON}}</pre>
</div>
</td>
</tr>
@@ -0,0 +1,47 @@
{{define "content"}}
<nav class="breadcrumb">
<a href="/">Games</a>
<span class="separator">/</span>
<span>{{.Game.GameID}}</span>
</nav>
<div class="page-header">
<h1>Game {{.Game.GameID}}</h1>
<span class="status-badge {{if eq .Game.RunStatus "Running"}}running{{else}}finished{{end}}">
{{.Game.RunStatus}}
</span>
</div>
<article>
<header>
<strong>Round {{.Game.CurrentRound}}</strong> &bull; {{.Game.ActionCount}} total actions
</header>
<div class="players">
{{range .Game.Players}}
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
{{.LeaderName}} - {{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
</span>
{{end}}
</div>
</article>
<h2>Action History</h2>
<table class="history-table">
<thead>
<tr>
<th style="width: 80px">#</th>
<th>Action Type</th>
<th style="width: 120px">Round</th>
</tr>
</thead>
<tbody id="history-body"
hx-get="/games/{{.Game.GameID}}/history?start=0&limit=50"
hx-trigger="load"
hx-swap="innerHTML">
<tr>
<td colspan="3" class="htmx-indicator">Loading history...</td>
</tr>
</tbody>
</table>
{{end}}
@@ -0,0 +1,37 @@
{{define "content"}}
<div class="page-header">
<h1>Running Games</h1>
<span>{{len .Games}} game{{if ne (len .Games) 1}}s{{end}}</span>
</div>
{{if .Games}}
{{range .Games}}
<article class="game-card">
<h3>
<a href="/games/{{.GameID}}">Game {{.GameID}}</a>
<span class="status-badge {{if eq .RunStatus "Running"}}running{{else}}finished{{end}}">
{{.RunStatus}}
</span>
</h3>
<div class="meta">
Round {{.CurrentRound}} &bull; {{.ActionCount}} actions
</div>
<div class="players">
{{range .Players}}
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
{{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
</span>
{{end}}
</div>
<div class="actions">
<a href="/games/{{.GameID}}" role="button" class="outline">View History</a>
</div>
</article>
{{end}}
{{else}}
<div class="empty-state">
<p>No games currently running.</p>
<p>Start a game from the Unity client to see it here.</p>
</div>
{{end}}
{{end}}
@@ -0,0 +1,23 @@
{{range .Entries}}
<tr class="action-row"
hx-get="/games/{{$.GameID}}/action/{{.Index}}"
hx-trigger="click"
hx-target="this"
hx-swap="afterend">
<td>{{.Index}}</td>
<td class="action-type">{{.ActionType}}</td>
<td>{{.RoundId}}</td>
</tr>
{{end}}
{{if .HasMore}}
<tr hx-get="/games/{{.GameID}}/history?start={{.NextStart}}&limit=50"
hx-trigger="revealed"
hx-swap="outerHTML">
<td colspan="3" class="htmx-indicator">Loading more...</td>
</tr>
{{end}}
{{if and (not .Entries) (not .HasMore)}}
<tr>
<td colspan="3" class="empty-state">No actions recorded yet.</td>
</tr>
{{end}}
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - Eagle Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<link rel="stylesheet" href="/static/style.css">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<nav>
<ul>
<li class="brand">Eagle Admin</li>
<li><a href="/">Games</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/health">Health</a></li>
</ul>
</nav>
<main class="container">
{{template "content" .}}
</main>
</body>
</html>
@@ -0,0 +1,52 @@
{{define "content"}}
<h1>Settings</h1>
<div class="settings-search">
<input type="text"
id="settings-filter"
name="filter"
placeholder="Search settings..."
value="{{.Filter}}"
hx-get="/settings/search"
hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
hx-target="#settings-body"
hx-swap="innerHTML">
<span class="settings-count">{{len .Settings}} settings</span>
</div>
<table class="settings-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Current Value</th>
<th>Default</th>
</tr>
</thead>
<tbody id="settings-body">
{{range .Settings}}
<tr class="setting-row{{if .IsModified}} modified{{end}}" id="setting-{{.Name}}">
<td class="setting-name">{{.Name}}</td>
<td class="setting-type">{{.SettingType}}</td>
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
<input type="hidden" name="name" value="{{.Name}}">
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
<button type="submit" class="btn-small">Save</button>
</form>
</td>
<td class="setting-default">{{.DefaultValue}}</td>
</tr>
{{end}}
{{if not .Settings}}
<tr>
<td colspan="4" class="empty-state">No settings found.</td>
</tr>
{{end}}
</tbody>
</table>
<p class="settings-note">
<strong>Note:</strong> Settings changes are in-memory only. A server restart will reset all values to defaults.
</p>
{{end}}
@@ -0,0 +1,19 @@
{{range .Settings}}
<tr class="setting-row{{if .IsModified}} modified{{end}}" id="setting-{{.Name}}">
<td class="setting-name">{{.Name}}</td>
<td class="setting-type">{{.SettingType}}</td>
<td class="setting-value">
<form hx-post="/settings/update" hx-target="#setting-{{.Name}}" hx-swap="outerHTML">
<input type="hidden" name="name" value="{{.Name}}">
<input type="text" name="value" value="{{.CurrentValue}}" class="setting-input">
<button type="submit" class="btn-small">Save</button>
</form>
</td>
<td class="setting-default">{{.DefaultValue}}</td>
</tr>
{{end}}
{{if not .Settings}}
<tr>
<td colspan="4" class="empty-state">No settings match "{{.Filter}}"</td>
</tr>
{{end}}
@@ -15,8 +15,9 @@ func usage() {
}
type Setting struct {
Name string
Type string
Name string
Type string
DefaultValue string
}
func parseSettings(buildFile string) ([]Setting, error) {
@@ -32,30 +33,38 @@ func parseSettings(buildFile string) ([]Setting, error) {
// Regex patterns to extract setting info
settingNameRegex := regexp.MustCompile(`setting_name = "([^"]+)"`)
settingTypeRegex := regexp.MustCompile(`setting_type = "([^"]+)"`)
var currentName, currentType string
settingValueRegex := regexp.MustCompile(`setting_value = "([^"]+)"`)
var currentName, currentType, currentValue string
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
// Look for setting_name
if matches := settingNameRegex.FindStringSubmatch(line); matches != nil {
currentName = matches[1]
}
// Look for setting_type
if matches := settingTypeRegex.FindStringSubmatch(line); matches != nil {
currentType = matches[1]
}
// Look for setting_value
if matches := settingValueRegex.FindStringSubmatch(line); matches != nil {
currentValue = matches[1]
}
// When we hit the end of a scala_setting_library block, save the setting
if strings.Contains(line, ")") && currentName != "" && currentType != "" {
settings = append(settings, Setting{
Name: currentName,
Type: currentType,
Name: currentName,
Type: currentType,
DefaultValue: currentValue,
})
currentName = ""
currentType = ""
currentValue = ""
}
}
@@ -92,6 +101,30 @@ func generateSettingsLoader(settings []Setting) string {
sb.WriteString(" case _ => throw NoSuchSettingException(key)\n")
sb.WriteString(" }\n\n")
// Generate the getAllSettings method that returns current values and metadata
sb.WriteString(" case class SettingInfo(\n")
sb.WriteString(" name: String,\n")
sb.WriteString(" settingType: String,\n")
sb.WriteString(" currentValue: String,\n")
sb.WriteString(" defaultValue: String\n")
sb.WriteString(" )\n\n")
sb.WriteString(" def getAllSettings: Vector[SettingInfo] = Vector(\n")
for i, setting := range settings {
if setting.Type == "Int" {
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Int\", %s.intValue.toString, \"%s\")",
setting.Name, setting.Name, setting.DefaultValue))
} else {
sb.WriteString(fmt.Sprintf(" SettingInfo(\"%s\", \"Double\", %s.doubleValue.toString, \"%s\")",
setting.Name, setting.Name, setting.DefaultValue))
}
if i < len(settings)-1 {
sb.WriteString(",")
}
sb.WriteString("\n")
}
sb.WriteString(" )\n\n")
// Add the rest of the class
sb.WriteString(` case class SettingLine(
key: String,
@@ -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 {
@@ -148,6 +148,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
],
)
@@ -199,6 +200,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
],
)
@@ -8,6 +8,7 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, LegacyProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
object MarchTowardProvinceCommandChooser {
def marchTowardProvinceCommand(
@@ -78,7 +79,7 @@ object MarchTowardProvinceCommandChooser {
takenBattalions,
gs.battalionTypes.toVector
)
val goldToTake = provinceGoldSurplus(originProvince.id, gs)
val goldToTake = provinceGoldSurplus(ProvinceConverter.fromProto(originProvince))
CommandSelection(
actingFactionId = fid,
actingProvinceId = actingProvinceId,
@@ -34,6 +34,7 @@ import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.LegacyProvinceDistances
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.views.province_view.FullProvinceInfo
@@ -152,7 +153,7 @@ object MidGameAIClient {
Math.min(actingProvince.food - foodToHoldBack, foodAvailable)
val maxGoldToSend = Math.min(
ProvinceGoldSurplusCalculator
.provinceGoldSurplus(actingProvinceId, gameState),
.provinceGoldSurplus(ProvinceConverter.fromProto(actingProvince)),
goldAvailable
)
@@ -851,6 +851,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:game_history",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators:chronicle_update_prompt_generator",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/settings:empty_province_monthly_devastation_delta",
"//src/main/scala/net/eagle0/eagle/library/settings:faction_bias_minimum_adjustment_per_round",
@@ -75,9 +75,14 @@ case class CheckForFactionChangesAction(
val newLeaders =
faction.leaderIds.diff(killedHeroIds)
// Only change faction head if the current head was killed
val newFactionHeadId =
if newLeaders.contains(faction.factionHeadId) then faction.factionHeadId
else newLeaders.headOption.getOrElse(0)
faction.copy(
leaderIds = newLeaders,
factionHeadId = newLeaders.headOption.getOrElse(0)
factionHeadId = newFactionHeadId
)
}
}
@@ -211,46 +216,65 @@ case class CheckForFactionChangesAction(
case Nil => None
case fs =>
val changedFactionData = fs.map { revisedFaction =>
val originalFaction = factions.find(_.id == revisedFaction.id).get
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
val newFactionName = newHeadHero.flatMap(_.ledFactionName)
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
val originalFaction = factions.find(_.id == revisedFaction.id).get
val factionHeadChanged = originalFaction.factionHeadId != revisedFaction.factionHeadId
// Only include new head info if the head actually changed
val (newFactionHeadHeroId, newFactionName, llmRequest, notification) =
if factionHeadChanged then {
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
val newName = newHeadHero.flatMap(_.ledFactionName)
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
(
Some(revisedFaction.factionHeadId),
newName,
Some(
NewFactionHeadMessage(
requestId = llmRequestId,
eagleGameId = gameId,
recipientFactionIds = Vector.empty,
alwaysGenerate = false,
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousFactionName = originalFaction.name,
newFactionName = newName,
previousHeadHeroId = originalFaction.factionHeadId
)
),
Some(
NotificationC(
details = NotificationDetails.NewFactionHead(
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousHeadHeroId = originalFaction.factionHeadId
),
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
llm = NotificationT.Llm.Id(llmRequestId),
deferred = true
)
)
)
} else {
(None, None, None, None)
}
(
ChangedFactionC(
factionId = revisedFaction.id,
removedLeaderHeroIds = originalFaction.leaderIds.filter(killedHeroIds.contains),
newFactionHeadHeroId = Some(revisedFaction.factionHeadId),
newFactionHeadHeroId = newFactionHeadHeroId,
newName = newFactionName
),
NewFactionHeadMessage(
requestId = llmRequestId,
eagleGameId = gameId,
recipientFactionIds = Vector.empty,
alwaysGenerate = false,
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousFactionName = originalFaction.name,
newFactionName = newFactionName,
previousHeadHeroId = originalFaction.factionHeadId
),
NotificationC(
details = NotificationDetails.NewFactionHead(
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousHeadHeroId = originalFaction.factionHeadId
),
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
llm = NotificationT.Llm.Id(llmRequestId),
deferred = true
)
llmRequest,
notification
)
}.toVector
Some(
ActionResultC(
actionResultType = FactionLeaderRemovedResultType,
changedFactions = changedFactionData.map(_._1),
newGeneratedTextRequests = changedFactionData.map(_._2),
newNotifications = changedFactionData.map(_._3)
newGeneratedTextRequests = changedFactionData.flatMap(_._2),
newNotifications = changedFactionData.flatMap(_._3)
)
)
}
@@ -4,6 +4,7 @@ import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{HeroId, ProvinceId, RoundId}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.llm_prompt_generators.ChronicleOptions
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.settings.{
EmptyProvinceMonthlyDevastationDelta,
@@ -85,12 +86,18 @@ case class NewRoundAction(
initialSequencer.withActionResult(_ => NewYearAction(gameState).immediateExecute)
else initialSequencer
// Select chronicler style using a hash that doesn't cycle predictably
val chroniclerStyle = ChronicleOptions.styles(
(gameState.gameId, newDate.year, newDate.month.value).hashCode.abs % ChronicleOptions.styles.size
)
// Add the main new round result
val afterNewRoundSequencer = afterNewYearSequencer.withActionResult { currentState =>
newRoundResult(
gs = currentState,
newRoundId = newRoundId,
newDate = newDate
newDate = newDate,
chroniclerStyle = chroniclerStyle
)
}
@@ -102,7 +109,8 @@ case class NewRoundAction(
private def chronicleLlmRequests(
newDate: Date,
gs: GameState
gs: GameState,
chroniclerStyle: String
): Vector[LlmRequestT] =
if isChronicleMonth(newDate) then
Vector(
@@ -113,7 +121,8 @@ case class NewRoundAction(
previous_entries = gs.chronicleEntries.map { entry =>
ChronicleUpdatePreviousEntry(
date = entry.date,
generatedTextId = entry.generatedTextId
generatedTextId = entry.generatedTextId,
chroniclerStyle = entry.chroniclerStyle
)
},
new_entries = ChronicleEventGenerator.eventTextEntries(
@@ -121,7 +130,8 @@ case class NewRoundAction(
since = gs.chronicleEntries.lastOption
.map(_.date)
.getOrElse(Date(year = 0, month = Date.Month.January))
)
),
chronicler_style = chroniclerStyle
)
)
else Vector()
@@ -132,7 +142,8 @@ case class NewRoundAction(
private def newRoundResult(
gs: GameState,
newRoundId: RoundId,
newDate: Date
newDate: Date,
chroniclerStyle: String
): ActionResultT = {
case class Changes(
changedProvince: ChangedProvinceC,
@@ -254,7 +265,7 @@ case class NewRoundAction(
)
}.toVector
val llmRequests = chronicleLlmRequests(newDate, gs)
val llmRequests = chronicleLlmRequests(newDate, gs, chroniclerStyle)
ActionResultC(
actionResultType = NewRoundActionResultType,
@@ -268,7 +279,8 @@ case class NewRoundAction(
newChronicleEntry = llmRequests.headOption.map { request =>
ChronicleEntry(
date = newDate,
generatedTextId = request.requestId
generatedTextId = request.requestId,
chroniclerStyle = Some(chroniclerStyle)
)
}
)
@@ -41,35 +41,51 @@ case class ChronicleUpdatePromptGenerator(
chronicleUpdateMessage: ChronicleUpdateMessage,
clientTextStore: ClientTextStore,
gameState: GameState,
randomInt: Int
randomInt: Int // Kept for backwards compatibility but no longer used
) extends LLMPromptGenerator {
private val chronicleWordCount: Int = ChronicleWordCount.intValue
private val randomStyle: String =
ChronicleOptions.styles(randomInt.abs % ChronicleOptions.styles.size)
// Use the style from the message (selected by NewRoundAction)
private val chroniclerStyle: String = chronicleUpdateMessage.chroniclerStyle
// Check if this chronicler has written any previous entries
private val previousChroniclerStyles: Set[String] =
chronicleUpdateMessage.previousEntries.map(_.chroniclerStyle).filter(_.nonEmpty).toSet
private val isSameChroniclerAsPrevious: Boolean =
previousChroniclerStyles.contains(chroniclerStyle)
private def introString(currentDate: Date): String = {
val intro =
s"""This is a fantasy setting with a medieval technology level. Multiple factions are engaged in a civil war for
|control of the kingdom. Write a $chronicleWordCount-word chronicle of the state of the war, as told by an
|in-universe chronicler, who writes in the style and format of $randomStyle (but without reproducing any
|in-universe chronicler, who writes in the style and format of $chroniclerStyle (but without reproducing any
|copyrighted material), as of ${GeneratorUtilities
.monthNames(
currentDate.month
)} of the Year of the Realm ${currentDate.year}.""".stripMargin
.replaceAll("\n", " ")
val styleGuidance =
if isSameChroniclerAsPrevious then
s"""You are the SAME chronicler who wrote some of the previous entries (marked as "Written by $chroniclerStyle").
|Continue in that same style and voice. Use the information from all previous entries.""".stripMargin
.replaceAll("\n", " ")
else
s"""Write in the style of $chroniclerStyle. Use the INFORMATION from the previous entries (names, events,
|factions, outcomes) but do not imitate the writing style of entries written by other chroniclers.""".stripMargin
.replaceAll("\n", " ")
val sampleDescription =
"""The first block of text below is the list of previous chronicle entries, with dates. These were probably
|written by different chroniclers, so the information in them is relevant, but the style may differ. The
|second block of text lists major events
|that happened since that last story. The third block of text lists the major surviving factions and what
|provinces they control. The update should be roughly chronological for the events of the last year, but more
|thematic, focusing on major trends, for the previous history. Not all the events need to be included, focus on
|the most important developments during the time period covered. The chronicler's description should not be
|separated into blocks, but told in medieval terms. Do not include any other context beyond the in-world text
|of the response itself. Start with a title of no more than five words, then "=====" on a separate line, then
|the main text of the chronicle, like this:""".stripMargin
s"""The first block of text below is the list of previous chronicle entries, with dates. Each entry is labeled
|with who wrote it (e.g., "Written by Homer:"). $styleGuidance The second block of text lists major events
|that happened since the last story. The third block of text lists the major surviving factions and what
|provinces they control. The update should be roughly chronological for the events of the last year, but more
|thematic, focusing on major trends, for the previous history. Not all the events need to be included, focus
|on the most important developments during the time period covered. The chronicler's description should not
|be separated into blocks, but told in medieval terms. Do not include any other context beyond the in-world
|text of the response itself. Start with a title of no more than five words, then "=====" on a separate line,
|then the main text of the chronicle, like this:""".stripMargin
.replaceAll("\n", " ")
s"""$intro
@@ -153,7 +169,10 @@ case class ChronicleUpdatePromptGenerator(
private def previousEntryText: Vector[TextGenerationResult] =
chronicleUpdateMessage.previousEntries.toVector.map { entry =>
clientTextStore.getText(entry.generatedTextId).map { text =>
s"${GeneratorUtilities.dateString(entry.date.get)}\n$text"
val chroniclerLabel =
if entry.chroniclerStyle.nonEmpty then s"Written by ${entry.chroniclerStyle}:"
else "Written by an unknown chronicler:"
s"${GeneratorUtilities.dateString(entry.date.get)}\n$chroniclerLabel\n$text"
}
}
@@ -165,7 +184,7 @@ case class ChronicleUpdatePromptGenerator(
factionText <- factionDescriptions(gameState)
eventsText <- eventsString(chronicleUpdateMessage.newEntries.toVector)
} yield {
SimpleTimedLogger.printLogger.logLine(s"random style is $randomStyle")
SimpleTimedLogger.printLogger.logLine(s"chronicler style is $chroniclerStyle")
s"""${introString(chronicleUpdateMessage.currentDate.get)}
|---
|FIRST SECTION
@@ -295,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",
@@ -315,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",
],
@@ -346,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",
],
)
@@ -430,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",
],
)
@@ -127,8 +127,7 @@ object CommandChoiceHelpers {
val goldExcess =
(province.gold - (fillUpBattalionsCost + newBattalionsCost + maximizeExistingBattalionArmamentCost + newBattalionArmamentCost + goldToHoldBack(
actingProvinceId,
gameState
provinceC
)))
.max(0)
.floor
@@ -19,10 +19,23 @@ import net.eagle0.eagle.library.util.command_choice_helpers.quest_command_select
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.QuestConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object FulfillQuestsCommandSelector {
private val choosers: Vector[QuestCommandChooser] = Vector(
AllianceQuestCommandChooser,
TruceWithFactionQuestCommandChooser,
ImproveQuestCommandChooser,
AlmsToProvinceQuestCommandChooser,
GiveToHeroesInProvinceQuestCommandChooser,
AlmsAcrossRealmQuestCommandChooser,
GiveToHeroesAcrossRealmQuestCommandChooser,
TruceCountQuestCommandChooser,
DismissSpecificVassalCommandChooser
)
private def choiceFrom(
choosers: Vector[QuestCommandChooser],
actingFactionId: FactionId,
@@ -43,6 +56,24 @@ object FulfillQuestsCommandSelector {
)
}
/** Protoless version - takes native GameState and uhsWithQuests directly */
def chosenFulfillEasyQuestsCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
choiceFrom(
choosers = choosers,
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = availableCommands,
uhsWithQuests = uhsWithQuests,
functionalRandom = functionalRandom
)
/** Proto version - converts to native and extracts uhsWithQuests */
def chosenFulfillEasyQuestsCommand(
actingFactionId: FactionId,
gameState: GameStateProto,
@@ -58,24 +89,15 @@ object FulfillQuestsCommandSelector {
uh <- province.unaffiliatedHeroes
recruitmentInfo <- uh.recruitmentInfo
quest <- recruitmentInfo.quest
} yield UnaffiliatedHeroWithQuest(province.id, uh, quest)).toVector
} yield UnaffiliatedHeroWithQuest(
province.id,
uh.heroId,
QuestConverter.fromProto(quest)
)).toVector
val nativeGameState = GameStateConverter.fromProto(gameState)
choiceFrom(
choosers = Vector[QuestCommandChooser](
AllianceQuestCommandChooser,
TruceWithFactionQuestCommandChooser,
ImproveQuestCommandChooser,
AlmsToProvinceQuestCommandChooser,
GiveToHeroesInProvinceQuestCommandChooser,
AlmsAcrossRealmQuestCommandChooser,
GiveToHeroesAcrossRealmQuestCommandChooser,
TruceCountQuestCommandChooser,
DismissSpecificVassalCommandChooser
),
chosenFulfillEasyQuestsCommand(
actingFactionId = actingFactionId,
gameState = nativeGameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = availableCommands,
uhsWithQuests = uhsWithQuests,
functionalRandom = functionalRandom
@@ -1,22 +1,12 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.hero.HeroT
object HeroSelector {
/** Protoless version */
def minimallyFatiguedHeroes(heroes: Vector[HeroT]): Vector[HeroT] = {
val minimumFatigue = heroes.map(HeroUtils.fatigue).min
heroes
.filter(HeroUtils.fatigue(_) == minimumFatigue)
}
/** Legacy proto version */
def minimallyFatiguedHeroesProto(heroes: Vector[Hero]): Vector[Hero] = {
val minimumFatigue = heroes.map(LegacyHeroUtils.fatigue).min
heroes
.filter(LegacyHeroUtils.fatigue(_) == minimumFatigue)
}
}
@@ -1,36 +1,12 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
import net.eagle0.eagle.library.settings.GoldPerHeroHeldBack
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.ProvinceId
object ProvinceGoldSurplusCalculator {
// Protoless version
def goldToHoldBack(province: ProvinceT): Int =
(GoldPerHeroHeldBack.doubleValue * province.rulingFactionHeroIds.length).ceil.toInt
def provinceGoldSurplus(province: ProvinceT): Int =
(province.gold - goldToHoldBack(province)).max(0)
// Legacy proto version for backward compatibility
def goldToHoldBack(
provinceId: ProvinceId,
gameState: GameStateProto
): Int =
(GoldPerHeroHeldBack.doubleValue * gameState
.provinces(provinceId)
.rulingFactionHeroIds
.length).ceil.toInt
def provinceGoldSurplus(
provinceId: ProvinceId,
gameState: GameStateProto
): Int =
(gameState.provinces(provinceId).gold - goldToHoldBack(
provinceId,
gameState
)).max(0)
}
@@ -3,11 +3,11 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
import net.eagle0.eagle.api.command.util.diplomacy_option.AllianceOption
import net.eagle0.eagle.common.unaffiliated_hero_quest.AllianceQuest
import net.eagle0.eagle.library.util.command_choice_helpers.{AllianceOfferCommandSelector, TrustForDiplomacy}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Ally
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.AllianceQuest
import net.eagle0.eagle.FactionId
object AllianceQuestCommandChooser extends QuestCommandChooser {
@@ -27,8 +27,8 @@ object AllianceQuestCommandChooser extends QuestCommandChooser {
}.flatten
functionalRandom
.nextFlatCollectFirst(uhsWithQuests.map(_.quest.details)) {
case _: AllianceQuest =>
.nextFlatCollectFirst(uhsWithQuests.map(_.quest)) {
case AllianceQuest =>
fr =>
val actingFaction = gameState.factions(actingFactionId)
val factionChoices =
@@ -1,10 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.{AlmsAcrossRealmQuest, Quest}
import net.eagle0.eagle.library.util.command_choice_helpers.AlmsCommandSelector
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.AlmsAcrossRealmQuest
import net.eagle0.eagle.FactionId
object AlmsAcrossRealmQuestCommandChooser extends DeterministicQuestCommandChooser {
@@ -13,13 +13,8 @@ object AlmsAcrossRealmQuestCommandChooser extends DeterministicQuestCommandChoos
): Option[Int] = uhsWithQuests
.map(uhwq => uhwq.quest)
.collect {
case Quest(
_,
componentsFulfilled,
AlmsAcrossRealmQuest(amount: Int, _ /* unknownFieldSet */ ),
_ /* unknownFieldSet */
) =>
amount - componentsFulfilled
case q: AlmsAcrossRealmQuest =>
q.totalFood - q.componentsFulfilled
}
.maxOption
@@ -1,10 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.AlmsToProvinceQuest
import net.eagle0.eagle.library.util.command_choice_helpers.AlmsCommandSelector
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.AlmsToProvinceQuest
object AlmsToProvinceQuestCommandChooser extends DeterministicQuestCommandChooser {
override def chosenDeterministicCommand(
@@ -14,17 +14,10 @@ object AlmsToProvinceQuestCommandChooser extends DeterministicQuestCommandChoose
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest]
): Option[CommandSelection] =
uhsWithQuests
.map(uhwq => (uhwq.pid, uhwq.quest.details))
.map(uhwq => (uhwq.pid, uhwq.quest))
.collect {
case (
pid,
AlmsToProvinceQuest(
_: ProvinceId,
amount: Int,
_ /* unknownFieldSet */
)
) =>
(pid, amount)
case (pid, q: AlmsToProvinceQuest) =>
(pid, q.totalFood - q.componentsFulfilled)
}
.groupBy(_._1)
.map { case (pid, amts) => (pid, amts.map(_._2).max) }
@@ -21,6 +21,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -44,6 +45,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -66,6 +68,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -92,6 +95,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:exile_vassal_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -115,6 +119,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:hero_gift_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -137,6 +142,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:hero_gift_command_selector",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -159,6 +165,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:improve_command_selector",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -204,6 +211,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -226,6 +234,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:truce_offer_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
],
)
@@ -236,9 +245,11 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/quest",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
],
)
@@ -3,12 +3,12 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.common.MoreOption
import net.eagle0.common.MoreSeq.flatCollectFirst
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.{DismissSpecificVassalQuest, Quest}
import net.eagle0.eagle.library.settings.RequiredPowerMultiplierForDismiss
import net.eagle0.eagle.library.util.command_choice_helpers.ExileVassalCommandSelector
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.DismissSpecificVassalQuest
import net.eagle0.eagle.FactionId
object DismissSpecificVassalCommandChooser extends DeterministicQuestCommandChooser {
@@ -22,22 +22,14 @@ object DismissSpecificVassalCommandChooser extends DeterministicQuestCommandChoo
uhsWithQuests.flatCollectFirst {
case UnaffiliatedHeroWithQuest(
pid,
uh,
Quest(
_,
_,
DismissSpecificVassalQuest(
targetHeroId,
_ /* unknownFieldSet */
),
_ /* unknownFieldSet */
)
heroId,
DismissSpecificVassalQuest(targetHeroId)
) =>
MoreOption.flatWhen(
// Don't exile a vassal that would leave the faction leader here by themselves
gameState.provinces(pid).rulingFactionHeroIds.size > 2 &&
// Don't exile a vassal unless they're much less powerful than the hero that would replace them
HeroUtils.power(gameState.heroes(uh.heroId)) >=
HeroUtils.power(gameState.heroes(heroId)) >=
RequiredPowerMultiplierForDismiss.doubleValue * HeroUtils
.power(
gameState.heroes(targetHeroId)
@@ -1,10 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.{GiveToHeroesAcrossRealmQuest, Quest}
import net.eagle0.eagle.library.util.command_choice_helpers.HeroGiftCommandSelector
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.GiveToHeroesAcrossRealmQuest
import net.eagle0.eagle.FactionId
object GiveToHeroesAcrossRealmQuestCommandChooser extends DeterministicQuestCommandChooser {
@@ -13,13 +13,8 @@ object GiveToHeroesAcrossRealmQuestCommandChooser extends DeterministicQuestComm
): Option[Int] = uhsWithQuests
.map(uhwq => uhwq.quest)
.collect {
case Quest(
_,
componentsFulfilled,
GiveToHeroesAcrossRealmQuest(amount: Int, _ /* unknownFieldSet */ ),
_ /* unknownFieldSet */
) =>
amount - componentsFulfilled
case q: GiveToHeroesAcrossRealmQuest =>
q.totalGold - q.componentsFulfilled
}
.maxOption
@@ -2,10 +2,10 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.GiveToHeroesInProvinceQuest
import net.eagle0.eagle.library.util.command_choice_helpers.HeroGiftCommandSelector
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.GiveToHeroesInProvinceQuest
object GiveToHeroesInProvinceQuestCommandChooser extends DeterministicQuestCommandChooser {
override def chosenDeterministicCommand(
@@ -15,18 +15,10 @@ object GiveToHeroesInProvinceQuestCommandChooser extends DeterministicQuestComma
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest]
): Option[CommandSelection] =
uhsWithQuests
.map(uhwq => (uhwq.pid, uhwq.quest.details, uhwq.quest.componentsFulfilled))
.map(uhwq => (uhwq.pid, uhwq.quest))
.collect {
case (
pid,
GiveToHeroesInProvinceQuest(
_: ProvinceId,
amount: Int,
_ /* unknownFieldSet */
),
amountCompleted
) =>
(pid, amount - amountCompleted)
case (pid, q: GiveToHeroesInProvinceQuest) =>
(pid, q.totalGold - q.componentsFulfilled)
}
.groupBy(_._1) // Group all the quests of this type for this province
.map {
@@ -2,14 +2,14 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.improvement_type.ImprovementType.{AGRICULTURE, ECONOMY, INFRASTRUCTURE}
import net.eagle0.eagle.common.unaffiliated_hero_quest.{
import net.eagle0.eagle.library.util.command_choice_helpers.ImproveCommandSelector
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.{
ImproveAgricultureQuest,
ImproveEconomyQuest,
ImproveInfrastructureQuest
}
import net.eagle0.eagle.library.util.command_choice_helpers.ImproveCommandSelector
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object ImproveQuestCommandChooser extends DeterministicQuestCommandChooser {
@@ -20,7 +20,7 @@ object ImproveQuestCommandChooser extends DeterministicQuestCommandChooser {
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest]
): Option[CommandSelection] =
uhsWithQuests
.map(uhwq => (uhwq.pid, uhwq.quest.details))
.map(uhwq => (uhwq.pid, uhwq.quest))
.collect {
case (pid, _: ImproveEconomyQuest) => (pid, ECONOMY)
case (pid, _: ImproveAgricultureQuest) => (pid, AGRICULTURE)
@@ -2,11 +2,11 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.TruceCountQuest
import net.eagle0.eagle.library.util.command_choice_helpers.{TruceOfferCommandSelector, TrustForDiplomacy}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.TruceCountQuest
import net.eagle0.eagle.FactionId
object TruceCountQuestCommandChooser extends QuestCommandChooser {
@@ -18,7 +18,7 @@ object TruceCountQuestCommandChooser extends QuestCommandChooser {
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
functionalRandom
.nextFlatCollectFirst(uhsWithQuests.map(_.quest.details)) {
.nextFlatCollectFirst(uhsWithQuests.map(_.quest)) {
case _: TruceCountQuest =>
fr =>
val actingFaction = gameState.factions(actingFactionId)
@@ -1,10 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.TruceWithFactionQuest
import net.eagle0.eagle.library.util.command_choice_helpers.{TruceOfferCommandSelector, TrustForDiplomacy}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.quest.concrete.TruceWithFactionQuest
import net.eagle0.eagle.FactionId
object TruceWithFactionQuestCommandChooser extends DeterministicQuestCommandChooser {
@@ -15,7 +15,7 @@ object TruceWithFactionQuestCommandChooser extends DeterministicQuestCommandChoo
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest]
): Option[CommandSelection] =
uhsWithQuests
.map(_.quest.details)
.map(_.quest)
.collect { case twfq: TruceWithFactionQuest => twfq }
.filter { twfq =>
TrustForDiplomacy.meetsConditionsForTruce(
@@ -1,11 +1,10 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.common.unaffiliated_hero_quest.Quest
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.ProvinceId
import net.eagle0.eagle.{HeroId, ProvinceId}
import net.eagle0.eagle.model.state.quest.QuestT
case class UnaffiliatedHeroWithQuest(
pid: ProvinceId,
uh: UnaffiliatedHero,
quest: Quest
heroId: HeroId,
quest: QuestT
)
@@ -10,7 +10,8 @@ import net.eagle0.eagle.model.state.quest.QuestT
case class ChronicleUpdatePreviousEntry(
date: Date,
generatedTextId: String
generatedTextId: String,
chroniclerStyle: Option[String] = None
)
sealed trait GeneratedTextRequestT {
@@ -130,7 +131,8 @@ enum LlmRequestT extends GeneratedTextRequestT:
alwaysGenerate: Boolean = false,
current_date: Date,
previous_entries: Vector[ChronicleUpdatePreviousEntry],
new_entries: Vector[ChronicleEvent]
new_entries: Vector[ChronicleEvent],
chronicler_style: String
)
case DivineMessage(
@@ -374,6 +374,7 @@ scala_library(
srcs = ["QuestConverter.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/llm_request:__pkg__",
@@ -10,12 +10,14 @@ object ChronicleEntryConverter {
def toProto(chronicleEntry: ChronicleEntry): ChronicleEntryProto =
ChronicleEntryProto(
generatedTextId = chronicleEntry.generatedTextId,
date = Some(DateConverter.toProto(chronicleEntry.date))
date = Some(DateConverter.toProto(chronicleEntry.date)),
chroniclerStyle = chronicleEntry.chroniclerStyle.getOrElse("")
)
def fromProto(chronicleEntryProto: ChronicleEntryProto): ChronicleEntry =
ChronicleEntry(
generatedTextId = chronicleEntryProto.generatedTextId,
date = DateConverter.fromProto(chronicleEntryProto.date)
date = DateConverter.fromProto(chronicleEntryProto.date),
chroniclerStyle = Option.when(chronicleEntryProto.chroniclerStyle.nonEmpty)(chronicleEntryProto.chroniclerStyle)
)
}
@@ -228,21 +228,25 @@ object LlmRequestConverter {
previousEntries: Vector[
ChronicleUpdatePreviousEntry
],
newEntries: Vector[ChronicleEvent]
newEntries: Vector[ChronicleEvent],
chroniclerStyle: String
) =>
ChronicleUpdateMessageProto(
currentDate = Some(DateConverter.toProto(currentDate)),
previousEntries = previousEntries.map {
case ChronicleUpdatePreviousEntry(
date,
generatedTextId
generatedTextId,
entryChroniclerStyle
) =>
ChronicleUpdateMessageProto.PreviousChronicleEntry(
date = Some(DateConverter.toProto(date)),
generatedTextId = generatedTextId
generatedTextId = generatedTextId,
chroniclerStyle = entryChroniclerStyle.getOrElse("")
)
},
newEntries = newEntries.map(ChronicleEventConverter.toProto)
newEntries = newEntries.map(ChronicleEventConverter.toProto),
chroniclerStyle = chroniclerStyle
)
case DivineMessage(
@@ -4,6 +4,7 @@ scala_library(
name = "province",
srcs = ["ProvinceConverter.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/main/scala/net/eagle0/eagle/library:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
@@ -4,5 +4,6 @@ import net.eagle0.eagle.model.state.date.Date
case class ChronicleEntry(
generatedTextId: String,
date: Date
date: Date,
chroniclerStyle: Option[String] = None
)
@@ -6,6 +6,7 @@ scala_library(
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/quest_creation:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment/prisoner_management:__pkg__",
@@ -46,7 +46,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library:eagle_client_exception",
"//src/main/scala/net/eagle0/eagle/library:engine",
"//src/main/scala/net/eagle0/eagle/library:game_history",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/settings:max_supported_players",
"//src/main/scala/net/eagle0/eagle/library/settings/loaders:settings_loader",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:game_parameters_utils",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
@@ -13,6 +13,8 @@ import net.eagle0.eagle.api.eagle.EagleGrpc.Eagle
import net.eagle0.eagle.api.eagle.HexMapResponse.OneMapResponseInfo
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest
import net.eagle0.eagle.api.pregenerated_text.PregeneratedText
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.settings.loaders.SettingsLoader
import net.eagle0.eagle.library.settings.MaxSupportedPlayers
import net.eagle0.eagle.library.EagleClientException
import net.eagle0.eagle.service.new_game_creation.GameParametersUtils
@@ -660,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 {
@@ -188,20 +188,19 @@ case class HumanPlayerClientConnectionState(
if knownShardokResults.isEmpty then this
else this.enqueueShardokResults(knownShardokResults)
val newHistoryClient = afterShardok.withNewCount(
unfilteredCountAfter = unfilteredCountAfter
// Always send the count update to the client, even when filteredResults is empty.
// Previously, we returned early when filteredResults.isEmpty, which advanced the
// server's tracking without notifying the client. This caused sync mismatches:
// the server thought the client was up-to-date, but the client never received
// the new count. This was particularly problematic after battles ended, when
// AI turns might be filtered out (not visible to the human player).
afterShardok.afterSendingResults(
availableCommands = availableCommands,
startingState = None,
filteredResults = filteredResults,
unfilteredCountAfter = unfilteredCountAfter,
serverGameStatus = serverGameStatus
)
if filteredResults.isEmpty then newHistoryClient
else
newHistoryClient
.afterSendingResults(
availableCommands = availableCommands,
startingState = None,
filteredResults = filteredResults,
unfilteredCountAfter = unfilteredCountAfter,
serverGameStatus = serverGameStatus
)
}
private def withNewCount(
@@ -13,8 +13,10 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_faction_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:faction_destroyed_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:faction_leader_removed_result_type",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province:orders",
@@ -1,7 +1,8 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, SeededRandom}
import net.eagle0.eagle.model.action_result.types.FactionDestroyedResultType
import net.eagle0.eagle.model.action_result.concrete.ChangedFactionC
import net.eagle0.eagle.model.action_result.types.{FactionDestroyedResultType, FactionLeaderRemovedResultType}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.{Army, MovingArmy, Supplies}
import net.eagle0.eagle.model.state.faction.concrete.FactionC
@@ -12,6 +13,7 @@ import net.eagle0.eagle.GameId
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.Inside.inside
import org.scalatest.LoneElement.convertToCollectionLoneElementWrapper
class CheckForFactionChangesActionTest extends AnyFlatSpec with Matchers {
@@ -123,6 +125,100 @@ class CheckForFactionChangesActionTest extends AnyFlatSpec with Matchers {
ar.removedFactionIds shouldBe Vector(fid)
}
}
it should "generate a new faction head notification when the faction head is killed" in {
// Kill hero 13 who is the faction head
val results =
CheckForFactionChangesAction(
gameId = gameId,
factions = factions,
provinces = provinces,
heroes = allHeroes,
killedHeroIds = Vector(13) // The faction head
).randomResults(staticFr).newValue
val factionLeaderRemovedResult = results.find(_.actionResultType == FactionLeaderRemovedResultType)
factionLeaderRemovedResult shouldBe defined
inside(factionLeaderRemovedResult.get) {
case ar: ActionResultT =>
inside(ar.changedFactions.loneElement) {
case cf: ChangedFactionC =>
cf.newFactionHeadHeroId shouldBe Some(12) // New head is next leader
}
ar.newNotifications.loneElement // Should have exactly one notification
ar.newGeneratedTextRequests.loneElement // Should have exactly one LLM request
}
}
it should "not generate a new faction head notification when a non-head leader is killed" in {
// Kill hero 12 who is NOT the faction head (hero 13 is)
val results =
CheckForFactionChangesAction(
gameId = gameId,
factions = factions,
provinces = provinces,
heroes = allHeroes,
killedHeroIds = Vector(12) // A non-head leader (sworn brother)
).randomResults(staticFr).newValue
val factionLeaderRemovedResult = results.find(_.actionResultType == FactionLeaderRemovedResultType)
factionLeaderRemovedResult shouldBe defined
inside(factionLeaderRemovedResult.get) {
case ar: ActionResultT =>
inside(ar.changedFactions.loneElement) {
case cf: ChangedFactionC =>
cf.removedLeaderHeroIds shouldBe Vector(12)
cf.newFactionHeadHeroId shouldBe None // Head didn't change
}
ar.newNotifications shouldBe empty // No notification
ar.newGeneratedTextRequests shouldBe empty // No LLM request
}
}
it should "preserve faction head when first leader in list is killed but head is not first" in {
// Test case where faction head is NOT first in leaderIds list
// This ensures we check the actual factionHeadId, not list position
val factionWithHeadNotFirst = FactionC(
id = 99,
name = "headNotFirst",
factionHeadId = 42, // Head is hero 42
leaderIds = Vector(41, 42, 43) // But 41 is first in list
)
val factionProvince99 = ProvinceC(
id = 99,
rulingFactionId = Some(99),
provinceOrders = Entrust,
rulingHeroId = Some(42),
rulingFactionHeroIds = Vector(41, 42, 43)
)
val heroes99 = Vector(41, 42, 43).map(hid => HeroC(id = hid, factionId = Some(99)))
// Kill hero 41 who is first in leaderIds but NOT the faction head
val results =
CheckForFactionChangesAction(
gameId = gameId,
factions = Vector(factionWithHeadNotFirst),
provinces = Vector(factionProvince99),
heroes = heroes99,
killedHeroIds = Vector(41)
).randomResults(staticFr).newValue
val factionLeaderRemovedResult = results.find(_.actionResultType == FactionLeaderRemovedResultType)
factionLeaderRemovedResult shouldBe defined
inside(factionLeaderRemovedResult.get) {
case ar: ActionResultT =>
inside(ar.changedFactions.loneElement) {
case cf: ChangedFactionC =>
cf.removedLeaderHeroIds shouldBe Vector(41)
cf.newFactionHeadHeroId shouldBe None // Head (42) is still alive, shouldn't change
}
ar.newNotifications shouldBe empty // No notification since head didn't change
ar.newGeneratedTextRequests shouldBe empty
}
}
//
// it should "return any ambassadors to their home provinces" in {
// val factionWithIncomingAmbassadors = factions(fid).update(
@@ -844,12 +844,10 @@ class NewRoundActionTest
val results = runNewRoundAction(GameStateConverter.fromProto(gameState))
val relevantAction = results.find(_.`type` == NEW_ROUND_ACTION).get
relevantAction.newChronicleEntry should contain(
ChronicleEntry(
date = Some(DateProto(year = 252, month = 11)),
generatedTextId = "chronicle_update_252_11"
)
)
val entry = relevantAction.newChronicleEntry.get
entry.date shouldBe Some(DateProto(year = 252, month = 11))
entry.generatedTextId shouldBe "chronicle_update_252_11"
entry.chroniclerStyle should not be empty
}
it should "include a chronicle LLM request if in November" in {
@@ -104,7 +104,8 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
previousEntries = Vector(
ChronicleUpdateMessage.PreviousChronicleEntry(
date = Some(Date(month = 1, year = 342)),
generatedTextId = "previous_chronicle_entry"
generatedTextId = "previous_chronicle_entry",
chroniclerStyle = "" // Unknown chronicler for this older entry
)
),
newEntries = Vector(
@@ -120,11 +121,12 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
)
)
),
currentDate = Some(currentDate)
currentDate = Some(currentDate),
chroniclerStyle = "a modern journalist"
),
gameState = gameStateWithFactions,
clientTextStore = mockClientTextStore,
randomInt = 17
randomInt = 17 // No longer used, kept for backwards compatibility
)
val generatedText = generator.generate match {
@@ -136,13 +138,14 @@ class ChronicleUpdatePromptGeneratorTest extends AnyFlatSpec with BeforeAndAfter
|
|${MapDescription.mapDescription}
|
|The first block of text below is the list of previous chronicle entries, with dates. These were probably written by different chroniclers, so the information in them is relevant, but the style may differ. The second block of text lists major events that happened since that last story. The third block of text lists the major surviving factions and what provinces they control. The update should be roughly chronological for the events of the last year, but more thematic, focusing on major trends, for the previous history. Not all the events need to be included, focus on the most important developments during the time period covered. The chronicler's description should not be separated into blocks, but told in medieval terms. Do not include any other context beyond the in-world text of the response itself. Start with a title of no more than five words, then \"=====\" on a separate line, then the main text of the chronicle, like this:
|The first block of text below is the list of previous chronicle entries, with dates. Each entry is labeled with who wrote it (e.g., "Written by Homer:"). Write in the style of a modern journalist. Use the INFORMATION from the previous entries (names, events, factions, outcomes) but do not imitate the writing style of entries written by other chroniclers. The second block of text lists major events that happened since the last story. The third block of text lists the major surviving factions and what provinces they control. The update should be roughly chronological for the events of the last year, but more thematic, focusing on major trends, for the previous history. Not all the events need to be included, focus on the most important developments during the time period covered. The chronicler's description should not be separated into blocks, but told in medieval terms. Do not include any other context beyond the in-world text of the response itself. Start with a title of no more than five words, then \"=====\" on a separate line, then the main text of the chronicle, like this:
|This is a title
|=====
|This is the chronicle text
|---
|FIRST SECTION
|January 342
|Written by an unknown chronicler:
|Last year's title
|=====
|Last year's text
@@ -5,8 +5,6 @@ import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.api.available_command.DiplomacyAvailableCommand
import net.eagle0.eagle.api.command.util.diplomacy_option.{AllianceOption, TruceOption}
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
import net.eagle0.eagle.common.unaffiliated_hero_quest.{AllianceQuest, Quest as QuestProto}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.library.settings.MinimumTrustToOfferAlliance
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.faction.concrete.FactionC
@@ -14,6 +12,7 @@ import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.{Ally, Hostile}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.quest.concrete.AllianceQuest
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -113,10 +112,8 @@ class AllianceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAfterEac
private val uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = 19,
uh = UnaffiliatedHeroProto(
heroId = 918
),
quest = QuestProto(details = AllianceQuest())
heroId = 918,
quest = AllianceQuest
)
)
@@ -3,9 +3,6 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.AlmsAvailableCommand
import net.eagle0.eagle.api.selected_command.AlmsSelectedCommand
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{AlmsAcrossRealmQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.library.settings.{AlmsSupportIncreasePerFood, MinSupportForTaxes}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.date.Date
@@ -14,6 +11,7 @@ import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{AlmsAcrossRealmQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -31,30 +29,16 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
private val uh1Id: HeroId = 57
private val uh2Id: HeroId = 67
private val unaffiliatedHeroSameProvinceProto = UnaffiliatedHeroProto(
heroId = uh1Id,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = AlmsAcrossRealmQuest(totalFood = 597)
)
)
)
)
private val almsAcrossRealmQuest597 = AlmsAcrossRealmQuest(
componentCount = 1,
componentsFulfilled = 0,
totalFood = 597
)
private val unaffiliatedHeroDifferentProvinceProto = UnaffiliatedHeroProto(
heroId = uh2Id,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = AlmsAcrossRealmQuest(totalFood = 1565)
)
)
)
)
private val almsAcrossRealmQuest1565 = AlmsAcrossRealmQuest(
componentCount = 1,
componentsFulfilled = 0,
totalFood = 1565
)
private def createGameState(
@@ -143,19 +127,21 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroSameProvinceProto,
quest = unaffiliatedHeroSameProvinceProto.getRecruitmentInfo.getQuest
heroId = uh1Id,
quest = almsAcrossRealmQuest597
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
)
)
)
}
@@ -169,30 +155,19 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroSameProvinceProto,
quest = unaffiliatedHeroSameProvinceProto.getRecruitmentInfo.getQuest
heroId = uh1Id,
quest = almsAcrossRealmQuest597
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
it should "return nothing if the hero has a different quest" in {
val newQuest = Quest(
details = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
)
val differentQuestHero = UnaffiliatedHeroProto(
heroId = uh1Id,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(newQuest)
)
)
val differentQuest = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val selectedCommand =
@@ -203,13 +178,13 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = differentQuestHero,
quest = newQuest
heroId = uh1Id,
quest = differentQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
it should "return an alms command with more food for an unaffiliated hero with that quest in another province" in {
@@ -221,24 +196,26 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroSameProvinceProto,
quest = unaffiliatedHeroSameProvinceProto.getRecruitmentInfo.getQuest
heroId = uh1Id,
quest = almsAcrossRealmQuest597
),
UnaffiliatedHeroWithQuest(
pid = ownedProvinceId,
uh = unaffiliatedHeroDifferentProvinceProto,
quest = unaffiliatedHeroDifferentProvinceProto.getRecruitmentInfo.getQuest
heroId = uh2Id,
quest = almsAcrossRealmQuest1565
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 1000, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 1000, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
)
)
)
}
@@ -252,24 +229,26 @@ class AlmsAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with BeforeAndA
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroSameProvinceProto,
quest = unaffiliatedHeroSameProvinceProto.getRecruitmentInfo.getQuest
heroId = uh1Id,
quest = almsAcrossRealmQuest597
),
UnaffiliatedHeroWithQuest(
pid = ownedProvinceId,
uh = unaffiliatedHeroDifferentProvinceProto,
quest = Quest(details = AlmsAcrossRealmQuest(totalFood = 221))
heroId = uh2Id,
quest = AlmsAcrossRealmQuest(componentCount = 1, componentsFulfilled = 0, totalFood = 221)
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsAcrossRealm quest"
)
)
)
}
@@ -3,9 +3,6 @@ package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selec
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.AlmsAvailableCommand
import net.eagle0.eagle.api.selected_command.AlmsSelectedCommand
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{AlmsToProvinceQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.library.settings.{AlmsSupportIncreasePerFood, MinSupportForTaxes}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.date.Date
@@ -14,6 +11,7 @@ import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{AlmsToProvinceQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -27,21 +25,11 @@ class AlmsToProvinceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAf
private val factionLeaderId: HeroId = 5
private val heroId: HeroId = 57
// Proto types for quest data (still needed for the API)
private val unaffiliatedHeroProto = UnaffiliatedHeroProto(
heroId = heroId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = AlmsToProvinceQuest(
totalFood = 597,
provinceId = actingProvinceId
)
)
)
)
)
private val almsToProvinceQuest = AlmsToProvinceQuest(
componentCount = 1,
componentsFulfilled = 0,
provinceId = actingProvinceId,
totalFood = 597
)
private def createGameState(
@@ -119,19 +107,21 @@ class AlmsToProvinceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAf
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = unaffiliatedHeroProto.getRecruitmentInfo.getQuest
heroId = heroId,
quest = almsToProvinceQuest
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsToProvince quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = giveAlmsCommand,
selected = AlmsSelectedCommand(amount = 597, actingHeroId = factionLeaderId),
reason = "fulfill AlmsToProvince quest"
)
)
)
}
@@ -145,30 +135,19 @@ class AlmsToProvinceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAf
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = unaffiliatedHeroProto.getRecruitmentInfo.getQuest
heroId = heroId,
quest = almsToProvinceQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
it should "return nothing if the hero has a different quest" in {
val newQuest = Quest(
details = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
)
val differentQuestHero = UnaffiliatedHeroProto(
heroId = heroId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(newQuest)
)
)
val differentQuest = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val selectedCommand =
@@ -179,12 +158,12 @@ class AlmsToProvinceQuestCommandChooserTest extends AnyFlatSpec with BeforeAndAf
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = differentQuestHero,
quest = newQuest
heroId = heroId,
quest = differentQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
}
@@ -17,6 +17,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -39,6 +40,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -61,6 +63,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -82,6 +85,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -102,6 +106,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
"//src/test/scala/net/eagle0/common:proto_matchers",
],
@@ -123,6 +128,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
"//src/test/scala/net/eagle0/common:proto_matchers",
],
@@ -4,9 +4,6 @@ import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{ExileVassalAvailableCommand, ImproveAvailableCommand}
import net.eagle0.eagle.api.selected_command.ExileVassalSelectedCommand
import net.eagle0.eagle.common.improvement_type.ImprovementType.{AGRICULTURE, ECONOMY}
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{DismissSpecificVassalQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.library.settings.RequiredPowerMultiplierForDismiss
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.faction.concrete.FactionC
@@ -14,6 +11,7 @@ import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.hero.Profession.{Engineer, Mage, NoProfession}
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{DismissSpecificVassalQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -60,18 +58,7 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
profession = Mage
)
private val unaffiliatedHeroProto = UnaffiliatedHeroProto(
heroId = strongFreeHeroId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = DismissSpecificVassalQuest(targetHeroId = weakVassalId)
)
)
)
)
)
private val dismissWeakVassalQuest = DismissSpecificVassalQuest(targetHeroId = weakVassalId)
private def createGameState(
rulingFactionHeroIds: Vector[HeroId] = Vector(factionLeaderId, weakVassalId, strongVassalId)
@@ -137,21 +124,21 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = DismissSpecificVassalQuest(weakVassalId)
)
heroId = strongFreeHeroId,
quest = dismissWeakVassalQuest
)
)
)
selectedCommand shouldBe Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = provinceId,
available = exileVassalsCommand,
selected = ExileVassalSelectedCommand(exiledHeroId = weakVassalId),
reason = "fulfill DismissSpecificVassal quest"
selectedCommand.shouldBe(
Some(
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = provinceId,
available = exileVassalsCommand,
selected = ExileVassalSelectedCommand(exiledHeroId = weakVassalId),
reason = "fulfill DismissSpecificVassal quest"
)
)
)
}
@@ -165,10 +152,8 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = ImproveAgricultureQuest(provinceId = provinceId, desiredValue = 32)
)
heroId = strongFreeHeroId,
quest = ImproveAgricultureQuest(provinceId = provinceId, desiredValue = 32)
)
)
)
@@ -187,10 +172,8 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = DismissSpecificVassalQuest(weakVassalId)
)
heroId = strongFreeHeroId,
quest = dismissWeakVassalQuest
)
)
)
@@ -207,10 +190,8 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = DismissSpecificVassalQuest(strongVassalId)
)
heroId = strongFreeHeroId,
quest = DismissSpecificVassalQuest(targetHeroId = strongVassalId)
)
)
)
@@ -229,10 +210,8 @@ class DismissSpecificVassalCommandChooserTest extends AnyFlatSpec with BeforeAnd
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = provinceId,
uh = unaffiliatedHeroProto,
quest = Quest(
details = DismissSpecificVassalQuest(weakVassalId)
)
heroId = strongFreeHeroId,
quest = dismissWeakVassalQuest
)
)
)
@@ -4,13 +4,11 @@ import net.eagle0.common.ProtoMatchers.equalProto
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{EligibleGift, HeroGiftAvailableCommand}
import net.eagle0.eagle.api.selected_command.HeroGiftSelectedCommand
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{GiveToHeroesAcrossRealmQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{GiveToHeroesAcrossRealmQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -30,30 +28,10 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
private val freeHeroInProvinceId: HeroId = 57
private val freeHeroOtherProvinceId: HeroId = 58
private val unaffiliatedHeroInProvinceProto = UnaffiliatedHeroProto(
heroId = freeHeroInProvinceId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = GiveToHeroesAcrossRealmQuest(totalGold = 397)
)
)
)
)
)
private val unaffiliatedHeroOtherProvinceProto = UnaffiliatedHeroProto(
heroId = freeHeroInProvinceId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = GiveToHeroesAcrossRealmQuest(totalGold = 397)
)
)
)
)
private val giveToHeroesQuest = GiveToHeroesAcrossRealmQuest(
componentCount = 1,
componentsFulfilled = 0,
totalGold = 397
)
private def createGameState(): GameState = {
@@ -144,8 +122,8 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroInProvinceProto,
quest = unaffiliatedHeroInProvinceProto.getRecruitmentInfo.getQuest
heroId = freeHeroInProvinceId,
quest = giveToHeroesQuest
)
)
)
@@ -176,9 +154,8 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroInProvinceProto,
quest = unaffiliatedHeroInProvinceProto.getRecruitmentInfo.getQuest
.withComponentsFulfilled(350)
heroId = freeHeroInProvinceId,
quest = giveToHeroesQuest.copy(componentsFulfilled = 350)
)
)
)
@@ -200,11 +177,9 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
}
it should "return nothing if the hero has a different quest" in {
val newQuest = Quest(
details = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val differentQuest = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val selectedCommand =
@@ -215,13 +190,13 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroInProvinceProto,
quest = newQuest
heroId = freeHeroInProvinceId,
quest = differentQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
it should "still give for a UH in another province" in {
@@ -234,8 +209,8 @@ class GiveToHeroesAcrossRealmQuestCommandChooserTest extends AnyFlatSpec with Be
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = ownedProvinceId,
uh = unaffiliatedHeroOtherProvinceProto,
quest = unaffiliatedHeroOtherProvinceProto.getRecruitmentInfo.getQuest
heroId = freeHeroOtherProvinceId,
quest = giveToHeroesQuest
)
)
)
@@ -4,13 +4,11 @@ import net.eagle0.common.ProtoMatchers.equalProto
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{EligibleGift, HeroGiftAvailableCommand}
import net.eagle0.eagle.api.selected_command.HeroGiftSelectedCommand
import net.eagle0.eagle.common.recruitment_info.RecruitmentInfo
import net.eagle0.eagle.common.unaffiliated_hero_quest.{GiveToHeroesInProvinceQuest, ImproveAgricultureQuest, Quest}
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero as UnaffiliatedHeroProto
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.concrete.{GiveToHeroesInProvinceQuest, ImproveAgricultureQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
@@ -27,20 +25,11 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
private val vassalId: HeroId = 10
private val freeHeroId: HeroId = 57
private val unaffiliatedHeroProto = UnaffiliatedHeroProto(
heroId = freeHeroId,
recruitmentInfo = Some(
RecruitmentInfo(
quest = Some(
Quest(
details = GiveToHeroesInProvinceQuest(
totalGold = 397,
provinceId = actingProvinceId
)
)
)
)
)
private val giveToHeroesQuest = GiveToHeroesInProvinceQuest(
componentCount = 1,
componentsFulfilled = 0,
provinceId = actingProvinceId,
totalGold = 397
)
private def createGameState(): GameState = {
@@ -117,8 +106,8 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = unaffiliatedHeroProto.getRecruitmentInfo.getQuest
heroId = freeHeroId,
quest = giveToHeroesQuest
)
)
)
@@ -149,9 +138,8 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = unaffiliatedHeroProto.getRecruitmentInfo.getQuest
.withComponentsFulfilled(350)
heroId = freeHeroId,
quest = giveToHeroesQuest.copy(componentsFulfilled = 350)
)
)
)
@@ -173,11 +161,9 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
}
it should "return nothing if the hero has a different quest" in {
val newQuest = Quest(
details = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val differentQuest = ImproveAgricultureQuest(
provinceId = actingProvinceId,
desiredValue = 98
)
val selectedCommand =
@@ -188,12 +174,12 @@ class GiveToHeroesInProvinceQuestCommandChooserTest extends AnyFlatSpec with Bef
uhsWithQuests = Vector(
UnaffiliatedHeroWithQuest(
pid = actingProvinceId,
uh = unaffiliatedHeroProto,
quest = newQuest
heroId = freeHeroId,
quest = differentQuest
)
)
)
selectedCommand shouldBe None
selectedCommand.shouldBe(None)
}
}