Clarify developed border tutorial dialogue (#8677)

This commit is contained in:
2026-07-17 14:22:56 -07:00
committed by GitHub
parent 27aae790ad
commit 76be4a49ea
13 changed files with 201 additions and 30 deletions
@@ -189,6 +189,48 @@ namespace eagle0.Tests {
textProvider.Clear();
}
[Test]
public void ServerDialogueConversionResolvesFactionReplacement() {
var textProvider = ClientTextProvider.Provider;
textProvider.Clear();
textProvider.HandleNewStreamingText("marek-name", "Old Marek", 0, true);
textProvider.HandleNewStreamingText(
"border-copy",
"We've earned support on the border with {factionName}.",
0,
true);
var dialogue = new Net.Eagle0.Eagle.Views.TutorialDialogue {
Id = "tutorial_attack_prepare_organize"
};
var step = new Net.Eagle0.Eagle.Views.TutorialDialogueStep {
SpeakerHeroId = 17,
DialogueTextId = "border-copy"
};
step.TextReplacements.Add(new Net.Eagle0.Eagle.Views.TutorialTextReplacement {
Token = "{factionName}",
FactionId = 5
});
dialogue.Steps.Add(step);
var heroes = new Dictionary<int, EagleHeroView> {
[17] = new EagleHeroView { Id = 17, NameTextId = "marek-name" }
};
var converted = DialogueManager.TryCreateServerScript(
dialogue,
heroes,
new Dictionary<int, EagleProvinceView>(),
textProvider,
out var script,
factionId => factionId == 5 ? "The Border League" : null);
Assert.IsTrue(converted);
Assert.AreEqual(
"We've earned support on the border with The Border League.",
script.steps[0].dialogueText);
textProvider.Clear();
}
[Test]
public void TacticalServerDialogueWaitsForBattleUi() {
var gameObject = new GameObject("DialogueManager test");
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using eagle;
@@ -140,7 +141,8 @@ namespace Eagle0.Tutorial {
model.Heroes,
model.Provinces,
ClientTextProvider.Provider,
out var script)) {
out var script,
model.FactionName)) {
return;
}
@@ -161,7 +163,8 @@ namespace Eagle0.Tutorial {
IDictionary<int, Net.Eagle0.Eagle.Views.HeroView> heroes,
IDictionary<int, EagleProvinceView> provinces,
ClientTextProvider textProvider,
out DialogueScript script) {
out DialogueScript script,
Func<int, string> factionName = null) {
script = null;
if (dialogue == null || string.IsNullOrEmpty(dialogue.Id) ||
dialogue.Steps.Count == 0 || heroes == null || provinces == null ||
@@ -207,6 +210,12 @@ namespace Eagle0.Tutorial {
}
replacementText = replacementProvince.Name;
break;
case Net.Eagle0.Eagle.Views.TutorialTextReplacement.ValueOneofCase
.FactionId:
if (factionName == null) return false;
replacementText = factionName(replacement.FactionId);
if (string.IsNullOrEmpty(replacementText)) return false;
break;
default: return false;
}
@@ -29,6 +29,7 @@ message TutorialTextReplacement {
oneof value {
int32 hero_id = 2;
int32 province_id = 3;
int32 faction_id = 4;
}
}
@@ -104,9 +104,9 @@ tutorial_taxes_collected_dialogue_3 One warning before we march, Sadar: a provin
tutorial_taxes_collected_instruction_3 Leave at least one faction hero in Onmaa. Marching every hero out will <b>abandon the province immediately</b>.
tutorial_taxes_collected_dialogue_4 A province ruled by an ordinary faction hero remains ours, but acts as a vassal. You can directly issue commands there only while your Warlord or a sworn sibling is present.
tutorial_taxes_collected_instruction_2 March your Warlord and most — <b>not all</b> — of your heroes to a neighboring province. Before submitting, verify that at least one faction hero remains in Onmaa.
tutorial_new_province_development_dialogue We've secured {provinceName}, Sadar. Before we raise an army here, use Improve and Alms to build local support.
tutorial_new_province_development_dialogue We've occupied {provinceName}, Sadar. Before we raise an army here, use Improve and Alms to build local support.
tutorial_new_province_development_instruction Raise {provinceName} to <b>40 support</b> with <b>Improve</b> and <b>Alms</b> before organizing an army.
tutorial_attack_prepare_organize_dialogue We've reached the border of another faction, Sadar. Before we invite a fight, we should make certain our own lands can withstand one. Our existing forces won't be enough — hire new soldiers and form two or three full battalions so each of our heroes can lead a force worth taking into battle.
tutorial_attack_prepare_organize_dialogue We've earned popular support on the border with {factionName}, Sadar. Before we invite a fight, we should make certain our own lands can withstand one. Our existing forces won't be enough — hire new soldiers and form two or three full battalions so each of our heroes can lead a force worth taking into battle.
tutorial_attack_prepare_organize_instruction Use <b>Organize Troops</b> to hire soldiers and raise at least <b>two or three full battalions</b> from the troop types currently available.
tutorial_attack_prepare_train_dialogue Good. The ranks are formed; now make certain they know how to fight together. Have a <b>Champion</b> lead their drills if possible — someone like you, Sadar! A full battalion is only as useful as its training.
tutorial_attack_prepare_train_instruction Use <b>Train</b> to improve your battalions' training before attacking.
Can't render this file because it contains an unexpected character in line 9 and column 47.
@@ -83,12 +83,12 @@ object StrategicTutorialDialogueSelector {
),
newlyArrivedUndevelopedProvinceId(startingState, result)
.map(provinceId => TutorialDialogueContent.developNewProvince(marekId, provinceId)),
Option.when(
!hasEmitted(startingState, TutorialDialogueContent.attackPrepareOrganizeDialogueId) &&
becameEligible(startingState, after)(hasDevelopedNonKingFactionBorder)
)(
TutorialDialogueContent.prepareAttackOrganize(marekId)
),
developedNonKingBorderFactionId(after)
.filter(_ =>
!hasEmitted(startingState, TutorialDialogueContent.attackPrepareOrganizeDialogueId) &&
developedNonKingBorderFactionId(startingState).isEmpty
)
.map(factionId => TutorialDialogueContent.prepareAttackOrganize(marekId, factionId)),
Option.when(
hasEmitted(startingState, TutorialDialogueContent.attackPrepareOrganizeDialogueId) &&
!hasEmitted(startingState, TutorialDialogueContent.attackPrepareTrainDialogueId) &&
@@ -207,10 +207,15 @@ object StrategicTutorialDialogueSelector {
}
}
private def hasDevelopedNonKingFactionBorder(state: GameState): Boolean =
private def developedNonKingBorderFactionId(state: GameState): Option[FactionId] =
state.provinces.valuesIterator
.filter(_.rulingFactionId.contains(PlayerFactionId))
.exists(province => isDevelopedNonKingFactionBorderProvince(state, province.id))
.filter(_.support >= DevelopedSupport)
.toVector
.sortBy(_.id)
.flatMap(_.neighbors.sortBy(_.provinceId))
.flatMap(neighbor => state.provinces.get(neighbor.provinceId).flatMap(_.rulingFactionId))
.find(factionId => factionId != PlayerFactionId && factionId != KingFactionId)
private def isDevelopedNonKingFactionBorderProvince(state: GameState, provinceId: Int): Boolean =
state.provinces.get(provinceId).exists { province =>
@@ -295,6 +300,7 @@ object StrategicTutorialDialogueSelector {
dialogue = dialogue,
heroIds = state.heroes.keySet,
provinceIds = state.provinces.keySet,
factionIds = state.factions.keySet,
availableTextIds = TutorialDialogueContent.textIds.toSet
)
}
@@ -59,6 +59,10 @@ object ActionResultViewConverter {
provinceId
) =>
TutorialTextReplacementProto.Value.ProvinceId(provinceId)
case net.eagle0.eagle.model.view.action_result.TutorialTextReplacementValue.FactionName(
factionId
) =>
TutorialTextReplacementProto.Value.FactionId(factionId)
}
)
)
@@ -12,6 +12,7 @@ sealed trait TutorialTextReplacementValue
object TutorialTextReplacementValue {
case class HeroName(heroId: Int) extends TutorialTextReplacementValue
case class ProvinceName(provinceId: Int) extends TutorialTextReplacementValue
case class FactionName(factionId: Int) extends TutorialTextReplacementValue
}
case class TutorialTextReplacement(token: String, value: TutorialTextReplacementValue)
@@ -32,7 +33,8 @@ object TutorialDialogueValidator {
heroIds: Set[Int],
provinceIds: Set[Int],
availableTextIds: Set[String],
unitIds: Option[Set[Int]] = None
unitIds: Option[Set[Int]] = None,
factionIds: Set[Int] = Set.empty
): TutorialDialogue = {
def isBlank(value: String): Boolean = Option(value).forall(_.trim.isEmpty)
def invalid(detail: String): Nothing =
@@ -66,6 +68,8 @@ object TutorialDialogueValidator {
invalid(s"$label replacement references missing hero $heroId")
case TutorialTextReplacementValue.ProvinceName(provinceId) if !provinceIds.contains(provinceId) =>
invalid(s"$label replacement references missing province $provinceId")
case TutorialTextReplacementValue.FactionName(factionId) if !factionIds.contains(factionId) =>
invalid(s"$label replacement references missing faction $factionId")
case _ =>
}
}
@@ -289,14 +289,17 @@ object TutorialDialogueContent {
)
)
def prepareAttackOrganize(marekId: Int): TutorialDialogue = TutorialDialogue(
def prepareAttackOrganize(marekId: Int, factionId: Int): TutorialDialogue = TutorialDialogue(
id = attackPrepareOrganizeDialogueId,
steps = Vector(
step(
marekId,
"tutorial_attack_prepare_organize_dialogue",
instructionTextId = Some("tutorial_attack_prepare_organize_instruction"),
focus = Some(TutorialFocus.Button("OrganizeTroopsButton"))
focus = Some(TutorialFocus.Button("OrganizeTroopsButton")),
textReplacements = Vector(
TutorialTextReplacement("{factionName}", TutorialTextReplacementValue.FactionName(factionId))
)
)
)
)
@@ -1105,6 +1105,12 @@ final class ClientState(
.map(_.name)
.filter(_.nonEmpty)
.map(replacement.token -> _)
case TutorialTextReplacement.Value.FactionId(factionId) =>
latestState
.flatMap(_.factions.get(factionId))
.map(_.name)
.filter(_.nonEmpty)
.map(replacement.token -> _)
case TutorialTextReplacement.Value.Empty => None
}
}
@@ -30,21 +30,27 @@ class StrategicTutorialDialogueSelectorTest extends AnyFlatSpec with Matchers {
private val PlayerFactionId = TutorialDialogueContent.playerFactionId
private val OnmaaId = 14
private val marek = HeroC(
private val marek = HeroC(
id = 10,
factionId = Some(PlayerFactionId),
nameTextId = LoadedHero.nameTextId("Old Marek the Learned")
)
private val ranil = HeroC(
private val ranil = HeroC(
id = 100,
factionId = Some(PlayerFactionId),
nameTextId = LoadedHero.nameTextId("John Ranil")
)
private val elena = HeroC(
private val elena = HeroC(
id = 101,
factionId = Some(PlayerFactionId),
nameTextId = LoadedHero.nameTextId("Elena Fyar")
)
private val borderFaction = FactionC(
id = 5,
factionHeadId = 200,
name = "The Border League",
leaderIds = Vector(200)
)
private def state(
currentRoundId: Int = 1,
@@ -374,11 +380,18 @@ class StrategicTutorialDialogueSelectorTest extends AnyFlatSpec with Matchers {
)
val border = state(
onmaa = borderProvince,
additionalProvinces = Map(39 -> ProvinceC(id = 39, rulingFactionId = Some(5)))
additionalProvinces = Map(39 -> ProvinceC(id = 39, rulingFactionId = Some(borderFaction.id))),
factions = Map(borderFaction.id -> borderFaction)
)
selectedIds(before, border, ActionResultType.ProvinceConquered) should contain(
"tutorial_attack_prepare_organize"
val organizeDialogue = selectedDialogues(before, border, ActionResultType.ProvinceConquered)
.find(_.id == TutorialDialogueContent.attackPrepareOrganizeDialogueId)
.getOrElse(fail("Organize guidance was not emitted"))
organizeDialogue.steps.loneElement.textReplacements shouldBe Vector(
TutorialTextReplacement(
"{factionName}",
TutorialTextReplacementValue.FactionName(borderFaction.id)
)
)
selectedIds(
border,
@@ -459,11 +472,13 @@ class StrategicTutorialDialogueSelectorTest extends AnyFlatSpec with Matchers {
)
val undeveloped = state(
onmaa = undevelopedProvince,
additionalProvinces = Map(39 -> ProvinceC(id = 39, rulingFactionId = Some(5)))
additionalProvinces = Map(39 -> ProvinceC(id = 39, rulingFactionId = Some(borderFaction.id))),
factions = Map(borderFaction.id -> borderFaction)
)
val developed = state(
onmaa = undevelopedProvince.copy(support = 40),
additionalProvinces = Map(39 -> ProvinceC(id = 39, rulingFactionId = Some(5)))
additionalProvinces = Map(39 -> ProvinceC(id = 39, rulingFactionId = Some(borderFaction.id))),
factions = Map(borderFaction.id -> borderFaction)
)
val almostTrained = undeveloped.copy(
battalions = Map(
@@ -29,9 +29,10 @@ class TutorialDialogueValidatorTest extends AnyFlatSpec with Matchers {
heroIds: Set[Int] = Set(17),
provinceIds: Set[Int] = Set(14),
textIds: Set[String] = Set("dialogue-copy", "instruction-copy"),
unitIds: Option[Set[Int]] = None
unitIds: Option[Set[Int]] = None,
factionIds: Set[Int] = Set.empty
): TutorialDialogue =
TutorialDialogueValidator.validate(dialogue, heroIds, provinceIds, textIds, unitIds)
TutorialDialogueValidator.validate(dialogue, heroIds, provinceIds, textIds, unitIds, factionIds)
"TutorialDialogueValidator" should "accept resolvable dialogue" in {
validate() shouldBe validDialogue
@@ -96,4 +97,18 @@ class TutorialDialogueValidatorTest extends AnyFlatSpec with Matchers {
error.getMessage should include("replacement references missing province 29")
}
it should "reject a replacement that references a missing faction" in {
val dialogue = validDialogue.copy(
steps = validDialogue.steps.map(
_.copy(
textReplacements = Vector(
TutorialTextReplacement("{factionName}", TutorialTextReplacementValue.FactionName(29))
)
)
)
)
val error = the[IllegalStateException] thrownBy validate(dialogue = dialogue)
error.getMessage should include("replacement references missing faction 29")
}
}
@@ -210,19 +210,22 @@ class TutorialGameCreationTest extends AnyFlatSpec with Matchers {
}
it should "provide valid server-owned content for every strategic tutorial dialogue" in {
val gameState = engineAndResults.engine.currentState
val marekId = gameState.heroes.values
val gameState = engineAndResults.engine.currentState
val marekId = gameState.heroes.values
.find(_.nameTextId == LoadedHero.nameTextId("Old Marek the Learned"))
.map(_.id)
.getOrElse(fail("Old Marek is missing from the tutorial state"))
val ranilId = gameState.heroes.values
val ranilId = gameState.heroes.values
.find(_.nameTextId == LoadedHero.nameTextId("John Ranil"))
.map(_.id)
.getOrElse(fail("John Ranil is missing from the tutorial state"))
val elenaId = gameState.heroes.values
val elenaId = gameState.heroes.values
.find(_.nameTextId == LoadedHero.nameTextId("Elena Fyar"))
.map(_.id)
.getOrElse(fail("Elena Fyar is missing from the tutorial state"))
val otherFactionId = gameState.factions.keys
.find(factionId => factionId != TutorialDialogueContent.playerFactionId && factionId != 2)
.getOrElse(fail("A non-player, non-King faction is missing from the tutorial state"))
val dialogues = Vector(
TutorialDialogueContent.opening(marekId),
@@ -241,7 +244,7 @@ class TutorialGameCreationTest extends AnyFlatSpec with Matchers {
TutorialDialogueContent.readyToJoin(marekId, provinceId = 14),
TutorialDialogueContent.inTown(marekId),
TutorialDialogueContent.taxesCollected(marekId),
TutorialDialogueContent.prepareAttackOrganize(marekId),
TutorialDialogueContent.prepareAttackOrganize(marekId, otherFactionId),
TutorialDialogueContent.prepareAttackTrain(marekId),
TutorialDialogueContent.prepareAttackArm(marekId),
TutorialDialogueContent.attackReady(marekId),
@@ -280,6 +283,7 @@ class TutorialGameCreationTest extends AnyFlatSpec with Matchers {
dialogue = dialogue,
heroIds = gameState.heroes.keySet,
provinceIds = gameState.provinces.keySet,
factionIds = gameState.factions.keySet,
availableTextIds = TutorialDialogueContent.textIds.toSet
) shouldBe dialogue
}
@@ -58,6 +58,7 @@ import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus
import net.eagle0.eagle.common.province_order_type.ProvinceOrderType
import net.eagle0.eagle.common.tribute_amount.TributeAmount
import net.eagle0.eagle.views.action_result_view.ActionResultView
import net.eagle0.eagle.views.faction_view.FactionView
import net.eagle0.eagle.views.game_state_view.{GameStateView, GameStateViewDiff}
import net.eagle0.eagle.views.hero_view.HeroView
import net.eagle0.eagle.views.province_view.{ProvinceView, ProvinceViewDiff}
@@ -641,6 +642,67 @@ class ClientStateTest extends AnyFlatSpec with Matchers {
output should include("Old Marek the Learned: A hero in Onmaa is ready to join.")
}
it should "resolve tutorial faction replacements from the current game state" in {
val state = new ClientState
state.handle(
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.GameUpdate(
GameUpdate(
gameId = 1L,
startingState = Some(
GameStateView(
gameId = 1L,
heroes = Map(17 -> HeroView(id = 17, nameTextId = "marek-name")),
factions = Map(5 -> FactionView(id = 5, name = "The Border League"))
)
)
)
)
)
)
state.handle(completedTextUpdate("marek-name", "Old Marek the Learned"))
state.handle(completedTextUpdate("border-copy", "We've earned support on the border with {factionName}."))
val output = capturedOutput(
state.handle(
UpdateStreamResponse(
UpdateStreamResponse.ResponseDetails.GameUpdate(
GameUpdate(
gameId = 1L,
gameUpdateDetails = GameUpdate.GameUpdateDetails.ActionResultResponse(
ActionResultResponse(
actionResultViews = Vector(
ActionResultView(
tutorialDialogues = Vector(
TutorialDialogue(
id = "tutorial_attack_prepare_organize",
steps = Vector(
TutorialDialogueStep(
speakerHeroId = 17,
dialogueTextId = "border-copy",
textReplacements = Vector(
TutorialTextReplacement(
token = "{factionName}",
value = TutorialTextReplacement.Value.FactionId(5)
)
)
)
)
)
)
)
)
)
)
)
)
)
)
)
output should include("Old Marek the Learned: We've earned support on the border with The Border League.")
}
it should "apply Eagle game state diffs from action result updates" in {
val state = new ClientState
state.handle(