Batch AI client updates and saves (#8711)

This commit is contained in:
2026-07-18 21:54:31 -07:00
committed by GitHub
parent 027c5b6598
commit 3b5deebb92
2 changed files with 206 additions and 40 deletions
@@ -194,7 +194,8 @@ object GameController {
* ended.
*/
private def endGameAllHumansDefeated(
gcwpr: GameControllerWithPostResults
gcwpr: GameControllerWithPostResults,
updateClientsAndSave: Boolean = true
): GameControllerWithPostResults = {
val gc = gcwpr.gameController
SimpleTimedLogger.printLogger.logLine(
@@ -202,7 +203,10 @@ object GameController {
)
// End the game with no victor (humans lost)
gc.withHandledEngineAndResults(gc.engine.endGame(victorFactionId = None))
gc.withHandledEngineAndResults(
gc.engine.endGame(victorFactionId = None),
updateClientsAndSave = updateClientsAndSave
)
}
def performAiCommands(
@@ -215,6 +219,8 @@ object GameController {
else if allHumansDefeated(gameControllerWithPostResults) then
endGameAllHumansDefeated(gameControllerWithPostResults)
else {
val initialResultCount = gameControllerWithPostResults.postResults.results.size
@tailrec
def go(
acc: GameControllerWithPostResults
@@ -262,13 +268,29 @@ object GameController {
// Check if game ended or all humans defeated after AI commands
if next.gameController.engine.currentState.runStatus == RunStatus.Over then next
else if allHumansDefeated(next) then endGameAllHumansDefeated(next)
else go(next)
else if allHumansDefeated(next) then {
val ended = endGameAllHumansDefeated(next, updateClientsAndSave = false)
GameControllerWithPostResults(
gameController = ended.gameController,
postResults = next.postResults.append(ended.postResults)
)
} else go(next)
}
end if
}
go(gameControllerWithPostResults)
val result = go(gameControllerWithPostResults)
val deferredAiResults = result.postResults.results.drop(initialResultCount)
if deferredAiResults.isEmpty then result
else
result.copy(
gameController = result.gameController
.withHumanClientsUpdated(
results = deferredAiResults,
clientTextStoreForUpdate = result.gameController.clientTextStore
)
.copyWithAtomicSave
)
}
def performAutomaticContinuation(
@@ -664,24 +686,28 @@ final case class GameController(
factionId: FactionId,
selectedProvinceId: ProvinceId,
command: ScalaSelectedCommand,
@unused save: Boolean,
save: Boolean,
runUpdateChecks: Boolean = true
): GameControllerWithPostResults =
): GameControllerWithPostResults = {
val engineAndResults =
if runUpdateChecks then
this.engine.postCommand(
factionId = factionId,
selectedProvinceId = selectedProvinceId,
selectedCommand = command
)
else
this.engine.postCommandWithoutUpdateChecks(
factionId = factionId,
selectedProvinceId = selectedProvinceId,
selectedCommand = command
)
withHandledEngineAndResults(
engineAndResults =
if runUpdateChecks then
this.engine.postCommand(
factionId = factionId,
selectedProvinceId = selectedProvinceId,
selectedCommand = command
)
else
this.engine.postCommandWithoutUpdateChecks(
factionId = factionId,
selectedProvinceId = selectedProvinceId,
selectedCommand = command
)
engineAndResults = engineAndResults,
updateClientsAndSave = save
)
}
def withNewAiClientWithFid(fid: FactionId): GameController =
this.withAiClient(AIClient(gameId = gameId, factionId = fid))
@@ -1094,7 +1120,8 @@ final case class GameController(
}
private def withHandledEngineAndResults(
engineAndResults: EngineAndResults
engineAndResults: EngineAndResults,
updateClientsAndSave: Boolean = true
): GameControllerWithPostResults = {
// Check for tutorial strategic events that should fire based on the new game state
val tutorialEvents = TutorialStrategicEvents.checkForEvents(engineAndResults.engine.currentState)
@@ -1112,23 +1139,14 @@ final case class GameController(
val newFullHistory = extractFullHistory(finalEngineAndResults.engine)
val newGc = copy(
val handledGc = copy(
engine = finalEngineAndResults.engine,
fullHistory = newFullHistory,
userIdToFactionId = userIdToFactionId.filter {
case (_, fid) =>
finalEngineAndResults.engine.factionIds.contains(fid)
},
humanClients = GameController.humanClientsAfterPostingResults(
humanClients = humanClients,
clientTextStore = this.clientTextStore,
visibilityExtensions = clientVisibilityExtensions,
engine = finalEngineAndResults.engine,
fullHistory = newFullHistory,
aiFactionIds = aiClientFactionIds.toSet,
battleProgress = battleProgress,
lastRevealedRound = lastRevealedRound
),
humanClients = humanClients,
aiClients = {
val afterRemovals = aiClients
.filterNot(aic =>
@@ -1150,13 +1168,17 @@ final case class GameController(
foldInLlmRequests(this.clientTextStore, llmRequestsWithHistoryCounts),
clientVisibilityExtensions
)
) match {
// Only save when there are new Eagle results. Shardok results are saved
// in withNewShardokResults, so we don't need to re-serialize Eagle history
// for Shardok-only updates.
case gc if finalEngineAndResults.results.nonEmpty => gc.copyWithAtomicSave
case gc => gc
}
)
val newGc =
if updateClientsAndSave then {
val withUpdatedClients = handledGc.withHumanClientsUpdated(
results = finalEngineAndResults.results,
clientTextStoreForUpdate = this.clientTextStore
)
if finalEngineAndResults.results.nonEmpty then withUpdatedClients.copyWithAtomicSave
else withUpdatedClients
} else handledGc
GameControllerWithPostResults(
gameController = newGc,
@@ -1168,6 +1190,23 @@ final case class GameController(
)
}
private def withHumanClientsUpdated(
results: Seq[ActionResultT],
clientTextStoreForUpdate: ClientTextStore
): GameController =
copy(
humanClients = GameController.humanClientsAfterPostingResults(
humanClients = humanClients,
clientTextStore = clientTextStoreForUpdate,
visibilityExtensions = results.flatMap(_.clientTextVisibilityExtensions).toVector,
engine = engine,
fullHistory = fullHistory,
aiFactionIds = aiClientFactionIds.toSet,
battleProgress = battleProgress,
lastRevealedRound = lastRevealedRound
)
)
def streamUpdates(
userId: UserId,
clientId: String,
@@ -4,6 +4,8 @@ import java.io.PrintWriter
import scala.collection.immutable.SortedMap
import net.eagle0.common.{RandomState, SeededRandom}
import net.eagle0.eagle.ai.AIClient
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.StreamingTextStatus
import net.eagle0.eagle.api.selected_command.{AttackDecisionSelectedCommand, ReturnSelectedCommand}
import net.eagle0.eagle.client_text.{ClientTextStore, ClientTextStoreWithUpdate, CompleteClientText}
@@ -11,6 +13,8 @@ import net.eagle0.eagle.library.*
import net.eagle0.eagle.model.action_result.concrete.ActionResultC
import net.eagle0.eagle.model.action_result.types.ActionResultType
import net.eagle0.eagle.model.state.{GameType, RoundPhase, TutorialPhase}
import net.eagle0.eagle.model.state.command.available.AvailableCommand.ReturnAvailable
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.command.selected.SelectedCommand as ScalaSelectedCommand
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.faction.concrete.FactionC
@@ -18,7 +22,7 @@ import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.service.{FullGameHistory, InMemoryHistory, SyncResponseObserver}
import net.eagle0.eagle.service.{FullGameHistory, InMemoryHistory, PostResults, SyncResponseObserver}
import net.eagle0.eagle.UserId
import org.scalamock.scalatest.MockFactory
import org.scalatest.flatspec.AnyFlatSpec
@@ -769,4 +773,127 @@ class GameControllerTest extends AnyFlatSpec with MockFactory with Matchers with
}
newController.clientTextStore shouldBe mockPrunedClientTextStore
}
"performAiCommands" should "update human clients and save once after an AI command batch" in {
val humanFactionId = 1
val aiFactionId = 2
val humanHeroId = 101
val aiHeroId = 202
val aiProvinceId = 20
val state = testGameState.copy(
provinces = Map(
10 -> ProvinceC(
id = 10,
name = "Human Province",
rulingFactionId = Some(humanFactionId),
rulingFactionHeroIds = Vector(humanHeroId)
),
aiProvinceId -> ProvinceC(
id = aiProvinceId,
name = "AI Province",
rulingFactionId = Some(aiFactionId),
rulingFactionHeroIds = Vector(aiHeroId)
)
),
heroes = Map(
humanHeroId -> HeroC(id = humanHeroId, factionId = Some(humanFactionId)),
aiHeroId -> HeroC(id = aiHeroId, factionId = Some(aiFactionId))
),
factions = Map(
humanFactionId -> FactionC(
id = humanFactionId,
factionHeadId = humanHeroId,
name = "Human",
leaderIds = Vector(humanHeroId)
),
aiFactionId -> FactionC(
id = aiFactionId,
factionHeadId = aiHeroId,
name = "AI",
leaderIds = Vector(aiHeroId)
)
)
)
val command = OneProvinceAvailableCommands(
provinceId = aiProvinceId,
commands = Vector(ReturnAvailable(actingProvinceId = aiProvinceId))
)
val aiCommands = SortedMap(aiProvinceId -> command)
val result1 = ActionResultC(
actionResultType = ActionResultType.UnknownActionUnspecified,
newDate = Some(state.currentDate),
newRandomSeed = Some(2L)
)
val result2 = result1.copy(newRandomSeed = Some(3L))
val engine0 = mock[Engine]
val engine1 = mock[Engine]
val engine2 = mock[Engine]
val history0 = mock[FullGameHistory]
val history1 = mock[FullGameHistory]
val history2 = mock[FullGameHistory]
val ear1 = mock[EngineAndResults]
val ear2 = mock[EngineAndResults]
Vector((engine0, history0, 0), (engine1, history1, 1), (engine2, history2, 2)).foreach {
case (engine, history, count) =>
(() => engine.currentState).expects().returning(state.copy(actionResultCount = count)).anyNumberOfTimes(): Unit
(() => engine.factionIds).expects().returning(Vector(humanFactionId, aiFactionId)).anyNumberOfTimes(): Unit
(() => engine.history).expects().returning(history).anyNumberOfTimes(): Unit
(() => engine.gameId).expects().returning(eagleGameId).anyNumberOfTimes(): Unit
(() => history.count).expects().returning(count).anyNumberOfTimes(): Unit
}
engine0.getAvailablePlayerCommands.expects(aiFactionId).returning(aiCommands): Unit
engine0.postCommand
.expects(aiFactionId, aiProvinceId, ScalaSelectedCommand.ReturnSelected)
.returning(ear1): Unit
(() => ear1.engine).expects().returning(engine1).anyNumberOfTimes(): Unit
(() => ear1.results).expects().returning(Vector(result1)).anyNumberOfTimes(): Unit
engine1.getAvailablePlayerCommands.expects(aiFactionId).returning(aiCommands): Unit
engine1.postCommand
.expects(aiFactionId, aiProvinceId, ScalaSelectedCommand.ReturnSelected)
.returning(ear2): Unit
(() => ear2.engine).expects().returning(engine2).anyNumberOfTimes(): Unit
(() => ear2.results).expects().returning(Vector(result2)).anyNumberOfTimes(): Unit
engine2.getAvailablePlayerCommands.expects(*).returning(SortedMap.empty).anyNumberOfTimes(): Unit
engine2.filteredHistoryWithUnfilteredCount
.expects(humanFactionId, 0)
.returning(FilteredHistoryWithUnfilteredCount(Vector.empty, 2)): Unit
(() => engine2.saveNow).expects().returning(engine2).once(): Unit
val observer = mock[SyncResponseObserver]
observer.onNext.expects(*).once(): Unit
val client = new HumanPlayerClientConnectionState(
eagleGameId = eagleGameId,
factionId = humanFactionId,
clientId = "human-client",
grpcObserver = observer,
unfilteredKnownHistoryCount = 0,
knownShardokResultCounts = Vector.empty,
streamingTextStatusMap = Map.empty,
logWriter = mockLogWriter
)
val textStore = mock[ClientTextStore]
textStore.hasIncompleteTextsAccessibleTo.expects(humanFactionId).returning(false): Unit
(() => textStore.saved).expects().returning(textStore).once(): Unit
val controller = GameController(
engine = engine0,
fullHistory = history0,
userIdToFactionId = Map(UserId("human") -> humanFactionId),
humanClients = Vector(client),
aiClients = Vector(RandomState(AIClient(eagleGameId, aiFactionId), SeededRandom(1L))),
clientTextStore = textStore
)
val updated = GameController.performAiCommands(
GameControllerWithPostResults(controller, PostResults(Some(state), Vector.empty, Vector.empty))
)
updated.postResults.results shouldBe Vector(result1, result2)
updated.gameController.engine shouldBe engine2
updated.gameController.humanClients.head.unfilteredKnownHistoryCount shouldBe 2
}
}