Add Shardok map queries to text client (#8558)

This commit is contained in:
2026-07-14 14:15:20 -07:00
committed by GitHub
parent 562d32d296
commit 66ecc25075
3 changed files with 263 additions and 7 deletions
+13
View File
@@ -133,6 +133,19 @@ Shardok update with no available commands clears the previous command list.
Use `shardok-commands-json` or `shardok-units-json` when the concise output omits a needed field.
The client synchronizes Shardok map definitions when it connects. To inspect the
base terrain together with current tile modifiers and occupying units, use:
```text
shardok-map <shardok-game-id>
shardok-map-json <shardok-game-id>
```
The battle ID may be omitted when the client knows about exactly one Shardok
battle. The concise map prints every coordinate's terrain plus fire, castle,
bridge, ice, and snow state. The JSON form includes the complete base map,
dynamic tile modifiers, and current active units.
## Reconnects And Shutdown
The client automatically recreates a failed gRPC stream and re-subscribes to the active game. It retains its in-process `ClientTextStore`, so a reconnect should announce:
@@ -30,7 +30,10 @@ import net.eagle0.eagle.views.tutorial_dialogue.{TutorialDialogue, TutorialTextR
import net.eagle0.shardok.api.command_descriptor.{AvailableCommands as ShardokAvailableCommands, CommandDescriptor}
import net.eagle0.shardok.api.game_state_view.GameStateViewDiff as ShardokGameStateViewDiff
import net.eagle0.shardok.api.unit_view.UnitView
import net.eagle0.shardok.common.coords.Coords
import net.eagle0.shardok.common.hex_map.HexMap
import net.eagle0.shardok.common.tile_modifier.TileModifier
import net.eagle0.shardok.common.tile_modifier_with_coords.TileModifierWithCoords
import net.eagle0.shardok.common.weather.Weather
import scalapb.{GeneratedMessage, GeneratedMessageCompanion}
import scalapb.json4s.{JsonFormat, Printer}
@@ -230,6 +233,12 @@ final class EagleTextClient(config: Config) {
case Vector("shardok-commands-json") => println(state.describeShardokCommands(rawJson = true))
case Vector("shardok-units") => println(state.describeShardokUnits(rawJson = false))
case Vector("shardok-units-json") => println(state.describeShardokUnits(rawJson = true))
case Vector("shardok-map") => println(state.describeShardokMap(None, rawJson = false))
case Vector("shardok-map", battleId) =>
println(state.describeShardokMap(Some(battleId), rawJson = false))
case Vector("shardok-map-json") => println(state.describeShardokMap(None, rawJson = true))
case Vector("shardok-map-json", battleId) =>
println(state.describeShardokMap(Some(battleId), rawJson = true))
case Vector("post-shardok", index) if isInt(index) =>
postShardok(None, index.toInt, roll = None)
case Vector("post-shardok", index, roll) if isInt(index) && isInt(roll) =>
@@ -424,8 +433,16 @@ final class EagleTextClient(config: Config) {
private def openRequestStream(): Unit = synchronized {
requestObserver = Some(stub.streamUpdates(responseObserver))
requestHexMaps()
}
private def requestHexMaps(): Unit =
send(
UpdateStreamRequest(
UpdateStreamRequest.RequestDetails.HexMapsRequest(state.hexMapsRequest)
)
)
private def reconnect(): Unit =
if running.get() && reconnecting.compareAndSet(false, true) then {
synchronized { requestObserver = None }
@@ -513,6 +530,7 @@ final class ClientState {
private var latestGameId: Option[Long] = None
private var latestUnfilteredResultCount: Int = 0
private var latestShardokBattles: Map[String, ShardokBattleState] = Map.empty
private var hexMaps: Map[String, CachedHexMap] = Map.empty
private val textStore = new ClientTextStore
private var pendingTutorialDialogues = Vector.empty[TutorialDialogue]
private var seenTutorialDialogueIds = Set.empty[String]
@@ -598,6 +616,11 @@ final class ClientState {
println(s"client-update: ${value.version} required=${value.required} ${value.message}")
case UpdateStreamResponse.ResponseDetails.HeartbeatResponse(_) =>
println("heartbeat")
case UpdateStreamResponse.ResponseDetails.HexMapResponse(value) =>
value.mapInfos.foreach { info =>
info.map.foreach(map => hexMaps += info.mapName -> CachedHexMap(info.mapHash, map))
}
println(s"hex-maps: received=${value.mapInfos.count(_.map.isDefined)} cached=${hexMaps.size}")
case UpdateStreamResponse.ResponseDetails.DropGameResponse(value) =>
println(s"drop-game: ${value.result} game=${value.gameId}")
case other =>
@@ -897,6 +920,15 @@ final class ClientState {
def currentGameId: Option[Long] = synchronized(latestGameId)
def hexMapsRequest: HexMapsRequest = synchronized {
HexMapsRequest(
mapInfos = hexMaps.toVector.sortBy(_._1).map {
case (mapName, cached) =>
HexMapsRequest.OneMapRequestInfo(mapName = mapName, mapHash = cached.hash)
}
)
}
def unfilteredResultCount: Int = synchronized(latestUnfilteredResultCount)
def streamingTextStatuses: Seq[UpdateStreamRequest.StreamGameRequest.StreamingTextStatus] = synchronized {
@@ -1173,7 +1205,9 @@ final class ClientState {
.sortBy(unit => (unit.playerId, unit.unitId))
.map(unit => s" ${shardokUnitSummary(unit, "captured", battle, rawJson)}")
else Vector(" no known units")
val tileLines = battle.tileModifiers.toVector.sortBy { case ((row, column), _) => (row, column) }.map {
val tileLines = battle.tileModifiers.toVector.filter {
case (_, modifiers) => ShardokBattleSnapshot.nonEmpty(modifiers)
}.sortBy { case ((row, column), _) => (row, column) }.map {
case ((row, column), modifiers) =>
val occupants = battle.units.values
.filter(_.location.exists(c => c.row == row && c.column == column))
@@ -1188,6 +1222,83 @@ final class ClientState {
.mkString("\n")
}
def describeShardokMap(shardokGameId: Option[String], rawJson: Boolean): String = synchronized {
selectedShardokBattle(shardokGameId) match {
case Left(error) => error
case Right(battle) =>
battleMapName(battle.shardokGameId) match {
case None => s"No map name is available for battle ${battle.shardokGameId}."
case Some(mapName) =>
hexMaps.get(mapName) match {
case None => s"Map $mapName is not cached yet. Wait for the hex-maps response and try again."
case Some(cached) =>
if rawJson then shardokMapJson(battle, mapName, cached.map)
else shardokMapText(battle, mapName, cached.map)
}
}
}
}
private def selectedShardokBattle(shardokGameId: Option[String]): Either[String, ShardokBattleState] =
shardokGameId match {
case Some(id) => latestShardokBattles.get(id).toRight(s"No Shardok state for battle $id.")
case None =>
latestShardokBattles.values.toVector match {
case Vector(only) => Right(only)
case Vector() => Left("No Shardok battles yet.")
case battles =>
Left(
s"Multiple Shardok battles are available: ${battles.map(_.shardokGameId).sorted.mkString(", ")}. Specify a battle ID."
)
}
}
private def battleMapName(shardokGameId: String): Option[String] =
latestState.toVector
.flatMap(_.outstandingBattles)
.find(_.shardokGameId.contains(shardokGameId))
.flatMap(_.mapName)
private def shardokMapText(battle: ShardokBattleState, mapName: String, hexMap: HexMap): String = {
val header = s"battle ${battle.shardokGameId} map=$mapName rows=${hexMap.rowCount} columns=${hexMap.columnCount}"
val cells = hexMap.terrain.zipWithIndex.map {
case (terrain, index) =>
val row = index / hexMap.columnCount
val column = index % hexMap.columnCount
val coords = (row, column)
val modifiers = battle.tileModifiers.get(coords) match {
case Some(dynamic) => Option.when(ShardokBattleSnapshot.nonEmpty(dynamic))(dynamic)
case None => terrain.modifier.filter(ShardokBattleSnapshot.nonEmpty)
}
val modifier = modifiers.map(value => s" ${tileModifierSummary(value)}").getOrElse("")
val occupants = battle.units.values
.filter(_.location.exists(c => c.row == row && c.column == column))
.map(_.unitId)
.toVector
.sorted
val occupied = if occupants.nonEmpty then occupants.mkString(" occupied-by=", ",", "") else ""
s" ($row,$column) ${terrain.`type`}$modifier$occupied"
}
(header +: cells).mkString("\n")
}
private def shardokMapJson(battle: ShardokBattleState, mapName: String, hexMap: HexMap): String = {
val dynamicModifiers = battle.tileModifiers.toVector.sortBy { case ((row, column), _) => (row, column) }.map {
case ((row, column), modifiers) =>
JsonFormat.toJsonString(
TileModifierWithCoords(
coords = Some(Coords(row = row, column = column)),
modifiers = Some(modifiers)
)
)
}.mkString("[", ",", "]")
val units = battle.units.values.toVector.sortBy(_.unitId).map(JsonFormat.toJsonString).mkString("[", ",", "]")
s"{\"battleId\":\"${jsonEscape(battle.shardokGameId)}\",\"mapName\":\"${jsonEscape(mapName)}\",\"baseMap\":${JsonFormat.toJsonString(hexMap)},\"dynamicTileModifiers\":$dynamicModifiers,\"units\":$units}"
}
private def jsonEscape(value: String): String =
value.replace("\\", "\\\\").replace("\"", "\\\"")
private def shardokBattleHeader(battle: ShardokBattleState): String = {
val serverStatus = battle.status.map(_.status).getOrElse("-")
val gameStatus = battle.gameStatus.map(_.toString).getOrElse("-")
@@ -1364,14 +1475,13 @@ final case class ShardokBattleSnapshot(
possibleChargeeIds: Vector[Int]
) {
def updated(diff: ShardokGameStateViewDiff): ShardokBattleSnapshot = {
val changedModifiers = diff.changedTileModifiers.flatMap { changed =>
val changedModifiers = diff.changedTileModifiers.flatMap { changed =>
for
coords <- changed.coords
modifiers <- changed.modifiers
yield (coords.row, coords.column) -> modifiers
}.toMap
val withModifiers = tileModifiers ++ changedModifiers
val nonEmptyModifiers = withModifiers.filter { case (_, modifiers) => ShardokBattleSnapshot.nonEmpty(modifiers) }
val withModifiers = tileModifiers ++ changedModifiers
copy(
units = (units -- diff.removedUnits) ++ diff.changedUnits.map(unit => unit.unitId -> unit),
@@ -1379,7 +1489,9 @@ final case class ShardokBattleSnapshot(
(reserveUnits -- diff.removedReserveUnitIds) ++ diff.changedReserveUnits.map(unit => unit.unitId -> unit),
capturedUnits =
(capturedUnits -- diff.removedCapturedUnitIds) ++ diff.changedCapturedUnits.map(unit => unit.unitId -> unit),
tileModifiers = nonEmptyModifiers,
// Retain empty modifiers as tombstones: they distinguish a removed bridge/castle/etc. from a tile
// that has never received a dynamic update and should still use its base-map modifier.
tileModifiers = withModifiers,
weather = diff.nextWeather.orElse(weather),
round = diff.nextRound.orElse(round),
currentPlayer = diff.nextPlayer.orElse(currentPlayer),
@@ -1420,7 +1532,7 @@ object ShardokBattleSnapshot {
possibleChargeeIds = battle.possibleChargeeIds
)
private def nonEmpty(modifiers: TileModifier): Boolean =
def nonEmpty(modifiers: TileModifier): Boolean =
modifiers.fire.nonEmpty || modifiers.castle.nonEmpty || modifiers.bridge.nonEmpty || modifiers.ice.nonEmpty ||
modifiers.snow.nonEmpty
}
@@ -1442,6 +1554,8 @@ final case class ShardokBattleState(
possibleChargeeIds: Vector[Int]
)
final case class CachedHexMap(hash: Long, map: HexMap)
final case class ShardokCommandPost(
shardokGameId: String,
playerId: Int,
@@ -1484,6 +1598,8 @@ object Help {
| shardok-commands-json
| shardok-units
| shardok-units-json
| shardok-map [shardok-game-id]
| shardok-map-json [shardok-game-id]
| post-json [--force] <province-id> <SelectedCommandType> <JSON>
| post-shardok <index> [integer-roll]
| post-shardok <shardok-game-id> <index> [integer-roll]
@@ -4,7 +4,14 @@ import java.io.{ByteArrayOutputStream, PrintStream}
import net.eagle0.eagle.api.available_command.{ImproveAvailableCommand, ResolveRansomOfferAvailableCommand}
import net.eagle0.eagle.api.command.{AvailableCommands as EagleAvailableCommands, OneProvinceAvailableCommands}
import net.eagle0.eagle.api.eagle.{ActionResultResponse, GameUpdate, ShardokActionResultResponse, UpdateStreamResponse}
import net.eagle0.eagle.api.eagle.{
ActionResultResponse,
GameUpdate,
HexMapResponse,
HexMapsRequest,
ShardokActionResultResponse,
UpdateStreamResponse
}
import net.eagle0.eagle.api.streaming_text_response.StreamingTextResponse
import net.eagle0.eagle.common.date.Date
import net.eagle0.eagle.common.diplomacy_offer.DiplomacyOffer
@@ -21,7 +28,9 @@ import net.eagle0.shardok.api.game_state_view.GameStateViewDiff as ShardokGameSt
import net.eagle0.shardok.api.unit_view.{BattalionView, HeroView as ShardokHeroView, UnitView}
import net.eagle0.shardok.common.coords.Coords
import net.eagle0.shardok.common.game_status.GameStatus
import net.eagle0.shardok.common.hex_map.HexMap
import net.eagle0.shardok.common.hostility.Hostility
import net.eagle0.shardok.common.terrain.Terrain
import net.eagle0.shardok.common.tile_modifier.TileModifier
import net.eagle0.shardok.common.tile_modifier_with_coords.TileModifierWithCoords
import net.eagle0.shardok.common.weather.Weather
@@ -459,6 +468,124 @@ class ClientStateTest extends AnyFlatSpec with Matchers {
output should include("tile (5,6) modifiers=fire:70.0,castle:85.0 occupied-by=12")
}
it should "cache synchronized maps and describe battle terrain" in {
val state = new ClientState
state.handle(
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.HexMapResponse(
HexMapResponse(
mapInfos = Vector(
HexMapResponse.OneMapResponseInfo(
mapName = "Onmaa",
mapHash = 1234L,
map = Some(
HexMap(
rowCount = 2,
columnCount = 2,
terrain = Vector(
Terrain(
`type` = Terrain.Type.PLAINS,
modifier = Some(
TileModifier(bridge = Some(TileModifier.SingleModifier(integrity = Some(100.0))))
)
),
Terrain(`type` = Terrain.Type.FOREST),
Terrain(`type` = Terrain.Type.RIVER),
Terrain(
`type` = Terrain.Type.SWAMP,
modifier = Some(
TileModifier(castle = Some(TileModifier.SingleModifier(integrity = Some(90.0))))
)
)
)
)
)
)
)
)
)
)
)
state.handle(
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.GameUpdate(
GameUpdate(
gameId = 1L,
startingState = Some(
GameStateView(
gameId = 1L,
outstandingBattles = Vector(
ShardokBattleView(shardokGameId = Some("battle-map"), mapName = Some("Onmaa"))
)
)
)
)
)
)
)
state.handle(
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.GameUpdate(
GameUpdate(
gameId = 1L,
gameUpdateDetails = GameUpdate.GameUpdateDetails.ShardokActionResultResponse(
ShardokActionResultResponse(
shardokGameResponses = Vector(
ShardokActionResultResponse.SingleShardokGameResultResponse(
shardokGameId = "battle-map",
newResultViewCount = 1,
actionResultViews = Vector(
ShardokActionResultView(
gameStateViewDiff = Some(
ShardokGameStateViewDiff(
changedUnits = Vector(
UnitView(
unitId = 7,
playerId = 1,
location = Some(Coords(row = 0, column = 1))
)
),
changedTileModifiers = Vector(
TileModifierWithCoords(
coords = Some(Coords(row = 0, column = 0)),
modifiers = Some(TileModifier())
),
TileModifierWithCoords(
coords = Some(Coords(row = 1, column = 0)),
modifiers = Some(
TileModifier(fire = Some(TileModifier.SingleModifier(integrity = Some(65.0))))
)
)
)
)
)
)
)
)
)
)
)
)
)
)
)
state.hexMapsRequest.mapInfos should contain only HexMapsRequest.OneMapRequestInfo(
mapName = "Onmaa",
mapHash = 1234L
)
val concise = state.describeShardokMap(Some("battle-map"), rawJson = false)
concise should include("battle battle-map map=Onmaa rows=2 columns=2")
concise.linesIterator.find(_.contains("(0,0)")) shouldBe Some(" (0,0) PLAINS")
concise should include("(0,1) FOREST occupied-by=7")
concise should include("(1,0) RIVER modifiers=fire:65.0")
concise should include("(1,1) SWAMP modifiers=castle:90.0")
val json = state.describeShardokMap(Some("battle-map"), rawJson = true)
json should include("\"battleId\":\"battle-map\"")
json should include("\"mapName\":\"Onmaa\"")
json should include("\"dynamicTileModifiers\"")
}
it should "post implicitly only when exactly one outstanding battle has commands" in {
val state = new ClientState
state.handle(startingState("battle-1", "battle-2"))