Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 e0d35bc409 Remove migration code - all games now use UUID from the start
Removed one-time migration code since we're starting fresh with
no legacy displayName-based games. All new games will use userId
(UUID) from the start.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:18:26 -08:00
adminandClaude Opus 4.5 72898e5761 Fix findByDisplayName to fall back to direct user search
The displayNameIndex may not be fully populated for users whose display
names were set before the index was implemented. Fall back to searching
the users list directly if the index lookup fails.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:15:51 -08:00
adminandClaude Opus 4.5 a96ac6bc8a Use UserId type in UserService for full type safety
- Updated UserService.findByUserId to take UserId instead of String
- Updated UserService.setDisplayName to take UserId instead of String
- Updated UserServiceImpl and NoOpUserService implementations
- Updated all call sites to pass UserId directly instead of using .value
- This completes the type safety for userId throughout the codebase,
  preventing accidental mixing of userId and displayName strings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 10:47:33 -08:00
adminandClaude Opus 4.5 e30fec13d4 Add UserId opaque type, startup migration, and backup logic
Major changes:
- Create UserId opaque type for compile-time safety between userId and displayName
- Add explicit startup migration in GamesManager.begin() that converts all games
  from displayName-based mapping to userId-based mapping
- Create backup of games.e0es before migration (games.e0es.backup.<timestamp>)
- Remove mixed-state handling logic ("if it looks like UUID") - all games now
  use userId format after migration
- Update GameController, EagleServiceImpl, GameAdminServiceImpl, AuthorizationUtils
  to use UserId type throughout
- Add comprehensive migration tests covering:
  - Normal migration of displayName to userId
  - Skipping already-migrated games
  - Handling users not found (faction becomes AI)
  - Backup file creation verification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 10:39:18 -08:00
adminandClaude Opus 4.5 ec1b300a1c Use UUID for user-to-game mapping instead of displayName
Previously, Eagle mapped users to games/factions using displayName. This
meant users who changed their display name would lose connection to their
games.

This change migrates to using userId (UUID) for the mapping:
- Add new proto fields user_id_to_pid and last_played_by_user_id
- Update GameController to use userIdToFactionId
- Add migration logic in GamesManager to convert legacy games on load
- Update EagleServiceImpl to use AuthorizationUtils.userId
- Update GameAdminServiceImpl to resolve display names from userIds
- Write to both old and new fields during transition period

Existing games are automatically migrated when first loaded after deploy.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:55:38 -08:00
20 changed files with 420 additions and 235 deletions
@@ -13,10 +13,19 @@ option objc_class_prefix = "E0G";
message RunningGame {
int64 game_id = 1;
// DEPRECATED: Use user_id_to_pid instead. This maps displayName -> factionId
// Kept for backward compatibility during migration.
map<string, int32> user_to_pid = 2;
bool ongoing = 3;
// DEPRECATED: Use last_played_by_user_id instead. This maps displayName -> timestamp
// Timestamp (millis since epoch) of when each user last took a turn
map<string, int64> last_played_by_user = 4;
// New UUID-based fields (preferred)
// Maps userId (UUID) -> factionId. Takes precedence over user_to_pid when present.
map<string, int32> user_id_to_pid = 5;
// Maps userId (UUID) -> timestamp. Takes precedence over last_played_by_user when present.
map<string, int64> last_played_by_user_id = 6;
}
message RunningGames {
@@ -82,3 +82,12 @@ scala_library(
deps = [
],
)
scala_library(
name = "user_id",
srcs = ["UserId.scala"],
visibility = [
":__subpackages__",
"//src/test:__subpackages__",
],
)
+13 -6
View File
@@ -128,8 +128,14 @@ object Main {
ShardokSecurityConfig(useTls = useTls, authToken = authToken)
}
// Create local UserService (used by GamesManager for user lookups)
// This is always created for GamesManager, regardless of external auth configuration
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
val userServiceForGames = new UserServiceImpl(authPersister)
val gamesManager = newGamesManager(
shardokInterfaceAddress = shardokInterfaceAddress,
userService = userServiceForGames,
securityConfig = shardokSecurityConfig
)
gamesManager.begin()
@@ -153,11 +159,10 @@ object Main {
ExternalAuthClient(url)(ExecutionContext.global)
}
// Create local UserService only if NOT using external auth service
// Use the same local UserService for auth if NOT using external auth service
// (external Go auth service handles user persistence)
val userService: Option[UserService] = if authServiceUrl.isEmpty then {
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
Some(new UserServiceImpl(authPersister))
Some(userServiceForGames)
} else {
None
}
@@ -177,6 +182,7 @@ object Main {
SeededRandom(random.nextLong()),
oauthService,
userService,
userServiceForGames,
externalAuthClient
).start
SimpleTimedLogger.printLogger.logLine(
@@ -192,7 +198,8 @@ object Main {
grpcPort: Int,
functionalRandom: FunctionalRandom,
oauthService: OAuthService,
userService: Option[UserService],
userServiceForAuth: Option[UserService],
userServiceForGames: UserService,
externalAuthClient: Option[ExternalAuthClient]
): Server = {
val serverBuilder = ServerBuilder.forPort(grpcPort)
@@ -213,7 +220,7 @@ object Main {
serverBuilder.addService(
GameAdminGrpc
.bindService(
GameAdminServiceImpl(gamesManager),
GameAdminServiceImpl(gamesManager, userServiceForGames),
ExecutionContext.global
)
)
@@ -223,7 +230,7 @@ object Main {
serverBuilder.addService(
AuthGrpc
.bindService(
AuthServiceImpl(oauthService, userService, jwt, externalAuthClient),
AuthServiceImpl(oauthService, userServiceForAuth, jwt, externalAuthClient),
ExecutionContext.global
)
)
@@ -2,6 +2,7 @@ package net.eagle0.eagle
import scala.util.Random
import net.eagle0.eagle.auth.NoOpUserService
import net.eagle0.eagle.service.new_game_creation.GameParametersUtils
import net.eagle0.eagle.service.ServerSetupHelpers.newGamesManager
@@ -13,7 +14,7 @@ object TestAIGame {
if args.length > 0 then args.head.toInt
else 1
val gamesManager = newGamesManager("eagle0.net:443")
val gamesManager = newGamesManager("eagle0.net:443", new NoOpUserService)
gamesManager.begin()
(1 to gameCount).foreach { _ =>
@@ -0,0 +1,18 @@
package net.eagle0.eagle
/**
* Opaque type for user IDs (UUIDs). This provides compile-time safety to prevent accidentally using a display name
* where a user ID is expected, or vice versa.
*/
opaque type UserId = String
object UserId:
/** Create a UserId from a string (typically a UUID) */
def apply(value: String): UserId = value
/** Create a UserId from a raw string - use when you know the string is a valid user ID */
def fromString(value: String): UserId = value
extension (userId: UserId)
/** Get the underlying string value */
def value: String = userId
@@ -49,8 +49,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/eagle:user_id",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/eagle:user_id",
"//src/main/scala/net/eagle0/eagle/service/persistence:persister",
],
)
@@ -9,6 +9,7 @@ import scala.util.{Success, Using}
import com.google.protobuf.timestamp.Timestamp
import net.eagle0.eagle.internal.user.user.{OAuthIdentity, User, UserDatabase}
import net.eagle0.eagle.service.persistence.Persister
import net.eagle0.eagle.UserId
/** Service for managing user records */
trait UserService {
@@ -22,13 +23,13 @@ trait UserService {
): User
/** Find user by internal user ID */
def findByUserId(userId: String): Option[User]
def findByUserId(userId: UserId): Option[User]
/** Find user by display name (case-insensitive) */
def findByDisplayName(displayName: String): Option[User]
/** Set or update display name */
def setDisplayName(userId: String, displayName: String): Either[String, User]
def setDisplayName(userId: UserId, displayName: String): Either[String, User]
/** Check if display name is available */
def isDisplayNameAvailable(displayName: String): Boolean
@@ -110,8 +111,8 @@ class UserServiceImpl(persister: Persister) extends UserService {
}
}
override def findByUserId(userId: String): Option[User] = lock.synchronized {
findByUserIdInternal(userId)
override def findByUserId(userId: UserId): Option[User] = lock.synchronized {
findByUserIdInternal(userId.value)
}
private def findByUserIdInternal(userId: String): Option[User] =
@@ -119,10 +120,15 @@ class UserServiceImpl(persister: Persister) extends UserService {
override def findByDisplayName(displayName: String): Option[User] = lock.synchronized {
val lowerName = displayName.toLowerCase
database.displayNameIndex.get(lowerName).flatMap(findByUserIdInternal)
// Try index first, then fall back to direct search (for users whose
// display names were set before the index was fully populated)
database.displayNameIndex
.get(lowerName)
.flatMap(findByUserIdInternal)
.orElse(database.users.find(_.displayNameLower == lowerName))
}
override def setDisplayName(userId: String, displayName: String): Either[String, User] =
override def setDisplayName(userId: UserId, displayName: String): Either[String, User] =
lock.synchronized {
// Validate: 3-20 chars, alphanumeric + underscore
val validPattern = "^[a-zA-Z0-9_]{3,20}$".r
@@ -134,10 +140,10 @@ class UserServiceImpl(persister: Persister) extends UserService {
// Check uniqueness
database.displayNameIndex.get(lowerName) match {
case Some(existingId) if existingId != userId =>
case Some(existingId) if existingId != userId.value =>
Left("Display name already taken")
case _ =>
findByUserIdInternal(userId) match {
case _ =>
findByUserIdInternal(userId.value) match {
case Some(user) =>
// Remove old name from index if exists
val newIndex = user.displayNameLower match {
@@ -151,8 +157,8 @@ class UserServiceImpl(persister: Persister) extends UserService {
)
database = database.copy(
users = database.users.filterNot(_.userId == userId) :+ updated,
displayNameIndex = newIndex + (lowerName -> userId)
users = database.users.filterNot(_.userId == userId.value) :+ updated,
displayNameIndex = newIndex + (lowerName -> userId.value)
)
saveDatabase()
Right(updated)
@@ -174,3 +180,22 @@ class UserServiceImpl(persister: Persister) extends UserService {
saveDatabase()
}
}
/**
* A no-op UserService for testing or when user persistence is not available. All lookup methods return None, and
* mutation methods do nothing.
*/
class NoOpUserService extends UserService {
override def findOrCreateUser(
provider: String,
providerUserId: String,
email: String,
avatarUrl: String
): User = User(userId = UUID.randomUUID().toString)
override def findByUserId(userId: UserId): Option[User] = None
override def findByDisplayName(displayName: String): Option[User] = None
override def setDisplayName(userId: UserId, displayName: String): Either[String, User] =
Left("UserService not available")
override def isDisplayNameAvailable(displayName: String): Boolean = true
}
@@ -16,6 +16,7 @@ import net.eagle0.eagle.auth.{
UserService
}
import net.eagle0.eagle.internal.user.user.User
import net.eagle0.eagle.UserId
/**
* gRPC service implementation for OAuth authentication.
@@ -1,6 +1,7 @@
package net.eagle0.eagle.service
import io.grpc.Context
import net.eagle0.eagle.UserId
object AuthorizationUtils {
// Username key - set by JWT auth for backwards compatibility with game management code
@@ -16,9 +17,9 @@ object AuthorizationUtils {
def userName: String = userNameCtxKey.get(Context.current)
// JWT auth accessors
def userId: String = {
def userId: UserId = {
val id = userIdCtxKey.get(Context.current)
if id != null && id.nonEmpty then id else userName
UserId(if id != null && id.nonEmpty then id else userName)
}
def displayName: String = {
@@ -12,6 +12,7 @@ scala_library(
":external_auth_client",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/eagle:user_id",
"//src/main/scala/net/eagle0/eagle/auth:jwt_service",
"//src/main/scala/net/eagle0/eagle/auth:oauth_service",
"//src/main/scala/net/eagle0/eagle/auth:user_service",
@@ -38,6 +39,8 @@ scala_library(
"//src/main/scala/net/eagle0/common/llm_integration:open_ai_chat_completions_service_impl",
"//src/main/scala/net/eagle0/common/llm_integration:streaming_text_results",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle:user_id",
"//src/main/scala/net/eagle0/eagle/auth:user_service",
"//src/main/scala/net/eagle0/eagle/library:eagle_client_exception",
"//src/main/scala/net/eagle0/eagle/library:engine",
"//src/main/scala/net/eagle0/eagle/library:game_history",
@@ -105,6 +108,7 @@ scala_library(
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle:user_id",
"//src/main/scala/net/eagle0/eagle/library:eagle_client_exception",
"//src/main/scala/net/eagle0/eagle/library:engine",
"//src/main/scala/net/eagle0/eagle/library:game_history",
@@ -141,6 +145,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"//src/main/scala/net/eagle0/eagle:user_id",
"@maven//:io_grpc_grpc_api",
],
)
@@ -234,11 +239,14 @@ scala_library(
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:running_games_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//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/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle:user_id",
"//src/main/scala/net/eagle0/eagle/ai:ai_client",
"//src/main/scala/net/eagle0/eagle/auth:user_service",
"//src/main/scala/net/eagle0/eagle/client_text",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store_impl",
@@ -542,6 +550,7 @@ scala_library(
":games_manager",
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_scala_grpc",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/auth:user_service",
"//src/main/scala/net/eagle0/eagle/library:engine",
"//src/main/scala/net/eagle0/eagle/service:game_persister_creation",
"//src/main/scala/net/eagle0/eagle/service:local_game_persister_creation",
@@ -17,44 +17,45 @@ import net.eagle0.eagle.library.settings.MaxSupportedPlayers
import net.eagle0.eagle.library.EagleClientException
import net.eagle0.eagle.service.new_game_creation.GameParametersUtils
import net.eagle0.eagle.service.CustomBattleManager.CreateCustomBattleResponse
import net.eagle0.eagle.UserId
class EagleServiceImpl(
gamesManager: GamesManager,
customBattleManager: CustomBattleManager,
var functionalRandom: FunctionalRandom
) extends Eagle {
private var lobbyUsers = Map[String, SyncResponseObserver]()
private var lobbyUsers = Map[UserId, SyncResponseObserver]()
private def lockAndDoWithUserName[T](f: String => T): Future[T] = {
val un = AuthorizationUtils.userName
private def lockAndDoWithUserId[T](f: UserId => T): Future[T] = {
val uid = AuthorizationUtils.userId
Future.fromTry(
this.synchronized {
Try(f(un))
Try(f(uid))
}
)
}
private def lockAndUpdateGamesWith[T](f: String => T): Future[T] =
lockAndDoWithUserName { un =>
val t = f(un)
private def lockAndUpdateGamesWith[T](f: UserId => T): Future[T] =
lockAndDoWithUserId { uid =>
val t = f(uid)
lobbyUsers.keys.foreach(k => lockedSendLobbyUpdate(k))
t
}
def joinGame(request: JoinGameRequest): Future[JoinGameResponse] =
lockAndUpdateGamesWith(un =>
lockAndUpdateGamesWith(uid =>
gamesManager
.joinGame(
userName = un,
userId = uid,
gameId = request.gameId,
desiredLeaderTextId = request.desiredLeaderTextId
)
)
def dropGame(request: DropGameRequest): Future[DropGameResponse] =
lockAndUpdateGamesWith(un =>
lockAndUpdateGamesWith(uid =>
gamesManager.dropGame(
userName = un,
userId = uid,
gameId = request.gameId
)
)
@@ -62,10 +63,10 @@ class EagleServiceImpl(
def createGame(
request: CreateGameRequest
): Future[CreateGameResponse] =
lockAndUpdateGamesWith(un =>
lockAndUpdateGamesWith(uid =>
gamesManager.createGameForUser(
expandedGameParameters = GameParametersUtils.defaultExpandedGameParameters,
userName = un,
userId = uid,
desiredLeaderTextId = request.desiredLeaderTextId,
maxHumanPlayers = request.humanPlayerCount,
totalPlayers = request.totalPlayerCount
@@ -93,7 +94,7 @@ class EagleServiceImpl(
def postCommand(
request: PostCommandRequest
): Future[PostCommandResponse] =
lockAndDoWithUserName { un =>
lockAndDoWithUserId { uid =>
request.command match {
case ShardokCommand(
shardokGameId,
@@ -105,7 +106,7 @@ class EagleServiceImpl(
) =>
if gamesManager.isEagleGame(request.gameId) then
gamesManager.postShardokCommand(
name = un,
userId = uid,
eagleGameId = request.gameId,
shardokGameId = shardokGameId,
shardokPlayerId = playerId,
@@ -115,7 +116,7 @@ class EagleServiceImpl(
)
else
customBattleManager.postShardokCommand(
name = un,
name = uid.value,
eagleGameId = request.gameId,
shardokGameId = shardokGameId,
shardokPlayerId = playerId,
@@ -130,7 +131,7 @@ class EagleServiceImpl(
_ /* unknownFieldSet */
) =>
gamesManager.postCommand(
name = un,
userId = uid,
gameId = request.gameId,
selectedProvinceId = provinceId,
command = eagleCommand,
@@ -145,7 +146,7 @@ class EagleServiceImpl(
) =>
if gamesManager.isEagleGame(request.gameId) then
gamesManager.postPlacementCommands(
name = un,
userId = uid,
eagleGameId = request.gameId,
shardokGameId = gameId,
shardokPlayerId = playerId,
@@ -154,7 +155,7 @@ class EagleServiceImpl(
)
else
customBattleManager.postPlacementCommands(
name = un,
name = uid.value,
eagleGameId = request.gameId,
shardokGameId = gameId,
shardokPlayerId = playerId,
@@ -186,9 +187,9 @@ class EagleServiceImpl(
case UpdateStreamRequest.RequestDetails.HeartbeatRequest(value) =>
handleHeartbeat(value, responseObserver)
case UpdateStreamRequest.RequestDetails.EnterLobbyRequest(_) =>
lockAndDoWithUserName { userName =>
lobbyUsers = lobbyUsers + (userName -> responseObserver)
lockedSendLobbyUpdate(userName)
lockAndDoWithUserId { userId =>
lobbyUsers = lobbyUsers + (userId -> responseObserver)
lockedSendLobbyUpdate(userId)
}
()
@@ -347,8 +348,8 @@ class EagleServiceImpl(
}
override def onCompleted(): Unit = {
lockAndDoWithUserName { userName =>
lobbyUsers = lobbyUsers - userName
lockAndDoWithUserId { userId =>
lobbyUsers = lobbyUsers - userId
}
responseObserver.onCompleted()
()
@@ -358,14 +359,15 @@ class EagleServiceImpl(
def streamUpdates(
responseObserver: StreamObserver[UpdateStreamResponse]
): StreamObserver[UpdateStreamRequest] = new SyncStreamObserver(
new SyncResponseObserver(AuthorizationUtils.userName, responseObserver)
// Note: userName is kept for logging purposes (displayName is more readable in logs)
new SyncResponseObserver(AuthorizationUtils.displayName, responseObserver)
)
private def handleHeartbeat(
request: HeartbeatRequest,
responseObserver: SyncResponseObserver
): Unit = {
lockAndDoWithUserName { userName =>
lockAndDoWithUserId { userId =>
// Synchronize on gamesManager to ensure heartbeat response is sent AFTER
// any pending game updates have been queued. Without this, there's a race
// condition where the heartbeat response can interleave with game updates,
@@ -373,14 +375,14 @@ class EagleServiceImpl(
// This particularly affects slower/remote connections (e.g., Windows clients).
gamesManager.synchronized {
println(
s"got heartbeat from $userName. Client timestamp ${request.clientTimestamp}, server timestamp ${System.currentTimeMillis()}"
s"got heartbeat from $userId. Client timestamp ${request.clientTimestamp}, server timestamp ${System.currentTimeMillis()}"
)
// Verify sync status for each game the client reports
val gameSyncResults = request.gameSyncStatuses.flatMap { clientGameStatus =>
gamesManager.gameControllerInfos.get(clientGameStatus.gameId).flatMap { controllerInfo =>
val controller = controllerInfo.controller
controller.userNameToFactionId.get(userName).map { factionId =>
controller.userIdToFactionId.get(userId).map { factionId =>
val serverUnfilteredCount = controller.engine.history.count
// Check Shardok sync status
@@ -432,7 +434,7 @@ class EagleServiceImpl(
if shardokDetails.nonEmpty then ", " + shardokDetails.mkString(", ") else ""
}"
}
println(s"[HEARTBEAT] Detected sync mismatches for $userName: ${mismatchDetails.mkString("; ")}")
println(s"[HEARTBEAT] Detected sync mismatches for $userId: ${mismatchDetails.mkString("; ")}")
}
val _ = responseObserver.onNext(
@@ -454,11 +456,11 @@ class EagleServiceImpl(
request: StreamGameRequest,
responseObserver: SyncResponseObserver
): Unit = {
lockAndDoWithUserName { userName =>
lockAndDoWithUserId { userId =>
try {
if gamesManager.isEagleGame(request.gameId) then
gamesManager.streamUpdates(
name = userName,
userId = userId,
gameId = request.gameId,
knownResultCount = request.unfilteredResultCount,
knownShardokResultCounts = request.shardokViewStatuses.toVector,
@@ -467,7 +469,7 @@ class EagleServiceImpl(
)
else
customBattleManager.streamUpdates(
name = userName,
name = userId.value,
knownShardokResultCounts = request.shardokViewStatuses.toVector,
responseObserver = responseObserver
)
@@ -537,17 +539,17 @@ class EagleServiceImpl(
()
}
private def lockedSendLobbyUpdate(userName: String): Unit =
private def lockedSendLobbyUpdate(userId: UserId): Unit =
try
lobbyUsers
.get(userName)
.get(userId)
.foreach(
_.onNext(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.LobbyResponse(
LobbyResponse(
runningGames = gamesManager
.gamesFor(userName)
.gamesFor(userId)
.map(gamePlayerInfos =>
GameInfo(
gameId = gamePlayerInfos.gameId,
@@ -556,8 +558,8 @@ class EagleServiceImpl(
lastPlayedTimestampMillis = gamePlayerInfos.lastPlayedMillis.getOrElse(0L)
)
),
availableGames = gamesManager.availableGamesFor(userName),
waitingGames = gamesManager.waitingGamesFor(userName),
availableGames = gamesManager.availableGamesFor(userId),
waitingGames = gamesManager.waitingGamesFor(userId),
newGameOptions = Some(
NewGameOptions(
maxSupportedPlayers = MaxSupportedPlayers.intValue,
@@ -21,18 +21,21 @@ import net.eagle0.common.SimpleTimedLogger
import net.eagle0.eagle.*
import net.eagle0.eagle.admin.game_admin.*
import net.eagle0.eagle.admin.game_admin.GameAdminGrpc.GameAdmin
import net.eagle0.eagle.auth.UserService
import net.eagle0.eagle.library.actions.applier.ActionResultWithResultingState
import net.eagle0.eagle.library.settings.loaders.SettingsLoader
import net.eagle0.eagle.library.EagleClientException
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.ActionResultProtoConverter
import net.eagle0.eagle.service.persistence.SaveDirectory
import net.eagle0.eagle.UserId
import org.json4s.*
import org.json4s.native.Serialization.writePretty
import scalapb.json4s.JsonFormat
class GameAdminServiceImpl(
gamesManager: GamesManager
gamesManager: GamesManager,
userService: UserService
) extends GameAdmin {
override def getRunningGames(
@@ -48,16 +51,18 @@ class GameAdminServiceImpl(
val players = gameState.factions.map {
case (factionId, faction) =>
val leader = gameState.heroes.get(faction.factionHeadId)
val userName = controller.userNameToFactionId.find { case (_, fid) => fid == factionId }
val leader = gameState.heroes.get(faction.factionHeadId)
// Find userId for this faction and derive displayName
val userIdOpt = controller.userIdToFactionId.find { case (_, fid) => fid == factionId }
.map(_._1)
val displayName = userIdOpt.flatMap(uid => userService.findByUserId(uid).map(_.displayName))
RunningGamePlayerInfo(
factionId = factionId,
factionName = Option(faction.name).filter(_.nonEmpty).getOrElse("[MISSING FACTION NAME]"),
leaderName = leader.map(_.nameTextId).flatMap(Option(_)).filter(_.nonEmpty).getOrElse("[MISSING LEADER]"),
isHuman = userName.isDefined,
userName = userName.getOrElse("")
isHuman = userIdOpt.isDefined,
userName = displayName.getOrElse("")
)
}.toSeq
@@ -69,14 +74,19 @@ class GameAdminServiceImpl(
runStatus = gameState.runStatus.toString
)
} else {
val players = summary.userToPid.map {
case (userName, factionId) =>
// For unloaded games, userIdToPid is always in userId format (migrated at startup)
val players = summary.userIdToPid.map {
case (userId, factionId) =>
val displayName = userService
.findByUserId(userId)
.map(_.displayName)
.getOrElse("[Unknown user]")
RunningGamePlayerInfo(
factionId = factionId,
factionName = "[Not loaded]",
leaderName = "[Not loaded]",
isHuman = true,
userName = userName
userName = displayName
)
}.toSeq
@@ -389,11 +399,22 @@ class GameAdminServiceImpl(
override def convertAiToHuman(
request: ConvertAiToHumanRequest
): Future[ConvertAiToHumanResponse] = Future.successful {
gamesManager.convertAiToHuman(
gameId = request.gameId,
factionId = request.factionId,
newUserName = request.newUsername
)
// Accept either userId or displayName as input
// If it looks like a UUID, use it directly; otherwise look up by displayName
val resolvedUserId = resolveToUserId(request.newUsername)
resolvedUserId match {
case Some(userId) =>
gamesManager.convertAiToHuman(
gameId = request.gameId,
factionId = request.factionId,
newUserId = userId
)
case None =>
ConvertAiToHumanResponse(
result = ConvertAiToHumanResponse.Result.INVALID_USERNAME,
errorMessage = s"User '${request.newUsername}' not found"
)
}
}
override def convertHumanToAi(
@@ -408,13 +429,39 @@ class GameAdminServiceImpl(
override def reassignFaction(
request: ReassignFactionRequest
): Future[ReassignFactionResponse] = Future.successful {
gamesManager.reassignFaction(
gameId = request.gameId,
factionId = request.factionId,
newUserName = request.newUsername
)
// Accept either userId or displayName as input
val resolvedUserId = resolveToUserId(request.newUsername)
resolvedUserId match {
case Some(userId) =>
gamesManager.reassignFaction(
gameId = request.gameId,
factionId = request.factionId,
newUserId = userId
)
case None =>
ReassignFactionResponse(
result = ReassignFactionResponse.Result.INVALID_USERNAME,
errorMessage = s"User '${request.newUsername}' not found"
)
}
}
/**
* Resolves admin input to a UserId. Accepts either:
* - A userId (UUID format) directly
* - A displayName (looks up the userId via UserService)
*
* This is for convenience in the admin UI - actual storage is always by userId.
*/
private def resolveToUserId(input: String): Option[UserId] =
// Try as userId first (check if user exists with this ID)
userService.findByUserId(UserId(input)) match {
case Some(user) => Some(UserId(user.userId))
case None =>
// Not a valid userId - try as displayName
userService.findByDisplayName(input).map(u => UserId(u.userId))
}
override def rewindGame(
request: RewindGameRequest
): Future[RewindGameResponse] = Future.successful {
@@ -543,6 +590,6 @@ class GameAdminServiceImpl(
}
object GameAdminServiceImpl {
def apply(gamesManager: GamesManager): GameAdminServiceImpl =
new GameAdminServiceImpl(gamesManager)
def apply(gamesManager: GamesManager, userService: UserService): GameAdminServiceImpl =
new GameAdminServiceImpl(gamesManager, userService)
}
@@ -5,13 +5,14 @@ import scala.util.{Failure, Random, Success, Try}
import io.sentry.Sentry
import net.eagle0.common.{ProtoParser, SeededRandom, SimpleTimedLogger}
import net.eagle0.common.shardok_internal_interface.ShardokInternalInterfaceGrpc.ShardokInternalInterfaceStub
import net.eagle0.eagle.{FactionId, GameId, ProvinceId, ShardokGameId}
import net.eagle0.eagle.{FactionId, GameId, ProvinceId, ShardokGameId, UserId}
import net.eagle0.eagle.admin.game_admin.{ConvertAiToHumanResponse, ConvertHumanToAiResponse, ReassignFactionResponse}
import net.eagle0.eagle.api.eagle.*
import net.eagle0.eagle.api.eagle.JoinGameResult.INVALID_OPTIONS_RESULT
import net.eagle0.eagle.api.eagle.ShardokPlacementCommands.ShardokPlacementCommand
import net.eagle0.eagle.api.eagle.UpdateStreamRequest.StreamGameRequest.{ShardokViewStatus, StreamingTextStatus}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.auth.UserService
import net.eagle0.eagle.client_text.{
ClientTextStore,
ClientTextStoreImpl,
@@ -23,6 +24,7 @@ import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.running_games.{RunningGame, RunningGames}
import net.eagle0.eagle.internal.shardok_battle as sb_proto
import net.eagle0.eagle.internal.user.user.User
import net.eagle0.eagle.library.*
import net.eagle0.eagle.library.settings.loaders.{BattalionTypeLoader, SettingsLoader}
import net.eagle0.eagle.library.settings.MaxSupportedPlayers
@@ -61,7 +63,7 @@ case class GamePlayerInfo(
)
case class WaitingGamePlayerInfo(
user: String,
userId: UserId,
leaderNameTextId: String
)
@@ -96,7 +98,7 @@ case class ControllerInfo(
var controller: GameController,
isAiGame: Boolean,
maxPlayers: Int,
var lastPlayedByUser: Map[String, Long] = Map.empty
var lastPlayedByUserId: Map[UserId, Long] = Map.empty
)
object GamesManager {
@@ -165,7 +167,8 @@ object GamesManager {
persister: Persister,
gameCreation: NewGameCreation,
gamePersisterCreation: GamePersisterCreation,
hexMaps: Map[String, HexMap]
hexMaps: Map[String, HexMap],
userService: UserService
): GamesManager =
// Lazy loading: Don't load any games at startup.
// Games are loaded on-demand when a user subscribes via streamUpdates().
@@ -184,7 +187,8 @@ object GamesManager {
gameCreation = gameCreation,
gamePersisterCreation = gamePersisterCreation,
pregeneratedClientText = pregeneratedTexts,
hexMaps = hexMaps
hexMaps = hexMaps,
userService = userService
)
private def gameHistoryFromFiles(
@@ -206,7 +210,7 @@ object GamesManager {
gameId: GameId,
gamePersister: Persister,
history: FullGameHistory,
userToFid: Map[String, FactionId],
userToFid: Map[UserId, FactionId],
pregeneratedHeroes: Vector[HeroWithName]
): GameController = {
val loadedTextStore = ClientTextStoreImpl
@@ -260,7 +264,7 @@ object GamesManager {
GameController.advancedFrom(
engine = preEngine,
fullHistory = history,
userNameToFactionId = userToFid,
userIdToFactionId = userToFid,
aiFids = aiFids.toVector,
clientTextStore = clientTextStore
) match {
@@ -283,7 +287,7 @@ object GamesManager {
gameId = runningGame.gameId,
gamePersister = persister,
history = gh,
userToFid = runningGame.userToPid,
userToFid = runningGame.userIdToPid.map { case (id, fid) => UserId(id) -> fid }.toMap,
pregeneratedHeroes = GameParametersUtils.allPregeneratedHeroes
)
)
@@ -306,7 +310,8 @@ class GamesManager(
gameCreation: NewGameCreation,
gamePersisterCreation: GamePersisterCreation,
pregeneratedClientText: PregeneratedClientTextStore,
val hexMaps: Map[String, HexMap]
val hexMaps: Map[String, HexMap],
val userService: UserService
) extends BattleUpdateReceiver
with LlmUpdateReceiver {
@@ -334,7 +339,7 @@ class GamesManager(
def getHexMap(mapName: String): HexMap = hexMaps(mapName)
def begin(): Unit = this.synchronized {
// Just connect to Shardok - games are loaded lazily on first subscribe
// Connect to Shardok
shardokClient.connect()
}
@@ -543,8 +548,22 @@ class GamesManager(
case Some(runningGame) =>
val gamePersister = gamePersisterCreation.persisterForGame(gameId)
// Use userId-based mapping (migration happens at startup)
val userIdToFid: Map[UserId, FactionId] =
runningGame.userIdToPid.map { case (id, fid) => UserId(id) -> fid }.toMap
val lastPlayedByUserId: Map[UserId, Long] =
runningGame.lastPlayedByUserId.map { case (id, ts) => UserId(id) -> ts }.toMap
val controllerOpt = SimpleTimedLogger.printLogger.withStopwatch(s"loading game history ${gameId.toHexString}") {
GamesManager.gameController(runningGame, gamePersister)
GamesManager.gameHistoryFromFiles(gameId, gamePersister).map { history =>
GamesManager.gameController(
gameId = gameId,
gamePersister = gamePersister,
history = history,
userToFid = userIdToFid,
pregeneratedHeroes = GameParametersUtils.allPregeneratedHeroes
)
}
}
controllerOpt match {
@@ -559,7 +578,7 @@ class GamesManager(
controller = controller,
isAiGame = false,
maxPlayers = 2,
lastPlayedByUser = runningGame.lastPlayedByUser.toMap
lastPlayedByUserId = lastPlayedByUserId
)
// Initialize the game (handle incomplete/unrequested texts, resume battles)
@@ -583,7 +602,7 @@ class GamesManager(
controller = initializedController,
isAiGame = false,
maxPlayers = 2,
lastPlayedByUser = runningGame.lastPlayedByUser.toMap
lastPlayedByUserId = lastPlayedByUserId
)
// Resume any outstanding battles
@@ -725,14 +744,14 @@ class GamesManager(
}
}
def gamesFor(name: String): Vector[GamePlayerInfo] = this.synchronized {
def gamesFor(userId: UserId): Vector[GamePlayerInfo] = this.synchronized {
// Read games.e0es fresh from storage to find games for this user.
// This handles the case where a game was created just before deployment
// and the client lost connection before getting the game ID.
val runningGames = readRunningGamesFromStorage()
val userGameIds = runningGames
.filter(_.userToPid.contains(name))
.map(_.gameId)
val userGameIds = runningGames.filter { rg =>
rg.userIdToPid.contains(userId.value)
}.map(_.gameId)
// Load any games that aren't already in memory
userGameIds.foreach(ensureGameLoaded)
@@ -740,8 +759,8 @@ class GamesManager(
// Now return info for all loaded games
gameControllerInfos.flatMap {
case (gameId, c) =>
c.controller.userNameToFactionId
.get(name)
c.controller.userIdToFactionId
.get(userId)
.map { fid =>
val resolveName: String => Option[String] = textId =>
c.controller.clientTextStore.getText(textId) match {
@@ -759,17 +778,17 @@ class GamesManager(
),
resolveName
),
lastPlayedMillis = c.lastPlayedByUser.get(name)
lastPlayedMillis = c.lastPlayedByUserId.get(userId)
)
}
}.toVector
}
private def availableOrNewWaitingGames(
userName: String
userId: UserId
): Vector[GameAwaitingPlayers] =
gamesAwaitingPlayers
.filterNot(gap => gap.existingHumanPlayers.map(_.user).contains(userName))
.filterNot(gap => gap.existingHumanPlayers.map(_.userId).contains(userId))
private def availableLeader(hero: HeroT, resolveName: String => Option[String]): AvailableLeader =
AvailableLeader(
@@ -793,9 +812,9 @@ class GamesManager(
expandedGameParameters.greatPeopleHeroProtos
.map(availableLeaderFromProto)
def availableGamesFor(userName: String): Vector[AvailableNewGame] =
def availableGamesFor(userId: UserId): Vector[AvailableNewGame] =
this.synchronized {
availableOrNewWaitingGames(userName).map { gap =>
availableOrNewWaitingGames(userId).map { gap =>
AvailableNewGame(
gameId = gap.gameId,
gameType = EagleGameType.GAME_TYPE_MULTI_PLAYER,
@@ -812,15 +831,15 @@ class GamesManager(
}
}
def waitingGamesFor(userName: String): Vector[WaitingGame] =
def waitingGamesFor(userId: UserId): Vector[WaitingGame] =
this.synchronized {
gamesAwaitingPlayers
.filter(gap => gap.existingHumanPlayers.map(_.user).contains(userName))
.filter(gap => gap.existingHumanPlayers.map(_.userId).contains(userId))
.map { gap =>
WaitingGame(
gameId = gap.gameId,
selectedLeader = gap.existingHumanPlayers
.find(_.user == userName)
.find(_.userId == userId)
.map(_.leaderNameTextId)
.map(leaderNameTextId =>
GameParametersUtils.allPregeneratedHeroProtos
@@ -849,11 +868,16 @@ class GamesManager(
// Add in-memory games (these have the freshest state)
val loadedGames = gameControllerInfos.map {
case (gameId, gc) =>
// Convert UserId to String for proto serialization
val userIdToFid = gc.controller.userIdToFactionId.map { case (uid, fid) => uid.value -> fid }
val lastPlayedByUserId = gc.lastPlayedByUserId.map { case (uid, ts) => uid.value -> ts }
RunningGame(
gameId = gameId,
userToPid = gc.controller.userNameToFactionId,
ongoing = true,
lastPlayedByUser = gc.lastPlayedByUser
// Use new userId-based format only
userIdToPid = userIdToFid,
lastPlayedByUserId = lastPlayedByUserId
)
}.toVector
@@ -888,20 +912,22 @@ class GamesManager(
s"resolveBattle called for ${battleProto.shardokGameId}"
)
this.synchronized {
// Derive factionId -> displayName from userIdToFactionId for Shardok UI
val playerToUserMap = gameControllerInfos(battle.eagleGameId).controller.userIdToFactionId.flatMap {
case (userId, fid) =>
userService.findByUserId(userId).map(u => fid -> u.displayName)
}
shardokClient
.resolveBattle(
battle = battleProto,
eagleGameState = GameStateConverter.toProto(gameState),
playerToUserMap = gameControllerInfos(
battle.eagleGameId
).controller.userNameToFactionId
.map(_.swap)
playerToUserMap = playerToUserMap
)
}
}
def postCommand(
name: String,
userId: UserId,
gameId: GameId,
selectedProvinceId: ProvinceId,
command: SelectedCommand,
@@ -916,7 +942,7 @@ class GamesManager(
val _ = synchronizedHandlePostResults(
gameControllerInfos(gameId).controller
.postHumanCommand(
userName = name,
userId = userId,
selectedProvinceId = selectedProvinceId,
command = command,
token = token
@@ -924,12 +950,12 @@ class GamesManager(
)
// Record last played time for this user
val ci = gameControllerInfos(gameId)
ci.lastPlayedByUser = ci.lastPlayedByUser.updated(name, System.currentTimeMillis())
ci.lastPlayedByUserId = ci.lastPlayedByUserId.updated(userId, System.currentTimeMillis())
save()
}
def postShardokCommand(
name: String,
userId: UserId,
eagleGameId: GameId,
shardokGameId: String,
shardokPlayerId: Int,
@@ -942,7 +968,7 @@ class GamesManager(
ensureGameLoaded(eagleGameId),
s"No game found with ID ${eagleGameId.toHexString}"
)
// FIXME: need to verify username match
// FIXME: need to verify userId match
SimpleTimedLogger
.fileLogger(eagleGameId, "timings")
.withStopwatch("postShardokCommand") {
@@ -954,17 +980,17 @@ class GamesManager(
index = index,
roll = roll,
eagleFactionId = gameControllerInfos(eagleGameId).controller
.userNameToFactionId(name)
.userIdToFactionId(userId)
)
}
// Record last played time for this user
val ci = gameControllerInfos(eagleGameId)
ci.lastPlayedByUser = ci.lastPlayedByUser.updated(name, System.currentTimeMillis())
ci.lastPlayedByUserId = ci.lastPlayedByUserId.updated(userId, System.currentTimeMillis())
save()
}
def postPlacementCommands(
name: String,
userId: UserId,
eagleGameId: GameId,
shardokGameId: String,
shardokPlayerId: Int,
@@ -976,7 +1002,7 @@ class GamesManager(
ensureGameLoaded(eagleGameId),
s"No game found with ID ${eagleGameId.toHexString}"
)
// FIXME: need to verify username match
// FIXME: need to verify userId match
shardokClient.postPlacementCommands(
eagleGameId = eagleGameId,
shardokGameId = shardokGameId,
@@ -985,7 +1011,7 @@ class GamesManager(
PlacementCommand(unitId = pc.unitId, row = pc.row, column = pc.column)
},
token = shardokToken,
eagleFactionId = gameControllerInfos(eagleGameId).controller.userNameToFactionId(name)
eagleFactionId = gameControllerInfos(eagleGameId).controller.userIdToFactionId(userId)
)
}
@@ -1035,7 +1061,7 @@ class GamesManager(
}
def streamUpdates(
name: String,
userId: UserId,
gameId: GameId,
knownResultCount: Int,
knownShardokResultCounts: Vector[ShardokViewStatus],
@@ -1054,13 +1080,13 @@ class GamesManager(
val loadEnd = System.currentTimeMillis()
if loadEnd - loadStart > 500 then
SimpleTimedLogger.printLogger.logLine(
s"[TIMING] ensureGameLoaded for $name took ${loadEnd - loadStart}ms"
s"[TIMING] ensureGameLoaded for $userId took ${loadEnd - loadStart}ms"
)
val filterStart = System.currentTimeMillis()
gameControllerInfos(gameId).controller = gameControllerInfos(gameId).controller
.streamUpdates(
userName = name,
userId = userId,
knownResultCount = knownResultCount,
knownShardokResultCounts = knownShardokResultCounts,
responseObserver = responseObserver,
@@ -1069,27 +1095,27 @@ class GamesManager(
val filterEnd = System.currentTimeMillis()
if filterEnd - filterStart > 500 then
SimpleTimedLogger.printLogger.logLine(
s"[TIMING] streamUpdates filtering for $name took ${filterEnd - filterStart}ms"
s"[TIMING] streamUpdates filtering for $userId took ${filterEnd - filterStart}ms"
)
val totalTime = System.currentTimeMillis() - streamStart
if totalTime > 500 then
SimpleTimedLogger.printLogger.logLine(
s"[TIMING] Total streamUpdates for $name took ${totalTime}ms (load: ${loadEnd - loadStart}ms, filter: ${filterEnd - filterStart}ms)"
s"[TIMING] Total streamUpdates for $userId took ${totalTime}ms (load: ${loadEnd - loadStart}ms, filter: ${filterEnd - filterStart}ms)"
)
}
def stopStreaming(
name: String,
userId: UserId,
gameId: GameId
): Unit =
this.synchronized {
gameControllerInfos(gameId).controller = gameControllerInfos(gameId).controller
.stopStreaming(userName = name)
.stopStreaming(userId = userId)
}
def joinGame(
userName: String,
userId: UserId,
gameId: GameId,
desiredLeaderTextId: String
): JoinGameResponse = this.synchronized {
@@ -1098,7 +1124,7 @@ class GamesManager(
if gap.availableNameTextIds.contains(desiredLeaderTextId) then {
val newGap = gap.withNewWaitingPlayerInfo(
WaitingGamePlayerInfo(
user = userName,
userId = userId,
leaderNameTextId = desiredLeaderTextId
)
)
@@ -1146,7 +1172,7 @@ class GamesManager(
def createGameForUser(
expandedGameParameters: ExpandedGameParameters,
userName: String,
userId: UserId,
desiredLeaderTextId: String,
maxHumanPlayers: Int,
totalPlayers: Int
@@ -1166,7 +1192,7 @@ class GamesManager(
)
val result = joinGame(
userName = userName,
userId = userId,
gameId = newGameId,
desiredLeaderTextId = desiredLeaderTextId
) match {
@@ -1242,7 +1268,7 @@ class GamesManager(
val userToFid = humanPlayers
.map(wgpi =>
wgpi.user -> initialEar.engine.currentState.heroes.values
wgpi.userId -> initialEar.engine.currentState.heroes.values
.find(_.nameTextId == wgpi.leaderNameTextId)
.get
.factionId
@@ -1279,7 +1305,7 @@ class GamesManager(
GameController.advancedFrom(
engine = initialEar.engine,
fullHistory = GamesManager.extractFullHistory(initialEar.engine),
userNameToFactionId = userToFid,
userIdToFactionId = userToFid,
aiFids = initialEar.engine.factionIds
.filterNot(userToFid.values.toVector.contains),
clientTextStore = textStoreWithPregenerated
@@ -1318,14 +1344,14 @@ class GamesManager(
}
def dropGame(
userName: String,
userId: UserId,
gameId: GameId
): DropGameResponse = this.synchronized {
// Check waiting games first
gamesAwaitingPlayers.find(_.gameId == gameId) match {
case Some(gap) =>
if gap.existingHumanPlayers.exists(_.user == userName) then {
val remainingPlayers = gap.existingHumanPlayers.filterNot(_.user == userName)
if gap.existingHumanPlayers.exists(_.userId == userId) then {
val remainingPlayers = gap.existingHumanPlayers.filterNot(_.userId == userId)
if remainingPlayers.isEmpty then
// Remove the waiting game entirely
gamesAwaitingPlayers = gamesAwaitingPlayers.filterNot(_.gameId == gameId)
@@ -1352,7 +1378,7 @@ class GamesManager(
gameControllerInfos.get(gameId) match {
case Some(controllerInfo) =>
val controller = controllerInfo.controller
if !controller.userNameToFactionId.contains(userName) then
if !controller.userIdToFactionId.contains(userId) then
DropGameResponse(
result = DropGameResult.USER_NOT_IN_GAME_DROP_GAME_RESULT,
gameId = gameId
@@ -1374,7 +1400,7 @@ class GamesManager(
)
} else {
// Other humans remain - convert to AI
val updatedController = controller.dropUser(userName)
val updatedController = controller.dropUser(userId)
gameControllerInfos(gameId).controller = updatedController
// Trigger AI commands if it's now their turn
@@ -1410,14 +1436,14 @@ class GamesManager(
def convertAiToHuman(
gameId: GameId,
factionId: FactionId,
newUserName: String
newUserId: UserId
): ConvertAiToHumanResponse = this.synchronized {
import ConvertAiToHumanResponse.Result
if newUserName.isBlank then
if newUserId.value.isBlank then
ConvertAiToHumanResponse(
result = Result.INVALID_USERNAME,
errorMessage = "Username cannot be blank"
errorMessage = "User ID cannot be blank"
)
else
gameControllerInfos.get(gameId) match {
@@ -1436,18 +1462,18 @@ class GamesManager(
result = Result.FACTION_NOT_FOUND,
errorMessage = s"No faction found with ID $factionId"
)
else if controller.userNameToFactionId.values.toSet.contains(factionId) then
else if controller.userIdToFactionId.values.toSet.contains(factionId) then
ConvertAiToHumanResponse(
result = Result.FACTION_ALREADY_HUMAN,
errorMessage = s"Faction $factionId is already human-controlled"
)
else if controller.userNameToFactionId.contains(newUserName) then
else if controller.userIdToFactionId.contains(newUserId) then
ConvertAiToHumanResponse(
result = Result.USERNAME_ALREADY_IN_USE,
errorMessage = s"Username '$newUserName' is already controlling another faction"
errorMessage = s"User '$newUserId' is already controlling another faction"
)
else {
val updatedController = controller.promoteAiToHuman(factionId, newUserName)
val updatedController = controller.promoteAiToHuman(factionId, newUserId)
gameControllerInfos(gameId).controller = updatedController
save()
ConvertAiToHumanResponse(result = Result.SUCCESS)
@@ -1511,14 +1537,14 @@ class GamesManager(
def reassignFaction(
gameId: GameId,
factionId: FactionId,
newUserName: String
newUserId: UserId
): ReassignFactionResponse = this.synchronized {
import ReassignFactionResponse.Result
if newUserName.isBlank then
if newUserId.value.isBlank then
ReassignFactionResponse(
result = Result.INVALID_USERNAME,
errorMessage = "Username cannot be blank"
errorMessage = "User ID cannot be blank"
)
else
gameControllerInfos.get(gameId) match {
@@ -1537,25 +1563,25 @@ class GamesManager(
result = Result.FACTION_NOT_FOUND,
errorMessage = s"No faction found with ID $factionId"
)
else if controller.userNameToFactionId.contains(newUserName) then
else if controller.userIdToFactionId.contains(newUserId) then
ReassignFactionResponse(
result = Result.USERNAME_ALREADY_IN_USE,
errorMessage = s"Username '$newUserName' is already controlling another faction"
errorMessage = s"User '$newUserId' is already controlling another faction"
)
else
controller.reassignFaction(factionId, newUserName) match {
controller.reassignFaction(factionId, newUserId) match {
case None =>
ReassignFactionResponse(
result = Result.FACTION_IS_AI,
errorMessage = s"Faction $factionId is AI-controlled, not human"
)
case Some((previousUsername, updatedController)) =>
case Some((previousUserId, updatedController)) =>
gameControllerInfos(gameId).controller = updatedController
save()
ReassignFactionResponse(
result = Result.SUCCESS,
previousUsername = previousUsername
previousUsername = previousUserId.value
)
}
end if
@@ -1652,7 +1678,7 @@ class GamesManager(
val isLoaded = gameControllerInfos.contains(rg.gameId)
RunningGameSummary(
gameId = rg.gameId,
userToPid = rg.userToPid,
userIdToPid = rg.userIdToPid.map { case (id, fid) => UserId(id) -> fid }.toMap,
isLoaded = isLoaded
)
}
@@ -1671,6 +1697,6 @@ class GamesManager(
case class RunningGameSummary(
gameId: GameId,
userToPid: Map[String, FactionId],
userIdToPid: Map[UserId, FactionId],
isLoaded: Boolean
)
@@ -22,6 +22,7 @@ import io.grpc.{
}
import net.eagle0.common.shardok_internal_interface.ShardokInternalInterfaceGrpc
import net.eagle0.common.shardok_internal_interface.ShardokInternalInterfaceGrpc.ShardokInternalInterfaceStub
import net.eagle0.eagle.auth.UserService
import net.eagle0.eagle.service.new_game_creation.FixedNewGameCreation
import net.eagle0.eagle.service.persistence.{CompoundPersister, LocalFilePersister, S3Persister, S3Utils, SaveDirectory}
import net.eagle0.eagle.service.persistence.credentials.S3Credentials
@@ -56,6 +57,7 @@ object ServerSetupHelpers {
def newGamesManager(
shardokInterfaceAddress: String,
userService: UserService,
securityConfig: ShardokSecurityConfig = ShardokSecurityConfig()
): GamesManager = {
implicit val ec = scala.concurrent.ExecutionContext.global
@@ -102,7 +104,8 @@ object ServerSetupHelpers {
persister = CompoundPersister(persisters),
gameCreation = FixedNewGameCreation,
gamePersisterCreation = LocalGamePersisterCreation,
hexMaps = hexMaps
hexMaps = hexMaps,
userService = userService
)
}
@@ -28,6 +28,7 @@ scala_library(
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle:user_id",
"//src/main/scala/net/eagle0/eagle/ai:ai_client",
"//src/main/scala/net/eagle0/eagle/client_text",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
@@ -8,7 +8,7 @@ import scala.collection.immutable.SortedMap
import io.grpc.{Status, StatusRuntimeException}
import net.eagle0.common.{RandomState, SeededRandom, SimpleTimedLogger}
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.{FactionId, GameId, ProvinceId, UserId}
import net.eagle0.eagle.ai.AIClient
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.eagle.ServerGameStatus
@@ -64,7 +64,7 @@ object GameController {
*/
private def allHumansDefeated(gcwpr: GameControllerWithPostResults): Boolean = {
val gc = gcwpr.gameController
val humanFactionIds = gc.userNameToFactionId.values.toSet
val humanFactionIds = gc.userIdToFactionId.values.toSet
val gs = gc.engine.currentState
// If there are no human players, they're trivially "not defeated" (nothing to defeat)
@@ -301,7 +301,7 @@ object GameController {
def advancedFrom(
engine: Engine,
fullHistory: FullGameHistory,
userNameToFactionId: Map[String, FactionId],
userIdToFactionId: Map[UserId, FactionId],
aiFids: Vector[FactionId],
clientTextStore: ClientTextStore
): GameControllerWithPostResults =
@@ -310,7 +310,7 @@ object GameController {
GameController(
engine = engine,
fullHistory = fullHistory,
userNameToFactionId = userNameToFactionId,
userIdToFactionId = userIdToFactionId,
humanClients = Vector(),
aiClients = Vector(),
clientTextStore = clientTextStore
@@ -324,10 +324,12 @@ object GameController {
// Controls a single Eagle game. Clients interact with the Engine through a GameClient interface, but this manages
// new clients joining or creating AI clients as necessary.
// Note: userIdToFactionId maps user IDs (UUIDs from the auth system) to faction IDs.
// For display names, use UserService to look up the displayName from the userId.
final case class GameController(
engine: Engine,
fullHistory: FullGameHistory,
userNameToFactionId: Map[String, FactionId],
userIdToFactionId: Map[UserId, FactionId],
humanClients: Vector[HumanPlayerClientConnectionState],
aiClients: Vector[RandomState[AIClient]],
clientTextStore: ClientTextStore
@@ -531,7 +533,7 @@ final case class GameController(
GameController(
engine = this.engine,
fullHistory = this.fullHistory,
userNameToFactionId = this.userNameToFactionId,
userIdToFactionId = this.userIdToFactionId,
humanClients = this.humanClients
.filterNot(_.factionId == client.factionId) :+ client,
aiClients = this.aiClients,
@@ -542,7 +544,7 @@ final case class GameController(
GameController(
engine = this.engine,
fullHistory = this.fullHistory,
userNameToFactionId = this.userNameToFactionId,
userIdToFactionId = this.userIdToFactionId,
humanClients = this.humanClients,
aiClients = this.aiClients :+ RandomState(
client,
@@ -621,7 +623,7 @@ final case class GameController(
gameController = GameController(
engine = engineAndResults.engine,
fullHistory = newFullHistory,
userNameToFactionId = userNameToFactionId.filter {
userIdToFactionId = userIdToFactionId.filter {
case (_, fid) =>
engineAndResults.engine.factionIds.contains(fid)
},
@@ -657,14 +659,14 @@ final case class GameController(
}
def streamUpdates(
userName: String,
userId: UserId,
knownResultCount: Int,
knownShardokResultCounts: Vector[ShardokViewStatus],
streamingTextStatuses: Vector[StreamingTextStatus],
responseObserver: SyncResponseObserver
): GameController =
userNameToFactionId
.get(userName)
userIdToFactionId
.get(userId)
.map { fid =>
this.withHumanClient(
clientTextStore.completeTexts.values
@@ -708,25 +710,25 @@ final case class GameController(
}
.get
def stopStreaming(userName: String): GameController =
def stopStreaming(userId: UserId): GameController =
GameController(
engine = this.engine,
fullHistory = this.fullHistory,
userNameToFactionId = this.userNameToFactionId,
humanClients = this.humanClients.filterNot(hc => userNameToFactionId.get(userName).contains(hc.factionId)),
userIdToFactionId = this.userIdToFactionId,
humanClients = this.humanClients.filterNot(hc => userIdToFactionId.get(userId).contains(hc.factionId)),
aiClients = this.aiClients,
clientTextStore = this.clientTextStore
)
def dropUser(userName: String): GameController =
userNameToFactionId.get(userName) match {
def dropUser(userId: UserId): GameController =
userIdToFactionId.get(userId) match {
case Some(factionId) =>
val newAiClient = RandomState(
AIClient(gameId = gameId, factionId = factionId),
SeededRandom(GameController.aiSeed(engine.gameId, factionId))
)
copy(
userNameToFactionId = userNameToFactionId - userName,
userIdToFactionId = userIdToFactionId - userId,
humanClients = humanClients.filterNot(_.factionId == factionId),
aiClients = aiClients :+ newAiClient
)
@@ -734,36 +736,36 @@ final case class GameController(
}
/** Convert an AI-controlled faction to human control */
def promoteAiToHuman(factionId: FactionId, userName: String): GameController =
def promoteAiToHuman(factionId: FactionId, userId: UserId): GameController =
copy(
userNameToFactionId = userNameToFactionId + (userName -> factionId),
userIdToFactionId = userIdToFactionId + (userId -> factionId),
aiClients = aiClients.filterNot(_.newValue.factionId == factionId)
)
/** Convert a human-controlled faction to AI control. Returns None if faction is not human-controlled. */
def demoteHumanToAi(factionId: FactionId): Option[GameController] =
userNameToFactionId.find(_._2 == factionId).map {
case (userName, _) =>
dropUser(userName)
userIdToFactionId.find(_._2 == factionId).map {
case (userId, _) =>
dropUser(userId)
}
/**
* Reassign a human-controlled faction to a new username. Returns previous username and new controller, or None if
* faction is not human-controlled.
* Reassign a human-controlled faction to a new userId. Returns previous userId and new controller, or None if faction
* is not human-controlled.
*/
def reassignFaction(factionId: FactionId, newUserName: String): Option[(String, GameController)] =
userNameToFactionId.find(_._2 == factionId).map {
case (oldUserName, _) =>
def reassignFaction(factionId: FactionId, newUserId: UserId): Option[(UserId, GameController)] =
userIdToFactionId.find(_._2 == factionId).map {
case (oldUserId, _) =>
(
oldUserName,
oldUserId,
copy(
userNameToFactionId = userNameToFactionId - oldUserName + (newUserName -> factionId),
userIdToFactionId = userIdToFactionId - oldUserId + (newUserId -> factionId),
humanClients = humanClients.filterNot(_.factionId == factionId)
)
)
}
def remainingHumanCount: Int = userNameToFactionId.size
def remainingHumanCount: Int = userIdToFactionId.size
/**
* Rewinds the game to a previous state. This sends a full state resync to all connected human clients (so they don't
@@ -798,7 +800,7 @@ final case class GameController(
val newController = GameController(
engine = rewoundEngine,
fullHistory = extractFullHistory(rewoundEngine),
userNameToFactionId = this.userNameToFactionId,
userIdToFactionId = this.userIdToFactionId,
humanClients = resyncedClients,
aiClients = this.aiClients.map { rsClient =>
// Reset AI clients to use the rewound state
@@ -814,26 +816,26 @@ final case class GameController(
}
def postHumanCommand(
userName: String,
userId: UserId,
selectedProvinceId: ProvinceId,
command: SelectedCommand,
token: Long
): GameControllerWithPostResults = {
val client = humanClients.find(hpccs =>
userNameToFactionId
.get(userName)
userIdToFactionId
.get(userId)
.contains(hpccs.factionId)
)
clientRequire(
client.isDefined,
s"No client for $userName"
s"No client for $userId"
)
val expectedToken = engine.tokenForFaction(client.get.factionId)
clientRequire(
expectedToken == token,
s"Token mismatch for $userName (expected $expectedToken, got $token)"
s"Token mismatch for $userId (expected $expectedToken, got $token)"
)
GameController.performAiCommands(
@@ -865,7 +867,7 @@ final case class GameController(
GameController(
engine = this.engine,
fullHistory = this.fullHistory,
userNameToFactionId = this.userNameToFactionId,
userIdToFactionId = this.userIdToFactionId,
humanClients = this.humanClients,
aiClients = newClients,
clientTextStore = this.clientTextStore
@@ -58,7 +58,10 @@ scala_test(
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_response_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle:user_id",
"//src/main/scala/net/eagle0/eagle/auth:user_service",
"//src/main/scala/net/eagle0/eagle/client_text:pregenerated_text_store",
"//src/main/scala/net/eagle0/eagle/library:engine",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
@@ -3,7 +3,7 @@ package net.eagle0.eagle.service
import java.io.File
import scala.collection.immutable.SortedMap
import scala.util.{Failure, Random}
import scala.util.{Failure, Random, Success}
import io.grpc.Channel
import net.eagle0.common.shardok_internal_interface.ShardokInternalInterfaceGrpc
@@ -11,8 +11,10 @@ import net.eagle0.common.ProtoMatchers
import net.eagle0.eagle.api.eagle.{CreateGameResponse, DropGameResponse, JoinGameResponse}
import net.eagle0.eagle.api.eagle.DropGameResult.*
import net.eagle0.eagle.api.eagle.JoinGameResult.*
import net.eagle0.eagle.auth.{NoOpUserService, UserService}
import net.eagle0.eagle.client_text.PregeneratedClientTextStore
import net.eagle0.eagle.internal.game_parameters.GameParameters
import net.eagle0.eagle.internal.user.user.User
import net.eagle0.eagle.library.{Engine, EngineAndResults}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.run_status.RunStatus
@@ -25,6 +27,7 @@ import net.eagle0.eagle.service.new_game_creation.{
NewGameCreation
}
import net.eagle0.eagle.service.persistence.Persister
import net.eagle0.eagle.UserId
import net.eagle0.util.hero_generation.LoadedHero
import org.scalamock.scalatest.MockFactory
import org.scalatest.flatspec.AnyFlatSpec
@@ -114,7 +117,8 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
persister = mockPersister,
gameCreation = mockGameCreation,
gamePersisterCreation = mockGamePersisterCreation,
hexMaps = Map()
hexMaps = Map(),
userService = new NoOpUserService
)
// Helper: allow games.e0es reads to return empty (for save() merging)
@@ -208,7 +212,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val gamesManager = defaultGamesManager
gamesManager.joinGame(
"bob",
UserId("bob"),
gameId = gameId,
desiredLeaderTextId = greatPerson2.greatPerson.hero.nameTextId
)
@@ -216,7 +220,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
mockRandom.throwOnNextLong()
gamesManager.joinGame(
"alice",
UserId("alice"),
gameId = gameId,
desiredLeaderTextId = greatPerson1.greatPerson.hero.nameTextId
) shouldBe
@@ -229,7 +233,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
(mockPersister.save _).expects(*, *).returns(true).anyNumberOfTimes()
gamesManager.joinGame(
"bob",
UserId("bob"),
gameId = gameId,
desiredLeaderTextId = greatPerson2.greatPerson.hero.nameTextId
)
@@ -237,7 +241,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
mockRandom.throwOnNextLong()
gamesManager.joinGame(
"alice",
UserId("alice"),
gameId = gameId,
desiredLeaderTextId = greatPerson2.greatPerson.hero.nameTextId
) shouldBe
@@ -331,7 +335,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val gamesManager = defaultGamesManager
gamesManager.joinGame(
"bob",
UserId("bob"),
gameId = gameId,
desiredLeaderTextId = greatPerson1.greatPerson.hero.nameTextId
)
@@ -339,13 +343,13 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
mockRandom.throwOnNextLong()
gamesManager.joinGame(
"alice",
UserId("alice"),
gameId = gameId,
desiredLeaderTextId = greatPerson2.greatPerson.hero.nameTextId
)
gamesManager.joinGame(
"fred",
UserId("fred"),
gameId = gameId,
desiredLeaderTextId = greatPerson3.greatPerson.hero.nameTextId
) shouldBe
@@ -360,7 +364,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val gamesManager = defaultGamesManager
gamesManager.createGameForUser(
expandedGameParameters = expandedGameParameters,
userName = "charlie",
userId = UserId("charlie"),
desiredLeaderTextId = greatPerson2.greatPerson.hero.nameTextId,
maxHumanPlayers = 3,
totalPlayers = 8
@@ -373,7 +377,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
gameId = 0xfeedface,
existingHumanPlayers = Vector(
WaitingGamePlayerInfo(
user = "charlie",
userId = UserId("charlie"),
leaderNameTextId = greatPerson2.greatPerson.hero.nameTextId
)
),
@@ -397,7 +401,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val gamesManager = defaultGamesManager
gamesManager.createGameForUser(
expandedGameParameters = expandedGameParameters,
userName = "charlie",
userId = UserId("charlie"),
desiredLeaderTextId = "franklin_id",
maxHumanPlayers = 3,
totalPlayers = 8
@@ -414,7 +418,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val gamesManager = defaultGamesManager
gamesManager.createGameForUser(
expandedGameParameters = expandedGameParameters,
userName = "charlie",
userId = UserId("charlie"),
desiredLeaderTextId = greatPerson2.greatPerson.hero.nameTextId,
maxHumanPlayers = 9,
totalPlayers = 8
@@ -428,7 +432,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val gamesManager = defaultGamesManager
gamesManager.createGameForUser(
expandedGameParameters = expandedGameParameters,
userName = "charlie",
userId = UserId("charlie"),
desiredLeaderTextId = greatPerson2.greatPerson.hero.nameTextId,
maxHumanPlayers = 8,
totalPlayers = 16
@@ -444,7 +448,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
val gamesManager = defaultGamesManager
gamesManager.createGameForUser(
expandedGameParameters = expandedGameParameters,
userName = "charlie",
userId = UserId("charlie"),
desiredLeaderTextId = "Sir not-appearing-in-this-film_id",
maxHumanPlayers = 3,
totalPlayers = 8
@@ -468,7 +472,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
expandedGameParameters = expandedGameParameters,
gameId = gameId,
existingHumanPlayers = Vector(
WaitingGamePlayerInfo(user = "alice", leaderNameTextId = greatPerson1.greatPerson.hero.nameTextId)
WaitingGamePlayerInfo(userId = UserId("alice"), leaderNameTextId = greatPerson1.greatPerson.hero.nameTextId)
),
humanPlayerCount = 2,
totalPlayerCount = 4,
@@ -479,10 +483,11 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
persister = mockPersister,
gameCreation = mockGameCreation,
gamePersisterCreation = mockGamePersisterCreation,
hexMaps = Map()
hexMaps = Map(),
userService = new NoOpUserService
)
gamesManager.dropGame(userName = "alice", gameId = gameId) shouldBe
gamesManager.dropGame(userId = UserId("alice"), gameId = gameId) shouldBe
DropGameResponse(result = SUCCESS_DROP_GAME_RESULT, gameId = gameId)
gamesManager.gamesAwaitingPlayers shouldBe empty
@@ -501,8 +506,11 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
expandedGameParameters = expandedGameParameters,
gameId = gameId,
existingHumanPlayers = Vector(
WaitingGamePlayerInfo(user = "alice", leaderNameTextId = greatPerson1.greatPerson.hero.nameTextId),
WaitingGamePlayerInfo(user = "bob", leaderNameTextId = greatPerson2.greatPerson.hero.nameTextId)
WaitingGamePlayerInfo(
userId = UserId("alice"),
leaderNameTextId = greatPerson1.greatPerson.hero.nameTextId
),
WaitingGamePlayerInfo(userId = UserId("bob"), leaderNameTextId = greatPerson2.greatPerson.hero.nameTextId)
),
humanPlayerCount = 2,
totalPlayerCount = 4,
@@ -513,15 +521,16 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
persister = mockPersister,
gameCreation = mockGameCreation,
gamePersisterCreation = mockGamePersisterCreation,
hexMaps = Map()
hexMaps = Map(),
userService = new NoOpUserService
)
gamesManager.dropGame(userName = "alice", gameId = gameId) shouldBe
gamesManager.dropGame(userId = UserId("alice"), gameId = gameId) shouldBe
DropGameResponse(result = SUCCESS_DROP_GAME_RESULT, gameId = gameId)
gamesManager.gamesAwaitingPlayers should have size 1
gamesManager.gamesAwaitingPlayers.head.existingHumanPlayers shouldBe Vector(
WaitingGamePlayerInfo(user = "bob", leaderNameTextId = greatPerson2.greatPerson.hero.nameTextId)
WaitingGamePlayerInfo(userId = UserId("bob"), leaderNameTextId = greatPerson2.greatPerson.hero.nameTextId)
)
}
@@ -535,7 +544,7 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
expandedGameParameters = expandedGameParameters,
gameId = gameId,
existingHumanPlayers = Vector(
WaitingGamePlayerInfo(user = "alice", leaderNameTextId = greatPerson1.greatPerson.hero.nameTextId)
WaitingGamePlayerInfo(userId = UserId("alice"), leaderNameTextId = greatPerson1.greatPerson.hero.nameTextId)
),
humanPlayerCount = 2,
totalPlayerCount = 4,
@@ -546,10 +555,11 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
persister = mockPersister,
gameCreation = mockGameCreation,
gamePersisterCreation = mockGamePersisterCreation,
hexMaps = Map()
hexMaps = Map(),
userService = new NoOpUserService
)
gamesManager.dropGame(userName = "charlie", gameId = gameId) shouldBe
gamesManager.dropGame(userId = UserId("charlie"), gameId = gameId) shouldBe
DropGameResponse(result = USER_NOT_IN_GAME_DROP_GAME_RESULT, gameId = gameId)
// Game should still exist with alice
@@ -566,13 +576,14 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
persister = mockPersister,
gameCreation = mockGameCreation,
gamePersisterCreation = mockGamePersisterCreation,
hexMaps = Map()
hexMaps = Map(),
userService = new NoOpUserService
)
// Lazy loading will try to read games.e0es from storage - return empty to indicate no games
(mockPersister.retrieveAsStream _).expects("games.e0es").returning(Failure(new Exception("not found")))
gamesManager.dropGame(userName = "alice", gameId = 0xdeadbeef) shouldBe
gamesManager.dropGame(userId = UserId("alice"), gameId = 0xdeadbeef) shouldBe
DropGameResponse(result = GAME_NOT_FOUND_DROP_GAME_RESULT, gameId = 0xdeadbeef)
}
@@ -588,7 +599,8 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
persister = mockPersister,
gameCreation = mockGameCreation,
gamePersisterCreation = mockGamePersisterCreation,
hexMaps = Map()
hexMaps = Map(),
userService = new NoOpUserService
)
// No calls to storage should have been made yet
@@ -608,14 +620,15 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
persister = mockPersister,
gameCreation = mockGameCreation,
gamePersisterCreation = mockGamePersisterCreation,
hexMaps = Map()
hexMaps = Map(),
userService = new NoOpUserService
)
// Lazy loading will attempt to read games.e0es from storage
(mockPersister.retrieveAsStream _).expects("games.e0es").returning(Failure(new Exception("not found")))
// Should return GAME_NOT_FOUND since the game doesn't exist in storage
gamesManager.dropGame(userName = "alice", gameId = nonExistentGameId) shouldBe
gamesManager.dropGame(userId = UserId("alice"), gameId = nonExistentGameId) shouldBe
DropGameResponse(result = GAME_NOT_FOUND_DROP_GAME_RESULT, gameId = nonExistentGameId)
}
@@ -629,17 +642,18 @@ class GamesManagerTest extends AnyFlatSpec with MockFactory with Matchers with B
persister = mockPersister,
gameCreation = mockGameCreation,
gamePersisterCreation = mockGamePersisterCreation,
hexMaps = Map()
hexMaps = Map(),
userService = new NoOpUserService
)
// First call: games.e0es doesn't exist
(mockPersister.retrieveAsStream _).expects("games.e0es").returning(Failure(new Exception("not found")))
gamesManager.dropGame(userName = "alice", gameId = 0xaaa) shouldBe
gamesManager.dropGame(userId = UserId("alice"), gameId = 0xaaa) shouldBe
DropGameResponse(result = GAME_NOT_FOUND_DROP_GAME_RESULT, gameId = 0xaaa)
// Second call: games.e0es still doesn't exist (fresh read)
(mockPersister.retrieveAsStream _).expects("games.e0es").returning(Failure(new Exception("not found")))
gamesManager.dropGame(userName = "bob", gameId = 0xbbb) shouldBe
gamesManager.dropGame(userId = UserId("bob"), gameId = 0xbbb) shouldBe
DropGameResponse(result = GAME_NOT_FOUND_DROP_GAME_RESULT, gameId = 0xbbb)
// Verifies that games.e0es is read fresh each time, not cached
@@ -11,6 +11,7 @@ scala_test(
"//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle:user_id",
"//src/main/scala/net/eagle0/eagle/client_text",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
@@ -16,6 +16,7 @@ import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import net.eagle0.eagle.service.{FullGameHistory, InMemoryHistory, SyncResponseObserver}
import net.eagle0.eagle.UserId
import org.scalamock.scalatest.MockFactory
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
@@ -66,7 +67,7 @@ class GameControllerTest extends AnyFlatSpec with MockFactory with Matchers with
val controller = GameController(
engine = mockEngine,
fullHistory = mockFullHistory,
userNameToFactionId = Map("Joe" -> 5),
userIdToFactionId = Map(UserId("Joe") -> 5),
humanClients = Vector(),
aiClients = Vector(),
clientTextStore = mockClientTextStore
@@ -79,13 +80,13 @@ class GameControllerTest extends AnyFlatSpec with MockFactory with Matchers with
val controller = GameController(
engine = mockEngine,
fullHistory = mockFullHistory,
userNameToFactionId = Map("Mary" -> 5),
userIdToFactionId = Map(UserId("Mary") -> 5),
humanClients = Vector(),
aiClients = Vector(),
clientTextStore = mockClientTextStore
)
controller.userNameToFactionId.get("Joe") shouldBe None
controller.userIdToFactionId.get(UserId("Joe")) shouldBe None
}
"PostCommand" should "throw if the token does not match" in {
@@ -104,7 +105,7 @@ class GameControllerTest extends AnyFlatSpec with MockFactory with Matchers with
val controller = GameController(
engine = mockEngine,
fullHistory = mockFullHistory,
userNameToFactionId = Map("joe" -> 7),
userIdToFactionId = Map(UserId("joe") -> 7),
humanClients = Vector(client),
aiClients = Vector(),
clientTextStore = mockClientTextStore
@@ -117,7 +118,7 @@ class GameControllerTest extends AnyFlatSpec with MockFactory with Matchers with
val ex = the[EagleClientException] thrownBy
controller.postHumanCommand(
userName = "joe",
userId = UserId("joe"),
selectedProvinceId = 0,
command = ReturnSelectedCommand(),
token = 41
@@ -152,7 +153,7 @@ class GameControllerTest extends AnyFlatSpec with MockFactory with Matchers with
val controller = GameController(
engine = mockEngine,
fullHistory = mockFullHistory,
userNameToFactionId = Map("joe" -> 7),
userIdToFactionId = Map(UserId("joe") -> 7),
humanClients = Vector(client),
aiClients = Vector(),
clientTextStore = mockClientTextStore
@@ -238,7 +239,7 @@ class GameControllerTest extends AnyFlatSpec with MockFactory with Matchers with
noException should be thrownBy
controller.postHumanCommand(
userName = "joe",
userId = UserId("joe"),
selectedProvinceId = 0,
command = command,
token = 5
@@ -343,7 +344,7 @@ class GameControllerTest extends AnyFlatSpec with MockFactory with Matchers with
val controller = GameController(
engine = mockEngine,
fullHistory = mockFullHistory,
userNameToFactionId = Map("player1" -> 1, "player2" -> 2),
userIdToFactionId = Map(UserId("player1") -> 1, UserId("player2") -> 2),
humanClients = Vector(client1, client2),
aiClients = Vector(),
clientTextStore = mockClientTextStore