Skip to content
Draft
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
49 changes: 49 additions & 0 deletions WEBSITE_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,55 @@

This document describes how the TI4 Map Generator Bot integrates with the AsyncTI4 website UI.

## Game Webhooks

Trusted external integrations can subscribe to non-FoW game changes without polling. Server operators create `webhook_user` rows with the callback URL and SHA-256 API key hash.

Supported events:

- `TURN_CHANGED`
- `PHASE_CHANGED`

Endpoints:

- `GET /api/public/game/webhook/eventTypes` returns supported event names, trigger descriptions, delivery settings, and payload fields.
- `PUT /api/public/game/{gameName}/webhook` with `X-API-Key` and JSON body:

```json
{ "eventTypes": ["TURN_CHANGED", "PHASE_CHANGED"] }
```

- `DELETE /api/public/game/{gameName}/webhook` with `X-API-Key`

Example `TURN_CHANGED` payload:

```json
{
"gameName": "pbd1234",
"eventType": "TURN_CHANGED",
"phaseOfGame": "action",
"round": 5,
"activePlayerId": "123456789012345678",
"activeFaction": "arborec",
"timestamp": "2026-06-02T14:30:00Z"
}
```

Example `PHASE_CHANGED` payload:

```json
{
"gameName": "pbd1234",
"eventType": "PHASE_CHANGED",
"previousPhaseOfGame": "strategy",
"phaseOfGame": "agenda",
"round": 4,
"timestamp": "2026-06-02T14:30:00Z"
}
```

Delivery is asynchronous. Webhook failures never block game flow; transient failures may be retried once, and permanent 4xx failures are not retried.

## Image Asset Pipeline

Game assets (faction banners, planet images, system tiles, etc.) are automatically uploaded to a CloudFront-backed S3 bucket and served to the website.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
import ti4.service.info.SecretObjectiveInfoService;
import ti4.service.leader.CommanderUnlockCheckService;
import ti4.service.turn.StartTurnService;
import ti4.service.webhook.GameWebhookNotifierFacade;

@UtilityClass
class AgendaResolveButtonHandler {
Expand Down Expand Up @@ -467,10 +468,13 @@ private static void sendNextStepUi(
+ ", please use this buttons to proceed after fully resolving the agenda.";
buttons = StartTurnService.getStartOfTurnButtons(executiveOrderPlayer, game, true, event);
game.removeStoredValue("executiveOrder");
String previousPhaseOfGame = game.getPhaseOfGame();
game.updateActivePlayer(executiveOrderPlayer);
Player oldSpeaker = game.getPlayer(game.getStoredValue("oldSpeakerExecutiveOrder"));
game.setSpeaker(oldSpeaker);
game.setPhaseOfGame("action");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "action");
GameWebhookNotifierFacade.turnChanged(game);
MessageHelper.sendMessageToChannelWithButtons(event.getChannel(), voteMessage, buttons);
}
if (!"action".equalsIgnoreCase(game.getPhaseOfGame())) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import ti4.message.MessageHelper;
import ti4.service.game.StartPhaseService;
import ti4.service.player.PlayerStatsService;
import ti4.service.webhook.GameWebhookNotifierFacade;

@UtilityClass
class ChecksAndBalancesButtonHandler {
Expand Down Expand Up @@ -65,8 +66,10 @@ public static void resolvePt2ChecksNBalances(
if (privatePlayer == null) {
privatePlayer = game.getRealPlayers().getFirst();
}
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("strategy");
game.updateActivePlayer(privatePlayer);
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "strategy");
MessageHelper.sendMessageToChannelWithButtons(
privatePlayer.getCorrectChannel(),
privatePlayer.getRepresentationUnfogged()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import ti4.message.MessageHelper;
import ti4.model.AgendaModel;
import ti4.model.DeckModel;
import ti4.service.webhook.GameWebhookNotifierFacade;

@UtilityClass
public class EdictPhaseHandler {
Expand All @@ -30,7 +31,9 @@ public List<String> getEdictDeck(Game game) {

@ButtonHandler("edictPhase")
public static void edictPhase(GenericInteractionCreateEvent event, Game game) {
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("agenda");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "agenda");
List<String> edicts = getEdictDeck(game);
List<Button> buttons = new ArrayList<>();
List<MessageEmbed> embeds = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import ti4.service.leader.CommanderUnlockCheckService;
import ti4.service.player.PlayerStatsService;
import ti4.service.strategycard.PickStrategyCardService;
import ti4.service.webhook.GameWebhookNotifierFacade;

@UtilityClass
public class PickStrategyCardButtonHandler {
Expand Down Expand Up @@ -188,8 +189,10 @@ public static void resolvePt2ChecksNBalances(
if (privatePlayer == null) {
privatePlayer = game.getRealPlayers().getFirst();
}
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("strategy");
game.updateActivePlayer(privatePlayer);
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "strategy");
MessageHelper.sendMessageToChannelWithButtons(
privatePlayer.getCorrectChannel(),
privatePlayer.getRepresentationUnfogged()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import ti4.service.game.StartPhaseService;
import ti4.service.player.PlayerStatsService;
import ti4.service.strategycard.PickStrategyCardService;
import ti4.service.webhook.GameWebhookNotifierFacade;

class SCPick extends GameStateSubcommand {

Expand Down Expand Up @@ -92,6 +93,7 @@ private static void secondHalfOfSCPick(
}

boolean nextCorrectPing = false;
String previousPhaseOfGame = game.getPhaseOfGame();
Queue<Player> players = new ArrayDeque<>(activePlayers);
while (players.iterator().hasNext() && player.isRealPlayer()) {
Player currentPlayer = players.poll();
Expand Down Expand Up @@ -121,6 +123,7 @@ private static void secondHalfOfSCPick(
game.updateActivePlayer(privatePlayer);
if (!allPicked) {
game.setPhaseOfGame("strategy");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "strategy");
MessageHelper.sendMessageToChannelWithButtons(
privatePlayer.getPrivateChannel(),
"Use buttons to pick your strategy card.",
Expand All @@ -130,6 +133,7 @@ private static void secondHalfOfSCPick(
if (!allPicked) {
game.updateActivePlayer(privatePlayer);
game.setPhaseOfGame("strategy");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "strategy");
PickStrategyCardService.checkForForcePickLastStratCard(event, privatePlayer, game, msgExtra);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import ti4.helpers.Constants;
import ti4.helpers.Helper;
import ti4.message.MessageHelper;
import ti4.service.webhook.GameWebhookNotifierFacade;

class SCUnpick extends GameStateSubcommand {

Expand All @@ -34,8 +35,10 @@ public void execute(SlashCommandInteractionEvent event) {
Player player = getPlayer();
player.removeSC(scUnpicked);
List<Button> scButtons = Helper.getRemainingSCButtons(game, player);
String previousPhaseOfGame = game.getPhaseOfGame();
game.updateActivePlayer(player);
game.setPhaseOfGame("strategy");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "strategy");
MessageHelper.sendMessageToChannelWithButtons(
player.getCorrectChannel(), player.getRepresentation() + ", please pick a strategy card.", scButtons);
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/ti4/helpers/AgendaHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
import ti4.service.unit.AddUnitService;
import ti4.service.unit.CheckUnitContainmentService;
import ti4.service.unit.DestroyUnitService;
import ti4.service.webhook.GameWebhookNotifierFacade;

@UtilityClass
public final class AgendaHelper {
Expand Down Expand Up @@ -1864,7 +1865,9 @@ private static List<Button> getVoteButtonsVersion2(int minVote, int voteTotal) {
}

public static void startTheVoting(Game game) {
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("agendaVoting");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "agendaVoting");
if (!game.getStoredValue("CommFormPreset").isEmpty()) {
autoResolve(
null,
Expand Down Expand Up @@ -4012,7 +4015,9 @@ public static void revealAgenda(
Integer uniqueID = discardAgendas.get(agendaID);
boolean action = false;
if (!"action".equalsIgnoreCase(game.getPhaseOfGame())) {
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("agendawaiting");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "agendawaiting");
if (aCount == 1) {
GMService.logActivity(game, "**Agenda** Phase for Round " + game.getRound() + " started.", true);
FowCommunicationThreadService.checkAllCommThreads(game);
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/ti4/helpers/StatusHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
import ti4.service.objectives.ScorePublicObjectiveService;
import ti4.service.planet.EronousPlanetService;
import ti4.service.turn.StartTurnService;
import ti4.service.webhook.GameWebhookNotifierFacade;
import ti4.settings.users.UserSettingsManager;

@UtilityClass
Expand Down Expand Up @@ -153,7 +154,9 @@ public static void offerPreScoringButtons(Game game, Player player) {
}

public static void beginScoring(GenericInteractionCreateEvent event, Game game, MessageChannel gameChannel) {
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("statusScoring");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "statusScoring");
game.setStoredValue("startTimeOfRound" + game.getRound() + "StatusScoring", System.currentTimeMillis() + "");
GMService.logActivity(game, "**StatusScoring** Phase for Round " + game.getRound() + " started.", true);
for (Player player : game.getRealPlayers()) {
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/ti4/helpers/thundersedge/TeHelperTechs.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import ti4.service.regex.RegexService;
import ti4.service.unit.DestroyUnitService;
import ti4.service.unit.ParsedUnit;
import ti4.service.webhook.GameWebhookNotifierFacade;

public final class TeHelperTechs {

Expand Down Expand Up @@ -583,7 +584,9 @@ public static void postExecutiveOrderButtons(GenericInteractionCreateEvent event
@ButtonHandler("useExecutiveOrder_")
private static void executiveOrder(ButtonInteractionEvent event, Game game, Player player, String buttonID) {
game.setStoredValue("executiveOrder", player.getFaction());
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("agenda");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "agenda");
boolean top = buttonID.contains("top");

String msg = game.getPing() + " the " + buttonID.split("_")[1]
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/ti4/service/game/StartPhaseService.java
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
import ti4.service.planet.PlanetService;
import ti4.service.strategycard.PickStrategyCardService;
import ti4.service.turn.StartTurnService;
import ti4.service.webhook.GameWebhookNotifierFacade;
import ti4.settings.users.UserSettingsManager;

@UtilityClass
Expand All @@ -83,7 +84,9 @@ public static void startPhase(GenericInteractionCreateEvent event, Game game, St
case "voting", "agendaVoting" -> AgendaHelper.startTheVoting(game);
case "shuffleDecks" -> game.shuffleDecks();
case "agenda" -> {
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("agenda");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "agenda");
Button flipAgenda = Buttons.blue("flip_agenda", "Flip Agenda");
List<Button> buttons = List.of(flipAgenda);
MessageHelper.sendMessageToChannelWithButtons(
Expand Down Expand Up @@ -529,8 +532,10 @@ public static void startStrategyPhase(GenericInteractionCreateEvent event, Game
firstSCPicker = firstInPriorityOrder.get();
}
String message = firstSCPicker.getRepresentationUnfogged() + " is up to pick a strategy card.";
String previousPhaseOfGame = game.getPhaseOfGame();
game.updateActivePlayer(firstSCPicker);
game.setPhaseOfGame("strategy");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "strategy");
GMService.logActivity(game, "**Strategy** Phase for Round " + game.getRound() + " started.", true);
FowCommunicationThreadService.checkAllCommThreads(game);
SpinService.executeSpinsForTrigger(game, SpinService.AutoTrigger.STRATEGY);
Expand Down Expand Up @@ -834,7 +839,9 @@ public static void sendStatusReminders(Game game, Player player) {
}

public static void startStatusHomework(GenericInteractionCreateEvent event, Game game) {
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("statusHomework");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "statusHomework");
game.setStoredValue("startTimeOfRound" + game.getRound() + "StatusHomework", System.currentTimeMillis() + "");
GMService.logActivity(game, "**StatusHomework** Phase for Round " + game.getRound() + " started.", true);
// first do cleanup if necessary
Expand Down Expand Up @@ -1116,7 +1123,9 @@ public static void postSurveyResults(Game game) {
public static void startActionPhase(GenericInteractionCreateEvent event, Game game, boolean incrementTgs) {
boolean isFowPrivateGame = game.isFowMode();
game.setStoredValue("willRevolution", "");
String previousPhaseOfGame = game.getPhaseOfGame();
game.setPhaseOfGame("action");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "action");
GMService.logActivity(game, "**Action** Phase for Round " + game.getRound() + " started.", true);
for (Player p2 : game.getRealPlayers()) {
ButtonHelperActionCards.checkForAssigningExtremeDuress(game, p2);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import ti4.message.MessageHelper;
import ti4.service.game.StartPhaseService;
import ti4.service.player.PlayerStatsService;
import ti4.service.webhook.GameWebhookNotifierFacade;

@UtilityClass
public class PickStrategyCardService {
Expand All @@ -37,6 +38,7 @@ public static void secondHalfOfSCPick(GenericInteractionCreateEvent event, Playe
}

boolean nextCorrectPing = false;
String previousPhaseOfGame = game.getPhaseOfGame();
Queue<Player> players = new ArrayDeque<>(activePlayers);
while (players.iterator().hasNext()) {
Player currentPlayer = players.poll();
Expand Down Expand Up @@ -66,6 +68,7 @@ public static void secondHalfOfSCPick(GenericInteractionCreateEvent event, Playe
game.updateActivePlayer(privatePlayer);
game.setPhaseOfGame("strategy");
game.updateActivePlayer(privatePlayer);
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "strategy");
boolean queuedPick = false;
if (event instanceof ButtonInteractionEvent bevent) {
queuedPick = checkForQueuedSCPick(bevent, privatePlayer, game, msgExtra);
Expand All @@ -80,6 +83,7 @@ public static void secondHalfOfSCPick(GenericInteractionCreateEvent event, Playe
if (!allPicked) {
game.updateActivePlayer(privatePlayer);
game.setPhaseOfGame("strategy");
GameWebhookNotifierFacade.phaseChanged(game, previousPhaseOfGame, "strategy");
boolean queuedPick;
if (event instanceof ButtonInteractionEvent bevent) {
queuedPick = checkForQueuedSCPick(bevent, privatePlayer, game, msgExtra);
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/ti4/service/turn/StartTurnService.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
import ti4.service.leader.CommanderUnlockCheckService;
import ti4.service.strategycard.PlayStrategyCardService;
import ti4.service.strategycard.StrategyCardMessageService;
import ti4.service.webhook.GameWebhookNotifierFacade;
import ti4.settings.users.UserSettingsManager;

@UtilityClass
Expand Down Expand Up @@ -152,6 +153,7 @@ public static void turnStart(GenericInteractionCreateEvent event, Game game, Pla

game.updateActivePlayer(player);
game.setPhaseOfGame("action");
GameWebhookNotifierFacade.turnChanged(game);
ButtonHelperFactionSpecific.resolveMilitarySupportCheck(player, game);
if (NetrunnersPromissoryHandler.shouldOfferSharedNetworkAccessButtons(player, game)) {
NetrunnersPromissoryHandler.offerSharedNetworkAccessButtons(player, game);
Expand Down
51 changes: 51 additions & 0 deletions src/main/java/ti4/service/webhook/GameWebhookEventType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package ti4.service.webhook;

import java.util.Map;

public enum GameWebhookEventType {
TURN_CHANGED(
"A new player's turn has started.",
"Emitted after the bot updates the active player in StartTurnService.turnStart.",
Map.of(
"gameName", "string",
"eventType", "string",
"phaseOfGame", "string",
"round", "integer",
"activePlayerId", "string",
"activeFaction", "string",
"timestamp", "date-time")),
PHASE_CHANGED(
"The game entered a new core phase.",
"Emitted after the bot updates the game phase for strategy, action, status, or agenda.",
Map.of(
"gameName", "string",
"eventType", "string",
"previousPhaseOfGame", "string|null",
"phaseOfGame", "string",
"round", "integer",
"activePlayerId", "string|null",
"activeFaction", "string|null",
"timestamp", "date-time"));

private final String description;
private final String trigger;
private final Map<String, String> payloadFields;

GameWebhookEventType(String description, String trigger, Map<String, String> payloadFields) {
this.description = description;
this.trigger = trigger;
this.payloadFields = payloadFields;
}

public String getDescription() {
return description;
}

public String getTrigger() {
return trigger;
}

public Map<String, String> getPayloadFields() {
return payloadFields;
}
}
Loading
Loading