Add Shardok-side connection status indicator

The strategic-map status widget already reports Your Turn / Waiting for
Players / etc. via ServerGameStatus. This adds the equivalent for
tactical battles so the player knows whether they're up, the AI is
thinking, or another human is about to act.

ShardokServerStatus is a separate proto from ServerGameStatus so the
strategic and tactical layers don't have to evolve in lockstep. The
server populates it per-battle in GameController by checking
shardokPlayerAvailableCommands across factions, classified into
AI vs human via aiClientFactionIds.

Client side: ShardokGameModel implements IShardokGameStateProvider and
caches the latest status. EagleGameController binds the persistent
ConnectionStatusUI to the Shardok model on entering battle;
ShardokGameController clears it on exit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 21:46:52 -07:00
co-authored by Claude Opus 4.7
parent 7306c9cb06
commit 77a071afcf
7 changed files with 129 additions and 6 deletions
@@ -18,6 +18,16 @@ namespace eagle {
bool IsProcessingCommand { get; }
}
/// <summary>
/// Shardok-side counterpart to <see cref="IGameStateProvider"/>. The two protos and providers
/// are kept separate on purpose so the strategic and tactical layers can evolve independently.
/// </summary>
public interface IShardokGameStateProvider {
/// <summary>Server-reported Shardok battle status. Null if no status received
/// yet.</summary>
ShardokServerStatus ShardokServerStatus { get; }
}
/// <summary>
/// Simple UI component to display connection status and reconnection countdown.
/// Attach to a TextMeshProUGUI component to display status.
@@ -28,6 +38,7 @@ namespace eagle {
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
private IShardokGameStateProvider _shardokGameStateProvider;
private string _environmentName;
[Tooltip("Optional text component to display connected environment (prod/qa)")]
@@ -68,6 +79,15 @@ namespace eagle {
_gameStateProvider = provider;
}
/// <summary>
/// Set the Shardok game state provider for showing battle-specific status.
/// Call this when entering a Shardok battle, pass null when leaving so the Eagle
/// strategic status takes over again.
/// </summary>
public void SetShardokGameStateProvider(IShardokGameStateProvider provider) {
_shardokGameStateProvider = provider;
}
/// <summary>
/// Set the environment name to display (e.g., "prod." or "qa.").
/// Pass null to clear the indicator.
@@ -131,6 +151,10 @@ namespace eagle {
}
private string GetConnectedStatusText() {
// While a Shardok battle is on screen, the tactical status takes precedence over the
// strategic one.
if (_shardokGameStateProvider != null) { return GetShardokConnectedStatusText(); }
// If no game state provider, just show "Connected"
if (_gameStateProvider == null) { return "<color=green>●</color> Connected"; }
@@ -160,6 +184,21 @@ namespace eagle {
};
}
private string GetShardokConnectedStatusText() {
var status = _shardokGameStateProvider.ShardokServerStatus;
if (status == null) { return "<color=yellow>●</color> Waiting for server..."; }
return status.Status switch { ShardokServerStatus.Types.Status.YourTurn =>
"<color=green>●</color> Your turn",
ShardokServerStatus.Types.Status.WaitingForAi =>
"<color=green>●</color> Waiting for AI",
ShardokServerStatus.Types.Status.WaitingForHumanPlayer =>
"<color=green>●</color> Waiting for other player",
ShardokServerStatus.Types.Status.Processing =>
"<color=green>●</color> Processing...",
_ => "<color=green>●</color> Connected" };
}
private string GetReconnectingText(DateTime? nextAttempt) {
if (!nextAttempt.HasValue) { return "<color=yellow>●</color> Reconnecting..."; }
@@ -846,6 +846,8 @@ namespace eagle {
"GoToBattle: shardokContainer missing ShardokGameController component");
}
PersistentUIManager.Instance?.connectionStatusUI?.SetShardokGameStateProvider(
shardokModel);
shardokController.SetUpGame(shardokModel);
gameObject.SetActive(false);
}
@@ -869,6 +871,8 @@ namespace eagle {
"GoToBattle: shardokContainer missing ShardokGameController component");
}
PersistentUIManager.Instance?.connectionStatusUI?.SetShardokGameStateProvider(
shardokModel);
shardokController.SetUpGame(shardokModel);
gameObject.SetActive(false);
}
@@ -441,6 +441,10 @@ namespace eagle {
shardokGameModel.HandleAvailableCommands(oneResponse.AvailableCommands);
if (oneResponse.ShardokServerStatus != null) {
shardokGameModel.HandleServerStatus(oneResponse.ShardokServerStatus);
}
// Clear resync flags after successfully receiving updates
ClearShardokResyncFlag(oneResponse.ShardokGameId);
shardokGameModel.SkipAutoPlacement = false;
@@ -313,6 +313,8 @@ namespace Shardok {
endTurnButton.interactable = false;
var gameOver = Model.CanExitBattle;
if (gameOver) {
eagle.PersistentUIManager.Instance?.connectionStatusUI?.SetShardokGameStateProvider(
null);
Model = null;
eagleCanvas.gameObject.SetActive(true);
hexGrid.ClearCellLabels();
@@ -16,7 +16,7 @@ using ShardokGameId = System.String;
using static Net.Eagle0.Eagle.Api.ShardokPlacementCommands.Types;
using Hostility = Net.Eagle0.Common.Hostility;
public class ShardokGameModel {
public class ShardokGameModel : eagle.IShardokGameStateProvider {
private class HeroNameListener : IClientTextListener {
private readonly string _textId;
private readonly ShardokGameModel _model;
@@ -119,6 +119,7 @@ public class ShardokGameModel {
private List<PlayerTotals> PlayerTotals { get; set; }
public List<CommandDescriptor> AvailableCommands { get; private set; } = new();
public Net.Eagle0.Eagle.Api.ShardokServerStatus ShardokServerStatus { get; private set; }
public Action UpdateAction { get; set; }
public bool BattleResetPending { get; set; }
public bool CanExitBattle { get; set; }
@@ -280,6 +281,11 @@ public class ShardokGameModel {
UpdateAction?.Invoke();
}
public void HandleServerStatus(Net.Eagle0.Eagle.Api.ShardokServerStatus status) {
ShardokServerStatus = status;
UpdateAction?.Invoke();
}
public bool HasTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
if (InSetUp) {
return false;
@@ -328,11 +328,29 @@ message ShardokActionResultResponse {
repeated .net.eagle0.shardok.api.ActionResultView action_result_views = 2;
int32 new_result_view_count = 3;
.net.eagle0.shardok.api.AvailableCommands available_commands = 4;
// Server-reported status for this Shardok battle, used by the connection-status UI to
// show whether the player is up, waiting on AI, or waiting on another human.
ShardokServerStatus shardok_server_status = 5;
}
repeated SingleShardokGameResultResponse shardok_game_responses = 1;
}
// Server-reported status for a single Shardok battle. Distinct from ServerGameStatus on purpose:
// the strategic and tactical layers evolve independently and shouldn't share a status enum.
message ShardokServerStatus {
enum Status {
UNKNOWN = 0;
YOUR_TURN = 1; // The receiving player has commands available
WAITING_FOR_AI = 2; // An AI-controlled faction has commands and we're awaiting its move
WAITING_FOR_HUMAN_PLAYER = 3; // Another human faction has commands and we're awaiting their move
PROCESSING = 4; // Server is between turns / resolving action
}
Status status = 1;
// For WAITING_FOR_HUMAN_PLAYER and WAITING_FOR_AI: which faction(s) we're waiting for
repeated int32 waiting_for_faction_ids = 2;
}
// Signal to the client that a tutorial battle has reset (defender lost, battle restarting).
// The client should clear all Shardok battle state for this game ID and prepare for fresh updates.
message ShardokBattleResetResponse {
@@ -11,7 +11,7 @@ import net.eagle0.common.{RandomState, SeededRandom, SimpleTimedLogger}
import net.eagle0.eagle.{FactionId, GameId, ProvinceId, ShardokGameId, UserId}
import net.eagle0.eagle.ai.AIClient
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.eagle.ServerGameStatus
import net.eagle0.eagle.api.eagle.{ServerGameStatus, ShardokServerStatus}
import net.eagle0.eagle.api.eagle.ShardokActionResultResponse.SingleShardokGameResultResponse
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{ShardokViewStatus, StreamingTextStatus}
import net.eagle0.eagle.api.selected_command.SelectedCommand
@@ -94,6 +94,42 @@ object GameController {
else None
}
/**
* Compute the Shardok-side status for one battle from the perspective of a single human player. Used by the
* connection-status indicator while a battle is on screen so the player can see whether they're up, waiting on AI, or
* waiting on another human.
*/
private[controller] def computeShardokServerStatus(
shardokGameId: ShardokGameId,
myFactionId: FactionId,
fullHistory: FullGameHistory,
allFactionIds: Iterable[FactionId],
aiFactionIds: Set[FactionId]
): ShardokServerStatus = {
def hasCommands(fid: FactionId): Boolean =
fullHistory.shardokPlayerAvailableCommands(shardokGameId, fid).exists(_.currentCommand.nonEmpty)
if hasCommands(myFactionId) then ShardokServerStatus(status = ShardokServerStatus.Status.YOUR_TURN)
else {
val factionsToAct = allFactionIds.filter(_ != myFactionId).filter(hasCommands).toVector
val aiToAct = factionsToAct.filter(aiFactionIds.contains)
val humansToAct = factionsToAct.filterNot(aiFactionIds.contains)
if aiToAct.nonEmpty then
ShardokServerStatus(
status = ShardokServerStatus.Status.WAITING_FOR_AI,
waitingForFactionIds = aiToAct
)
else if humansToAct.nonEmpty then
ShardokServerStatus(
status = ShardokServerStatus.Status.WAITING_FOR_HUMAN_PLAYER,
waitingForFactionIds = humansToAct
)
else ShardokServerStatus(status = ShardokServerStatus.Status.PROCESSING)
}
end if
}
/**
* Checks if all human factions are defeated (can't issue commands). A faction is defeated if:
* - It no longer exists in the game state, OR
@@ -223,6 +259,7 @@ object GameController {
visibilityExtensions: Vector[ClientTextVisibilityExtensionT],
engine: Engine,
fullHistory: FullGameHistory,
aiFactionIds: Set[FactionId],
battleProgress: Map[ShardokGameId, BattleProgressTracker],
lastRevealedRound: Int
): Vector[HumanPlayerClientConnectionState] = {
@@ -231,7 +268,8 @@ object GameController {
clientTextStore,
visibilityExtensions,
engine,
fullHistory
fullHistory,
aiFactionIds
)
// Send battle progress updates if there is any progress to report
if battleProgress.isEmpty then afterResults
@@ -260,7 +298,8 @@ object GameController {
clientTextStore: ClientTextStore,
visibilityExtensions: Vector[ClientTextVisibilityExtensionT],
engine: Engine,
fullHistory: FullGameHistory
fullHistory: FullGameHistory,
aiFactionIds: Set[FactionId]
): Vector[HumanPlayerClientConnectionState] =
humanClients.flatMap { client =>
val clientTextUpdates = visibilityExtensions
@@ -302,11 +341,19 @@ object GameController {
shardokGameId = shardokGameId,
factionId = client.factionId
)
val shardokStatus = GameController.computeShardokServerStatus(
shardokGameId = shardokGameId,
myFactionId = client.factionId,
fullHistory = fullHistory,
allFactionIds = engine.factionIds,
aiFactionIds = aiFactionIds
)
SingleShardokGameResultResponse(
shardokGameId = shardokGameId,
actionResultViews = arvs,
newResultViewCount = startingCount + arvs.length,
availableCommands = acs
availableCommands = acs,
shardokServerStatus = Some(shardokStatus)
)
}
@@ -670,6 +717,7 @@ final case class GameController(
visibilityExtensions = Vector.empty,
engine = newEngine,
fullHistory = newHistory,
aiFactionIds = aiClientFactionIds.toSet,
battleProgress = newBattleProgress,
lastRevealedRound = newLastRevealedRound
)
@@ -679,7 +727,8 @@ final case class GameController(
clientTextStore = clientTextStore,
visibilityExtensions = Vector.empty,
engine = newEngine,
fullHistory = newHistory
fullHistory = newHistory,
aiFactionIds = aiClientFactionIds.toSet
)
GameControllerWithPostResults(
@@ -874,6 +923,7 @@ final case class GameController(
visibilityExtensions = clientVisibilityExtensions,
engine = finalEngineAndResults.engine,
fullHistory = newFullHistory,
aiFactionIds = aiClientFactionIds.toSet,
battleProgress = battleProgress,
lastRevealedRound = lastRevealedRound
),