Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.7 d12c81bc3b 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 <noreply@anthropic.com>
2026-05-08 20:17:31 -07:00
15 changed files with 328 additions and 15 deletions
@@ -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),
@@ -120,6 +120,11 @@ private:
vector<shared_ptr<ShardokAIClient>> 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<std::pair<int32_t, vector<PlayerId>>> watcherAllies;
static auto MakeLogFilePath() -> string {
const time_t timer = time(nullptr);
char buf[255];
@@ -139,14 +144,16 @@ public:
ShardokGameController(
unique_ptr<ShardokEngine> e,
string mapName,
string serializedRequest = "")
string serializedRequest = "",
vector<std::pair<int32_t, vector<PlayerId>>> 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();
}
@@ -217,6 +217,58 @@ auto ShardokEngine::GetGameStateView(const PlayerId askingPlayer) const
return filteredHistory;
}
[[nodiscard]] auto ShardokEngine::FilterNewResultsForWatcher(
const PlayerId askingPlayer,
const vector<PlayerId> &alliedPids,
const int64_t previousActionCount) const
-> vector<net::eagle0::shardok::api::ActionResultView> {
if (previousActionCount < startingHistoryCount) {
throw ShardokInternalErrorException("Unable to fetch history before startingHistoryCount");
}
vector<ShardokActionWithResultingState> unfilteredHistory = GetGameHistory(previousActionCount);
vector<net::eagle0::shardok::api::ActionResultView> 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<net::eagle0::shardok::api::ActionResultView> {
return FilterNewResults(askingPlayer, 0);
@@ -152,6 +152,16 @@ public:
[[nodiscard]] auto FilterNewResults(PlayerId askingPlayer, int64_t previousActionCount) const
-> vector<net::eagle0::shardok::api::ActionResultView>;
// 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<PlayerId> &alliedPids,
int64_t previousActionCount) const
-> vector<net::eagle0::shardok::api::ActionResultView>;
[[nodiscard]] auto GetFilteredGameHistory(PlayerId askingPlayer) const
-> vector<net::eagle0::shardok::api::ActionResultView>;
@@ -4,6 +4,9 @@
#include "ActionResultFilter.hpp"
#include <algorithm>
#include <ranges>
#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<ActionResultView> {
return ActionResultFilteredForPlayerWithAllies(
beforeState,
beforeView,
afterView,
settings,
result,
askingPlayer,
std::vector<PlayerId>{});
}
[[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<PlayerId> &alliedPids) -> std::optional<ActionResultView> {
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<unsigned int>(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<ActionResultView>();
}
@@ -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();
@@ -6,6 +6,7 @@
#define EAGLE0_ACTIONRESULTFILTER_HPP
#include <optional>
#include <vector>
#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<net::eagle0::shardok::api::ActionResultView>;
// 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<PlayerId> &alliedPids)
-> std::optional<net::eagle0::shardok::api::ActionResultView>;
} // namespace shardok
#endif // EAGLE0_ACTIONRESULTFILTER_HPP
@@ -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<PlayerId>& 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:
@@ -5,6 +5,8 @@
#ifndef EAGLE0_GAMESTATEFILTER_HPP
#define EAGLE0_GAMESTATEFILTER_HPP
#include <vector>
#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<PlayerId>& alliedPids) -> net::eagle0::shardok::api::GameStateView;
} // namespace shardok
#endif // EAGLE0_GAMESTATEFILTER_HPP
@@ -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<std::pair<int32_t, std::vector<PlayerId>>> watcherAllies;
watcherAllies.reserve(request.watcher_eagle_faction_ids_size());
for (const int32_t watcherFid : request.watcher_eagle_faction_ids()) {
std::vector<PlayerId> 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<PlayerId>(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(
@@ -32,7 +32,9 @@ auto SetUpController(
const vector<Unit> &units,
const vector<PlayerInfoProto> &playerSetupInfos,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> shared_ptr<ShardokGameController>;
const string &serializedRequest,
const std::vector<std::pair<int32_t, std::vector<PlayerId>>> &watcherAllies = {})
-> shared_ptr<ShardokGameController>;
ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &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<std::pair<int32_t, std::vector<PlayerId>>> &watcherAllies) -> GameId {
// Check for existing game with this ID and early-exit
std::shared_lock<std::shared_mutex> 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<Unit> &units,
const vector<PlayerInfoProto> &playerInfos,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> shared_ptr<ShardokGameController> {
const string &serializedRequest,
const std::vector<std::pair<int32_t, std::vector<PlayerId>>> &watcherAllies)
-> shared_ptr<ShardokGameController> {
auto unplacedUnits = std::vector<Unit>();
UnitId nextUnitId = 0;
@@ -220,7 +226,11 @@ auto SetUpController(
engine->SetTutorialBattleConfig(tutorialConfig);
return std::make_shared<ShardokGameController>(std::move(engine), mapName, serializedRequest);
return std::make_shared<ShardokGameController>(
std::move(engine),
mapName,
serializedRequest,
watcherAllies);
}
} // namespace shardok
@@ -110,7 +110,9 @@ public:
const string &customMapBytes,
int month,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> GameId;
const string &serializedRequest,
const std::vector<std::pair<int32_t, std::vector<PlayerId>>> &watcherAllies = {})
-> GameId;
auto UnlockedCreateFromGameStateBytes(
const byte_vector &gameStateBytes,
@@ -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;
}
@@ -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)
@@ -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",
],
)
@@ -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 <gtest/gtest.h>
#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<Unit> attackerUnits;
attackerUnits.push_back(GetUnitT1());
attackerUnits.back().mutable_location() = Coords(4, 0);
vector<Unit> 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<PlayerId>{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<Unit> attackerUnits;
attackerUnits.push_back(GetUnitT1());
attackerUnits.back().mutable_location() = Coords(4, 0);
vector<Unit> 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<PlayerId>{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