Add focus province quest type (#8701)

* Add focus province quest type

* Test focus quest without focus province
This commit is contained in:
2026-07-18 08:56:10 -07:00
committed by GitHub
parent 14a475032e
commit 5e7223aa8e
12 changed files with 213 additions and 3 deletions
@@ -64,6 +64,7 @@ message QuestDetails {
StartDroughtQuest start_drought_quest = 47;
SpendOnFeastsInProvinceQuest spend_on_feasts_in_province_quest = 48;
SpendOnFeastsAcrossRealmQuest spend_on_feasts_across_realm_quest = 49;
FocusProvinceQuest focus_province_quest = 50;
}
}
@@ -260,3 +261,7 @@ message RepairDevastationQuest {
message ReconSpecificProvincesQuest {
repeated int32 target_province_ids = 1;
}
message FocusProvinceQuest {
int32 target_province_id = 1;
}
@@ -18,6 +18,7 @@ import net.eagle0.eagle.model.state.quest.{
DismissSpecificVassalQuest,
ExecutePrisonerQuest,
ExilePrisonerQuest,
FocusProvinceQuest,
MobilizeProvincesQuest,
Quest,
ReleasePrisonerQuest,
@@ -188,6 +189,12 @@ case class CheckForFailedQuestsAction(
p.id == targetProvinceId &&
p.rulingFactionId == province.rulingFactionId
)
case FocusProvinceQuest(_, _, targetProvinceId) =>
// Fails if we no longer own the target province
!provinces.exists(p =>
p.id == targetProvinceId &&
p.rulingFactionId == province.rulingFactionId
)
case _ => false
}
@@ -31,6 +31,7 @@ import net.eagle0.eagle.model.state.quest.{
ExilePrisonerQuest,
ExpandToProvincesQuest,
FightBeastsAloneQuest,
FocusProvinceQuest,
GrandArmyQuest,
ImproveAgricultureQuest,
ImproveEconomyQuest,
@@ -277,6 +278,11 @@ case class CheckForFulfilledQuestsAction(
Option.when(matchingCount >= q.targetProvinceCount) {
uh.withQuest(q.withComponentsFulfilled(q.componentsFulfilled + 1))
}
case q: FocusProvinceQuest if q.componentsFulfilled < q.componentCount =>
val faction = requiredFactionWithId(province.getRulingFactionId)
Option.when(faction.focusProvinceId.contains(q.targetProvinceId)) {
uh.withQuest(q.withComponentsFulfilled(q.componentsFulfilled + 1))
}
case _ => None
}
}
@@ -290,7 +296,8 @@ case class CheckForFulfilledQuestsAction(
}
/**
* Returns changed provinces for province order quest progress, to be included in an EndVassalCommandsPhase result.
* Returns changed provinces for monthly order and focus quest progress, to be included in an EndVassalCommandsPhase
* result.
*/
def provinceOrderQuestChangedProvinces: Vector[ChangedProvinceC] =
provinces
@@ -31,8 +31,8 @@ case class EndVassalCommandsPhaseAction(
functionalRandom = functionalRandom
).withActionResults { gs =>
val currentDate = gs.currentDate
// First, increment province order quest counters. This needs to happen
// before the fulfillment check so the updated counters are visible.
// First, increment monthly order and focus quest counters. This needs to
// happen before the fulfillment check so the updated counters are visible.
val checkAction = CheckForFulfilledQuestsAction(
gameId = gs.gameId,
currentDate = currentDate,
@@ -388,6 +388,12 @@ case class DivineMessagePromptGenerator(
TextGenerationSuccess(
s"$divinedHeroName wants to see $targetDevastationRepaired devastation repaired across the realm."
)
case FocusProvinceQuest(componentCount, _, targetProvinceId) =>
val targetProvince = gameState.provinces(targetProvinceId)
TextGenerationSuccess(
s"$divinedHeroName wants ${faction.name} to make ${targetProvince.name} their focus province for a cumulative $componentCount months."
)
}
override def generate: TextGenerationResult = for {
@@ -375,6 +375,12 @@ object QuestEndedGeneratorUtilities {
for {
unaffiliatedHeroName <- unaffiliatedHeroNameResult
} yield s"$unaffiliatedHeroName wanted to see $targetDevastationRepaired devastation repaired across the realm."
case FocusProvinceQuest(componentCount, _, targetProvinceId) =>
val targetProvince = gameState.provinces(targetProvinceId)
for {
unaffiliatedHeroName <- unaffiliatedHeroNameResult
} yield s"$unaffiliatedHeroName wanted ${faction.name} to make ${targetProvince.name} their focus province for a cumulative $componentCount months."
}
}
}
@@ -16,6 +16,7 @@ import net.eagle0.eagle.common.unaffiliated_hero_quest.{
ExilePrisonerQuest as ExilePrisonerQuestProto,
ExpandToProvincesQuest as ExpandToProvincesQuestProto,
FightBeastsAloneQuest as FightBeastsAloneQuestProto,
FocusProvinceQuest as FocusProvinceQuestProto,
GiveToHeroesAcrossRealmQuest as GiveToHeroesAcrossRealmQuestProto,
GiveToHeroesInProvinceQuest as GiveToHeroesInProvinceQuestProto,
GrandArmyQuest as GrandArmyQuestProto,
@@ -268,6 +269,12 @@ object QuestConverter {
componentCount = q.componentCount,
componentsFulfilled = q.componentsFulfilled
)
case q: FocusProvinceQuest =>
QuestProto(
details = FocusProvinceQuestProto(q.targetProvinceId),
componentCount = q.componentCount,
componentsFulfilled = q.componentsFulfilled
)
}
def fromProto(questProto: QuestProto): Quest =
@@ -534,6 +541,15 @@ object QuestConverter {
componentsFulfilled = questProto.componentsFulfilled,
targetDevastationRepaired = targetDevastationRepaired
)
case FocusProvinceQuestProto(
targetProvinceId,
_ /* unknownFieldSet */
) =>
FocusProvinceQuest(
componentCount = questProto.componentCount,
componentsFulfilled = questProto.componentsFulfilled,
targetProvinceId = targetProvinceId
)
case QuestDetailsProto.Empty =>
throw new IllegalArgumentException("Empty quest details")
}
@@ -238,3 +238,12 @@ case class RepairDevastationQuest(
override def withComponentsFulfilled(componentsFulfilled: Int): Quest =
this.copy(componentsFulfilled = componentsFulfilled)
}
case class FocusProvinceQuest(
override val componentCount: Int,
override val componentsFulfilled: Int,
targetProvinceId: ProvinceId
) extends ComponentQuest {
override def withComponentsFulfilled(componentsFulfilled: Int): Quest =
this.copy(componentsFulfilled = componentsFulfilled)
}
@@ -9,6 +9,7 @@ import net.eagle0.eagle.model.state.quest.{
DismissSpecificVassalQuest,
ExecutePrisonerQuest,
ExilePrisonerQuest,
FocusProvinceQuest,
ReleasePrisonerQuest,
ReturnPrisonerQuest,
TruceCountQuest,
@@ -697,4 +698,40 @@ class CheckForFailedQuestsActionTest extends AnyFlatSpec with Matchers {
)
.isQuestFailed(executePrisonerQuest, province) shouldBe false
}
behavior of "focus province quest"
private val focusProvinceQuest = FocusProvinceQuest(
componentCount = 3,
componentsFulfilled = 1,
targetProvinceId = 47
)
it should "fail if the target province is no longer owned by the faction" in {
val targetProvince = ProvinceC(id = 47, rulingFactionId = Some(enemyFactionId))
CheckForFailedQuestsAction(
gameId = gameId,
currentDate = date,
currentRoundId = roundId,
provinces = provinces :+ targetProvince,
factions = factions,
heroes = Map.empty,
heroBackstoryTextIdLookup = hid => s"backstory $hid"
).isQuestFailed(focusProvinceQuest, province) shouldBe true
}
it should "not fail while the faction still owns the target province" in {
val targetProvince = ProvinceC(id = 47, rulingFactionId = Some(actingFid))
CheckForFailedQuestsAction(
gameId = gameId,
currentDate = date,
currentRoundId = roundId,
provinces = provinces :+ targetProvince,
factions = factions,
heroes = Map.empty,
heroBackstoryTextIdLookup = hid => s"backstory $hid"
).isQuestFailed(focusProvinceQuest, province) shouldBe false
}
}
@@ -20,6 +20,7 @@ import net.eagle0.eagle.model.state.quest.{
BorderSecurityQuest,
DevelopProvincesQuest,
DismissSpecificVassalQuest,
FocusProvinceQuest,
GiveToHeroesAcrossRealmQuest,
GrandArmyQuest,
ImproveAgricultureQuest,
@@ -157,6 +158,93 @@ class CheckForFulfilledQuestsActionTest extends AnyFlatSpec with Matchers with B
}
}
it should "progress a focus province quest when the target is the faction focus" in {
val targetProvinceId = 45
val questGiver = UnaffiliatedHeroC(
heroId = 83,
unaffiliatedHeroType = UnaffiliatedHeroType.Resident,
recruitmentInfo = RecruitmentInfo.HasQuest(
FocusProvinceQuest(
componentCount = 3,
componentsFulfilled = 1,
targetProvinceId = targetProvinceId
)
)
)
val provinceWithQuest = province.copy(unaffiliatedHeroes = Vector(questGiver))
val changedProvinces = CheckForFulfilledQuestsAction(
gameId = gameId,
currentDate = date,
currentRoundId = roundId,
provinces = Vector(provinceWithQuest),
factions = Vector(faction.copy(focusProvinceId = Some(targetProvinceId))),
battalions = Vector.empty,
getHero = _ => None,
battalionTypes = battalionTypes,
heroBackstoryTextIdLookup = hid => s"backstory_$hid"
).provinceOrderQuestChangedProvinces
inside(changedProvinces.loneElement.changedUnaffiliatedHeroes.loneElement.quest) {
case Some(changedQuest: FocusProvinceQuest) =>
changedQuest.componentsFulfilled.shouldBe(2)
}
}
it should "not progress a focus province quest when another province is the faction focus" in {
val questGiver = UnaffiliatedHeroC(
heroId = 84,
unaffiliatedHeroType = UnaffiliatedHeroType.Resident,
recruitmentInfo = RecruitmentInfo.HasQuest(
FocusProvinceQuest(
componentCount = 3,
componentsFulfilled = 1,
targetProvinceId = 45
)
)
)
val provinceWithQuest = province.copy(unaffiliatedHeroes = Vector(questGiver))
CheckForFulfilledQuestsAction(
gameId = gameId,
currentDate = date,
currentRoundId = roundId,
provinces = Vector(provinceWithQuest),
factions = Vector(faction.copy(focusProvinceId = Some(46))),
battalions = Vector.empty,
getHero = _ => None,
battalionTypes = battalionTypes,
heroBackstoryTextIdLookup = hid => s"backstory_$hid"
).provinceOrderQuestChangedProvinces.shouldBe(empty)
}
it should "not progress a focus province quest when the faction has no focus province" in {
val questGiver = UnaffiliatedHeroC(
heroId = 85,
unaffiliatedHeroType = UnaffiliatedHeroType.Resident,
recruitmentInfo = RecruitmentInfo.HasQuest(
FocusProvinceQuest(
componentCount = 3,
componentsFulfilled = 1,
targetProvinceId = 45
)
)
)
val provinceWithQuest = province.copy(unaffiliatedHeroes = Vector(questGiver))
CheckForFulfilledQuestsAction(
gameId = gameId,
currentDate = date,
currentRoundId = roundId,
provinces = Vector(provinceWithQuest),
factions = Vector(faction.copy(focusProvinceId = None)),
battalions = Vector.empty,
getHero = _ => None,
battalionTypes = battalionTypes,
heroBackstoryTextIdLookup = hid => s"backstory_$hid"
).provinceOrderQuestChangedProvinces.shouldBe(empty)
}
behavior of "component based quest"
it should "return true if the quest components are fulfilled" in {
@@ -0,0 +1,12 @@
load("@rules_scala//scala:scala.bzl", "scala_test")
scala_test(
name = "quest_converter_test",
srcs = ["QuestConverterTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:quest_converter",
"@rules_scala//scala/scalatest",
],
)
@@ -0,0 +1,17 @@
package net.eagle0.eagle.model.proto_converters
import net.eagle0.eagle.model.state.quest.FocusProvinceQuest
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class QuestConverterTest extends AnyFlatSpec with Matchers {
"QuestConverter" should "round-trip a focus province quest" in {
val quest = FocusProvinceQuest(
componentCount = 4,
componentsFulfilled = 2,
targetProvinceId = 17
)
QuestConverter.fromProto(QuestConverter.toProto(quest)) shouldBe quest
}
}