Track generated text in text client (#8424)

This commit is contained in:
2026-07-10 19:20:33 -07:00
committed by GitHub
parent 311cf6e262
commit 8e30daa63b
2 changed files with 75 additions and 4 deletions
@@ -1,5 +1,6 @@
package net.eagle0.eagle.text_client
import java.nio.charset.StandardCharsets
import java.nio.file.{Files, Paths}
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit
@@ -216,6 +217,7 @@ final class EagleTextClient(config: Config) {
case Vector("status") => println(state.describeStatus)
case Vector("state") => println(state.describeState)
case Vector("state-json") => println(state.stateJson)
case Vector("text", textId) => println(state.describeText(textId))
case Vector("commands") => println(state.describeCommands(rawJson = false))
case Vector("commands-json") => println(state.describeCommands(rawJson = true))
case Vector("shardok-commands") => println(state.describeShardokCommands(rawJson = false))
@@ -303,6 +305,7 @@ final class EagleTextClient(config: Config) {
UpdateStreamRequest.StreamGameRequest(
gameId = gameId,
unfilteredResultCount = state.unfilteredResultCount,
streamingTextStatuses = state.streamingTextStatuses,
clientId = UUID.randomUUID().toString
)
)
@@ -472,6 +475,7 @@ final class ClientState {
private var latestGameId: Option[Long] = None
private var latestUnfilteredResultCount: Int = 0
private var latestShardokBattles: Map[String, ShardokBattleState] = Map.empty
private val textStore = new ClientTextStore
def handle(response: UpdateStreamResponse): Unit = synchronized {
response.responseDetails match {
@@ -518,7 +522,11 @@ final class ClientState {
.mkString(", ")
println(s"shardok-update: $summaries")
case GameUpdate.GameUpdateDetails.StreamingTextResponse(value) =>
println(s"text-update: ${value.llmIdentifier} bytes=${value.newText.length} complete=${value.completed}")
val updatedText =
textStore.update(value.llmIdentifier, value.newText, value.startingByteCount, value.completed)
println(
s"text-update: ${value.llmIdentifier} bytes=${value.newText.length} total-bytes=${updatedText.getBytes(StandardCharsets.UTF_8).length} complete=${value.completed}"
)
case GameUpdate.GameUpdateDetails.ErrorResponse(value) =>
println(s"game-error: ${value.errorString}")
case GameUpdate.GameUpdateDetails.ShardokBattleResetResponse(value) =>
@@ -767,6 +775,7 @@ final class ClientState {
.sortBy(entry => (entry.date.map(_.year).getOrElse(0), entry.date.map(_.month).getOrElse(0)))
def setCurrentGame(gameId: Long): Unit = synchronized {
if latestGameId.exists(_ != gameId) then textStore.clear()
latestGameId = Some(gameId)
}
@@ -774,6 +783,10 @@ final class ClientState {
def unfilteredResultCount: Int = synchronized(latestUnfilteredResultCount)
def streamingTextStatuses: Seq[UpdateStreamRequest.StreamGameRequest.StreamingTextStatus] = synchronized {
textStore.statuses
}
def commandToken: Option[Long] = synchronized(latestCommands.map(_.token))
def marchWarnings(provinceId: Int, command: SelectedCommandProto): Vector[String] = synchronized {
@@ -928,10 +941,17 @@ final class ClientState {
latestState match {
case Some(gs) =>
val provinces = gs.provinces.values.toVector.sortBy(_.id).take(20).map { p =>
val ruler = p.rulingFactionId.map(_.toString).getOrElse("-")
val info =
val ruler = p.rulingFactionId.map(_.toString).getOrElse("-")
val leader =
p.fullInfo
.flatMap(_.rulingHeroId)
.flatMap(gs.heroes.get)
.map(describeHero)
.map(name => s" leader=$name")
.getOrElse("")
val info =
p.fullInfo.map(i => s" gold=${i.gold} food=${i.food} heroes=${i.rulingFactionHeroIds.size}").getOrElse("")
s"province ${p.id}: ${p.name} ruler=$ruler$info"
s"province ${p.id}: ${p.name} ruler=$ruler$leader$info"
}
val battles =
gs.outstandingBattles.map(b => s"battle ${b.shardokGameId.getOrElse("-")} province=${b.defenderProvince}")
@@ -947,6 +967,13 @@ final class ClientState {
latestState.map(toJson).getOrElse("{}")
}
def describeText(textId: String): String = synchronized {
textStore.entry(textId) match {
case Some(entry) => s"text $textId complete=${entry.completed}: ${entry.text}"
case None => s"No streamed text for $textId."
}
}
def describeCommands(rawJson: Boolean): String = synchronized {
latestCommands match {
case Some(commands) if commands.commandsByProvince.nonEmpty =>
@@ -1027,6 +1054,9 @@ final class ClientState {
private def commandName(command: AvailableCommandProto): String =
command.getClass.getSimpleName.stripSuffix("$")
private def describeHero(hero: HeroView): String =
textStore.entry(hero.nameTextId).map(_.text).filter(_.nonEmpty).getOrElse(s"${hero.nameTextId}#${hero.id}")
private def commandJson(command: AvailableCommandProto): String =
command match {
case message: GeneratedMessage => toJson(message)
@@ -1069,6 +1099,34 @@ final class ClientState {
}
}
final case class ClientTextEntry(text: String, completed: Boolean)
final class ClientTextStore {
private var entries: Map[String, ClientTextEntry] = Map.empty
def clear(): Unit = entries = Map.empty
def entry(textId: String): Option[ClientTextEntry] = entries.get(textId)
def statuses: Seq[UpdateStreamRequest.StreamGameRequest.StreamingTextStatus] =
entries.toVector.sortBy(_._1).map {
case (textId, entry) =>
UpdateStreamRequest.StreamGameRequest.StreamingTextStatus(
llmIdentifier = textId,
knownByteCount = entry.text.getBytes(StandardCharsets.UTF_8).length,
knownComplete = entry.completed
)
}
def update(textId: String, newText: String, startingByteCount: Int, completed: Boolean): String = {
val currentText = entries.get(textId).map(_.text).getOrElse("")
val retainedBytes = currentText.getBytes(StandardCharsets.UTF_8).take(startingByteCount)
val updatedText = String(retainedBytes, StandardCharsets.UTF_8) + newText
entries += textId -> ClientTextEntry(updatedText, completed)
updatedText
}
}
final case class ShardokUnitSnapshot(
units: Map[Int, UnitView],
reserveUnits: Map[Int, UnitView],
@@ -1124,6 +1182,7 @@ object Help {
| status
| state
| state-json
| text <text-id>
| commands
| commands-json
| shardok-commands
@@ -8,6 +8,18 @@ import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class ClientStateTest extends AnyFlatSpec with Matchers {
"ClientTextStore" should "resume UTF-8 streaming text from byte offsets" in {
val store = new ClientTextStore
val _ = store.update("hero-name", "Cæ", startingByteCount = 0, completed = false)
val _ = store.update("hero-name", "sar", startingByteCount = 4, completed = true)
store.entry("hero-name") shouldBe Some(ClientTextEntry("Cæsar", completed = true))
store.statuses should have size 1
store.statuses.head.knownByteCount shouldBe 6
store.statuses.head.knownComplete shouldBe true
}
"ClientState" should "apply Eagle game state diffs from action result updates" in {
val state = new ClientState
state.handle(