Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
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;

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) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> payload = fromServer == null
? Map.of("server", server)
: Map.of("server", server, "from", fromServer);
publish(PartyEventType.WARP, party, payload);
return null;
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())));
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,14 +166,16 @@ 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;
}
UUID id = uuid(member.id());
Optional<LocalPlayer> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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);
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,32 @@
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;
import net.onelitefeather.quelea.velocity.http.QueleaProblemException;
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;
}

Expand All @@ -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<RegisteredServer> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading