Add protoless ProvinceViewFilter overload with faction filtering (#5034)

- Add filteredProvinceView(ProvinceT, ScalaGameState, FactionId) overload
- Use protoless FactionUtils.hasAlliance, Visibility.hasFullVisibility, and ProvinceUtils.incomingOthers
- Add helper methods: fullProvinceInfoScala, maybeIncomingAttackersScala, unaffiliatedHeroInfoScala
- Handle reconned provinces directly from Scala FactionT.reconnedProvinces

GameStateViewFilter improvements:
- Eliminate GameStateConverter.toProto() call in Scala overload
- Use new ProvinceViewFilter Scala overload for faction filtering
- Convert battalionTypes and chronicleEntries to proto only at output boundary

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-03 22:16:44 -08:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 78533a9ff0
commit c1756cb36b
6 changed files with 190 additions and 14 deletions
+15 -2
View File
@@ -179,6 +179,18 @@ Remove Legacy* utilities by migrating remaining callers:
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
- Factory is now fully protoless internally (still returns proto types for API boundary)
**ProvinceViewFilter** - added Scala overload with faction filtering:
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
- Scala overload now fully protoless internally
- Uses the new ProvinceViewFilter Scala overload with faction filtering
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
## Key Files
### Protoless Model Types
@@ -195,6 +207,7 @@ Remove Legacy* utilities by migrating remaining callers:
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok, and client view creation
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
- Next focus: view_filters package to reduce proto usage in client view creation
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
@@ -142,9 +142,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:faction_view_filter",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:hero_view_filter",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:province_view_filter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:round_phase_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/chronicle_entry:chronicle_entry_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
@@ -230,6 +231,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/library/util:province_event_utils",
"//src/main/scala/net/eagle0/eagle/library/util:stat_with_condition_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
@@ -244,6 +246,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
"//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",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
@@ -1,9 +1,10 @@
package net.eagle0.eagle.library.util.view_filters
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.model.proto_converters.chronicle_entry.ChronicleEntryConverter
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.view.province.ProvinceViewConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.proto_converters.RoundPhaseConverter
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
import net.eagle0.eagle.views.faction_view.FactionView
@@ -13,27 +14,24 @@ import net.eagle0.eagle.FactionId
object GameStateViewFilter {
/**
* Scala GameState overload - uses Scala sub-filters where available. Converts to proto only for ProvinceViewFilter
* (which still needs proto for faction filtering).
* Scala GameState overload - fully protoless internal processing, converts to proto only at the output boundary.
*/
def filteredGameState(
gs: ScalaGameState,
factionId: Option[FactionId]
): GameStateView = {
// Convert to proto only for ProvinceViewFilter which still needs it
val protoGs = GameStateConverter.toProto(gs)
val factions = gs.factions.values.toVector
GameStateView(
currentRoundId = gs.currentRoundId,
currentPhase = RoundPhaseConverter.toProto(gs.currentPhase),
currentDate = gs.currentDate.map(DateConverter.toProto),
provinces = protoGs.provinces.map {
provinces = gs.provinces.map {
case (pid, prov) =>
pid -> ProvinceViewConverter.toProto(
factionId
.map(fid => ProvinceViewFilter.filteredProvinceView(prov, protoGs, fid))
.getOrElse(ProvinceViewFilter.filteredProvinceView(prov, protoGs))
.map(fid => ProvinceViewFilter.filteredProvinceView(prov, gs, fid))
.getOrElse(ProvinceViewFilter.filteredProvinceView(prov, gs))
)
},
heroes = (gs.heroes ++ gs.killedHeroes).map {
@@ -52,8 +50,8 @@ object GameStateViewFilter {
outstandingBattles = gs.outstandingBattles
.map(ob => BattleFilter.filterBattle(ob, factions, factionId)),
gameId = gs.gameId,
battalionTypes = protoGs.battalionTypes,
chronicleEntries = protoGs.chronicleEntries
battalionTypes = gs.battalionTypes.map(BattalionTypeConverter.toProto),
chronicleEntries = gs.chronicleEntries.map(ChronicleEntryConverter.toProto)
)
}
@@ -7,7 +7,7 @@ import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.unaffiliated_hero.UnaffiliatedHero
import net.eagle0.eagle.library.util.{IncomingArmyUtils, ProvinceEventUtils}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
@@ -153,6 +153,166 @@ object ProvinceViewFilter {
)
}
/** Filtered view for a specific faction (Scala overload) */
def filteredProvinceView(
province: ProvinceT,
gs: ScalaGameState,
factionId: FactionId
): ProvinceView = {
val factions = gs.factions.values.toVector
val relevantFactions = factions.filter(possibleAlly =>
FactionUtils.hasAlliance(possibleAlly.id, factionId, factions)
) ++ gs.factions.get(factionId)
// Take the most recent reconned view from this faction or any allies
// (already stored as Scala ProvinceView in Scala GameState)
val reconnedView =
relevantFactions
.flatMap(_.reconnedProvinces)
.filter(_.id == province.id)
.maxByOption(_.asOf)
val showAll = province.rulingFactionId.exists { rfid =>
Visibility.hasFullVisibility(factionId, rfid, factions)
}
// Convert Scala events to proto for filtering (ProvinceView uses proto events)
val activeEventsProto = province.activeEvents.map(ProvinceEventConverter.toProto)
val knownEvents = activeEventsProto.filter { pe =>
if showAll then true
else if reconnedView.exists(_.knownEvents.contains(pe)) then true
else eventIsUniversallyVisible(pe)
}
val incomingAttackers =
myIncomingArmiesScala(province, gs, factionId) ++ (if showAll then
maybeIncomingAttackersScala(
province,
gs
)
else Vector())
val incomingSupplies =
if showAll then
province.incomingShipments.map { shipment =>
IncomingArmyView(
factionId = shipment.factionId,
heroCount = 0,
troopCount = 0,
originProvinceId = shipment.originProvinceId
.getOrElse(shipment.destinationProvinceId),
destinationProvinceId = shipment.destinationProvinceId,
arrivalRoundId = shipment.arrivalRound,
food = shipment.supplies.food,
gold = shipment.supplies.gold
)
}
else Vector()
val fullInfo = Option
.when(showAll)(fullProvinceInfoScala(province, gs, Some(factionId)))
.orElse(
reconnedView.flatMap(_.fullInfo).map(deadHeroesFilteredOutScala(_, gs))
)
ProvinceView(
id = province.id,
name = province.name,
rulingFactionId = province.rulingFactionId,
fullInfo = fullInfo,
knownEvents = knownEvents,
asOf =
if showAll then gs.currentDate
else reconnedView.flatMap(_.asOf),
knownIncomingArmies = incomingAttackers ++ incomingSupplies
)
}
private def deadHeroesFilteredOutScala(
fullProvinceInfo: FullProvinceInfo,
gameState: ScalaGameState
): FullProvinceInfo =
fullProvinceInfo.copy(
rulingFactionHeroIds =
fullProvinceInfo.rulingFactionHeroIds.filterNot(hid => gameState.killedHeroes.contains(hid)),
unaffiliatedHeroes =
fullProvinceInfo.unaffiliatedHeroes.filterNot(uh => gameState.killedHeroes.contains(uh.heroId))
)
private def fullProvinceInfoScala(
province: ProvinceT,
gs: ScalaGameState,
factionId: Option[FactionId]
): FullProvinceInfo = {
val factions = gs.factions.values.toVector
val factionLeaderIds = factions.flatMap(_.leaderIds)
FullProvinceInfo(
rulingHeroId = province.rulingHeroId,
rulingFactionHeroIds = province.rulingFactionHeroIds
.map(hid => gs.heroes(hid))
.sortWith(HeroUtils.sortOrderer(factionLeaderIds))
.map(_.id),
battalions = province.battalionIds
.map(bid => gs.battalions(bid))
.map(BattalionViewFilter.filteredBattalionViewBelongingToPlayer)
.sortBy(_.typeId.value),
defendingArmy = province.defendingArmy
.map(army => ArmyFilter.filterArmy(army, gs.battalions, factionId)),
economy = province.economy.toFloat,
agriculture = province.agriculture.toFloat,
infrastructure = province.infrastructure.toFloat,
economyDevastation = province.economyDevastation.toFloat,
agricultureDevastation = province.agricultureDevastation.toFloat,
infrastructureDevastation = province.infrastructureDevastation.toFloat,
gold = province.gold,
food = province.food,
priceIndex = province.priceIndex,
goldCap = ProvinceUtils.goldCap(province),
foodCap = ProvinceUtils.foodCap(province),
support = Some(supportScala(province.support)),
foodConsumption = ProvinceUtils.monthlyFoodConsumption(province, gs),
provinceOrders = ProvinceOrderTypeConverter.toProto(province.provinceOrders),
hexMapName = province.hexMapName,
castleCount = province.castleCount,
heroCap = province.heroCap,
unaffiliatedHeroes = province.unaffiliatedHeroes.map(unaffiliatedHeroInfoScala(_, gs)),
rulerIsTraveling = province.rulerIsTraveling
)
}
private def maybeIncomingAttackersScala(
province: ProvinceT,
gs: ScalaGameState
): Vector[IncomingArmyView] =
ProvinceUtils
.incomingOthers(province, gs.currentRoundId)
.filter(ma =>
IncomingArmyUtils.isHostileProvince(
province = province,
factionId = ma.army.factionId,
gameState = gs
)
)
.map(army => incomingArmyInfoScala(ma = army, gs = gs, showDetails = false))
private def unaffiliatedHeroInfoScala(
uh: net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroT,
gs: ScalaGameState
): UnaffiliatedHeroBasics = {
val hero = gs.heroes(uh.heroId)
val factions = gs.factions.values.toVector
UnaffiliatedHeroBasics(
heroId = hero.id,
nameTextId = hero.nameTextId,
profession = hero.profession,
status = uh.unaffiliatedHeroType,
isFactionLeader = FactionUtils.isFactionLeader(hero.id, factions),
recruitmentInfo = uh.recruitmentInfo
)
}
private def myIncomingArmiesScala(
province: ProvinceT,
gs: ScalaGameState,
@@ -132,6 +132,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
@@ -4,6 +4,7 @@ scala_library(
name = "chronicle_entry_converter",
srcs = ["ChronicleEntryConverter.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__subpackages__",
],