Expose Eagle province graph in text client (#8625)

* Expose Eagle province graph in text client

* Limit province views to strategic adjacency

* Isolate queued battle result Unity test
This commit is contained in:
2026-07-15 15:25:45 -07:00
committed by GitHub
parent 45a2d50b57
commit b1b44ebb6c
13 changed files with 126 additions and 15 deletions
+11
View File
@@ -131,6 +131,17 @@ 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 initial Eagle state includes the complete strategic province graph. Inspect it with:
```text
eagle-map
eagle-map-json
```
Both forms list every province and all of its neighbors by ID and name. Use the JSON form when exact IDs or
machine-readable relationships are needed; unlike March destinations, this is the complete map graph and is not
filtered by the commands currently available.
During battle setup, `shardok-commands` prints any server-recommended unit placements in order and identifies the
ordinary current command index for the next recommendation:
@@ -106,6 +106,7 @@ namespace eagle0.Tests {
};
updater.ReceiveGameUpdate(new GameUpdate {
GameId = gameId,
StartingState = new GameStateView { GameId = gameId },
ActionResultResponse = new ActionResultResponse()
});
@@ -34,6 +34,8 @@ message ProvinceView {
repeated IncomingArmyView known_incoming_armies = 5;
.net.eagle0.eagle.common.Date as_of = 7;
repeated int32 neighbor_province_ids = 8;
}
message UnaffiliatedHeroBasics {
@@ -69,7 +69,8 @@ object ProvinceViewFilter {
rulingFactionId = province.rulingFactionId,
fullInfo = Some(info),
knownEvents = Vector.empty,
asOf = None
asOf = None,
neighborProvinceIds = province.neighbors.map(_.provinceId)
)
}
@@ -131,7 +132,8 @@ object ProvinceViewFilter {
rulingFactionId = province.rulingFactionId,
fullInfo = Some(info),
knownEvents = Vector.empty,
asOf = Some(gs.currentDate)
asOf = Some(gs.currentDate),
neighborProvinceIds = province.neighbors.map(_.provinceId)
)
}
@@ -203,7 +205,8 @@ object ProvinceViewFilter {
fullInfo = Some(fullInfo),
knownEvents = knownEvents.toVector,
asOf = reconnedView.flatMap(_.asOf),
knownIncomingArmies = incomingAttackers
knownIncomingArmies = incomingAttackers,
neighborProvinceIds = province.neighbors.map(_.provinceId)
)
}
@@ -273,7 +276,8 @@ object ProvinceViewFilter {
fullInfo = fullInfo,
knownEvents = knownEvents,
asOf = if showAll then None else reconnedView.flatMap(_.asOf),
knownIncomingArmies = incomingAttackers ++ incomingSupplies
knownIncomingArmies = incomingAttackers ++ incomingSupplies,
neighborProvinceIds = province.neighbors.map(_.provinceId)
)
}
@@ -47,6 +47,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:event",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/incoming_army",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/province:event",
"//src/main/scala/net/eagle0/eagle/model/view/incoming_army:incoming_army_view",
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
@@ -15,7 +15,8 @@ object ProvinceViewConverter {
fullInfo = view.fullInfo.map(FullProvinceInfoConverter.toProto),
knownEvents = view.knownEvents.map(ProvinceEventConverter.toProto),
knownIncomingArmies = view.knownIncomingArmies.map(IncomingArmyViewConverter.toProto),
asOf = view.asOf.map(DateConverter.toProto)
asOf = view.asOf.map(DateConverter.toProto),
neighborProvinceIds = view.neighborProvinceIds
)
def fromProto(proto: ProvinceViewProto): ProvinceView =
@@ -26,6 +27,7 @@ object ProvinceViewConverter {
fullInfo = proto.fullInfo.map(FullProvinceInfoConverter.fromProto),
knownEvents = proto.knownEvents.map(ProvinceEventConverter.fromProto).toVector,
knownIncomingArmies = proto.knownIncomingArmies.map(IncomingArmyViewConverter.fromProto).toVector,
asOf = proto.asOf.map(d => DateConverter.fromProto(Some(d)))
asOf = proto.asOf.map(d => DateConverter.fromProto(Some(d))),
neighborProvinceIds = proto.neighborProvinceIds.toVector
)
}
@@ -37,6 +37,7 @@ scala_library(
":full_province_info",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/province:event",
"//src/main/scala/net/eagle0/eagle/model/view/incoming_army:incoming_army_view",
],
@@ -12,5 +12,6 @@ case class ProvinceView(
fullInfo: Option[FullProvinceInfo],
knownEvents: Vector[ProvinceEvent],
knownIncomingArmies: Vector[IncomingArmyView] = Vector.empty,
asOf: Option[Date]
asOf: Option[Date],
neighborProvinceIds: Vector[ProvinceId] = Vector.empty
)
@@ -278,7 +278,9 @@ final class EagleTextClient(config: Config) {
case Vector("status") => println(state.describeStatus)
case Vector("state") => println(state.describeState)
case Vector("state-json") => println(state.stateJson)
case Vector("text", textId) => println(state.describeText(textId))
case Vector("eagle-map") => println(state.describeEagleMap(rawJson = false))
case Vector("eagle-map-json") => println(state.describeEagleMap(rawJson = true))
case Vector("text", textId) => println(state.describeText(textId))
case Vector("commands") => println(state.describeCommands(rawJson = false))
case Vector("commands-json") => println(state.describeCommands(rawJson = true))
case Vector("shardok-commands") => println(state.describeShardokCommands(rawJson = false))
@@ -1202,6 +1204,35 @@ final class ClientState {
latestState.map(toJson).getOrElse("{}")
}
def describeEagleMap(rawJson: Boolean): String = synchronized {
latestState match {
case None => "No state yet. Use stream <game-id>."
case Some(gs) =>
val provinces = gs.provinces.values.toVector.sortBy(_.id)
if rawJson then {
val provinceJson = provinces.map { province =>
val neighbors = province.neighborProvinceIds.sorted.map { neighborProvinceId =>
val neighborName = gs.provinces.get(neighborProvinceId).map(_.name).getOrElse("")
s"{\"provinceId\":$neighborProvinceId,\"name\":\"${jsonEscape(neighborName)}\"}"
}
.mkString("[", ",", "]")
s"{\"id\":${province.id},\"name\":\"${jsonEscape(province.name)}\",\"neighbors\":$neighbors}"
}.mkString("[", ",", "]")
s"{\"provinces\":$provinceJson}"
} else {
val lines = provinces.map { province =>
val neighbors = province.neighborProvinceIds.sorted.map { neighborProvinceId =>
val name = gs.provinces.get(neighborProvinceId).map(_.name).filter(_.nonEmpty)
name.map(value => s"$neighborProvinceId $value").getOrElse(neighborProvinceId.toString)
}
.mkString(", ")
s" ${province.id} ${province.name}: $neighbors"
}
(s"Eagle map: provinces=${provinces.size}" +: lines).mkString("\n")
}
}
}
def describeText(textId: String): String = synchronized {
textStore.entry(textId) match {
case Some(entry) => s"text $textId complete=${entry.completed}: ${entry.text}"
@@ -1931,6 +1962,8 @@ object Help {
| status
| state
| state-json
| eagle-map
| eagle-map-json
| text <text-id>
| commands
| commands-json
@@ -9,9 +9,8 @@ import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.hero.Profession
import net.eagle0.eagle.model.state.province.{Neighbor, ProvinceOrderType, ProvinceT}
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.province.ProvinceOrderType
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroType}
import net.eagle0.eagle.model.state.unaffiliated_hero.concrete.UnaffiliatedHeroC
@@ -136,6 +135,14 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
private def filteredProvinceView(province: ProvinceT, gs: GameState, factionId: Int): ProvinceView =
ProvinceViewFilter.filteredProvinceView(province, gs, factionId, FilterContext.build(gs))
it should "expose the static province neighbor graph" in {
val neighbors = Vector(Neighbor(provinceId = 8, startingPositionIndex = 3))
val province = ProvinceC(id = 5, neighbors = neighbors)
val gs = minimalGameState(provinces = Map(5 -> province))
ProvinceViewFilter.filteredProvinceView(province, gs).neighborProvinceIds shouldBe Vector(8)
}
it should "keep a devastation of 0 at 0" in {
val province = ProvinceC(id = 5, rulingFactionId = Some(3), economyDevastation = 0.0)
val gs = minimalGameState(provinces = Map(5 -> province))
@@ -51,6 +51,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/state:combat_unit",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/province:orders",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
"//src/main/scala/net/eagle0/eagle/model/view/army:army_view",
@@ -83,7 +83,8 @@ class ProvinceViewConverterTest extends AnyFlatSpec with Matchers {
unitDetails = Vector(UnitDetails(heroId = 20: HeroId, battalion = None))
)
private val sampleDate = Date(year = 1000, month = Date.Month.March)
private val sampleDate = Date(year = 1000, month = Date.Month.March)
private val sampleNeighborProvinceId = 43: ProvinceId
"toProto" should "convert all fields correctly" in {
val scala = ProvinceView(
@@ -93,7 +94,8 @@ class ProvinceViewConverterTest extends AnyFlatSpec with Matchers {
fullInfo = Some(sampleFullInfo),
knownEvents = Vector.empty,
knownIncomingArmies = Vector(sampleIncomingArmy),
asOf = Some(sampleDate)
asOf = Some(sampleDate),
neighborProvinceIds = Vector(sampleNeighborProvinceId)
)
val proto = ProvinceViewConverter.toProto(scala)
@@ -108,6 +110,7 @@ class ProvinceViewConverterTest extends AnyFlatSpec with Matchers {
knownEvents,
knownIncomingArmies,
asOf,
neighborProvinceIds,
_ /* unknownFields */
) =>
id shouldBe 42
@@ -117,6 +120,7 @@ class ProvinceViewConverterTest extends AnyFlatSpec with Matchers {
knownEvents.size shouldBe 0
knownIncomingArmies.size shouldBe 1
asOf shouldBe defined
neighborProvinceIds should contain only 43
}
}
@@ -128,7 +132,8 @@ class ProvinceViewConverterTest extends AnyFlatSpec with Matchers {
fullInfo = Some(sampleFullInfo),
knownEvents = Vector.empty,
knownIncomingArmies = Vector(sampleIncomingArmy),
asOf = Some(sampleDate)
asOf = Some(sampleDate),
neighborProvinceIds = Vector(sampleNeighborProvinceId)
)
val proto = ProvinceViewConverter.toProto(original)
@@ -143,7 +148,8 @@ class ProvinceViewConverterTest extends AnyFlatSpec with Matchers {
fullInfo,
knownEvents,
knownIncomingArmies,
asOf
asOf,
neighborProvinceIds
) =>
id shouldBe (55: ProvinceId)
name shouldBe "Another Province"
@@ -152,6 +158,7 @@ class ProvinceViewConverterTest extends AnyFlatSpec with Matchers {
knownEvents.size shouldBe 0
knownIncomingArmies.size shouldBe 1
asOf shouldBe defined
neighborProvinceIds should contain only sampleNeighborProvinceId
}
}
@@ -181,7 +188,8 @@ class ProvinceViewConverterTest extends AnyFlatSpec with Matchers {
fullInfo = Some(sampleFullInfo),
knownEvents = Vector.empty,
knownIncomingArmies = Vector(sampleIncomingArmy),
asOf = Some(sampleDate)
asOf = Some(sampleDate),
neighborProvinceIds = Vector(sampleNeighborProvinceId)
)
val roundTripped = ProvinceViewConverter.fromProto(
@@ -232,6 +232,45 @@ class ClientStateTest extends AnyFlatSpec with Matchers {
output should include("instruction: Click Fight!")
}
it should "describe the complete Eagle province neighbor graph" in {
val state = new ClientState
state.handle(
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.GameUpdate(
GameUpdate(
gameId = 1L,
startingState = Some(
GameStateView(
gameId = 1L,
provinces = Map(
1 -> ProvinceView(
id = 1,
name = "North & West",
neighborProvinceIds = Vector(2)
),
2 -> ProvinceView(
id = 2,
name = "East",
neighborProvinceIds = Vector(1)
)
)
)
)
)
)
)
)
state.describeEagleMap(rawJson = false) shouldBe
"Eagle map: provinces=2\n" +
" 1 North & West: 2 East\n" +
" 2 East: 1 North & West"
val json = state.describeEagleMap(rawJson = true)
json should include("\"id\":1,\"name\":\"North & West\"")
json should include("\"provinceId\":2,\"name\":\"East\"")
}
it should "retain unresolved hero-departure dialogue across a refreshed starting state" in {
val state = new ClientState
val gameState = GameStateView(