diff --git a/src/main/java/ti4/discord/JdaService.java b/src/main/java/ti4/discord/JdaService.java
index 2984a739d0..98ecc5a041 100644
--- a/src/main/java/ti4/discord/JdaService.java
+++ b/src/main/java/ti4/discord/JdaService.java
@@ -493,6 +493,7 @@ private static void initializeWhitelistedRoles() {
adminRoles.add(jda.getRoleById("1487725249398308884")); // Balacasi's server
adminRoles.add(jda.getRoleById("1500012691224395906")); // BEANS's server
adminRoles.add(jda.getRoleById("1516450864376578238")); // Stabar's Server
+ adminRoles.add(jda.getRoleById("1527947707518423150")); // niugnip's Server
adminRoles.removeIf(Objects::isNull);
@@ -532,6 +533,7 @@ private static void initializeWhitelistedRoles() {
developerRoles.add(jda.getRoleById("1487725369766449173")); // Balacasi's server
developerRoles.add(jda.getRoleById("1500012939326001263")); // BEANS's server
developerRoles.add(jda.getRoleById("1516450864376578238")); // Stabar's Server
+ developerRoles.add(jda.getRoleById("1527947972615209041")); // niugnip's Server
developerRoles.removeIf(Objects::isNull);
@@ -555,7 +557,7 @@ private static void initializeWhitelistedRoles() {
bothelperRoles.add(jda.getRoleById("1458845770672377993")); // Async Tredenary (Planetary Duck System)
bothelperRoles.add(jda.getRoleById("1458845518393246036")); // Async Quadrodenary (Dannel's Camp Ground)
bothelperRoles.add(jda.getRoleById("1088532690803884052")); // FoW Server
- bothelperRoles.add(jda.getRoleById("1063464689218105354")); // FoW Server Game Admin
+ bothelperRoles.add(jda.getRoleById("1063464689218105354")); // FoW Server Game Supervisor
bothelperRoles.add(jda.getRoleById("1429853811891241128")); // FoW Server Chapter 2 Bothelper
bothelperRoles.add(jda.getRoleById("1429853811891241129")); // FoW Server Chapter 2 Game Supervisor
bothelperRoles.add(jda.getRoleById("1248693989193023519")); // Community Server
@@ -575,6 +577,7 @@ private static void initializeWhitelistedRoles() {
bothelperRoles.add(jda.getRoleById("1487725393673719950")); // Balacasi's server
bothelperRoles.add(jda.getRoleById("1500013009492246558")); // BEANS's server
bothelperRoles.add(jda.getRoleById("1516450864376578238")); // Stabar's Server
+ bothelperRoles.add(jda.getRoleById("1527947912108183686")); // niugnip's Server
bothelperRoles.removeIf(Objects::isNull);
}
@@ -622,6 +625,32 @@ public static boolean isProduction() {
return Constants.ASYNCTI4_HUB_SERVER_ID.equals(guildPrimaryID);
}
+ /**
+ * Whether a user holds any of the given roles, by user and role ID. Unlike CommandHelper#hasRole
+ * this needs no interaction event, so it also works from the web API where all we have is a
+ * Discord user ID. Reads straight from JDA's cache (see the MemberCachePolicy.ALL /
+ * ChunkingFilter.ALL setup above), so it stays a synchronous lookup rather than a REST call.
+ *
+ *
Roles that can't be resolved are skipped, and the result is false if none matched - callers
+ * gating access on a role should fail closed rather than treat "couldn't check" as a pass.
+ */
+ public static boolean hasAnyRole(String userId, String... roleIds) {
+ if (jda == null || userId == null) {
+ return false;
+ }
+ for (String roleId : roleIds) {
+ Role role = roleId == null ? null : jda.getRoleById(roleId);
+ if (role == null) {
+ continue;
+ }
+ Member member = role.getGuild().getMemberById(userId);
+ if (member != null && member.getRoles().contains(role)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private static boolean isWhitelistedGuild(Guild guild) {
return guilds.stream().anyMatch(whitelistGuild -> whitelistGuild.getId().equals(guild.getId()))
|| Constants.EMOJI_FARM_SERVERS.containsKey(guild.getId());
diff --git a/src/main/java/ti4/spring/api/webdata/GameWebDataController.java b/src/main/java/ti4/spring/api/webdata/GameWebDataController.java
index 83234c4865..c6bc68c6dc 100644
--- a/src/main/java/ti4/spring/api/webdata/GameWebDataController.java
+++ b/src/main/java/ti4/spring/api/webdata/GameWebDataController.java
@@ -2,13 +2,20 @@
import java.util.List;
import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
+import ti4.discord.JdaService;
import ti4.game.Game;
+import ti4.game.Player;
import ti4.spring.context.RequestContext;
import ti4.spring.service.gameevent.GameEventDto;
import ti4.spring.service.gameevent.GameEventService;
@@ -22,6 +29,29 @@ public class GameWebDataController {
private final GameWebDataService gameWebDataService;
private final GameEventService gameEventService;
+ /**
+ * TEMPORARY: while the new FoW web UI is in limited testing, production access is restricted to
+ * FoW server staff. Deliberately scoped to that server's own roles rather than JdaService's
+ * bothelperRoles, which spans every Async and community server plus all admins/developers -
+ * far wider than this gate is meant to be. Applies to GMs too: most hold one of these anyway,
+ * and exempting them would leave nearly every FoW game reachable. Remove this and its two call
+ * sites to open the UI up.
+ */
+ private static final String[] FOW_WEB_UI_TESTER_ROLE_IDS = {
+ "1088532690803884052", // FoW Server
+ "1063464689218105354", // FoW Server Game Supervisor
+ "1429853811891241128", // FoW Server Chapter 2 Bothelper
+ "1429853811891241129", // FoW Server Chapter 2 Game Supervisor
+ };
+
+ private static final String FOW_WEB_UI_RESTRICTED_MESSAGE =
+ "The new Fog of War web UI is in limited testing and is currently restricted to Fog of War server staff.";
+
+ private boolean canUseFowWebUi(String userId) {
+ // Only gated on production; local/dev bots keep working without the role.
+ return !JdaService.isProduction() || JdaService.hasAnyRole(userId, FOW_WEB_UI_TESTER_ROLE_IDS);
+ }
+
@GetMapping(value = "/web-data", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity get(@PathVariable String gameName) {
Game game = RequestContext.getGame();
@@ -34,6 +64,81 @@ public ResponseEntity get(@PathVariable String gameName) {
return ResponseEntity.ok(gameWebDataService.getOrCompute(gameName));
}
+ /**
+ * Fog-of-War aware web-data endpoint. GM/owner gets the full unfiltered payload (or,
+ * with {@code asPlayer}, a preview of another player's fogged view for debugging).
+ * Participants always get their own fogged view. Everyone else is forbidden.
+ */
+ @GetMapping(value = "/web-data-fow", produces = MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity getFow(
+ @PathVariable String gameName, @RequestParam(required = false) String asPlayer) {
+ Game game = RequestContext.getGame();
+ if (game == null || !game.isFowMode()) {
+ return ResponseEntity.notFound().build();
+ }
+
+ String userId = getOptionalUserId();
+ if (userId == null) {
+ return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
+ .body("Authentication required to view Fog of War games");
+ }
+
+ if (!canUseFowWebUi(userId)) {
+ return ResponseEntity.status(HttpStatus.FORBIDDEN).body(FOW_WEB_UI_RESTRICTED_MESSAGE);
+ }
+
+ if (isGm(game, userId)) {
+ if (asPlayer == null || asPlayer.isBlank()) {
+ return withViewerIsGmHeader(gameWebDataService.getOrCompute(gameName), true);
+ }
+ Player target = resolvePlayer(game, asPlayer);
+ if (target == null) {
+ return ResponseEntity.notFound().build();
+ }
+ return withViewerIsGmHeader(gameWebDataService.computeFiltered(game, target), true);
+ }
+
+ Player viewer = game.getPlayer(userId);
+ if (viewer == null) {
+ return ResponseEntity.status(HttpStatus.FORBIDDEN)
+ .body("You must be participating in this game to view it");
+ }
+ return withViewerIsGmHeader(gameWebDataService.computeFiltered(game, viewer), false);
+ }
+
+ /**
+ * Whether the requesting user is GM/owner can't be derived from the response body alone: a GM
+ * previewing another player's view and that player's own real view are shaped identically. Callers
+ * that need to distinguish them (e.g. to keep showing "view as" controls while previewing) read this
+ * header instead.
+ */
+ private ResponseEntity withViewerIsGmHeader(String body, boolean isGm) {
+ return ResponseEntity.ok()
+ .header("X-Viewer-Is-Gm", String.valueOf(isGm))
+ .body(body);
+ }
+
+ private boolean isGm(Game game, String userId) {
+ return userId.equals(game.getOwnerID())
+ || game.getPlayersWithGMRole().stream().anyMatch(p -> userId.equals(p.getUserID()));
+ }
+
+ private Player resolvePlayer(Game game, String idOrFaction) {
+ Player byUserId = game.getPlayer(idOrFaction);
+ if (byUserId != null) {
+ return byUserId;
+ }
+ return game.getPlayerFromColorOrFaction(idOrFaction);
+ }
+
+ private String getOptionalUserId() {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {
+ return auth.getName();
+ }
+ return null;
+ }
+
@GetMapping(value = "/game-state", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity getGameState() {
Game game = RequestContext.getGame();
@@ -46,6 +151,11 @@ public ResponseEntity getGameState() {
return ResponseEntity.ok(WebGameState.fromGame(game));
}
+ /**
+ * The event log's compact-map-state snapshots and movement/retreat diffs are built from the
+ * full, unfiltered game state - there's no fogged variant of "what changed," so this stays
+ * blocked for FoW games except for the GM/owner, who already sees the unfiltered view.
+ */
@GetMapping(value = "/events", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity> getEvents() {
Game game = RequestContext.getGame();
@@ -53,7 +163,10 @@ public ResponseEntity> getEvents() {
return ResponseEntity.notFound().build();
}
if (game.isFowMode()) {
- return ResponseEntity.notFound().build();
+ String userId = getOptionalUserId();
+ if (userId == null || !isGm(game, userId) || !canUseFowWebUi(userId)) {
+ return ResponseEntity.notFound().build();
+ }
}
return ResponseEntity.ok(gameEventService.getEvents(game));
}
diff --git a/src/main/java/ti4/spring/api/webdata/GameWebDataService.java b/src/main/java/ti4/spring/api/webdata/GameWebDataService.java
index 47017a15d9..6c262283b8 100644
--- a/src/main/java/ti4/spring/api/webdata/GameWebDataService.java
+++ b/src/main/java/ti4/spring/api/webdata/GameWebDataService.java
@@ -4,16 +4,22 @@
import com.github.benmanes.caffeine.cache.Caffeine;
import java.time.Duration;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import org.springframework.stereotype.Service;
import ti4.cache.CacheManager;
import ti4.game.Game;
import ti4.game.Player;
+import ti4.game.Tile;
import ti4.game.persistence.GameManager;
+import ti4.helpers.FoWHelper;
import ti4.json.JsonMapperManager;
+import ti4.service.fow.FOWPlusService;
+import ti4.service.option.FOWOptionService.FOWOption;
import ti4.website.model.WebBorderAnomalies;
import ti4.website.model.WebCardPool;
import ti4.website.model.WebExpeditions;
@@ -66,6 +72,146 @@ private static Cache createCache() {
return cache;
}
+ /**
+ * Builds the fogged view of {@code game} as seen by {@code viewer}: tiles outside
+ * {@link FoWHelper#fowFilter} are replaced with the viewer's remembered "ghost" tile (or
+ * omitted if never seen); other players' stats/scores are redacted per
+ * {@link FoWHelper#canSeeStatsOfPlayer}; names are redacted if the game's HIDE_PLAYER_NAMES
+ * option is set; the explore/relic card pool is omitted if HIDE_EXPLORES (or FoW+) is active.
+ */
+ public String computeFiltered(Game game, Player viewer) {
+ try {
+ return JsonMapperManager.basic().writeValueAsString(buildFilteredWebData(game, viewer));
+ } catch (Exception e) {
+ throw new IllegalStateException("Could not serialize filtered web data for game " + game.getName(), e);
+ }
+ }
+
+ public static Map buildFilteredWebData(Game game, Player viewer) {
+ Set visible = FoWHelper.fowFilter(game, viewer);
+ Map webData = buildWebData(game, buildFogSubstitutedTileMap(game, viewer, visible));
+ webData.put("viewingAsPlayerId", viewer.getUserID());
+ applyStatRedaction(webData, game, viewer);
+ applyPlayerNameRedaction(webData, game);
+ applyExploreDeckRedaction(webData, game);
+ applyControlIdentityRedaction(webData, game, viewer);
+ applyObjectiveProgressRedaction(webData, game, viewer);
+ applyLawRedaction(webData, game, viewer);
+ applyStrategyCardRedaction(webData, game, viewer);
+ applyStatTileRedaction(webData, game, viewer);
+ applyGhostTileMetadata(webData, viewer, visible);
+ return webData;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void applyStrategyCardRedaction(Map webData, Game game, Player viewer) {
+ List strategyCards = (List) webData.get("strategyCards");
+ WebStrategyCard.redactPickers(strategyCards, game, viewer);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static void applyStatTileRedaction(Map webData, Game game, Player viewer) {
+ Map> statTilePositions = (Map>) webData.get("statTilePositions");
+ WebStatTilePositions.redact(statTilePositions, game, viewer);
+ }
+
+ private static void applyLawRedaction(Map webData, Game game, Player viewer) {
+ @SuppressWarnings("unchecked")
+ List lawsInPlay = (List) webData.get("lawsInPlay");
+ WebLaw.redactElectedFaction(lawsInPlay, game, viewer);
+ }
+
+ /**
+ * Flags positions outside the viewer's current vision as remembered "ghost" tiles, mirroring the
+ * greyed-out fog tile with a "last seen" label the Discord PNG already draws for these same
+ * positions (see WebTileUnitData#markGhostTiles).
+ */
+ private static void applyGhostTileMetadata(Map webData, Player viewer, Set visible) {
+ @SuppressWarnings("unchecked")
+ Map tileUnitData = (Map) webData.get("tileUnitData");
+ WebTileUnitData.markGhostTiles(tileUnitData, visible, viewer);
+ }
+
+ private static void applyObjectiveProgressRedaction(Map webData, Game game, Player viewer) {
+ WebObjectives objectives = (WebObjectives) webData.get("objectives");
+ WebObjectives.redactFactionProgress(objectives, viewer);
+ WebObjectives.redactScorers(objectives, game, viewer);
+ }
+
+ private static void applyControlIdentityRedaction(Map webData, Game game, Player viewer) {
+ @SuppressWarnings("unchecked")
+ Map tileUnitData = (Map) webData.get("tileUnitData");
+ WebTileUnitData.redactControlIdentities(tileUnitData, game, viewer);
+ WebTileUnitData.redactUnitIdentities(tileUnitData, game, viewer);
+ WebTileUnitData.redactProduction(tileUnitData, game, viewer);
+ }
+
+ /**
+ * Mirrors MapGenerator's private-FoW rendering: a player area is only included at all if the
+ * viewer can see that player's stats (FoWHelper#canSeeStatsOfPlayer).
+ *
+ * Hidden players are left out of scoreBreakdowns entirely - keying them by faction would
+ * publish both their identity and which objectives they scored, neither of which survives the
+ * fog. Their score-track position is public though, so their totals go into hiddenPlayerVps as
+ * bare numbers: enough to place an anonymous token on the track, with nothing to tie it to a
+ * faction. Sorted so the order can't be used to correlate a player across requests.
+ */
+ private static void applyStatRedaction(Map webData, Game game, Player viewer) {
+ List playerDataList = new ArrayList<>();
+ Map scoreBreakdowns = new HashMap<>();
+ List hiddenPlayerVps = new ArrayList<>();
+ for (Player player : game.getRealPlayersNNeutral()) {
+ // The neutral (Dicecord) player represents public neutral forces, not a hidden real
+ // opponent, and FoWHelper#canSeeStatsOfPlayer always returns false for it (it isn't a
+ // "real player") - so it's always included here rather than treated as unidentified.
+ boolean canSeeStats = player.isNeutral() || FoWHelper.canSeeStatsOfPlayer(game, player, viewer);
+ if (canSeeStats) {
+ playerDataList.add(WebPlayerArea.fromPlayer(player, game));
+ scoreBreakdowns.put(player.getFaction(), WebScoreBreakdown.fromPlayer(player, game));
+ } else {
+ hiddenPlayerVps.add(player.getTotalVictoryPoints());
+ }
+ }
+ Collections.sort(hiddenPlayerVps);
+ webData.put("playerData", playerDataList);
+ webData.put("scoreBreakdowns", scoreBreakdowns);
+ webData.put("hiddenPlayerVps", hiddenPlayerVps);
+ }
+
+ private static void applyPlayerNameRedaction(Map webData, Game game) {
+ if (!game.hideUserNames()) return;
+ @SuppressWarnings("unchecked")
+ List playerDataList = (List) webData.get("playerData");
+ for (WebPlayerArea area : playerDataList) {
+ area.setUserName(null);
+ area.setDisplayName(null);
+ area.setDiscordId(null);
+ }
+ }
+
+ private static void applyExploreDeckRedaction(Map webData, Game game) {
+ boolean hideExplores = FOWPlusService.isActive(game) || game.getFowOption(FOWOption.HIDE_EXPLORES);
+ if (hideExplores) {
+ webData.put("cardPool", null);
+ }
+ }
+
+ private static Map buildFogSubstitutedTileMap(Game game, Player viewer, Set visible) {
+ Map substituted = new LinkedHashMap<>();
+ for (Map.Entry entry : game.getTileMap().entrySet()) {
+ String position = entry.getKey();
+ if (visible.contains(position)) {
+ substituted.put(position, entry.getValue());
+ continue;
+ }
+ Tile fogTile = viewer.buildFogTile(position, viewer);
+ if (fogTile != null) {
+ substituted.put(position, fogTile);
+ }
+ }
+ return substituted;
+ }
+
private static String serialize(Game game) {
try {
return JsonMapperManager.basic().writeValueAsString(buildWebData(game));
@@ -75,13 +221,17 @@ private static String serialize(Game game) {
}
public static Map buildWebData(Game game) {
+ return buildWebData(game, game.getTileMap());
+ }
+
+ public static Map buildWebData(Game game, Map tileMap) {
List playerDataList = new ArrayList<>();
for (Player player : game.getRealPlayersNNeutral()) {
playerDataList.add(WebPlayerArea.fromPlayer(player, game));
}
- WebTilePositions webTilePositions = WebTilePositions.fromGame(game);
- Map tileUnitData = WebTileUnitData.fromGame(game);
+ WebTilePositions webTilePositions = WebTilePositions.fromTileMap(tileMap);
+ Map tileUnitData = WebTileUnitData.fromTileMap(game, tileMap);
WebStatTilePositions webStatTilePositions = WebStatTilePositions.fromGame(game);
WebObjectives webObjectives = WebObjectives.fromGame(game);
WebCardPool webCardPool = WebCardPool.fromGame(game);
@@ -141,6 +291,8 @@ public static Map buildWebData(Game game) {
? webBorderAnomalies.getBorderAnomalies()
: null);
webData.put("isTwilightsFallMode", game.isTwilightsFallMode());
+ webData.put("isFowMode", game.isFowMode());
+ webData.put("hidePlayerInfos", game.isFowMode() && game.getFowOption(FOWOption.HIDE_PLAYER_INFOS));
return webData;
}
}
diff --git a/src/main/java/ti4/spring/websocket/WebSocketNotifier.java b/src/main/java/ti4/spring/websocket/WebSocketNotifier.java
index eee2f8ebaf..52478c5c99 100644
--- a/src/main/java/ti4/spring/websocket/WebSocketNotifier.java
+++ b/src/main/java/ti4/spring/websocket/WebSocketNotifier.java
@@ -71,8 +71,13 @@ public void notifyGameStateChanged(Game game) {
// Sole writer of the web-data cache: if anything else wrote it, the diff
// baseline would drift from what clients last received.
if (game.isFowMode()) {
+ // FoW views differ per-viewer, so don't stream a single patch like non-FoW
+ // games. Refresh the (unfiltered) cache backing the GM's REST view and tell
+ // clients to refetch web-data-fow, which re-applies each viewer's fog
+ // server-side.
gameWebDataService.put(gameId, MAPPER.writeValueAsString(current));
- return; // cache refreshed for REST, but FoW games never stream
+ notifyGameRefresh(gameId);
+ return;
}
String previousJson = gameWebDataService.getIfCached(gameId);
JsonNode previous = previousJson == null ? null : MAPPER.readTree(previousJson);
diff --git a/src/main/java/ti4/website/model/WebLaw.java b/src/main/java/ti4/website/model/WebLaw.java
index ce53f5f691..794e32bee1 100644
--- a/src/main/java/ti4/website/model/WebLaw.java
+++ b/src/main/java/ti4/website/model/WebLaw.java
@@ -4,6 +4,8 @@
import java.util.List;
import lombok.Data;
import ti4.game.Game;
+import ti4.game.Player;
+import ti4.helpers.FoWHelper;
import ti4.image.Mapper;
import ti4.model.AgendaModel;
@@ -32,6 +34,26 @@ public class WebLaw {
// Additional metadata
private boolean displaysElectedFaction;
+ /**
+ * Obscures which faction was elected by a law for players the viewer can't identify
+ * (FoWHelper#canSeeStatsOfPlayer), substituting the same "fow:<color>" sentinel used for
+ * control tokens and units (see WebTileUnitData#redactControlIdentities). Which color got
+ * elected is announced publicly, so it stays visible - only the faction behind it is hidden.
+ * electedInfo is dropped outright since it's the raw elected string and would leak the faction.
+ */
+ public static void redactElectedFaction(List lawsInPlay, Game game, Player viewer) {
+ for (WebLaw law : lawsInPlay) {
+ if (!"player".equals(law.electedType) || law.electedFaction == null) {
+ continue;
+ }
+ Player electedPlayer = game.getPlayerFromColorOrFaction(law.electedFaction);
+ if (electedPlayer != null && !FoWHelper.canSeeStatsOfPlayer(game, electedPlayer, viewer)) {
+ law.electedFaction = WebTileUnitData.UNKNOWN_FACTION_PREFIX + electedPlayer.getColor();
+ law.electedInfo = null;
+ }
+ }
+ }
+
public static WebLaw fromGameLaw(String lawId, Integer uniqueId, Game game) {
WebLaw webLaw = new WebLaw();
@@ -56,8 +78,20 @@ public static WebLaw fromGameLaw(String lawId, Integer uniqueId, Game game) {
webLaw.electedInfo = electedInfo;
if (electedInfo != null && !electedInfo.isEmpty()) {
- // Check if it's a player faction
- if (Mapper.isValidFaction(electedInfo)) {
+ // Check if it's a player - elections are recorded as either the faction or the
+ // color (see IsPlayerElectedService, which checks both), so resolve via
+ // getPlayerFromColorOrFaction rather than only matching a faction string.
+ Player electedPlayer = game.getPlayerFromColorOrFaction(electedInfo);
+ if (electedPlayer != null) {
+ webLaw.electedType = "player";
+ webLaw.electedFaction = electedPlayer.getFaction();
+ }
+ // The election named a faction that no current player resolves to - it's still a
+ // player election, so keep showing it rather than falling through to "other".
+ // getPlayerFromColorOrFaction only matches players presently in the game, whereas a
+ // recorded election can outlive them (a player leaving, or a stale color recorded
+ // before a /player change_color, which doesn't rewrite lawsInfo).
+ else if (Mapper.isValidFaction(electedInfo)) {
webLaw.electedType = "player";
webLaw.electedFaction = electedInfo;
}
diff --git a/src/main/java/ti4/website/model/WebObjectives.java b/src/main/java/ti4/website/model/WebObjectives.java
index 7be6ba2dcb..e498cc6d53 100644
--- a/src/main/java/ti4/website/model/WebObjectives.java
+++ b/src/main/java/ti4/website/model/WebObjectives.java
@@ -11,10 +11,20 @@
import ti4.game.Game;
import ti4.game.Player;
import ti4.helpers.Constants;
+import ti4.helpers.FoWHelper;
import ti4.image.Mapper;
import ti4.model.PublicObjectiveModel;
import ti4.service.info.ListPlayerInfoService;
+/*
+ * Note on FoW: under fog you learn that *someone* scored an objective, not who. So
+ * {@link #redactScorers} drops unidentified factions out of {@code scoredFactions} entirely and
+ * replaces them with a bare count - sending the real faction strings and hiding them client-side
+ * would leave the answer sitting in the payload. Numeric {@code factionProgress} is private too
+ * (derived from hand/resources), so {@link #redactFactionProgress} strips it down to only the
+ * viewer's own entry.
+ */
+
@Data
public class WebObjectives {
@@ -28,6 +38,9 @@ static class ObjectiveInfo {
private boolean hasRedTape;
private List scoredFactions;
private List peekingFactions;
+ // How many scorers were dropped from scoredFactions because the viewer can't identify them
+ // (see #redactScorers). Always 0 in the unfiltered view.
+ private int unidentifiedScorerCount;
private Map factionProgress;
private int progressThreshold;
@@ -60,6 +73,43 @@ static class ObjectiveInfo {
private List customObjectives;
private List allObjectives;
+ // The redactions below mutate through allObjectives, which holds the same ObjectiveInfo
+ // instances referenced by the stage1/stage2/custom lists - so updating it updates all of them.
+
+ /**
+ * Replaces scorers the viewer can't identify with a count, and drops them from the peeking list.
+ * The frontend renders {@code unidentifiedScorerCount} anonymous tokens, which carry no faction
+ * string at all - so there is nothing to correlate across objectives, and nothing recoverable
+ * from the raw payload.
+ */
+ public static void redactScorers(WebObjectives objectives, Game game, Player viewer) {
+ for (ObjectiveInfo info : objectives.allObjectives) {
+ int before = info.scoredFactions.size();
+ info.scoredFactions.removeIf(faction -> !canIdentify(game, viewer, faction));
+ info.unidentifiedScorerCount = before - info.scoredFactions.size();
+ info.peekingFactions.removeIf(faction -> !canIdentify(game, viewer, faction));
+ }
+ }
+
+ private static boolean canIdentify(Game game, Player viewer, String faction) {
+ Player player = game.getPlayerFromColorOrFaction(faction);
+ // Neutral forces are public, and canSeeStatsOfPlayer is false for any non-real player.
+ // An unresolvable faction fails closed - better an extra anonymous token than a leak.
+ return player != null && (player.isNeutral() || FoWHelper.canSeeStatsOfPlayer(game, player, viewer));
+ }
+
+ /** Strips numeric factionProgress down to just {@code viewer}'s own entry. */
+ public static void redactFactionProgress(WebObjectives objectives, Player viewer) {
+ String ownFaction = viewer.getFaction();
+ for (ObjectiveInfo info : objectives.allObjectives) {
+ Integer ownProgress = info.factionProgress.get(ownFaction);
+ info.factionProgress.clear();
+ if (ownProgress != null) {
+ info.factionProgress.put(ownFaction, ownProgress);
+ }
+ }
+ }
+
public static WebObjectives fromGame(Game game) {
WebObjectives webObjectives = new WebObjectives();
diff --git a/src/main/java/ti4/website/model/WebScoreBreakdown.java b/src/main/java/ti4/website/model/WebScoreBreakdown.java
index 25cc324498..9ffd78b2ef 100644
--- a/src/main/java/ti4/website/model/WebScoreBreakdown.java
+++ b/src/main/java/ti4/website/model/WebScoreBreakdown.java
@@ -22,6 +22,10 @@
@Data
public class WebScoreBreakdown {
private List entries;
+ // Score-track position, which isn't fogged. Under FoW a hidden player has no breakdown at all
+ // (see GameWebDataService#applyStatRedaction) - their total travels in hiddenPlayerVps instead,
+ // detached from any faction.
+ private Integer totalVps;
@Data
public static class ScoreBreakdownEntry {
@@ -109,6 +113,7 @@ public static WebScoreBreakdown fromPlayer(Player player, Game game) {
WebScoreBreakdown breakdown = new WebScoreBreakdown();
breakdown.entries = new ArrayList<>();
+ breakdown.totalVps = player.getTotalVictoryPoints();
addScoredEntries(player, game, breakdown.entries);
addQualifiesAndPotentialEntries(player, game, breakdown.entries);
diff --git a/src/main/java/ti4/website/model/WebStatTilePositions.java b/src/main/java/ti4/website/model/WebStatTilePositions.java
index 339a7cbdae..8c9b4cc8de 100644
--- a/src/main/java/ti4/website/model/WebStatTilePositions.java
+++ b/src/main/java/ti4/website/model/WebStatTilePositions.java
@@ -8,12 +8,27 @@
import lombok.Data;
import ti4.game.Game;
import ti4.game.Player;
+import ti4.helpers.FoWHelper;
import ti4.helpers.PlayerStatsHelper;
@Data
public class WebStatTilePositions {
private Map> statTilePositions;
+ /**
+ * Drops stat-tile positions for players whose stats the viewer can't see. The tiles sit beside a
+ * player's home system, so publishing them keyed by faction would give away both who is in the
+ * game and roughly where they are - exactly what {@link FoWHelper#canSeeStatsOfPlayer} (which
+ * bottoms out in "is their home system in view") is there to gate.
+ */
+ public static void redact(Map> statTilePositions, Game game, Player viewer) {
+ // An unresolvable faction fails closed: dropping its entry is safe, keeping it could leak.
+ statTilePositions.keySet().removeIf(faction -> {
+ Player player = game.getPlayerFromColorOrFaction(faction);
+ return player == null || !FoWHelper.canSeeStatsOfPlayer(game, player, viewer);
+ });
+ }
+
public static WebStatTilePositions fromGame(Game game) {
WebStatTilePositions webStatTilePositions = new WebStatTilePositions();
Map> factionToStatTiles = new HashMap<>();
diff --git a/src/main/java/ti4/website/model/WebStrategyCard.java b/src/main/java/ti4/website/model/WebStrategyCard.java
index 48bf09bb9f..654b187e35 100644
--- a/src/main/java/ti4/website/model/WebStrategyCard.java
+++ b/src/main/java/ti4/website/model/WebStrategyCard.java
@@ -1,7 +1,10 @@
package ti4.website.model;
+import java.util.List;
import lombok.Data;
import ti4.game.Game;
+import ti4.game.Player;
+import ti4.helpers.FoWHelper;
import ti4.model.StrategyCardModel;
@Data
@@ -15,6 +18,32 @@ public class WebStrategyCard {
private int tradeGoods;
private String pickedByFaction;
+ /**
+ * Hides who holds which strategy card. Playing a card is announced, so {@code played} stays
+ * visible along with the pick it implies - but until then, whether a card has been taken is
+ * itself private, and who took it is only visible to someone who can already see that player's
+ * stats (FoWHelper#canSeeStatsOfPlayer).
+ *
+ * Trade goods are cleared alongside, because they'd otherwise give the picks away by
+ * elimination: unpicked cards accumulate trade goods, and picking one takes them, so a nonzero
+ * count marks a card as definitely still unpicked. Blanking every unplayed card leaves them
+ * indistinguishable from each other rather than merely unlabelled.
+ */
+ public static void redactPickers(List strategyCards, Game game, Player viewer) {
+ for (WebStrategyCard card : strategyCards) {
+ Player picker =
+ card.pickedByFaction == null ? null : game.getPlayerFromColorOrFaction(card.pickedByFaction);
+ if (picker != null && FoWHelper.canSeeStatsOfPlayer(game, picker, viewer)) {
+ continue;
+ }
+ card.pickedByFaction = null;
+ if (!card.played) {
+ card.picked = false;
+ card.tradeGoods = 0;
+ }
+ }
+ }
+
public static WebStrategyCard fromGameStrategyCard(int scNumber, Game game) {
WebStrategyCard webSC = new WebStrategyCard();
diff --git a/src/main/java/ti4/website/model/WebTilePositions.java b/src/main/java/ti4/website/model/WebTilePositions.java
index 89fb7ce8e0..d858a4dba2 100644
--- a/src/main/java/ti4/website/model/WebTilePositions.java
+++ b/src/main/java/ti4/website/model/WebTilePositions.java
@@ -12,11 +12,15 @@ public class WebTilePositions {
private List tilePositions;
public static WebTilePositions fromGame(Game game) {
+ return fromTileMap(game.getTileMap());
+ }
+
+ public static WebTilePositions fromTileMap(Map tileMap) {
WebTilePositions webTilePositions = new WebTilePositions();
List tilePositions = new ArrayList<>();
- for (Map.Entry entry : game.getTileMap().entrySet()) {
+ for (Map.Entry entry : tileMap.entrySet()) {
String position = entry.getKey();
Tile tile = entry.getValue();
diff --git a/src/main/java/ti4/website/model/WebTileUnitData.java b/src/main/java/ti4/website/model/WebTileUnitData.java
index 3719da04ca..e5d3e0c387 100644
--- a/src/main/java/ti4/website/model/WebTileUnitData.java
+++ b/src/main/java/ti4/website/model/WebTileUnitData.java
@@ -4,6 +4,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import lombok.Data;
import ti4.game.Game;
import ti4.game.Planet;
@@ -12,6 +13,7 @@
import ti4.game.UnitHolder;
import ti4.helpers.ActionCardHelper;
import ti4.helpers.Constants;
+import ti4.helpers.FoWHelper;
import ti4.helpers.Helper;
import ti4.helpers.PdsCoverage;
import ti4.helpers.PdsCoverageHelper;
@@ -19,6 +21,7 @@
import ti4.helpers.Units.UnitType;
import ti4.image.DrawingUtil;
import ti4.image.TileGenerator;
+import ti4.service.map.CustomHyperlaneService;
import ti4.website.WebPdsCoverage;
@Data
@@ -29,6 +32,16 @@ public final class WebTileUnitData {
private boolean isAnomaly;
private Map production;
private Map pds; // PDS coverage data per faction
+ // Connection matrix (6x6 binary, "i,j,...;..." rows) for hyperlane tiles; null if not a
+ // hyperlane or no connection data is configured. See CustomHyperlaneService for the format.
+ private String hyperlaneMatrix;
+ // True if this position is outside the viewer's current vision and the tile/unit data above
+ // is a remembered "ghost" snapshot rather than the live tile (see #markGhostTiles). Always
+ // false in the unfiltered (GM/non-FoW) view.
+ private boolean isGhost;
+ // The viewer's last-seen label for this position (e.g. "Rnd 4"), same string shown on the
+ // Discord PNG fog overlay (Player#getFogLabels). Null unless isGhost is true.
+ private String fogLabel;
private WebTileUnitData() {
space = new HashMap<>();
@@ -39,10 +52,105 @@ private WebTileUnitData() {
pds = null; // Only populated if there is PDS coverage
}
+ // Marks a player the viewer can't identify: the real color is kept so the frontend can still
+ // render it, but the faction behind it is withheld. Shared with WebLaw#redactElectedFaction.
+ public static final String UNKNOWN_FACTION_PREFIX = "fow:";
+
public static Map fromGame(Game game) {
+ return fromTileMap(game, game.getTileMap());
+ }
+
+ /**
+ * Redacts control-token/CC faction identity in place for players the viewer can't identify
+ * (FoWHelper#canSeeStatsOfPlayer), substituting "fow:<color>" so the frontend can still
+ * render the colored token without the faction seal - matching how DrawingUtil#drawControlToken
+ * always draws the real-colored base token on Discord but skips the faction icon overlay.
+ */
+ public static void redactControlIdentities(Map tileUnitData, Game game, Player viewer) {
+ for (WebTileUnitData tileData : tileUnitData.values()) {
+ for (WebTilePlanet planet : tileData.planets.values()) {
+ if (planet.getControlledBy() != null) {
+ planet.setControlledBy(obscureIfHidden(game, viewer, planet.getControlledBy()));
+ }
+ }
+ tileData.ccs.replaceAll(faction -> obscureIfHidden(game, viewer, faction));
+ }
+ }
+
+ /**
+ * Flags positions outside the viewer's current vision as ghost tiles and stamps them with the
+ * viewer's remembered last-seen label, mirroring the greyed-out "Rnd N" fog tile the Discord PNG
+ * draws (TileGenerator#createTileImage, Player#getFogLabels). The tile/unit data at these
+ * positions is already the remembered snapshot (see GameWebDataService#buildFogSubstitutedTileMap)
+ * - this only adds the metadata the frontend needs to render it distinctly from a live tile.
+ */
+ public static void markGhostTiles(
+ Map tileUnitData, Set visiblePositions, Player viewer) {
+ Map fogLabels = viewer.getFogLabels();
+ for (Map.Entry entry : tileUnitData.entrySet()) {
+ if (visiblePositions.contains(entry.getKey())) {
+ continue;
+ }
+ WebTileUnitData tileData = entry.getValue();
+ tileData.isGhost = true;
+ tileData.fogLabel = fogLabels.get(entry.getKey());
+ }
+ }
+
+ private static String obscureIfHidden(Game game, Player viewer, String faction) {
+ Player player = game.getPlayerFromColorOrFaction(faction);
+ if (player == null || player.isNeutral() || FoWHelper.canSeeStatsOfPlayer(game, player, viewer)) {
+ return faction;
+ }
+ return UNKNOWN_FACTION_PREFIX + player.getColor();
+ }
+
+ /**
+ * Strips production entries for players the viewer can't identify - unlike control
+ * tokens/units, there's no meaningful "unidentified" placeholder for this number, so
+ * unidentified entries are removed outright rather than obscured.
+ */
+ public static void redactProduction(Map tileUnitData, Game game, Player viewer) {
+ for (WebTileUnitData tileData : tileUnitData.values()) {
+ tileData.production.keySet().removeIf(color -> !canSeeStatsByColor(game, viewer, color));
+ }
+ }
+
+ private static boolean canSeeStatsByColor(Game game, Player viewer, String color) {
+ Player player = game.getPlayerFromColorOrFaction(color);
+ return player == null || FoWHelper.canSeeStatsOfPlayer(game, player, viewer);
+ }
+
+ /**
+ * Redacts unit/token faction identity in place for players the viewer can't identify, the same
+ * way {@link #redactControlIdentities} does for control tokens/CC: the real faction key is
+ * replaced with "fow:<color>" so the frontend can still render the correct unit color
+ * without exposing which faction it belongs to (which would otherwise also leak faction-specific
+ * unit upgrades/tech via the unit tooltip).
+ */
+ public static void redactUnitIdentities(Map tileUnitData, Game game, Player viewer) {
+ for (WebTileUnitData tileData : tileUnitData.values()) {
+ tileData.space = obscureEntityFactionKeys(tileData.space, game, viewer);
+ for (WebTilePlanet planet : tileData.planets.values()) {
+ planet.setEntities(obscureEntityFactionKeys(planet.getEntities(), game, viewer));
+ }
+ }
+ }
+
+ private static Map> obscureEntityFactionKeys(
+ Map> entities, Game game, Player viewer) {
+ Map> result = new HashMap<>();
+ for (Map.Entry> entry : entities.entrySet()) {
+ String obscured = obscureIfHidden(game, viewer, entry.getKey());
+ result.computeIfAbsent(obscured, _ -> new ArrayList<>()).addAll(entry.getValue());
+ }
+ return result;
+ }
+
+ public static Map fromTileMap(Game game, Map tileMap) {
Map tileUnitData = new HashMap<>();
- for (Map.Entry entry : game.getTileMap().entrySet()) {
+ for (Map.Entry entry : tileMap.entrySet()) {
String position = entry.getKey();
Tile tile = entry.getValue();
@@ -70,6 +178,9 @@ private static WebTileUnitData extractTileUnitData(Game game, Tile tile) {
// Set anomaly status
tileData.isAnomaly = tile.isAnomaly(game, null);
+ // Hyperlane connection matrix, if applicable
+ tileData.hyperlaneMatrix = CustomHyperlaneService.getHyperlaneDataForTile(tile, game);
+
// Extract command tokens from space
UnitHolder spaceHolder = tile.getUnitHolders().get(Constants.SPACE);
if (spaceHolder != null) {
@@ -110,17 +221,24 @@ private static WebTileUnitData extractTileUnitData(Game game, Tile tile) {
}
planetData.setControlledBy(controllingFaction);
+ // Exhausted status is visible to everyone with vision of this tile regardless
+ // of faction identification (it doesn't reveal who controls the planet, only
+ // that a visible planet is exhausted), so it's set here unconditionally rather
+ // than sourced from the viewer-filtered per-player WebPlayerArea data.
+ Player controllingPlayer = game.getPlayerFromColorOrFaction(controllingFaction);
+ planetData.setExhausted(controllingPlayer != null
+ && controllingPlayer.getExhaustedPlanets().contains(holderName));
+
// Update metadata (commodities, shields)
updatePlanetMetadata(game, planet, planetData);
}
}
}
- // Calculate production and capacity for each player
+ // Calculate production for each player
for (Player player : game.getRealPlayers()) {
String color = player.getColor();
- // Calculate production value for this player in this tile
int productionValue = Helper.getProductionValue(player, game, tile, false);
if (productionValue > 0) {
tileData.production.put(color, productionValue);