Add server tutorial dialogue payload (#8456)

* Drive tutorial opening dialogue from server

* Update tutorial dialogue contracts

* Keep tutorial rendering client-driven

* Make tutorial focus targets data-driven

* Reference tutorial speakers and client text
This commit is contained in:
2026-07-11 14:13:20 -07:00
committed by GitHub
parent 2fd16d962c
commit 38129c2a8e
17 changed files with 269 additions and 13 deletions
@@ -20,6 +20,7 @@ proto_library(
visibility = ["//visibility:public"],
deps = [
":game_state_view_proto",
":tutorial_dialogue_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_proto",
"@com_google_protobuf//:wrappers_proto",
@@ -268,6 +269,7 @@ csharp_proto_compile(
":province_view_proto",
":shardok_battle_view_proto",
":stat_with_condition_proto",
":tutorial_dialogue_proto",
],
visibility = ["//src/main/csharp/net/eagle0/clients/unity/eagle0:__pkg__"],
)
@@ -287,6 +289,7 @@ go_proto_library(
":province_view_proto",
":shardok_battle_view_proto",
":stat_with_condition_proto",
":tutorial_dialogue_proto",
],
visibility = ["//visibility:public"],
deps = [
@@ -294,3 +297,18 @@ go_proto_library(
"//src/main/protobuf/net/eagle0/eagle/common:common_go_proto",
],
)
proto_library(
name = "tutorial_dialogue_proto",
srcs = ["tutorial_dialogue.proto"],
visibility = ["//visibility:public"],
)
scala_proto_library(
name = "tutorial_dialogue_scala_proto",
visibility = [
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [":tutorial_dialogue_proto"],
)
@@ -10,6 +10,7 @@ import "google/protobuf/wrappers.proto";
import "src/main/protobuf/net/eagle0/eagle/common/action_result_notification_details.proto";
import "src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto";
import "src/main/protobuf/net/eagle0/eagle/views/game_state_view.proto";
import "src/main/protobuf/net/eagle0/eagle/views/tutorial_dialogue.proto";
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.views";
@@ -26,4 +27,6 @@ message ActionResultView {
GameStateViewDiff game_state_diff = 5;
repeated .net.eagle0.eagle.common.Notification notifications = 6;
repeated TutorialDialogue tutorial_dialogues = 7;
}
@@ -0,0 +1,36 @@
syntax = "proto3";
package net.eagle0.eagle.views;
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.views";
option java_outer_classname = "TutorialDialogueProto";
option objc_class_prefix = "E0G";
// Tutorial copy is server-owned. Clients may interpret focus as a presentation
// hint, but should render the speaker and text directly from this payload.
message TutorialDialogue {
string id = 1;
repeated TutorialDialogueStep steps = 2;
}
message TutorialDialogueStep {
int32 speaker_hero_id = 1;
string dialogue_text_id = 2;
optional string instruction_text_id = 3;
TutorialFocus focus = 5;
}
message TutorialFocus {
oneof target {
int32 province_id = 1;
string button_id = 2;
TutorialHexTile hex_tile = 3;
int32 unit_id = 4;
}
}
message TutorialHexTile {
int32 row = 1;
int32 column = 2;
}
@@ -27,6 +27,15 @@ filegroup(
],
)
filegroup(
name = "tutorial_dialogue_text",
srcs = ["tutorial_dialogue_text.tsv"],
visibility = [
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
"//src/test:__subpackages__",
],
)
filegroup(
name = "beasts",
srcs = ["beasts.tsv"],
@@ -0,0 +1,5 @@
text_id text
tutorial_opening_dialogue_1 Sadar Rakon. I had hoped we would never see this day.\n\nYou were once Ikhaan Tarn's most trusted lieutenant — the right hand of the Shardok himself. But over the past year, Tarn's behavior grew ever more erratic. The paranoia, the inscrutable orders, those strange training exercises no one could explain... and King Bregos would hear none of it.\n\nSo you did what had to be done. You and a handful of others raised the banner of the Reclamation — not against the Crown, but against the man who wields its sword and can no longer be trusted with it.
tutorial_opening_dialogue_2 But Tarn still commands the royal army, and he is perhaps the most capable soldier this realm has ever seen. Your allies are scattered. You've been pushed back to Onmaa, your back to the sea. The rebellion teeters on the edge of defeat.\n\nThis is the last stand, Sadar. If we fall here, the Reclamation dies with us.
tutorial_opening_dialogue_3 Tarn's forces are already at the gates. If we survive this day... well. Perhaps there is still a way to reach the King's ear. But first, we must survive.\n\nJoin the battle. Let us make our stand.
tutorial_opening_instruction Click <b>Fight!</b> to enter tactical combat.
1 text_id text
2 tutorial_opening_dialogue_1 Sadar Rakon. I had hoped we would never see this day.\n\nYou were once Ikhaan Tarn's most trusted lieutenant — the right hand of the Shardok himself. But over the past year, Tarn's behavior grew ever more erratic. The paranoia, the inscrutable orders, those strange training exercises no one could explain... and King Bregos would hear none of it.\n\nSo you did what had to be done. You and a handful of others raised the banner of the Reclamation — not against the Crown, but against the man who wields its sword and can no longer be trusted with it.
3 tutorial_opening_dialogue_2 But Tarn still commands the royal army, and he is perhaps the most capable soldier this realm has ever seen. Your allies are scattered. You've been pushed back to Onmaa, your back to the sea. The rebellion teeters on the edge of defeat.\n\nThis is the last stand, Sadar. If we fall here, the Reclamation dies with us.
4 tutorial_opening_dialogue_3 Tarn's forces are already at the gates. If we survive this day... well. Perhaps there is still a way to reach the King's ear. But first, we must survive.\n\nJoin the battle. Let us make our stand.
5 tutorial_opening_instruction Click <b>Fight!</b> to enter tactical combat.
@@ -8,11 +8,12 @@ import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.view_filters.GameStateViewFilter
import net.eagle0.eagle.library.util.GameStateViewDiffer
import net.eagle0.eagle.model.action_result.types.ActionResultType
import net.eagle0.eagle.model.state.{GameType, RoundPhase, TutorialPhase}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.RoundPhase
import net.eagle0.eagle.model.view.action_result.ActionResultView
import net.eagle0.eagle.model.view.action_result.{ActionResultView, TutorialDialogue, TutorialDialogueContent}
import net.eagle0.eagle.model.view.game_state.{GameStateView, GameStateViewDiff}
import net.eagle0.eagle.FactionId
import net.eagle0.util.hero_generation.LoadedHero
object ActionResultFilter {
private val UNIVERSALLY_VISIBLE_TYPES: Set[ActionResultType] = Set(
@@ -107,6 +108,8 @@ object ActionResultFilter {
def shouldIncludeNotification(targetFactionIds: Vector[FactionId]): Boolean =
targetFactionIds.isEmpty || factionId.exists(targetFactionIds.contains)
val tutorialDialogues = openingTutorialDialogue(actionResult.actionResultType, result.resultingState, factionId)
if gsDiff.isDefined &&
actionResult.actingFactionId.isDefined &&
factionId.exists { askingFid =>
@@ -137,7 +140,8 @@ object ActionResultFilter {
provinceId = actionResult.provinceId,
actingHeroId = actionResult.actingHeroId,
gameStateDiff = gsDiff,
notifications = filteredNotifications
notifications = filteredNotifications,
tutorialDialogues = tutorialDialogues
)
}
}
@@ -195,6 +199,9 @@ object ActionResultFilter {
def shouldIncludeNotification(targetFactionIds: Vector[FactionId]): Boolean =
targetFactionIds.isEmpty || targetFactionIds.contains(factionId)
val tutorialDialogues =
openingTutorialDialogue(actionResult.actionResultType, result.resultingState, Some(factionId))
if gsDiff.isDefined &&
actionResult.actingFactionId.isDefined &&
!actionResult.actingFactionId.exists(targetFid =>
@@ -220,11 +227,31 @@ object ActionResultFilter {
provinceId = actionResult.provinceId,
actingHeroId = actionResult.actingHeroId,
gameStateDiff = gsDiff,
notifications = filteredNotifications
notifications = filteredNotifications,
tutorialDialogues = tutorialDialogues
)
}
}
private def openingTutorialDialogue(
actionResultType: ActionResultType,
state: GameState,
factionId: Option[FactionId]
): Vector[TutorialDialogue] =
Option
.when(
factionId.contains(TutorialDialogueContent.playerFactionId) &&
actionResultType == ActionResultType.GameStart &&
state.gameType == GameType.Tutorial(TutorialPhase.OpeningBattle)
) {
val speakerHeroId = state.heroes.values
.find(_.nameTextId == LoadedHero.nameTextId("Old Marek the Learned"))
.map(_.id)
.getOrElse(throw new IllegalStateException("Old Marek is missing from the opening tutorial state"))
TutorialDialogueContent.opening(speakerHeroId)
}
.toVector
def filterForObserver(
startingState: GameState,
results: Vector[ActionResultWithResultingState]
@@ -115,6 +115,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/view/action_result:action_result_view",
"//src/main/scala/net/eagle0/eagle/model/view/game_state:game_state_view_diff",
"//src/main/scala/net/eagle0/util/hero_generation:loaded_hero",
],
)
@@ -5,6 +5,12 @@ import net.eagle0.eagle.model.proto_converters.view.game_state.GameStateViewDiff
import net.eagle0.eagle.model.proto_converters.NotificationConverter
import net.eagle0.eagle.model.view.action_result.ActionResultView
import net.eagle0.eagle.views.action_result_view.ActionResultView as ActionResultViewProto
import net.eagle0.eagle.views.tutorial_dialogue.{
TutorialDialogue as TutorialDialogueProto,
TutorialDialogueStep as TutorialDialogueStepProto,
TutorialFocus as TutorialFocusProto,
TutorialHexTile as TutorialHexTileProto
}
object ActionResultViewConverter {
def toProto(view: ActionResultView): ActionResultViewProto =
@@ -16,6 +22,30 @@ object ActionResultViewConverter {
gameStateDiff = view.gameStateDiff.map(GameStateViewDiffConverter.toProto),
notifications = view.notifications
.map(NotificationConverter.toProto)
.collect { case (proto, false) => proto }
.collect { case (proto, false) => proto },
tutorialDialogues = view.tutorialDialogues.map(dialogue =>
TutorialDialogueProto(
id = dialogue.id,
steps = dialogue.steps.map(step =>
TutorialDialogueStepProto(
speakerHeroId = step.speakerHeroId,
dialogueTextId = step.dialogueTextId,
instructionTextId = step.instructionTextId,
focus = step.focus.map {
case net.eagle0.eagle.model.view.action_result.TutorialFocus.Province(provinceId) =>
TutorialFocusProto(target = TutorialFocusProto.Target.ProvinceId(provinceId))
case net.eagle0.eagle.model.view.action_result.TutorialFocus.Button(buttonId) =>
TutorialFocusProto(target = TutorialFocusProto.Target.ButtonId(buttonId))
case net.eagle0.eagle.model.view.action_result.TutorialFocus.HexTile(row, column) =>
TutorialFocusProto(
target = TutorialFocusProto.Target.HexTile(TutorialHexTileProto(row = row, column = column))
)
case net.eagle0.eagle.model.view.action_result.TutorialFocus.Unit(unitId) =>
TutorialFocusProto(target = TutorialFocusProto.Target.UnitId(unitId))
}
)
)
)
)
)
}
@@ -9,6 +9,7 @@ scala_library(
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:tutorial_dialogue_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
@@ -11,5 +11,6 @@ case class ActionResultView(
provinceId: Option[ProvinceId] = None,
actingHeroId: Option[HeroId] = None,
gameStateDiff: Option[GameStateViewDiff] = None,
notifications: Vector[NotificationT] = Vector.empty
notifications: Vector[NotificationT] = Vector.empty,
tutorialDialogues: Vector[TutorialDialogue] = Vector.empty
)
@@ -2,10 +2,15 @@ load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "action_result_view",
srcs = ["ActionResultView.scala"],
srcs = [
"ActionResultView.scala",
"TutorialDialogue.scala",
"TutorialDialogueContent.scala",
],
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/action_result:__pkg__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
"//src/main/scala/net/eagle0/eagle/service/controller:__pkg__",
],
deps = [
@@ -0,0 +1,18 @@
package net.eagle0.eagle.model.view.action_result
sealed trait TutorialFocus
object TutorialFocus {
case class Province(provinceId: Int) extends TutorialFocus
case class Button(buttonId: String) extends TutorialFocus
case class HexTile(row: Int, column: Int) extends TutorialFocus
case class Unit(unitId: Int) extends TutorialFocus
}
case class TutorialDialogueStep(
speakerHeroId: Int,
dialogueTextId: String,
instructionTextId: Option[String] = None,
focus: Option[TutorialFocus] = None
)
case class TutorialDialogue(id: String, steps: Vector[TutorialDialogueStep])
@@ -0,0 +1,38 @@
package net.eagle0.eagle.model.view.action_result
object TutorialDialogueContent {
val playerFactionId: Int = 3
val openingFirstDialogueTextId: String = "tutorial_opening_dialogue_1"
val openingSecondDialogueTextId: String = "tutorial_opening_dialogue_2"
val openingThirdDialogueTextId: String = "tutorial_opening_dialogue_3"
val openingInstructionTextId: String = "tutorial_opening_instruction"
val textIds: Vector[String] = Vector(
openingFirstDialogueTextId,
openingSecondDialogueTextId,
openingThirdDialogueTextId,
openingInstructionTextId
)
def opening(speakerHeroId: Int): TutorialDialogue = TutorialDialogue(
id = "tutorial_opening",
steps = Vector(
TutorialDialogueStep(
speakerHeroId = speakerHeroId,
dialogueTextId = openingFirstDialogueTextId
),
TutorialDialogueStep(
speakerHeroId = speakerHeroId,
dialogueTextId = openingSecondDialogueTextId,
focus = Some(TutorialFocus.Province(provinceId = 14))
),
TutorialDialogueStep(
speakerHeroId = speakerHeroId,
dialogueTextId = openingThirdDialogueTextId,
instructionTextId = Some(openingInstructionTextId),
focus = Some(TutorialFocus.Button(buttonId = "FightButton"))
)
)
)
}
@@ -289,6 +289,7 @@ scala_library(
srcs = ["GamesManager.scala"],
resources = [
"//src/main/resources/net/eagle0/eagle:settings",
"//src/main/resources/net/eagle0/eagle:tutorial_dialogue_text",
"//src/main/resources/net/eagle0/shardok:battalion_types",
],
visibility = [
@@ -335,6 +336,7 @@ scala_library(
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:proto_parser",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/common:tsv_utils",
"//src/main/scala/net/eagle0/common:zip_utils",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle:user_id",
@@ -367,6 +369,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/shardok_battle",
"//src/main/scala/net/eagle0/eagle/model/view/action_result:action_result_view",
"//src/main/scala/net/eagle0/eagle/service/controller:game_controller",
"//src/main/scala/net/eagle0/eagle/service/controller:human_player_client_connection_state",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation",
@@ -6,7 +6,7 @@ import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Failure, Random, Success}
import io.sentry.Sentry
import net.eagle0.common.{ProtoParser, SeededRandom, SimpleTimedLogger, ZipUtils}
import net.eagle0.common.{ProtoParser, SeededRandom, SimpleTimedLogger, TsvUtils, ZipUtils}
import net.eagle0.common.shardok_internal_interface.ShardokInternalInterfaceGrpc.ShardokInternalInterfaceStub
import net.eagle0.common.tutorial_battle_config.TutorialBattleConfig
import net.eagle0.eagle.{ClientTextId, FactionId, GameId, ProvinceId, ShardokGameId, UserId}
@@ -47,6 +47,7 @@ import net.eagle0.eagle.model.state.{BattalionType, TutorialPhase}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.shardok_battle.ShardokBattle
import net.eagle0.eagle.model.view.action_result.TutorialDialogueContent
import net.eagle0.eagle.service.controller.*
import net.eagle0.eagle.service.new_game_creation.{
ExpandedGameParameters,
@@ -120,11 +121,19 @@ object GamesManager {
private def isOpeningTutorialBattle(battle: ShardokBattle): Boolean =
battle.battleIndex == 1 && battle.reinforcementHeroIds.nonEmpty
private val tutorialDialogueTexts: Map[ClientTextId, String] =
TsvUtils
.loadMaps(getClass.getResource("/net/eagle0/eagle/tutorial_dialogue_text.tsv"))
.getOrElse(throw new EagleInternalException("Failed to load tutorial dialogue text"))
.map(row => row("text_id") -> row("text").replace("\\n", "\n"))
.toMap
private val pregeneratedTexts = PregeneratedClientTextStore(
texts = GameParametersUtils.fixedHeroesNameMap ++
GameParametersUtils.pregeneratedHeroesNameMap ++
GameParametersUtils.fixedHeroBackstories ++
GameParametersUtils.pregeneratedHeroBackstories +
GameParametersUtils.pregeneratedHeroBackstories ++
tutorialDialogueTexts +
(NewGameCreation.initialChronicleGeneratedTextId -> NewGameCreation.initialChronicleText) +
(TutorialGameCreation.tutorialChronicleGeneratedTextId -> TutorialGameCreation.tutorialChronicleText)
)
@@ -190,6 +199,19 @@ object GamesManager {
factionIds
)
private def withPopulatedTutorialTexts(
clientTextStore: ClientTextStore,
factionIds: Vector[FactionId]
): ClientTextStore =
clientTextStore.withAddedCompleteTexts(
TutorialDialogueContent.textIds.map(textId =>
textId -> pregeneratedTexts
.getText(textId)
.getOrElse(throw new EagleInternalException(s"Missing pregenerated tutorial dialogue text: $textId"))
),
factionIds
)
private[service] def pregeneratedTextPairs(
pregenerated: PregeneratedClientTextStore,
heroes: Iterable[HeroT]
@@ -1952,10 +1974,13 @@ class GamesManager(
)
// Copy pregenerated texts (hero names, backstories) into the game's text store
val populatedTextStore = GamesManager.withPopulatedPregeneratedTexts(
clientTextStore = initialTextStore,
heroes = engineAndResults.engine.currentState.heroes.values,
factionIds = engineAndResults.engine.factionIds
val populatedTextStore = GamesManager.withPopulatedTutorialTexts(
clientTextStore = GamesManager.withPopulatedPregeneratedTexts(
clientTextStore = initialTextStore,
heroes = engineAndResults.engine.currentState.heroes.values,
factionIds = engineAndResults.engine.factionIds
),
factionIds = Vector(TutorialDialogueContent.playerFactionId)
)
// Player is Sadar Rakon (faction 3), AI factions are King (2), Bridget (4), and Hedrick (5)
@@ -27,6 +27,7 @@ import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.backstory_version.BackstoryVersion
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.view.action_result.TutorialDialogueContent
import net.eagle0.eagle.service.controller.GameController
import net.eagle0.eagle.service.controller.HumanPlayerClientConnectionState
import net.eagle0.eagle.service.new_game_creation.{
@@ -1179,6 +1180,16 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val factionIds = controller.engine.currentState.factions.keys.toVector
val chronicleId = "chronicle_update_371_11"
TutorialDialogueContent.textIds.foreach { textId =>
controller.clientTextStore.completeTexts.keySet should contain(textId)
}
controller.clientTextStore
.getCompleteTextsAccessibleTo(TutorialDialogueContent.playerFactionId)
.map(_.id) should contain allElementsOf TutorialDialogueContent.textIds
controller.clientTextStore.completeTexts(TutorialDialogueContent.openingFirstDialogueTextId).text should include(
"\n\n"
)
// A yearly chronicle text generated during the live tutorial session, then persisted.
val _ = controller.clientTextStore
.withAddedCompleteText(chronicleId, "A Realm in Ruin", factionIds)
@@ -7,6 +7,7 @@ import scala.util.{Failure, Try}
import net.eagle0.eagle.library.settings.loaders.BattalionTypeLoader
import net.eagle0.eagle.library.settings.HistoryBackend
import net.eagle0.eagle.model.view.action_result.{TutorialDialogueContent, TutorialFocus}
import net.eagle0.eagle.service.persistence.Persister
import net.eagle0.util.hero_generation.LoadedHero
import org.scalatest.flatspec.AnyFlatSpec
@@ -174,4 +175,28 @@ class TutorialGameCreationTest extends AnyFlatSpec with Matchers {
gameState.heroes(100).nameTextId.shouldBe(LoadedHero.nameTextId("John Ranil"))
gameState.heroes(101).nameTextId.shouldBe(LoadedHero.nameTextId("Elena Fyar"))
}
it should "deliver the opening dialogue in the player's game-start result" in {
val history = engineAndResults.engine.filteredHistoryWithUnfilteredCount(
factionId = TutorialDialogueContent.playerFactionId,
knownUnfilteredCount = 0
)
val dialogues = history.filteredResults.flatMap(_.tutorialDialogues)
dialogues.map(_.id).should(contain("tutorial_opening"))
inside(dialogues.find(_.id == "tutorial_opening")) {
case Some(opening) =>
opening.steps.should(have.size(3))
engineAndResults.engine.currentState.heroes(opening.steps.head.speakerHeroId).nameTextId shouldBe
LoadedHero.nameTextId("Old Marek the Learned")
opening.steps.map(_.dialogueTextId) shouldBe Vector(
TutorialDialogueContent.openingFirstDialogueTextId,
TutorialDialogueContent.openingSecondDialogueTextId,
TutorialDialogueContent.openingThirdDialogueTextId
)
opening.steps(1).focus.shouldBe(Some(TutorialFocus.Province(provinceId = 14)))
opening.steps(2).focus.shouldBe(Some(TutorialFocus.Button(buttonId = "FightButton")))
opening.steps.last.instructionTextId.shouldBe(Some(TutorialDialogueContent.openingInstructionTextId))
}
}
}