From 6a97f411ce8dbc1cddd3ef9699aa553d3a13edbb Mon Sep 17 00:00:00 2001 From: Yakiv Kovalskyi Date: Fri, 10 Jul 2026 06:46:06 +0200 Subject: [PATCH] Server: expire unanswered rematch offers after 60 seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rematch slot used to live until a player disconnected, declined, queued elsewhere, or closed the sheet — an ignored offer left the requester waiting forever against an idle-but-connected opponent. Each slot now carries an expiry task (60s, injectable for tests like the clock override). On expiry both players get rematch_unavailable: the requester stops waiting and an unanswered offer leaves the opponent's sheet — the app already handles that message, so no client changes are needed. Any other slot teardown cancels the task. Completes the optional-hardening item of #39; the decline action itself landed in #55/#65. Co-Authored-By: Claude Fable 5 --- chess-server/README.md | 3 +- .../Sources/App/Online/GameCoordinator.swift | 28 +++++++++++- .../Tests/AppTests/MatchFlowTests.swift | 44 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/chess-server/README.md b/chess-server/README.md index 78c0f2e..148b74e 100644 --- a/chess-server/README.md +++ b/chess-server/README.md @@ -69,7 +69,8 @@ client. Client sends `join_queue` / `leave_queue` / `move` / `resign` / predates the picker. Clocks are enforced server-side; `game_start` echoes the control and both remaining clocks, every `move_played` carries both clocks, and a fallen flag ends the game with reason `timeout`. Rematches - reuse the finished game's control. + reuse the finished game's control; an offer that isn't agreed within 60 + seconds of game end expires, and both players get `rematch_unavailable`. - **Draw offers** stay on the table until answered or either side moves. - **Ratings**: every user starts at Elo 1200; finished games (including timeouts and abandonments) are rated with K=32. Deltas ride along on diff --git a/chess-server/Sources/App/Online/GameCoordinator.swift b/chess-server/Sources/App/Online/GameCoordinator.swift index 39cf206..ca7a8ba 100644 --- a/chess-server/Sources/App/Online/GameCoordinator.swift +++ b/chess-server/Sources/App/Online/GameCoordinator.swift @@ -135,6 +135,9 @@ actor GameCoordinator { /// A rematch is played at the same control as the finished game. let timeControl: TimeControl var requested: Set = [] + /// Expires the slot when the rematch window closes unanswered; + /// cancelled when the slot is dropped for any other reason. + var expiryTask: Task? func opponent(of userID: UUID) -> UUID? { if userID == whiteID { return blackID } @@ -157,9 +160,14 @@ actor GameCoordinator { private var rematchSlots: [UUID: RematchSlot] = [:] private var rematchSlotByUser: [UUID: UUID] = [:] - init(app: Application, clock: ClockConfig? = nil) { + /// How long after a game ends a rematch can still be agreed; afterwards + /// the slot expires and both players are told the offer is gone. + private let rematchWindow: Duration + + init(app: Application, clock: ClockConfig? = nil, rematchWindow: Duration = .seconds(60)) { self.app = app self.clockOverride = clock + self.rematchWindow = rematchWindow } private func clockConfig(for control: TimeControl) -> ClockConfig { @@ -290,10 +298,21 @@ actor GameCoordinator { private func dropRematchSlot(_ slotID: UUID) { guard let slot = rematchSlots.removeValue(forKey: slotID) else { return } + slot.expiryTask?.cancel() rematchSlotByUser[slot.whiteID] = nil rematchSlotByUser[slot.blackID] = nil } + /// The rematch window closed with nobody (or only one side) agreeing: + /// both players hear the offer is gone, so a waiting requester stops + /// waiting and an unanswered offer disappears from the opponent's sheet. + private func expireRematchSlot(_ slotID: UUID) { + guard let slot = rematchSlots[slotID] else { return } + dropRematchSlot(slotID) + send(.rematchUnavailable, to: socketsByUser[slot.whiteID]) + send(.rematchUnavailable, to: socketsByUser[slot.blackID]) + } + private func seat(for userID: UUID) async -> Seat? { guard let socket = socketsByUser[userID], let user = try? await User.find(userID, on: app.db) @@ -487,6 +506,13 @@ actor GameCoordinator { rematchSlotByUser[game.white.userID] = game.id rematchSlotByUser[game.black.userID] = game.id + let slotID = game.id + rematchSlots[game.id]?.expiryTask = Task { [weak self, rematchWindow] in + try? await Task.sleep(for: rematchWindow) + guard !Task.isCancelled else { return } + await self?.expireRematchSlot(slotID) + } + let ratingDeltas = await updateRatings(for: game) // Persist before announcing: clients fetch history as soon as they diff --git a/chess-server/Tests/AppTests/MatchFlowTests.swift b/chess-server/Tests/AppTests/MatchFlowTests.swift index 73270f6..19ab81d 100644 --- a/chess-server/Tests/AppTests/MatchFlowTests.swift +++ b/chess-server/Tests/AppTests/MatchFlowTests.swift @@ -403,6 +403,50 @@ final class MatchFlowTests: XCTestCase { try await match.black.close() } + func testRematchOfferExpires() async throws { + // Replace the coordinator with a sub-second rematch window. + app.gameCoordinator = GameCoordinator(app: app, rematchWindow: .milliseconds(800)) + + let match = try await startMatch() + + try await match.white.send(.resign) + _ = try await match.white.next() // game over + _ = try await match.black.next() // game over + + // White asks; Black lets the offer sit until the window closes. + try await match.white.send(.requestRematch) + guard case .rematchOffered = try await match.black.next() else { + return XCTFail("expected rematch offer relay") + } + + // Both sides learn the window closed: the requester stops waiting + // and the unanswered offer leaves the opponent's sheet. + guard case .rematchUnavailable = try await match.white.next(timeoutSeconds: 5) else { + return XCTFail("requester should be told the offer expired") + } + guard case .rematchUnavailable = try await match.black.next(timeoutSeconds: 5) else { + return XCTFail("offeree should be told the offer expired") + } + + // The slot is gone: a late request is rejected, but queueing for a + // new opponent still works for both players. + try await match.white.send(.requestRematch) + guard case .errorMessage = try await match.white.next() else { + return XCTFail("expired slot should reject rematch requests") + } + try await match.white.send(.joinQueue(timeControl: .default)) + guard case .queued = try await match.white.next() else { + return XCTFail("expired slot must not block re-queueing") + } + try await match.black.send(.joinQueue(timeControl: .default)) + guard case .gameStart = try await match.black.next() else { + return XCTFail("both players should be pairable after expiry") + } + + try await match.white.close() + try await match.black.close() + } + func testRematchUnavailableWhenOpponentQueuesElsewhere() async throws { let match = try await startMatch()