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);