From e34c0a174794181a1c57a239c1d87cb4bf0e0404 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Wed, 28 Oct 2026 22:38:14 +0100 Subject: [PATCH] feat(party): bring the party back to the lobby with its leader --- README.md | 2 +- .../quelea/backend/api/PartyController.java | 3 +- .../quelea/backend/api/WarpRequest.java | 9 +- .../quelea/backend/party/PartyService.java | 10 +- .../backend/api/PartiesControllerTest.java | 22 ++ .../backend/party/PartyServiceTest.java | 25 +++ .../quelea/velocity/QueleaVelocity.java | 2 +- .../quelea/velocity/command/PartyActions.java | 2 +- .../quelea/velocity/event/EventHandler.java | 4 +- .../quelea/velocity/follow/FollowPolicy.java | 20 +- .../velocity/http/HttpQueleaGateway.java | 5 +- .../quelea/velocity/http/QueleaGateway.java | 5 +- .../velocity/listener/PresenceListener.java | 30 +-- .../velocity/command/FakeQueleaGateway.java | 2 +- .../velocity/event/EventHandlerTest.java | 76 +++++++ .../velocity/follow/FollowPolicyTest.java | 47 ++--- .../velocity/http/HttpQueleaGatewayTest.java | 15 ++ .../listener/PresenceListenerTest.java | 196 ++++++++++++++++++ 18 files changed, 416 insertions(+), 59 deletions(-) create mode 100644 velocity/src/test/java/net/onelitefeather/quelea/velocity/listener/PresenceListenerTest.java diff --git a/README.md b/README.md index 49d6e70..cc2f9e6 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ flowchart LR R2 <--> D ``` -**Follow rule.** When the proxy sees the party leader connect to a server that does not match the lobby pattern (`QUELEA_LOBBY_PATTERN`), it asks the service to warp the party. Every proxy then moves its local members to that exact server. With the default follow mode `lobby`, only members who are in a lobby follow; members already in another game stay where they are. +**Follow rule.** Whenever the proxy sees the party leader move from one server to another, it asks the service to warp the party to the new server and names the server the leader left. Every proxy then moves its local members to that exact server. With the default follow mode `lobby`, a member follows if they were on the server the leader left (they were together) or if they are in a lobby (`QUELEA_LOBBY_PATTERN`); a member in another game stays where they are. So a round that ends brings the party back to the lobby with its leader, and a lobby hop drags the other lobby members along. The leader's first connection after login warps nothing. Events are delivered at most once. A proxy that reconnects does not receive events it missed, so a warp that was lost can be repeated with `/party warp`. diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/api/PartyController.java b/backend/src/main/java/net/onelitefeather/quelea/backend/api/PartyController.java index 4520fbc..0ddf6a0 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/api/PartyController.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/api/PartyController.java @@ -193,7 +193,8 @@ public void disband(@PathVariable UUID partyId, @Body @Valid ActorRequest reques public void warp(@PathVariable UUID partyId, @Body @Valid WarpRequest request, @Parameter(in = ParameterIn.HEADER, name = HttpHeaders.IF_MATCH, description = IF_MATCH_DESCRIPTION) @Nullable @Header(HttpHeaders.IF_MATCH) String ifMatch) { - metrics.run("warp", () -> parties.warp(partyId, request.actorId(), request.server(), new Precondition(ifMatch))); + metrics.run("warp", () -> parties.warp(partyId, request.actorId(), request.server(), request.fromServer(), + new Precondition(ifMatch))); } @Post("/{partyId}/chat") diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/api/WarpRequest.java b/backend/src/main/java/net/onelitefeather/quelea/backend/api/WarpRequest.java index de82adf..14d2b10 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/api/WarpRequest.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/api/WarpRequest.java @@ -1,6 +1,8 @@ package net.onelitefeather.quelea.backend.api; +import io.micronaut.core.annotation.Nullable; import io.micronaut.serde.annotation.Serdeable; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -8,5 +10,10 @@ import java.util.UUID; @Serdeable -public record WarpRequest(@NotNull UUID actorId, @NotBlank @Size(max = 64) String server) { +public record WarpRequest(@NotNull UUID actorId, + @NotBlank @Size(max = 64) String server, + @Nullable @Size(max = 64) + @Schema(description = "The server the party leader left. Members on this server follow the warp " + + "even when they are in a game. Omitted by clients that do not send it.") + String fromServer) { } diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartyService.java b/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartyService.java index 9e94612..5623431 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartyService.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/party/PartyService.java @@ -266,11 +266,19 @@ public void warp(UUID partyId, UUID actorId, String server) { /** Runs under the party lock like the other leader commands, so an {@code If-Match} is compared with the current version. */ public void warp(UUID partyId, UUID actorId, String server, Precondition precondition) { + warp(partyId, actorId, server, null, precondition); + } + + /** {@code fromServer} is the server the leader left; it is carried as {@code from} when present. */ + public void warp(UUID partyId, UUID actorId, String server, String fromServer, Precondition precondition) { locked(partyId, () -> { Party party = require(partyId); precondition.check(party); requireLeader(party, actorId); - publish(PartyEventType.WARP, party, Map.of("server", server)); + Map payload = fromServer == null + ? Map.of("server", server) + : Map.of("server", server, "from", fromServer); + publish(PartyEventType.WARP, party, payload); return null; }); } diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/api/PartiesControllerTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/api/PartiesControllerTest.java index 5153bf9..9faf41b 100644 --- a/backend/src/test/java/net/onelitefeather/quelea/backend/api/PartiesControllerTest.java +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/api/PartiesControllerTest.java @@ -130,6 +130,28 @@ void warpAnswers202() { assertEquals(202, response.status()); } + @Test + void warpWithOriginAnswers202() { + UUID leader = api.login(PROXY, name("lead")); + String partyId = create(leader).json().get("id").asText(); + + Response response = api.postJson("/v1/parties/" + partyId + "/warp", + "{\"actorId\":\"" + leader + "\",\"server\":\"Lobby-1\",\"fromServer\":\"Bounce-2\"}"); + + assertEquals(202, response.status()); + } + + @Test + void warpWithOriginLongerThan64CharactersAnswers400() { + UUID leader = api.login(PROXY, name("lead")); + String partyId = create(leader).json().get("id").asText(); + + Response response = api.postJson("/v1/parties/" + partyId + "/warp", + "{\"actorId\":\"" + leader + "\",\"server\":\"Lobby-1\",\"fromServer\":\"" + "x".repeat(65) + "\"}"); + + assertEquals(400, response.status(), "an origin longer than 64 characters is an invalid body"); + } + @Test void chatAnswers202() { UUID leader = api.login(PROXY, name("lead")); diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyServiceTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyServiceTest.java index 5bc7055..bb64786 100644 --- a/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyServiceTest.java +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyServiceTest.java @@ -536,6 +536,31 @@ void warpByLeaderPublishesWarpWithServer() { assertEquals(alpha, warp.leaderId(), "the event must name the leader"); } + @Test + void warpWithOriginPublishesTheOriginAsFrom() { + PartyFixture f = new PartyFixture(); + UUID alpha = f.online("Alpha"); + Party party = f.partyService.create(alpha); + + f.partyService.warp(party.id(), alpha, "Lobby-2", "Bounce-2", Precondition.NONE); + + PartyEvent warp = lastEvent(f, "party.warp"); + assertEquals("Lobby-2", warp.payload().get("server"), "the event must carry the target server name"); + assertEquals("Bounce-2", warp.payload().get("from"), "the event must carry the server the leader left"); + } + + @Test + void warpWithoutOriginPublishesNoFrom() { + PartyFixture f = new PartyFixture(); + UUID alpha = f.online("Alpha"); + Party party = f.partyService.create(alpha); + + f.partyService.warp(party.id(), alpha, "Bounce-2"); + + PartyEvent warp = lastEvent(f, "party.warp"); + assertFalse(warp.payload().containsKey("from"), "a warp without an origin must not claim one: " + warp.payload()); + } + @Test void warpByMemberIsNotLeader() { PartyFixture f = new PartyFixture(); diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java index 84974ca..228668a 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/QueleaVelocity.java @@ -92,7 +92,7 @@ public void onProxyInitialize(ProxyInitializeEvent event) { VelocityLocalPlayers localPlayers = new VelocityLocalPlayers(proxy); proxy.getEventManager().register(this, - new PresenceListener(gateway, executor, config.proxyId(), follow, logger)); + new PresenceListener(gateway, executor, config.proxyId(), logger)); PartyActions actions = new PartyActions(gateway, executor, messages, logger); PartyCommands commands = new PartyCommands(actions, localPlayers, localPlayers::wrap, messages); diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java index 503d3b7..e06b022 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/command/PartyActions.java @@ -126,7 +126,7 @@ public void warp(LocalPlayer player) { player.send(messages.message(locale, "command.warp.no-server")); return; } - gateway.warp(party.id(), player.id().toString(), server.get()); + gateway.warp(party.id(), player.id().toString(), server.get(), null); player.send(messages.message(locale, "command.warp.done", Placeholder.unparsed("server", server.get()))); }); } diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java index bcb39cb..9ae9b98 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/event/EventHandler.java @@ -166,6 +166,8 @@ private void warp(PartyEvent event) { if (server == null) { return; } + // Absent from warps of services that do not name the origin; the lobby rule then applies alone. + String origin = event.field("from"); for (Member member : event.members()) { if (member.id().equals(event.leaderId())) { continue; @@ -173,7 +175,7 @@ private void warp(PartyEvent event) { UUID id = uuid(member.id()); Optional local = id == null ? Optional.empty() : players.find(id); local.ifPresent(player -> { - if (!follow.shouldFollow(player.currentServer().orElse(null), server)) { + if (!follow.shouldFollow(player.currentServer().orElse(null), server, origin)) { return; } player.connect(server).thenAccept(outcome -> reportWarp(player, server, outcome)); diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/follow/FollowPolicy.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/follow/FollowPolicy.java index baf5130..9ddf8a9 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/follow/FollowPolicy.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/follow/FollowPolicy.java @@ -3,7 +3,7 @@ import java.util.regex.Pattern; /** - * Decides when a party leader's move starts a warp and which members follow a warp. Pure: no proxy calls. + * Decides which members follow a warp. Pure: no proxy calls. */ public final class FollowPolicy { @@ -28,25 +28,23 @@ public boolean isLobby(String server) { } /** - * @param newServer the server the leader just connected to - * @return whether that move is a game the party should follow to - */ - public boolean shouldTriggerWarp(String newServer) { - return newServer != null && !isLobby(newServer); - } - - /** + * A member follows when mode is {@code always}, when the member was on the server the leader left (they were + * together), or when the member is in a lobby. The leader and members already on the target are never moved; + * the caller checks the leader by id. Without an origin (a warp from a service that does not send one) only the + * lobby rule applies. + * * @param memberCurrentServer the server the member is on now, {@code null} when unknown * @param target the server the party was warped to + * @param origin the server the leader left, {@code null} when the warp does not name one * @return whether the member should be connected to {@code target} */ - public boolean shouldFollow(String memberCurrentServer, String target) { + public boolean shouldFollow(String memberCurrentServer, String target, String origin) { if (target == null || target.equals(memberCurrentServer)) { return false; } return switch (mode) { - case LOBBY -> isLobby(memberCurrentServer); case ALWAYS -> true; + case LOBBY -> (origin != null && origin.equals(memberCurrentServer)) || isLobby(memberCurrentServer); }; } } diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGateway.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGateway.java index 3bb359d..c7edf2f 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGateway.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGateway.java @@ -132,10 +132,13 @@ public void disband(String partyId, String actorId) { } @Override - public void warp(String partyId, String actorId, String server) { + public void warp(String partyId, String actorId, String server, String fromServer) { JsonObject body = new JsonObject(); body.addProperty("actorId", actorId); body.addProperty("server", server); + if (fromServer != null) { + body.addProperty("fromServer", fromServer); + } call("POST", partyRoute(partyId) + "/warp", body); } diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaGateway.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaGateway.java index ff93271..2a74366 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaGateway.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/http/QueleaGateway.java @@ -33,7 +33,10 @@ public interface QueleaGateway { void disband(String partyId, String actorId); - void warp(String partyId, String actorId, String server); + /** + * @param fromServer the server the leader left, or {@code null} when the warp does not name one + */ + void warp(String partyId, String actorId, String server, String fromServer); void chat(String partyId, String actorId, String message); diff --git a/velocity/src/main/java/net/onelitefeather/quelea/velocity/listener/PresenceListener.java b/velocity/src/main/java/net/onelitefeather/quelea/velocity/listener/PresenceListener.java index 5717873..802c989 100644 --- a/velocity/src/main/java/net/onelitefeather/quelea/velocity/listener/PresenceListener.java +++ b/velocity/src/main/java/net/onelitefeather/quelea/velocity/listener/PresenceListener.java @@ -5,7 +5,7 @@ import com.velocitypowered.api.event.connection.PostLoginEvent; import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.proxy.Player; -import net.onelitefeather.quelea.velocity.follow.FollowPolicy; +import com.velocitypowered.api.proxy.server.RegisteredServer; import net.onelitefeather.quelea.velocity.http.Party; import net.onelitefeather.quelea.velocity.http.PlayerRef; import net.onelitefeather.quelea.velocity.http.QueleaGateway; @@ -13,26 +13,24 @@ import net.onelitefeather.quelea.velocity.http.QueleaUnavailableException; import org.slf4j.Logger; -import java.util.concurrent.ExecutorService; +import java.util.Optional; +import java.util.concurrent.Executor; /** - * Reports logins and disconnects, and warps a party when its leader moves to a game. Service calls run on the + * Reports logins and disconnects, and warps a party when its leader moves between servers. Service calls run on the * executor, never on Velocity's event thread. */ public final class PresenceListener { private final QueleaGateway gateway; - private final ExecutorService executor; + private final Executor executor; private final String proxyId; - private final FollowPolicy follow; private final Logger logger; - public PresenceListener(QueleaGateway gateway, ExecutorService executor, String proxyId, FollowPolicy follow, - Logger logger) { + public PresenceListener(QueleaGateway gateway, Executor executor, String proxyId, Logger logger) { this.gateway = gateway; this.executor = executor; this.proxyId = proxyId; - this.follow = follow; this.logger = logger; } @@ -49,23 +47,29 @@ public void onDisconnect(DisconnectEvent event) { submit("disconnect of " + event.getPlayer().getUsername(), () -> gateway.disconnect(proxyId, playerId)); } + /** + * A leader's move from a server to another one warps the party to the new server, whatever the server is. The + * first connection after login has no previous server and warps nothing. + */ @Subscribe public void onServerConnected(ServerConnectedEvent event) { - String server = event.getServer().getServerInfo().getName(); - if (!follow.shouldTriggerWarp(server)) { + Optional previous = event.getPreviousServer(); + if (previous.isEmpty()) { return; } + String server = event.getServer().getServerInfo().getName(); + String origin = previous.get().getServerInfo().getName(); Player player = event.getPlayer(); String playerId = player.getUniqueId().toString(); - submit("warp of " + player.getUsername(), () -> warpIfLeader(playerId, server)); + submit("warp of " + player.getUsername(), () -> warpIfLeader(playerId, server, origin)); } - private void warpIfLeader(String playerId, String server) { + private void warpIfLeader(String playerId, String server, String origin) { Party party = gateway.partyOf(playerId).orElse(null); if (party == null || !playerId.equals(party.leaderId())) { return; } - gateway.warp(party.id(), playerId, server); + gateway.warp(party.id(), playerId, server, origin); } private void submit(String action, Runnable call) { diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java index ffdf665..5cb0bca 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/command/FakeQueleaGateway.java @@ -82,7 +82,7 @@ public void disband(String partyId, String actorId) { } @Override - public void warp(String partyId, String actorId, String server) { + public void warp(String partyId, String actorId, String server, String fromServer) { throw new UnsupportedOperationException(); } diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/EventHandlerTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/EventHandlerTest.java index 8467312..206c97c 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/EventHandlerTest.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/event/EventHandlerTest.java @@ -117,6 +117,82 @@ void warpMovesEligibleLocalMembersOnly() { assertTrue(leader.connectRequests().isEmpty(), "the leader is never moved by the warp"); } + private static final UUID CO_PLAYER_ON_ORIGIN = UUID.fromString("00000000-0000-0000-0000-00000000000e"); + + private EventHandler handler(FollowPolicy policy) { + return new EventHandler(players, messages, policy, Clock.fixed(NOW, ZoneOffset.UTC)); + } + + private static PartyEvent warp(String target, String origin) { + String payload = origin == null + ? "{\"server\":\"" + target + "\"}" + : "{\"server\":\"" + target + "\",\"from\":\"" + origin + "\"}"; + return event("party.warp", LEADER.toString(), """ + %s,%s,%s,%s""".formatted(member(LEADER, "Alice"), member(FOLLOWER_IN_LOBBY, "Bob"), + member(FOLLOWER_IN_GAME, "Carol"), member(CO_PLAYER_ON_ORIGIN, "Dave")), payload); + } + + @Test + void leaderLeavingAGameWarpsTheCoPlayerOnTheOriginAndTheLobbyMember() { + FakeLocalPlayer coPlayer = new FakeLocalPlayer(CO_PLAYER_ON_ORIGIN, "Dave", Locale.ENGLISH, "Bounce-2"); + players = new FakeLocalPlayers().with(leader, lobbyFollower, gameFollower, coPlayer); + + handler(lobbyMode).handle(warp("Lobby-2", "Bounce-2")); + + assertEquals(List.of("Lobby-2"), coPlayer.connectRequests(), "the co-player on the game the leader left follows"); + assertEquals(List.of("Lobby-2"), lobbyFollower.connectRequests(), "a member in a lobby follows"); + assertTrue(gameFollower.connectRequests().isEmpty(), "a member in another game stays where they are"); + assertTrue(leader.connectRequests().isEmpty(), "the leader is never moved by the warp"); + } + + @Test + void leaderJoiningAGameWarpsTheLobbyMembersOnly() { + FakeLocalPlayer coPlayer = new FakeLocalPlayer(CO_PLAYER_ON_ORIGIN, "Dave", Locale.ENGLISH, "Lobby-1"); + players = new FakeLocalPlayers().with(leader, lobbyFollower, gameFollower, coPlayer); + + handler(lobbyMode).handle(warp("Bounce-2", "Lobby-1")); + + assertEquals(List.of("Bounce-2"), lobbyFollower.connectRequests(), "the lobby member follows"); + assertEquals(List.of("Bounce-2"), coPlayer.connectRequests(), "the lobby member follows"); + assertTrue(gameFollower.connectRequests().isEmpty(), "a member in another game stays where they are"); + } + + @Test + void leaderMovingBetweenLobbiesAlignsTheMembersInTheOtherLobbies() { + FakeLocalPlayer otherInstance = new FakeLocalPlayer(CO_PLAYER_ON_ORIGIN, "Dave", Locale.ENGLISH, "Lobby-3"); + players = new FakeLocalPlayers().with(leader, lobbyFollower, gameFollower, otherInstance); + + handler(lobbyMode).handle(warp("Lobby-2", "Lobby-1")); + + assertEquals(List.of("Lobby-2"), lobbyFollower.connectRequests(), "a member on the leader's old lobby follows"); + assertEquals(List.of("Lobby-2"), otherInstance.connectRequests(), "a member on another lobby instance follows"); + assertTrue(gameFollower.connectRequests().isEmpty(), "a member in a game stays where they are"); + } + + @Test + void warpWithoutOriginKeepsTheLobbyRule() { + FakeLocalPlayer coPlayer = new FakeLocalPlayer(CO_PLAYER_ON_ORIGIN, "Dave", Locale.ENGLISH, "Bounce-2"); + players = new FakeLocalPlayers().with(leader, lobbyFollower, gameFollower, coPlayer); + + handler(lobbyMode).handle(warp("Lobby-2", null)); + + assertEquals(List.of("Lobby-2"), lobbyFollower.connectRequests(), "the lobby member follows as before"); + assertTrue(coPlayer.connectRequests().isEmpty(), "without an origin a member in a game does not follow"); + assertTrue(gameFollower.connectRequests().isEmpty(), "a member in another game stays where they are"); + } + + @Test + void alwaysModeMovesEveryLocalMemberIncludingTheOneInAnotherGame() { + FakeLocalPlayer coPlayer = new FakeLocalPlayer(CO_PLAYER_ON_ORIGIN, "Dave", Locale.ENGLISH, "Bounce-2"); + players = new FakeLocalPlayers().with(leader, lobbyFollower, gameFollower, coPlayer); + + handler(FollowPolicy.of("(?i)^lobby.*", FollowMode.ALWAYS)).handle(warp("Lobby-2", "Bounce-2")); + + assertEquals(List.of("Lobby-2"), gameFollower.connectRequests(), "always: a member in another game follows too"); + assertEquals(List.of("Lobby-2"), coPlayer.connectRequests(), "always: the co-player follows"); + assertTrue(leader.connectRequests().isEmpty(), "the leader is never moved by the warp"); + } + @Test void refusedWarpTellsTheMemberWhy() { lobbyFollower.refusingConnections(); diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/follow/FollowPolicyTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/follow/FollowPolicyTest.java index f13e4ba..5d56b0c 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/follow/FollowPolicyTest.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/follow/FollowPolicyTest.java @@ -43,50 +43,47 @@ void unknownServerIsNotALobby() { } @Nested - class WarpTrigger { + class LobbyMode { @Test - void gameServerTriggersAWarp() { - assertTrue(lobbyMode().shouldTriggerWarp("Bounce-2")); + void memberInALobbyFollowsToTheTarget() { + assertTrue(lobbyMode().shouldFollow("Lobby-1", "Bounce-2", "Lobby-1")); } @Test - void returningToTheLobbyTriggersNoWarp() { - assertFalse(lobbyMode().shouldTriggerWarp("Lobby-2")); + void memberOnTheOriginFollowsWhenTheLeaderLeftAGame() { + assertTrue(lobbyMode().shouldFollow("Bounce-2", "Lobby-2", "Bounce-2")); } @Test - void unknownServerTriggersNoWarp() { - assertFalse(lobbyMode().shouldTriggerWarp(null)); - } - } - - @Nested - class LobbyMode { - - @Test - void memberInALobbyFollowsToTheTarget() { - assertTrue(lobbyMode().shouldFollow("Lobby-1", "Bounce-2")); + void memberInAnotherGameStaysPut() { + assertFalse(lobbyMode().shouldFollow("Tamias-1", "Lobby-2", "Bounce-2")); } @Test - void memberInAnotherGameStaysPut() { - assertFalse(lobbyMode().shouldFollow("Tamias-1", "Bounce-2")); + void memberInAnotherLobbyInstanceFollowsTheLeader() { + assertTrue(lobbyMode().shouldFollow("Lobby-1", "Lobby-2", "Bounce-2")); } @Test void memberAlreadyOnTheTargetIsNotConnectedAgain() { - assertFalse(lobbyMode().shouldFollow("Bounce-2", "Bounce-2")); + assertFalse(lobbyMode().shouldFollow("Bounce-2", "Bounce-2", "Lobby-1")); } @Test void memberOfUnknownServerDoesNotFollow() { - assertFalse(lobbyMode().shouldFollow(null, "Bounce-2")); + assertFalse(lobbyMode().shouldFollow(null, "Bounce-2", "Lobby-1")); } @Test void noTargetMeansNoMove() { - assertFalse(lobbyMode().shouldFollow("Lobby-1", null)); + assertFalse(lobbyMode().shouldFollow("Lobby-1", null, "Bounce-2")); + } + + @Test + void withoutAnOriginOnlyLobbyMembersFollow() { + assertTrue(lobbyMode().shouldFollow("Lobby-1", "Bounce-2", null)); + assertFalse(lobbyMode().shouldFollow("Tamias-1", "Bounce-2", null)); } } @@ -95,22 +92,22 @@ class AlwaysMode { @Test void memberInALobbyFollows() { - assertTrue(alwaysMode().shouldFollow("Lobby-1", "Bounce-2")); + assertTrue(alwaysMode().shouldFollow("Lobby-1", "Bounce-2", "Lobby-1")); } @Test void memberInAnotherGameFollows() { - assertTrue(alwaysMode().shouldFollow("Tamias-1", "Bounce-2")); + assertTrue(alwaysMode().shouldFollow("Tamias-1", "Bounce-2", "Lobby-1")); } @Test void memberOfUnknownServerFollows() { - assertTrue(alwaysMode().shouldFollow(null, "Bounce-2")); + assertTrue(alwaysMode().shouldFollow(null, "Bounce-2", null)); } @Test void memberAlreadyOnTheTargetDoesNotFollow() { - assertFalse(alwaysMode().shouldFollow("Bounce-2", "Bounce-2")); + assertFalse(alwaysMode().shouldFollow("Bounce-2", "Bounce-2", "Lobby-1")); } } } diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGatewayTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGatewayTest.java index 9c5e8b2..d450170 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGatewayTest.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/http/HttpQueleaGatewayTest.java @@ -123,6 +123,21 @@ void sendsTheLeaderAsJson() { assertTrue(lastRequestBody.get().contains("\"leaderId\":\"leader-1\""), lastRequestBody.get()); } + @Test + void warpSendsTheOriginAsFromServer() { + gateway().warp("p-1", "leader-1", "Lobby-2", "Bounce-2"); + + assertTrue(lastRequestBody.get().contains("\"fromServer\":\"Bounce-2\""), lastRequestBody.get()); + assertTrue(lastRequestBody.get().contains("\"server\":\"Lobby-2\""), lastRequestBody.get()); + } + + @Test + void manualWarpOmitsFromServer() { + gateway().warp("p-1", "leader-1", "Lobby-2", null); + + assertFalse(lastRequestBody.get().contains("fromServer"), lastRequestBody.get()); + } + @Test void mapsAProblemDocumentToItsCode() { reply = new Reply(403, "application/problem+json", """ diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/listener/PresenceListenerTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/listener/PresenceListenerTest.java new file mode 100644 index 0000000..d749de2 --- /dev/null +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/listener/PresenceListenerTest.java @@ -0,0 +1,196 @@ +package net.onelitefeather.quelea.velocity.listener; + +import com.velocitypowered.api.event.player.ServerConnectedEvent; +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import com.velocitypowered.api.proxy.server.ServerInfo; +import net.onelitefeather.quelea.velocity.http.Invite; +import net.onelitefeather.quelea.velocity.http.Member; +import net.onelitefeather.quelea.velocity.http.Party; +import net.onelitefeather.quelea.velocity.http.PlayerRef; +import net.onelitefeather.quelea.velocity.http.QueleaGateway; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Proxy; +import java.net.InetSocketAddress; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PresenceListenerTest { + + private static final UUID LEADER = UUID.fromString("00000000-0000-0000-0000-00000000000a"); + private static final UUID MEMBER = UUID.fromString("00000000-0000-0000-0000-00000000000b"); + private static final Instant JOINED = Instant.parse("2026-10-28T10:00:00Z"); + + private final RecordingGateway gateway = new RecordingGateway(); + private final PresenceListener listener = new PresenceListener(gateway, Runnable::run, "proxy-1", + LoggerFactory.getLogger(PresenceListenerTest.class)); + + /** A party led by Alice with Bob in it; Bob's connections are the member case. */ + private void leaderOfAParty() { + gateway.party = Optional.of(new Party("p-1", LEADER.toString(), + List.of(new Member(LEADER.toString(), "Alice", JOINED), new Member(MEMBER.toString(), "Bob", JOINED)), + JOINED)); + } + + private void connect(UUID player, String previous, String target) { + listener.onServerConnected(new ServerConnectedEvent(stub(Player.class, Map.of( + "getUniqueId", player, "getUsername", player.equals(LEADER) ? "Alice" : "Bob")), + server(target), previous == null ? null : server(previous))); + } + + @Test + void leaderLeavingAGameWarpsThePartyToTheLobbyWithTheGameAsOrigin() { + leaderOfAParty(); + + connect(LEADER, "Bounce-2", "Lobby-2"); + + assertEquals(List.of("p-1:Lobby-2:Bounce-2"), gateway.warps(), "the warp names the target and the origin"); + } + + @Test + void leaderJoiningAGameWarpsThePartyWithTheLobbyAsOrigin() { + leaderOfAParty(); + + connect(LEADER, "Lobby-1", "Bounce-2"); + + assertEquals(List.of("p-1:Bounce-2:Lobby-1"), gateway.warps()); + } + + @Test + void leaderMovingBetweenLobbiesWarpsTheParty() { + leaderOfAParty(); + + connect(LEADER, "Lobby-1", "Lobby-2"); + + assertEquals(List.of("p-1:Lobby-2:Lobby-1"), gateway.warps(), "lobby moves warp too, so the party aligns"); + } + + @Test + void firstConnectionAfterLoginRequestsNoWarp() { + leaderOfAParty(); + + connect(LEADER, null, "Lobby-1"); + + assertTrue(gateway.warps().isEmpty(), "a login has no previous server and warps nothing"); + } + + @Test + void memberMovingServersRequestsNoWarp() { + leaderOfAParty(); + + connect(MEMBER, "Lobby-1", "Bounce-2"); + + assertTrue(gateway.warps().isEmpty(), "only the leader warps the party"); + } + + /** Answers the party lookup from a field and records the warps; every other route is unsupported. */ + private static final class RecordingGateway implements QueleaGateway { + + private Optional party = Optional.empty(); + private final List warps = new ArrayList<>(); + + List warps() { + return List.copyOf(warps); + } + + @Override + public Optional partyOf(String playerId) { + return party; + } + + @Override + public void warp(String partyId, String actorId, String server, String fromServer) { + warps.add(partyId + ":" + server + ":" + fromServer); + } + + @Override + public Party createParty(String leaderId) { + throw new UnsupportedOperationException(); + } + + @Override + public Invite invite(String actorId, String targetName) { + throw new UnsupportedOperationException(); + } + + @Override + public List invites(String playerId) { + throw new UnsupportedOperationException(); + } + + @Override + public Party acceptInvite(String playerId, String partyId) { + throw new UnsupportedOperationException(); + } + + @Override + public void denyInvite(String playerId, String partyId) { + throw new UnsupportedOperationException(); + } + + @Override + public void leave(String playerId) { + throw new UnsupportedOperationException(); + } + + @Override + public Party kick(String partyId, String actorId, String targetId) { + throw new UnsupportedOperationException(); + } + + @Override + public Party promote(String partyId, String actorId, String targetId) { + throw new UnsupportedOperationException(); + } + + @Override + public void disband(String partyId, String actorId) { + throw new UnsupportedOperationException(); + } + + @Override + public void chat(String partyId, String actorId, String message) { + throw new UnsupportedOperationException(); + } + + @Override + public void snapshot(String proxyId, List players) { + throw new UnsupportedOperationException(); + } + + @Override + public void login(String proxyId, String playerId, String name) { + throw new UnsupportedOperationException(); + } + + @Override + public void disconnect(String proxyId, String playerId) { + throw new UnsupportedOperationException(); + } + + @Override + public void heartbeat(String proxyId) { + throw new UnsupportedOperationException(); + } + } + + private static RegisteredServer server(String name) { + return stub(RegisteredServer.class, Map.of("getServerInfo", + new ServerInfo(name, new InetSocketAddress("127.0.0.1", 25565)))); + } + + /** A Velocity interface answering the given getters; the proxy never calls anything else. */ + private static T stub(Class type, Map answers) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, + (proxy, method, args) -> answers.get(method.getName()))); + } +}