Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 07512e8077 Implement neverJoinFactionIds functionality for heroes
Heroes with neverJoinFactionIds are now effectively excluded from recruitment
and divination by those factions:

- Add WouldNeverJoin case to RecruitmentInfo sealed trait
- Add RECRUITMENT_STATUS_WOULD_NEVER_JOIN proto enum value
- Filter heroes from recruit/divine/please-recruit-me commands
- Exclude Recruit option for captured heroes who would never join
- Override recruitment status to WouldNeverJoin in faction-specific views

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 11:17:51 -08:00
11 changed files with 195 additions and 10 deletions
@@ -28,4 +28,5 @@ enum RecruitmentStatus {
RECRUITMENT_STATUS_HAS_QUEST = 10;
RECRUITMENT_STATUS_EXILE = 11;
RECRUITMENT_STATUS_MOVING_PRISONER = 12;
RECRUITMENT_STATUS_WOULD_NEVER_JOIN = 13;
}
@@ -28,6 +28,7 @@ object AvailableDivineCommandsFactory extends ScalaAvailableCommandsFactory {
) {
val undivined = province.unaffiliatedHeroes
.filter(_.recruitmentInfo == RecruitmentInfo.NotDivined)
.filterNot(uh => gameState.heroes(uh.heroId).neverJoinFactionIds.contains(factionId))
val expandedHeroes = undivined.map(uh =>
ExpandedUnaffiliatedHero(
@@ -29,10 +29,12 @@ object AvailableHandleCapturedHeroCommandFactory extends ScalaAvailableCommandsF
private def expandedCapturedHero(
gs: GameState,
ch: CapturedHero
ch: CapturedHero,
factionId: FactionId
): ExpandedCapturedHero = {
val isFactionLeader = FactionUtils
.isFactionLeader(ch.heroId, gs.factions.values.toVector)
val wouldNeverJoin = gs.heroes(ch.heroId).neverJoinFactionIds.contains(factionId)
ExpandedCapturedHero(
heroId = ch.heroId,
@@ -44,6 +46,7 @@ object AvailableHandleCapturedHeroCommandFactory extends ScalaAvailableCommandsF
.exists(_.rulingFactionId.contains(ch.previousFactionId))
then optionsForFactionLeader
else if isFactionLeader then optionsWithoutRecruit
else if wouldNeverJoin then optionsWithoutRecruit
else optionsWithRecruit,
messageId =
if ch.recruitmentAttempted then ch.recruitmentRefusedMessageId
@@ -55,14 +58,15 @@ object AvailableHandleCapturedHeroCommandFactory extends ScalaAvailableCommandsF
private def firstOption(
gameState: GameState,
capturedHeroes: Vector[CapturedHero],
provinceId: ProvinceId
provinceId: ProvinceId,
factionId: FactionId
): Option[HandleCapturedHeroAvailable] =
capturedHeroes
.find(ch => ch.recruitmentRefusedMessageId.nonEmpty || ch.messageId.nonEmpty)
.map { capturedHero =>
HandleCapturedHeroAvailable(
actingProvinceId = provinceId,
availableHeroes = Vector(expandedCapturedHero(gameState, capturedHero))
availableHeroes = Vector(expandedCapturedHero(gameState, capturedHero, factionId))
)
}
@@ -82,13 +86,15 @@ object AvailableHandleCapturedHeroCommandFactory extends ScalaAvailableCommandsF
firstOption(
gameState = gameState,
capturedHeroes = notRecruitedNonEmpty,
provinceId = provinceId
provinceId = provinceId,
factionId = factionId
)
case (alreadyRecruitedNonEmpty, _) =>
firstOption(
gameState = gameState,
capturedHeroes = alreadyRecruitedNonEmpty,
provinceId = provinceId
provinceId = provinceId,
factionId = factionId
)
}
}
@@ -28,6 +28,7 @@ object AvailableRecruitHeroesCommandFactory extends ScalaAvailableCommandsFactor
val expandedRecruitable = province.unaffiliatedHeroes
.filterNot(uh => FactionUtils.isFactionLeader(uh.heroId, gameState.factions.values.toVector))
.filterNot(uh => gameState.heroes(uh.heroId).neverJoinFactionIds.contains(factionId))
.filterNot(h =>
RecruitmentOdds.scoreForUnaffiliatedHero(
faction = faction,
@@ -57,7 +57,10 @@ object UnaffiliatedHeroUtils {
factionId: FactionId,
unaffiliatedHero: UnaffiliatedHeroT
): Boolean = {
val targetHero = gameState.heroes(unaffiliatedHero.heroId)
val targetHero = gameState.heroes(unaffiliatedHero.heroId)
// Heroes should not offer to join factions they'd never join
if targetHero.neverJoinFactionIds.contains(factionId) then return false
val actingFaction = gameState.factions(factionId)
val actingFactionLeader = gameState.heroes(actingFaction.factionHeadId)
willPleaseRecruitMe(
@@ -6,6 +6,7 @@ import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.StatWithConditionUtils.supportScala
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.{ProvinceEvent, ProvinceT}
import net.eagle0.eagle.model.state.unaffiliated_hero.RecruitmentInfo
import net.eagle0.eagle.model.view.incoming_army.{IncomingArmyView, UnitDetails}
import net.eagle0.eagle.model.view.province.{FullProvinceInfo, ProvinceView, UnaffiliatedHeroBasics}
import net.eagle0.eagle.FactionId
@@ -323,7 +324,7 @@ object ProvinceViewFilter {
hexMapName = province.hexMapName,
castleCount = province.castleCount,
heroCap = province.heroCap,
unaffiliatedHeroes = province.unaffiliatedHeroes.map(unaffiliatedHeroInfoWithContext(_, gs, ctx)),
unaffiliatedHeroes = province.unaffiliatedHeroes.map(unaffiliatedHeroInfoWithContext(_, gs, ctx, factionId)),
rulerIsTraveling = province.rulerIsTraveling
)
@@ -345,16 +346,24 @@ object ProvinceViewFilter {
private def unaffiliatedHeroInfoWithContext(
uh: net.eagle0.eagle.model.state.unaffiliated_hero.UnaffiliatedHeroT,
gs: GameState,
ctx: FilterContext
ctx: FilterContext,
viewingFactionId: Option[FactionId]
): UnaffiliatedHeroBasics = {
val hero = gs.heroes(uh.heroId)
val hero = gs.heroes(uh.heroId)
// Override recruitment info to WouldNeverJoin if the viewing faction is in the hero's neverJoinFactionIds
val effectiveRecruitmentInfo = viewingFactionId match {
case Some(fid) if hero.neverJoinFactionIds.contains(fid) =>
RecruitmentInfo.WouldNeverJoin
case _ =>
uh.recruitmentInfo
}
UnaffiliatedHeroBasics(
heroId = hero.id,
nameTextId = hero.nameTextId,
profession = hero.profession,
status = uh.unaffiliatedHeroType,
isFactionLeader = ctx.factionLeaderIds.contains(hero.id),
recruitmentInfo = uh.recruitmentInfo
recruitmentInfo = effectiveRecruitmentInfo
)
}
@@ -75,6 +75,8 @@ object UnaffiliatedHeroConverter {
RecruitmentInfo.Exile
case RecruitmentStatusProto.RECRUITMENT_STATUS_MOVING_PRISONER =>
RecruitmentInfo.MovingPrisoner
case RecruitmentStatusProto.RECRUITMENT_STATUS_WOULD_NEVER_JOIN =>
RecruitmentInfo.WouldNeverJoin
case RecruitmentStatusProto.Unrecognized(unrecognizedValue) =>
throw new ProtoConversionException(
s"Unrecognized recruitment status: $unrecognizedValue"
@@ -137,6 +139,10 @@ object UnaffiliatedHeroConverter {
RecruitmentInfoProto(
status = RecruitmentStatusProto.RECRUITMENT_STATUS_MOVING_PRISONER
)
case RecruitmentInfo.WouldNeverJoin =>
RecruitmentInfoProto(
status = RecruitmentStatusProto.RECRUITMENT_STATUS_WOULD_NEVER_JOIN
)
}
def fromProto(uhProto: UnaffiliatedHeroProto): UnaffiliatedHeroT =
@@ -30,6 +30,7 @@ object RecruitmentInfo {
case class HasQuest(quest: Quest) extends RecruitmentInfo
case object Exile extends RecruitmentInfo
case object MovingPrisoner extends RecruitmentInfo
case object WouldNeverJoin extends RecruitmentInfo
}
trait UnaffiliatedHeroT {
@@ -172,4 +172,44 @@ class AvailableDivineCommandsFactoryTest extends AnyFlatSpec with Matchers with
costPerHero shouldBe 110
}
}
it should "not include heroes who would never join this faction" in {
val gs = makeGameState().copy(
heroes = Map(
10 -> HeroC(id = 10, factionId = Some(actingFid), nameTextId = "hero_10", vigor = 100),
99 -> HeroC(id = 99, factionId = None, nameTextId = "hero_99", neverJoinFactionIds = Set(actingFid)),
198 -> HeroC(id = 198, factionId = None, nameTextId = "hero_198", vigor = 100)
)
)
val command = AvailableDivineCommandsFactory
.availableCommand(
gameState = gs,
factionId = actingFid,
provinceId = actingPid
)
.get
inside(command) {
case DivineAvailable(_, divinableHeroes, _, _) =>
divinableHeroes should have size 1
divinableHeroes.head.heroId shouldBe 198
}
}
it should "return None when all undivined heroes would never join this faction" in {
val gs = makeGameState().copy(
heroes = Map(
10 -> HeroC(id = 10, factionId = Some(actingFid), nameTextId = "hero_10", vigor = 100),
99 -> HeroC(id = 99, factionId = None, nameTextId = "hero_99", neverJoinFactionIds = Set(actingFid)),
198 -> HeroC(id = 198, factionId = None, nameTextId = "hero_198", neverJoinFactionIds = Set(actingFid))
)
)
AvailableDivineCommandsFactory.availableCommand(
gameState = gs,
factionId = actingFid,
provinceId = actingPid
) shouldBe None
}
}
@@ -217,4 +217,43 @@ class AvailableRecruitHeroesCommandFactoryTest extends AnyFlatSpec with Matchers
provinceId = provinceId
) shouldBe None
}
it should "not include heroes who would never join this faction" in {
val gs = makeGameState(
heroes = Map(
9 -> HeroC(id = 9, factionId = Some(factionId), nameTextId = "hn_9"),
11 -> HeroC(id = 11, factionId = None, nameTextId = "hn_11", neverJoinFactionIds = Set(factionId))
)
)
AvailableRecruitHeroesCommandFactory.availableCommand(
gameState = gs,
factionId = factionId,
provinceId = provinceId
) shouldBe None
}
it should "include heroes who would never join a different faction" in {
val otherFactionId: FactionId = 99
val gs = makeGameState(
heroes = Map(
9 -> HeroC(id = 9, factionId = Some(factionId), nameTextId = "hn_9"),
11 -> HeroC(id = 11, factionId = None, nameTextId = "hn_11", neverJoinFactionIds = Set(otherFactionId))
)
)
val command = AvailableRecruitHeroesCommandFactory
.availableCommand(
gameState = gs,
factionId = factionId,
provinceId = provinceId
)
.get
inside(command) {
case RecruitHeroesAvailable(availableHeroes, _) =>
availableHeroes should have size 1
availableHeroes.head.heroId shouldBe 11
}
}
}
@@ -943,4 +943,82 @@ class ProvinceViewFilterTest extends AnyFlatSpec with BeforeAndAfterEach with Ma
uhs.map(_.heroId) should contain theSameElementsAs Vector(6)
}
}
it should "show WouldNeverJoin status for heroes who would never join viewing faction" in {
val viewingFactionId = 11
val province = ProvinceC(
id = 5,
rulingFactionId = Some(viewingFactionId),
unaffiliatedHeroes = Vector(
UnaffiliatedHeroC(
heroId = 1,
unaffiliatedHeroType = UnaffiliatedHeroType.Resident,
recruitmentInfo = RecruitmentInfo.WouldJoin
),
UnaffiliatedHeroC(
heroId = 2,
unaffiliatedHeroType = UnaffiliatedHeroType.Resident,
recruitmentInfo = RecruitmentInfo.NotDivined
)
)
)
val heroes = Map(
1 -> HeroC(id = 1, nameTextId = "hn_1", neverJoinFactionIds = Set(viewingFactionId)),
2 -> HeroC(id = 2, nameTextId = "hn_2", neverJoinFactionIds = Set(viewingFactionId))
)
val gs = minimalGameState(provinces = Map(5 -> province), heroes = heroes)
val filtered = filteredProvinceView(province, gs, viewingFactionId)
inside(filtered.fullInfo.get.unaffiliatedHeroes) {
case uhs: Vector[UnaffiliatedHeroBasics] =>
uhs should have size 2
// Both heroes should show WouldNeverJoin, overriding their underlying status
uhs.foreach(_.recruitmentInfo shouldBe RecruitmentInfo.WouldNeverJoin)
}
}
it should "not override status to WouldNeverJoin for heroes who can join viewing faction" in {
val viewingFactionId = 11
val otherFactionId = 99
val province = ProvinceC(
id = 5,
rulingFactionId = Some(viewingFactionId),
unaffiliatedHeroes = Vector(
UnaffiliatedHeroC(
heroId = 1,
unaffiliatedHeroType = UnaffiliatedHeroType.Resident,
recruitmentInfo = RecruitmentInfo.WouldJoin
),
UnaffiliatedHeroC(
heroId = 2,
unaffiliatedHeroType = UnaffiliatedHeroType.Resident,
recruitmentInfo = RecruitmentInfo.NotDivined
)
)
)
val heroes = Map(
// Hero 1 would never join a different faction, not the viewing faction
1 -> HeroC(id = 1, nameTextId = "hn_1", neverJoinFactionIds = Set(otherFactionId)),
// Hero 2 has no restrictions
2 -> HeroC(id = 2, nameTextId = "hn_2")
)
val gs = minimalGameState(provinces = Map(5 -> province), heroes = heroes)
val filtered = filteredProvinceView(province, gs, viewingFactionId)
inside(filtered.fullInfo.get.unaffiliatedHeroes) {
case uhs: Vector[UnaffiliatedHeroBasics] =>
uhs should have size 2
val hero1 = uhs.find(_.heroId == 1).get
val hero2 = uhs.find(_.heroId == 2).get
// These should keep their original status since viewing faction is not in neverJoinFactionIds
hero1.recruitmentInfo shouldBe RecruitmentInfo.WouldJoin
hero2.recruitmentInfo shouldBe RecruitmentInfo.NotDivined
}
}
}