Files
amethyst/docs/chess-engine-refactoring.md
T
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

288 lines
11 KiB
Markdown

# 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:
```kotlin
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 → `Channel``withTimeoutOrNull` pattern:
```kotlin
// 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:
```kotlin
// 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`):
```kotlin
interface ChessRelayFetcher {
suspend fun fetchGameEvents(gameId: String): ChessGameEvents
suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent>
suspend fun fetchRecentGames(): List<RelayGameSummary>
}
```
Rewrite refresh cycle:
```kotlin
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