From d12c81bc3b4be075b7eb4c60bacf4ce5ed176aa8 Mon Sep 17 00:00:00 2001 From: Dan Crosby Date: Fri, 8 May 2026 20:17:31 -0700 Subject: [PATCH] Generate ally-aware Shardok views for non-participant watchers When a faction watched an ally's battle, the Eagle layer fell back to Shardok's generic observer (factionId == -1) view because Shardok only emitted filtered views for battle participants. The observer view treats every unit as an enemy, so visibility was knowledge-gated: battalion stats (threshold 50) usually crept above the floor while hero stats (threshold 75) lagged behind, leaving watchers with battalion data but no hero stats for their own allies' heroes. Eagle now sends the set of watcher faction IDs in NewGameRequest, and Shardok emits one filtered view per watcher in GetUpdates. Each watcher's view treats their allied participants' units as fully visible (hero stats, hidden actions like meteor casts, hidden-target coords) while non-allied units fall back to observer-style knowledge gating. The watcher path is implemented via new GameStateFilteredForPlayerWithAllies and ActionResultFilteredForPlayerWithAllies overloads that take alliedPids explicitly, plus a new ShardokEngine::FilterNewResultsForWatcher that drives them. The IsHiddenFromOpponents predicate now treats the actor's allies as self for visibility purposes. Co-Authored-By: Claude Opus 4.7 --- .../controller/ShardokGameController.cpp | 15 +++ .../controller/ShardokGameController.hpp | 11 +- .../eagle0/shardok/library/ShardokEngine.cpp | 52 +++++++++ .../eagle0/shardok/library/ShardokEngine.hpp | 10 ++ .../view_filters/ActionResultFilter.cpp | 29 ++++- .../view_filters/ActionResultFilter.hpp | 14 +++ .../library/view_filters/GameStateFilter.cpp | 14 ++- .../library/view_filters/GameStateFilter.hpp | 12 ++ .../server/EagleInterfaceGrpcServer.cpp | 20 +++- .../shardok/server/ShardokGamesManager.cpp | 20 +++- .../shardok/server/ShardokGamesManager.hpp | 4 +- .../net/eagle0/common/game_setup_info.proto | 6 + .../ShardokInterfaceGrpcClient.scala | 13 ++- .../shardok/library/view_filters/BUILD.bazel | 15 +++ .../view_filters/WatcherFilter_test.cpp | 108 ++++++++++++++++++ 15 files changed, 328 insertions(+), 15 deletions(-) create mode 100644 src/test/cpp/net/eagle0/shardok/library/view_filters/WatcherFilter_test.cpp 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