diff --git a/README.md b/README.md index a22b733..7dad173 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ flowchart LR 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`. -**Lonely parties.** A party that still has only its creator after `QUELEA_PARTY_ALONE_TIMEOUT` is disbanded. `QUELEA_PARTY_ALONE_WARNING` before that, the service publishes `party.expiring` with the expiry time, and the proxy tells the leader how many minutes are left. Expired parties are disbanded with the reason `expired-alone`. Every `party.disbanded` event carries a `reason`: `leader` (the leader disbanded), `last-member-left` (only one member remained) or `expired-alone`. Clients must treat an unknown reason like `leader`. +**Lonely parties.** A party that has only one member after `QUELEA_PARTY_ALONE_TIMEOUT` is disbanded. The timeout counts from the creation or from the leave or kick that left that one member, and a join ends it. A leave or kick that leaves one member keeps the party open. `QUELEA_PARTY_ALONE_WARNING` before that, the service publishes `party.expiring` with the expiry time, and the proxy tells the leader how many minutes are left. Expired parties are disbanded with the reason `expired-alone`. Every `party.disbanded` event carries a `reason`: `leader` (the leader disbanded), `last-member-left` (no member remained) or `expired-alone`. Clients must treat an unknown reason like `leader`. **Party versions and conditional requests.** Every party has a `version`: 1 when it is created, +1 with every change. It is in the party JSON and in every event. `GET /v1/parties/{id}` and `GET /v1/players/{id}/party` send it as a strong `ETag` (for example `"4"`) and answer `304 Not Modified` without a body when `If-None-Match` names the current tag. `kick`, `promote`, `disband` and `warp` accept `If-Match` with one or more strong tags or `*`. If none matches the current version, the command changes nothing and answers `412 precondition-failed`. A weak tag (`W/`) never matches. Without the header the command runs unconditionally. @@ -87,7 +87,7 @@ Set these on the service. Defaults are in `backend/src/main/resources/applicatio | `QUELEA_REDIS_LOCK_WAIT` | `1s` | How long a request waits for a busy party lock before it fails with `lock-timeout`. | | `QUELEA_PARTY_MAX_SIZE` | `8` | Maximum members per party, counting the leader. Minimum 2. | | `QUELEA_INVITE_TTL` | `60s` | How long an invite stays valid. Re-inviting refreshes it. | -| `QUELEA_PARTY_ALONE_TIMEOUT` | `30m` | A party that has only its creator for this long is disbanded with reason `expired-alone`. | +| `QUELEA_PARTY_ALONE_TIMEOUT` | `30m` | A party that has only one member for this long is disbanded with reason `expired-alone`. | | `QUELEA_PARTY_ALONE_WARNING` | `5m` | `party.expiring` is published this long before the expiry. `0s` disables the warning. | | `QUELEA_PARTY_EXPIRY_INTERVAL` | `15s` | How often the expiry job runs; one replica per run, under a lock. The expiry can be this late. | | `QUELEA_PROXY_TIMEOUT` | `45s` | A proxy without a heartbeat for this long is treated as dead; its players are disconnected from their parties. | diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/party/Party.java b/backend/src/main/java/net/onelitefeather/quelea/backend/party/Party.java index 6b1c608..70ecff1 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/party/Party.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/party/Party.java @@ -10,14 +10,26 @@ /** * A party in the order its members joined; the leader is one of the members. {@code version} is 1 when the party is * created and grows by one with every change of the party's state; it is the basis of the HTTP entity tag. + * {@code aloneSince} is the start of the current alone period (one member), or {@code null} when the party has two or + * more members or was stored before the field existed; {@link #aloneStart()} resolves the null case to the creation. */ @Serdeable -public record Party(UUID id, UUID leaderId, List members, Instant createdAt, long version) { +public record Party(UUID id, UUID leaderId, List members, Instant createdAt, long version, Instant aloneSince) { public Party { members = List.copyOf(members); } + /** A party without an alone-since time, as before the field existed. */ + public Party(UUID id, UUID leaderId, List members, Instant createdAt, long version) { + this(id, leaderId, members, createdAt, version, null); + } + + /** When the current alone period began: the stored alone-since time, or the creation for a legacy party. */ + public Instant aloneStart() { + return aloneSince != null ? aloneSince : createdAt; + } + public boolean isMember(UUID playerId) { return member(playerId).isPresent(); } @@ -30,13 +42,13 @@ public boolean isLeader(UUID playerId) { return leaderId.equals(playerId); } - /** The party after a change of its members: the next version. */ + /** The party after a join: the next version, with no alone period (a join ends the one before). */ public Party withMembers(List newMembers) { - return new Party(id, leaderId, newMembers, createdAt, version + 1); + return new Party(id, leaderId, newMembers, createdAt, version + 1, null); } /** The party after a change of its leader: the next version. */ public Party withLeader(UUID newLeaderId) { - return new Party(id, newLeaderId, members, createdAt, version + 1); + return new Party(id, newLeaderId, members, createdAt, version + 1, aloneSince); } } 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 5623431..b244b7c 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 @@ -66,7 +66,7 @@ public Party create(UUID leaderId) { throw new PartyException(PartyProblem.ALREADY_IN_PARTY, "The player is already in a party."); } Instant now = clock.instant(); - Party party = new Party(partyId, leaderId, List.of(new Member(leaderId, leader.name(), now)), now, 1); + Party party = new Party(partyId, leaderId, List.of(new Member(leaderId, leader.name(), now)), now, 1, now); parties.save(party); markAlone(party); statistics.created(); @@ -254,7 +254,7 @@ public void warnExpiring(UUID partyId) { if (party == null || party.members().size() != 1) { return null; } - publish(PartyEventType.EXPIRING, party, Map.of("expiresAt", party.createdAt().plus(settings.getAloneTimeout()))); + publish(PartyEventType.EXPIRING, party, Map.of("expiresAt", party.aloneStart().plus(settings.getAloneTimeout()))); return null; }); } @@ -292,8 +292,9 @@ public void chat(UUID partyId, UUID actorId, String message) { } /** - * Removes {@code gone} (after LEFT or KICKED), then either disbands a party of at most one member or hands - * leadership to the longest-standing remaining member. Returns the party state after the change. + * Removes {@code gone} (after LEFT or KICKED). A party left with no member is disbanded; a party left with one + * member stays open and starts a new alone period at this moment; a larger party keeps its members. Leadership + * passes to the longest-standing remaining member when the leader left. Returns the party state after the change. */ private Party removeMember(Party party, Member gone, PartyEventType type, Map payload) { List remaining = party.members().stream() @@ -307,14 +308,19 @@ private Party removeMember(Party party, Member gone, PartyEventType type, Map stillBound, l Map.of("formerMembers", formerMembers, "reason", reason)); } - /** A party of one member expires after the alone timeout and is warned before, unless the warning is off. */ + /** + * A party of one member expires after the alone timeout, counted from the start of its alone period, and is warned + * before, unless the warning is off. + */ private void markAlone(Party party) { - Instant expireAt = party.createdAt().plus(settings.getAloneTimeout()); + Instant expireAt = party.aloneStart().plus(settings.getAloneTimeout()); Duration warning = settings.getAloneWarning(); Instant warnAt = warning.isZero() || warning.isNegative() ? null : expireAt.minus(warning); parties.markAlone(party.id(), expireAt, warnAt); diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/party/port/PartyStore.java b/backend/src/main/java/net/onelitefeather/quelea/backend/party/port/PartyStore.java index b394f3f..5ad09c9 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/party/port/PartyStore.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/party/port/PartyStore.java @@ -40,7 +40,7 @@ public interface PartyStore { /** * Marks a party that has one member: its expiry is due at {@code expireAt}; its warning is due at - * {@code warnAt}, or never when {@code warnAt} is null. + * {@code warnAt}, or never when {@code warnAt} is null; a null {@code warnAt} also removes a warning set earlier. */ void markAlone(UUID partyId, Instant expireAt, Instant warnAt); diff --git a/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStore.java b/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStore.java index b770144..4eca18d 100644 --- a/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStore.java +++ b/backend/src/main/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStore.java @@ -124,6 +124,9 @@ public void markAlone(UUID partyId, Instant expireAt, Instant warnAt) { commands.zadd(keys.aloneExpire(), (double) expireAt.toEpochMilli(), partyId.toString()); if (warnAt != null) { commands.zadd(keys.aloneWarn(), (double) warnAt.toEpochMilli(), partyId.toString()); + } else { + // A restarted alone period without warning must not keep the warning of the one before. + commands.zrem(keys.aloneWarn(), partyId.toString()); } } diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyJsonTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyJsonTest.java new file mode 100644 index 0000000..51ea86b --- /dev/null +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyJsonTest.java @@ -0,0 +1,46 @@ +package net.onelitefeather.quelea.backend.party; + +import io.micronaut.serde.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** The stored form of a party, read and written through the same mapper the Redis store uses. */ +class PartyJsonTest { + + private static final Instant CREATED = Instant.parse("2026-01-01T12:00:00Z"); + private static final UUID PARTY = UUID.fromString("00000000-0000-0000-0000-0000000000aa"); + private static final UUID LEADER = UUID.fromString("00000000-0000-0000-0000-0000000000bb"); + + private final ObjectMapper json = ObjectMapper.getDefault(); + + @Test + void partyStoredBeforeTheAloneSinceFieldReadsItAsNullAndStartsAtCreation() throws Exception { + // The shape 0.4.0 wrote: no aloneSince key at all. + String legacy = """ + {"id":"%s","leaderId":"%s","members":[{"id":"%s","name":"Alpha","joinedAt":"%s"}],\ + "createdAt":"%s","version":3}""".formatted(PARTY, LEADER, LEADER, CREATED, CREATED); + + Party party = json.readValue(legacy, Party.class); + + assertNull(party.aloneSince(), "a party without the key must read the alone-since time as null"); + assertEquals(CREATED, party.aloneStart(), "without an alone-since time, the alone period starts at creation"); + assertEquals(3L, party.version(), "the rest of the stored party must read unchanged"); + } + + @Test + void aloneSinceSurvivesAWriteAndARead() throws Exception { + Instant departure = CREATED.plusSeconds(600); + Party alone = new Party(PARTY, LEADER, List.of(new Member(LEADER, "Alpha", CREATED)), CREATED, 4, departure); + + Party read = json.readValue(json.writeValueAsString(alone), Party.class); + + assertEquals(departure, read.aloneSince(), "the alone-since time must come back unchanged"); + assertEquals(departure, read.aloneStart(), "the alone period must start at the departure, not at creation"); + } +} 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 bb64786..6dc2e95 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 @@ -335,7 +335,7 @@ void leaderLeavingMakesLongestStandingMemberLeader() { } @Test - void lastPartnerLeavingDisbandsPartyAndReleasesRemainingMember() { + void partnerLeavingKeepsThePartyOpenWithTheRemainingMember() { PartyFixture f = new PartyFixture(); UUID alpha = f.online("Alpha"); UUID bravo = f.online("Bravo"); @@ -345,22 +345,23 @@ void lastPartnerLeavingDisbandsPartyAndReleasesRemainingMember() { f.partyService.leave(bravo, "leave"); - assertProblem(PartyProblem.NOT_IN_PARTY, () -> f.partyService.partyOf(alpha)); - assertProblem(PartyProblem.PARTY_NOT_FOUND, () -> f.partyService.get(party.id())); - assertEquals("party.disbanded", lastEventType(f), "the party must be disbanded when one member remains"); + assertEquals(party.id(), f.partyService.partyOf(alpha).id(), "the party must keep its id and the remaining member"); + assertEquals(List.of(alpha), f.partyService.get(party.id()).members().stream().map(Member::id).toList(), + "the remaining member must be the only member"); + assertFalse(f.events.types().contains("party.disbanded"), "a departure that leaves one member must not disband the party"); } @Test - void lastPartnerLeavingCarriesReasonLastMemberLeft() { + void soloPartyLeavingDisbandsWithReasonLastMemberLeft() { PartyFixture f = new PartyFixture(); UUID alpha = f.online("Alpha"); - UUID bravo = f.online("Bravo"); - Party party = join(f, alpha, "Bravo"); + Party party = f.partyService.create(alpha); - f.partyService.leave(bravo, "leave"); + f.partyService.leave(alpha, "leave"); + assertProblem(PartyProblem.PARTY_NOT_FOUND, () -> f.partyService.get(party.id())); assertEquals("last-member-left", lastEvent(f, "party.disbanded").payload().get("reason"), - "a party that drops to one member must be disbanded with reason last-member-left, whatever the leave reason"); + "a party that loses its last member must be disbanded with reason last-member-left"); } @Test @@ -449,7 +450,7 @@ void kickRemovesMemberAndPublishesKicked() { } @Test - void kickOfLastOtherMemberDisbandsParty() { + void kickOfLastOtherMemberKeepsThePartyOpen() { PartyFixture f = new PartyFixture(); UUID alpha = f.online("Alpha"); UUID bravo = f.online("Bravo"); @@ -457,9 +458,10 @@ void kickOfLastOtherMemberDisbandsParty() { f.partyService.kick(party.id(), alpha, bravo); - assertProblem(PartyProblem.PARTY_NOT_FOUND, () -> f.partyService.get(party.id())); - assertProblem(PartyProblem.NOT_IN_PARTY, () -> f.partyService.partyOf(alpha)); - assertEquals("party.disbanded", lastEventType(f), "a party of one must be disbanded"); + assertEquals(party.id(), f.partyService.partyOf(alpha).id(), "the kick must keep the party with its remaining member"); + assertEquals(List.of(alpha), f.partyService.get(party.id()).members().stream().map(Member::id).toList(), + "the leader must be the only member"); + assertEquals("party.kicked", lastEventType(f), "the kick must be published, and no disband follows it"); } @Test diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyStatisticsTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyStatisticsTest.java index a305494..2de5302 100644 --- a/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyStatisticsTest.java +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyStatisticsTest.java @@ -42,13 +42,10 @@ void leaderDisbandCountsReasonLeaderAndRecordsLifetime() { void lastMemberLeavingCountsReasonLastMemberLeftWithLifetime() { PartyFixture f = new PartyFixture(); UUID leader = f.online("Alpha"); - UUID bravo = f.online("Bravo"); f.partyService.create(leader); - Invite invite = f.partyService.invite(leader, "Bravo"); - f.partyService.accept(bravo, invite.partyId()); f.clock.advance(Duration.ofMinutes(10)); - f.partyService.leave(bravo, "quit"); + f.partyService.leave(leader, "quit"); assertEquals(1.0, disbanded(f.meters, "last-member-left"), "the party must count reason last-member-left"); assertEquals(10.0, lifetime(f.meters, "last-member-left").totalTime(TimeUnit.MINUTES), 0.001, diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyStaysOpenTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyStaysOpenTest.java new file mode 100644 index 0000000..bab7718 --- /dev/null +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/party/PartyStaysOpenTest.java @@ -0,0 +1,153 @@ +package net.onelitefeather.quelea.backend.party; + +import net.onelitefeather.quelea.backend.event.PartyEvent; +import net.onelitefeather.quelea.backend.problem.PartyException; +import net.onelitefeather.quelea.backend.problem.PartyProblem; +import net.onelitefeather.quelea.backend.support.PartyFixture; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** The alone period of a party that lost its other members: it starts at the departure, not at creation. */ +class PartyStaysOpenTest { + + @Test + void partyLeftAloneExpiresThirtyMinutesAfterTheDeparture() { + PartyFixture f = new PartyFixture(); + UUID alpha = f.online("Alpha"); + UUID bravo = f.online("Bravo"); + Party party = join(f, alpha, bravo); + f.clock.advance(Duration.ofMinutes(10)); + f.partyService.leave(bravo, "disconnect"); + + f.clock.advance(Duration.ofMinutes(29)); + job(f).sweep(); + assertEquals(party.id(), f.partyService.partyOf(alpha).id(), "the party must be open before the alone timeout has passed"); + + f.clock.advance(Duration.ofMinutes(1)); + job(f).sweep(); + assertProblem(PartyProblem.NOT_IN_PARTY, () -> f.partyService.partyOf(alpha)); + assertEquals("expired-alone", lastEvent(f, "party.disbanded").payload().get("reason"), + "the party must expire with reason expired-alone once the timeout ends"); + } + + @Test + void warningIsPublishedOnceWithTheEndOfTheCurrentAlonePeriod() { + PartyFixture f = new PartyFixture(); + UUID alpha = f.online("Alpha"); + UUID bravo = f.online("Bravo"); + join(f, alpha, bravo); + f.clock.advance(Duration.ofMinutes(10)); + Instant departure = f.clock.instant(); + f.partyService.leave(bravo, "disconnect"); + + f.clock.advance(Duration.ofMinutes(25)); + job(f).sweep(); + job(f).sweep(); + + assertEquals(1, count(f, "party.expiring"), "the warning must be published exactly once"); + assertEquals(departure.plus(Duration.ofMinutes(30)), lastEvent(f, "party.expiring").payload().get("expiresAt"), + "the warning must carry the end of the alone period that started at the departure"); + } + + @Test + void secondMemberJoiningCancelsTheAloneTimer() { + PartyFixture f = new PartyFixture(); + UUID alpha = f.online("Alpha"); + UUID bravo = f.online("Bravo"); + UUID charlie = f.online("Charlie"); + Party party = join(f, alpha, bravo); + f.partyService.leave(bravo, "disconnect"); + f.clock.advance(Duration.ofMinutes(10)); + f.partyService.invite(alpha, "Charlie"); + f.partyService.accept(charlie, party.id()); + + f.clock.advance(Duration.ofMinutes(40)); + job(f).sweep(); + + assertEquals(2, f.partyService.get(party.id()).members().size(), "a party with two members must not expire"); + assertFalse(f.events.types().contains("party.disbanded"), "no disband may follow a join"); + assertEquals(0, count(f, "party.expiring"), "no warning may be published once a second member has joined"); + } + + @Test + void aloneAgainRestartsTheTimerFromTheNewDeparture() { + PartyFixture f = new PartyFixture(); + UUID alpha = f.online("Alpha"); + UUID bravo = f.online("Bravo"); + UUID charlie = f.online("Charlie"); + Party party = join(f, alpha, bravo); + f.clock.advance(Duration.ofMinutes(10)); + f.partyService.leave(bravo, "disconnect"); + f.clock.advance(Duration.ofMinutes(2)); + f.partyService.invite(alpha, "Charlie"); + f.partyService.accept(charlie, party.id()); + f.clock.advance(Duration.ofMinutes(8)); + f.partyService.leave(charlie, "disconnect"); + + f.clock.advance(Duration.ofMinutes(20)); + job(f).sweep(); + assertEquals(party.id(), f.partyService.partyOf(alpha).id(), + "the party must still be open 30 minutes after the first departure, as the timer restarted"); + + f.clock.advance(Duration.ofMinutes(9)); + job(f).sweep(); + assertEquals(party.id(), f.partyService.partyOf(alpha).id(), "the timer must not end before 30 minutes after the second departure"); + + f.clock.advance(Duration.ofMinutes(1)); + job(f).sweep(); + assertProblem(PartyProblem.NOT_IN_PARTY, () -> f.partyService.partyOf(alpha)); + assertEquals("expired-alone", lastEvent(f, "party.disbanded").payload().get("reason")); + } + + @Test + void legacyPartyWithoutAloneSinceExpiresByItsCreation() { + PartyFixture f = new PartyFixture(); + UUID alpha = f.online("Alpha"); + Instant created = f.clock.instant(); + UUID partyId = UUID.randomUUID(); + // Written by an older backend: no aloneSince, its alone sets were derived from createdAt. + f.parties.save(new Party(partyId, alpha, List.of(new Member(alpha, "Alpha", created)), created, 1)); + f.parties.claimPlayer(alpha, partyId); + f.parties.markAlone(partyId, created.plus(Duration.ofMinutes(30)), created.plus(Duration.ofMinutes(25))); + + f.clock.advance(Duration.ofMinutes(25)); + job(f).sweep(); + + assertEquals(created.plus(Duration.ofMinutes(30)), lastEvent(f, "party.expiring").payload().get("expiresAt"), + "a legacy party must warn with the end computed from its creation"); + assertTrue(f.parties.find(partyId).isPresent()); + } + + private static Party join(PartyFixture f, UUID leader, UUID other) { + Invite invite = f.partyService.invite(leader, f.presence.find(other).orElseThrow().name()); + return f.partyService.accept(other, invite.partyId()); + } + + private static PartyExpiryJob job(PartyFixture f) { + return new PartyExpiryJob(f.locks, f.parties, f.partyService, f.clock); + } + + private static long count(PartyFixture f, String type) { + return f.events.types().stream().filter(type::equals).count(); + } + + private static PartyEvent lastEvent(PartyFixture f, String type) { + List matching = f.events.events().stream().filter(e -> e.type().equals(type)).toList(); + assertTrue(!matching.isEmpty(), "no event of type " + type + " was published: " + f.events.types()); + return matching.get(matching.size() - 1); + } + + private static void assertProblem(PartyProblem expected, org.junit.jupiter.api.function.Executable action) { + PartyException thrown = assertThrows(PartyException.class, action, "expected " + expected.code()); + assertEquals(expected, thrown.problem(), "wrong problem for the rule that was broken"); + } +} diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStoreTest.java b/backend/src/test/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStoreTest.java index fbbd554..eee3386 100644 --- a/backend/src/test/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStoreTest.java +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/store/redis/RedisPartyStoreTest.java @@ -98,6 +98,40 @@ void saveThenFindReturnsTheSameParty() { assertEquals(party, store.find(party.id()).orElseThrow()); } + @Test + void partyWithAloneSinceRoundTripsIt() { + Party alone = new Party(UUID.randomUUID(), UUID.randomUUID(), List.of(new Member(UUID.randomUUID(), "Alpha", NOW)), + NOW, 3, NOW.plus(Duration.ofMinutes(10))); + + store.save(alone); + + assertEquals(NOW.plus(Duration.ofMinutes(10)), store.find(alone.id()).orElseThrow().aloneSince(), + "the stored alone-since time must come back unchanged"); + } + + @Test + void partyStoredWithoutAloneSinceReadsItAsNullAndStartsAtCreation() { + Party legacy = party(UUID.randomUUID()); + + store.save(legacy); + Party read = store.find(legacy.id()).orElseThrow(); + + assertEquals(null, read.aloneSince(), "a party without a stored alone-since time must read it as null"); + assertEquals(NOW, read.aloneStart(), "without alone-since, the alone period starts at creation"); + } + + @Test + void restartedAlonePeriodWithoutWarningDropsTheOldWarning() { + UUID party = UUID.randomUUID(); + store.markAlone(party, NOW.plus(Duration.ofMinutes(30)), NOW.plus(Duration.ofMinutes(25))); + + store.markAlone(party, NOW.plus(Duration.ofMinutes(60)), null); + + assertTrue(store.dueWarnings(NOW.plus(Duration.ofMinutes(26))).isEmpty(), + "the warning of the earlier alone period must not fire after the restart without warning"); + assertEquals(List.of(party), store.dueExpiries(NOW.plus(Duration.ofMinutes(60))), "the new expiry must be scheduled"); + } + @Test void findReturnsEmptyForUnknownParty() { assertTrue(store.find(UUID.randomUUID()).isEmpty()); diff --git a/backend/src/test/java/net/onelitefeather/quelea/backend/support/InMemoryPartyStore.java b/backend/src/test/java/net/onelitefeather/quelea/backend/support/InMemoryPartyStore.java index 89cb545..5df7889 100644 --- a/backend/src/test/java/net/onelitefeather/quelea/backend/support/InMemoryPartyStore.java +++ b/backend/src/test/java/net/onelitefeather/quelea/backend/support/InMemoryPartyStore.java @@ -105,6 +105,8 @@ public void markAlone(UUID partyId, Instant expireAt, Instant warnAt) { expiries.put(partyId, expireAt); if (warnAt != null) { warnings.put(partyId, warnAt); + } else { + warnings.remove(partyId); } } 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 9ae9b98..5073a4c 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 @@ -30,6 +30,7 @@ public final class EventHandler { private static final String EXPIRED_ALONE = "expired-alone"; private static final String DISBANDED_BY_LEADER = "leader"; + private static final String ALONE_KEY = "party.left.alone"; private final LocalPlayers players; private final Messages messages; @@ -50,9 +51,8 @@ public void handle(PartyEvent event) { case "party.invited" -> invited(event); case "party.joined" -> notifyOthers(event, "party.joined", playerName(event)); case "party.denied" -> notifyOthers(event, "party.denied", playerName(event)); - case "party.left" -> notifyOthers(event, "party.left", playerName(event)); - case "party.kicked" -> notifyMembers(event, event.leaderId(), event.field("playerId"), "party.kicked", - playerName(event)); + case "party.left" -> left(event); + case "party.kicked" -> kicked(event); case "party.leader-changed" -> leaderChanged(event); case "party.disbanded" -> disbanded(event); case "party.expiring" -> expiring(event); @@ -114,6 +114,31 @@ private void notifyOthers(PartyEvent event, String key, TagResolver... resolvers notifyMembers(event, actorId, actorId, key, resolvers); } + /** A leave that leaves one member tells that member the party stays open; a larger party keeps the generic line. */ + private void left(PartyEvent event) { + if (event.members().size() == 1) { + sendTo(event.members().get(0).id(), ALONE_KEY, playerName(event)); + return; + } + notifyOthers(event, "party.left", playerName(event)); + } + + /** + * A kick that leaves one member: the kicked player gets the self line, and the remaining member (the kicking + * leader, who is the actor) gets the alone line, which the command answer does not give. + */ + private void kicked(PartyEvent event) { + if (event.members().size() != 1) { + notifyMembers(event, event.leaderId(), event.field("playerId"), "party.kicked", playerName(event)); + return; + } + String kickedId = event.field("playerId"); + if (kickedId != null) { + sendTo(kickedId, "party.kicked.self", playerName(event)); + } + sendTo(event.members().get(0).id(), ALONE_KEY, playerName(event)); + } + private void leaderChanged(PartyEvent event) { String leaderId = event.leaderId(); String actorId = event.field("previousLeaderId"); diff --git a/velocity/src/main/resources/lang/messages_de.properties b/velocity/src/main/resources/lang/messages_de.properties index ae1c257..88ac5c0 100644 --- a/velocity/src/main/resources/lang/messages_de.properties +++ b/velocity/src/main/resources/lang/messages_de.properties @@ -87,6 +87,7 @@ party.denied= hat die Einladung abgelehnt. party.left= hat die Party verlassen. party.kicked= <#fca5a5>wurde aus der Party entfernt. party.kicked.self=<#fca5a5>Du wurdest aus der Party entfernt. +party.left.alone= ist nicht mehr in deiner Party. Sie bleibt offen, schließt sich aber, wenn eine Weile niemand beitritt. party.leader-changed=★ ist jetzt der Party-Leiter. party.leader-changed.self=★ Du bist jetzt der Party-Leiter. party.disbanded=<#fca5a5>Die Party wurde aufgelöst. diff --git a/velocity/src/main/resources/lang/messages_en.properties b/velocity/src/main/resources/lang/messages_en.properties index bafc242..4c4d707 100644 --- a/velocity/src/main/resources/lang/messages_en.properties +++ b/velocity/src/main/resources/lang/messages_en.properties @@ -87,6 +87,7 @@ party.denied= declined the invite. party.left= left the party. party.kicked= <#fca5a5>was kicked from the party. party.kicked.self=<#fca5a5>You were kicked from the party. +party.left.alone= is no longer in your party. It stays open, but closes if nobody joins for a while. party.leader-changed=★ is the new party leader. party.leader-changed.self=★ You are the new party leader. party.disbanded=<#fca5a5>The party was disbanded. 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 206c97c..541e7ba 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 @@ -2,6 +2,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.onelitefeather.quelea.velocity.follow.FollowMode; import net.onelitefeather.quelea.velocity.follow.FollowPolicy; @@ -333,6 +334,50 @@ void leaveNotifiesTheRemainingMembersButNotTheLeaver() { assertEquals(1, leader.sent().size(), "the remaining member is told"); } + @Test + void leaveLeavingOneMemberTellsTheRemainingMemberThatThePartyStaysOpen() { + PartyEvent left = event("party.left", LEADER.toString(), member(LEADER, "Alice"), """ + {"playerId":"%s","playerName":"Bob"}""".formatted(FOLLOWER_IN_LOBBY)); + + handler().handle(left); + + assertEquals(List.of(plain(aloneMessage("Bob"))), sentTexts(leader), + "the single remaining member must get the party.left.alone message, and only that"); + } + + @Test + void kickLeavingOneMemberTellsTheRemainingLeaderThatThePartyStaysOpen() { + PartyEvent kicked = event("party.kicked", LEADER.toString(), member(LEADER, "Alice"), """ + {"playerId":"%s","playerName":"Bob"}""".formatted(FOLLOWER_IN_LOBBY)); + + handler().handle(kicked); + + assertEquals(List.of(plain(aloneMessage("Bob"))), sentTexts(leader), + "the leader who kicked, left alone, is told the party stays open; the command answer does not say it"); + assertEquals(1, lobbyFollower.sent().size(), "the kicked player is told"); + } + + @Test + void leaveWithTwoRemainingKeepsTheGenericLeftMessage() { + PartyEvent left = event("party.left", LEADER.toString(), """ + %s,%s""".formatted(member(LEADER, "Alice"), member(FOLLOWER_IN_GAME, "Carol")), """ + {"playerId":"%s","playerName":"Bob"}""".formatted(FOLLOWER_IN_LOBBY)); + + handler().handle(left); + + String generic = plain(messages.message(Locale.ENGLISH, "party.left", Placeholder.unparsed("player", "Bob"))); + assertEquals(List.of(generic), sentTexts(leader), "the leader must get the generic party.left message"); + assertEquals(List.of(generic), sentTexts(gameFollower), "the other member must get the generic party.left message"); + } + + private Component aloneMessage(String player) { + return messages.message(Locale.ENGLISH, "party.left.alone", Placeholder.unparsed("player", player)); + } + + private static List sentTexts(FakeLocalPlayer player) { + return player.sent().stream().map(EventHandlerTest::plain).toList(); + } + @Test void promoteNotifiesTheNewLeaderAndOthersButNotThePreviousLeader() { PartyEvent promoted = event("party.leader-changed", FOLLOWER_IN_LOBBY.toString(), """ diff --git a/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java b/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java index b2067ff..c0a8caa 100644 --- a/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java +++ b/velocity/src/test/java/net/onelitefeather/quelea/velocity/i18n/MessagesTest.java @@ -144,6 +144,12 @@ void expiryMessagesExistInBothLanguages() { } } + @Test + void remainingMemberMessageExistsInBothLanguages() { + assertTrue(messages().hasKey(Locale.ENGLISH, "party.left.alone"), "English is missing party.left.alone"); + assertTrue(messages().hasKey(Locale.GERMAN, "party.left.alone"), "German is missing party.left.alone"); + } + @Test void germanExpiryMessageNamesTheMinutesLeft() { String text = plain(messages().line(Locale.GERMAN, "party.expiring", Placeholder.unparsed("minutes", "5")));