Skip to content
Open
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
3 changes: 2 additions & 1 deletion chess-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion chess-server/Sources/App/Online/GameCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ actor GameCoordinator {
/// A rematch is played at the same control as the finished game.
let timeControl: TimeControl
var requested: Set<UUID> = []
/// Expires the slot when the rematch window closes unanswered;
/// cancelled when the slot is dropped for any other reason.
var expiryTask: Task<Void, Never>?

func opponent(of userID: UUID) -> UUID? {
if userID == whiteID { return blackID }
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions chess-server/Tests/AppTests/MatchFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading