Files
amethyst/docs/chess-engine-refactoring.md
nrobi144 42b33ca04c feat(chess): add platform adapters and enhance lobby state (steps 5-6)
- Add ChessLobbyState with completedGames, replaceGameState(), moveToCompleted()
- Add challengerAvatarUrl to ChessChallenge
- Add CompletedGame data class for game history

Platform Adapters:
- AndroidChessAdapter: AndroidChessPublisher, AndroidRelayFetcher, AndroidMetadataProvider
- DesktopChessAdapter: DesktopChessPublisher, DesktopRelayFetcher, DesktopMetadataProvider

Shared Infrastructure:
- ChessRelayFetchHelper for one-shot relay queries
- IUserMetadataProvider interface for platform-specific metadata
- ChessFilterBuilder with simple filter methods for fetchers
- ChessSubscriptionController interface for subscription management

Fix ChessSubscription.kt to remove broken dataSources().chess reference
(subscriptions now managed by ChessLobbyLogic)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:14:34 +02:00

11 KiB

Chess Engine Refactoring

Context

Chess feature had inconsistent state reconstruction:

  • Android: Replays moves from LocalCache.addressables (can get stale/desync)
  • Desktop: Buffers pendingMoves and syncs via forceResync(fen) (fragile)

Both ViewModels (~1200 + ~956 lines) duplicate logic that now exists in shared components.

Completed Work

Shared Components (Done)

File Location Purpose
ChessStateReconstructor quartz/commonMain/ Deterministic event→state reconstruction
ChessStateReconstructorTest quartz/jvmAndroidTest/ 22 integration tests
ChessEventCollector commons/commonMain/ Thread-safe event aggregation with dedup
ChessGameLoader commons/commonMain/ Converts ReconstructionResult → LiveChessGameState
ChessLobbyLogic commons/commonMain/ Shared business logic (challenges, moves, polling)
ChessLobbyState commons/commonMain/ Shared UI state (StateFlows for games, challenges, status)
ChessBroadcastStatus commons/commonMain/ Shared broadcast status sealed class
ChessPollingDelegate commons/commonMain/ Configurable periodic refresh
ChessFilterBuilder commons/commonMain/subscription/ Shared relay filter construction
ChessSubscriptionController commons/commonMain/subscription/ Platform subscription interface
ChessStatusBanner amethyst/ (Android only) Android broadcast status UI

Test Coverage

  • Game lifecycle, move ordering/dedup, game end conditions
  • Viewer perspectives, draw offers, castling, determinism

Target Architecture

BOTH PLATFORMS IDENTICAL:

VM (~150 lines) → ChessLobbyLogic
                      │
                      ├─ publish: ChessEventPublisher (platform impl)
                      │
                      └─ refresh (periodic):
                            one-shot REQ to relays
                                    ↓
                            transient ChessEventCollector (use-once)
                                    ↓
                            ChessStateReconstructor.reconstruct()
                                    ↓
                            LiveChessGameState → UI
                            (collector discarded, no cache)

Key principle: No cache. Relays are the ONLY source of truth.

Every refresh cycle:

  1. One-shot REQ to relays for all game events
  2. Transient collector → reconstruct → get state
  3. Diff against UI state, update if changed
  4. Discard collector

Real-time subscription events apply moves optimistically to LiveChessGameState. Periodic full-reconstruction corrects any drift.


Resolved Decisions

1. No LocalCache for Chess

Decision: Chess events must NOT enter LocalCache. Chess is inherently remote-only.

How to stop writes: Remove chess event handlers from LocalCache.justConsumeInnerInner() (lines 2956-2960 in LocalCache.kt). These are:

is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified)  // REMOVE
is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified)     // REMOVE
is LiveChessMoveEvent -> consume(event, relay, wasVerified)           // REMOVE
is LiveChessGameEndEvent -> consume(event, relay, wasVerified)        // REMOVE

Also remove chess DataSource from RelaySubscriptionsCoordinator (line 83: val chess = ChessFilterAssembler(client)). Chess manages its own relay subscriptions independently.

2. One-Shot Relay Fetch (in commons)

Decision: Create a shared one-shot fetch helper in commons using existing IRequestListener + Channel pattern.

Existing pattern (proven in production):

  • quartz/.../accessories/NostrClientSingleDownloadExt.kt — single event download
  • quartz/.../accessories/NostrClientSendAndWaitExt.kt — multi-relay wait

Design: The fetcher takes an INostrClient (available on both platforms) and uses the existing IRequestListener callback → ChannelwithTimeoutOrNull pattern:

// commons/commonMain - shared one-shot fetch
class ChessRelayFetchHelper(private val client: INostrClient) {

    suspend fun fetchEvents(
        filters: Map<NormalizedRelayUrl, List<Filter>>,
        timeoutMs: Long = 30_000,
    ): List<Event> {
        val events = mutableListOf<Event>()
        val eoseReceived = CompletableDeferred<Unit>()
        val subId = UUID.randomUUID().toString().take(8)

        val listener = object : IRequestListener {
            override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
                events.add(event)
            }
            override fun onEose(relay: NormalizedRelayUrl, forFilters: List<Filter>?) {
                eoseReceived.complete(Unit)
            }
        }

        client.openReqSubscription(subId, filters, listener)
        withTimeoutOrNull(timeoutMs) { eoseReceived.await() }
        client.close(subId)

        return events
    }
}

Platform adapters inject their INostrClient:

  • Android: account.client (or equivalent from relay pool)
  • Desktop: relayManager.client

3. Metadata Provider (interface in commons)

Decision: Shared IUserMetadataProvider interface in commons. Platform-specific implementations.

Android uses LocalCache.users[pubkey].info, Desktop uses UserMetadataCache. Both implement:

// commons/commonMain
interface IUserMetadataProvider {
    fun getDisplayName(pubkey: String): String
    fun getPictureUrl(pubkey: String): String?
}

ChessChallenge gets enriched with display fields by calling provider at construction time.

4. Challenge Type Migration

Decision: Enrich ChessChallenge with display fields (displayName, avatarUrl) so it replaces both Note (Android) and LiveChessGameChallengeEvent (Desktop) in UI code.


Implementation Steps

Step 1: Stop chess events entering LocalCache

Files:

  • amethyst/.../model/LocalCache.kt — remove chess when branches (lines ~2956-2960)
  • amethyst/.../service/relayClient/RelaySubscriptionsCoordinator.kt — remove val chess DataSource

Step 2: Create one-shot relay fetch helper in commons

File: commons/src/commonMain/.../chess/ChessRelayFetchHelper.kt (new)

Uses INostrClient + IRequestListener + Channel pattern. Shared by both platforms.

Step 3: Create IUserMetadataProvider in commons

File: commons/src/commonMain/.../chess/IUserMetadataProvider.kt (new)

Step 4: Rewrite ChessLobbyLogic (relay-first)

File: commons/src/commonMain/.../chess/ChessLobbyLogic.kt

Replace ChessEventFetcher with ChessRelayFetcher (wraps ChessRelayFetchHelper):

interface ChessRelayFetcher {
    suspend fun fetchGameEvents(gameId: String): ChessGameEvents
    suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent>
    suspend fun fetchRecentGames(): List<RelayGameSummary>
}

Rewrite refresh cycle:

private suspend fun refreshGame(gameId: String) {
    val events = relayFetcher.fetchGameEvents(gameId)  // one-shot from relays
    val result = ChessStateReconstructor.reconstruct(events, userPubkey)
    when (result) {
        is ReconstructionResult.Success ->
            state.replaceGameState(gameId, ChessGameLoader.toLiveGameState(result, userPubkey))
        is ReconstructionResult.Error ->
            state.setError("Game $gameId: ${result.message}")
    }
}

Add:

  • handleIncomingEvent(event) — optimistic real-time event routing
  • handleGameAccepted() + startGameFromAcceptance()
  • acceptDraw(), declineDraw(), claimAbandonmentVictory()
  • retryWithBackoff() for publish operations
  • Subscription controller integration

Step 5: Enhance ChessLobbyState

File: commons/src/commonMain/.../chess/ChessLobbyState.kt

Add:

  • replaceGameState(gameId, newState) — for full reconstruction updates
  • completedGames: StateFlow<List<CompletedGame>>
  • Enrich ChessChallenge with displayName, avatarUrl

Step 6: Platform adapters

Android: amethyst/.../chess/AndroidChessAdapter.kt (new)

  • AndroidChessPublisher — wraps account.signAndComputeBroadcast()
  • AndroidRelayFetcher — wraps ChessRelayFetchHelper(account.client)
  • AndroidMetadataProvider — wraps LocalCache.users[pubkey].info

Desktop: desktopApp/.../chess/DesktopChessAdapter.kt (new)

  • DesktopChessPublisher — wraps account.signer.sign() + relayManager.broadcastToAll()
  • DesktopRelayFetcher — wraps ChessRelayFetchHelper(relayManager.client)
  • DesktopMetadataProvider — wraps UserMetadataCache

Step 7: Rewrite Android ChessViewModel

File: amethyst/.../chess/ChessViewModel.kt (1220 → ~150 lines)

Thin wrapper: delegates to ChessLobbyLogic, exposes state, routes LocalCache.live.newEventBundles for real-time events.

Delete: RetryOperation, PublicGameInfo, all LocalCache queries, all handle* methods, ChessStatus.

Step 8: Rewrite Desktop DesktopChessViewModel

File: desktopApp/.../chess/DesktopChessViewModel.kt (956 → ~150 lines)

Thin wrapper: delegates to ChessLobbyLogic, keeps UserMetadataCache platform-specific.

Delete: pendingMoves, pendingAccepts, challengesByGameId, processedEventIds, gamesBeingCreated, all handle* methods, applyPendingMoves, CompletedGame.

Step 9: Create shared BroadcastBanner

File: commons/src/commonMain/.../chess/ChessBroadcastBanner.kt (new)

Extract from Android's ChessStatusBanner.kt, use ChessBroadcastStatus from commons.

Step 10: Update UI consumers

Android:

  • ChessGameScreen.kt / ChessLobbyScreen.kt: ChessChallenge instead of Note
  • Remove ChessStatusBanner.kt, use shared ChessBroadcastBanner

Desktop:

  • ChessScreen.kt: ChessChallenge instead of LiveChessGameChallengeEvent
  • Add ChessBroadcastBanner

Files Modified

File Action Notes
LocalCache.kt Edit Remove chess event handlers
RelaySubscriptionsCoordinator.kt Edit Remove chess DataSource
commons/.../ChessRelayFetchHelper.kt New One-shot relay query helper
commons/.../IUserMetadataProvider.kt New Metadata interface
commons/.../ChessLobbyLogic.kt Rewrite Relay-first, event routing, reconstructor
commons/.../ChessLobbyState.kt Enhance replaceGameState, completedGames, enriched ChessChallenge
commons/.../ChessBroadcastBanner.kt New Shared broadcast status UI
amethyst/.../AndroidChessAdapter.kt New Publisher + fetcher + metadata
amethyst/.../ChessViewModel.kt Rewrite 1220→150 lines
amethyst/.../ChessStatusBanner.kt Delete Replaced by shared
amethyst/.../ChessGameScreen.kt Update Challenge type + banner
amethyst/.../ChessLobbyScreen.kt Update Challenge type
desktopApp/.../DesktopChessAdapter.kt New Publisher + fetcher + metadata
desktopApp/.../DesktopChessViewModel.kt Rewrite 956→150 lines
desktopApp/.../ChessScreen.kt Update Challenge type + banner

Verification

  1. ./gradlew :quartz:build
  2. ./gradlew :commons:build
  3. ./gradlew :amethyst:compileDebugKotlin
  4. ./gradlew :desktopApp:compileKotlin
  5. ./gradlew :quartz:jvmAndroidTest — ChessStateReconstructor tests
  6. ./gradlew :desktopApp:run — manual test: create challenge, play moves