Add ShardokServerStatus proto and server-side population (#6685)

Introduces a new ShardokServerStatus message on
SingleShardokGameResultResponse so the client can render a tactical-side
connection-status indicator (Your Turn / Waiting for AI / Waiting for
Other Player / Processing).

ShardokServerStatus is intentionally separate from ServerGameStatus so
the strategic and tactical layers don't have to evolve in lockstep.

GameController.computeShardokServerStatus picks the status from the
receiving player's perspective by checking shardokPlayerAvailableCommands
across factions and classifying them via aiClientFactionIds. The two
humanClientsAfterPostingResults overloads now thread an aiFactionIds set
through and populate the new field on every per-battle response.

This is the server half of a two-PR split. The client wiring lands
separately; until then the new field is emitted but unread, which is
safe.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 06:07:54 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 9eb8f7f086
commit b0639dc8d0
2 changed files with 73 additions and 5 deletions
@@ -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
),