Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 91c990da04 Update C# client for ReconProvincesQuest rename and new ReconSpecificProvincesQuest
- Update DisplayNames.cs with renamed and new quest types
- Update UnaffiliatedHeroRowController.cs to handle both quest types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 06:50:24 -08:00
adminandClaude Opus 4.5 f15aab6fe7 Rename ReconSpecificProvincesQuest to ReconProvincesQuest and add new ReconSpecificProvincesQuest
- Rename existing ReconSpecificProvincesQuest (count-based) to ReconProvincesQuest
- Add new ReconSpecificProvincesQuest that tracks specific province IDs to recon
- Update proto definitions, Quest.scala, QuestConverter.scala
- Update LLM prompt generators for both quest types
- Update CheckForFulfilledQuestsAction comment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 06:48:13 -08:00
75 changed files with 19769 additions and 33914 deletions
+2 -9
View File
@@ -27,8 +27,9 @@ permissions:
contents: read
jobs:
lint:
test:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -36,14 +37,6 @@ jobs:
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
test:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Run tests
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
+3 -15
View File
@@ -184,23 +184,11 @@ jobs:
- name: Upload to TestFlight
if: ${{ github.event.inputs.skip_upload != 'true' }}
env:
# App Store Connect API Key (replaces deprecated altool with Apple ID)
# Create at: https://appstoreconnect.apple.com/access/api
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
run: |
# Write API key to file (xcodebuild needs a file path)
API_KEY_PATH=$(mktemp)
echo "$APP_STORE_CONNECT_API_KEY" > "$API_KEY_PATH"
export APP_STORE_CONNECT_API_KEY_PATH="$API_KEY_PATH"
chmod +x ./ci/github_actions/upload_testflight.sh
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.xcarchive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
# Cleanup
rm -f "$API_KEY_PATH"
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.ipa"
- name: Upload IPA artifact
# Only keep artifact if we skipped TestFlight upload (for debugging)
+26 -80
View File
@@ -1,102 +1,48 @@
#!/usr/bin/env bash
# Upload xcarchive to TestFlight using App Store Connect API Key
# This replaces the deprecated altool which was removed in Xcode 14+
#
# Uses xcodebuild -exportArchive with destination=upload, which is Apple's
# recommended approach for CI/CD pipelines.
# Upload IPA to TestFlight using Apple ID credentials (same as Mac notarization)
set -euxo pipefail
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
IPA_PATH=${1:?Usage: upload_testflight.sh <ipa_path>}
ARCHIVE_PATH=${1:?Usage: upload_testflight.sh <xcarchive_path> <team_id> <profile_uuid>}
TEAM_ID=${2:?Missing team ID}
PROFILE_UUID=${3:?Missing provisioning profile UUID}
if [ ! -d "$ARCHIVE_PATH" ]; then
echo "Error: xcarchive not found: $ARCHIVE_PATH"
if [ ! -f "$IPA_PATH" ]; then
echo "Error: IPA file not found: $IPA_PATH"
exit 1
fi
echo "Uploading to TestFlight: $ARCHIVE_PATH"
echo "Uploading to TestFlight: $IPA_PATH"
# Requires App Store Connect API Key:
# - APP_STORE_CONNECT_API_KEY_ID: Key ID from App Store Connect
# - APP_STORE_CONNECT_API_ISSUER_ID: Issuer ID from App Store Connect
# - APP_STORE_CONNECT_API_KEY_PATH: Path to .p8 private key file
#
# To create an API key:
# 1. Go to https://appstoreconnect.apple.com/access/api
# 2. Click the + button to create a new key
# 3. Give it a name and Admin or App Manager access
# 4. Download the .p8 file (you can only download it once!)
# 5. Note the Key ID and Issuer ID shown on the page
# Uses same credentials as Mac notarization:
# - APPLE_ID: Your Apple ID email
# - APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
if [ -z "${APP_STORE_CONNECT_API_KEY_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_ID environment variable not set"
echo "Create an API key at https://appstoreconnect.apple.com/access/api"
if [ -z "${APPLE_ID:-}" ]; then
echo "Error: APPLE_ID environment variable not set"
exit 1
fi
if [ -z "${APP_STORE_CONNECT_API_ISSUER_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_ISSUER_ID environment variable not set"
if [ -z "${APP_SPECIFIC_PASSWORD:-}" ]; then
echo "Error: APP_SPECIFIC_PASSWORD environment variable not set"
exit 1
fi
if [ -z "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_PATH environment variable not set"
echo "Uploading with xcrun altool..."
# Capture output to check for errors (altool may return 0 even on failure)
OUTPUT=$(xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--username "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" 2>&1) || true
echo "$OUTPUT"
# Check for error indicators in output
if echo "$OUTPUT" | grep -q "ERROR:"; then
echo "ERROR: Upload failed. See error messages above."
exit 1
fi
if [ ! -f "$APP_STORE_CONNECT_API_KEY_PATH" ]; then
echo "Error: API key file not found: $APP_STORE_CONNECT_API_KEY_PATH"
exit 1
fi
# Create export options plist with upload destination
EXPORT_OPTIONS_PLIST=$(mktemp)
trap "rm -f $EXPORT_OPTIONS_PLIST" EXIT
cat > "$EXPORT_OPTIONS_PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key>
<string>upload</string>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution: Daniel Crosby (UWJ88DX8WQ)</string>
<key>provisioningProfiles</key>
<dict>
<key>net.eagle0.eagle</key>
<string>$PROFILE_UUID</string>
</dict>
</dict>
</plist>
EOF
echo "Export options:"
cat "$EXPORT_OPTIONS_PLIST"
echo "Uploading to App Store Connect..."
# Use xcodebuild to upload with App Store Connect API authentication
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
-authenticationKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" \
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID"
echo "Upload complete! Check App Store Connect for processing status."
echo "The build should appear in TestFlight within 15-30 minutes after processing."
-150
View File
@@ -1,150 +0,0 @@
# rules_apple Workspace Separation
This document explains why `rules_apple` is built in a separate Bazel workspace (`sparkle_workspace/`) and what conditions need to be met before it can be reintegrated into the main workspace.
## Background
The main workspace cannot include `rules_apple` due to transitive dependency conflicts with `rules_swift`. Multiple dependencies require different major versions of `rules_swift`:
| Dependency | rules_swift Version | Compatibility Level |
|------------|---------------------|---------------------|
| grpc | 3.x | 3 |
| flatbuffers | 2.x | 2 |
| rules_apple | 2.x | 2 |
Bzlmod's `single_version_override` can force a single version, but compatibility levels 2 and 3 are incompatible. Forcing `rules_swift` 3.x (required for grpc) breaks `rules_apple` and `flatbuffers`.
## Current Solution
The `SparklePlugin` (the only component requiring `rules_apple`) is built in an isolated workspace:
```
sparkle_workspace/
├── MODULE.bazel # Minimal deps: apple_support, rules_apple, sparkle
├── BUILD.bazel # Builds SparklePlugin.bundle
├── SparklePlugin.m # Objective-C source
├── Info.plist # Bundle metadata
└── external/
└── BUILD.sparkle # Build file for Sparkle framework
```
### How It Works
1. **Build script**: `scripts/build_sparkle_plugin.sh` builds the plugin from the separate workspace
2. **Output**: The built `SparklePlugin.bundle` is placed in `Assets/Plugins/macOS/`
3. **CI integration**: `mac_build.yml` calls `inject_sparkle.sh` to embed Sparkle into the Mac app
The main workspace uses `single_version_override` for `rules_swift` 3.x to satisfy grpc, and includes a comment noting that `rules_apple` is intentionally excluded.
## What SparklePlugin Does
SparklePlugin is a native macOS library that:
- Initializes the [Sparkle](https://sparkle-project.org/) auto-update framework
- Exposes C functions for Unity to call via P/Invoke:
- `SparklePlugin_Initialize`
- `SparklePlugin_CheckForUpdates`
- `SparklePlugin_CheckForUpdatesInBackground`
- `SparklePlugin_IsCheckingForUpdates`
- `SparklePlugin_GetAutomaticallyChecksForUpdates`
- `SparklePlugin_SetAutomaticallyChecksForUpdates`
- `SparklePlugin_IsRunningFromReadOnlyVolume`
- `SparklePlugin_ShowDMGWarning`
## Conditions for Reintegration
To move `rules_apple` back into the main workspace, **all** of the following must be true:
### 1. rules_swift Version Alignment
Check if grpc, flatbuffers, and rules_apple all support the same `rules_swift` major version:
```bash
# Check what rules_swift version each dependency requires
bazel mod graph 2>&1 | grep -A2 "rules_swift"
```
Or check BCR directly:
- https://registry.bazel.build/modules/grpc
- https://registry.bazel.build/modules/flatbuffers
- https://registry.bazel.build/modules/rules_apple
**Test**: After updating versions, verify you can add this to MODULE.bazel without errors:
```python
bazel_dep(name = "rules_apple", version = "X.Y.Z")
```
### 2. Build Test
If the dependency can be added, test that both the main build and SparklePlugin work:
```bash
# Main workspace builds
bazel build //src/main/scala/net/eagle0/eagle:eagle_server
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
bazel build //ci:eagle_server_image
# SparklePlugin builds (would move to main workspace)
bazel build //sparkle:SparklePlugin # After moving files
```
### 3. Files to Move
When reintegrating, move these from `sparkle_workspace/` to the main workspace:
| Source | Destination |
|--------|-------------|
| `sparkle_workspace/SparklePlugin.m` | `src/main/objc/net/eagle0/sparkle/SparklePlugin.m` |
| `sparkle_workspace/Info.plist` | `src/main/objc/net/eagle0/sparkle/Info.plist` |
| `sparkle_workspace/BUILD.bazel` | `src/main/objc/net/eagle0/sparkle/BUILD.bazel` |
| `sparkle_workspace/external/BUILD.sparkle` | (inline into MODULE.bazel http_archive) |
### 4. MODULE.bazel Changes
Add to the main MODULE.bazel:
```python
bazel_dep(name = "rules_apple", version = "X.Y.Z", repo_name = "build_bazel_rules_apple")
# Sparkle framework
http_archive(
name = "sparkle",
build_file_content = """...""", # Contents from external/BUILD.sparkle
sha256 = "...",
url = "https://github.com/sparkle-project/Sparkle/releases/download/...",
)
```
Remove the `single_version_override` for `rules_swift` if no longer needed.
### 5. Update Build Scripts
- Update `scripts/build_sparkle_plugin.sh` to build from main workspace
- Update `scripts/build_plugins.sh` if it references the separate workspace
- Verify `mac_build.yml` still works
### 6. Cleanup
After successful integration:
```bash
rm -rf sparkle_workspace/
```
Update comments in MODULE.bazel that reference the separate workspace.
## Version History
| Date | Event |
|------|-------|
| 2026-02-04 | Separated sparkle_workspace (PR #5882) to enable Bazel 8 upgrade |
| 2026-02-05 | Upgraded to Bazel 8.5.1 (PR #5883) |
## Monitoring
Periodically check if the upstream dependencies have aligned:
1. **grpc releases**: https://github.com/grpc/grpc/releases
2. **flatbuffers releases**: https://github.com/google/flatbuffers/releases
3. **rules_apple releases**: https://github.com/bazelbuild/rules_apple/releases
4. **BCR updates**: https://registry.bazel.build/
When a new version of any of these is released, check if the `rules_swift` requirements have converged.
-230
View File
@@ -1,230 +0,0 @@
# Tutorial Battle System
This document describes the tutorial battle system for first-time players. The system provides a scripted introductory battle that teaches combat basics while telling a story.
---
## Overview
When a new player starts their first game in tutorial mode, they experience:
1. **Narrative Intro** - Story screens introducing the scenario
2. **Immediate Battle** - Skip strategic map, start directly in combat
3. **Scripted Flee** - Enemy flees when player is "on the ropes"
4. **Reinforcements** - Allied heroes arrive mid-battle
5. **Heroes Join** - Reinforcement heroes join player's faction after battle
---
## Battle Configuration
### Defender (Player - John Ranil)
- **Heroes**: John Ranil + 2 random sworn brothers (3 total)
- **Units**:
- 2x Light Infantry (300 troops each, 60 training/armament)
- 1x Longbowmen (200 troops, 60 training/armament)
- **Province**: 14 (Onmaa) with 40 support, 500 gold, 3000 food
- **Restriction**: Cannot flee (must defend)
### Attacker (Ikhaan Tarn)
- **Heroes**: Ikhaan Tarn + 2 sworn brothers (3 total)
- **Units**:
- 1x Heavy Cavalry (400 troops, 80 training/armament)
- 1x Heavy Infantry (500 troops, 80 training/armament)
- 1x Longbowmen (300 troops, 80 training/armament)
- **Origin Province**: 32
### Flee Trigger
Attacker flees when ANY of these conditions are met:
- Player loses 1 unit
- 5 rounds have passed
### Reinforcements
When Tarn flees, these heroes arrive as player reinforcements:
- Elena Fyar
- Hedrick
- The Boulder
---
## Implementation Architecture
### Phase 1: Narrative Intro System
**Proto Changes:**
- `game_state_view.proto`: Added `TutorialNarrativeScreen` message and `pending_narrative_screens` field
- `game_parameters.proto`: Added `TutorialNarrativeScreen` and `tutorial_narrative_screens` field
**Unity Client:**
- `NarrativeScreenController.cs`: Modal screen with title, body text, optional image
- `EagleGameController.cs`: Checks for pending narrative screens on game start
**Narrative Content** (defined in `tutorial_game_parameters.json`):
```
Screen 1: "The Engineer of Onmaa" - Player backstory
Screen 2: "The Traitor Arrives" - Tarn's attack
Screen 3: "Defend Your Home" - Call to action
```
### Phase 2: Auto-Start Battle
**Configuration:**
- `tutorial_game_parameters.json`: Contains `tutorialBattle` config with attacker, target, flee conditions
- `game_parameters.proto`: `TutorialBattleConfig` message
**Game Creation:**
- `NewGameCreation.scala`: `setupTutorialBattle()` method creates:
- `HostileArmyGroup` with `Attacking` status (bypasses decision phase)
- `Army` for defender
- Both added to target province
**Battle Creation:**
- When round advances to `BattleRequest` phase, `RequestBattlesAction` automatically creates the `ShardokBattle`
### Phase 3: Scripted Flee Mechanism
**C++ Component:**
- `TutorialBattleController.hpp/cpp`: Controller for scripted battle logic
**Key Methods:**
```cpp
// Check if attacker should flee
bool ShouldTriggerScriptedFlee(const GameStateW& state);
// Check if player (defender) can flee
bool PlayerCanFlee(PlayerId playerId);
// Execute the scripted flee (100% success rate)
std::vector<ActionResult> ExecuteScriptedFlee(...);
```
**Integration Points (TODO):**
- `ShardokEngine.cpp`: Check `ShouldTriggerScriptedFlee()` during turn processing
- `FleeCommandFactory.cpp`: Disable flee commands for defender in tutorial mode
### Phase 4: Mid-Battle Reinforcements
**Action Types** (in `action_type.proto`):
- `TUTORIAL_ENEMY_FLED = 78`
- `TUTORIAL_REINFORCEMENTS_ARRIVED = 79`
**Implementation (TODO):**
- When scripted flee triggers, create reinforcement units in defender's reserves
- Units have `UnitStatus_RESERVE_UNIT` status
- Reinforcements enter battle on subsequent turns
### Phase 5: Tutorial Popups
**Content Definitions** (in `TutorialContentDefinitions.cs`):
- `tutorial_battle_started`: "Defend your province!"
- `tutorial_enemy_fled`: "The enemy retreats!"
- `tutorial_reinforcements_arrived`: "Allies have arrived!"
- `tutorial_battle_victory`: Victory celebration
**Trigger Integration:**
- `TutorialTriggerRegistry.cs`: Handles tutorial action types from `ActionResultView`
- `ShardokGameController.cs`: Detects tutorial events and triggers popups
### Phase 6: Post-Battle Hero Joining
**Implementation (TODO):**
- `ResolveBattleAction.scala`: Check if battle was tutorial and defender won
- Create `TutorialHeroJoined` action results adding reinforcement heroes to player faction
- Set appropriate loyalty, vigor, location for joined heroes
---
## File Summary
### New Files
| File | Purpose |
|------|---------|
| `TutorialBattleController.hpp/cpp` | C++ scripted flee and reinforcement logic |
| `NarrativeScreenController.cs` | Unity narrative screen display |
### Modified Files
| File | Changes |
|------|---------|
| `tutorial_game_parameters.json` | Battle configuration and narrative content |
| `game_state_view.proto` | Narrative screens field |
| `game_parameters.proto` | Tutorial battle config |
| `player_info.proto` | Tutorial battle flags for Shardok |
| `action_type.proto` | Tutorial action types |
| `NewGameCreation.scala` | Auto-start battle setup |
| `EagleGameController.cs` | Narrative display integration |
| `TutorialContentDefinitions.cs` | Battle tutorial content |
| `TutorialTriggerRegistry.cs` | Tutorial event triggers |
---
## Remaining Implementation
### ShardokEngine Integration
Integrate `TutorialBattleController` into the Shardok battle loop:
1. Initialize controller from battle config when battle starts
2. Check `ShouldTriggerScriptedFlee()` at end of each round
3. If true, call `ExecuteScriptedFlee()` and add results to action queue
### Disable Player Flee
In `FleeCommandFactory.cpp`:
- Check if tutorial mode is enabled
- If defender, don't offer flee commands
### Reinforcement Units
In `TutorialBattleController::ExecuteScriptedFlee()`:
- Create `Unit` objects for Elena Fyar, Hedrick, The Boulder
- Add to defender's reserve units
- Generate `TUTORIAL_REINFORCEMENTS_ARRIVED` action
### Post-Battle Hero Joining
In Eagle's battle resolution:
- Detect tutorial battle completion
- Create heroes in player's faction with proper stats
- Generate notification action results
---
## Testing Checklist
- [ ] New user sees narrative screens before battle
- [ ] Battle starts immediately after narratives (no strategic map)
- [ ] Player has correct units (3 heroes, 3 battalions)
- [ ] Attacker has correct units (3 heroes, 3 battalions)
- [ ] Player cannot flee
- [ ] Tarn flees after player loses 1 unit
- [ ] Tarn flees after 5 rounds (if no units lost)
- [ ] Reinforcements appear when Tarn flees
- [ ] Tutorial popups appear at correct moments
- [ ] Reinforcement heroes join faction after battle victory
---
## Configuration Reference
### tutorial_game_parameters.json
```json
{
"tutorialBattle": {
"attackerFactionHead": "Ikhaan Tarn",
"targetProvinceId": 14,
"fleeAfterDefenderUnitsLost": 1,
"fleeAfterRounds": 5,
"reinforcements": ["Elena Fyar", "Hedrick", "The Boulder"]
},
"tutorialNarrativeScreens": [
{ "title": "...", "bodyText": "...", "imagePath": "" }
]
}
```
### TutorialBattleConfig Proto (Shardok)
```protobuf
message TutorialBattleConfig {
bool enabled = 1;
int32 flee_after_defender_units_lost = 2;
int32 flee_after_rounds = 3;
int32 attacker_player_id = 4;
bool defender_can_flee = 5;
repeated string reinforcement_hero_names = 6;
}
```
-113
View File
@@ -1,113 +0,0 @@
# AI Quest Completion Behavior
This document describes which quests the AI attempts to complete proactively to recruit unaffiliated heroes.
## Overview
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
## Quests the AI Actively Completes
### Diplomacy Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
### Resource Giving Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `AlmsToProvinceQuest` | `AlmsToProvinceQuestCommandChooser` | Gives food to the specified province |
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
### Province Development Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
### Other Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
## Quests the AI Does Not Attempt to Complete
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
### Combat/Military Quests
- `DefeatFactionQuest` - Defeat a specific faction
- `GrandArmyQuest` - Accumulate a large number of troops
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
- `WinBattlesQuest` - Win a number of battles
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
### Expansion Quests
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
- `SpecificExpansionQuest` - Conquer a specific province
- `BorderSecurityQuest` - Have troops in a border province
### Prisoner Quests
- `ExecutePrisonerQuest` - Execute a specific prisoner
- `ExilePrisonerQuest` - Exile a specific prisoner
- `ReleasePrisonerQuest` - Release a specific prisoner
- `ReturnPrisonerQuest` - Return a prisoner to their faction
- `ReleaseAllPrisonersQuest` - Release all prisoners
### Province Order Quests
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
- `RestProvinceQuest` - Use the Rest command in a specific province
### Reconnaissance Quests
- `ReconProvincesQuest` - Reconnoiter a number of provinces
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
### Economic Quests
- `TotalDevelopmentQuest` - Achieve total development level in a province
- `WealthQuest` - Accumulate gold and food
- `SpendOnFeastsQuest` - Spend gold on feasts
- `SendSuppliesQuest` - Send food to a specific province
- `RepairDevastationQuest` - Repair devastation
### Special Event Quests
- `SuppressRiotByForceQuest` - Suppress a riot by force
- `FightBeastsAloneQuest` - Fight beasts alone
- `StartBlizzardQuest` - Start a blizzard in a province
- `StartEpidemicQuest` - Start an epidemic in a province
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
### Miscellaneous
- `BattalionDiversityQuest` - Have diverse battalion types
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
- `BetrayAllyQuest` - Betray an allied faction
## Implementation Details
The quest completion logic is located in:
- `FulfillQuestsCommandSelector.scala` - Main entry point, iterates through choosers
- `quest_command_selectors/` - Individual quest handlers
Each chooser extends either:
- `QuestCommandChooser` - For quests requiring randomness
- `DeterministicQuestCommandChooser` - For quests with deterministic command selection
The AI prioritizes quests in the order they appear in `FulfillQuestsCommandSelector.choosers`:
1. Alliance
2. TruceWithFaction
3. Improve (Agriculture/Economy/Infrastructure)
4. AlmsToProvince
5. GiveToHeroesInProvince
6. AlmsAcrossRealm
7. GiveToHeroesAcrossRealm
8. TruceCount
9. DismissSpecificVassal
+5 -26
View File
@@ -22,31 +22,11 @@ NC='\033[0m' # No Color
MODE="${1:-check}"
EXIT_CODE=0
# Cache for expensive bazel query results
LIBRARY_DEPS_CACHE=""
MAIN_DEPS_CACHE=""
# Get deps of library/ (cached)
get_library_deps() {
if [ -z "$LIBRARY_DEPS_CACHE" ]; then
LIBRARY_DEPS_CACHE=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...)' 2>/dev/null || true)
fi
echo "$LIBRARY_DEPS_CACHE"
}
# Get deps of src/main (cached)
get_main_deps() {
if [ -z "$MAIN_DEPS_CACHE" ]; then
MAIN_DEPS_CACHE=$(bazel query 'deps(//src/main/...)' 2>/dev/null || true)
fi
echo "$MAIN_DEPS_CACHE"
}
# Rule 1: src/main should not depend on src/test
check_main_depends_on_test() {
echo -e "${YELLOW}Checking: src/main should not depend on src/test...${NC}"
violations=$(get_main_deps | grep "^//src/test/" || true)
violations=$(bazel query 'deps(//src/main/...) intersect //src/test/...' 2>/dev/null || true)
if [ -n "$violations" ]; then
echo -e "${RED}VIOLATION: src/main depends on src/test:${NC}"
@@ -63,7 +43,7 @@ check_main_depends_on_test() {
check_library_depends_on_scala_proto() {
echo -e "${YELLOW}Checking: library/ should not depend on Scala proto types...${NC}"
violations=$(get_library_deps | grep "^//src/main/protobuf/.*_scala_proto$" || true)
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$violations" ]; then
count=0
else
@@ -85,7 +65,7 @@ check_library_depends_on_scala_proto() {
check_library_depends_on_proto_converters() {
echo -e "${YELLOW}Checking: library/ should not depend on proto_converters...${NC}"
violations=$(get_library_deps | grep "^//src/main/scala/net/eagle0/eagle/model/proto_converters/" || true)
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/scala/net/eagle0/eagle/model/proto_converters/...' 2>/dev/null | grep "^//" || true)
if [ -n "$violations" ]; then
count=$(echo "$violations" | wc -l | tr -d ' ')
@@ -105,8 +85,7 @@ check_library_depends_on_proto_converters() {
count_proto_deps() {
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
library_deps=$(get_library_deps)
scala_proto_results=$(echo "$library_deps" | grep "^//src/main/protobuf/.*_scala_proto$" || true)
scala_proto_results=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$scala_proto_results" ]; then
scala_proto_count=0
else
@@ -115,7 +94,7 @@ count_proto_deps() {
echo "library/ Scala proto deps: $scala_proto_count"
# C++/Go proto deps are expected (map generation tools)
all_proto_count=$(echo "$library_deps" | grep -c "^//src/main/protobuf/" || echo "0")
all_proto_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | wc -l | tr -d ' ')
echo "library/ all proto deps (includes C++/Go build tools): $all_proto_count"
}
@@ -806,9 +806,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
/// <summary>
/// Auto-create and launch a tutorial game for first-time users.
/// Tutorial mode: 3 factions (player, King, weak rival), player spawns at province 14 with
/// quest.
/// Auto-create and launch a 7-faction game for first-time users.
/// </summary>
private void AutoCreateFirstGame() {
if (fetchedNewGameLeaders == null || fetchedNewGameLeaders.Count == 0) {
@@ -820,9 +818,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var randomIndex = new System.Random().Next(fetchedNewGameLeaders.Count);
var leaderTextId = fetchedNewGameLeaders[randomIndex].NameTextId;
// Create a tutorial game (1 human, 3 total factions - player, King, and weak rival)
Debug.Log($"[ConnectionHandler] Auto-creating tutorial game with leader {leaderTextId}");
CreateGame(leaderTextId, 1, 3, isTutorialMode: true);
// Create a 7-faction game (1 human + 6 AI) - same as lobby default
Debug.Log($"[ConnectionHandler] Auto-creating 7-faction game with leader {leaderTextId}");
CreateGame(leaderTextId, 1, 7);
}
private void StartListeningForLobbyUpdates() {
@@ -948,25 +946,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private void ConfirmDropGame(long gameId) { _internalDropGame(gameId); }
/// <summary>
/// 3-parameter overload for CreateCallback delegate compatibility.
/// </summary>
private void CreateGame(string leaderNameTextId, int humanPlayerCount, int totalPlayerCount) {
CreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount, isTutorialMode: false);
}
private void CreateGame(
string leaderNameTextId,
int humanPlayerCount,
int totalPlayerCount,
bool isTutorialMode) {
// Track multiplayer status for tutorial system
TutorialManager.IsMultiplayerGame = humanPlayerCount > 1;
var game = _internalCreateGame(
leaderNameTextId,
humanPlayerCount,
totalPlayerCount,
isTutorialMode);
var game = _internalCreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount);
}
public void QuitButtonClicked() { Application.Quit(); }
@@ -1005,11 +988,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
} catch (Exception e) { Console.WriteLine(e.ToString()); }
}
private async Task<bool> _internalCreateGame(
string desiredLeaderTextId,
int humanPlayerCount,
int totalPlayerCount,
bool isTutorialMode = false) {
private async Task<bool>
_internalCreateGame(string desiredLeaderTextId, int humanPlayerCount, int totalPlayerCount) {
try {
return await _persistentClientConnection.SendUpdateStreamRequestAsync(
new UpdateStreamRequest {
@@ -1017,8 +997,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
new CreateGameRequest {
DesiredLeaderTextId = desiredLeaderTextId,
TotalPlayerCount = totalPlayerCount,
HumanPlayerCount = humanPlayerCount,
IsTutorialMode = isTutorialMode
HumanPlayerCount = humanPlayerCount
}
});
} catch (WebException e) {
@@ -45,9 +45,14 @@ namespace eagle {
public Toggle demandTributeToggle;
public Toggle withdrawToggle;
public Toggle safePassageToggle;
public ToggleGroup decisionToggleGroup;
public GameObject tributeContainer;
public GameObject advanceImage;
public GameObject demandTributeImage;
public GameObject withdrawImage;
public GameObject safePassageImage;
public Image goldIcon;
public Image foodIcon;
public Slider goldSlider;
public Slider foodSlider;
public TMP_Text goldLabel;
@@ -135,10 +140,21 @@ namespace eagle {
}
}
ConfigureToggle(advanceToggle, enableAttack);
ConfigureToggle(withdrawToggle, enableWithdraw);
ConfigureToggle(demandTributeToggle, enableDemandTribute);
ConfigureToggle(safePassageToggle, enableSafePassage);
advanceToggle.enabled = enableAttack;
advanceToggle.gameObject.SetActive(enableAttack);
advanceImage.SetActive(enableAttack);
withdrawToggle.enabled = enableWithdraw;
withdrawToggle.gameObject.SetActive(enableWithdraw);
withdrawImage.SetActive(enableWithdraw);
demandTributeToggle.enabled = enableDemandTribute;
demandTributeToggle.gameObject.SetActive(enableDemandTribute);
demandTributeImage.SetActive(enableDemandTribute);
safePassageToggle.enabled = enableSafePassage;
safePassageToggle.gameObject.SetActive(enableSafePassage);
safePassageImage.SetActive(enableSafePassage);
goldSlider.minValue = 0;
goldSlider.maxValue = maxGoldTribute;
@@ -186,7 +202,14 @@ namespace eagle {
foodLabel.text = ((int)newValue).ToString();
}
private void EnableSliders(bool newEnabled) { tributeContainer.SetActive(newEnabled); }
private void EnableSliders(bool newEnabled) {
goldIcon.gameObject.SetActive(newEnabled);
foodIcon.gameObject.SetActive(newEnabled);
goldSlider.gameObject.SetActive(newEnabled);
foodSlider.gameObject.SetActive(newEnabled);
goldLabel.gameObject.SetActive(newEnabled);
foodLabel.gameObject.SetActive(newEnabled);
}
public void ToggleChanged(bool value) {
bool enableSliders =
@@ -195,24 +218,5 @@ namespace eagle {
EnableSliders(enableSliders);
}
private const float DisabledAlpha = 0.35f;
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
if (!available) { toggle.isOn = false; }
toggle.group = available ? decisionToggleGroup : null;
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
}
}
}
@@ -23,7 +23,6 @@ namespace eagle {
public Toggle AdvanceToggle;
public Toggle WithdrawToggle;
public ToggleGroup decisionToggleGroup;
private FreeForAllDecisionAvailableCommand FreeForAllDecisionCommand =>
_availableCommand.FreeForAllDecisionCommand;
@@ -64,32 +63,10 @@ namespace eagle {
row.Origin = _model.Provinces[army.OriginProvinceId].Name;
});
bool withdrawAvailable = FreeForAllDecisionCommand.AvailableDecisions.Any(
x => x.SealedValueCase == AttackDecisionType.SealedValueOneofCase.Withdraw);
ConfigureToggle(AdvanceToggle, true);
ConfigureToggle(WithdrawToggle, withdrawAvailable);
AdvanceToggle.isOn = true;
}
private const float DisabledAlpha = 0.35f;
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
if (!available) { toggle.isOn = false; }
toggle.group = available ? decisionToggleGroup : null;
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
AdvanceToggle.enabled = true;
WithdrawToggle.enabled = FreeForAllDecisionCommand.AvailableDecisions.Any(
x => x.SealedValueCase == AttackDecisionType.SealedValueOneofCase.Withdraw);
}
// Use this for initialization
@@ -15,7 +15,12 @@ namespace eagle {
public Toggle ExileToggle;
public Toggle ExecuteToggle;
public Toggle ReturnToggle;
public ToggleGroup decisionToggleGroup;
public RawImage RecruitIcon;
public RawImage ImprisonIcon;
public RawImage ExileIcon;
public RawImage ExecuteIcon;
public RawImage ReturnIcon;
public HeroDetailsController heroDetails;
@@ -90,54 +95,51 @@ namespace eagle {
messageUpdater.TextId = SelectedHeroWithOptions.MessageId;
var options = SelectedHeroWithOptions.Options;
ConfigureToggle(
RecruitToggle,
options.Contains(CapturedHeroOption.RecruitCapturedHeroOption));
ConfigureToggle(
ImprisonToggle,
options.Contains(CapturedHeroOption.ImprisonCapturedHeroOption));
ConfigureToggle(
ExileToggle,
options.Contains(CapturedHeroOption.ExileCapturedHeroOption));
ConfigureToggle(
// The order here is important: the last available toggle in this list
// will be on by default
ConditionallyEnable(
ExecuteToggle,
options.Contains(CapturedHeroOption.ExecuteCapturedHeroOption));
ConfigureToggle(
ExecuteIcon,
CapturedHeroOption.ExecuteCapturedHeroOption);
ConditionallyEnable(ExileToggle, ExileIcon, CapturedHeroOption.ExileCapturedHeroOption);
ConditionallyEnable(
ReturnToggle,
options.Contains(CapturedHeroOption.ReturnCapturedHeroOption));
// Select first available option (priority order: Recruit > Imprison > Exile > Execute >
// Return)
if (options.Contains(CapturedHeroOption.RecruitCapturedHeroOption))
RecruitToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ImprisonCapturedHeroOption))
ImprisonToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ExileCapturedHeroOption))
ExileToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ExecuteCapturedHeroOption))
ExecuteToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ReturnCapturedHeroOption))
ReturnToggle.isOn = true;
ReturnIcon,
CapturedHeroOption.ReturnCapturedHeroOption);
ConditionallyEnable(
ImprisonToggle,
ImprisonIcon,
CapturedHeroOption.ImprisonCapturedHeroOption);
ConditionallyEnable(
RecruitToggle,
RecruitIcon,
CapturedHeroOption.RecruitCapturedHeroOption);
}
private const float DisabledAlpha = 0.35f;
private bool ConditionallyEnable(Toggle toggle, CapturedHeroOption option) {
bool enable = SelectedHeroWithOptions.Options.Contains(option);
toggle.gameObject.SetActive(enable);
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
if (enable) {
ExecuteToggle.isOn = false;
ExileToggle.isOn = false;
ImprisonToggle.isOn = false;
RecruitToggle.isOn = false;
ReturnToggle.isOn = false;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
toggle.isOn = true;
} else
toggle.isOn = false;
if (!available) { toggle.isOn = false; }
return enable;
}
toggle.group = available ? decisionToggleGroup : null;
private void ConditionallyEnable(Toggle toggle, RawImage img, CapturedHeroOption option) {
bool enable = ConditionallyEnable(toggle, option);
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
Color color = img.color;
color.a = enable ? 1.0f : 0.25f;
img.color = color;
}
}
}
@@ -21,7 +21,12 @@ namespace eagle {
public Toggle ExileToggle;
public Toggle ExecuteToggle;
public Toggle ReturnToggle;
public ToggleGroup decisionToggleGroup;
public RawImage ReleaseIcon;
public RawImage MoveIcon;
public RawImage ExileIcon;
public RawImage ExecuteIcon;
public RawImage ReturnIcon;
public TMP_Dropdown moveToDropdown;
@@ -71,33 +76,6 @@ namespace eagle {
public override string CommitWarningText =>
$"This will create a treaty with {_model.FactionName(SelectedHero.FactionId.Value)}";
private List<PrisonerManagementOption> MoveOptions =>
SelectedPrisonerToManage.AvailableOptions
.Where(opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Move)
.ToList();
protected override ProvinceId? TargetedProvince {
get {
if (MoveToggle.isOn && moveToDropdown.value >= 0 &&
moveToDropdown.value < MoveOptions.Count) {
return MoveOptions[moveToDropdown.value].Move.ToProvinceId;
}
return null;
}
set {
if (!value.HasValue) return;
var moveOptions = MoveOptions;
int index = moveOptions.FindIndex(opt => opt.Move.ToProvinceId == value.Value);
if (index != -1) {
MoveToggle.isOn = true;
moveToDropdown.value = index;
moveToDropdown.gameObject.SetActive(true);
}
}
}
private PrisonerManagementOption SelectedOption {
get {
if (ExecuteToggle.isOn)
@@ -165,43 +143,28 @@ namespace eagle {
MessageText.text = GetQuestMessagesForPrisoner(SelectedHero.Id);
var options = SelectedPrisonerToManage.AvailableOptions;
bool hasRelease = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Release);
bool hasMove = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Move);
bool hasExile = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Exile);
bool hasExecute = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Execute);
bool hasReturn = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Return);
ConfigureToggle(ReleaseToggle, hasRelease);
ConfigureToggle(MoveToggle, hasMove);
ConfigureToggle(ExileToggle, hasExile);
ConfigureToggle(ExecuteToggle, hasExecute);
ConfigureToggle(ReturnToggle, hasReturn);
// Select first available option (priority order: Release > Move > Exile > Execute >
// Return)
if (hasRelease) ReleaseToggle.isOn = true;
else if (hasMove)
MoveToggle.isOn = true;
else if (hasExile)
ExileToggle.isOn = true;
else if (hasExecute)
ExecuteToggle.isOn = true;
else if (hasReturn)
ReturnToggle.isOn = true;
// Update dropdown visibility based on selected toggle
moveToDropdown.gameObject.SetActive(MoveToggle.isOn);
// The order here is important: the last available toggle in this list
// will be on by default
ConditionallyEnable(
ExecuteToggle,
ExecuteIcon,
PrisonerManagementOption.SealedValueOneofCase.Execute);
ConditionallyEnable(
ExileToggle,
ExileIcon,
PrisonerManagementOption.SealedValueOneofCase.Exile);
ConditionallyEnable(
ReturnToggle,
ReturnIcon,
PrisonerManagementOption.SealedValueOneofCase.Return);
ConditionallyEnable(
MoveToggle,
MoveIcon,
PrisonerManagementOption.SealedValueOneofCase.Move);
ConditionallyEnable(
ReleaseToggle,
ReleaseIcon,
PrisonerManagementOption.SealedValueOneofCase.Release);
}
private string GetQuestMessagesForPrisoner(HeroId prisonerId) {
@@ -262,23 +225,36 @@ namespace eagle {
: "A free hero";
}
private const float DisabledAlpha = 0.35f;
private bool ConditionallyEnable(
Toggle toggle,
PrisonerManagementOption.SealedValueOneofCase option) {
bool enable = SelectedPrisonerToManage.AvailableOptions.Any(
opt => opt.SealedValueCase == option);
toggle.gameObject.SetActive(enable);
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
if (enable) {
ExecuteToggle.isOn = false;
ExileToggle.isOn = false;
MoveToggle.isOn = false;
ReleaseToggle.isOn = false;
ReturnToggle.isOn = false;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
toggle.isOn = true;
} else
toggle.isOn = false;
if (!available) { toggle.isOn = false; }
return enable;
}
toggle.group = available ? decisionToggleGroup : null;
private void ConditionallyEnable(
Toggle toggle,
RawImage img,
PrisonerManagementOption.SealedValueOneofCase option) {
bool enable = ConditionallyEnable(toggle, option);
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
Color color = img.color;
color.a = enable ? 1.0f : 0.25f;
img.color = color;
}
}
}
@@ -77,10 +77,6 @@ namespace eagle {
public Button gameIdButton;
public Button nextActiveProvinceButton;
[Header("Tutorial Narrative")]
public NarrativeScreenController narrativeScreenController;
private bool _narrativeScreensShown = false;
[Header("Commands")]
public MouseHandler mouseHandlerPrefab;
public GameObject commandPanel;
@@ -528,29 +524,6 @@ namespace eagle {
}
}
/// <summary>
/// Shows tutorial narrative screens before gameplay.
/// After all screens are dismissed, proceeds to battle if one is waiting.
/// </summary>
private void ShowNarrativeScreens(IList<TutorialNarrativeScreen> screens) {
if (narrativeScreenController == null) {
Debug.LogWarning("EagleGameController: NarrativeScreenController not assigned");
OnNarrativeComplete();
return;
}
narrativeScreenController.Show(screens, OnNarrativeComplete);
}
/// <summary>
/// Called when narrative screens are complete.
/// Proceeds to tutorial battle or normal game flow.
/// </summary>
private void OnNarrativeComplete() {
// If there's a battle waiting, go directly to it
if (Model != null && Model.RunningShardokGameModels.Count > 0) { GoToBattle(); }
}
void SetHeaderString() {
var date = Model.CurrentDate;
if (date != null) {
@@ -610,18 +583,9 @@ namespace eagle {
return;
}
// Check for tutorial narrative screens on first model load
if (oldModel == null && !_narrativeScreensShown &&
Model.PendingNarrativeScreens != null && Model.PendingNarrativeScreens.Count > 0) {
_narrativeScreensShown = true;
ShowNarrativeScreens(Model.PendingNarrativeScreens);
return; // Wait for narrative to complete before proceeding
}
// Start onboarding after first model update (UI panels are now populated)
// Skip onboarding if we had narrative screens (tutorial battle mode)
if (oldModel == null && TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted && !_narrativeScreensShown) {
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
}
@@ -59,11 +59,6 @@ namespace eagle {
public List<ProvinceId> ProvincesForFaction(FactionId factionId);
public List<ChronicleEntry> ChronicleEntries { get; }
/// <summary>
/// Narrative screens to display before gameplay (tutorial intro).
/// </summary>
public IList<TutorialNarrativeScreen> PendingNarrativeScreens { get; }
}
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
@@ -206,9 +201,6 @@ namespace eagle {
public List<ChronicleEntry> ChronicleEntries { get; set; }
public IList<TutorialNarrativeScreen> PendingNarrativeScreens =>
GsView.PendingNarrativeScreens;
public List<AvailableCommand> AvailableCommandsForProvince(ProvinceId pid) {
if (AvailableCommandsByProvince.TryGetValue(pid, out var cmds)) {
return cmds.Commands.ToList();
@@ -9,11 +9,6 @@ namespace eagle {
public class FreeHeroesTableController : MonoBehaviour {
public GameObject panel;
[Header("Hero Backstory Popup")]
public GameObject popupPanel;
public HeroDetailsController popupPanelDetailsController;
public GeneratedTextUpdater popupPanelBackstory;
private ProvinceView _currentProvince;
public ProvinceView CurrentProvince {
get => _currentProvince;
@@ -78,21 +73,6 @@ namespace eagle {
UpdateUnaffiliatedHeroSelections();
}
public void LongHoverRowChanged(int? newIndex) {
if (popupPanel == null) return;
if (newIndex is {} index) {
var heroId = UnaffiliatedHeroes.ElementAt(index).HeroId;
if (Model.Heroes.TryGetValue(heroId, out var hero)) {
popupPanelDetailsController.SetHero(hero, Model);
popupPanelBackstory.TextId = hero.BackstoryTextId;
popupPanel.SetActive(true);
}
} else {
popupPanel.SetActive(false);
}
}
private void setUpUnaffiliatedHeroesTable() {
unaffiliatedHeroesTable.RowCount = UnaffiliatedHeroCount;
@@ -108,10 +88,7 @@ namespace eagle {
});
}
void Start() {
panel.SetActive(Model != null);
if (popupPanel != null) { popupPanel.SetActive(false); }
}
void Start() { panel.SetActive(Model != null); }
private bool HasProvinceInfo =>
(CurrentProvince != null && CurrentProvince.FullInfo != null);
@@ -33,6 +33,7 @@ RectTransform:
m_Children:
- {fileID: 1571934834221197146}
m_Father: {fileID: 13469637781584011}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -106,23 +107,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 0
m_enableWordWrapping: 0
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -192,6 +190,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4705461973370478516}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -285,6 +284,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3691368921260708877}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -379,6 +379,7 @@ RectTransform:
m_Children:
- {fileID: 409974592681485098}
m_Father: {fileID: 13469637781584011}
m_RootOrder: 5
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -452,23 +453,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 0
m_enableWordWrapping: 0
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -539,6 +537,7 @@ RectTransform:
- {fileID: 8905938057092819695}
- {fileID: 4705461973370478516}
m_Father: {fileID: 6123654153464171526}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -624,6 +623,7 @@ RectTransform:
- {fileID: 6100877526870815819}
- {fileID: 5909934266303401356}
m_Father: {fileID: 3691368921260708877}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -687,7 +687,7 @@ GameObject:
- component: {fileID: 1571934834221197146}
- component: {fileID: 2314867006400566416}
m_Layer: 0
m_Name: 'TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]'
m_Name: TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
@@ -706,6 +706,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3080737000364722689}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -753,6 +754,7 @@ RectTransform:
- {fileID: 13469637781584011}
- {fileID: 6123654154484763561}
m_Father: {fileID: 6123654153464171526}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -816,7 +818,7 @@ GameObject:
- component: {fileID: 7991069078033516274}
- component: {fileID: 3709131301875944532}
m_Layer: 0
m_Name: 'TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]'
m_Name: TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
@@ -835,6 +837,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1392924472004276419}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -860,7 +863,7 @@ GameObject:
- component: {fileID: 2443631178969516575}
- component: {fileID: 8616701387306228446}
m_Layer: 0
m_Name: 'TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]'
m_Name: TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
@@ -879,6 +882,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4523044671415516144}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -904,7 +908,7 @@ GameObject:
- component: {fileID: 7240827529169758877}
- component: {fileID: 4768305797197202640}
m_Layer: 0
m_Name: 'TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]'
m_Name: TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
@@ -923,6 +927,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 107607736864462239}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -970,6 +975,7 @@ RectTransform:
m_Children:
- {fileID: 7240827529169758877}
m_Father: {fileID: 13469637781584011}
m_RootOrder: 3
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1043,23 +1049,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 0
m_enableWordWrapping: 0
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -1112,7 +1115,6 @@ GameObject:
- component: {fileID: 7083373564964204245}
- component: {fileID: 1611950019350954987}
- component: {fileID: 7571393407091744620}
- component: {fileID: 839825112791048530}
m_Layer: 0
m_Name: Unaffiliated Hero Row Prefab
m_TagString: Untagged
@@ -1135,6 +1137,7 @@ RectTransform:
- {fileID: 3691368921260708877}
- {fileID: 7084089121705905319}
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 0, y: 1}
@@ -1221,6 +1224,7 @@ MonoBehaviour:
conLabel: {fileID: 663501340024978645}
archeryImage: {fileID: 6373503588455539415}
fireImage: {fileID: 7060661334717899661}
extinguishImage: {fileID: 0}
--- !u!114 &1611950019350954987
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -1279,30 +1283,6 @@ MonoBehaviour:
m_ChildScaleWidth: 0
m_ChildScaleHeight: 0
m_ReverseArrangement: 0
--- !u!114 &839825112791048530
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6123654153464171525}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 73953ac09da84b2d96cb90a2ab37ed79, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TableRowHoverDetector
onHoverBegin:
m_PersistentCalls:
m_Calls: []
onHoverEnd:
m_PersistentCalls:
m_Calls: []
onLongHoverBegin:
m_PersistentCalls:
m_Calls: []
onLongHoverEnd:
m_PersistentCalls:
m_Calls: []
--- !u!1 &6123654153502973338
GameObject:
m_ObjectHideFlags: 0
@@ -1336,6 +1316,7 @@ RectTransform:
m_Children:
- {fileID: 6123654154611511663}
m_Father: {fileID: 13469637781584011}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1409,23 +1390,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -1474,7 +1452,7 @@ GameObject:
- component: {fileID: 6123654154152031040}
- component: {fileID: 6123654154152031042}
m_Layer: 0
m_Name: 'TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]'
m_Name: TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
@@ -1493,6 +1471,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6123654154484763561}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1539,6 +1518,7 @@ RectTransform:
m_Children:
- {fileID: 6123654154152031040}
m_Father: {fileID: 7084089121705905319}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1613,23 +1593,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 25
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -1658,7 +1635,7 @@ GameObject:
- component: {fileID: 6123654154611511663}
- component: {fileID: 6123654154611511657}
m_Layer: 0
m_Name: 'TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]'
m_Name: TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
@@ -1677,6 +1654,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6123654153502973339}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -1724,6 +1702,7 @@ RectTransform:
m_Children:
- {fileID: 7991069078033516274}
m_Father: {fileID: 13469637781584011}
m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1797,23 +1776,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 0
m_enableWordWrapping: 0
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -1884,6 +1860,7 @@ RectTransform:
m_Children:
- {fileID: 2443631178969516575}
m_Father: {fileID: 13469637781584011}
m_RootOrder: 4
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -1957,23 +1934,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 0
m_enableWordWrapping: 0
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -2022,7 +1996,7 @@ GameObject:
- component: {fileID: 409974592681485098}
- component: {fileID: 3950145718335139402}
m_Layer: 0
m_Name: 'TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]'
m_Name: TMP SubMeshUI [Irkel Atlas Material + LiberationSans SDF Atlas]
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
@@ -2041,6 +2015,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 6859780550973135875}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -2087,6 +2062,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 4705461973370478516}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -2185,6 +2161,7 @@ RectTransform:
- {fileID: 4523044671415516144}
- {fileID: 6859780550973135875}
m_Father: {fileID: 7084089121705905319}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -345,34 +345,18 @@ namespace eagle {
var details = quest.Details.DevelopProvincesQuest;
var targetMonths = quest.ComponentCount;
var currentMonths = quest.ComponentsFulfilled;
var currentProvinces =
model.PlayerId.HasValue
? model.ProvincesForFaction(model.PlayerId.Value)
.Count(pid => model.Provinces[pid]
.FullInfo?.ProvinceOrders ==
ProvinceOrderType.Develop)
: 0;
var statusText = currentProvinces >= details.TargetProvinceCount
? "currently on target"
: "not currently meeting";
return $"Maintain {details.TargetProvinceCount} provinces with Develop orders for {targetMonths} months ({currentMonths}/{targetMonths} months, {statusText})";
var progressText =
currentMonths > 0 ? $" ({currentMonths}/{targetMonths} months)" : "";
return $"Maintain {details.TargetProvinceCount} provinces with Develop orders for {targetMonths} months{progressText}";
}
case SealedValueOneofCase.MobilizeProvincesQuest: {
var details = quest.Details.MobilizeProvincesQuest;
var targetMonths = quest.ComponentCount;
var currentMonths = quest.ComponentsFulfilled;
var currentProvinces =
model.PlayerId.HasValue
? model.ProvincesForFaction(model.PlayerId.Value)
.Count(pid => model.Provinces[pid]
.FullInfo?.ProvinceOrders ==
ProvinceOrderType.Mobilize)
: 0;
var statusText = currentProvinces >= details.TargetProvinceCount
? "currently on target"
: "not currently meeting";
return $"Maintain {details.TargetProvinceCount} provinces with Mobilize orders for {targetMonths} months ({currentMonths}/{targetMonths} months, {statusText})";
var progressText =
currentMonths > 0 ? $" ({currentMonths}/{targetMonths} months)" : "";
return $"Maintain {details.TargetProvinceCount} provinces with Mobilize orders for {targetMonths} months{progressText}";
}
case SealedValueOneofCase.BattalionDiversityQuest:
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using HexCell = HexGrid.CellGeometry;
@@ -39,19 +38,12 @@ public class HexMesh : MonoBehaviour {
hexMesh.RecalculateNormals();
if (needsSetSharedMesh && triangles.Count > 0) {
// Defer mesh collider assignment to next frame to avoid PhysX cooking
// errors during initial setup
StartCoroutine(SetMeshColliderNextFrame());
if (needsSetSharedMesh) {
meshCollider.sharedMesh = hexMesh;
needsSetSharedMesh = false;
}
}
private IEnumerator SetMeshColliderNextFrame() {
yield return null; // Wait one frame
if (meshCollider != null && hexMesh != null) { meshCollider.sharedMesh = hexMesh; }
}
private void Triangulate(HexCell cell) {
Vector3 center = new Vector3(
cell.AnchoredPosition.x,
@@ -74,21 +74,6 @@ namespace Shardok {
public Button endTurnButton;
public TMP_Text locationNameText;
public TMP_Text roundInfoText;
[Header("Layout Variants")]
public GameObject topRowContainer;
public GameObject leftColumnContainer;
public TMP_Text turnStatusLabelAlt;
public TMP_Text locationNameTextAlt;
public TMP_Text roundInfoTextAlt;
public TMP_Text connectionStatusLabelAlt;
private double _aspectRatio;
private const double WideScreenThreshold = 2.0;
private bool IsWideScreen => _aspectRatio > WideScreenThreshold;
private int _lastWidth;
private int _lastHeight;
public TMP_Text gameOverText;
public Canvas gameOverCanvas;
public AudioSource audioClipSource;
@@ -259,10 +244,7 @@ namespace Shardok {
commandWarningPanel.SetActive(false);
}
void Update() {
HandleButton();
UpdateLayoutForAspectRatio();
}
void Update() { HandleButton(); }
public void EndTurn() {
endTurnButton.interactable = false;
@@ -548,7 +530,7 @@ namespace Shardok {
if (Model.GameStatus != null &&
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
SetTurnStatusText("Game Over!");
turnStatusLabel.text = "Game Over!";
gameOverText.text = Model.GameStatus.Description;
gameOverCanvas.gameObject.SetActive(true);
@@ -557,7 +539,7 @@ namespace Shardok {
endTurnButton.interactable = true;
} else if (Model.GameStatus != null && Model.MyTurn) {
gameOverCanvas.gameObject.SetActive(false);
SetTurnStatusText("Your Turn");
turnStatusLabel.text = "Your Turn";
if (Model.InSetUp) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
@@ -592,20 +574,19 @@ namespace Shardok {
SelectAppropriateDefaultCommand();
} else {
SetTurnStatusText($"{Model.CurrentPlayerName}'s Turn");
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
}
SetLocationNameText(Model.LocationName);
locationNameText.text = Model.LocationName;
if (Model.History.Count > 0) {
string monthString = new DateTime(777, Model.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
string roundText = $"{monthString} {Model.CurrentRound}";
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
Weather weather = Model.Weather;
if (weather != null) {
roundText += ", " + ProtoExtensions.WeatherToString(weather);
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
}
SetRoundInfoText(roundText);
// Update weather visual effects
if (weatherEffectAnimator != null) { weatherEffectAnimator.SetWeather(weather); }
@@ -2211,50 +2192,5 @@ namespace Shardok {
int gridRow = Model.Map.RowCount - 1 - mapCoords.Row;
return gridRow * Model.Map.ColumnCount + mapCoords.Column;
}
#region Layout Variant Helpers
private void SetTurnStatusText(string text) {
turnStatusLabel.text = text;
if (turnStatusLabelAlt != null) turnStatusLabelAlt.text = text;
}
private void SetLocationNameText(string text) {
locationNameText.text = text;
if (locationNameTextAlt != null) locationNameTextAlt.text = text;
}
private void SetRoundInfoText(string text) {
roundInfoText.text = text;
if (roundInfoTextAlt != null) roundInfoTextAlt.text = text;
}
private void SetConnectionStatusText(string text) {
connectionStatusLabel.text = text;
if (connectionStatusLabelAlt != null) connectionStatusLabelAlt.text = text;
}
private void UpdateLayoutForAspectRatio() {
var newWidth = Screen.width;
var newHeight = Screen.height;
if (newWidth == _lastWidth && newHeight == _lastHeight) { return; }
_lastWidth = newWidth;
_lastHeight = newHeight;
_aspectRatio = (double)newWidth / newHeight;
if (IsWideScreen) {
// Widescreen: use left column layout
if (topRowContainer != null) topRowContainer.SetActive(false);
if (leftColumnContainer != null) leftColumnContainer.SetActive(true);
} else {
// Normal/narrow: use top row layout
if (topRowContainer != null) topRowContainer.SetActive(true);
if (leftColumnContainer != null) leftColumnContainer.SetActive(false);
}
}
#endregion
}
}
@@ -34,9 +34,6 @@ namespace Eagle0.Tutorial {
// Register Shardok (tactical battle) tutorials
RegisterShardokTutorials(registry);
// Register tutorial battle tutorials (scripted first-time battle)
RegisterTutorialBattleTutorials(registry);
// Register lobby tutorials (pre-game)
RegisterLobbyTutorials(registry);
}
@@ -565,8 +562,7 @@ namespace Eagle0.Tutorial {
"command_issue_orders",
"Issue Orders",
"Set standing orders for your provinces.\n\n" +
"Orders determine how provinces behave each turn - prioritize military, economy, or defense.\n\n" +
"You can also designate a <b>Focus Province</b> where your vassals will send excess supplies.");
"Orders determine how provinces behave each turn - prioritize military, economy, or defense. You can also designate a Focus Province for special attention.");
registry.RegisterTutorial(issueOrders, "command_panel_IssueOrdersCommand");
// Control Weather command
@@ -1105,59 +1101,6 @@ namespace Eagle0.Tutorial {
return sequence;
}
// ========== TUTORIAL BATTLE TUTORIALS ==========
// These appear during the scripted tutorial battle for first-time players.
private static void RegisterTutorialBattleTutorials(TutorialTriggerRegistry registry) {
// Tutorial battle started - special intro for the scripted battle
var battleStarted = CreateSingleStepTutorial(
"tutorial_battle_started",
"Defend Your Home!",
"The traitor Ikhaan Tarn attacks your province with his veterans. " +
"Your small garrison is outnumbered, but you must hold the line.\n\n" +
"Fight bravely! Position your units wisely and look for opportunities " +
"to strike at the enemy's weak points.\n\n" +
"<color=#FFCC00>Help is on the way...</color>",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(battleStarted, "tutorial_battle_started");
// Tutorial enemy fled - when Tarn flees the battle
var enemyFled = CreateSingleStepTutorial(
"tutorial_enemy_fled",
"The Enemy Retreats!",
"Ikhaan Tarn has fled the battlefield! Your brave defense has " +
"bought precious time.\n\n" +
"Perhaps he realized that you are more formidable than he expected, " +
"or perhaps something else has scared him off...",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(enemyFled, "tutorial_enemy_fled");
// Tutorial reinforcements arrived - when heroes arrive to help
var reinforcementsArrived = CreateSingleStepTutorial(
"tutorial_reinforcements_arrived",
"Allies Arrive!",
"Reinforcements have arrived just in time! " +
"<b>Elena Fyar</b>, <b>Hedrick</b>, and <b>The Boulder</b> have joined " +
"your forces.\n\n" +
"These heroes heard of your stand against the traitor and rode hard " +
"to aid you. They will now fight under your banner.\n\n" +
"<color=#00FF00>Check your reserves - new units are available!</color>",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(reinforcementsArrived, "tutorial_reinforcements_arrived");
// Tutorial battle victory - when the player wins the tutorial battle
var battleVictory = CreateSingleStepTutorial(
"tutorial_battle_victory",
"Victory!",
"You have defended your home against the traitor's attack!\n\n" +
"The heroes who came to your aid have sworn to serve you. " +
"With their help, you can now begin building your power and " +
"eventually bring Ikhaan Tarn to justice.\n\n" +
"<b>Your journey as a ruler begins now.</b>",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(battleVictory, "tutorial_battle_victory");
}
// ========== PRE-GAME TUTORIALS ==========
// These appear before the user enters a game (sign-in, lobby).
@@ -558,14 +558,6 @@ namespace Eagle0.Tutorial {
case ActionType.CrossWaterFailed:
OnGameEvent("terrain_water_encountered", result);
break;
// Tutorial battle events
case ActionType.TutorialEnemyFled:
OnGameEvent("tutorial_enemy_fled", result);
break;
case ActionType.TutorialReinforcementsArrived:
OnGameEvent("tutorial_reinforcements_arrived", result);
break;
}
}
@@ -1,238 +0,0 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Controller for displaying narrative screens before tutorial battles.
/// Shows story screens with title, body text, and optional hero portraits.
/// Chains through multiple screens before proceeding to gameplay.
/// </summary>
public class NarrativeScreenController : MonoBehaviour {
[Header("Content")]
[Tooltip("Title text")]
public TextMeshProUGUI TitleText;
[Tooltip("Body text")]
public TextMeshProUGUI BodyText;
[Tooltip("Optional hero portrait or scene image")]
public Image SceneImage;
[Tooltip("Container for the scene image")]
public GameObject SceneImageContainer;
[Header("Buttons")]
[Tooltip("Continue button")]
public Button ContinueButton;
[Tooltip("Text on continue button")]
public TextMeshProUGUI ContinueButtonText;
[Tooltip("Skip button to skip all narrative")]
public Button SkipButton;
[Header("Progress")]
[Tooltip("Progress indicator (e.g., dots or page numbers)")]
public TextMeshProUGUI ProgressText;
[Header("Background")]
[Tooltip("Background panel container")]
public GameObject PanelContainer;
[Tooltip("Fade overlay for transitions")]
public CanvasGroup FadeOverlay;
[Header("Animation")]
[Tooltip("Animator for screen transitions")]
public Animator ScreenAnimator;
[Tooltip("Fade duration in seconds")]
public float FadeDuration = 0.3f;
// Internal state
private List<TutorialNarrativeScreen> _screens;
private int _currentScreenIndex;
private Action _onComplete;
private bool _isTransitioning;
private void Awake() {
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
if (SkipButton != null) { SkipButton.onClick.AddListener(OnSkipClicked); }
Hide();
}
/// <summary>
/// Shows the narrative screen sequence.
/// </summary>
/// <param name="screens">List of narrative screens to display</param>
/// <param name="onComplete">Callback when all screens are dismissed</param>
public void Show(IEnumerable<TutorialNarrativeScreen> screens, Action onComplete) {
_screens = new List<TutorialNarrativeScreen>(screens);
_onComplete = onComplete;
_currentScreenIndex = 0;
if (_screens.Count == 0) {
_onComplete?.Invoke();
return;
}
gameObject.SetActive(true);
if (PanelContainer != null) { PanelContainer.SetActive(true); }
DisplayCurrentScreen();
}
/// <summary>
/// Displays the current screen in the sequence.
/// </summary>
private void DisplayCurrentScreen() {
if (_currentScreenIndex >= _screens.Count) {
CompleteNarrative();
return;
}
var screen = _screens[_currentScreenIndex];
// Set title
if (TitleText != null) { TitleText.text = screen.Title ?? ""; }
// Set body text
if (BodyText != null) {
BodyText.text = screen.BodyText ?? "";
LayoutRebuilder.ForceRebuildLayoutImmediate(BodyText.GetComponent<RectTransform>());
}
// Set scene image if provided
if (SceneImageContainer != null) {
bool hasImage = !string.IsNullOrEmpty(screen.ImagePath);
SceneImageContainer.SetActive(hasImage);
if (hasImage && SceneImage != null) {
// Load image from resources
var sprite = Resources.Load<Sprite>(screen.ImagePath);
if (sprite != null) {
SceneImage.sprite = sprite;
} else {
Debug.LogWarning(
$"NarrativeScreenController: Could not load image at '{screen.ImagePath}'");
SceneImageContainer.SetActive(false);
}
}
}
// Update button text based on whether this is the last screen
if (ContinueButtonText != null) {
bool isLastScreen = _currentScreenIndex >= _screens.Count - 1;
ContinueButtonText.text = isLastScreen ? "Begin Battle" : "Continue";
}
// Update progress indicator
if (ProgressText != null) {
if (_screens.Count > 1) {
ProgressText.text = $"{_currentScreenIndex + 1} / {_screens.Count}";
ProgressText.gameObject.SetActive(true);
} else {
ProgressText.gameObject.SetActive(false);
}
}
// Trigger show animation if available
if (ScreenAnimator != null) { ScreenAnimator.SetTrigger("Show"); }
}
/// <summary>
/// Called when Continue button is clicked.
/// </summary>
private void OnContinueClicked() {
if (_isTransitioning) return;
_currentScreenIndex++;
if (_currentScreenIndex >= _screens.Count) {
CompleteNarrative();
} else {
// Animate transition to next screen
StartTransition(() => DisplayCurrentScreen());
}
}
/// <summary>
/// Called when Skip button is clicked.
/// </summary>
private void OnSkipClicked() {
if (_isTransitioning) return;
CompleteNarrative();
}
/// <summary>
/// Starts a transition animation between screens.
/// </summary>
private void StartTransition(Action onTransitionComplete) {
if (FadeOverlay == null || FadeDuration <= 0) {
onTransitionComplete?.Invoke();
return;
}
_isTransitioning = true;
StartCoroutine(TransitionCoroutine(onTransitionComplete));
}
private System.Collections.IEnumerator TransitionCoroutine(Action onComplete) {
// Fade out
float elapsed = 0f;
while (elapsed < FadeDuration) {
elapsed += Time.deltaTime;
FadeOverlay.alpha = Mathf.Lerp(0f, 1f, elapsed / FadeDuration);
yield return null;
}
FadeOverlay.alpha = 1f;
// Call the transition action
onComplete?.Invoke();
// Fade in
elapsed = 0f;
while (elapsed < FadeDuration) {
elapsed += Time.deltaTime;
FadeOverlay.alpha = Mathf.Lerp(1f, 0f, elapsed / FadeDuration);
yield return null;
}
FadeOverlay.alpha = 0f;
_isTransitioning = false;
}
/// <summary>
/// Completes the narrative sequence and invokes the callback.
/// </summary>
private void CompleteNarrative() {
var callback = _onComplete;
Hide();
callback?.Invoke();
}
/// <summary>
/// Hides the narrative screen.
/// </summary>
public void Hide() {
if (ScreenAnimator != null) { ScreenAnimator.SetTrigger("Hide"); }
if (PanelContainer != null) { PanelContainer.SetActive(false); }
gameObject.SetActive(false);
_screens = null;
_onComplete = null;
_currentScreenIndex = 0;
_isTransitioning = false;
}
/// <summary>
/// Checks if the controller is currently showing screens.
/// </summary>
public bool IsShowing => gameObject.activeSelf && _screens != null && _screens.Count > 0;
}
}
@@ -9,7 +9,7 @@ PlayerSettings:
AndroidFilterTouchesWhenObscured: 0
AndroidEnableSustainedPerformanceMode: 0
defaultScreenOrientation: 4
targetDevice: 1
targetDevice: 2
useOnDemandResources: 0
accelerometerFrequency: 60
companyName: Shardok Games
@@ -349,7 +349,6 @@ message CreateGameRequest {
string desired_leader_text_id = 1;
int32 total_player_count = 2;
int32 human_player_count = 3;
bool is_tutorial_mode = 4;
}
message CreateGameResponse {
@@ -61,7 +61,6 @@ message QuestDetails {
ReconProvincesQuest recon_provinces_quest = 44;
RepairDevastationQuest repair_devastation_quest = 45;
ReconSpecificProvincesQuest recon_specific_provinces_quest = 46;
StartDroughtQuest start_drought_quest = 47;
}
}
@@ -222,10 +221,6 @@ message StartEpidemicQuest {
int32 province_id = 1;
}
message StartDroughtQuest {
int32 province_id = 1;
}
message SpendOnFeastsQuest {
int32 total_gold = 1;
}
@@ -52,22 +52,6 @@ message SetFaction {
int32 earliest_round_for_invitation = 5;
}
// Configuration for tutorial battle that triggers at game start
message TutorialBattleConfig {
string attacker_faction_head = 1; // Faction head who attacks
int32 target_province_id = 2; // Province to attack
int32 flee_after_defender_units_lost = 3; // Attacker flees after defender loses N units
int32 flee_after_rounds = 4; // Attacker flees after N rounds
repeated string reinforcements = 5; // Hero names for reinforcements when attacker flees
}
// Narrative screen shown before tutorial battle
message TutorialNarrativeScreen {
string title = 1;
string body_text = 2;
string image_path = 3;
}
message GameParameters {
string map_file_path = 2;
@@ -75,8 +59,4 @@ message GameParameters {
ProvinceOverrides occupied_province_overrides = 4;
repeated SetFaction set_factions = 5;
// Tutorial-specific configuration
TutorialBattleConfig tutorial_battle = 6;
repeated TutorialNarrativeScreen tutorial_narrative_screens = 7;
}
@@ -22,13 +22,6 @@ option java_package = "net.eagle0.eagle.views";
option java_outer_classname = "GameStateView";
option objc_class_prefix = "E0G";
// Narrative screen for tutorial and story sequences
message TutorialNarrativeScreen {
string title = 1;
string body_text = 2;
string image_path = 3; // Optional hero portrait or scene image
}
message GameStateView {
int32 current_round_id = 1;
.net.eagle0.eagle.common.RoundPhase current_phase = 2;
@@ -50,9 +43,6 @@ message GameStateView {
repeated .net.eagle0.eagle.common.BattalionType battalion_types = 12;
repeated .net.eagle0.eagle.common.ChronicleEntry chronicle_entries = 14;
// Tutorial narrative screens to display before gameplay
repeated TutorialNarrativeScreen pending_narrative_screens = 15;
}
message GameStateViewDiff {
@@ -1,113 +0,0 @@
{
"mapFilePath": "/net/eagle0/eagle/province_map.tsv",
"genericProvinceOverrides": {
"economy": 20.0,
"agriculture": 20.0,
"infrastructure": 20.0,
"specialBattalionTypes": []
},
"occupiedProvinceOverrides": {
"randomRulingPlayerHeroCount": 0,
"economy": 40.0,
"agriculture": 40.0,
"infrastructure": 45.0,
"gold": 500,
"food": 3000,
"support": 40,
"orders": "ENTRUST",
"specialBattalionTypes": [],
"battalions": [
{
"type": "LIGHT_INFANTRY",
"size": 300,
"training": 60,
"armament": 60,
"name": "Ranil's Spearmen"
},
{
"type": "LIGHT_INFANTRY",
"size": 300,
"training": 60,
"armament": 60,
"name": "Ranil's Guard"
},
{
"type": "LONGBOWMEN",
"size": 200,
"training": 60,
"armament": 60,
"name": "Ranil's Archers"
}
]
},
"setFactions": [
{
"factionHeadName": "Ikhaan Tarn",
"swornBrotherNames": [],
"occupiedProvinces": [
{
"provinceId": 32,
"economy": 85.0,
"agriculture": 91.0,
"infrastructure": 66.0,
"gold": 4400,
"food": 5500,
"support": 35,
"orders": "ENTRUST",
"rulingPlayerHeroNames": ["Ikhaan Tarn"],
"randomRulingPlayerHeroCount": 2,
"specialBattalionTypes": [],
"battalions": [
{
"type": "HEAVY_CAVALRY",
"size": 400,
"training": 80,
"armament": 80,
"name": "Tarn's Knights"
},
{
"type": "HEAVY_INFANTRY",
"size": 500,
"training": 80,
"armament": 80,
"name": "Tarn's Stormtroopers"
},
{
"type": "LONGBOWMEN",
"size": 300,
"training": 80,
"armament": 80,
"name": "Tarn's Sharpshooters"
}
]
}
],
"startingTrusts": [],
"earliestRoundForInvitation": 999
}
],
"tutorialBattle": {
"attackerFactionHead": "Ikhaan Tarn",
"targetProvinceId": 14,
"fleeAfterDefenderUnitsLost": 1,
"fleeAfterRounds": 5,
"reinforcements": ["Elena Fyar", "Hedrick", "The Boulder"]
},
"tutorialNarrativeScreens": [
{
"title": "The Engineer of Onmaa",
"bodyText": "You are John Ranil, an engineer who once served the crown. When civil war erupted, you retreated to Onmaa, your ancestral home, hoping to wait out the conflict in peace.",
"imagePath": ""
},
{
"title": "The Traitor Arrives",
"bodyText": "The traitor Ikhaan Tarn has arrived at Onmaa with his army. He once served King Bregos Fyar but betrayed him to join The Eagle, a mysterious mage from distant lands. Now Tarn seeks to expand his new master's domain.",
"imagePath": ""
},
{
"title": "Defend Your Home",
"bodyText": "Against impossible odds, you must defend your home. Your small garrison is no match for Tarn's veterans, but you must hold the line until help arrives. Fight bravely, engineer.",
"imagePath": ""
}
]
}
@@ -58,28 +58,4 @@ object ApiKeys {
lazy val anthropic: String = getKey(anthropicKeyName, anthropicEnvVar)
lazy val gemini: String = getKey(geminiKeyName, geminiEnvVar)
/** Check if OpenAI API key is available without throwing */
def hasOpenAI: Boolean =
Option(System.getenv(openAIEnvVar)).exists(_.nonEmpty) ||
dictionary.get(openAIKeyName).exists(v => v.nonEmpty && !v.startsWith("your_"))
/** Check if Anthropic API key is available without throwing */
def hasAnthropic: Boolean =
Option(System.getenv(anthropicEnvVar)).exists(_.nonEmpty) ||
dictionary.get(anthropicKeyName).exists(v => v.nonEmpty && !v.startsWith("your_"))
/** Check if Gemini API key is available without throwing */
def hasGemini: Boolean =
Option(System.getenv(geminiEnvVar)).exists(_.nonEmpty) ||
dictionary.get(geminiKeyName).exists(v => v.nonEmpty && !v.startsWith("your_"))
/** Get list of providers with valid API keys configured */
def availableProviders: Vector[String] =
Vector("openai", "claude", "gemini").filter {
case "openai" => hasOpenAI
case "claude" => hasAnthropic
case "gemini" => hasGemini
case _ => false
}
}
@@ -3,14 +3,6 @@ load("@rules_scala//scala:scala.bzl", "scala_binary", "scala_library")
scala_library(
name = "api_keys",
srcs = ["ApiKeys.scala"],
visibility = ["//visibility:public"],
deps = [],
)
scala_library(
name = "external_text_generation_error",
srcs = ["ExternalTextGenerationError.scala"],
visibility = ["//visibility:public"],
deps = [],
)
@@ -68,11 +60,7 @@ scala_library(
visibility = [
"//visibility:public",
],
exports = [
":external_text_generation_error",
],
deps = [
":external_text_generation_error",
":external_text_generation_service_impl",
":rate_limits",
":streaming_text_results",
@@ -18,6 +18,16 @@ import net.eagle0.common.sse.OkHttpSseListener
import okhttp3.{MediaType, OkHttpClient, Request, RequestBody}
import okhttp3.sse.EventSources
enum ExternalTextGenerationError extends Error:
case RateLimit(code: Int, msg: String)
case Http(code: Int, msg: String)
case Timeout(msg: String)
def message: String = this match
case RateLimit(_, msg) => msg
case Http(_, msg) => msg
case Timeout(msg) => msg
object ExternalTextGenerationCaller {
private val initialBackoffSeconds: Double = 2.0
@@ -197,20 +207,16 @@ final class ExternalTextGenerationCaller(
case Failure(exception) => Failure(exception)
}.recoverWith {
// Don't retry 4xx client errors - they won't succeed
case e: ExternalTextGenerationError.Http =>
Future.failed(e)
// Don't retry 5xx server errors at caller level - let LlmResolver handle failover
case e: ExternalTextGenerationError.ServerError =>
println(s"Server error ${e.code} - not retrying, will failover to another provider")
case e: ExternalTextGenerationError.Http =>
Future.failed(e)
// Retry transient errors (timeouts, network issues)
case e: ExternalTextGenerationError.Timeout =>
case e: ExternalTextGenerationError.Timeout =>
println(s"Timeout error - retrying: ${e.message}")
tryAgain()
case e: IOException =>
case e: IOException =>
println(s"IOException - retrying: ${e.getMessage}")
tryAgain()
case e: Throwable =>
case e: Throwable =>
println(s"error ${e.getMessage} - retrying")
tryAgain()
}
@@ -1,13 +0,0 @@
package net.eagle0.common.llm_integration
enum ExternalTextGenerationError extends Error:
case RateLimit(code: Int, msg: String)
case Http(code: Int, msg: String)
case ServerError(code: Int, msg: String) // 5xx errors - should trigger provider failover
case Timeout(msg: String)
def message: String = this match
case RateLimit(_, msg) => msg
case Http(_, msg) => msg
case ServerError(_, msg) => msg
case Timeout(msg) => msg
@@ -24,7 +24,6 @@ scala_library(
srcs = ["OkHttpSseListener.scala"],
visibility = ["//visibility:public"],
deps = [
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_error",
"@maven//:com_squareup_okhttp3_okhttp",
"@maven//:com_squareup_okhttp3_okhttp_sse",
],
@@ -3,7 +3,6 @@ package net.eagle0.common.sse
import java.util.concurrent.CompletableFuture
import java.util.function.Consumer
import net.eagle0.common.llm_integration.ExternalTextGenerationError
import okhttp3.sse.{EventSource, EventSourceListener}
import okhttp3.Response
@@ -59,28 +58,13 @@ class OkHttpSseListener(messageDataConsumer: Consumer[String]) extends EventSour
} else {
println(s"SSE failure: $errorMessage")
}
// Create appropriate exception based on response code
val exception = if response != null && response.code() >= 500 then {
// 5xx server errors - should trigger provider failover
ExternalTextGenerationError.ServerError(
response.code(),
s"Server error ${response.code()}: $errorMessage"
)
} else if response != null && response.code() >= 400 then {
// 4xx client errors
ExternalTextGenerationError.Http(
response.code(),
s"HTTP error ${response.code()}: $errorMessage"
)
} else if t != null then {
t
} else {
new RuntimeException(
s"SSE failed with response code ${Option(response).map(_.code().toString).getOrElse("unknown")}"
)
}
val _ = future.completeExceptionally(exception)
// OkHttp can pass null throwable for HTTP error responses
val exception =
if t != null then t
else
new RuntimeException(
s"SSE failed with response code ${Option(response).map(_.code().toString).getOrElse("unknown")}"
)
val _ = future.completeExceptionally(exception)
}
}
@@ -2,6 +2,7 @@ package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.library.settings.FeastGoldCostPerHero
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.FeastAvailable
import net.eagle0.eagle.model.state.game_state.GameState
@@ -9,7 +10,17 @@ import net.eagle0.eagle.model.state.province.ProvinceT
object AvailableFeastCommandFactory extends ScalaAvailableCommandsFactory {
private def feastGoldCost(province: ProvinceT): Int =
private def hasHeroThatBenefits(
gameState: GameState,
province: ProvinceT
): Boolean = {
val factions = gameState.factions.values.toVector
province.rulingFactionHeroIds
.flatMap(hid => gameState.heroes.get(hid))
.exists(h => h.vigor < h.constitution || HeroUtils.effectiveLoyalty(h, factions) < 100.0)
}
def feastGoldCost(province: ProvinceT): Int =
(province.rulingFactionHeroIds.length.toDouble * province.priceIndex * FeastGoldCostPerHero.intValue).ceil.toInt
override def availableCommand(
@@ -21,7 +32,7 @@ object AvailableFeastCommandFactory extends ScalaAvailableCommandsFactory {
val goldCost = feastGoldCost(province)
Option.when(
province.gold >= goldCost
province.gold >= goldCost && hasHeroThatBenefits(gameState, province)
) {
FeastAvailable(
goldCost = goldCost,
@@ -92,7 +92,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/library/settings:portion_of_capacity_for_upgrade_battalion_quest",
"//src/main/scala/net/eagle0/eagle/library/util/battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:quest_fulfillment_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
@@ -1226,7 +1225,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_random_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util:returning_heroes",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:quest_fulfillment_utils",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:province_view_filter",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
# Transitive deps of action_result_concrete (needed because ActionResultC has fields of these types)
@@ -1242,10 +1240,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/province:incoming_end_turn_action",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
@@ -27,7 +27,6 @@ import net.eagle0.eagle.model.state.quest.{
ReturnPrisonerQuest,
SendSuppliesQuest,
StartBlizzardQuest,
StartDroughtQuest,
StartEpidemicQuest,
SwearBrotherhoodWithHeroQuest,
TruceCountQuest,
@@ -176,17 +175,15 @@ case class CheckForFailedQuestsAction(
case _: StartBlizzardQuest =>
// Never fails - if a blizzard already exists, quest creation should not offer this
false
case _: StartDroughtQuest =>
// Never fails - if a drought already exists, quest creation should not offer this
false
case _: StartEpidemicQuest =>
// Never fails - if an epidemic already exists, quest creation should not offer this
false
case SendSuppliesQuest(_, _, targetProvinceId, _) =>
// Fails if we no longer own the target province
!provinces.exists(p =>
p.id == targetProvinceId &&
p.rulingFactionId == province.rulingFactionId
// Fails if target province is no longer owned by an enemy faction
val targetProvince = provinces.find(_.id == targetProvinceId)
targetProvince.forall(tp =>
tp.rulingFactionId.isEmpty ||
tp.rulingFactionId == province.rulingFactionId
)
case RestProvinceQuest(_, _, targetProvinceId) =>
// Fails if we no longer own the target province
@@ -4,7 +4,6 @@ import net.eagle0.eagle.{BattalionId, FactionId, GameId, HeroId, ProvinceId, Rou
import net.eagle0.eagle.library.actions.impl.common.ProtolessSequentialResultsAction
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, PortionOfCapacityForUpgradeBattalionQuest}
import net.eagle0.eagle.library.util.battalion_type_finder.BattalionTypeFinder
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentUtils
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
@@ -42,7 +41,6 @@ import net.eagle0.eagle.model.state.quest.{
ReturnPrisonerQuest,
SpecificExpansionQuest,
StartBlizzardQuest,
StartDroughtQuest,
StartEpidemicQuest,
SuppressRiotByForceQuest,
SwearBrotherhoodWithHeroQuest,
@@ -206,16 +204,11 @@ case class CheckForFulfilledQuestsAction(
case _: BetrayAllyQuest =>
false // action quest - fulfilled in BreakAllianceResolutionHelpers
case BorderSecurityQuest(targetProvinceId) =>
// Fulfilled when all neighbors of the target province are controlled by the faction or an ally
// Fulfilled when all neighbors of the target province are controlled by the faction
provinceWithId(targetProvinceId).exists { targetProvince =>
targetProvince.neighbors.forall { neighbor =>
provinceWithId(neighbor.provinceId).exists { neighborProvince =>
neighborProvince.rulingFactionId == province.rulingFactionId ||
(for {
questFactionId <- province.rulingFactionId
neighborFactionId <- neighborProvince.rulingFactionId
} yield FactionUtils.hasAlliance(questFactionId, neighborFactionId, factions))
.getOrElse(false)
neighborProvince.rulingFactionId == province.rulingFactionId
}
}
}
@@ -223,8 +216,6 @@ case class CheckForFulfilledQuestsAction(
false // action quest - fulfilled in ResolveBattleAction
case _: StartBlizzardQuest =>
false // action quest - fulfilled in ControlWeatherCommand
case _: StartDroughtQuest =>
false // action quest - fulfilled in ControlWeatherCommand
case _: StartEpidemicQuest =>
false // action quest - fulfilled in ControlWeatherCommand
// Note: WinBattlesQuest is handled by ComponentQuest case above
@@ -1,12 +1,9 @@
package net.eagle0.eagle.library.actions.impl.action
import scala.annotation.unused
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialResultsAction
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentUtils
import net.eagle0.eagle.library.util.view_filters.ProvinceViewFilter
import net.eagle0.eagle.library.util.ReturningHeroes
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
@@ -14,9 +11,7 @@ import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFact
import net.eagle0.eagle.model.action_result.types.ActionResultType.{EndReconResolutionPhase, ReconSucceeded}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.{IncomingEndTurnAction, IncomingRecon, ProvinceT}
import net.eagle0.eagle.model.state.quest.{ReconProvincesQuest, ReconSpecificProvincesQuest}
import net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroT
import net.eagle0.eagle.model.state.province.{IncomingEndTurnAction, IncomingRecon}
import net.eagle0.eagle.model.state.RoundPhase
import net.eagle0.eagle.ProvinceId
@@ -69,9 +64,6 @@ case class PerformReconResolutionAction(
// Extract heroId from the recon action details
val heroId = incomingEndTurnAction.details.asInstanceOf[IncomingRecon].heroId
val factionProvinces = gameState.provinces.values.toVector
.filter(_.rulingFactionId.contains(fromFactionId))
ReturningHeroes
.heroesReturningToFaction(
hids = Vector(heroId),
@@ -81,7 +73,7 @@ case class PerformReconResolutionAction(
functionalRandom = functionalRandom
)
.map { returningHeroes =>
val baseResult = ActionResultC(
ActionResultC(
actionResultType = ReconSucceeded,
changedHeroes = returningHeroes.changedHeroes,
changedProvinces = Vector(
@@ -111,52 +103,6 @@ case class PerformReconResolutionAction(
)
}.toVector
)
// Increment recon quest counters
val withReconProvincesQuestIncrement =
reconProvincesQuestIncrementer(factionProvinces)(baseResult)
reconSpecificProvincesQuestIncrementer(factionProvinces, provinceId)(withReconProvincesQuestIncrement)
}
}
private def reconProvincesQuestCheck(
uh: UnaffiliatedHeroT,
@unused province: ProvinceT
): Boolean =
uh.quest.exists {
case q: ReconProvincesQuest => q.componentsFulfilled < q.componentCount
case _ => false
}
private def reconProvincesQuestIncrementer(
factionProvinces: Vector[ProvinceT]
): ActionResultT => ActionResultT =
QuestFulfillmentUtils.withCountersIncremented(
provinces = factionProvinces,
incrementAmount = 1,
check = reconProvincesQuestCheck
)
private def reconSpecificProvincesQuestCheck(
reconnedProvinceId: ProvinceId
)(
uh: UnaffiliatedHeroT,
@unused province: ProvinceT
): Boolean =
uh.quest.exists {
case q: ReconSpecificProvincesQuest =>
q.componentsFulfilled < q.componentCount &&
q.targetProvinceIds.contains(reconnedProvinceId)
case _ => false
}
private def reconSpecificProvincesQuestIncrementer(
factionProvinces: Vector[ProvinceT],
reconnedProvinceId: ProvinceId
): ActionResultT => ActionResultT =
QuestFulfillmentUtils.withCountersIncremented(
provinces = factionProvinces,
incrementAmount = 1,
check = reconSpecificProvincesQuestCheck(reconnedProvinceId)
)
}
@@ -421,7 +421,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
"//src/main/scala/net/eagle0/eagle/library/settings:vigor_gain_from_feast",
"//src/main/scala/net/eagle0/eagle/library/util:price_index_utils",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:quest_fulfillment_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
@@ -435,8 +434,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
@@ -683,7 +680,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:improvement_support_gain",
"//src/main/scala/net/eagle0/eagle/library/settings:improvement_xp_per_stat",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:quest_fulfillment_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -701,8 +697,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
@@ -1141,7 +1135,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_simple_action",
"//src/main/scala/net/eagle0/eagle/library/settings:rest_vigor_gain",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:quest_fulfillment_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -1156,8 +1149,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
],
)
@@ -212,8 +212,7 @@ class CommandFactory extends TCommandFactory {
currentRoundId = gameState.currentRoundId,
allProvinces = allProvinces(gameState),
factions = allFactions(gameState),
battalions = allBattalions(gameState),
heroes = gameState.heroes.values.toVector
battalions = allBattalions(gameState)
)
case (ac: ExileVassalAvailable, sc: ExileVassalSelected) =>
ExileVassalCommand.make(
@@ -227,13 +226,12 @@ class CommandFactory extends TCommandFactory {
)
case (ac: FeastAvailable, _: FeastSelected.type) =>
val province = gameState.provinces(ac.actingProvinceId)
val faction = gameState.factions(actingFactionId)
FeastCommand.make(
actingFactionId = actingFactionId,
province = province,
rulingFactionHeroes = province.rulingFactionHeroIds
.map(gameState.heroes),
factionLeaderIds = faction.leaderIds,
factionLeaderIds = gameState.factions(actingFactionId).leaderIds,
goldCost = ac.goldCost
)
case (ac: FreeForAllDecisionAvailable, sc: FreeForAllDecisionSelected) =>
@@ -325,9 +323,7 @@ class CommandFactory extends TCommandFactory {
improvementType = improvementTypeMap(sc.improvementType),
availableHeroIds = ac.availableHeroIds,
availableImprovementTypes = ac.availableTypes.map(improvementTypeMap),
lockImprovementType = sc.lockType,
factionProvinces = gameState.provinces.values.toVector
.filter(_.rulingFactionId.contains(actingFactionId))
lockImprovementType = sc.lockType
)
case (ac: IssueOrdersAvailable, sc: IssueOrdersSelected) =>
IssueOrdersCommand.make(
@@ -649,9 +645,7 @@ class CommandFactory extends TCommandFactory {
RestCommand.make(
actingFactionId = actingFactionId,
provinceId = ac.actingProvinceId,
factionHeroesInProvince = factionHeroesInProvince(gameState, ac.actingProvinceId),
factionProvinces = gameState.provinces.values.toVector
.filter(_.rulingFactionId.contains(actingFactionId))
factionHeroesInProvince = factionHeroesInProvince(gameState, ac.actingProvinceId)
)
case (ac: ReturnAvailable, _: ReturnSelected.type) =>
ReturnCommand.make(
@@ -18,7 +18,6 @@ import net.eagle0.eagle.model.action_result.NotificationDetails.Divined
import net.eagle0.eagle.model.action_result.NotificationT.Llm
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.quest.{
AlmsToProvinceQuest,
@@ -29,8 +28,6 @@ import net.eagle0.eagle.model.state.quest.{
ImproveInfrastructureQuest,
SpecificExpansionQuest,
StartBlizzardQuest,
StartDroughtQuest,
StartEpidemicQuest,
TruceWithFactionQuest
}
import net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroT
@@ -50,8 +47,7 @@ object DivineCommand {
currentRoundId: RoundId,
allProvinces: Vector[ProvinceT],
factions: Vector[FactionT],
battalions: Vector[BattalionT],
heroes: Vector[HeroT]
battalions: Vector[BattalionT]
): ProtolessRandomSimpleAction = {
commandRequire(
selectedHeroIds.distinct.size == selectedHeroIds.size,
@@ -83,8 +79,7 @@ object DivineCommand {
currentRoundId = currentRoundId,
allProvinces = allProvinces,
factions = factions,
battalions = battalions,
heroes = heroes
battalions = battalions
)
}
@@ -96,8 +91,7 @@ object DivineCommand {
currentRoundId: RoundId,
allProvinces: Vector[ProvinceT],
factions: Vector[FactionT],
battalions: Vector[BattalionT],
heroes: Vector[HeroT]
battalions: Vector[BattalionT]
) extends ProtolessRandomSimpleAction {
override def immediateExecute(
functionalRandom: FunctionalRandom
@@ -121,7 +115,6 @@ object DivineCommand {
allProvinces = allProvinces,
factions = factions,
battalions = battalions,
heroes = heroes,
functionalRandom = fr
)
.map { newRI =>
@@ -185,10 +178,6 @@ object DivineCommand {
Vector(provinceId)
case StartBlizzardQuest(provinceId) =>
Vector(provinceId)
case StartDroughtQuest(provinceId) =>
Vector(provinceId)
case StartEpidemicQuest(provinceId) =>
Vector(provinceId)
}
.getOrElse(Vector())
@@ -3,7 +3,6 @@ package net.eagle0.eagle.library.actions.impl.command
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.library.actions.impl.common.ProtolessSimpleAction
import net.eagle0.eagle.library.settings.{LoyaltyGainFromFeast, VigorGainFromFeast}
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentUtils
import net.eagle0.eagle.library.util.PriceIndexUtils
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedHeroC, StatDelta, StatNoChange}
@@ -11,7 +10,6 @@ import net.eagle0.eagle.model.action_result.types.ActionResultType.Feast
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.quest.SpendOnFeastsQuest
object FeastCommand {
def make(
@@ -58,43 +56,18 @@ object FeastCommand {
)
)
private def changedProvinces: Vector[ChangedProvinceC] = {
// Increment SpendOnFeastsQuest progress by goldCost for unaffiliated hero in this province
val questProgressChange = QuestFulfillmentUtils.counterIncrementedCP(
province = province,
incrementAmount = goldCost,
check = (uh, _) =>
uh.quest.exists {
case q: SpendOnFeastsQuest => q.componentsFulfilled < q.componentCount
case _ => false
}
private def changedProvinces: Vector[ChangedProvinceC] =
Vector(
ChangedProvinceC(
provinceId = province.id,
goldDelta = Some(-goldCost),
newPriceIndex = PriceIndexUtils.maybeNewPriceIndexFromGoldSpend(
currentPriceIndex = province.priceIndex,
goldSpent = goldCost
)
)
)
questProgressChange match {
case Some(cp) =>
Vector(
cp.copy(
goldDelta = Some(-goldCost),
newPriceIndex = PriceIndexUtils.maybeNewPriceIndexFromGoldSpend(
currentPriceIndex = province.priceIndex,
goldSpent = goldCost
)
)
)
case None =>
Vector(
ChangedProvinceC(
provinceId = province.id,
goldDelta = Some(-goldCost),
newPriceIndex = PriceIndexUtils.maybeNewPriceIndexFromGoldSpend(
currentPriceIndex = province.priceIndex,
goldSpent = goldCost
)
)
)
}
}
override def immediateExecute: ActionResultT =
ActionResultC(
actionResultType = Feast,
@@ -1,7 +1,5 @@
package net.eagle0.eagle.library.actions.impl.command
import scala.annotation.unused
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.library.actions.impl.common.ProtolessSimpleAction
import net.eagle0.eagle.library.settings.{
@@ -12,7 +10,6 @@ import net.eagle0.eagle.library.settings.{
ImprovementSupportGain,
ImprovementXpPerStat
}
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentUtils
import net.eagle0.eagle.library.util.EagleRequire.commandRequire
import net.eagle0.eagle.model.action_result.changed_province.concrete.{ChangedProvinceC, NewLockedImprovementType}
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedHeroC, StatDelta}
@@ -20,8 +17,6 @@ import net.eagle0.eagle.model.action_result.types.ActionResultType.Improve
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.hero.{HeroT, Profession}
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.quest.RepairDevastationQuest
import net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroT
import net.eagle0.eagle.model.state.ImprovementType
object ImproveCommand {
@@ -32,8 +27,7 @@ object ImproveCommand {
improvementType: ImprovementType,
lockImprovementType: Boolean,
availableHeroIds: Vector[HeroId],
availableImprovementTypes: Vector[ImprovementType],
factionProvinces: Vector[ProvinceT]
availableImprovementTypes: Vector[ImprovementType]
): ProtolessSimpleAction = {
commandRequire(
availableHeroIds.contains(actingHero.id),
@@ -50,8 +44,7 @@ object ImproveCommand {
actingHero = actingHero,
province = actingProvince,
improvementType = improvementType,
lockType = lockImprovementType,
factionProvinces = factionProvinces
lockType = lockImprovementType
)
}
@@ -60,8 +53,7 @@ object ImproveCommand {
province: ProvinceT,
actingHero: HeroT,
improvementType: ImprovementType,
lockType: Boolean,
factionProvinces: Vector[ProvinceT]
lockType: Boolean
) extends ProtolessSimpleAction {
private val maxImprovement = 100.0
@@ -138,7 +130,7 @@ object ImproveCommand {
agilityXpDelta = Some(ImprovementXpPerStat.intValue)
)
val baseResult = ActionResultC(
ActionResultC(
actionResultType = Improve,
actingFactionId = Some(factionId),
provinceId = Some(provinceId),
@@ -146,33 +138,8 @@ object ImproveCommand {
changedHeroes = Vector(leaderAfter),
changedProvinces = Vector(changedProvince)
)
// Increment RepairDevastationQuest progress when repairing devastation
improvementType match {
case ImprovementType.Devastation =>
repairDevastationQuestIncrementer(withProfessionBonus.toInt)(baseResult)
case _ => baseResult
}
}
private def repairDevastationQuestCheck(
uh: UnaffiliatedHeroT,
@unused province: ProvinceT
): Boolean =
uh.quest.exists {
case q: RepairDevastationQuest => q.componentsFulfilled < q.componentCount
case _ => false
}
private def repairDevastationQuestIncrementer(
devastationRepaired: Int
): ActionResultT => ActionResultT =
QuestFulfillmentUtils.withCountersIncremented(
provinces = factionProvinces,
incrementAmount = devastationRepaired,
check = repairDevastationQuestCheck
)
private def ImprovementFromZero(actor: HeroT): Double =
ImprovementPerStat.doubleValue * (actor.strength + actor.agility) / 2.0
@@ -1,25 +1,18 @@
package net.eagle0.eagle.library.actions.impl.command
import scala.annotation.unused
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.library.actions.impl.common.ProtolessSimpleAction
import net.eagle0.eagle.library.settings.RestVigorGain
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentUtils
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedHeroC, StatDelta}
import net.eagle0.eagle.model.action_result.types.ActionResultType.Rest
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.quest.RestProvinceQuest
import net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroT
object RestCommand {
private case class RestCommand(
factionId: FactionId,
provinceId: ProvinceId,
factionHeroesInProvince: Vector[HeroT],
factionProvinces: Vector[ProvinceT]
factionHeroesInProvince: Vector[HeroT]
) extends ProtolessSimpleAction {
override def immediateExecute: ActionResultT = {
val restedHeroes = factionHeroesInProvince
@@ -34,46 +27,24 @@ object RestCommand {
)
)
val baseResult = ActionResultC(
ActionResultC(
actionResultType = Rest,
actingFactionId = Some(factionId),
provinceId = Some(provinceId),
provinceIdActed = Some(provinceId),
changedHeroes = restedHeroes
)
// Increment RestProvinceQuest progress when resting in the target province
restProvinceQuestIncrementer(baseResult)
}
private def restProvinceQuestCheck(
uh: UnaffiliatedHeroT,
@unused province: ProvinceT
): Boolean =
uh.quest.exists {
case q: RestProvinceQuest =>
q.targetProvinceId == provinceId && q.componentsFulfilled < q.componentCount
case _ => false
}
private def restProvinceQuestIncrementer: ActionResultT => ActionResultT =
QuestFulfillmentUtils.withCountersIncremented(
provinces = factionProvinces,
incrementAmount = 1,
check = restProvinceQuestCheck
)
}
def make(
actingFactionId: FactionId,
provinceId: ProvinceId,
factionHeroesInProvince: Vector[HeroT],
factionProvinces: Vector[ProvinceT]
factionHeroesInProvince: Vector[HeroT]
): ProtolessSimpleAction =
RestCommand(
factionId = actingFactionId,
provinceId = provinceId,
factionHeroesInProvince = factionHeroesInProvince,
factionProvinces = factionProvinces
factionHeroesInProvince = factionHeroesInProvince
)
}
@@ -318,12 +318,6 @@ case class DivineMessagePromptGenerator(
s"$divinedHeroName wants to see a magical epidemic unleashed upon ${targetProvince.name}."
)
case StartDroughtQuest(targetProvinceId) =>
val targetProvince = gameState.provinces(targetProvinceId)
TextGenerationSuccess(
s"$divinedHeroName wants to see a magical drought unleashed upon ${targetProvince.name}."
)
case SpendOnFeastsQuest(componentCount, _, totalGold) =>
TextGenerationSuccess(
s"$divinedHeroName wants $actingHeroName to host lavish feasts spending $totalGold gold over $componentCount months."
@@ -326,12 +326,6 @@ object QuestEndedGeneratorUtilities {
unaffiliatedHeroName <- unaffiliatedHeroNameResult
} yield s"$unaffiliatedHeroName wanted to see a magical epidemic unleashed upon ${targetProvince.name}."
case StartDroughtQuest(targetProvinceId) =>
val targetProvince = gameState.provinces(targetProvinceId)
for {
unaffiliatedHeroName <- unaffiliatedHeroNameResult
} yield s"$unaffiliatedHeroName wanted to see a magical drought unleashed upon ${targetProvince.name}."
case SpendOnFeastsQuest(componentCount, _, totalGold) =>
for {
unaffiliatedHeroName <- unaffiliatedHeroNameResult
@@ -305,7 +305,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:give_to_heroes_in_province_quest_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:improve_quest_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:quest_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:total_development_quest_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:truce_count_quest_command_chooser",
"//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",
@@ -10,7 +10,6 @@ import net.eagle0.eagle.library.util.command_choice_helpers.quest_command_select
GiveToHeroesInProvinceQuestCommandChooser,
ImproveQuestCommandChooser,
QuestCommandChooser,
TotalDevelopmentQuestCommandChooser,
TruceCountQuestCommandChooser,
TruceWithFactionQuestCommandChooser,
UnaffiliatedHeroWithQuest
@@ -23,16 +22,15 @@ import net.eagle0.eagle.FactionId
object FulfillQuestsCommandSelector {
private val choosers: Vector[QuestCommandChooser] = Vector(
AllianceQuestCommandChooser,
TruceWithFactionQuestCommandChooser,
ImproveQuestCommandChooser,
TotalDevelopmentQuestCommandChooser,
AlmsToProvinceQuestCommandChooser,
GiveToHeroesInProvinceQuestCommandChooser,
AlmsAcrossRealmQuestCommandChooser,
GiveToHeroesAcrossRealmQuestCommandChooser,
TruceCountQuestCommandChooser,
DismissSpecificVassalCommandChooser,
AllianceQuestCommandChooser
DismissSpecificVassalCommandChooser
)
private def choiceFrom(
@@ -191,31 +191,6 @@ scala_library(
],
)
scala_library(
name = "total_development_quest_command_chooser",
srcs = ["TotalDevelopmentQuestCommandChooser.scala"],
visibility = [
"//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 = [
":quest_command_chooser",
],
deps = [
":quest_command_chooser",
":unaffiliated_hero_with_quest",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:improve_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/common",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
],
)
scala_library(
name = "truce_count_quest_command_chooser",
srcs = ["TruceCountQuestCommandChooser.scala"],
@@ -1,69 +0,0 @@
package net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors
import net.eagle0.eagle.library.util.command_choice_helpers.ImproveCommandSelector
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.common.ImprovementType
import net.eagle0.eagle.model.state.command.common.ImprovementType.{Agriculture, Devastation, Economy, Infrastructure}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.quest.TotalDevelopmentQuest
import net.eagle0.eagle.FactionId
object TotalDevelopmentQuestCommandChooser extends DeterministicQuestCommandChooser {
private def currentTotalDevelopment(province: ProvinceT): Double =
ProvinceUtils.effectiveAgriculture(province) +
ProvinceUtils.effectiveEconomy(province) +
ProvinceUtils.effectiveInfrastructure(province)
private def shouldRepairDevastation(province: ProvinceT): Boolean = {
val totalDevastation =
province.economyDevastation + province.agricultureDevastation + province.infrastructureDevastation
totalDevastation >= 4
}
private def lowestImprovementType(province: ProvinceT): ImprovementType = {
val eco = ProvinceUtils.effectiveEconomy(province)
val agri = ProvinceUtils.effectiveAgriculture(province)
val infra = ProvinceUtils.effectiveInfrastructure(province)
if eco <= agri && eco <= infra then Economy
else if agri <= infra then Agriculture
else Infrastructure
}
private def chosenImprovementType(province: ProvinceT): ImprovementType =
if shouldRepairDevastation(province) then Devastation
else lowestImprovementType(province)
override def chosenDeterministicCommand(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
uhsWithQuests: Vector[UnaffiliatedHeroWithQuest]
): Option[CommandSelection] =
uhsWithQuests
.map(uhwq => (uhwq.pid, uhwq.quest))
.collect { case (pid, q: TotalDevelopmentQuest) => (pid, q) }
.filter {
case (pid, quest) =>
val province = gameState.provinces(pid)
// Only try to improve if we haven't reached the target yet
currentTotalDevelopment(province) < quest.desiredTotalDevelopment
}
.flatMap {
case (pid, _) =>
val province = gameState.provinces(pid)
val improvementType = chosenImprovementType(province)
ImproveCommandSelector.chosenSpecificImprovementCommandForProvince(
actingFactionId = actingFactionId,
actingProvinceId = pid,
availableCommands = availableCommands,
improvementType = improvementType
)
}
.headOption
.map(_.withReason("fulfill TotalDevelopment quest"))
}
@@ -27,8 +27,6 @@ import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Ally
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.quest.{
AllianceQuest,
@@ -53,17 +51,12 @@ import net.eagle0.eagle.model.state.quest.{
ImproveInfrastructureQuest,
MobilizeProvincesQuest,
Quest,
ReconProvincesQuest,
ReconSpecificProvincesQuest,
ReleaseAllPrisonersQuest,
ReleasePrisonerQuest,
RepairDevastationQuest,
RescueImprisonedLeaderQuest,
RestProvinceQuest,
ReturnPrisonerQuest,
SendSuppliesQuest,
SpecificExpansionQuest,
SpendOnFeastsQuest,
StartBlizzardQuest,
StartEpidemicQuest,
SuppressRiotByForceQuest,
@@ -120,7 +113,6 @@ object QuestCreationUtils {
allProvinces: Vector[ProvinceT],
factions: Vector[FactionT],
battalions: Vector[BattalionT],
heroes: Vector[HeroT],
functionalRandom: FunctionalRandom
): RandomState[Vector[Quest]] =
functionalRandom.nextFlatMap(
@@ -158,14 +150,9 @@ object QuestCreationUtils {
QuestCreatorFrom(winBattleOutnumberedQuests),
winBattlesQuests,
QuestCreatorFrom(apprehendOutlawQuests),
startBlizzardQuests(heroes),
startEpidemicQuests(heroes),
sendSuppliesQuests,
restProvinceQuests,
reconProvincesQuests,
repairDevastationQuests,
spendOnFeastsQuests,
reconSpecificProvincesQuests
QuestCreatorFrom(startBlizzardQuests),
QuestCreatorFrom(startEpidemicQuests),
sendSuppliesQuests
)
) {
case (questCreator, fr) =>
@@ -922,71 +909,45 @@ object QuestCreationUtils {
.filter(_.unaffiliatedHeroType == Outlaw)
.map(outlaw => ApprehendOutlawQuest(outlawHeroId = outlaw.heroId))
private def startBlizzardQuests(heroes: Vector[HeroT]): QuestCreator =
(
province: ProvinceT,
allProvinces: Vector[ProvinceT],
_: Vector[FactionT],
_: Vector[BattalionT],
functionalRandom: FunctionalRandom
) => {
// Only available if faction has at least one Mage
val hasMage = province.rulingFactionHeroIds.exists { heroId =>
heroes.find(_.id == heroId).exists(_.profession == Profession.Mage)
}
private def startBlizzardQuests(
province: ProvinceT,
allProvinces: Vector[ProvinceT],
@unused factions: Vector[FactionT],
@unused battalions: Vector[BattalionT]
): Vector[Quest] = {
// Target provinces in the faction's territory or adjacent to it that don't have a blizzard
val factionProvinces = FactionUtils.provinces(province.getRulingFactionId, allProvinces)
if !hasMage then RandomState(Vector(), functionalRandom)
else {
// Target provinces in the faction's territory or adjacent to it that don't have a blizzard
val factionProvinces = FactionUtils.provinces(province.getRulingFactionId, allProvinces)
// Get provinces reachable by Control Weather: faction provinces + neighbors of faction provinces
val reachableProvinceIds = factionProvinces.flatMap { fp =>
fp.id +: fp.neighbors.map(_.provinceId)
}.distinct
// Get provinces reachable by Control Weather: faction provinces + neighbors of faction provinces
val reachableProvinceIds = factionProvinces.flatMap { fp =>
fp.id +: fp.neighbors.map(_.provinceId)
}.distinct
reachableProvinceIds
.flatMap(pid => allProvinces.find(_.id == pid))
.filterNot(ProvinceUtils.hasBlizzard)
.map(targetProvince => StartBlizzardQuest(provinceId = targetProvince.id))
}
val quests = reachableProvinceIds
.flatMap(pid => allProvinces.find(_.id == pid))
.filterNot(ProvinceUtils.hasBlizzard)
.map(targetProvince => StartBlizzardQuest(provinceId = targetProvince.id))
private def startEpidemicQuests(
province: ProvinceT,
allProvinces: Vector[ProvinceT],
@unused factions: Vector[FactionT],
@unused battalions: Vector[BattalionT]
): Vector[Quest] = {
// Target provinces in the faction's territory or adjacent to it that don't have an epidemic
val factionProvinces = FactionUtils.provinces(province.getRulingFactionId, allProvinces)
RandomState(quests, functionalRandom)
}
end if
}
// Include faction provinces and their neighbors as potential targets
val targetProvinceIds = factionProvinces.flatMap { fp =>
fp.id +: fp.neighbors.map(_.provinceId)
}.distinct
private def startEpidemicQuests(heroes: Vector[HeroT]): QuestCreator =
(
province: ProvinceT,
allProvinces: Vector[ProvinceT],
_: Vector[FactionT],
_: Vector[BattalionT],
functionalRandom: FunctionalRandom
) => {
// Only available if faction has at least one Necromancer
val hasNecromancer = province.rulingFactionHeroIds.exists { heroId =>
heroes.find(_.id == heroId).exists(_.profession == Profession.Necromancer)
}
if !hasNecromancer then RandomState(Vector(), functionalRandom)
else {
// Target provinces in the faction's territory or adjacent to it that don't have an epidemic
val factionProvinces = FactionUtils.provinces(province.getRulingFactionId, allProvinces)
// Include faction provinces and their neighbors as potential targets
val targetProvinceIds = factionProvinces.flatMap { fp =>
fp.id +: fp.neighbors.map(_.provinceId)
}.distinct
val quests = targetProvinceIds
.flatMap(pid => allProvinces.find(_.id == pid))
.filterNot(ProvinceUtils.hasEpidemic)
.map(targetProvince => StartEpidemicQuest(provinceId = targetProvince.id))
RandomState(quests, functionalRandom)
}
end if
}
targetProvinceIds
.flatMap(pid => allProvinces.find(_.id == pid))
.filterNot(ProvinceUtils.hasEpidemic)
.map(targetProvince => StartEpidemicQuest(provinceId = targetProvince.id))
}
private def sendSuppliesQuests(
province: ProvinceT,
@@ -1019,123 +980,4 @@ object QuestCreationUtils {
}
end if
}
private def restProvinceQuests(
province: ProvinceT,
allProvinces: Vector[ProvinceT],
@unused factions: Vector[FactionT],
@unused battalions: Vector[BattalionT],
functionalRandom: FunctionalRandom
): RandomState[Vector[Quest]] = {
// Get all faction provinces as potential targets
val factionProvinces = FactionUtils.provinces(province.getRulingFactionId, allProvinces)
val minMonths = 2
val maxMonths = 4
functionalRandom.nextIntInclusive(minMonths, maxMonths).map { targetMonths =>
factionProvinces.map { targetProvince =>
RestProvinceQuest(
componentCount = targetMonths,
componentsFulfilled = 0,
targetProvinceId = targetProvince.id
)
}
}
}
private def reconProvincesQuests(
@unused province: ProvinceT,
@unused allProvinces: Vector[ProvinceT],
@unused factions: Vector[FactionT],
@unused battalions: Vector[BattalionT],
functionalRandom: FunctionalRandom
): RandomState[Vector[Quest]] = {
val minProvinces = 3
val maxProvinces = 6
functionalRandom.nextIntInclusive(minProvinces, maxProvinces).map { targetCount =>
Vector(
ReconProvincesQuest(
componentCount = targetCount,
componentsFulfilled = 0,
targetProvinceCount = targetCount
)
)
}
}
private def repairDevastationQuests(
@unused province: ProvinceT,
@unused allProvinces: Vector[ProvinceT],
@unused factions: Vector[FactionT],
@unused battalions: Vector[BattalionT],
functionalRandom: FunctionalRandom
): RandomState[Vector[Quest]] = {
val minDevastation = 30
val maxDevastation = 100
functionalRandom.nextIntInclusive(minDevastation, maxDevastation).map { targetAmount =>
Vector(
RepairDevastationQuest(
componentCount = targetAmount,
componentsFulfilled = 0,
targetDevastationRepaired = targetAmount
)
)
}
}
private def spendOnFeastsQuests(
@unused province: ProvinceT,
@unused allProvinces: Vector[ProvinceT],
@unused factions: Vector[FactionT],
@unused battalions: Vector[BattalionT],
functionalRandom: FunctionalRandom
): RandomState[Vector[Quest]] =
functionalRandom.nextIntInclusive(500, 1000).map { totalGold =>
Vector(
SpendOnFeastsQuest(
componentCount = totalGold,
componentsFulfilled = 0,
totalGold = totalGold
)
)
}
private def reconSpecificProvincesQuests(
province: ProvinceT,
allProvinces: Vector[ProvinceT],
@unused factions: Vector[FactionT],
@unused battalions: Vector[BattalionT],
functionalRandom: FunctionalRandom
): RandomState[Vector[Quest]] = {
// Find provinces not controlled by this faction (good targets for recon)
val nonFactionProvinces = allProvinces
.filterNot(_.rulingFactionId.contains(province.getRulingFactionId))
val minProvinces = 2
val maxProvinces = 4
// Only available if there are enough non-faction provinces
if nonFactionProvinces.size < minProvinces then RandomState(Vector(), functionalRandom)
else {
val cappedMax = Math.min(maxProvinces, nonFactionProvinces.size)
functionalRandom
.nextIntInclusive(minProvinces, cappedMax)
.continue {
case (targetCount, fr) =>
fr.nextRandomSubset(targetCount, nonFactionProvinces).map { selectedProvinces =>
Vector(
ReconSpecificProvincesQuest(
componentCount = targetCount,
componentsFulfilled = 0,
targetProvinceIds = selectedProvinces.map(_.id)
)
)
}
}
}
end if
}
}
@@ -32,7 +32,6 @@ object UnaffiliatedHeroUtils {
allProvinces: Vector[ProvinceT],
factions: Vector[FactionT],
battalions: Vector[BattalionT],
heroes: Vector[HeroT],
functionalRandom: FunctionalRandom
): RandomState[RecruitmentInfo] =
QuestCreationUtils
@@ -41,7 +40,6 @@ object UnaffiliatedHeroUtils {
allProvinces = allProvinces,
factions = factions,
battalions = battalions,
heroes = heroes,
functionalRandom
)
.continue { case (quests, fr) => fr.nextRandomElementOpt(quests) }
@@ -37,7 +37,6 @@ import net.eagle0.eagle.common.unaffiliated_hero_quest.{
SpecificExpansionQuest as SpecificExpansionQuestProto,
SpendOnFeastsQuest as SpendOnFeastsQuestProto,
StartBlizzardQuest as StartBlizzardQuestProto,
StartDroughtQuest as StartDroughtQuestProto,
StartEpidemicQuest as StartEpidemicQuestProto,
SuppressRiotByForceQuest as SuppressRiotByForceQuestProto,
SwearBrotherhoodWithHeroQuest as SwearBrotherhoodWithHeroQuestProto,
@@ -219,8 +218,6 @@ object QuestConverter {
QuestProto(details = StartBlizzardQuestProto(q.provinceId))
case q: StartEpidemicQuest =>
QuestProto(details = StartEpidemicQuestProto(q.provinceId))
case q: StartDroughtQuest =>
QuestProto(details = StartDroughtQuestProto(q.provinceId))
case q: SpendOnFeastsQuest =>
QuestProto(
details = SpendOnFeastsQuestProto(q.totalGold),
@@ -453,8 +450,6 @@ object QuestConverter {
StartBlizzardQuest(provinceId)
case StartEpidemicQuestProto(provinceId, _ /* unknownFieldSet */ ) =>
StartEpidemicQuest(provinceId)
case StartDroughtQuestProto(provinceId, _ /* unknownFieldSet */ ) =>
StartDroughtQuest(provinceId)
case SpendOnFeastsQuestProto(totalGold, _ /* unknownFieldSet */ ) =>
SpendOnFeastsQuest(
componentCount = questProto.componentCount,
@@ -173,8 +173,6 @@ case class StartBlizzardQuest(provinceId: ProvinceId) extends Quest
case class StartEpidemicQuest(provinceId: ProvinceId) extends Quest
case class StartDroughtQuest(provinceId: ProvinceId) extends Quest
case class SpendOnFeastsQuest(
override val componentCount: Int,
override val componentsFulfilled: Int,
@@ -342,9 +342,7 @@ scala_library(
deps = [
":llm_update_queuing_proxy",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/common/llm_integration:api_keys",
"//src/main/scala/net/eagle0/common/llm_integration:claude_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_caller",
"//src/main/scala/net/eagle0/common/llm_integration:external_text_generation_service_impl",
@@ -65,12 +65,11 @@ class EagleServiceImpl(
): Future[CreateGameResponse] =
lockAndUpdateGamesWith(uid =>
gamesManager.createGameForUser(
expandedGameParameters = GameParametersUtils.parametersFor(request.isTutorialMode),
expandedGameParameters = GameParametersUtils.defaultExpandedGameParameters,
userId = uid,
desiredLeaderTextId = request.desiredLeaderTextId,
maxHumanPlayers = request.humanPlayerCount,
totalPlayers = request.totalPlayerCount,
isTutorialMode = request.isTutorialMode
totalPlayers = request.totalPlayerCount
)
)
@@ -68,8 +68,7 @@ case class GameAwaitingPlayers(
existingHumanPlayers: Vector[WaitingGamePlayerInfo],
totalPlayerCount: Int,
humanPlayerCount: Int,
greatPeople: Vector[Hero],
isTutorialMode: Boolean = false
greatPeople: Vector[Hero]
) {
private def claimedNameTextIds: Vector[String] =
existingHumanPlayers.map(_.leaderNameTextId)
@@ -86,8 +85,7 @@ case class GameAwaitingPlayers(
existingHumanPlayers = existingHumanPlayers :+ waitingGamePlayerInfo,
totalPlayerCount = totalPlayerCount,
humanPlayerCount = humanPlayerCount,
greatPeople = greatPeople,
isTutorialMode = isTutorialMode
greatPeople = greatPeople
)
}
@@ -1106,8 +1104,7 @@ class GamesManager(
expandedGameParameters = gap.expandedGameParameters,
gameId = newGap.gameId,
humanPlayers = newGap.existingHumanPlayers,
aiPlayerCount = newGap.totalPlayerCount - newGap.existingHumanPlayers.size,
isTutorialMode = newGap.isTutorialMode
aiPlayerCount = newGap.totalPlayerCount - newGap.existingHumanPlayers.size
): Unit
} else
gamesAwaitingPlayers = gamesAwaitingPlayers
@@ -1146,8 +1143,7 @@ class GamesManager(
userId: UserId,
desiredLeaderTextId: String,
maxHumanPlayers: Int,
totalPlayers: Int,
isTutorialMode: Boolean = false
totalPlayers: Int
): CreateGameResponse =
if maxHumanPlayers > totalPlayers || totalPlayers > MaxSupportedPlayers.intValue
then CreateGameResponse(INVALID_OPTIONS_RESULT, 0)
@@ -1160,8 +1156,7 @@ class GamesManager(
existingHumanPlayers = Vector.empty,
totalPlayerCount = totalPlayers,
humanPlayerCount = maxHumanPlayers,
greatPeople = expandedGameParameters.greatPeopleHeroProtos,
isTutorialMode = isTutorialMode
greatPeople = expandedGameParameters.greatPeopleHeroProtos
)
val result = joinGame(
@@ -1214,8 +1209,7 @@ class GamesManager(
expandedGameParameters: ExpandedGameParameters,
gameId: GameId,
humanPlayers: Vector[WaitingGamePlayerInfo],
aiPlayerCount: Int,
isTutorialMode: Boolean = false
aiPlayerCount: Int
): GameControllerWithPostResults = {
val humanPlayerCount = humanPlayers.size
val initialEar = gameCreation
@@ -1225,8 +1219,7 @@ class GamesManager(
persister = gamePersisterCreation.persisterForGame(gameId),
humanPlayerFactionLeaderNameTextIds = humanPlayers.map(_.leaderNameTextId),
playerCount = humanPlayerCount + aiPlayerCount,
allBattalionTypes = GamesManager.battalionTypes,
isTutorialMode = isTutorialMode
allBattalionTypes = GamesManager.battalionTypes
)
val unrequestedTexts = initialEar.results
@@ -1,22 +1,16 @@
package net.eagle0.eagle.service
import java.io.FileWriter
import java.time.{Duration, Instant}
import java.util.function.Consumer
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Success}
import io.sentry.Sentry
import net.eagle0.common.{FunctionalRandom, RandomState, SimpleTimedLogger}
import net.eagle0.common.llm_integration.{
ApiKeys,
ClaudeServiceImpl,
ExternalTextGenerationCaller,
ExternalTextGenerationError,
GeminiServiceImpl,
OpenAIChatCompletionsServiceImpl,
StreamingTextResults
OpenAIChatCompletionsServiceImpl
}
import net.eagle0.eagle.client_text.{
ClientTextStore,
@@ -93,39 +87,17 @@ object LlmResolver {
gameState: GameState,
partialCompletion: Option[String] = None
)
/** Provider health tracking for circuit breaker pattern */
case class ProviderHealth(
consecutiveFailures: Int = 0,
unhealthySince: Option[Instant] = None
) {
private val unhealthyDurationMinutes = 5
private val failureThreshold = 3
def isHealthy: Boolean = unhealthySince.forall { since =>
Duration.between(since, Instant.now()).toMinutes >= unhealthyDurationMinutes
}
def recordFailure: ProviderHealth = {
val newFailures = consecutiveFailures + 1
if newFailures >= failureThreshold then ProviderHealth(newFailures, Some(Instant.now()))
else ProviderHealth(newFailures, unhealthySince)
}
def recordSuccess: ProviderHealth = ProviderHealth(0, None)
}
}
class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, isGameValid: GameId => Boolean) {
import LlmResolver.{LlmRequestWithGameState, ProviderHealth}
import LlmResolver.LlmRequestWithGameState
implicit val ec: ExecutionContext = ExecutionContext.global
private val callerCount = 2
/** Create callers for a specific provider */
private def createCallersForProvider(provider: String): Vector[ExternalTextGenerationCaller] =
provider.toLowerCase match {
private def createLlmCallers(): Vector[ExternalTextGenerationCaller] =
LlmProvider.stringValue.toLowerCase match {
case "openai" =>
(1 to callerCount).map { _ =>
new ExternalTextGenerationCaller(
@@ -144,102 +116,17 @@ class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, isGameValid: GameId =>
serviceImpl = new GeminiServiceImpl(defaultModelName = GeminiModelName.stringValue)
)
}.toVector
case other =>
throw new IllegalArgumentException(s"Unknown LLM provider: $other")
case provider =>
throw new IllegalArgumentException(s"Unknown LLM provider: $provider")
}
/** Create callers for all available providers (not just primary) */
private def createAllProviderCallers(): Map[String, Vector[ExternalTextGenerationCaller]] = {
val available = ApiKeys.availableProviders
SimpleTimedLogger.printLogger.logLine(s"[LLM] Available providers: ${available.mkString(", ")}")
available.flatMap { provider =>
scala.util.Try(createCallersForProvider(provider)) match {
case scala.util.Success(callers) =>
SimpleTimedLogger.printLogger.logLine(s"[LLM] Created $callerCount callers for provider: $provider")
Some(provider -> callers)
case scala.util.Failure(ex) =>
SimpleTimedLogger.printLogger.logLine(s"[LLM] Failed to create callers for $provider: ${ex.getMessage}")
None
}
}.toMap
}
// Multi-provider caller map
@volatile private var _providerCallersInitialized: Boolean = false
@volatile private var _providerCallers: Map[String, Vector[ExternalTextGenerationCaller]] = Map.empty
private def providerCallers: Map[String, Vector[ExternalTextGenerationCaller]] = {
if !_providerCallersInitialized then {
synchronized {
if !_providerCallersInitialized then {
_providerCallers = createAllProviderCallers()
_providerCallersInitialized = true
}
}
}
_providerCallers
}
// Provider health tracking
@volatile private var _providerHealth: Map[String, ProviderHealth] = Map.empty
private def providerHealth: Map[String, ProviderHealth] = synchronized {
if _providerHealth.isEmpty && providerCallers.nonEmpty then {
_providerHealth = providerCallers.keys.map(_ -> ProviderHealth()).toMap
}
_providerHealth
}
private def recordProviderFailure(provider: String): Unit = synchronized {
val current = providerHealth.getOrElse(provider, ProviderHealth())
val updated = current.recordFailure
_providerHealth = providerHealth.updated(provider, updated)
if !updated.isHealthy then {
SimpleTimedLogger.printLogger.logLine(
s"[LLM] Provider $provider marked unhealthy after ${updated.consecutiveFailures} consecutive failures"
)
}
}
private def recordProviderSuccess(provider: String): Unit = synchronized {
val current = providerHealth.getOrElse(provider, ProviderHealth())
if current.consecutiveFailures > 0 || current.unhealthySince.isDefined then {
SimpleTimedLogger.printLogger.logLine(s"[LLM] Provider $provider recovered")
}
_providerHealth = providerHealth.updated(provider, ProviderHealth())
}
/** Get callers for healthy providers, primary first */
private def getHealthyCallers: Vector[(String, ExternalTextGenerationCaller)] = {
val primary = LlmProvider.stringValue.toLowerCase
val orderedProviders = (primary +: ApiKeys.availableProviders.filterNot(_ == primary)).distinct
orderedProviders
.filter(p => providerHealth.get(p).forall(_.isHealthy))
.flatMap(p => providerCallers.get(p).toVector.flatten.map(c => (p, c)))
}
/** Select a caller with capacity, preferring healthy providers */
private def selectCaller: Option[(String, ExternalTextGenerationCaller)] =
getHealthyCallers.filter { case (_, c) => c.getInProgressCount < c.maxConcurrentStreams }.minByOption {
case (_, c) => c.getInProgressCount
}
/** Select a caller from a provider other than the excluded one */
private def selectCallerExcluding(excludeProvider: String): Option[(String, ExternalTextGenerationCaller)] =
getHealthyCallers.filter {
case (p, c) => p != excludeProvider && c.getInProgressCount < c.maxConcurrentStreams
}.minByOption { case (_, c) => c.getInProgressCount }
// Legacy accessor for compatibility
@volatile private var _llmCallersInitialized: Boolean = false
@volatile private var _llmCallers: Vector[ExternalTextGenerationCaller] = Vector.empty
def llmCallers: Vector[ExternalTextGenerationCaller] = {
if !_llmCallersInitialized then {
synchronized {
if !_llmCallersInitialized then {
// Use the primary provider's callers for backward compatibility
_llmCallers = providerCallers.getOrElse(LlmProvider.stringValue.toLowerCase, Vector.empty)
_llmCallers = createLlmCallers()
_llmCallersInitialized = true
}
}
@@ -248,12 +135,8 @@ class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, isGameValid: GameId =>
}
def recreateLlmCallers(): Unit = synchronized {
_providerCallers = createAllProviderCallers()
_providerCallersInitialized = true
_providerHealth = _providerCallers.keys.map(_ -> ProviderHealth()).toMap
_llmCallers = _providerCallers.getOrElse(LlmProvider.stringValue.toLowerCase, Vector.empty)
_llmCallers = createLlmCallers()
_llmCallersInitialized = true
SimpleTimedLogger.printLogger.logLine("[LLM] Recreated callers for all providers")
}
lazy private val fileWriter =
@@ -350,73 +233,39 @@ class LlmResolver(updateReceiver: LlmUpdateQueuingProxy, isGameValid: GameId =>
)
}
val streamingConsumer: Consumer[StreamingTextResults] = streamingResults =>
updateReceiver.receiveStreamingLlmResponse(
streamId = streamingResults.streamId,
llmRequest = llmRequest,
responseText = streamingResults.value,
completed = streamingResults.completed
)
/** Try to complete the request with a specific provider, with optional failover */
def tryWithProvider(
providerName: String,
caller: ExternalTextGenerationCaller,
excludedProviders: Set[String]
): Future[Unit] =
caller
.streamCompletion(
inputText = prompt,
partialCompletion = partialCompletion,
streamingConsumer = streamingConsumer
)
.transform {
case Success(result) =>
recordProviderSuccess(providerName)
Success(result)
case Failure(e: ExternalTextGenerationError.ServerError) =>
recordProviderFailure(providerName)
Failure(e)
case Failure(e) => Failure(e)
}
.recoverWith {
case e: ExternalTextGenerationError.ServerError =>
// Try failover to another provider
val newExcluded = excludedProviders + providerName
selectCallerExcluding(providerName) match {
case Some((nextProvider, nextCaller)) if !newExcluded.contains(nextProvider) =>
SimpleTimedLogger.printLogger.logLine(
s"[LLM] Provider $providerName returned ${e.code}, failing over to $nextProvider"
)
tryWithProvider(nextProvider, nextCaller, newExcluded)
case _ =>
// No more providers to try, propagate the error
SimpleTimedLogger.printLogger.logLine(
s"[LLM] Provider $providerName returned ${e.code}, no fallback providers available"
)
Future.failed(e)
}
}
selectCaller.map {
case (providerName, chosenCaller) =>
llmCallers
.filter(x => x.getInProgressCount < x.maxConcurrentStreams)
.minByOption(_.getInProgressCount)
.map { chosenCaller =>
log(llmRequest, prompt)
LlmResolverRequested {
tryWithProvider(providerName, chosenCaller, Set.empty).recover {
case ex =>
// Log the failure for debugging and send to Sentry
SimpleTimedLogger.printLogger.logLine(
s"LLM processing failed for request ${llmRequest.requestId}: $ex"
)
ex.printStackTrace()
Sentry.captureException(ex)
// Handle failure by moving request back to unrequested state for retry
updateReceiver.receiveStreamingLlmFailure(llmRequest)
throw ex // Re-throw to maintain error semantics
}
chosenCaller
.streamCompletion(
inputText = prompt,
partialCompletion = partialCompletion,
streamingConsumer = streamingResults =>
updateReceiver.receiveStreamingLlmResponse(
streamId = streamingResults.streamId,
llmRequest = llmRequest,
responseText = streamingResults.value,
completed = streamingResults.completed
)
)
.recover {
case ex =>
// Log the failure for debugging and send to Sentry
SimpleTimedLogger.printLogger.logLine(
s"LLM processing failed for request ${llmRequest.requestId}: $ex"
)
ex.printStackTrace()
Sentry.captureException(ex)
// Handle failure by moving request back to unrequested state for retry
updateReceiver.receiveStreamingLlmFailure(llmRequest)
throw ex // Re-throw to maintain error semantics
}
}
}
}
.getOrElse(LlmResolverTooManyRequestsInFlight)
case TextGenerationDependencyInProgress(
notSatisfiedTextId,
@@ -665,8 +665,9 @@ final case class GameController(
streamingTextStatuses: Vector[StreamingTextStatus],
responseObserver: SyncResponseObserver
): GameController =
userIdToFactionId.get(userId) match {
case Some(fid) =>
userIdToFactionId
.get(userId)
.map { fid =>
this.withHumanClient(
clientTextStore.completeTexts.values
.filter(ct =>
@@ -706,17 +707,8 @@ final case class GameController(
client.update(clientText = clientText)
}
)
case None =>
SimpleTimedLogger.printLogger.logLine(
s"streamUpdates: userId $userId not found in game $gameId - client should reconnect"
)
responseObserver.onError(
Status.UNAUTHENTICATED
.withDescription("User not found in game - please reconnect")
.asRuntimeException()
)
this
}
}
.get
def stopStreaming(userId: UserId): GameController =
GameController(
@@ -33,9 +33,6 @@ object GameParametersUtils {
private val defaultResourceLocation: String =
"/net/eagle0/eagle/game_parameters.json"
private val tutorialResourceLocation: String =
"/net/eagle0/eagle/tutorial_game_parameters.json"
private val fixedHeroFilePath: String = "/net/eagle0/eagle/heroes.tsv"
private val generatedHeroFilePath: String =
"/net/eagle0/eagle/generated_heroes.tsv"
@@ -77,12 +74,6 @@ object GameParametersUtils {
val defaultExpandedGameParameters: ExpandedGameParameters =
expandedGameParametersFrom(defaultResourceLocation).get
lazy val tutorialExpandedGameParameters: ExpandedGameParameters =
expandedGameParametersFrom(tutorialResourceLocation).get
def parametersFor(isTutorialMode: Boolean): ExpandedGameParameters =
if isTutorialMode then tutorialExpandedGameParameters else defaultExpandedGameParameters
private def heroNamesMap(fixedHeroes: FixedHeroes): Map[String, String] = {
val heroes = fixedHeroes.greatPeople.map(
_.greatPerson.hero
@@ -21,13 +21,11 @@ import net.eagle0.eagle.model.action_result.generated_text_request.{
}
import net.eagle0.eagle.model.action_result.types.ActionResultType
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.state.{Army, CombatUnit, HostileArmyGroup, HostileArmyGroupStatus, MovingArmy, Supplies}
import net.eagle0.eagle.model.state.chronicle_entry.ChronicleEntry
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Hostile
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.quest.DefeatFactionQuest
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType}
import net.eagle0.eagle.model.state.unaffiliated_hero.concrete.UnaffiliatedHeroC
import net.eagle0.eagle.model.state.BattalionType
@@ -65,8 +63,7 @@ trait NewGameCreation {
persister: Persister,
humanPlayerFactionLeaderNameTextIds: Vector[String],
playerCount: Int,
allBattalionTypes: Vector[BattalionType],
isTutorialMode: Boolean = false
allBattalionTypes: Vector[BattalionType]
): EngineAndResults
}
@@ -267,23 +264,20 @@ object FixedNewGameCreation extends NewGameCreation {
expandedGameParameters: ExpandedGameParameters,
headName: String,
provinceId: ProvinceId,
gameId: GameId,
isTutorialMode: Boolean = false
gameId: GameId
): RandomState[ActionResultC] =
withFactionHead(
expandedGameParameters = expandedGameParameters,
headName = headName,
provinceId = provinceId,
gameId = gameId,
isTutorialMode = isTutorialMode
gameId = gameId
)
def withHumanPlayerFactions(
expandedGameParameters: ExpandedGameParameters,
factionHeadNameTextIds: Vector[String],
provinceIds: Vector[ProvinceId],
gameId: GameId,
isTutorialMode: Boolean = false
gameId: GameId
): RandomState[ActionResultC] =
factionHeadNameTextIds
.map(nameTextId =>
@@ -301,8 +295,7 @@ object FixedNewGameCreation extends NewGameCreation {
expandedGameParameters = expandedGameParameters,
headName = name,
provinceId = pid,
gameId = gameId,
isTutorialMode = isTutorialMode
gameId = gameId
)
}
@@ -310,8 +303,7 @@ object FixedNewGameCreation extends NewGameCreation {
expandedGameParameters: ExpandedGameParameters,
headName: String,
provinceId: ProvinceId,
gameId: GameId,
isTutorialMode: Boolean = false
gameId: GameId
): RandomState[ActionResultC] = {
val fid = value.newValue.nextFactionId
@@ -323,10 +315,6 @@ object FixedNewGameCreation extends NewGameCreation {
greatPerson = gp.greatPerson.copy(hero = gp.greatPerson.hero.withFactionId(fid))
)
// Tutorial mode: 4 heroes (faction head + 3 sworn brothers)
// Normal mode: uses randomRulingPlayerHeroCount from occupiedProvinceOverrides (default 2)
val tutorialExtraHeroCount = if isTutorialMode then 3 else 0
value
.map(_.withLoadedHero(gpWithFid.greatPerson.hero.withFactionId(fid)))
.map {
@@ -342,22 +330,14 @@ object FixedNewGameCreation extends NewGameCreation {
)
} match {
case RandomState((ar, hid), nr) =>
// In tutorial mode, add extra sworn brothers for a total of 4 heroes
val withExtraTutorialHeroes =
if isTutorialMode then RandomState(ar, nr).withRandomHeroes(fid, gameId, tutorialExtraHeroCount)
else RandomState((ar, Vector[HeroId]()), nr)
withExtraTutorialHeroes.continue {
case ((arWithHeroes, extraHids), nextRandom) =>
RandomState(arWithHeroes, nextRandom).withProvinceOverrides(
province = value.newValue.provinceMap(provinceId),
provinceOverrides = expandedGameParameters.gameParameters.getOccupiedProvinceOverrides,
extraHeroIds = Vector(hid) ++ extraHids,
factionId = fid,
battalionName = Some(s"$headName's Loyalists"),
gameId = gameId
)
}
RandomState(ar, nr).withProvinceOverrides(
province = value.newValue.provinceMap(provinceId),
provinceOverrides = expandedGameParameters.gameParameters.getOccupiedProvinceOverrides,
extraHeroIds = Vector(hid),
factionId = fid,
battalionName = Some(s"$headName's Loyalists"),
gameId = gameId
)
}
}
@@ -553,115 +533,13 @@ object FixedNewGameCreation extends NewGameCreation {
)
}
// Tutorial mode constants
private val tutorialPlayerStartProvinceId: ProvinceId = 14 // Onmaa
private val tutorialRivalFactionId: FactionId = 2 // Norfolk's faction
/// Sets up a tutorial battle by adding a hostile army group with Attacking status.
/// When the round advances to BattleRequest phase, the battle will be automatically created.
private def setupTutorialBattle(
ar: ActionResultC,
attackerFactionHeadName: String,
targetProvinceId: ProvinceId
): ActionResultC = {
// Find the attacker faction by their faction head's name (using nameTextId)
val attackerHero = ar.newHeroes.find(_.nameTextId == attackerFactionHeadName)
val attackerFactionId = attackerHero.flatMap(_.factionId)
// Find the defender (player) faction - should be the ruling faction of the target province
val targetProvince = ar.newProvinces.find(_.id == targetProvinceId)
val defenderFactionId = targetProvince.flatMap(_.rulingFactionId)
(attackerFactionId, defenderFactionId, targetProvince) match {
case (Some(attackerFid), Some(defenderFid), Some(province)) =>
// Get attacker heroes and their battalions
val attackerHeroes = ar.newHeroes.filter(_.factionId.contains(attackerFid))
val attackerUnits = attackerHeroes.map { hero =>
// Find battalions for this hero (at their province)
val heroBattalionIds = ar.newProvinces
.filter(_.rulingFactionId.contains(attackerFid))
.flatMap(_.battalionIds)
CombatUnit(
factionId = attackerFid,
heroId = hero.id,
battalionId = heroBattalionIds.headOption
)
}
// Get defender heroes and their battalions
val defenderHeroes = ar.newHeroes.filter(_.factionId.contains(defenderFid))
val defenderUnits = defenderHeroes.map { hero =>
val heroBattalionIds = province.battalionIds
CombatUnit(
factionId = defenderFid,
heroId = hero.id,
battalionId = heroBattalionIds.headOption
)
}
// Find the origin province (attacker's province)
val attackerProvince = ar.newProvinces
.find(_.rulingFactionId.contains(attackerFid))
.map(_.id)
.getOrElse(32) // Default to province 32 if not found
// Get starting position index from neighbor info
val startingPositionIndex = province.neighbors
.find(_.provinceId == attackerProvince)
.map(_.startingPositionIndex)
.getOrElse(0)
// Create hostile army group with Attacking status (bypasses decision phase)
val hostileArmyGroup = HostileArmyGroup(
factionId = attackerFid,
armies = Vector(
MovingArmy(
id = 1,
army = Army(
factionId = attackerFid,
units = attackerUnits,
fleeProvinceId = Some(attackerProvince)
),
arrivalRound = 1,
destinationProvinceId = targetProvinceId,
originProvinceId = attackerProvince,
supplies = Supplies(gold = 500, food = 1000),
suppliesLoss = 0.0,
startingPositionIndex = Some(startingPositionIndex)
)
),
status = HostileArmyGroupStatus.Attacking
)
// Create defending army for the player
val defendingArmy = Army(
factionId = defenderFid,
units = defenderUnits,
fleeProvinceId = None
)
// Add the hostile army and defending army to the province
val updatedProvince = province.updateWith(
hostileArmies = province.hostileArmies :+ hostileArmyGroup,
defendingArmy = Some(defendingArmy)
)
ar.withProvince(updatedProvince)
case _ =>
// If we can't find the factions or province, return unchanged
ar
}
}
override def createGame(
expandedGameParameters: ExpandedGameParameters,
gameId: GameId,
persister: Persister,
humanPlayerFactionLeaderNameTextIds: Vector[String],
playerCount: Int,
allBattalionTypes: Vector[BattalionType],
isTutorialMode: Boolean = false
allBattalionTypes: Vector[BattalionType]
): EngineAndResults = {
val gameParameters = expandedGameParameters.gameParameters
@@ -676,22 +554,13 @@ object FixedNewGameCreation extends NewGameCreation {
gameId = gameId
)
// In tutorial mode, player spawns at province 14 (Onmaa) instead of random
val splitPids = setFactionsResult.continue {
case (sfr, nextRandom) =>
if isTutorialMode then {
// Tutorial: force player to province 14, no AI provinces needed (they're all set factions)
RandomState(
(Vector(tutorialPlayerStartProvinceId), Vector[ProvinceId]()),
nextRandom
)
} else {
randomProvinceIdSet(
sfr.newProvinces.toVector,
playerCount,
nextRandom
).map(_.splitAt(humanPlayerFactionLeaderNameTextIds.size))
}
randomProvinceIdSet(
sfr.newProvinces.toVector,
playerCount,
nextRandom
).map(_.splitAt(humanPlayerFactionLeaderNameTextIds.size))
}
val allFactionsResult = splitPids.continue {
@@ -701,8 +570,7 @@ object FixedNewGameCreation extends NewGameCreation {
expandedGameParameters = expandedGameParameters,
factionHeadNameTextIds = humanPlayerFactionLeaderNameTextIds,
provinceIds = humanProvinceIds,
gameId = gameId,
isTutorialMode = isTutorialMode
gameId = gameId
)
.withOtherAiFactions(
expandedGameParameters = expandedGameParameters,
@@ -799,63 +667,8 @@ object FixedNewGameCreation extends NewGameCreation {
)
}
// In tutorial mode, add an unaffiliated hero with DefeatFactionQuest to player's starting province
val withTutorialQuestHeroState =
if isTutorialMode then {
val currentAR = provincesWithExtraHeroesState.newValue
val nextRandom = provincesWithExtraHeroesState.nextRandom
val questHeroId = currentAR.nextHeroId
// Generate a random hero for the quest giver
val (heroGenResult, newRandom) = RandomHeroGenerator
.createHeroesWithNoProfession(
hids = Vector(questHeroId),
functionalRandom = nextRandom,
remainingImagePathsByProfession = (p, g) => ImagePaths.imagePathsByProfession((p, g)),
date = StartDate.startDate
) match {
case RandomState(result, nr) => (result, nr)
}
val questHero = heroGenResult.head match {
case HeroGenerationResponse(hero, nameInfo) =>
(
hero,
NameGenerationRequestCreator.nameGenerationRequest(
heroId = hero.id,
gameId = gameId,
nameInfo = nameInfo
)
)
}
// Create the unaffiliated hero with DefeatFactionQuest
val tutorialQuestHero = UnaffiliatedHeroC(
heroId = questHeroId,
unaffiliatedHeroType = UnaffiliatedHeroType.Resident,
recruitmentInfo = RecruitmentInfo.HasQuest(
DefeatFactionQuest(targetFactionId = tutorialRivalFactionId)
)
)
// Add the hero to the player's starting province
val playerProvince = currentAR.provinceMap(tutorialPlayerStartProvinceId)
val updatedProvince = playerProvince.updateWith(
unaffiliatedHeroes = playerProvince.unaffiliatedHeroes :+ tutorialQuestHero
)
val updatedAR = currentAR
.withHeroes(Vector(questHero._1))
.withGeneratedTextRequests(questHero._2.map(convertNameRequest).toVector)
.withProvince(updatedProvince)
RandomState(updatedAR, newRandom)
} else {
provincesWithExtraHeroesState
}
val withBattalionTypesResult =
withTutorialQuestHeroState.newValue.copy(
provincesWithExtraHeroesState.newValue.copy(
newBattalionTypes = allBattalionTypes
)
@@ -903,20 +716,8 @@ object FixedNewGameCreation extends NewGameCreation {
newHeroes = withBattalionTypesResult.newHeroes.sortBy(_.id)
)
// Set up tutorial battle if configured
val withTutorialBattle =
if isTutorialMode then
gameParameters.tutorialBattle.fold(withPersonalityRequests) { tutorialBattle =>
setupTutorialBattle(
ar = withPersonalityRequests,
attackerFactionHeadName = tutorialBattle.attackerFactionHead,
targetProvinceId = tutorialBattle.targetProvinceId
)
}
else withPersonalityRequests
createEngine(
startGameActionResult = withTutorialBattle,
startGameActionResult = withPersonalityRequests,
heroGenerator = fixedHeroesARwithGenerator.newValue._2,
gameId = gameId,
persister = persister
@@ -134,20 +134,16 @@ class ShardokInterfaceGrpcClient(
val _ = Future {
Thread.sleep(delayMs)
pendingBattles.synchronized {
pendingBattles.get(shardokGameId) match {
case Some((_, storedRequest)) =>
println(s"Reconnecting stream for $shardokGameId (delay was ${delayMs}ms)")
try subscribeToGame(eagleGameId, shardokGameId, Some(storedRequest))
catch {
case e: Exception =>
println(s"Reconnect failed for $shardokGameId: $e")
// Exponential backoff with max 10 seconds
val nextDelay = Math.min(delayMs * 2, 10000)
scheduleReconnect(eagleGameId, shardokGameId, nextDelay)
}
case None =>
// Battle was completed or cancelled while waiting to reconnect
}
if pendingBattles.contains(shardokGameId) then
println(s"Reconnecting stream for $shardokGameId (delay was ${delayMs}ms)")
try subscribeToGame(eagleGameId, shardokGameId, None)
catch {
case e: Exception =>
println(s"Reconnect failed for $shardokGameId: $e")
// Exponential backoff with max 10 seconds
val nextDelay = Math.min(delayMs * 2, 10000)
scheduleReconnect(eagleGameId, shardokGameId, nextDelay)
}
}
}
}
@@ -15,13 +15,11 @@ cc_library(
)
# Test for AbstractMCTSAI algorithm
# Disabled: MCTS is not currently in use
cc_test(
name = "abstract_mcts_ai_test",
srcs = ["AbstractMCTSAI_test.cpp"],
copts = TEST_COPTS,
linkstatic = True,
tags = ["manual"],
deps = [
":mock_tictactoe",
"//src/main/cpp/net/eagle0/common/mcts/abstract:abstract_mcts_ai",
@@ -63,6 +61,7 @@ cc_test(
test_suite(
name = "all_abstract_tests",
tests = [
":abstract_mcts_ai_test",
":mcts_integration_test",
":mcts_node_test",
],
@@ -60,8 +60,7 @@ class AvailableFeastCommandFactoryTest extends AnyFlatSpec with Matchers with Be
) shouldBe empty
}
"availableCommand" should "be available even if everybody has maxed loyalty and vigor" in {
// Feast is available as long as there's enough gold, so players can complete SpendOnFeastsQuest
"availableCommand" should "return nothing if everybody has maxed loyalty and vigor" in {
val gameState = GameState(
gameId = 0,
currentRoundId = 0,
@@ -97,11 +96,11 @@ class AvailableFeastCommandFactoryTest extends AnyFlatSpec with Matchers with Be
chronicleEntries = Vector()
)
inside(AvailableFeastCommandFactory.availableCommand(gameState, 9, 5).get) {
case cmd: FeastAvailable =>
cmd.actingProvinceId shouldBe 5
cmd.goldCost shouldBe 60
}
AvailableFeastCommandFactory.availableCommand(
gameState,
9,
5
) shouldBe empty
}
"availableCommand" should "set the basics" in {
@@ -562,10 +562,8 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state/province:incoming_end_turn_action",
"//src/main/scala/net/eagle0/eagle/model/state/province:orders",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/quest",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
)
@@ -12,10 +12,8 @@ import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.province.Neighbor
import net.eagle0.eagle.model.state.quest.{
AllianceQuest,
BorderSecurityQuest,
DismissSpecificVassalQuest,
GiveToHeroesAcrossRealmQuest,
GrandArmyQuest,
@@ -908,168 +906,4 @@ class CheckForFulfilledQuestsActionTest extends AnyFlatSpec with Matchers with B
province = province
) shouldBe false
}
behavior of "BorderSecurityQuest"
it should "be fulfilled when all neighbors are controlled by the faction" in {
val targetProvince = ProvinceC(
id = 100,
rulingFactionId = Some(fid),
neighbors = Vector(
Neighbor(provinceId = 101, startingPositionIndex = 0),
Neighbor(provinceId = 102, startingPositionIndex = 0)
)
)
val neighbor1 = ProvinceC(id = 101, rulingFactionId = Some(fid))
val neighbor2 = ProvinceC(id = 102, rulingFactionId = Some(fid))
val questProvince = province.copy(rulingFactionId = Some(fid))
CheckForFulfilledQuestsAction(
gameId = gameId,
currentDate = date,
currentRoundId = roundId,
provinces = Vector(questProvince, targetProvince, neighbor1, neighbor2),
factions = factions,
battalions = Vector(),
getHero = _ => None,
battalionTypes = battalionTypes,
heroBackstoryTextIdLookup = hid => s"backstory_$hid"
).didFulfillQuest(
quest = BorderSecurityQuest(targetProvinceId = 100),
province = questProvince
) shouldBe true
}
it should "not be fulfilled when a neighbor is controlled by another faction" in {
val targetProvince = ProvinceC(
id = 100,
rulingFactionId = Some(fid),
neighbors = Vector(
Neighbor(provinceId = 101, startingPositionIndex = 0),
Neighbor(provinceId = 102, startingPositionIndex = 0)
)
)
val neighbor1 = ProvinceC(id = 101, rulingFactionId = Some(fid))
val neighbor2 = ProvinceC(id = 102, rulingFactionId = Some(otherFid))
val questProvince = province.copy(rulingFactionId = Some(fid))
CheckForFulfilledQuestsAction(
gameId = gameId,
currentDate = date,
currentRoundId = roundId,
provinces = Vector(questProvince, targetProvince, neighbor1, neighbor2),
factions = factions,
battalions = Vector(),
getHero = _ => None,
battalionTypes = battalionTypes,
heroBackstoryTextIdLookup = hid => s"backstory_$hid"
).didFulfillQuest(
quest = BorderSecurityQuest(targetProvinceId = 100),
province = questProvince
) shouldBe false
}
it should "be fulfilled when a neighbor is controlled by an ally" in {
val allyFid = 50
val targetProvince = ProvinceC(
id = 100,
rulingFactionId = Some(fid),
neighbors = Vector(
Neighbor(provinceId = 101, startingPositionIndex = 0),
Neighbor(provinceId = 102, startingPositionIndex = 0)
)
)
val neighbor1 = ProvinceC(id = 101, rulingFactionId = Some(fid))
val neighbor2 = ProvinceC(id = 102, rulingFactionId = Some(allyFid))
val questProvince = province.copy(rulingFactionId = Some(fid))
val factionWithAlly = faction.copy(
factionRelationships = Vector(
FactionRelationship(
targetFactionId = allyFid,
relationshipLevel = FactionRelationship.RelationshipLevel.Ally
)
)
)
val allyFaction = FactionC(
id = allyFid,
name = s"Faction $allyFid",
factionHeadId = allyFid,
leaderIds = Vector(allyFid),
factionRelationships = Vector(
FactionRelationship(
targetFactionId = fid,
relationshipLevel = FactionRelationship.RelationshipLevel.Ally
)
)
)
CheckForFulfilledQuestsAction(
gameId = gameId,
currentDate = date,
currentRoundId = roundId,
provinces = Vector(questProvince, targetProvince, neighbor1, neighbor2),
factions = Vector(factionWithAlly, allyFaction),
battalions = Vector(),
getHero = _ => None,
battalionTypes = battalionTypes,
heroBackstoryTextIdLookup = hid => s"backstory_$hid"
).didFulfillQuest(
quest = BorderSecurityQuest(targetProvinceId = 100),
province = questProvince
) shouldBe true
}
it should "not be fulfilled when a neighbor is controlled by a faction with only a truce" in {
val truceFid = 50
val targetProvince = ProvinceC(
id = 100,
rulingFactionId = Some(fid),
neighbors = Vector(
Neighbor(provinceId = 101, startingPositionIndex = 0),
Neighbor(provinceId = 102, startingPositionIndex = 0)
)
)
val neighbor1 = ProvinceC(id = 101, rulingFactionId = Some(fid))
val neighbor2 = ProvinceC(id = 102, rulingFactionId = Some(truceFid))
val questProvince = province.copy(rulingFactionId = Some(fid))
val factionWithTruce = faction.copy(
factionRelationships = Vector(
FactionRelationship(
targetFactionId = truceFid,
relationshipLevel = FactionRelationship.RelationshipLevel.Truce
)
)
)
val truceFaction = FactionC(
id = truceFid,
name = s"Faction $truceFid",
factionHeadId = truceFid,
leaderIds = Vector(truceFid),
factionRelationships = Vector(
FactionRelationship(
targetFactionId = fid,
relationshipLevel = FactionRelationship.RelationshipLevel.Truce
)
)
)
CheckForFulfilledQuestsAction(
gameId = gameId,
currentDate = date,
currentRoundId = roundId,
provinces = Vector(questProvince, targetProvince, neighbor1, neighbor2),
factions = Vector(factionWithTruce, truceFaction),
battalions = Vector(),
getHero = _ => None,
battalionTypes = battalionTypes,
heroBackstoryTextIdLookup = hid => s"backstory_$hid"
).didFulfillQuest(
quest = BorderSecurityQuest(targetProvinceId = 100),
province = questProvince
) shouldBe false
}
}
@@ -15,15 +15,11 @@ import net.eagle0.eagle.model.state.hero.backstory_version.BackstoryVersion
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.{IncomingEndTurnAction, IncomingRecon, ProvinceOrderType}
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.quest.{ReconProvincesQuest, ReconSpecificProvincesQuest}
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType}
import net.eagle0.eagle.model.state.unaffiliated_hero.concrete.UnaffiliatedHeroC
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
import org.scalatest.Inside.inside
class PerformReconResolutionActionTest extends AnyFlatSpec with BeforeAndAfterEach with Matchers {
@@ -297,121 +293,4 @@ class PerformReconResolutionActionTest extends AnyFlatSpec with BeforeAndAfterEa
allResults.count(_.actionResultType == ReconSucceeded) shouldBe 3
allResults.count(_.actionResultType == EndReconResolutionPhase) shouldBe 1
}
"recon quests" should "increment ReconProvincesQuest counter" in {
val quest = ReconProvincesQuest(componentCount = 5, componentsFulfilled = 2, targetProvinceCount = 5)
val questHero = UnaffiliatedHeroC(
heroId = 200,
unaffiliatedHeroType = UnaffiliatedHeroType.Traveler,
recruitmentInfo = RecruitmentInfo.HasQuest(quest)
)
val provincesWithQuest = baseProvinces +
(9 -> baseProvinces(9).copy(unaffiliatedHeroes = Vector(questHero)))
val heroesWithQuest = baseHeroes + (200 -> HeroC(id = 200))
val gsWithQuest = makeGameState(provincesWithQuest, heroesWithQuest, baseFactions)
val oneResult = PerformReconResolutionAction(gsWithQuest, actionResultApplier)
.oneResult(
provinceId = 3,
incomingEndTurnAction = incomingRecon,
functionalRandom = functionalRandom
)
.newValue
inside(oneResult) {
case ar: ActionResultC =>
// Find the changed unaffiliated hero across all province changes for province 9
val updatedUH = ar.changedProvinces.collect { case cp: ChangedProvinceC if cp.provinceId == 9 => cp }
.flatMap(_.changedUnaffiliatedHeroes)
.find(_.heroId == 200)
inside(updatedUH) {
case Some(uh) =>
inside(uh.quest) {
case Some(q: ReconProvincesQuest) =>
q.componentsFulfilled shouldBe 3
}
}
}
}
it should "increment ReconSpecificProvincesQuest counter when target province is reconned" in {
val quest =
ReconSpecificProvincesQuest(componentCount = 3, componentsFulfilled = 0, targetProvinceIds = Vector(3, 15))
val questHero = UnaffiliatedHeroC(
heroId = 200,
unaffiliatedHeroType = UnaffiliatedHeroType.Traveler,
recruitmentInfo = RecruitmentInfo.HasQuest(quest)
)
val provincesWithQuest = baseProvinces +
(9 -> baseProvinces(9).copy(unaffiliatedHeroes = Vector(questHero)))
val heroesWithQuest = baseHeroes + (200 -> HeroC(id = 200))
val gsWithQuest = makeGameState(provincesWithQuest, heroesWithQuest, baseFactions)
val oneResult = PerformReconResolutionAction(gsWithQuest, actionResultApplier)
.oneResult(
provinceId = 3,
incomingEndTurnAction = incomingRecon,
functionalRandom = functionalRandom
)
.newValue
inside(oneResult) {
case ar: ActionResultC =>
// Find the changed unaffiliated hero across all province changes for province 9
val updatedUH = ar.changedProvinces.collect { case cp: ChangedProvinceC if cp.provinceId == 9 => cp }
.flatMap(_.changedUnaffiliatedHeroes)
.find(_.heroId == 200)
inside(updatedUH) {
case Some(uh) =>
inside(uh.quest) {
case Some(q: ReconSpecificProvincesQuest) =>
q.componentsFulfilled shouldBe 1
}
}
}
}
it should "not increment ReconSpecificProvincesQuest counter when non-target province is reconned" in {
val quest =
ReconSpecificProvincesQuest(componentCount = 3, componentsFulfilled = 0, targetProvinceIds = Vector(15, 20))
val questHero = UnaffiliatedHeroC(
heroId = 200,
unaffiliatedHeroType = UnaffiliatedHeroType.Traveler,
recruitmentInfo = RecruitmentInfo.HasQuest(quest)
)
val provincesWithQuest = baseProvinces +
(9 -> baseProvinces(9).copy(unaffiliatedHeroes = Vector(questHero)))
val heroesWithQuest = baseHeroes + (200 -> HeroC(id = 200))
val gsWithQuest = makeGameState(provincesWithQuest, heroesWithQuest, baseFactions)
val oneResult = PerformReconResolutionAction(gsWithQuest, actionResultApplier)
.oneResult(
provinceId = 3,
incomingEndTurnAction = incomingRecon,
functionalRandom = functionalRandom
)
.newValue
inside(oneResult) {
case ar: ActionResultC =>
val provinceCP = ar.changedProvinces.collectFirst {
case cp: ChangedProvinceC if cp.provinceId == 9 => cp
}
// No quest update expected - either no CP for province 9 or no changed UHs
provinceCP.foreach { cp =>
cp.changedUnaffiliatedHeroes.find(_.heroId == 200) shouldBe None
}
}
}
}
@@ -24,6 +24,7 @@ import net.eagle0.eagle.library.settings.{
PriceIndexShiftPerGold
}
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.NotificationC
import net.eagle0.eagle.model.action_result.types.ActionResultType
import net.eagle0.eagle.model.action_result.NotificationDetails.Divined
import net.eagle0.eagle.model.action_result.NotificationT.Llm
@@ -111,8 +112,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
ex.getMessage shouldBe "requirement failed: Duplicate heroIds found in Vector(12, 13, 12)"
}
@@ -128,8 +128,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province.withGold(199)),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
ex.getMessage shouldBe "requirement failed: Insufficient gold to divine 2 heroes"
}
@@ -145,8 +144,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
ex.getMessage shouldBe "requirement failed: Selected heroes Vector(12, 14) was not a subset of Vector(12, 13, 15)"
}
@@ -162,8 +160,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
}
}
@@ -179,8 +176,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
.immediateExecute(functionalRandom = functionalRandom)
.newValue
@@ -203,8 +199,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
)
),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
val ex = the[EagleInternalException] thrownBy
@@ -224,8 +219,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
.immediateExecute(functionalRandom = functionalRandom)
.newValue
@@ -260,8 +254,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
.immediateExecute(functionalRandom = functionalRandom)
.newValue
@@ -283,8 +276,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province.withPriceIndex(0.9)),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
.immediateExecute(functionalRandom = functionalRandom)
.newValue
@@ -306,8 +298,7 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
.immediateExecute(functionalRandom = functionalRandom)
.newValue
@@ -329,30 +320,28 @@ class DivineCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEac
currentRoundId = currentRoundId,
allProvinces = Vector(province),
factions = factions,
battalions = Vector(),
heroes = Vector()
battalions = Vector()
)
.immediateExecute(functionalRandom = functionalRandom)
.newValue
result.newNotifications should have size 2
// Verify each notification has the correct field associations
// (ignoring affectedProvinceIds which varies based on random quest assignment)
forExactly(1, result.newNotifications) { notif =>
notif.affectedHeroIds shouldBe Vector(12)
notif.details shouldBe Divined
notif.targetFactionIds shouldBe Vector(actingFactionId)
notif.llm shouldBe Llm.Id("387 divine province 93 uh 12")
notif.deferred shouldBe false
}
forExactly(1, result.newNotifications) { notif =>
notif.affectedHeroIds shouldBe Vector(15)
notif.details shouldBe Divined
notif.targetFactionIds shouldBe Vector(actingFactionId)
notif.llm shouldBe Llm.Id("387 divine province 93 uh 15")
notif.deferred shouldBe false
}
result.newNotifications should contain theSameElementsAs Vector(
NotificationC(
details = Divined,
targetFactionIds = Vector(actingFactionId),
llm = Llm.Id("387 divine province 93 uh 12"),
affectedProvinceIds = Vector(provinceId),
affectedHeroIds = Vector(12),
deferred = false
),
NotificationC(
details = Divined,
targetFactionIds = Vector(actingFactionId),
llm = Llm.Id("387 divine province 93 uh 15"),
affectedProvinceIds = Vector(),
affectedHeroIds = Vector(15),
deferred = false
)
)
}
}
@@ -87,8 +87,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = Vector(ImprovementType.Devastation),
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
cmd.immediateExecute
@@ -106,8 +105,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -123,8 +121,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -140,8 +137,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -160,8 +156,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -181,8 +176,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -198,8 +192,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -215,8 +208,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -236,8 +228,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Devastation,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -260,8 +251,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Devastation,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val betterExpectedImprovement = 7.12 // improvementPerStat * (85 + 93) / 2.0
@@ -284,8 +274,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Devastation,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val expectedImprovement =
@@ -308,8 +297,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Devastation,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -330,8 +318,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -352,8 +339,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val reducedExpectedImprovement =
@@ -377,8 +363,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val betterExpectedImprovement = 7.12 // improvementPerStat * (85 + 93) / 2.0
@@ -402,8 +387,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val expectedImprovement =
@@ -426,8 +410,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Economy,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -448,8 +431,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Agriculture,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -470,8 +452,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Infrastructure,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = false,
factionProvinces = Vector(province)
lockImprovementType = false
)
val result = cmd.immediateExecute
@@ -492,8 +473,7 @@ class ImproveCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEa
improvementType = ImprovementType.Infrastructure,
availableHeroIds = heroes.map(_.id),
availableImprovementTypes = availableTypes,
lockImprovementType = true,
factionProvinces = Vector(province)
lockImprovementType = true
)
val result = cmd.immediateExecute
@@ -43,8 +43,7 @@ class RestCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEach
val command = RestCommand.make(
actingFactionId = rulingFactionId,
provinceId = provinceId,
factionHeroesInProvince = factionHeroesInProvince,
factionProvinces = Vector()
factionHeroesInProvince = factionHeroesInProvince
)
val result = command.immediateExecute
@@ -62,8 +61,7 @@ class RestCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEach
val command = RestCommand.make(
actingFactionId = rulingFactionId,
provinceId = provinceId,
factionHeroesInProvince = factionHeroesInProvince,
factionProvinces = Vector()
factionHeroesInProvince = factionHeroesInProvince
)
val result = command.immediateExecute
@@ -83,8 +81,7 @@ class RestCommandTest extends AnyFlatSpec with Matchers with BeforeAndAfterEach
val command = RestCommand.make(
actingFactionId = rulingFactionId,
provinceId = provinceId,
factionHeroesInProvince = heroesWithAlmostRested,
factionProvinces = Vector()
factionHeroesInProvince = heroesWithAlmostRested
)
val result = command.immediateExecute
@@ -147,7 +147,6 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
)
.newValue
@@ -165,8 +164,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) { q =>
@@ -184,8 +182,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) { q =>
@@ -202,8 +199,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) { q =>
@@ -221,8 +217,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) { q =>
@@ -239,8 +234,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) { q =>
@@ -256,8 +250,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue should contain.allOf(
SpecificExpansionQuest(provinceId = immediate1.id),
@@ -280,8 +273,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
),
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) { q =>
@@ -298,8 +290,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) { q =>
@@ -316,8 +307,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) { q =>
@@ -335,8 +325,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue should contain(
DismissSpecificVassalQuest(targetHeroId = 2)
@@ -351,8 +340,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) {
@@ -369,8 +357,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) {
@@ -390,8 +377,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
) { q =>
@@ -412,8 +398,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
.collect { case ubq: UpgradeBattalionQuest => ubq }
@@ -452,8 +437,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = battalions,
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -493,8 +477,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = battalions,
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
val ubqs = quests.collect {
@@ -521,15 +504,14 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = battalions,
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
forAtLeast(1, quests) { q =>
inside(q) {
case GrandArmyQuest(totalTroopCount) =>
totalTroopCount should be >= 3200 + 2500
totalTroopCount should be > 3200 + 2500
totalTroopCount should be < 3200 + 5000
}
}
@@ -554,8 +536,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
leaderIds = Vector(73)
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -589,8 +570,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
leaderIds = Vector(73)
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -623,8 +603,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
leaderIds = Vector(73)
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -657,8 +636,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
)
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -682,8 +660,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -706,8 +683,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
)
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -738,8 +714,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
)
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -782,8 +757,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
)
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -836,8 +810,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
)
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -909,8 +882,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
)
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -930,8 +902,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -953,8 +924,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -981,8 +951,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
),
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1011,8 +980,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
),
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1036,8 +1004,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1066,8 +1033,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
allProvinces = allProvinces,
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1092,8 +1058,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
),
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1122,8 +1087,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
),
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1172,8 +1136,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
),
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1231,8 +1194,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
),
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1295,8 +1257,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
name = "Faction 71"
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1364,8 +1325,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
name = "Faction 73"
),
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -1433,8 +1393,7 @@ class QuestCreationUtilsTest extends AnyFlatSpec with Matchers with BeforeAndAft
),
factions = factions,
battalions = Vector(),
heroes = Vector(),
functionalRandom = new JankyRandom(new Random())
new JankyRandom(new Random())
)
.newValue
@@ -132,7 +132,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val mockEngineAndResults = mock[EngineAndResults]
mockGameCreation.createGame
.expects(*, *, *, *, *, *, *)
.expects(*, *, *, *, *, *)
.returns(mockEngineAndResults): Unit
(() => mockEngine.updated)
.expects()
@@ -254,7 +254,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
allowGamesE0esReads()
val mockEngineAndResults = mock[EngineAndResults]
mockGameCreation.createGame
.expects(*, *, *, *, *, *, *)
.expects(*, *, *, *, *, *)
.returns(mockEngineAndResults): Unit
(() => mockEngine.updated)