Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 de22df3848 Remove C++ tutorial battle controller (will be in separate PR)
The Shardok TutorialBattleController and related proto changes will be
handled in a separate C++ PR for the tactical layer integration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 18:03:25 -08:00
adminandClaude Opus 4.5 e281f3b012 Add tutorial battle system documentation
Document the tutorial battle system design including:
- Battle configuration (defender vs attacker setup)
- Implementation architecture across all 6 phases
- File summary of new and modified files
- Remaining implementation tasks
- Testing checklist
- Configuration reference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 15:41:59 -08:00
adminandClaude Opus 4.5 9695db2536 Add tutorial battle setup in NewGameCreation
When tutorial mode is enabled and a tutorialBattle config exists in game
parameters, set up the battle by:
- Adding a hostile army group with Attacking status from the attacker faction
- Creating a defending army for the player faction
- Adding both to the target province (Onmaa)

The battle will be automatically created when the game advances to the
BattleRequest phase via RequestBattlesAction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 10:53:02 -08:00
adminandClaude Opus 4.5 aaba34e7de Add tutorial battle system with narrative screens and scripted flee
Implement tutorial battle system for first-time players:
- Narrative intro screens before battle with story text
- TutorialBattleController C++ component for scripted flee logic
- Flee triggers: player loses 1 unit OR 5 rounds pass
- Tutorial action types for enemy fled and reinforcements arrived
- Tutorial battle config in game parameters proto

Unity client changes:
- NarrativeScreenController for displaying story screens
- EagleGameController integration to show narratives before battle
- Tutorial content definitions for battle events
- Fix CreateGame delegate signature mismatch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 10:42:31 -08:00
adminandClaude Opus 4.5 9e37236fe3 Add tutorial scenario system for first-time players
Implement a guided tutorial scenario for new users with:
- Separate tutorial_game_parameters.json with simplified setup
- King (Bregos Fyar) controls 5 provinces (37, 40, 32, 6, 11)
- Weak rival (Norfolk) with 1 hero at province 13
- Player spawns at province 14 (Onmaa) with 4 heroes
- Unaffiliated hero in player's province with DefeatFactionQuest

Server changes:
- Add is_tutorial_mode to CreateGameRequest proto
- GameParametersUtils: Add tutorial parameters loading
- EagleServiceImpl: Use parametersFor() based on tutorial mode
- GamesManager: Pass isTutorialMode through game creation
- NewGameCreation: Force player spawn location, add extra heroes,
  create quest hero in tutorial mode

Client changes:
- ConnectionHandler: Pass isTutorialMode=true in AutoCreateFirstGame

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 10:17:48 -08:00
16 changed files with 1000 additions and 43 deletions
+230
View File
@@ -0,0 +1,230 @@
# 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;
}
```
@@ -806,7 +806,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
/// <summary>
/// Auto-create and launch a 7-faction game for first-time users.
/// 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.
/// </summary>
private void AutoCreateFirstGame() {
if (fetchedNewGameLeaders == null || fetchedNewGameLeaders.Count == 0) {
@@ -818,9 +820,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var randomIndex = new System.Random().Next(fetchedNewGameLeaders.Count);
var leaderTextId = fetchedNewGameLeaders[randomIndex].NameTextId;
// 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);
// 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);
}
private void StartListeningForLobbyUpdates() {
@@ -946,10 +948,25 @@ 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);
var game = _internalCreateGame(
leaderNameTextId,
humanPlayerCount,
totalPlayerCount,
isTutorialMode);
}
public void QuitButtonClicked() { Application.Quit(); }
@@ -988,8 +1005,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
} catch (Exception e) { Console.WriteLine(e.ToString()); }
}
private async Task<bool>
_internalCreateGame(string desiredLeaderTextId, int humanPlayerCount, int totalPlayerCount) {
private async Task<bool> _internalCreateGame(
string desiredLeaderTextId,
int humanPlayerCount,
int totalPlayerCount,
bool isTutorialMode = false) {
try {
return await _persistentClientConnection.SendUpdateStreamRequestAsync(
new UpdateStreamRequest {
@@ -997,7 +1017,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
new CreateGameRequest {
DesiredLeaderTextId = desiredLeaderTextId,
TotalPlayerCount = totalPlayerCount,
HumanPlayerCount = humanPlayerCount
HumanPlayerCount = humanPlayerCount,
IsTutorialMode = isTutorialMode
}
});
} catch (WebException e) {
@@ -77,6 +77,10 @@ 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;
@@ -524,6 +528,29 @@ 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) {
@@ -583,9 +610,18 @@ 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) {
!TutorialManager.Instance.State.OnboardingCompleted && !_narrativeScreensShown) {
TutorialManager.Instance.StartOnboarding();
}
@@ -59,6 +59,11 @@ 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 {
@@ -201,6 +206,9 @@ 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();
@@ -34,6 +34,9 @@ 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);
}
@@ -1102,6 +1105,59 @@ 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,6 +558,14 @@ 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;
}
}
@@ -0,0 +1,238 @@
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;
}
}
@@ -349,6 +349,7 @@ 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 {
@@ -52,6 +52,22 @@ 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;
@@ -59,4 +75,8 @@ 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,6 +22,13 @@ 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;
@@ -43,6 +50,9 @@ 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 {
@@ -0,0 +1,113 @@
{
"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": ""
}
]
}
@@ -65,11 +65,12 @@ class EagleServiceImpl(
): Future[CreateGameResponse] =
lockAndUpdateGamesWith(uid =>
gamesManager.createGameForUser(
expandedGameParameters = GameParametersUtils.defaultExpandedGameParameters,
expandedGameParameters = GameParametersUtils.parametersFor(request.isTutorialMode),
userId = uid,
desiredLeaderTextId = request.desiredLeaderTextId,
maxHumanPlayers = request.humanPlayerCount,
totalPlayers = request.totalPlayerCount
totalPlayers = request.totalPlayerCount,
isTutorialMode = request.isTutorialMode
)
)
@@ -68,7 +68,8 @@ case class GameAwaitingPlayers(
existingHumanPlayers: Vector[WaitingGamePlayerInfo],
totalPlayerCount: Int,
humanPlayerCount: Int,
greatPeople: Vector[Hero]
greatPeople: Vector[Hero],
isTutorialMode: Boolean = false
) {
private def claimedNameTextIds: Vector[String] =
existingHumanPlayers.map(_.leaderNameTextId)
@@ -85,7 +86,8 @@ case class GameAwaitingPlayers(
existingHumanPlayers = existingHumanPlayers :+ waitingGamePlayerInfo,
totalPlayerCount = totalPlayerCount,
humanPlayerCount = humanPlayerCount,
greatPeople = greatPeople
greatPeople = greatPeople,
isTutorialMode = isTutorialMode
)
}
@@ -1104,7 +1106,8 @@ class GamesManager(
expandedGameParameters = gap.expandedGameParameters,
gameId = newGap.gameId,
humanPlayers = newGap.existingHumanPlayers,
aiPlayerCount = newGap.totalPlayerCount - newGap.existingHumanPlayers.size
aiPlayerCount = newGap.totalPlayerCount - newGap.existingHumanPlayers.size,
isTutorialMode = newGap.isTutorialMode
): Unit
} else
gamesAwaitingPlayers = gamesAwaitingPlayers
@@ -1143,7 +1146,8 @@ class GamesManager(
userId: UserId,
desiredLeaderTextId: String,
maxHumanPlayers: Int,
totalPlayers: Int
totalPlayers: Int,
isTutorialMode: Boolean = false
): CreateGameResponse =
if maxHumanPlayers > totalPlayers || totalPlayers > MaxSupportedPlayers.intValue
then CreateGameResponse(INVALID_OPTIONS_RESULT, 0)
@@ -1156,7 +1160,8 @@ class GamesManager(
existingHumanPlayers = Vector.empty,
totalPlayerCount = totalPlayers,
humanPlayerCount = maxHumanPlayers,
greatPeople = expandedGameParameters.greatPeopleHeroProtos
greatPeople = expandedGameParameters.greatPeopleHeroProtos,
isTutorialMode = isTutorialMode
)
val result = joinGame(
@@ -1209,7 +1214,8 @@ class GamesManager(
expandedGameParameters: ExpandedGameParameters,
gameId: GameId,
humanPlayers: Vector[WaitingGamePlayerInfo],
aiPlayerCount: Int
aiPlayerCount: Int,
isTutorialMode: Boolean = false
): GameControllerWithPostResults = {
val humanPlayerCount = humanPlayers.size
val initialEar = gameCreation
@@ -1219,7 +1225,8 @@ class GamesManager(
persister = gamePersisterCreation.persisterForGame(gameId),
humanPlayerFactionLeaderNameTextIds = humanPlayers.map(_.leaderNameTextId),
playerCount = humanPlayerCount + aiPlayerCount,
allBattalionTypes = GamesManager.battalionTypes
allBattalionTypes = GamesManager.battalionTypes,
isTutorialMode = isTutorialMode
)
val unrequestedTexts = initialEar.results
@@ -33,6 +33,9 @@ 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"
@@ -74,6 +77,12 @@ 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,11 +21,13 @@ 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
@@ -63,7 +65,8 @@ trait NewGameCreation {
persister: Persister,
humanPlayerFactionLeaderNameTextIds: Vector[String],
playerCount: Int,
allBattalionTypes: Vector[BattalionType]
allBattalionTypes: Vector[BattalionType],
isTutorialMode: Boolean = false
): EngineAndResults
}
@@ -264,20 +267,23 @@ object FixedNewGameCreation extends NewGameCreation {
expandedGameParameters: ExpandedGameParameters,
headName: String,
provinceId: ProvinceId,
gameId: GameId
gameId: GameId,
isTutorialMode: Boolean = false
): RandomState[ActionResultC] =
withFactionHead(
expandedGameParameters = expandedGameParameters,
headName = headName,
provinceId = provinceId,
gameId = gameId
gameId = gameId,
isTutorialMode = isTutorialMode
)
def withHumanPlayerFactions(
expandedGameParameters: ExpandedGameParameters,
factionHeadNameTextIds: Vector[String],
provinceIds: Vector[ProvinceId],
gameId: GameId
gameId: GameId,
isTutorialMode: Boolean = false
): RandomState[ActionResultC] =
factionHeadNameTextIds
.map(nameTextId =>
@@ -295,7 +301,8 @@ object FixedNewGameCreation extends NewGameCreation {
expandedGameParameters = expandedGameParameters,
headName = name,
provinceId = pid,
gameId = gameId
gameId = gameId,
isTutorialMode = isTutorialMode
)
}
@@ -303,7 +310,8 @@ object FixedNewGameCreation extends NewGameCreation {
expandedGameParameters: ExpandedGameParameters,
headName: String,
provinceId: ProvinceId,
gameId: GameId
gameId: GameId,
isTutorialMode: Boolean = false
): RandomState[ActionResultC] = {
val fid = value.newValue.nextFactionId
@@ -315,6 +323,10 @@ 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 {
@@ -330,14 +342,22 @@ object FixedNewGameCreation extends NewGameCreation {
)
} match {
case RandomState((ar, hid), nr) =>
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
)
// 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
)
}
}
}
@@ -533,13 +553,115 @@ 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]
allBattalionTypes: Vector[BattalionType],
isTutorialMode: Boolean = false
): EngineAndResults = {
val gameParameters = expandedGameParameters.gameParameters
@@ -554,13 +676,22 @@ 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) =>
randomProvinceIdSet(
sfr.newProvinces.toVector,
playerCount,
nextRandom
).map(_.splitAt(humanPlayerFactionLeaderNameTextIds.size))
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))
}
}
val allFactionsResult = splitPids.continue {
@@ -570,7 +701,8 @@ object FixedNewGameCreation extends NewGameCreation {
expandedGameParameters = expandedGameParameters,
factionHeadNameTextIds = humanPlayerFactionLeaderNameTextIds,
provinceIds = humanProvinceIds,
gameId = gameId
gameId = gameId,
isTutorialMode = isTutorialMode
)
.withOtherAiFactions(
expandedGameParameters = expandedGameParameters,
@@ -667,8 +799,63 @@ 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 =
provincesWithExtraHeroesState.newValue.copy(
withTutorialQuestHeroState.newValue.copy(
newBattalionTypes = allBattalionTypes
)
@@ -716,8 +903,20 @@ 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 = withPersonalityRequests,
startGameActionResult = withTutorialBattle,
heroGenerator = fixedHeroesARwithGenerator.newValue._2,
gameId = gameId,
persister = persister
@@ -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)