From e132ae6052316ec76160a8d56c24eba37b59b85a Mon Sep 17 00:00:00 2001 From: niugnip <1573579+niugnip@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:45:15 +0200 Subject: [PATCH 1/8] Send the hyperlane connection matrix in the web tile payload Custom hyperlane tiles ("hl") carry their connection layout in per-position data rather than in the tile artwork, so the web UI has no way to draw them without it. Exposes the matrix CustomHyperlaneService already computes for the Discord renderer. --- src/main/java/ti4/website/model/WebTileUnitData.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/ti4/website/model/WebTileUnitData.java b/src/main/java/ti4/website/model/WebTileUnitData.java index 3719da04ca..e87e1d6fd2 100644 --- a/src/main/java/ti4/website/model/WebTileUnitData.java +++ b/src/main/java/ti4/website/model/WebTileUnitData.java @@ -19,6 +19,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 +30,9 @@ 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; private WebTileUnitData() { space = new HashMap<>(); @@ -70,6 +74,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) { From d766ebb240e555d38477b71be85fd358e8a44ddb Mon Sep 17 00:00:00 2001 From: niugnip <1573579+niugnip@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:45:25 +0200 Subject: [PATCH 2/8] Add Fog of War support for the new web UI Serves a per-viewer filtered view of the game: tiles outside current vision are replaced with the viewer's remembered "ghost" snapshot, unidentified players' unit/control-token faction identity is obscured behind a "fow:" sentinel while keeping the real color, and player stats, score breakdowns, objective progress, elected laws and expedition completers are redacted for players the viewer can't identify. Also populates the per-planet exhausted flag for on-tile planets, which the web UI previously had to derive from each player's exhaustedPlanets list - unavailable for unidentified players. Co-Authored-By: Claude Sonnet 5 --- .../api/webdata/GameWebDataController.java | 75 ++++++++++ .../api/webdata/GameWebDataService.java | 131 +++++++++++++++++- .../spring/websocket/WebSocketNotifier.java | 7 +- src/main/java/ti4/website/model/WebLaw.java | 38 ++++- .../java/ti4/website/model/WebObjectives.java | 23 +++ .../ti4/website/model/WebScoreBreakdown.java | 21 +++ .../ti4/website/model/WebTilePositions.java | 6 +- .../ti4/website/model/WebTileUnitData.java | 120 +++++++++++++++- 8 files changed, 411 insertions(+), 10 deletions(-) diff --git a/src/main/java/ti4/spring/api/webdata/GameWebDataController.java b/src/main/java/ti4/spring/api/webdata/GameWebDataController.java index 83234c4865..f6a1e40073 100644 --- a/src/main/java/ti4/spring/api/webdata/GameWebDataController.java +++ b/src/main/java/ti4/spring/api/webdata/GameWebDataController.java @@ -2,13 +2,19 @@ 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.game.Game; +import ti4.game.Player; import ti4.spring.context.RequestContext; import ti4.spring.service.gameevent.GameEventDto; import ti4.spring.service.gameevent.GameEventService; @@ -34,6 +40,75 @@ 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"); + } + + boolean isGm = userId.equals(game.getOwnerID()) + || game.getPlayersWithGMRole().stream().anyMatch(p -> userId.equals(p.getUserID())); + + if (isGm) { + 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 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(); diff --git a/src/main/java/ti4/spring/api/webdata/GameWebDataService.java b/src/main/java/ti4/spring/api/webdata/GameWebDataService.java index 47017a15d9..b18a64c9ae 100644 --- a/src/main/java/ti4/spring/api/webdata/GameWebDataService.java +++ b/src/main/java/ti4/spring/api/webdata/GameWebDataService.java @@ -8,12 +8,17 @@ 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 +71,122 @@ 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, viewer); + applyLawRedaction(webData, game, viewer); + applyGhostTileMetadata(webData, viewer, visible); + return webData; + } + + 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, Player viewer) { + WebObjectives objectives = (WebObjectives) webData.get("objectives"); + WebObjectives.redactFactionProgress(objectives, 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); + } + + /** + * 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). Score breakdowns are kept + * for everyone but trimmed to SCORED entries only for hidden players - scored objectives/relics + * are visible on the physical board regardless of fog, unlike hand/resource stats. + */ + private static void applyStatRedaction(Map webData, Game game, Player viewer) { + List playerDataList = new ArrayList<>(); + Map scoreBreakdowns = new HashMap<>(); + for (Player player : game.getRealPlayersNNeutral()) { + boolean canSeeStats = FoWHelper.canSeeStatsOfPlayer(game, player, viewer); + if (canSeeStats) { + playerDataList.add(WebPlayerArea.fromPlayer(player, game)); + } + scoreBreakdowns.put( + player.getFaction(), + canSeeStats + ? WebScoreBreakdown.fromPlayer(player, game) + : WebScoreBreakdown.redacted(player, game)); + } + webData.put("playerData", playerDataList); + webData.put("scoreBreakdowns", scoreBreakdowns); + } + + 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 +196,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 +266,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..c472c330f2 100644 --- a/src/main/java/ti4/website/model/WebObjectives.java +++ b/src/main/java/ti4/website/model/WebObjectives.java @@ -15,6 +15,13 @@ import ti4.model.PublicObjectiveModel; import ti4.service.info.ListPlayerInfoService; +/* + * Note on FoW: {@code scoredFactions} intentionally stays untouched by redaction - whether a + * faction has scored an objective is public knowledge (visible via the scored token on the + * physical board). Numeric {@code factionProgress} is private (derived from hand/resources), so + * {@link #redactFactionProgress} strips it down to only the viewer's own entry. + */ + @Data public class WebObjectives { @@ -60,6 +67,22 @@ static class ObjectiveInfo { private List customObjectives; private List allObjectives; + /** + * Strips numeric factionProgress down to just {@code viewer}'s own entry. allObjectives holds + * the same ObjectiveInfo instances referenced by the stage1/stage2/custom lists, so mutating + * through it updates all of them. + */ + 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..cbf3bdb5e0 100644 --- a/src/main/java/ti4/website/model/WebScoreBreakdown.java +++ b/src/main/java/ti4/website/model/WebScoreBreakdown.java @@ -22,6 +22,9 @@ @Data public class WebScoreBreakdown { private List entries; + // Total VP is always public (visible on the physical score track regardless of fog), so it's + // sent for every player, including ones whose player area/entries are otherwise redacted. + private Integer totalVps; @Data public static class ScoreBreakdownEntry { @@ -109,6 +112,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); @@ -117,6 +121,23 @@ public static WebScoreBreakdown fromPlayer(Player player, Game game) { return breakdown; } + /** + * Minimal score breakdown for a viewer who can't see this player's stats. Scored entries are + * kept (public knowledge - visible via scored objective/relic tokens on the physical board); + * QUALIFIES/POTENTIAL/UNSCORED entries are dropped since they reveal hand/resource information + * that requires seeing the player's home system. Total VP stays visible - it's the score track + * position, public regardless of fog. + */ + public static WebScoreBreakdown redacted(Player player, Game game) { + WebScoreBreakdown full = fromPlayer(player, game); + WebScoreBreakdown breakdown = new WebScoreBreakdown(); + breakdown.totalVps = full.totalVps; + breakdown.entries = full.entries.stream() + .filter(entry -> entry.getState() == EntryState.SCORED) + .toList(); + return breakdown; + } + private static void addScoredEntries(Player player, Game game, List entries) { if (player == null || game == null || entries == null) { return; 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 e87e1d6fd2..279b227db0 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; @@ -33,6 +35,13 @@ public final class WebTileUnitData { // 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<>(); @@ -43,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 || player.isNeutral() || 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(); @@ -117,17 +221,25 @@ 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 - for (Player player : game.getRealPlayers()) { + // Calculate production for each player, including the neutral (Dicecord) player so e.g. a + // neutral space dock's production still shows up. + for (Player player : game.getRealPlayersNNeutral()) { 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); From 62c632bafa6d10ba27f76f922b5b9e266c7a7675 Mon Sep 17 00:00:00 2001 From: niugnip <1573579+niugnip@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:41:29 +0200 Subject: [PATCH 3/8] Let the GM see the event log in FoW games; trim comment wording /events previously 404'd unconditionally for any FoW game, with no viewer check at all - the GM couldn't see it either. Extracts the existing owner/GM-role check (already used by /web-data-fow) into a shared isGm helper and allows the GM/owner through. Also drops the "physical board" framing from a few FoW comments - FoW+ implements plenty that wouldn't exist on a physical board. Co-Authored-By: Claude Sonnet 5 --- .../api/webdata/GameWebDataController.java | 20 ++++++++++++++----- .../api/webdata/GameWebDataService.java | 2 +- .../java/ti4/website/model/WebObjectives.java | 4 ++-- .../ti4/website/model/WebScoreBreakdown.java | 8 ++++---- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/main/java/ti4/spring/api/webdata/GameWebDataController.java b/src/main/java/ti4/spring/api/webdata/GameWebDataController.java index f6a1e40073..fcb9dd62ab 100644 --- a/src/main/java/ti4/spring/api/webdata/GameWebDataController.java +++ b/src/main/java/ti4/spring/api/webdata/GameWebDataController.java @@ -59,10 +59,7 @@ public ResponseEntity getFow( .body("Authentication required to view Fog of War games"); } - boolean isGm = userId.equals(game.getOwnerID()) - || game.getPlayersWithGMRole().stream().anyMatch(p -> userId.equals(p.getUserID())); - - if (isGm) { + if (isGm(game, userId)) { if (asPlayer == null || asPlayer.isBlank()) { return withViewerIsGmHeader(gameWebDataService.getOrCompute(gameName), true); } @@ -93,6 +90,11 @@ private ResponseEntity withViewerIsGmHeader(String body, boolean 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) { @@ -121,6 +123,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(); @@ -128,7 +135,10 @@ public ResponseEntity> getEvents() { return ResponseEntity.notFound().build(); } if (game.isFowMode()) { - return ResponseEntity.notFound().build(); + String userId = getOptionalUserId(); + if (userId == null || !isGm(game, 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 b18a64c9ae..e80ecfbcb4 100644 --- a/src/main/java/ti4/spring/api/webdata/GameWebDataService.java +++ b/src/main/java/ti4/spring/api/webdata/GameWebDataService.java @@ -133,7 +133,7 @@ private static void applyControlIdentityRedaction(Map webData, G * 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). Score breakdowns are kept * for everyone but trimmed to SCORED entries only for hidden players - scored objectives/relics - * are visible on the physical board regardless of fog, unlike hand/resource stats. + * stay visible regardless of fog, unlike hand/resource stats. */ private static void applyStatRedaction(Map webData, Game game, Player viewer) { List playerDataList = new ArrayList<>(); diff --git a/src/main/java/ti4/website/model/WebObjectives.java b/src/main/java/ti4/website/model/WebObjectives.java index c472c330f2..07533c555e 100644 --- a/src/main/java/ti4/website/model/WebObjectives.java +++ b/src/main/java/ti4/website/model/WebObjectives.java @@ -17,8 +17,8 @@ /* * Note on FoW: {@code scoredFactions} intentionally stays untouched by redaction - whether a - * faction has scored an objective is public knowledge (visible via the scored token on the - * physical board). Numeric {@code factionProgress} is private (derived from hand/resources), so + * faction has scored an objective is public knowledge (visible via the scored token). Numeric + * {@code factionProgress} is private (derived from hand/resources), so * {@link #redactFactionProgress} strips it down to only the viewer's own entry. */ diff --git a/src/main/java/ti4/website/model/WebScoreBreakdown.java b/src/main/java/ti4/website/model/WebScoreBreakdown.java index cbf3bdb5e0..16aa1b6aaf 100644 --- a/src/main/java/ti4/website/model/WebScoreBreakdown.java +++ b/src/main/java/ti4/website/model/WebScoreBreakdown.java @@ -123,10 +123,10 @@ public static WebScoreBreakdown fromPlayer(Player player, Game game) { /** * Minimal score breakdown for a viewer who can't see this player's stats. Scored entries are - * kept (public knowledge - visible via scored objective/relic tokens on the physical board); - * QUALIFIES/POTENTIAL/UNSCORED entries are dropped since they reveal hand/resource information - * that requires seeing the player's home system. Total VP stays visible - it's the score track - * position, public regardless of fog. + * kept (public knowledge - visible via scored objective/relic tokens); QUALIFIES/POTENTIAL/ + * UNSCORED entries are dropped since they reveal hand/resource information that requires + * seeing the player's home system. Total VP stays visible - it's the score track position, + * public regardless of fog. */ public static WebScoreBreakdown redacted(Player player, Game game) { WebScoreBreakdown full = fromPlayer(player, game); From 463ecc29b70ac6e765845e90fefb86bd97bf0549 Mon Sep 17 00:00:00 2001 From: niugnip <1573579+niugnip@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:39:52 +0200 Subject: [PATCH 4/8] Stop treating the neutral player as a hidden FoW opponent The neutral (Dicecord) player isn't a "real player" per FoWHelper#canSeeStatsOfPlayer, so it was falling through every identity check as if it were a hidden opponent - excluded from FoW playerData entirely, and its units/control tokens obscured with the "fow:" sentinel. Neutral forces are public knowledge, not a hidden opponent, so those checks now special-case it through. --- .../java/ti4/spring/api/webdata/GameWebDataService.java | 6 +++++- src/main/java/ti4/website/model/WebTileUnitData.java | 7 +++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/ti4/spring/api/webdata/GameWebDataService.java b/src/main/java/ti4/spring/api/webdata/GameWebDataService.java index e80ecfbcb4..7e7d657bb9 100644 --- a/src/main/java/ti4/spring/api/webdata/GameWebDataService.java +++ b/src/main/java/ti4/spring/api/webdata/GameWebDataService.java @@ -127,6 +127,7 @@ private static void applyControlIdentityRedaction(Map webData, G Map tileUnitData = (Map) webData.get("tileUnitData"); WebTileUnitData.redactControlIdentities(tileUnitData, game, viewer); WebTileUnitData.redactUnitIdentities(tileUnitData, game, viewer); + WebTileUnitData.redactProduction(tileUnitData, game, viewer); } /** @@ -139,7 +140,10 @@ private static void applyStatRedaction(Map webData, Game game, P List playerDataList = new ArrayList<>(); Map scoreBreakdowns = new HashMap<>(); for (Player player : game.getRealPlayersNNeutral()) { - boolean canSeeStats = FoWHelper.canSeeStatsOfPlayer(game, player, viewer); + // 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)); } diff --git a/src/main/java/ti4/website/model/WebTileUnitData.java b/src/main/java/ti4/website/model/WebTileUnitData.java index 279b227db0..e5d3e0c387 100644 --- a/src/main/java/ti4/website/model/WebTileUnitData.java +++ b/src/main/java/ti4/website/model/WebTileUnitData.java @@ -118,7 +118,7 @@ public static void redactProduction(Map tileUnitData, G private static boolean canSeeStatsByColor(Game game, Player viewer, String color) { Player player = game.getPlayerFromColorOrFaction(color); - return player == null || player.isNeutral() || FoWHelper.canSeeStatsOfPlayer(game, player, viewer); + return player == null || FoWHelper.canSeeStatsOfPlayer(game, player, viewer); } /** @@ -235,9 +235,8 @@ private static WebTileUnitData extractTileUnitData(Game game, Tile tile) { } } - // Calculate production for each player, including the neutral (Dicecord) player so e.g. a - // neutral space dock's production still shows up. - for (Player player : game.getRealPlayersNNeutral()) { + // Calculate production for each player + for (Player player : game.getRealPlayers()) { String color = player.getColor(); int productionValue = Helper.getProductionValue(player, game, tile, false); From ca1d78799dc541f9d9c52997b3f48dbd13ca5862 Mon Sep 17 00:00:00 2001 From: niugnip <1573579+niugnip@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:19:42 +0200 Subject: [PATCH 5/8] Add niugnip's server roles Registers the admin/developer/bothelper role IDs for niugnip's server, following the existing per-server pattern in JdaService. --- src/main/java/ti4/discord/JdaService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/ti4/discord/JdaService.java b/src/main/java/ti4/discord/JdaService.java index 2984a739d0..6f1c73fc54 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); @@ -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); } From e0a4be5dca97160786ce873aec215f72e93a9513 Mon Sep 17 00:00:00 2001 From: niugnip <1573579+niugnip@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:34:29 +0200 Subject: [PATCH 6/8] Restrict the FoW web UI to FoW server staff in production Temporary rollout gate while the new Fog of War web UI is tested: in production, the FoW web-data and event-log endpoints require one of the FoW server's staff roles (server, Game Supervisor, and the Chapter 2 bothelper/supervisor equivalents). GMs are not exempt - most hold one of these already, and exempting them would leave nearly every FoW game reachable, which defeats the point. Scoped to that server's own roles rather than reusing bothelperRoles, which pulls in every Async and community server plus all admins and developers - far wider than this gate is meant to be. Only applies when JdaService#isProduction(), so local and dev bots are unaffected and need no role. Fails closed: unresolvable roles are skipped and access is denied rather than treating "couldn't check" as a pass. Adds JdaService#hasAnyRole(userId, roleIds...), since the existing role checks need an interaction event and the web API only has a Discord user ID. It reads JDA's member cache (populated eagerly via MemberCachePolicy.ALL / ChunkingFilter.ALL), so it stays a synchronous lookup rather than a REST call on the request thread. Also relabels role 1063464689218105354 from "FoW Server Game Admin" to "FoW Server Game Supervisor", matching what the role is actually called now. Revert this commit to open the UI up. --- src/main/java/ti4/discord/JdaService.java | 28 ++++++++++++++++- .../api/webdata/GameWebDataController.java | 30 ++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/main/java/ti4/discord/JdaService.java b/src/main/java/ti4/discord/JdaService.java index 6f1c73fc54..98ecc5a041 100644 --- a/src/main/java/ti4/discord/JdaService.java +++ b/src/main/java/ti4/discord/JdaService.java @@ -557,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 @@ -625,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 fcb9dd62ab..c6bc68c6dc 100644 --- a/src/main/java/ti4/spring/api/webdata/GameWebDataController.java +++ b/src/main/java/ti4/spring/api/webdata/GameWebDataController.java @@ -13,6 +13,7 @@ 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; @@ -28,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(); @@ -59,6 +83,10 @@ public ResponseEntity getFow( .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); @@ -136,7 +164,7 @@ public ResponseEntity> getEvents() { } if (game.isFowMode()) { String userId = getOptionalUserId(); - if (userId == null || !isGm(game, userId)) { + if (userId == null || !isGm(game, userId) || !canUseFowWebUi(userId)) { return ResponseEntity.notFound().build(); } } From 6a3ccb9b97caae91e4dd94cb724f4daef1894937 Mon Sep 17 00:00:00 2001 From: niugnip <1573579+niugnip@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:46:15 +0200 Subject: [PATCH 7/8] Withhold hidden players' identity from the FoW payload Under fog you learn that someone scored an objective, not who - but the payload was sending every scorer's real faction and leaving it to the client to render the unidentifiable ones as anonymous tokens. The answer was sitting in the JSON the whole time. Auditing the rest of the payload for the same shape turned up three more fields with no redaction at all. - objectives: unidentifiable scorers are removed from scoredFactions and replaced by a bare unidentifiedScorerCount; peekingFactions is filtered the same way. - scoreBreakdowns: hidden players are left out entirely rather than carrying a faction-keyed, SCORED-only breakdown that spelled out their whole scoring history. - hiddenPlayerVps: their score-track totals instead travel as a sorted list of bare numbers - enough to place an anonymous token on the track, with nothing tying it to a faction. - strategyCards: pickedByFaction is cleared unless the viewer can see that player's stats, and an unplayed card no longer reports that it was picked at all. Playing a card is announced, so played (and the pick it implies) stays visible. - statTilePositions: entries are dropped for players whose stats the viewer can't see. The tiles sit beside a home system, so a faction-keyed map gave away both who was in the game and roughly where. With no faction strings reaching the client there is nothing to correlate across objective cards, so the per-objective shuffle that existed to frustrate that correlation is gone too. Verified against a running bot with a player forced unidentifiable: their faction no longer appears anywhere in the fogged payload. --- .../api/webdata/GameWebDataService.java | 41 ++++++++++++++----- .../java/ti4/website/model/WebObjectives.java | 35 ++++++++++++++-- .../ti4/website/model/WebScoreBreakdown.java | 22 ++-------- .../website/model/WebStatTilePositions.java | 14 +++++++ .../ti4/website/model/WebStrategyCard.java | 29 +++++++++++++ 5 files changed, 108 insertions(+), 33 deletions(-) diff --git a/src/main/java/ti4/spring/api/webdata/GameWebDataService.java b/src/main/java/ti4/spring/api/webdata/GameWebDataService.java index 7e7d657bb9..6c262283b8 100644 --- a/src/main/java/ti4/spring/api/webdata/GameWebDataService.java +++ b/src/main/java/ti4/spring/api/webdata/GameWebDataService.java @@ -4,6 +4,7 @@ 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; @@ -94,12 +95,26 @@ public static Map buildFilteredWebData(Game game, Player viewer) applyPlayerNameRedaction(webData, game); applyExploreDeckRedaction(webData, game); applyControlIdentityRedaction(webData, game, viewer); - applyObjectiveProgressRedaction(webData, 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"); @@ -117,9 +132,10 @@ private static void applyGhostTileMetadata(Map webData, Player v WebTileUnitData.markGhostTiles(tileUnitData, visible, viewer); } - private static void applyObjectiveProgressRedaction(Map webData, Player 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) { @@ -132,13 +148,18 @@ private static void applyControlIdentityRedaction(Map webData, G /** * 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). Score breakdowns are kept - * for everyone but trimmed to SCORED entries only for hidden players - scored objectives/relics - * stay visible regardless of fog, unlike hand/resource stats. + * 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 @@ -146,15 +167,15 @@ private static void applyStatRedaction(Map webData, Game game, P 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()); } - scoreBreakdowns.put( - player.getFaction(), - canSeeStats - ? WebScoreBreakdown.fromPlayer(player, game) - : WebScoreBreakdown.redacted(player, game)); } + Collections.sort(hiddenPlayerVps); webData.put("playerData", playerDataList); webData.put("scoreBreakdowns", scoreBreakdowns); + webData.put("hiddenPlayerVps", hiddenPlayerVps); } private static void applyPlayerNameRedaction(Map webData, Game game) { diff --git a/src/main/java/ti4/website/model/WebObjectives.java b/src/main/java/ti4/website/model/WebObjectives.java index 07533c555e..e6e5004cd9 100644 --- a/src/main/java/ti4/website/model/WebObjectives.java +++ b/src/main/java/ti4/website/model/WebObjectives.java @@ -11,15 +11,18 @@ 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: {@code scoredFactions} intentionally stays untouched by redaction - whether a - * faction has scored an objective is public knowledge (visible via the scored token). Numeric - * {@code factionProgress} is private (derived from hand/resources), so - * {@link #redactFactionProgress} strips it down to only the viewer's own entry. + * 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 @@ -35,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; @@ -72,6 +78,27 @@ static class ObjectiveInfo { * the same ObjectiveInfo instances referenced by the stage1/stage2/custom lists, so mutating * through 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. + return player == null || player.isNeutral() || FoWHelper.canSeeStatsOfPlayer(game, player, viewer); + } + public static void redactFactionProgress(WebObjectives objectives, Player viewer) { String ownFaction = viewer.getFaction(); for (ObjectiveInfo info : objectives.allObjectives) { diff --git a/src/main/java/ti4/website/model/WebScoreBreakdown.java b/src/main/java/ti4/website/model/WebScoreBreakdown.java index 16aa1b6aaf..9ffd78b2ef 100644 --- a/src/main/java/ti4/website/model/WebScoreBreakdown.java +++ b/src/main/java/ti4/website/model/WebScoreBreakdown.java @@ -22,8 +22,9 @@ @Data public class WebScoreBreakdown { private List entries; - // Total VP is always public (visible on the physical score track regardless of fog), so it's - // sent for every player, including ones whose player area/entries are otherwise redacted. + // 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 @@ -121,23 +122,6 @@ public static WebScoreBreakdown fromPlayer(Player player, Game game) { return breakdown; } - /** - * Minimal score breakdown for a viewer who can't see this player's stats. Scored entries are - * kept (public knowledge - visible via scored objective/relic tokens); QUALIFIES/POTENTIAL/ - * UNSCORED entries are dropped since they reveal hand/resource information that requires - * seeing the player's home system. Total VP stays visible - it's the score track position, - * public regardless of fog. - */ - public static WebScoreBreakdown redacted(Player player, Game game) { - WebScoreBreakdown full = fromPlayer(player, game); - WebScoreBreakdown breakdown = new WebScoreBreakdown(); - breakdown.totalVps = full.totalVps; - breakdown.entries = full.entries.stream() - .filter(entry -> entry.getState() == EntryState.SCORED) - .toList(); - return breakdown; - } - private static void addScoredEntries(Player player, Game game, List entries) { if (player == null || game == null || entries == null) { return; diff --git a/src/main/java/ti4/website/model/WebStatTilePositions.java b/src/main/java/ti4/website/model/WebStatTilePositions.java index 339a7cbdae..8e3a7fca89 100644 --- a/src/main/java/ti4/website/model/WebStatTilePositions.java +++ b/src/main/java/ti4/website/model/WebStatTilePositions.java @@ -8,12 +8,26 @@ 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) { + 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(); From bcb54ffc4c4dfbe7d67493f3386d0d30ad1e7770 Mon Sep 17 00:00:00 2001 From: niugnip <1573579+niugnip@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:04:34 +0200 Subject: [PATCH 8/8] Fail closed on unresolvable factions in the FoW redaction Review follow-up: canIdentify and the stat-tile redaction kept a faction in the payload when it could not be resolved to a player, while redactPickers redacted the same case. A redaction layer should treat "couldn't resolve" like "couldn't check" in the rollout gate: deny rather than pass. All three now fail closed. Also reattaches the redactFactionProgress javadoc, which the redactScorers insertion had left stacked on the wrong method. Co-Authored-By: Claude Fable 5 --- src/main/java/ti4/website/model/WebObjectives.java | 12 ++++++------ .../java/ti4/website/model/WebStatTilePositions.java | 3 ++- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/main/java/ti4/website/model/WebObjectives.java b/src/main/java/ti4/website/model/WebObjectives.java index e6e5004cd9..e498cc6d53 100644 --- a/src/main/java/ti4/website/model/WebObjectives.java +++ b/src/main/java/ti4/website/model/WebObjectives.java @@ -73,11 +73,9 @@ static class ObjectiveInfo { private List customObjectives; private List allObjectives; - /** - * Strips numeric factionProgress down to just {@code viewer}'s own entry. allObjectives holds - * the same ObjectiveInfo instances referenced by the stage1/stage2/custom lists, so mutating - * through it updates all of them. - */ + // 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 @@ -96,9 +94,11 @@ public static void redactScorers(WebObjectives objectives, Game game, Player vie 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. - return player == null || player.isNeutral() || FoWHelper.canSeeStatsOfPlayer(game, player, viewer); + // 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) { diff --git a/src/main/java/ti4/website/model/WebStatTilePositions.java b/src/main/java/ti4/website/model/WebStatTilePositions.java index 8e3a7fca89..8c9b4cc8de 100644 --- a/src/main/java/ti4/website/model/WebStatTilePositions.java +++ b/src/main/java/ti4/website/model/WebStatTilePositions.java @@ -22,9 +22,10 @@ public class WebStatTilePositions { * 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); + return player == null || !FoWHelper.canSeeStatsOfPlayer(game, player, viewer); }); }