From 06d6b0b29ceda577de47f7954b3339a7d1505dab Mon Sep 17 00:00:00 2001 From: Jakob Schweigert Date: Thu, 11 Jun 2026 14:08:00 +0200 Subject: [PATCH] Add game webhooks for turn and phase changes --- WEBSITE_INTEGRATION.md | 49 ++ .../agenda/AgendaResolveButtonHandler.java | 4 + .../ChecksAndBalancesButtonHandler.java | 3 + .../handlers/edict/EdictPhaseHandler.java | 3 + .../PickStrategyCardButtonHandler.java | 3 + .../interactions/commands/player/SCPick.java | 4 + .../commands/player/SCUnpick.java | 3 + src/main/java/ti4/helpers/AgendaHelper.java | 5 + src/main/java/ti4/helpers/StatusHelper.java | 3 + .../helpers/thundersedge/TeHelperTechs.java | 3 + .../ti4/service/game/StartPhaseService.java | 9 + .../strategycard/PickStrategyCardService.java | 4 + .../ti4/service/turn/StartTurnService.java | 2 + .../service/webhook/GameWebhookEventType.java | 51 ++ .../service/webhook/GameWebhookNotifier.java | 81 +++ .../webhook/GameWebhookNotifierFacade.java | 41 ++ .../service/webhook/GameWebhookPayload.java | 15 + .../webhook/WebhookDispatchService.java | 96 ++++ .../api/webhook/GameWebhookApiController.java | 64 +++ .../webhook/GameWebhookEventTypeResponse.java | 27 + .../GameWebhookSubscriptionRequest.java | 5 + .../service/webhook/GameWebhookEntity.java | 33 ++ .../webhook/GameWebhookRepository.java | 14 + .../GameWebhookSubscriptionService.java | 122 +++++ .../service/webhook/WebhookUserEntity.java | 33 ++ .../webhook/WebhookUserRepository.java | 9 + .../webhook/GameWebhookNotifierTest.java | 99 ++++ .../webhook/WebhookDispatchServiceTest.java | 27 + .../webhook/GameWebhookApiControllerTest.java | 477 ++++++++++++++++++ 29 files changed, 1289 insertions(+) create mode 100644 src/main/java/ti4/service/webhook/GameWebhookEventType.java create mode 100644 src/main/java/ti4/service/webhook/GameWebhookNotifier.java create mode 100644 src/main/java/ti4/service/webhook/GameWebhookNotifierFacade.java create mode 100644 src/main/java/ti4/service/webhook/GameWebhookPayload.java create mode 100644 src/main/java/ti4/service/webhook/WebhookDispatchService.java create mode 100644 src/main/java/ti4/spring/api/webhook/GameWebhookApiController.java create mode 100644 src/main/java/ti4/spring/api/webhook/GameWebhookEventTypeResponse.java create mode 100644 src/main/java/ti4/spring/api/webhook/GameWebhookSubscriptionRequest.java create mode 100644 src/main/java/ti4/spring/service/webhook/GameWebhookEntity.java create mode 100644 src/main/java/ti4/spring/service/webhook/GameWebhookRepository.java create mode 100644 src/main/java/ti4/spring/service/webhook/GameWebhookSubscriptionService.java create mode 100644 src/main/java/ti4/spring/service/webhook/WebhookUserEntity.java create mode 100644 src/main/java/ti4/spring/service/webhook/WebhookUserRepository.java create mode 100644 src/test/java/ti4/service/webhook/GameWebhookNotifierTest.java create mode 100644 src/test/java/ti4/service/webhook/WebhookDispatchServiceTest.java create mode 100644 src/test/java/ti4/spring/api/webhook/GameWebhookApiControllerTest.java diff --git a/WEBSITE_INTEGRATION.md b/WEBSITE_INTEGRATION.md index 8f45fe9bbe..ce985e37a1 100644 --- a/WEBSITE_INTEGRATION.md +++ b/WEBSITE_INTEGRATION.md @@ -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. diff --git a/src/main/java/ti4/discord/interactions/buttons/handlers/agenda/AgendaResolveButtonHandler.java b/src/main/java/ti4/discord/interactions/buttons/handlers/agenda/AgendaResolveButtonHandler.java index 121dea25f4..4e22de6222 100644 --- a/src/main/java/ti4/discord/interactions/buttons/handlers/agenda/AgendaResolveButtonHandler.java +++ b/src/main/java/ti4/discord/interactions/buttons/handlers/agenda/AgendaResolveButtonHandler.java @@ -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 { @@ -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())) { diff --git a/src/main/java/ti4/discord/interactions/buttons/handlers/agenda/ChecksAndBalancesButtonHandler.java b/src/main/java/ti4/discord/interactions/buttons/handlers/agenda/ChecksAndBalancesButtonHandler.java index ea245c94f6..abe8034bb3 100644 --- a/src/main/java/ti4/discord/interactions/buttons/handlers/agenda/ChecksAndBalancesButtonHandler.java +++ b/src/main/java/ti4/discord/interactions/buttons/handlers/agenda/ChecksAndBalancesButtonHandler.java @@ -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 { @@ -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() diff --git a/src/main/java/ti4/discord/interactions/buttons/handlers/edict/EdictPhaseHandler.java b/src/main/java/ti4/discord/interactions/buttons/handlers/edict/EdictPhaseHandler.java index a0dd23222c..80d23ab0b3 100644 --- a/src/main/java/ti4/discord/interactions/buttons/handlers/edict/EdictPhaseHandler.java +++ b/src/main/java/ti4/discord/interactions/buttons/handlers/edict/EdictPhaseHandler.java @@ -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 { @@ -30,7 +31,9 @@ public List 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 edicts = getEdictDeck(game); List