mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-28 21:35:42 +00:00
Add Shardok map queries to text client (#8558)
This commit is contained in:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user