- 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>
11 KiB
Chess Engine Refactoring
Context
Chess feature had inconsistent state reconstruction:
- Android: Replays moves from
LocalCache.addressables(can get stale/desync) - Desktop: Buffers
pendingMovesand syncs viaforceResync(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:
- One-shot REQ to relays for all game events
- Transient collector → reconstruct → get state
- Diff against UI state, update if changed
- 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 downloadquartz/.../accessories/NostrClientSendAndWaitExt.kt— multi-relay wait
Design: The fetcher takes an INostrClient (available on both platforms) and uses the existing IRequestListener callback → Channel → withTimeoutOrNull 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 chesswhenbranches (lines ~2956-2960)amethyst/.../service/relayClient/RelaySubscriptionsCoordinator.kt— removeval chessDataSource
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 routinghandleGameAccepted()+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 updatescompletedGames: StateFlow<List<CompletedGame>>- Enrich
ChessChallengewithdisplayName,avatarUrl
Step 6: Platform adapters
Android: amethyst/.../chess/AndroidChessAdapter.kt (new)
AndroidChessPublisher— wrapsaccount.signAndComputeBroadcast()AndroidRelayFetcher— wrapsChessRelayFetchHelper(account.client)AndroidMetadataProvider— wrapsLocalCache.users[pubkey].info
Desktop: desktopApp/.../chess/DesktopChessAdapter.kt (new)
DesktopChessPublisher— wrapsaccount.signer.sign()+relayManager.broadcastToAll()DesktopRelayFetcher— wrapsChessRelayFetchHelper(relayManager.client)DesktopMetadataProvider— wrapsUserMetadataCache
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:ChessChallengeinstead ofNote- Remove
ChessStatusBanner.kt, use sharedChessBroadcastBanner
Desktop:
ChessScreen.kt:ChessChallengeinstead ofLiveChessGameChallengeEvent- 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
./gradlew :quartz:build./gradlew :commons:build./gradlew :amethyst:compileDebugKotlin./gradlew :desktopApp:compileKotlin./gradlew :quartz:jvmAndroidTest— ChessStateReconstructor tests./gradlew :desktopApp:run— manual test: create challenge, play moves