Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
d128215
Add local sample game bootstrap service
Copilot Apr 15, 2026
80aa191
Format local sample game bootstrap
Copilot Apr 15, 2026
f64ec7c
Refine local sample game bootstrap
Copilot Apr 15, 2026
f01db7c
Fix local startup validation edge cases
Copilot Apr 15, 2026
9752e72
Harden local sample channel naming
Copilot Apr 15, 2026
be26e67
Add recreate game and local dev commands
Copilot Apr 15, 2026
ea4f6ab
Format recreate game and local dev workflow
Copilot Apr 15, 2026
48ccc1f
Tighten local dev source path resolution
Copilot Apr 15, 2026
b6a5efa
Relax local dev clean confirmation casing
Copilot Apr 15, 2026
ee8eb94
Merge master package moves into recreate workflow
Copilot Apr 15, 2026
73f5adb
Address package-move review followups
Copilot Apr 15, 2026
845c3fa
Harden recreate and local dev safety checks
Copilot Apr 15, 2026
4dea2b7
Resolve merge conflict with master
Copilot Apr 15, 2026
08c76a0
Merge remote-tracking branch 'origin/master' into copilot/add-local-m…
Copilot Apr 15, 2026
6f1abb2
F
wholton Apr 16, 2026
2e719d3
F
wholton Apr 16, 2026
e6938a9
Update recreate guild selection and local dev clone flow
Copilot Apr 16, 2026
c55e559
Address recreate flow review feedback
Copilot Apr 16, 2026
6abb0fd
Polish recreate review fixes
Copilot Apr 16, 2026
8c73ab9
Finalize recreate cleanup
Copilot Apr 16, 2026
f998407
Resolve final recreate review nits
Copilot Apr 16, 2026
179ef3b
Clarify capacity selection and test mocks
Copilot Apr 16, 2026
649dd93
Tidy recreate docs and test setup
Copilot Apr 16, 2026
dce08e3
Update GameManager.java
wholton Apr 16, 2026
763dd5c
Update GameLoadService.java
wholton Apr 16, 2026
a876cbf
Apply suggestion from @Copilot
wholton Apr 16, 2026
9209027
Require opt-in for local dev startup
Copilot Apr 16, 2026
64e553a
Use game guild for recreate game
Copilot Apr 16, 2026
7aed034
Add recreate game fallback coverage
Copilot Apr 16, 2026
59b7841
Remove args-based local dev startup gating
Copilot Apr 16, 2026
d0323e0
Format local dev env flag cleanup
Copilot Apr 16, 2026
3eb4f64
F
wholton Apr 16, 2026
2e469e3
Wire recreate-game into command save flow
Copilot Apr 16, 2026
ef902be
Return recreate-game summary directly
Copilot Apr 16, 2026
0e3d857
Format recreate-game summary refactor
Copilot Apr 16, 2026
4f50d4c
Document recreate-game result helper
Copilot Apr 16, 2026
313fc6e
Delete RecreateGameTest.java
wholton Apr 16, 2026
6285a15
Switch local-dev action handling
Copilot Apr 16, 2026
237d64c
Handle unknown local-dev action
Copilot Apr 16, 2026
b903991
Add explicit return to local-dev create branch
Copilot Apr 16, 2026
f48147c
Add explicit return to local-dev default branch
Copilot Apr 16, 2026
09444bb
Merge remote-tracking branch 'origin/master' into copilot/add-local-m…
wholton Apr 18, 2026
24bf5e9
Switch local test games to numeric suffix names
Copilot Apr 18, 2026
4d72d63
Format updated local test naming tests
Copilot Apr 18, 2026
1f1a08c
Tighten local test name regex
Copilot Apr 18, 2026
c0e0dd2
Simplify local test suffix naming
Copilot Apr 18, 2026
40e7f1b
Format simplified local test naming tests
Copilot Apr 18, 2026
012a6c0
Merge remote-tracking branch 'origin/master' into copilot/add-local-m…
wholton Apr 19, 2026
42516d3
Implement local dev source file recreation
Copilot Apr 19, 2026
35f964c
Format local dev source file changes
Copilot Apr 19, 2026
4d21c14
Address local dev recreation review feedback
Copilot Apr 19, 2026
9627037
Harden temp local dev source handling
Copilot Apr 19, 2026
677a33b
Clarify local dev source file handling
Copilot Apr 19, 2026
97170b5
Format local dev source path logging
Copilot Apr 19, 2026
5c3c941
Merge branch 'copilot/add-local-map-file-and-channels' of https://git…
wholton Apr 19, 2026
ee37422
Merge remote-tracking branch 'origin/master' into copilot/add-local-m…
wholton Apr 21, 2026
b35319b
Implement Fog of War recreate flow
Copilot Apr 21, 2026
2ef9531
Format Fog of War recreate support
Copilot Apr 21, 2026
670977d
Expand Fog of War recreate test coverage
Copilot Apr 21, 2026
1ca9ea4
Clarify Fog of War recreate intent
Copilot Apr 21, 2026
4db2c0e
Polish Fog of War recreate notes
Copilot Apr 21, 2026
cb52772
Merge branch 'copilot/add-local-map-file-and-channels' of https://git…
wholton Apr 22, 2026
2611057
Merge remote-tracking branch 'origin/master' into copilot/add-local-m…
wholton May 3, 2026
95e7006
F
wholton May 3, 2026
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
Expand Up @@ -20,7 +20,8 @@ public class AdminCommand implements ParentCommand {
new DeletePersistenceManagerFile(),
new CardsInfoForPlayer(),
new UpdateThreadArchiveTime(),
new GetChannelHtml())
new GetChannelHtml(),
new RecreateGame())
.collect(Collectors.toMap(Subcommand::getName, subcommand -> subcommand));

@Override
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package ti4.discord.interactions.commands.admin;

import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import ti4.discord.interactions.commands.GameStateSubcommand;
import ti4.helpers.Constants;
import ti4.message.MessageHelper;
import ti4.service.game.RecreateGameService;

class RecreateGame extends GameStateSubcommand {

RecreateGame() {
super(Constants.RECREATE_GAME, "Recreate a game's channels and roles.", true, false);
addOption(OptionType.STRING, Constants.GAME_NAME, "Game to recreate", true, true);
}

@Override
public void execute(SlashCommandInteractionEvent event) {
var game = getGame();
MessageHelper.replyToMessage(event, RecreateGameService.recreateGame(game));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ public class DeveloperCommand implements ParentCommand {
new CustomCommand(),
new RunAgainstSpecificGame(),
new ProduceNucleusGenStats(),
// new StartLazaxSeason1(),
// new PostLazaxSeason1Public(),
new LocalDevelopment(),
new RunSql())
.collect(Collectors.toMap(Subcommand::getName, subcommand -> subcommand));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package ti4.discord.interactions.commands.developer;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.PosixFilePermission;
import java.nio.file.attribute.PosixFilePermissions;
import java.util.Set;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Message.Attachment;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import ti4.discord.interactions.commands.Subcommand;
import ti4.helpers.Constants;
import ti4.logging.BotLogger;
import ti4.message.MessageHelper;
import ti4.service.game.LocalDevelopmentSampleGameService;

class LocalDevelopment extends Subcommand {

private static final String ACTION_CREATE = "create";
private static final String ACTION_CLEAN = "clean";

LocalDevelopment() {
super(Constants.LOCAL_DEV, "Manage local development test games.");
addOptions(new OptionData(OptionType.STRING, Constants.ACTION, "Local development action")
.setRequired(true)
.addChoice("Create", ACTION_CREATE)
.addChoice("Clean", ACTION_CLEAN));
addOptions(new OptionData(OptionType.STRING, Constants.SOURCE, "Source game name").setRequired(false));
addOptions(new OptionData(OptionType.ATTACHMENT, Constants.SOURCE_FILE, "Source game save file")
.setRequired(false));
addOptions(
new OptionData(OptionType.STRING, Constants.CONFIRM, "Type YES to confirm cleanup").setRequired(false));
}

@Override
public void execute(SlashCommandInteractionEvent event) {
Guild guild = event.getGuild();
if (guild == null) {
MessageHelper.replyToMessage(event, "This command must be run in a server.");
return;
}

String action = event.getOption(Constants.ACTION, ACTION_CREATE, OptionMapping::getAsString);
switch (action) {
case ACTION_CLEAN -> {
String confirm = event.getOption(Constants.CONFIRM, "", OptionMapping::getAsString);
if (!"YES".equalsIgnoreCase(confirm)) {
MessageHelper.replyToMessage(event, "Cleanup requires `confirm: YES`.");
return;
}
var result = LocalDevelopmentSampleGameService.cleanTestGames(guild);
MessageHelper.replyToMessage(event, result.getSummary());
return;
}
case ACTION_CREATE -> {
OptionMapping sourceOption = event.getOption(Constants.SOURCE);
OptionMapping sourceFileOption = event.getOption(Constants.SOURCE_FILE);
if (sourceOption != null && sourceFileOption != null) {
MessageHelper.replyToMessage(event, "Use either `source` or `source_file`, not both.");
return;
}

String result;
if (sourceFileOption != null) {
Attachment sourceFile = sourceFileOption.getAsAttachment();
if (!"txt".equalsIgnoreCase(sourceFile.getFileExtension())) {
MessageHelper.replyToMessage(event, "Source file must be a .txt file.");
return;
}
String contentType = sourceFile.getContentType();
if (contentType != null && !contentType.startsWith("text/plain")) {
MessageHelper.replyToMessage(event, "Source file must be plain text.");
return;
}
Path downloadedSourceFile = null;
try {
downloadedSourceFile = createRestrictedTempFile();
sourceFile
.getProxy()
.downloadToFile(downloadedSourceFile.toFile())
.get();
} catch (Exception e) {
MessageHelper.replyToMessage(event, "Failed to download the uploaded source file.");
return;
}
try {
result = LocalDevelopmentSampleGameService.createAndRecreateTestGameFromSourceFile(
guild, event.getUser().getId(), downloadedSourceFile);
} catch (Exception e) {
MessageHelper.replyToMessage(event, "Failed to process the uploaded source file.");
return;
} finally {
if (downloadedSourceFile != null) {
try {
Files.deleteIfExists(downloadedSourceFile);
} catch (Exception e) {
BotLogger.warning(
"LocalDevelopment: failed to delete temporary source file "
+ downloadedSourceFile,
e);
}
}
}
if (result == null) {
MessageHelper.replyToMessage(
event, "Failed to create a local development test game from the uploaded source file.");
return;
}
} else {
String sourceGame = event.getOption(
Constants.SOURCE,
LocalDevelopmentSampleGameService.DEFAULT_SOURCE_GAME_NAME,
OptionMapping::getAsString);
result = LocalDevelopmentSampleGameService.createAndRecreateTestGame(
guild, event.getUser().getId(), sourceGame);
if (result == null) {
MessageHelper.replyToMessage(
event, "Failed to create a local development test game from `" + sourceGame + "`.");
return;
}
}
MessageHelper.replyToMessage(event, result);
return;
}
default -> {
MessageHelper.replyToMessage(event, "Unknown local development action: `" + action + "`.");
return;
}
}
}

private static Path createRestrictedTempFile() throws Exception {
try {
Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rw-------");
return Files.createTempFile(
"ti4-local-dev-source-", Constants.TXT, PosixFilePermissions.asFileAttribute(permissions));
} catch (UnsupportedOperationException e) {
return Files.createTempFile("ti4-local-dev-source-", Constants.TXT);
}
}
}
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 @@ -1025,6 +1025,7 @@ public static String solaxPing() {
public static final String TTPG_FILE_NAME = "ttpg_file_name";
public static final String GAME_FUN_NAME = "game_fun_name";
public static final String CATEGORY = "category";
public static final String ACTION = "action";
public static final String SEND_JSON_TO_S3 = "send_games_to_s3";
public static final String SERVER_LIMIT_STATS = "server_limit_stats";
public static final String END = "end";
Expand Down Expand Up @@ -1057,12 +1058,14 @@ public static String solaxPing() {
public static final String FIX_CHANNEL_PERMISSIONS = "fix_channel_permissions";
public static final String CATEGORY_CHANNEL_COUNT = "category_channel_count";
public static final String BEGIN_VIDEO_GEN = "begin_video_gen";
public static final String RECREATE_GAME = "recreate_game";
public static final String INCLUDE_SUBCOMMANDS = "include_subcommands";
public static final String INCLUDE_OPTIONS = "include_options";
public static final String CARDS_INFO = "cards_info";
public static final String FRANKEN = "franken";
public static final String SHOW_GAME_AS_PLAYER = "show_game_as";
public static final String CHECK_PRIVATE_COMMUNICATIONS = "check_private_communications";
public static final String LOCAL_DEV = "local_dev";

// DRAFTS
public static final String BAG_DRAFT = "bag_draft";
Expand Down Expand Up @@ -1504,6 +1507,7 @@ public static String solaxPing() {
public static final String INCLUDE_RAW_STRING = "include_raw_string";
public static final String RESTORE_GAME = "restore_game";
public static final String SAVE_FILE = "save_file";
public static final String SOURCE_FILE = "source_file";
public static final String GAMES = "games";
public static final String GAMES_WITH_FACTIONS = "games_with_factions";
public static final String GAME_STATISTIC = "game_statistic";
Expand Down
20 changes: 17 additions & 3 deletions src/main/java/ti4/image/MapGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public class MapGenerator implements AutoCloseable {
private byte[] mainImageBytes;
private String imageFormat = "webp";
private final GenericInteractionCreateEvent event;
private final Player overriddenFowPlayer;
private final int scoreTokenSpacing;
private final Game game;
private final DisplayType displayType;
Expand Down Expand Up @@ -121,14 +122,19 @@ public class MapGenerator implements AutoCloseable {
private StopWatch debugDiscordTime;
private StopWatch debugWebsiteTime;

MapGenerator(Game game, @Nullable DisplayType displayType, @Nullable GenericInteractionCreateEvent event) {
MapGenerator(
Game game,
@Nullable DisplayType displayType,
@Nullable GenericInteractionCreateEvent event,
@Nullable Player overriddenFowPlayer) {
debug = GlobalSettings.getSetting(
GlobalSettings.ImplementedSettings.DEBUG.toString(), Boolean.class, Boolean.FALSE);
if (debug) debugAbsoluteStartTime = StopWatch.createStarted();

this.game = game;
this.displayType = defaultIfNull(displayType);
this.event = event;
this.overriddenFowPlayer = overriddenFowPlayer;

// Get a control token to calculate needed width of objectives later based on number of players
String controlID = Mapper.getControlID("red");
Expand Down Expand Up @@ -361,8 +367,16 @@ private void setupFow(Map<String, Tile> tilesToDisplay) {
isFoWPrivate = true;
// IMPORTANT NOTE : This method used to be local and was refactored to extract
// any references to tilesToDisplay
fowPlayer = CommandHelper.getPlayerFromGame(
game, event.getMember(), event.getUser().getId());
if (overriddenFowPlayer != null) {
fowPlayer = overriddenFowPlayer;
} else if (event != null) {
fowPlayer = CommandHelper.getPlayerFromGame(
game, event.getMember(), event.getUser().getId());
} else {
// This only applies when neither an explicit FoW player nor an interaction event is available.
isFoWPrivate = false;
return;
}

Set<String> tilesToShow = FoWHelper.fowFilter(game, fowPlayer);
Set<String> keys = new HashSet<>(tilesToDisplay.keySet());
Expand Down
24 changes: 18 additions & 6 deletions src/main/java/ti4/image/MapRenderPipeline.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import ti4.executors.CircuitBreaker;
import ti4.executors.ExecutionHistoryManager;
import ti4.game.Game;
import ti4.game.Player;
import ti4.helpers.DisplayType;
import ti4.helpers.TimedRunnable;
import ti4.logging.BotLogger;
Expand All @@ -35,8 +36,8 @@ private static void render(RenderEvent renderEvent) {
String gameName = renderEvent.game.getName();
var timedRunnable =
new TimedRunnable("Render event task for " + gameName, EXECUTION_TIME_SECONDS_WARNING_THRESHOLD, () -> {
try (var mapGenerator =
new MapGenerator(renderEvent.game, renderEvent.displayType, renderEvent.event)) {
try (var mapGenerator = new MapGenerator(
renderEvent.game, renderEvent.displayType, renderEvent.event, renderEvent.fowPlayer)) {
mapGenerator.draw();
if (renderEvent.uploadToDiscord) {
uploadToDiscord(mapGenerator, renderEvent.callback());
Expand Down Expand Up @@ -67,34 +68,44 @@ public static void renderToWebsiteOnly(Game game, @Nullable GenericInteractionCr
GlobalSettings.ImplementedSettings.UPLOAD_DATA_TO_WEB_SERVER.toString(),
Boolean.class,
Boolean.FALSE)) {
queue(game, event, null, null, false, true);
queue(game, event, null, null, null, false, true);
}
}

public static void queue(
Game game, @Nullable SlashCommandInteractionEvent event, @Nullable Consumer<FileUpload> callback) {
queue(game, event, null, callback, true, true);
queue(game, event, null, callback, null, true, true);
}

public static void queue(
Game game,
@Nullable GenericInteractionCreateEvent event,
@Nullable DisplayType displayType,
@Nullable Consumer<FileUpload> callback,
@Nullable Player fowPlayer) {
queue(game, event, displayType, callback, fowPlayer, true, true);
}

public static void queue(
Game game,
@Nullable GenericInteractionCreateEvent event,
@Nullable DisplayType displayType,
@Nullable Consumer<FileUpload> callback) {
queue(game, event, displayType, callback, true, true);
queue(game, event, displayType, callback, null, true, true);
}

private static void queue(
Game game,
@Nullable GenericInteractionCreateEvent event,
@Nullable DisplayType displayType,
@Nullable Consumer<FileUpload> callback,
@Nullable Player fowPlayer,
boolean uploadToDiscord,
boolean uploadToWebsite) {
if (game == null) {
throw new IllegalArgumentException("game cannot be null in render pipeline");
}
render(new RenderEvent(game, event, displayType, callback, uploadToDiscord, uploadToWebsite));
render(new RenderEvent(game, event, displayType, callback, fowPlayer, uploadToDiscord, uploadToWebsite));
}

public static boolean shutdown() {
Expand All @@ -113,6 +124,7 @@ private record RenderEvent(
GenericInteractionCreateEvent event,
DisplayType displayType,
Consumer<FileUpload> callback,
Player fowPlayer,
boolean uploadToDiscord,
boolean uploadToWebsite) {}
}
41 changes: 41 additions & 0 deletions src/main/java/ti4/service/ShowGameService.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ public static void simpleShowGame(Game game, GenericInteractionCreateEvent event
simpleShowGame(game, event, DisplayType.all);
}

public static void postShowGame(Game game, MessageChannel channel) {
postShowGame(game, channel, DisplayType.all);
}

public static void postShowGame(Game game, MessageChannel channel, Player fowPlayer) {
postShowGame(game, channel, DisplayType.all, fowPlayer);
}

public static void simpleShowGame(Game game, GenericInteractionCreateEvent event, DisplayType displayType) {
boolean shouldPersistFullMapMessageId = displayType == DisplayType.all && !game.isFowMode();
boolean shouldPersistFowMapMessageId = displayType == DisplayType.all && game.isFowMode();
Expand Down Expand Up @@ -76,6 +84,39 @@ public static void simpleShowGame(Game game, GenericInteractionCreateEvent event
});
}

static void postShowGame(Game game, MessageChannel channel, DisplayType displayType) {
postShowGame(game, channel, displayType, null);
}

static void postShowGame(Game game, MessageChannel channel, DisplayType displayType, Player fowPlayer) {
if (channel == null) {
return;
}
boolean shouldPersistFullMapMessageId = displayType == DisplayType.all && !game.isFowMode();
Consumer<Message> persistMessageId = shouldPersistFullMapMessageId
? msg -> SpringContext.getBean(GameImageService.class)
.saveDiscordMessageId(
game.getName(),
msg.getIdLong(),
msg.getGuild().getIdLong(),
msg.getChannel().getIdLong())
: null;

MapRenderPipeline.queue(
game,
null,
displayType,
fileUpload -> {
if (includeButtons(displayType)) {
ButtonHelper.sendFileWithCorrectButtons(
channel, fileUpload, null, Buttons.mapImageButtons(game), game, persistMessageId);
} else {
MessageHelper.sendFileUploadToChannel(channel, fileUpload, persistMessageId);
}
},
fowPlayer);
}

/**
* Check if the map is being sent to a player's private channel (for FoW games).
*/
Expand Down
Loading
Loading