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
86 changes: 86 additions & 0 deletions openspec/changes/keep-parties-after-members-leave/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Design

## Context

`PartyService.removeMember` (used by leave and kick) disbands any party that is left with at most one member, with reason
`last-member-left`. The lonely-party rules from `add-party-lifecycle` mark a party alone at creation, clear the mark on
join, and compute the warning and expiry from `createdAt`. Warnings and expiries live in two Redis sorted sets
(`parties:alone:warn`, `parties:alone:expire`), written by `markAlone`, read by `PartyExpiryJob`.

## Decisions

### 1. Reuse the alone timeout

A party that drops to one member gets the same `QUELEA_PARTY_ALONE_TIMEOUT` and `QUELEA_PARTY_ALONE_WARNING` as a new
party. Rejected: a separate `left-alone-timeout` setting. Nobody asked for a different duration, and one setting keeps
the operator's table in the README short.

### 2. Stored timestamp: `aloneSince` on the party

`Party` gets one nullable field, `aloneSince` (Instant). It is set to `createdAt` on creation, set to the departure
time when a leave or a kick leaves exactly one member, and cleared to `null` when a join makes the party two-member.
The alone end is `(aloneSince ?? createdAt) + aloneTimeout`. The warning time is that end minus the warning.

A party stored without the field (written before this change) reads `null` and therefore expires by `createdAt` exactly
as before. No migration is needed; Dragonfly keys are ephemeral.

Rejected alternatives:
- Read the expiry from the score of `parties:alone:expire`. The sorted sets are derived indexes; a missing or cleared
entry would then change what `party.expiring` says. The state belongs to the party record, the index only orders the
work.
- Rewrite `createdAt` when the party drops to one member. That would change `quelea.party.lifetime` and every event's
meaning of `createdAt`, to serve one field.
- Keep a separate `aloneSince` key per party in Redis. One more key to create, expire and delete on every disband, for a
value that already belongs to the party record.

### 3. `markAlone` clears a stale warning

`markAlone(partyId, expireAt, null)` currently only adds the expiry entry. When a party restarts its alone period with the
warning disabled, an old warning entry could still fire at the old time. `markAlone` therefore removes the warning entry
when there is no warning time. The restart in the second join-and-leave cycle is covered by a Redis integration test.

### 4. Leave and kick share one path

`kick` already calls `removeMember`. A kick that leaves one member therefore follows the same rule as a leave: the party
stays, the remaining member becomes leader if the leader was removed, and the alone period starts. No second code path.

### 5. Events for the departure

A leave or kick that leaves one member publishes the departure event (`party.left` or `party.kicked`) with the remaining
members, then `party.leader-changed` if leadership moved, as a party with more members does. It publishes no
`party.disbanded`. The wire format is unchanged.

### 6. Mixed versions with velocity plugin 0.4.0

Checked against the 0.4.0 tag (`EventHandler`, `messages_en.properties`):
- `party.left` and `party.kicked` are handled generically. The remaining member is in `event.members()`, so 0.4.0 sends
them the existing "X left the party." or "X was kicked from the party." line. No exception, no missing member.
- `party.leader-changed` for a remaining leader is handled (`party.leader-changed.self`).
- `party.expiring` is handled (`expiresAt` parsed, minutes rounded up, sent to the leader if local). It arrives 25 minutes
after the departure, not at the departure.
- `party.disbanded` with `expired-alone` is handled and sent to the former members.

So 0.4.0 copes with a one-member party after a leave. The gap is the notice at the departure: the message says that
someone left and nothing says the party stays open. The new key covers that, see decision 7. A 0.4.0 plugin without the
key keeps the generic line and still gets the warning and the expiry messages.

### 7. Message for the remaining member

Existing keys (en and de): `party.left`, `party.kicked` and `party.expiring`. `party.expiring` says "closes in N minutes
because nobody joined", which is not the situation after a leave, and no key says the party stays open for a while.
A new key `party.left.alone` (and `party.kicked.alone`, if the wording differs for kicks) is sent to the single remaining
member after `party.left` or `party.kicked` leaves one member. Its text names the departure and says the party closes if
nobody joins for a while, without a minute count, because the event carries no expiry time.

### 8. Overlap with `add-open-party-joining`

That change keeps the lonely expiry unchanged for public parties. This change adds the alone period for parties that lost
members, for any party type. The two do not touch the same branch: the expiry rule is the same for both, and a public party
simply keeps its current join path. Whichever lands first, the other rebases onto it; no shared field is renamed.

## Rejected alternatives (summary)

- A separate `left-alone-timeout`: no requirement for a different duration.
- A short grace period only for reconnects: a reconnect can take longer than any grace period, and a party that is never
closed is the case this change removes; the same timeout covers the reconnect case.
- Keeping `last-member-left` for every drop to one member: contradicts the requirement that a party stays open.
66 changes: 66 additions & 0 deletions openspec/changes/keep-parties-after-members-leave/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Proposal

**Ships as:** `fix(party)`: one code pull request `fix(party): keep a party alive after its other members leave`.

## Why

A player creates a party, invites a friend, and the two play for an hour. The friend's connection drops, or they
leave to get something to eat, and the party is gone the moment they leave: the player who stayed is told that the
party was disbanded because the last member left. When the friend reconnects a few minutes later, there is no party
to rejoin. The player has to create one again and invite everyone again.

The alone rule already exists for parties that never got a second member: they close after the alone timeout. The
team decided on 2026-10-28 that "alone" means the same thing for a party that has lost its other members: no second
member for the alone timeout, counted from creation or from the moment the last other member left. Only the creation
case was built. A party that drops to one member through a leave or a kick is still disbanded on the spot.

## What Changes

- **A party that drops to one member stays open.** When a leave or a kick leaves exactly one member, the party keeps
its id and its remaining member. If the leader left, leadership passes by the existing succession rule (the
longest-standing remaining member). The party is not disbanded.
- **The alone timeout restarts at that moment.** The party gets the same alone timeout as a new party
(`QUELEA_PARTY_ALONE_TIMEOUT`, default 30 minutes), counted from the moment it became alone. The alone warning
(`QUELEA_PARTY_ALONE_WARNING`, default 5 minutes) is sent before that end, as today.
- **A second member cancels the timer.** A join ends the alone period. If the party becomes alone again later, the timer
starts again from that new moment.
- **A party that drops to zero members is disbanded at once** with `last-member-left`, as today.
- **The end of the timer is reported with its real time.** `party.expiring` carries the moment the alone timeout ends,
counted from the start of the current alone period, not from the creation of the party.
- **The remaining member is told what happened.** The velocity plugin tells the remaining member, in English and
German, that the others left and that the party closes if nobody joins for a while.

A party written by an older backend, without the new stored timestamp, still loads and expires by its creation time as
before.

## Capabilities

### New Capabilities
None.

### Modified Capabilities
- `party-management`: lonely-party expiry counts from the start of the current alone period; a departure that leaves one
member keeps the party open.
- `party-events`: the disband reason `last-member-left` is used only when no member remains; `party.expiring` carries
the real expiry time.
- `velocity-plugin`: the remaining member is told that the party stays open after a leave or a kick.

## Out of Scope

- A different timeout for parties that lost members. Nobody asked for one; the existing setting applies.
- A grace period that only applies to reconnects.
- Keeping a party of zero members, or re-inviting on behalf of a player.
- Changing how public parties are joined. That is `add-open-party-joining`; the two changes are compatible in either
order (see design).
- Archiving `add-party-lifecycle`. It is still unarchived; this change's deltas refer to its requirements.

## Impact

- Backend: `PartyService` (leave, kick, accept, expiry and warning), `Party` (one new nullable field), the Redis
adapter for the alone sets (`markAlone` must also clear a stale warning), tests with a fixed `Clock`.
- Velocity plugin: one new message key in English and German for the remaining member, the event handler for
`party.left` and `party.kicked`, tests.
- Statistics: `last-member-left` becomes rarer. `quelea.party.lifetime` keeps its meaning (creation to disband) and the
dashboard keeps working; `expired-alone` lifetimes become longer for parties that lost members.
- API: no change to the HTTP surface. The `party.expiring` `expiresAt` value is now correct for parties that lost
members.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Spec Delta

## Purpose
Event model and real-time delivery to the clients of the event stream.

## MODIFIED Requirements

### Requirement: Disband reason
When the service disbands a party, the `party.disbanded` event SHALL carry `reason` in its payload with the value `leader`,
`last-member-left` or `expired-alone`. The value `last-member-left` SHALL be used only when the departure left no member in
the party. A party that drops to one member is not disbanded by the departure.

#### Scenario: Leader disbands
- **WHEN** the leader disbands the party
- **THEN** the `party.disbanded` payload has `reason` `leader`

#### Scenario: Last member leaves
- **GIVEN** player A is the only member of a party
- **WHEN** A leaves
- **THEN** the `party.disbanded` payload has `reason` `last-member-left`

#### Scenario: Alone timeout ends
- **GIVEN** a party has had one member since 10:00
- **WHEN** the alone timeout ends at 10:30
- **THEN** the `party.disbanded` payload has `reason` `expired-alone`

## ADDED Requirements

### Requirement: Expiry time of party.expiring
The `party.expiring` event SHALL carry `expiresAt`, the moment the alone timeout of the party's current alone period ends. The
alone period starts at the party's creation or at the departure that left the party with one member. For a party stored without
an alone-since value, the alone period starts at its creation.

#### Scenario: Party was left alone
- **GIVEN** a party was created at 09:00 with members A and B, and B left at 10:00
- **WHEN** the warning is due and the alone timeout is 30 minutes
- **THEN** `party.expiring` carries `expiresAt` 10:30, not 09:30

#### Scenario: Legacy party
- **GIVEN** a party was created at 09:00 by an older backend and has no alone-since value
- **WHEN** the warning is due
- **THEN** `party.expiring` carries `expiresAt` 09:30
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Spec Delta

## Purpose
Party lifecycle, membership rules and invites.

## MODIFIED Requirements

### Requirement: Lonely-party expiry
While a party has exactly one member, the service SHALL count its alone time from the moment that alone period began, which
is the party's creation or the departure that left it with one member. The service SHALL publish `party.expiring` once the
configured warning time before the end of the configured alone timeout is reached, and SHALL disband the party with the reason
`expired-alone` when the alone timeout has passed, at most one expiry interval late. A second member joining the party SHALL
end the alone period.

#### Scenario: Nobody joins
- **GIVEN** player A created a party 30 minutes ago and nobody accepted an invite
- **WHEN** the expiry job runs
- **THEN** the party is disbanded, A is in no party and a `party.disbanded` event with reason `expired-alone` is published

#### Scenario: Warning before expiry
- **GIVEN** player A created a party 25 minutes ago and nobody joined
- **WHEN** the expiry job runs
- **THEN** a `party.expiring` event with the expiry time is published exactly once

#### Scenario: Somebody joins in time
- **GIVEN** player A created a party 20 minutes ago
- **WHEN** player B accepts A's invite
- **THEN** the party is no longer subject to expiry

#### Scenario: Several replicas
- **GIVEN** two replicas run the expiry job at the same moment
- **WHEN** a party is due
- **THEN** it is disbanded once and one `party.disbanded` event is published

## ADDED Requirements

### Requirement: Party stays open after its other members leave
When a leave or a kick leaves a party with exactly one member, the service SHALL keep the party with its id, SHALL keep that
member in it, SHALL make that member leader when the departed member was the leader, and SHALL start a new alone period at the
moment of the departure. The service SHALL NOT disband such a party because of the departure.

#### Scenario: Drops to one and stays open
- **GIVEN** player A leads a party with members A and B
- **WHEN** B leaves the party
- **THEN** the party still exists with the same id, A is its only member and its leader, and no `party.disbanded` event is published

#### Scenario: Leader leaves and the other member takes over
- **GIVEN** player A leads a party with members A and B
- **WHEN** A leaves the party
- **THEN** the party still exists with B as its only member and its leader, and a `party.leader-changed` event names B

#### Scenario: Expiry after the timeout
- **GIVEN** B left a party of A and B at 10:00, and nobody joined since
- **WHEN** the expiry job runs at or after 10:30
- **THEN** the party is disbanded with reason `expired-alone` and A is in no party

#### Scenario: Warning at the right time
- **GIVEN** B left a party of A and B at 10:00, and nobody joined since
- **WHEN** the expiry job runs at 10:25
- **THEN** a `party.expiring` event is published exactly once, with `expiresAt` 10:30

#### Scenario: Second member cancels
- **GIVEN** B left a party of A and B at 10:00
- **WHEN** player C accepts A's invite at 10:10
- **THEN** the party is no longer subject to expiry, and no `party.expiring` event is published at 10:25

#### Scenario: Alone again restarts the timer
- **GIVEN** A and B are in a party; B leaves at 10:10; C joins at 10:12; C leaves at 10:20
- **WHEN** the expiry job runs at 10:30
- **THEN** the party is still open with A as its only member, and it is disbanded with reason `expired-alone` at or after 10:50, not at 10:40

#### Scenario: Zero members disbands at once
- **GIVEN** player A is the only member of a party
- **WHEN** A leaves the party
- **THEN** the party is disbanded immediately with reason `last-member-left`, and no alone period is started

#### Scenario: Kick that leaves one member behaves like a leave
- **GIVEN** player A leads a party with members A and B
- **WHEN** A kicks B
- **THEN** the party stays open with A as its only member, and the alone period starts at the kick

#### Scenario: Legacy party without the stored alone time
- **GIVEN** a party was stored by an older backend with creation time 09:00 and no alone-since value
- **WHEN** the expiry job runs at 09:30
- **THEN** the party is disbanded with reason `expired-alone`, as it would have been before this change
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Spec Delta

## Purpose
Velocity proxy plugin: party commands, party messages and event handling.

## ADDED Requirements

### Requirement: Remaining member is told the party stays open
When a `party.left` or `party.kicked` event leaves exactly one member in the party, the plugin SHALL tell that member, in the
locale of the player, that the others left or were removed and that the party closes if nobody joins for a while. With more
members left, the plugin SHALL keep its existing messages.

#### Scenario: Partner leaves
- **GIVEN** a local player A is the only member left after B left
- **WHEN** the plugin handles `party.left`
- **THEN** A receives the `party.left.alone` message in A's locale

#### Scenario: Partner is kicked
- **GIVEN** a local player A is the only member left after the leader kicked B
- **WHEN** the plugin handles `party.kicked`
- **THEN** A receives the `party.left.alone` message in A's locale

#### Scenario: Larger party
- **GIVEN** three members, one of them leaves
- **WHEN** the plugin handles `party.left`
- **THEN** the remaining two receive `party.left` and no `party.left.alone` message is sent

#### Scenario: Older backend behaviour
- **GIVEN** a party that a plugin 0.4.0 receives a `party.left` for with one member left
- **WHEN** the plugin handles it
- **THEN** the remaining member receives the generic `party.left` message and nothing fails
Loading
Loading