Add text client march warnings (#8423)

This commit is contained in:
2026-07-10 16:00:26 -07:00
committed by GitHub
parent c358567a3f
commit 43ee2bfd1f
@@ -11,6 +11,7 @@ import scala.util.{Failure, Success, Try}
import io.grpc.{ManagedChannel, ManagedChannelBuilder, Metadata}
import io.grpc.stub.{MetadataUtils, StreamObserver}
import net.eagle0.eagle.api.available_command.AvailableCommand as AvailableCommandProto
import net.eagle0.eagle.api.available_command.MarchAvailableCommand
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.eagle.*
import net.eagle0.eagle.api.eagle.EagleGrpc.EagleStub
@@ -319,18 +320,21 @@ final class EagleTextClient(config: Config) {
)
private def postJson(rest: String): Unit = {
val firstSplit = rest.indexOf(' ')
if firstSplit < 0 then println("usage: post-json <province-id> <SelectedCommandType> <json>")
val (force, commandText) =
if rest.startsWith("--force ") then (true, rest.stripPrefix("--force ").trim)
else (false, rest)
val firstSplit = commandText.indexOf(' ')
if firstSplit < 0 then println("usage: post-json [--force] <province-id> <SelectedCommandType> <json>")
else {
val provinceId = rest.take(firstSplit).toInt
val afterId = rest.drop(firstSplit + 1).trim
val provinceId = commandText.take(firstSplit).toInt
val afterId = commandText.drop(firstSplit + 1).trim
val typeSplit = afterId.indexOf(' ')
if typeSplit < 0 then println("usage: post-json <province-id> <SelectedCommandType> <json>")
if typeSplit < 0 then println("usage: post-json [--force] <province-id> <SelectedCommandType> <json>")
else {
val commandType = afterId.take(typeSplit)
val json = afterId.drop(typeSplit + 1).trim
parseSelected(commandType, json) match {
case Success(selected) => postSelected(provinceId, selected)
case Success(selected) => postSelected(provinceId, selected, force)
case Failure(error) => println(s"Could not parse $commandType JSON: ${error.getMessage}")
}
}
@@ -338,25 +342,33 @@ final class EagleTextClient(config: Config) {
end if
}
private def postSelected(provinceId: Int, selected: SelectedCommandProto): Unit =
private def postSelected(provinceId: Int, selected: SelectedCommandProto, force: Boolean = false): Unit =
state.currentGameId match {
case Some(gameId) =>
state.commandToken match {
case Some(token) =>
send(
UpdateStreamRequest(
UpdateStreamRequest.RequestDetails.PostCommandRequest(
PostCommandRequest(
gameId = gameId,
command = EagleCommand(
provinceId = provinceId,
command = selected,
eagleToken = token
val warnings = state.marchWarnings(provinceId, selected)
if warnings.nonEmpty && !force then {
println("March warning:")
warnings.foreach(warning => println(s"- $warning"))
println("Repeat with post-json --force to submit this march.")
} else {
warnings.foreach(warning => println(s"March warning (forced): $warning"))
send(
UpdateStreamRequest(
UpdateStreamRequest.RequestDetails.PostCommandRequest(
PostCommandRequest(
gameId = gameId,
command = EagleCommand(
provinceId = provinceId,
command = selected,
eagleToken = token
)
)
)
)
)
)
}
case None => println("No command token yet. Stream a game and wait for available commands.")
}
case None => println("No current game. Use stream <game-id> first.")
@@ -764,6 +776,97 @@ final class ClientState {
def commandToken: Option[Long] = synchronized(latestCommands.map(_.token))
def marchWarnings(provinceId: Int, command: SelectedCommandProto): Vector[String] = synchronized {
command match {
case march: sc.MarchSelectedCommand =>
val state = latestState
val origin = state.flatMap(_.provinces.get(march.originProvince))
val originName = origin.map(_.name).filter(_.nonEmpty).getOrElse(s"province ${march.originProvince}")
val destination = state.flatMap(_.provinces.get(march.destinationProvinceId))
val destinationName =
destination.map(_.name).filter(_.nonEmpty).getOrElse(s"province ${march.destinationProvinceId}")
val selectedHeroIds = march.marchingUnits.map(_.heroId).toSet
val originHeroIds = origin.flatMap(_.fullInfo).map(_.rulingFactionHeroIds.toSet).getOrElse(Set.empty)
val abandonWarning =
Option.when(originHeroIds.nonEmpty && originHeroIds.subsetOf(selectedHeroIds))(
s"This march would abandon $originName."
)
val marchAvailable = availableMarchCommand(provinceId)
val originCommand =
marchAvailable.flatMap(_.oneProvinceCommands.find(_.originProvinceId == march.originProvince))
val selectedBattalionIds = march.marchingUnits.flatMap(_.battalionId).toSet
val monthlyFood = originCommand.toVector
.flatMap(_.availableBattalions)
.filter(battalion => selectedBattalionIds.contains(battalion.battalionId))
.map(_.monthlyFood)
.sum
val foodWarning =
Option.when(monthlyFood > 0 && march.food < 2 * monthlyFood)(
f"The selected food covers less than two months of these battalions ($monthlyFood%.0f monthly)."
)
val restrictiveHeroIds = for {
oneProvince <- originCommand.toVector
unit <- march.marchingUnits
battalionId <- unit.battalionId.toVector
suitability <- oneProvince.suitableBattalionsForHeroes.get(unit.heroId).toVector
battalionSuitability <- suitability.battalionIdsWithSuitability.find(_.battalionId == battalionId)
if battalionSuitability.level.toString == "RESTRICTIVE"
} yield unit.heroId
val restrictiveUnitsWarning =
Option.when(restrictiveHeroIds.nonEmpty)(
"One or more heroes are leading a battalion type that would restrict their abilities."
)
val originFactionId = origin.flatMap(_.rulingFactionId)
val destinationFactionId = destination.flatMap(_.rulingFactionId)
val truceWarning = for {
gameState <- state
factionId <- originFactionId
targetFactionId <- destinationFactionId
if factionId != targetFactionId
relationship <- gameState.factions
.get(factionId)
.toVector
.flatMap(_.factionRelationships)
.find(_.targetFactionId == targetFactionId)
if relationship.relationshipLevel.toString != "HOSTILE"
} yield s"$destinationName belongs to a faction with which you have a treaty."
val noBattalionsWarning =
Option.when(
selectedBattalionIds.isEmpty && destinationFactionId.exists(_ != originFactionId.getOrElse(-1))
)(
s"$destinationName is hostile and no battalion was selected."
)
val riverWarning = for {
oneProvince <- originCommand
destinationInfo <- oneProvince.availableDestinationProvinces.find(_.provinceId == march.destinationProvinceId)
if destinationInfo.requiresRiverCrossing
if destinationFactionId != originFactionId
} yield s"Assaulting $destinationName may require crossing a river."
Vector(
abandonWarning,
foodWarning,
restrictiveUnitsWarning,
truceWarning,
noBattalionsWarning,
riverWarning
).flatten
case _ => Vector.empty
}
}
private def availableMarchCommand(provinceId: Int): Option[MarchAvailableCommand] =
latestCommands
.flatMap(_.commandsByProvince.get(provinceId))
.flatMap(_.commands.collectFirst { case command: MarchAvailableCommand => command })
def shardokCommandPost(shardokGameId: Option[String], index: Int): Option[ShardokCommandPost] = synchronized {
val candidates = shardokGameId match {
case Some(id) => latestShardokBattles.get(id).toVector
@@ -1027,7 +1130,7 @@ object Help {
| shardok-commands-json
| shardok-units
| shardok-units-json
| post-json <province-id> <SelectedCommandType> <JSON>
| post-json [--force] <province-id> <SelectedCommandType> <JSON>
| post-shardok <index> [roll]
| post-shardok <shardok-game-id> <index> [roll]
| post-rest <province-id>