diff --git a/src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.cpp b/src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.cpp index bc84cec75e..61e484d08a 100644 --- a/src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.cpp +++ b/src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.cpp @@ -342,6 +342,21 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd updates.filteredResults.emplace_back(fid, actionResultViews, acs); } + // Per-watcher filtered views. A watcher is a non-participant Eagle + // faction allied to one or more participants — they get a view that + // treats their allied participants' units as fully visible (hero + // stats, hidden-from-opponents actions like meteor casts, etc.). + // Participants whose allies list named this watcher are passed in as + // alliedPids so UnitFilter / ActionResultFilter reveal those units to + // the watcher. + for (const auto &[watcherFid, allyPids] : watcherAllies) { + // Watchers can't post commands, so availableCommands is null. + updates.filteredResults.emplace_back( + watcherFid, + engine->FilterNewResultsForWatcher(-1, allyPids, startingActionId), + nullptr); + } + updates.filteredResults.emplace_back( -1, engine->FilterNewResults(-1, startingActionId), diff --git a/src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp b/src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp index e1c52ccde6..a609815d25 100644 --- a/src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp +++ b/src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp @@ -120,6 +120,11 @@ private: vector> aiClients; std::thread aiThread; + // Each entry maps an Eagle faction id of a non-participant watcher to the + // Shardok pids of participants whose allies list named this watcher. Used + // when generating per-watcher filtered views in GetUpdates. + const vector>> watcherAllies; + static auto MakeLogFilePath() -> string { const time_t timer = time(nullptr); char buf[255]; @@ -139,14 +144,16 @@ public: ShardokGameController( unique_ptr e, string mapName, - string serializedRequest = "") + string serializedRequest = "", + vector>> watcherAllies = {}) : serializedRequest(std::move(serializedRequest)), engine(std::move(e)), cachedGameId(engine->GetGameId()), mapName(std::move(mapName)), logFilePath(MakeLogFilePath()), aiClients(MakeAIClients(engine)), - aiThread(&ShardokGameController::DoAIThread, this) { + aiThread(&ShardokGameController::DoAIThread, this), + watcherAllies(std::move(watcherAllies)) { std::unique_lock lk(masterLock); aiCondition.notify_one(); } diff --git a/src/main/cpp/net/eagle0/shardok/library/ShardokEngine.cpp b/src/main/cpp/net/eagle0/shardok/library/ShardokEngine.cpp index ba0c75294f..d0259c83e7 100644 --- a/src/main/cpp/net/eagle0/shardok/library/ShardokEngine.cpp +++ b/src/main/cpp/net/eagle0/shardok/library/ShardokEngine.cpp @@ -217,6 +217,58 @@ auto ShardokEngine::GetGameStateView(const PlayerId askingPlayer) const return filteredHistory; } +[[nodiscard]] auto ShardokEngine::FilterNewResultsForWatcher( + const PlayerId askingPlayer, + const vector &alliedPids, + const int64_t previousActionCount) const + -> vector { + if (previousActionCount < startingHistoryCount) { + throw ShardokInternalErrorException("Unable to fetch history before startingHistoryCount"); + } + + vector unfilteredHistory = GetGameHistory(previousActionCount); + + vector filteredHistory{}; + + GameStateW startingState = (previousActionCount == 0) + ? GameStateW{} + : GetGameStateAtStartOfAction(previousActionCount); + GameStateView startingView = (previousActionCount == 0) ? GameStateView{} + : GameStateFilteredForPlayerWithAllies( + settingsGetter, + startingState, + askingPlayer, + alliedPids); + + GameStateW &previousState = startingState; + GameState *previousStatePtr = nullptr; + GameStateView &previousView = startingView; + + for (const ShardokActionWithResultingState &awrs : unfilteredHistory) { + GameStateView viewAfter = GameStateFilteredForPlayerWithAllies( + settingsGetter, + GameStateW::FromByteString(awrs.state_after_fb()), + askingPlayer, + alliedPids); + + if (auto filteredResult = ActionResultFilteredForPlayerWithAllies( + previousStatePtr, + previousView, + viewAfter, + settingsGetter, + awrs.action_result(), + askingPlayer, + alliedPids); + filteredResult.has_value()) { + filteredHistory.push_back(*filteredResult); + } + previousState = GameStateW::FromByteString(awrs.state_after_fb()); + previousStatePtr = previousState.Get(); + previousView = viewAfter; + } + return filteredHistory; +} + auto ShardokEngine::GetFilteredGameHistory(const PlayerId askingPlayer) const -> vector { return FilterNewResults(askingPlayer, 0); diff --git a/src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp b/src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp index 3c254112a5..8068bfc320 100644 --- a/src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp +++ b/src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp @@ -152,6 +152,16 @@ public: [[nodiscard]] auto FilterNewResults(PlayerId askingPlayer, int64_t previousActionCount) const -> vector; + // Variant for non-participant watchers: askingPlayer is a sentinel (not in + // player_infos) and alliedPids names the Shardok pids whose units the + // watcher should see as "self/ally" — fully visible, including hero stats + // and hidden actions. + [[nodiscard]] auto FilterNewResultsForWatcher( + PlayerId askingPlayer, + const vector &alliedPids, + int64_t previousActionCount) const + -> vector; + [[nodiscard]] auto GetFilteredGameHistory(PlayerId askingPlayer) const -> vector; diff --git a/src/main/cpp/net/eagle0/shardok/library/view_filters/ActionResultFilter.cpp b/src/main/cpp/net/eagle0/shardok/library/view_filters/ActionResultFilter.cpp index 206e0caae4..93cd670d6b 100644 --- a/src/main/cpp/net/eagle0/shardok/library/view_filters/ActionResultFilter.cpp +++ b/src/main/cpp/net/eagle0/shardok/library/view_filters/ActionResultFilter.cpp @@ -4,6 +4,9 @@ #include "ActionResultFilter.hpp" +#include +#include + #include "src/main/cpp/net/eagle0/shardok/library/unit/Unit.hpp" #include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.hpp" #include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateViewDiffer.hpp" @@ -60,6 +63,24 @@ auto TargetIsHiddenFromOpponents(const ActionResultType type) -> bool { const SettingsGetter &settings, const ActionResult &result, const PlayerId askingPlayer) -> std::optional { + return ActionResultFilteredForPlayerWithAllies( + beforeState, + beforeView, + afterView, + settings, + result, + askingPlayer, + std::vector{}); +} + +[[nodiscard]] auto ActionResultFilteredForPlayerWithAllies( + const GameState *beforeState, + const net::eagle0::shardok::api::GameStateView &beforeView, + const net::eagle0::shardok::api::GameStateView &afterView, + const SettingsGetter &settings, + const ActionResult &result, + const PlayerId askingPlayer, + const std::vector &alliedPids) -> std::optional { double opponentKnowledgeOfActor = 0.0; bool hiddenActor = false; bool wasHidden = false; @@ -76,13 +97,16 @@ auto TargetIsHiddenFromOpponents(const ActionResultType type) -> bool { beforeState->units()->size() > static_cast(actorId) && beforeState->units()->Get(result.actor().value())->hidden(); } + const PlayerId actorPlayer = result.has_player() ? result.player().value() : -1; + const bool actorIsSelfOrAlly = + askingPlayer == actorPlayer || std::ranges::contains(alliedPids, actorPlayer); if (IsHiddenFromOpponents( settings.Backing().minimum_knowledge_for_profession(), result.type(), opponentKnowledgeOfActor, hiddenActor, wasHidden) && - askingPlayer != result.player().value()) { + !actorIsSelfOrAlly) { return std::optional(); } @@ -107,8 +131,7 @@ auto TargetIsHiddenFromOpponents(const ActionResultType type) -> bool { } if (result.has_target_coords() && - ((result.has_player() && result.player().value() == askingPlayer) || - !TargetIsHiddenFromOpponents(result.type()))) { + (actorIsSelfOrAlly || !TargetIsHiddenFromOpponents(result.type()))) { *resultView.mutable_target_coords() = result.target_coords(); } else { resultView.clear_target_coords(); diff --git a/src/main/cpp/net/eagle0/shardok/library/view_filters/ActionResultFilter.hpp b/src/main/cpp/net/eagle0/shardok/library/view_filters/ActionResultFilter.hpp index 5b91f40845..8fb34986e7 100644 --- a/src/main/cpp/net/eagle0/shardok/library/view_filters/ActionResultFilter.hpp +++ b/src/main/cpp/net/eagle0/shardok/library/view_filters/ActionResultFilter.hpp @@ -6,6 +6,7 @@ #define EAGLE0_ACTIONRESULTFILTER_HPP #include +#include #include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h" #include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp" @@ -25,6 +26,19 @@ using GameState = net::eagle0::shardok::storage::fb::GameState; const net::eagle0::shardok::storage::ActionResult &result, PlayerId askingPlayer) -> std::optional; +// Variant that takes the asker's allied Shardok pids explicitly. Allied actor +// actions (like hidden meteor casts) are revealed to the asker as if the +// asker were the actor's owner. +[[nodiscard]] auto ActionResultFilteredForPlayerWithAllies( + const GameState *beforeState, + const net::eagle0::shardok::api::GameStateView &beforeView, + const net::eagle0::shardok::api::GameStateView &afterView, + const SettingsGetter &settings, + const net::eagle0::shardok::storage::ActionResult &result, + PlayerId askingPlayer, + const std::vector &alliedPids) + -> std::optional; + } // namespace shardok #endif // EAGLE0_ACTIONRESULTFILTER_HPP diff --git a/src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.cpp b/src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.cpp index 492ecedcbd..fe1af79037 100644 --- a/src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.cpp +++ b/src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.cpp @@ -19,6 +19,18 @@ auto GameStateFilteredForPlayer( const SettingsGetter& settings, const GameState* gameState, const PlayerId askingPlayer) -> GameStateView { + return GameStateFilteredForPlayerWithAllies( + settings, + gameState, + askingPlayer, + AlliedPids(gameState, askingPlayer)); +} + +auto GameStateFilteredForPlayerWithAllies( + const SettingsGetter& settings, + const GameState* gameState, + const PlayerId askingPlayer, + const std::vector& alliedPids) -> GameStateView { GameStateView gsv{}; gsv.set_asking_player(askingPlayer); @@ -30,8 +42,6 @@ auto GameStateFilteredForPlayer( *gsv.add_player_infos() = fb::ToPlayerInfoProto(info); } - const auto& alliedPids = AlliedPids(gameState, askingPlayer); - for (const auto& unit : *gameState->units()) { switch (unit->status()) { case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT: diff --git a/src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.hpp b/src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.hpp index cb1eba5b1f..41e7fa5472 100644 --- a/src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.hpp +++ b/src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.hpp @@ -5,6 +5,8 @@ #ifndef EAGLE0_GAMESTATEFILTER_HPP #define EAGLE0_GAMESTATEFILTER_HPP +#include + #include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h" #include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp" #include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp" @@ -19,6 +21,16 @@ using GameState = net::eagle0::shardok::storage::fb::GameState; const GameState* gameState, PlayerId askingPlayer) -> net::eagle0::shardok::api::GameStateView; +// Variant that takes the asker's allied Shardok pids explicitly. Used for +// non-participant watchers who have no entry in the GameState's player_infos +// but should still see units owned by their participating allies as if those +// units were their own. +[[nodiscard]] auto GameStateFilteredForPlayerWithAllies( + const SettingsGetter& settings, + const GameState* gameState, + PlayerId askingPlayer, + const std::vector& alliedPids) -> net::eagle0::shardok::api::GameStateView; + } // namespace shardok #endif // EAGLE0_GAMESTATEFILTER_HPP diff --git a/src/main/cpp/net/eagle0/shardok/server/EagleInterfaceGrpcServer.cpp b/src/main/cpp/net/eagle0/shardok/server/EagleInterfaceGrpcServer.cpp index 82f6399cf6..361ac2e087 100644 --- a/src/main/cpp/net/eagle0/shardok/server/EagleInterfaceGrpcServer.cpp +++ b/src/main/cpp/net/eagle0/shardok/server/EagleInterfaceGrpcServer.cpp @@ -196,6 +196,23 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) { string unitsString; for (const auto &hid : heroIds) { unitsString += std::to_string(hid) + " "; } + // Build the watcher → allied-Shardok-pids mapping. A non-participant + // faction "watches" via any participant that lists it as an ally. + std::vector>> watcherAllies; + watcherAllies.reserve(request.watcher_eagle_faction_ids_size()); + for (const int32_t watcherFid : request.watcher_eagle_faction_ids()) { + std::vector allyPids; + for (int i = 0; i < request.user_infos_size(); i++) { + for (const auto &ally : request.user_infos(i).allies()) { + if (ally.eagle_faction_id() == watcherFid) { + allyPids.push_back(static_cast(i)); + break; + } + } + } + if (!allyPids.empty()) { watcherAllies.emplace_back(watcherFid, std::move(allyPids)); } + } + printf("Starting game %s with units %s\n", gameId.c_str(), unitsString.c_str()); gamesManager->UnlockedCreateSpecifiedGame( gameId, @@ -204,7 +221,8 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) { customMapBytes, month, request.tutorial_battle_config(), - request.SerializeAsString()); + request.SerializeAsString(), + watcherAllies); } auto EagleInterfaceImpl::PostCommand( diff --git a/src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.cpp b/src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.cpp index 64174938fb..bacf29168c 100644 --- a/src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.cpp +++ b/src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.cpp @@ -32,7 +32,9 @@ auto SetUpController( const vector &units, const vector &playerSetupInfos, const TutorialBattleConfigProto &tutorialConfig, - const string &serializedRequest) -> shared_ptr; + const string &serializedRequest, + const std::vector>> &watcherAllies = {}) + -> shared_ptr; ShardokGamesManager::ShardokGamesManager(const std::vector &extraSettings) { gameSettings = SettingsLoader::LoadSettings(); @@ -85,7 +87,8 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame( const string &customMapBytes, const int month, const TutorialBattleConfigProto &tutorialConfig, - const string &serializedRequest) -> GameId { + const string &serializedRequest, + const std::vector>> &watcherAllies) -> GameId { // Check for existing game with this ID and early-exit std::shared_lock lk(runningControllersLock); const auto existingGame = runningControllers.find(gameId); @@ -120,7 +123,8 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame( units, playerInfos, tutorialConfig, - serializedRequest); + serializedRequest, + watcherAllies); return LockedCreateGame(controller, playerSetupInfos, serializedRequest); } @@ -147,7 +151,9 @@ auto SetUpController( const vector &units, const vector &playerInfos, const TutorialBattleConfigProto &tutorialConfig, - const string &serializedRequest) -> shared_ptr { + const string &serializedRequest, + const std::vector>> &watcherAllies) + -> shared_ptr { auto unplacedUnits = std::vector(); UnitId nextUnitId = 0; @@ -220,7 +226,11 @@ auto SetUpController( engine->SetTutorialBattleConfig(tutorialConfig); - return std::make_shared(std::move(engine), mapName, serializedRequest); + return std::make_shared( + std::move(engine), + mapName, + serializedRequest, + watcherAllies); } } // namespace shardok diff --git a/src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp b/src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp index d0f7ea4559..185c4ab53a 100644 --- a/src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp +++ b/src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp @@ -110,7 +110,9 @@ public: const string &customMapBytes, int month, const TutorialBattleConfigProto &tutorialConfig, - const string &serializedRequest) -> GameId; + const string &serializedRequest, + const std::vector>> &watcherAllies = {}) + -> GameId; auto UnlockedCreateFromGameStateBytes( const byte_vector &gameStateBytes, diff --git a/src/main/protobuf/net/eagle0/common/game_setup_info.proto b/src/main/protobuf/net/eagle0/common/game_setup_info.proto index 7f43ba6945..0d187badea 100644 --- a/src/main/protobuf/net/eagle0/common/game_setup_info.proto +++ b/src/main/protobuf/net/eagle0/common/game_setup_info.proto @@ -31,4 +31,10 @@ message NewGameRequest { // Configuration for scripted tutorial battles TutorialBattleConfig tutorial_battle_config = 7; + + // Eagle faction IDs of non-participants who should receive an ally-aware + // filtered view of this battle (e.g., factions allied to a participant + // but not themselves fighting). Each entry derives its allied participant + // set from existing PlayerSetupInfo.allies entries that name this faction. + repeated int32 watcher_eagle_faction_ids = 8; } diff --git a/src/main/scala/net/eagle0/eagle/shardok_interface/ShardokInterfaceGrpcClient.scala b/src/main/scala/net/eagle0/eagle/shardok_interface/ShardokInterfaceGrpcClient.scala index 20c0873bb9..f67ef102c0 100644 --- a/src/main/scala/net/eagle0/eagle/shardok_interface/ShardokInterfaceGrpcClient.scala +++ b/src/main/scala/net/eagle0/eagle/shardok_interface/ShardokInterfaceGrpcClient.scala @@ -264,6 +264,16 @@ class ShardokInterfaceGrpcClient( s"${battle.players.map(sp => sp.getArmyGroup.armies.flatMap(_.getArmy.units).map(_.heroId).sorted.mkString(" ")).mkString(", ")}" ) + val participantFids: Set[FactionId] = battle.players.map(_.eagleFid).toSet + val watcherFids: Vector[FactionId] = battle.players.flatMap { sp => + eagleGameState + .factions(sp.eagleFid) + .factionRelationships + .filter(_.relationshipLevel != HOSTILE) + .map(_.targetFactionId) + }.toVector.distinct + .filterNot(participantFids.contains) + val newGameRequest = NewGameRequest( hexMapName = battle.hexMapName, gameId = battle.shardokGameId, @@ -276,7 +286,8 @@ class ShardokInterfaceGrpcClient( ) }, eagleGameId = eagleGameState.gameId, - tutorialBattleConfig = tutorialBattleConfig + tutorialBattleConfig = tutorialBattleConfig, + watcherEagleFactionIds = watcherFids ) resolveNewGameRequest(battle, newGameRequest) diff --git a/src/test/cpp/net/eagle0/shardok/library/view_filters/BUILD.bazel b/src/test/cpp/net/eagle0/shardok/library/view_filters/BUILD.bazel index ac51601330..6568dd9b8c 100644 --- a/src/test/cpp/net/eagle0/shardok/library/view_filters/BUILD.bazel +++ b/src/test/cpp/net/eagle0/shardok/library/view_filters/BUILD.bazel @@ -15,3 +15,18 @@ cc_test( "@googletest//:gtest_main", ], ) + +cc_test( + name = "watcher_filter_test", + size = "small", + srcs = ["WatcherFilter_test.cpp"], + copts = TEST_COPTS, + deps = [ + "//src/main/cpp/net/eagle0/shardok/library:engine", + "//src/main/cpp/net/eagle0/shardok/library/view_filters:game_state_filter", + "//src/test/cpp/net/eagle0/shardok/library:hex_map_test_data", + "//src/test/cpp/net/eagle0/shardok/library:shardok_engine_based_test_data", + "//src/test/cpp/net/eagle0/shardok/library:unit_tests_data", + "@googletest//:gtest_main", + ], +) diff --git a/src/test/cpp/net/eagle0/shardok/library/view_filters/WatcherFilter_test.cpp b/src/test/cpp/net/eagle0/shardok/library/view_filters/WatcherFilter_test.cpp new file mode 100644 index 0000000000..e5cdf48f7f --- /dev/null +++ b/src/test/cpp/net/eagle0/shardok/library/view_filters/WatcherFilter_test.cpp @@ -0,0 +1,108 @@ +// +// Tests for the watcher path of GameStateFilter / ActionResultFilter, used +// when a non-participant Eagle faction watches an ally's battle. Hero stats +// for allied units must be visible to the watcher even though the watcher +// has no opponent_knowledge entry. +// + +#include + +#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp" +#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp" +#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateFilter.hpp" +#include "src/test/cpp/net/eagle0/shardok/library/HexMap_test_data.hpp" +#include "src/test/cpp/net/eagle0/shardok/library/ShardokEngineBasedTestData.hpp" +#include "src/test/cpp/net/eagle0/shardok/library/Unit_tests_data.hpp" + +namespace shardok { + +class WatcherFilterTest : public ::testing::Test { + void SetUp() override { + InitializeGameSettings(); + // The test settings default knowledge thresholds to 0; set them so the + // observer-vs-watcher distinction is observable. + GetGameSettingsSetter().SetInt(kSettingsKeys.minimumKnowledgeForHeroStats, 75); + GetGameSettingsSetter().SetInt(kSettingsKeys.minimumKnowledgeForBattalionStats, 50); + } +}; + +TEST_F(WatcherFilterTest, AlliedParticipantHeroStatsVisibleToWatcher) { + vector attackerUnits; + attackerUnits.push_back(GetUnitT1()); + attackerUnits.back().mutable_location() = Coords(4, 0); + + vector defenderUnits; + defenderUnits.push_back(GetUnitT2()); + defenderUnits.back().mutable_location() = Coords(2, 0); + + const auto game = GameWithUnits(BASIC_MAP, attackerUnits, defenderUnits); + const auto* gs = game->GetCurrentGameState().Get(); + const auto& settings = game->GetGameSettings()->GetGetter(); + + // Watcher allied to player 0 (the attacker). + const auto watcherView = GameStateFilteredForPlayerWithAllies( + settings, + gs, + /*askingPlayer=*/-1, + /*alliedPids=*/std::vector{0}); + + // Generic observer (no alliedPids) — used for comparison. + const auto observerView = GameStateFilteredForPlayer(settings, gs, -1); + + // Find player 0's unit in the watcher view and confirm hero stats present. + bool foundAllyWithHeroStats = false; + for (const auto& uv : watcherView.units()) { + if (uv.player_id() == 0 && uv.has_attached_hero() && uv.attached_hero().has_stats()) { + foundAllyWithHeroStats = true; + // Ally battalion stats also revealed. + EXPECT_TRUE(uv.battalion().has_armament()); + EXPECT_TRUE(uv.battalion().has_morale()); + } + } + EXPECT_TRUE(foundAllyWithHeroStats) + << "Watcher allied to player 0 should see hero stats on player 0's unit"; + + // Sanity check: legacy observer view (no alliedPids) does NOT reveal + // hero stats for the same unit, since opponent_knowledge defaults to 0. + bool observerSawAllyHeroStats = false; + for (const auto& uv : observerView.units()) { + if (uv.player_id() == 0 && uv.has_attached_hero() && uv.attached_hero().has_stats()) { + observerSawAllyHeroStats = true; + } + } + EXPECT_FALSE(observerSawAllyHeroStats) + << "Generic observer should NOT see hero stats with default knowledge"; +} + +TEST_F(WatcherFilterTest, NonAlliedEnemyUnitGatedByOpponentKnowledge) { + vector attackerUnits; + attackerUnits.push_back(GetUnitT1()); + attackerUnits.back().mutable_location() = Coords(4, 0); + + vector defenderUnits; + defenderUnits.push_back(GetUnitT2()); + defenderUnits.back().mutable_location() = Coords(2, 0); + + const auto game = GameWithUnits(BASIC_MAP, attackerUnits, defenderUnits); + const auto* gs = game->GetCurrentGameState().Get(); + const auto& settings = game->GetGameSettings()->GetGetter(); + + // Watcher allied only to player 0; player 1 is non-allied, default + // opponent_knowledge of 0, so no stats should leak through. + const auto watcherView = GameStateFilteredForPlayerWithAllies( + settings, + gs, + /*askingPlayer=*/-1, + /*alliedPids=*/std::vector{0}); + + bool foundEnemyHeroStats = false; + for (const auto& uv : watcherView.units()) { + if (uv.player_id() == 1 && uv.has_attached_hero() && uv.attached_hero().has_stats()) { + foundEnemyHeroStats = true; + } + } + EXPECT_FALSE(foundEnemyHeroStats) + << "Watcher should not see non-allied enemy hero stats without sufficient knowledge"; +} + +} // namespace shardok