Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 823e56fd2d Client displays server-reported game status
Update client to use ServerGameStatus from ActionResultResponse:
- IGameStateProvider now has ServerStatus instead of inferring state
- GameModelUpdater stores ServerStatus when receiving ActionResultResponse
- ConnectionStatusUI displays server-reported status:
  - YOUR_TURN -> "Your turn"
  - WAITING_FOR_PLAYERS -> "Waiting for other players"
  - GENERATING_TEXT -> "Generating..."
  - PROCESSING_ACTION -> "Processing..."

Client-side IsProcessingCommand still takes priority (for responsive
feedback when submitting commands, before server responds).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 10:28:28 -08:00
adminandClaude Opus 4.5 8a619a9e9e Server sends ServerGameStatus in ActionResultResponse
Include server-reported game status in every ActionResultResponse:
- YOUR_TURN: when availableCommands is present with commands
- WAITING_FOR_PLAYERS: when no commands available

This allows the client to display accurate server state rather than
inferring it from local data. Detecting mismatches between server
status and client state can reveal desync issues.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 10:26:51 -08:00
adminandClaude Opus 4.5 6002f1b483 Add ServerGameStatus proto for server-reported game state
Add ServerGameStatus message to ActionResultResponse:
- YOUR_TURN: Player has commands available
- WAITING_FOR_PLAYERS: Waiting for other player(s) to act
- GENERATING_TEXT: LLM text generation in progress
- PROCESSING_ACTION: Server is processing an action

Includes waiting_for_faction_ids and generating_llm_id for additional context.

This allows the client to display accurate server state rather than
inferring it from local data, which enables detecting desync issues.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 10:24:43 -08:00
adminandClaude Opus 4.5 a6c67db11c Add game state to connection status indicator
When connected, the status indicator now shows game-specific state:
- "Generating..." - LLM text generation in progress (highest priority)
- "Processing..." - Command submitted, awaiting response (only if > 500ms)
- "Your turn" - Player has available commands
- "Waiting for other players" - No commands, waiting for opponents

Implementation:
- Add IGameStateProvider interface in ConnectionStatusUI.cs
- Implement interface in GameModelUpdater with:
  - HasAvailableCommands: check AvailableCommandsByProvince and CommandToken
  - IsStreamingTextInProgress: check ClientTextProvider for incomplete entries
  - IsProcessingCommand: track command submission time (500ms delay to avoid flash)
- Wire up in EagleGameController when entering/leaving game

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 10:09:20 -08:00
5 changed files with 127 additions and 4 deletions
@@ -1,8 +1,22 @@
using System;
using Net.Eagle0.Eagle.Api;
using TMPro;
using UnityEngine;
namespace eagle {
/// <summary>
/// Provides game state information for the connection status UI.
/// Implement this interface to show game-specific status when connected.
/// </summary>
public interface IGameStateProvider {
/// <summary>Server-reported game status. Null if no status received yet.</summary>
ServerGameStatus ServerStatus { get; }
/// <summary>True if a command was submitted and we're awaiting response (for >
/// 500ms).</summary>
bool IsProcessingCommand { get; }
}
/// <summary>
/// Simple UI component to display connection status and reconnection countdown.
/// Attach to a TextMeshProUGUI component to display status.
@@ -10,6 +24,7 @@ namespace eagle {
public class ConnectionStatusUI : MonoBehaviour {
private TextMeshProUGUI _textComponent;
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
// Update interval in seconds
private const float UpdateInterval = 0.5f;
@@ -36,6 +51,14 @@ namespace eagle {
_connection = connection;
}
/// <summary>
/// Set the game state provider for showing game-specific status.
/// Call this when entering a game, clear it when leaving.
/// </summary>
public void SetGameStateProvider(IGameStateProvider provider) {
_gameStateProvider = provider;
}
void Update() {
if (_connection == null || _textComponent == null) { return; }
@@ -72,7 +95,7 @@ namespace eagle {
var nextAttempt = _connection.NextReconnectAttempt;
string statusText = state switch {
ConnectionState.Connected => "<color=green>●</color> Connected",
ConnectionState.Connected => GetConnectedStatusText(),
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
ConnectionState.Reconnecting => GetReconnectingText(nextAttempt),
@@ -83,6 +106,34 @@ namespace eagle {
_textComponent.text = statusText;
}
private string GetConnectedStatusText() {
// If no game state provider, just show "Connected"
if (_gameStateProvider == null) { return "<color=green>●</color> Connected"; }
// Processing takes priority (client knows it submitted a command)
if (_gameStateProvider.IsProcessingCommand) {
return "<color=green>●</color> Processing...";
}
// Use server-reported status
var serverStatus = _gameStateProvider.ServerStatus;
if (serverStatus == null) {
// No server status yet - waiting for first response
return "<color=green>●</color> Connected";
}
return serverStatus.Status switch {
ServerGameStatus.Types.Status.YourTurn => "<color=green>●</color> Your turn",
ServerGameStatus.Types.Status.WaitingForPlayers =>
"<color=green>●</color> Waiting for other players",
ServerGameStatus.Types.Status.GeneratingText =>
"<color=green>●</color> Generating...",
ServerGameStatus.Types.Status.ProcessingAction =>
"<color=green>●</color> Processing...",
_ => "<color=green>●</color> Connected"
};
}
private string GetReconnectingText(DateTime? nextAttempt) {
if (!nextAttempt.HasValue) { return "<color=yellow>●</color> Reconnecting..."; }
@@ -165,6 +165,10 @@ namespace eagle {
Model = null;
chronicleCanvasController.Entries = new List<ChronicleEntry>();
SetMusic();
// Clear game state provider when leaving game
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) { statusUI.SetGameStateProvider(null); }
}
void MapControllerChangedTarget(List<ProvinceId> newTarget) {
@@ -263,6 +267,10 @@ namespace eagle {
ModelUpdater.ErrorHandler = errorHandler;
// Set up game state provider for connection status UI
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) { statusUI.SetGameStateProvider(ModelUpdater); }
// Fire-and-forget - subscription is awaited internally and failures are logged
MainQueue.Q.EnqueueForNextUpdate(
() => { _ = ModelUpdater.StartListeningForUpdates(); });
@@ -62,7 +62,7 @@ namespace eagle {
RollFetcher RollFetcher { get; }
}
public class GameModelUpdater : IClientConnectionSubscriber {
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
private FactionId? PlayerId => _currentModel.PlayerId;
public Int64? CurrentEagleToken => _currentModel.CommandToken;
public Int64? CurrentShardokToken(string shardokGameId) {
@@ -126,6 +126,11 @@ namespace eagle {
private readonly Logger _connectionLogger = Logger.GetLogger("ConnectionLogger");
// Track when a command was submitted for "Processing..." display
// Only show "Processing..." if command has been pending for > 500ms
private DateTime? _commandSubmittedTime;
private const double ProcessingDisplayDelayMs = 500.0;
// Track which Shardok games need full state resync after connection drop
// Thread-safe: accessed from both connection thread and Unity main thread
private readonly ConcurrentDictionary<string, bool> _shardokNeedsResync =
@@ -284,6 +289,14 @@ namespace eagle {
switch (updateItem.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
// Clear processing state - we received a response from the server
_commandSubmittedTime = null;
// Store server-reported game status for UI
if (updateItem.ActionResultResponse.ServerGameStatus != null) {
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
}
_lastUnfilteredResultCount =
updateItem.ActionResultResponse.UnfilteredResultCountAfter;
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
@@ -405,6 +418,7 @@ namespace eagle {
_currentModel.AvailableCommandsByProvince.Clear();
_currentModel.CommandToken = null;
_currentModel.LastPostedToken = token;
_commandSubmittedTime = DateTime.UtcNow;
return PersistentConnection.PostEagleCommand(
gameId: GameId,
token: token,
@@ -766,5 +780,21 @@ namespace eagle {
Notify(result);
}
}
#region IGameStateProvider implementation
/// <summary>Server-reported game status from the last ActionResultResponse.</summary>
public ServerGameStatus ServerStatus { get; private set; }
/// <summary>
/// True if a command was submitted and we're awaiting response.
/// Only returns true if processing for > 500ms to avoid flashing.
/// </summary>
public bool IsProcessingCommand =>
_commandSubmittedTime.HasValue &&
(DateTime.UtcNow - _commandSubmittedTime.Value).TotalMilliseconds >
ProcessingDisplayDelayMs;
#endregion
}
}
@@ -289,6 +289,25 @@ message ActionResultResponse {
int32 unfiltered_result_count_after = 1;
repeated .net.eagle0.eagle.views.ActionResultView action_result_views = 2;
AvailableCommands available_commands = 3;
// Server-reported game status for connection status UI
ServerGameStatus server_game_status = 4;
}
// Server-reported game status for the connection status indicator.
// Tells the client what the server is doing/waiting for.
message ServerGameStatus {
enum Status {
UNKNOWN = 0;
YOUR_TURN = 1; // Player has commands available
WAITING_FOR_PLAYERS = 2; // Waiting for other player(s) to act
GENERATING_TEXT = 3; // LLM text generation in progress
PROCESSING_ACTION = 4; // Server is processing an action
}
Status status = 1;
// For WAITING_FOR_PLAYERS: which faction(s) we're waiting for
repeated int32 waiting_for_faction_ids = 2;
// For GENERATING_TEXT: which LLM stream is being generated
string generating_llm_id = 3;
}
message ShardokActionResultResponse {
@@ -7,7 +7,13 @@ import scala.annotation.tailrec
import net.eagle0.eagle.{FactionId, GameId, ShardokGameId}
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.eagle.{ActionResultResponse, GameUpdate, ShardokActionResultResponse, UpdateStreamResponse}
import net.eagle0.eagle.api.eagle.{
ActionResultResponse,
GameUpdate,
ServerGameStatus,
ShardokActionResultResponse,
UpdateStreamResponse
}
import net.eagle0.eagle.api.eagle.GameUpdate.GameUpdateDetails
import net.eagle0.eagle.api.eagle.ShardokActionResultResponse.SingleShardokGameResultResponse
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{ShardokViewStatus, StreamingTextStatus}
@@ -358,6 +364,14 @@ case class HumanPlayerClientConnectionState(
unfilteredCountAfter: Int,
availableCommands: Option[AvailableCommands]
): HumanPlayerClientConnectionState = {
// Determine server game status based on available commands
val serverGameStatus = availableCommands match {
case Some(cmds) if cmds.commandsByProvince.nonEmpty =>
Some(ServerGameStatus(status = ServerGameStatus.Status.YOUR_TURN))
case _ =>
Some(ServerGameStatus(status = ServerGameStatus.Status.WAITING_FOR_PLAYERS))
}
send(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.GameUpdate(
@@ -368,7 +382,8 @@ case class HumanPlayerClientConnectionState(
ActionResultResponse(
actionResultViews = filteredResults,
unfilteredResultCountAfter = unfilteredCountAfter,
availableCommands = availableCommands
availableCommands = availableCommands,
serverGameStatus = serverGameStatus
)
)
)