Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package ti4.discord.interactions.commands.fow;

import java.util.List;
import java.util.stream.Collectors;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import ti4.discord.interactions.commands.CommandHelper;
import ti4.discord.interactions.commands.GameStateSubcommand;
import ti4.game.Game;
import ti4.game.Player;
import ti4.game.Tile;
import ti4.helpers.Constants;
import ti4.helpers.Helper;
import ti4.image.PositionMapper;
import ti4.message.MessageHelper;

class AddVisionToken extends GameStateSubcommand {

public AddVisionToken() {
super(
Constants.ADD_VISION_TOKEN,
"Place a fog-vision token that reveals a system to some or all players.",
true,
true);
addOptions(new OptionData(
OptionType.STRING, Constants.POSITION, "Tile position(s) on map, comma separated", true));
addOptions(new OptionData(
OptionType.STRING,
Constants.TARGET_FACTION_OR_COLOR,
"Faction/Color(s) to reveal to - leave empty to reveal to everyone")
.setAutoComplete(true));
}

@Override
public void execute(SlashCommandInteractionEvent event) {
Game game = getGame();
List<String> positions =
Helper.getListFromCSV(event.getOption(Constants.POSITION).getAsString());

// Empty target list => reveal to everyone (stored as a blank grant, read as ALL by FoWHelper).
List<Player> targets = CommandHelper.getTargetPlayersFromOption(game, event);
String grant = targets.stream().map(Player::getColor).collect(Collectors.joining(","));
String targetDesc = targets.isEmpty()
? "everyone"
: targets.stream().map(Player::getColor).collect(Collectors.joining(", "));

StringBuilder sb = new StringBuilder();
for (String position : positions) {
if (!PositionMapper.isTilePositionValid(position)) {
MessageHelper.replyToMessage(event, "Tile position '" + position + "' is invalid");
continue;
}
Tile tile = game.getTileByPosition(position);
if (tile == null) {
MessageHelper.replyToMessage(event, "No tile found at position '" + position + "'");
continue;
}
tile.addToken(Constants.TOKEN_FOWVISION_PNG, Constants.SPACE);
game.setStoredValue(Constants.FOW_VISION_GRANT_PREFIX + position, grant);
sb.append("Placed a fog-vision token on ")
.append(position)
.append(" - revealed to ")
.append(targetDesc)
.append('\n');
}
if (!sb.isEmpty()) {
MessageHelper.sendMessageToChannel(event.getChannel(), sb.toString());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public class FOWCommand implements ParentCommand {
private final Map<String, Subcommand> subcommands = Stream.of(
new AddFogTile(),
new RemoveFogTile(),
new AddVisionToken(),
new RemoveVisionToken(),
new CheckChannels(),
new PingActivePlayer(),
new PingSystem(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package ti4.discord.interactions.commands.fow;

import java.util.List;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import ti4.discord.interactions.commands.GameStateSubcommand;
import ti4.game.Game;
import ti4.game.Tile;
import ti4.helpers.Constants;
import ti4.helpers.Helper;
import ti4.image.PositionMapper;
import ti4.message.MessageHelper;

class RemoveVisionToken extends GameStateSubcommand {

public RemoveVisionToken() {
super(Constants.REMOVE_VISION_TOKEN, "Remove a fog-vision token from a system.", true, true);
addOptions(new OptionData(
OptionType.STRING, Constants.POSITION, "Tile position(s) on map, comma separated", true));
}

@Override
public void execute(SlashCommandInteractionEvent event) {
Game game = getGame();
List<String> positions =
Helper.getListFromCSV(event.getOption(Constants.POSITION).getAsString());

StringBuilder sb = new StringBuilder();
for (String position : positions) {
if (!PositionMapper.isTilePositionValid(position)) {
MessageHelper.replyToMessage(event, "Tile position '" + position + "' is invalid");
continue;
}
Tile tile = game.getTileByPosition(position);
if (tile == null) {
MessageHelper.replyToMessage(event, "No tile found at position '" + position + "'");
continue;
}
boolean removed = tile.removeToken(Constants.TOKEN_FOWVISION_PNG, Constants.SPACE);
game.removeStoredValue(Constants.FOW_VISION_GRANT_PREFIX + position);
sb.append(removed ? "Removed the fog-vision token from " : "No fog-vision token was on ")
.append(position)
.append('\n');
}
if (!sb.isEmpty()) {
MessageHelper.sendMessageToChannel(event.getChannel(), sb.toString());
}
}
}
19 changes: 19 additions & 0 deletions src/main/java/ti4/game/Tile.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import ti4.logging.LogOrigin;
import ti4.model.TileModel;
import ti4.model.TileModel.TileBack;
import ti4.model.TokenModel;
import ti4.model.UnitModel;
import ti4.model.WormholeModel;
import ti4.service.emoji.TI4Emoji;
Expand Down Expand Up @@ -677,6 +678,24 @@ public boolean hasCabalSpaceDockOrGravRiftToken(Game game) {
return false;
}

/**
* True if this tile carries a fog-vision token (a token whose {@link TokenModel} has
* {@code isFowVision}). Deliberately independent of {@link #isAnomaly()} — a fog-vision
* marker is not an anomaly and must not gain anomaly movement/combat/ability behaviour.
*/
@JsonIgnore
public boolean hasFowVisionToken() {
for (UnitHolder uh : unitHolders.values()) {
for (String token : uh.getTokenList()) {
TokenModel model = Mapper.getToken(token);
if (model != null && Boolean.TRUE.equals(model.getIsFowVision())) {
return true;
}
}
}
return false;
}

@JsonIgnore
public boolean isAnomaly() {
return isAnomaly(null, null);
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/ti4/helpers/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,10 @@ public static String privateStaticVoidPing() {
public static final String SEARCH_WARRANT = "search_warrant";
public static final String ADD_CUSTOM_ADJACENT_TILES = "add_custom_adjacent_tiles";
public static final String ADD_FOG_TILE = "add_fog_tile";
public static final String ADD_VISION_TOKEN = "add_vision_token";
public static final String REMOVE_VISION_TOKEN = "remove_vision_token";
public static final String TOKEN_FOWVISION_PNG = "token_fowvision.png";
public static final String FOW_VISION_GRANT_PREFIX = "fowVisionGrant";
public static final String ADD_ADJACENCY_OVERRIDE = "add_adjacency_override";
public static final String REMOVE_ADJACENCY_OVERRIDE = "remove_adjacency_override";
public static final String REMOVE_ALL_ADJACENCY_OVERRIDES = "remove_all_adjacency_overrides";
Expand Down
26 changes: 26 additions & 0 deletions src/main/java/ti4/helpers/FoWHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ private static void initializeFog(Game game, @NotNull Player player, boolean for
tilePositionsToShow.addAll(adjacentTiles);
}

addFowVisionTiles(game, player, tilePositionsToShow);

String playerSweep = Mapper.getSweepID(player.getColor());
for (Tile tile : game.getTileMap().values()) {
if (tile.hasCC(playerSweep)) {
Expand Down Expand Up @@ -184,9 +186,33 @@ public static Set<String> getTilePositionsToShow(Game game, @NotNull Player play
tilePositionsToShow.add(tile.getPosition());
}
}

addFowVisionTiles(game, player, tilePositionsToShow);
return tilePositionsToShow;
}

/**
* Fog-vision tokens/tiles reveal a system independent of unit presence. A tile is added to the
* player's visible set if it is an intrinsic fog-vision tile (revealed to everyone) or carries a
* fog-vision token whose recipient list (stored per position; blank = everyone) includes the
* player. Only the tile's own position is added — this grants no adjacency or movement.
*/
private static void addFowVisionTiles(Game game, @NotNull Player player, Set<String> tilePositionsToShow) {
for (Tile tile : game.getTileMap().values()) {
String pos = tile.getPosition();
if (tile.getTileModel().isFowVision()) {
tilePositionsToShow.add(pos); // intrinsic vision tile: everyone sees it
continue;
}
if (!tile.hasFowVisionToken()) continue; // token presence is the master gate
String grant = game.getStoredValue(Constants.FOW_VISION_GRANT_PREFIX + pos);
boolean all = grant == null || grant.isBlank();
if (all || List.of(grant.split(",")).contains(player.getColor())) {
tilePositionsToShow.add(pos);
}
}
}

public static void updateFog(Game game, Player player) {
if (player != null) initializeFog(game, player, true);
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/ti4/model/TileModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public String toValue() {
private @JsonProperty("isNebula") boolean nebula;
private @JsonProperty("isGravityRift") boolean gravityRift;
private @JsonProperty("isScar") boolean isScar;
private @JsonProperty("isFowVision") boolean fowVision;
private String imageURL;
private ComponentSource source;
private TileBack tileBack = TileBack.BLACK;
Expand Down Expand Up @@ -186,6 +187,11 @@ public boolean isScar() {
return isScar;
}

@JsonIgnore
public boolean isFowVision() {
return fowVision;
}

@JsonIgnore
public boolean isAnomaly() {
return asteroidField || gravityRift || nebula || supernova || isScar;
Expand Down
1 change: 1 addition & 0 deletions src/main/java/ti4/model/TokenModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public class TokenModel implements ModelInterface, EmbeddableModel {
private Boolean isNova;

private Boolean isScar;
private Boolean isFowVision;
private List<String> aliasList;
private List<Wormhole> wormholes;
private ComponentSource source;
Expand Down
23 changes: 19 additions & 4 deletions src/main/java/ti4/service/fow/LoreEffects.java
Original file line number Diff line number Diff line change
Expand Up @@ -692,11 +692,27 @@ private static EffectDescription effectAc(EffectContext ctx) {
return new EffectDescription(who(ctx) + " drew " + StringHelper.pluralize(n, "action card") + ".", false);
}

/**
* Resolves a token/attachment name to its canonical stored id, in the same priority order as
* the {@code /add_token} and {@code /remove_token} slash commands: attachments (e.g.
* "positiveres" -> "attachment_positiveres.png") are tried before generic tokens (wormholes,
* DMZ, etc.), falling back to the raw input for anything the bot doesn't recognize.
* {@code Mapper.getTokenID} alone only knows generic tokens — an attachment name resolved
* that way stores the bare model id instead of its image filename. The stat modifier still
* applies either way ({@code Mapper.getAttachmentInfo} accepts both forms), but the bare id
* never resolves to an image file, so the token silently never renders on the map.
*/
private static String resolveTokenOrAttachment(String rawId) {
String attachmentId = Mapper.getAttachmentImagePath(AliasHandler.resolveAttachment(rawId));
if (attachmentId != null) return attachmentId;
String tokenId = Mapper.getTokenID(AliasHandler.resolveToken(rawId));
return tokenId != null ? tokenId : rawId;
}

// token <tokenId> -> drops a token on the target holder (name resolved via Mapper, e.g. "gravityrift")
private static EffectDescription effectToken(EffectContext ctx) {
if (ctx.tile == null || ctx.args.length == 0) return null;
String tokenId = Mapper.getTokenID(ctx.arg(0));
if (tokenId == null) tokenId = ctx.arg(0);
String tokenId = resolveTokenOrAttachment(ctx.arg(0));
ctx.tile.addToken(tokenId, ctx.holder);
String readableName = tokenId.replaceFirst("^token_", "").replaceFirst("\\.png$", "");
String location = ctx.tile.getPosition() + (Constants.SPACE.equals(ctx.holder) ? "" : " (" + ctx.holder + ")");
Expand All @@ -707,8 +723,7 @@ private static EffectDescription effectToken(EffectContext ctx) {
// removetoken <tokenId> [planet] -> removes a token or planet attachment from the target holder
private static EffectDescription effectRemoveToken(EffectContext ctx) {
if (ctx.tile == null || ctx.args.length == 0) return null;
String tokenId = Mapper.getTokenID(ctx.arg(0));
if (tokenId == null) tokenId = ctx.arg(0);
String tokenId = resolveTokenOrAttachment(ctx.arg(0));

String holder = ctx.holder;
if (ctx.args.length >= 2) {
Expand Down
1 change: 1 addition & 0 deletions src/main/resources/data/tokens.properties
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ custodianvp=token_custodianvp.png
custc1=token_custc1.png
custvpc1=token_custvpc1.png
dmz_large=token_dmz_large.png
fowvision=token_fowvision.png
frontier=token_frontier.png
gamma=token_gamma.png
gravityrift=token_gravityrift.png
Expand Down
12 changes: 12 additions & 0 deletions src/main/resources/data/tokens/other.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@
"imagePath": "token_big_obelisk.png",
"source": "other"
},
{
"id": "fowvision",
"imagePath": "token_fowvision.png",
"spaceOrPlanet": "space",
"isFowVision": true,
"aliasList": [
"fowvision",
"token_fowvision",
"vision"
],
"source": "other"
},
{
"id": "consulate",
"imagePath": "token_consulate.png",
Expand Down
Binary file added src/main/resources/tokens/token_fowvision.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions src/test/java/ti4/service/fow/LoreServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,20 @@ void atLocationOverrideRedirectsToken() {
.getTokenList()
.contains("token_gravityrift.png"));
}

@Test
void attachmentNameResolvesToImageFilenameNotBareModelId() {
// Regression: "positiveres" is an attachment id (Mapper.getAttachmentInfo), not a
// generic token — Mapper.getTokenID alone can't resolve it, so it used to be stored as
// the bare, un-renderable "positiveres" string. The resource-modifier stat still
// applied either way (getAttachmentInfo accepts both the bare id and the image
// filename), which is exactly why this was invisible until the token silently never
// appeared on the rendered map image.
LoreEffects.applyLoreEffectsForTest(player, game, entry("!token positiveres"), systemTile, "mr", true);
var tokens = systemTile.getUnitHolders().get("mr").getTokenList();
assertTrue(tokens.contains("attachment_positiveres.png"), "resolved image filename must be stored");
assertFalse(tokens.contains("positiveres"), "bare attachment id must not be stored");
}
}

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -628,6 +642,17 @@ void missingTokenReportsNothingRemoved() {
assertEquals(1, descs.size());
assertTrue(descs.get(0).contains("nothing removed"));
}

@Test
void removesAttachmentAddedByShortName() {
// Add and remove must resolve "positiveres" to the same stored id (attachment
// resolution takes priority over generic-token resolution on both sides) or removal
// silently no-ops against a token that was actually stored under a different string.
LoreEffects.applyLoreEffectsForTest(player, game, entry("!token positiveres"), systemTile, "mr", true);
LoreEffects.applyLoreEffectsForTest(
player, game, entry("!removetoken positiveres"), systemTile, "mr", true);
assertFalse(systemTile.getUnitHolders().get("mr").getTokenList().contains("attachment_positiveres.png"));
}
}

// -----------------------------------------------------------------------
Expand Down