Playable Android table

The game runs on device: verified on a Pixel 10 Pro emulator (Android 17) by
installing, tapping through a hand, and confirming it advanced pre-flop to flop
with correct pot, folds, and re-offered action.

App:
- :app module on AGP 9.2.1. Note AGP 9 has built-in Kotlin support, so applying
  org.jetbrains.kotlin.android conflicts with it ("extension with name 'kotlin'
  already registered"); only android.application + kotlin.compose are applied,
  matching recipeze.
- PokerViewModel runs a continuous cash game and publishes to Compose.
- Compose table: opponents, board, pot, hero, action bar with a raise slider.

Frames are queued, not conflated. An all-in runout emits flop, turn and river
microseconds apart; pushing those into a StateFlow would collapse them and the
board would jump from empty to complete. The engine's suspending observer sends
into a Channel, a consumer paces each frame, and only then is StateFlow updated
— so backpressure paces the engine rather than the UI dropping frames. Three
tests cover this, including a characterisation test showing a conflating
StateFlow does lose the intermediate frames.

Assets:
- tools/generate_card_assets.sh rasterises the SVGs into four density buckets
  using sips, which renders SVG directly — no librsvg or ImageMagick.
- Resource names are prefixed card_ because Android resource names may not start
  with a digit (10_of_clubs would be rejected).
- CardArt.kt maps deck index to drawable via static R references, so R8 resource
  shrinking cannot strip the artwork the way getIdentifier lookups would risk.

Layout fixes found by actually looking at the running app: five opponents did
not fit a fixed-width scrolling row (Enzo was off-screen), the header collided
with the status bar clock, and the board floated against a large dead space.

Tests: 52 -> 55, green on jvmTest and testAndroidHostTest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-25 17:23:34 -04:00
parent 679b25e2c7
commit 950c7ceb57
234 changed files with 726 additions and 5 deletions
+6 -2
View File
@@ -9,8 +9,10 @@ No `java`/`gradle` on PATH — use Android Studio's bundled JDK:
```bash
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
./gradlew :engine:jvmTest # evaluator + engine tests
./gradlew :engine:jvmTest # engine tests (JVM)
./gradlew :engine:testAndroidHostTest # same suite, Android variant
./gradlew :sim:run --args="50000" # simulate 50k hands, print bot stats
./gradlew :app:assembleDebug # build the APK
```
## Layout
@@ -21,7 +23,9 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
| `engine/src/commonMain/.../bot/` | Skill/style profiles, `MathBot` |
| `engine/src/commonMain/.../game/` | `Table` — betting rounds, side pots, showdown |
| `sim/` | JVM-only headless simulator used to **tune** bot profiles |
| `assets/cards/` | 52 CC0 card faces + generated backs |
| `app/` | Android app: Compose table, `PokerViewModel` |
| `assets/cards/` | 52 CC0 card faces + generated backs (**source of truth**) |
| `tools/generate_card_assets.sh` | Rasterises those SVGs into `app/.../drawable-*` |
`engine` is pure Kotlin with no platform APIs, so `androidTarget()` /
`iosArm64()` slot in without touching `commonMain`.
+47
View File
@@ -0,0 +1,47 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.jsjdesigns.poker"
compileSdk = 37
defaultConfig {
applicationId = "com.jsjdesigns.poker"
minSdk = 26
targetSdk = 37
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release { isMinifyEnabled = false }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures { compose = true }
}
dependencies {
implementation(project(":engine"))
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.compose.material3)
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Poker">
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,74 @@
package com.jsjdesigns.poker
import androidx.annotation.DrawableRes
/**
* Maps a deck index (0..51) to its drawable.
*
* Generated by tools/generate_card_assets.sh's companion step — regenerate rather
* than hand-editing. References are static so R8 resource shrinking cannot strip
* the artwork, which `getIdentifier` lookups would risk.
*
* Order matches [com.jsjdesigns.poker.core.Card]: index = (rank - 2) * 4 + suit,
* suits ordered clubs, diamonds, hearts, spades.
*/
private val CARD_FACES = intArrayOf(
R.drawable.card_2_of_clubs, // 0
R.drawable.card_2_of_diamonds, // 1
R.drawable.card_2_of_hearts, // 2
R.drawable.card_2_of_spades, // 3
R.drawable.card_3_of_clubs, // 4
R.drawable.card_3_of_diamonds, // 5
R.drawable.card_3_of_hearts, // 6
R.drawable.card_3_of_spades, // 7
R.drawable.card_4_of_clubs, // 8
R.drawable.card_4_of_diamonds, // 9
R.drawable.card_4_of_hearts, // 10
R.drawable.card_4_of_spades, // 11
R.drawable.card_5_of_clubs, // 12
R.drawable.card_5_of_diamonds, // 13
R.drawable.card_5_of_hearts, // 14
R.drawable.card_5_of_spades, // 15
R.drawable.card_6_of_clubs, // 16
R.drawable.card_6_of_diamonds, // 17
R.drawable.card_6_of_hearts, // 18
R.drawable.card_6_of_spades, // 19
R.drawable.card_7_of_clubs, // 20
R.drawable.card_7_of_diamonds, // 21
R.drawable.card_7_of_hearts, // 22
R.drawable.card_7_of_spades, // 23
R.drawable.card_8_of_clubs, // 24
R.drawable.card_8_of_diamonds, // 25
R.drawable.card_8_of_hearts, // 26
R.drawable.card_8_of_spades, // 27
R.drawable.card_9_of_clubs, // 28
R.drawable.card_9_of_diamonds, // 29
R.drawable.card_9_of_hearts, // 30
R.drawable.card_9_of_spades, // 31
R.drawable.card_10_of_clubs, // 32
R.drawable.card_10_of_diamonds, // 33
R.drawable.card_10_of_hearts, // 34
R.drawable.card_10_of_spades, // 35
R.drawable.card_jack_of_clubs, // 36
R.drawable.card_jack_of_diamonds, // 37
R.drawable.card_jack_of_hearts, // 38
R.drawable.card_jack_of_spades, // 39
R.drawable.card_queen_of_clubs, // 40
R.drawable.card_queen_of_diamonds, // 41
R.drawable.card_queen_of_hearts, // 42
R.drawable.card_queen_of_spades, // 43
R.drawable.card_king_of_clubs, // 44
R.drawable.card_king_of_diamonds, // 45
R.drawable.card_king_of_hearts, // 46
R.drawable.card_king_of_spades, // 47
R.drawable.card_ace_of_clubs, // 48
R.drawable.card_ace_of_diamonds, // 49
R.drawable.card_ace_of_hearts, // 50
R.drawable.card_ace_of_spades, // 51
)
@DrawableRes
fun cardFace(index: Int): Int = CARD_FACES[index]
@DrawableRes
fun cardBack(): Int = R.drawable.card_back
@@ -0,0 +1,20 @@
package com.jsjdesigns.poker
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.lifecycle.viewmodel.compose.viewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
val vm: PokerViewModel = viewModel()
TableScreen(vm)
}
}
}
}
@@ -0,0 +1,139 @@
package com.jsjdesigns.poker
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.jsjdesigns.poker.bot.BotProfile
import com.jsjdesigns.poker.bot.MathBot
import com.jsjdesigns.poker.bot.PlayStyle
import com.jsjdesigns.poker.bot.SkillLevel
import com.jsjdesigns.poker.game.Action
import com.jsjdesigns.poker.game.ActionType
import com.jsjdesigns.poker.game.HumanAgent
import com.jsjdesigns.poker.game.Seat
import com.jsjdesigns.poker.game.Table
import com.jsjdesigns.poker.game.TableSnapshot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlin.random.Random
const val HERO_SEAT = 0
private const val STARTING_STACK = 200
private const val SMALL_BLIND = 1
private const val BIG_BLIND = 2
data class UiState(
val snapshot: TableSnapshot? = null,
val handsPlayed: Int = 0,
val message: String? = null,
)
/**
* Drives a continuous cash game and publishes it to Compose.
*
* **Frames are queued, not conflated.** The engine can emit the flop, turn and
* river of an all-in runout within microseconds of each other. Pushing those
* straight into a `StateFlow` would collapse them — `StateFlow` keeps only the
* latest value — and the board would appear to jump from empty to complete.
* Instead the engine's suspending observer sends into a [Channel], a consumer
* paces each frame, and only then is `StateFlow` updated. Because the observer
* suspends when the channel is full, the engine cannot outrun the animation:
* backpressure does the pacing for us.
*/
class PokerViewModel : ViewModel() {
private val human = HumanAgent()
private val frames = Channel<TableSnapshot>(capacity = 32)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
/** What the player is being asked to decide, or null when it isn't their turn. */
val offer = human.offer
private val roster = listOf(
BotProfile("You", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE),
BotProfile("Ada", SkillLevel.EXPERT, PlayStyle.TIGHT_AGGRESSIVE, "Ice-cold. Punishes mistakes."),
BotProfile("Bruno", SkillLevel.ADVANCED, PlayStyle.LOOSE_AGGRESSIVE, "Relentless pressure."),
BotProfile("Cleo", SkillLevel.INTERMEDIATE, PlayStyle.TRAPPER, "Quiet until she has you."),
BotProfile("Dex", SkillLevel.INTERMEDIATE, PlayStyle.CALLING_STATION, "Pays to see it."),
BotProfile("Enzo", SkillLevel.BEGINNER, PlayStyle.MANIAC, "Chaos, and certain he's winning."),
)
private val seats: List<Seat>
private val table: Table
init {
val deckRandom = Random(System.nanoTime())
seats = roster.mapIndexed { i, profile ->
val agent = if (i == HERO_SEAT) human else MathBot(profile, Random(deckRandom.nextLong()))
Seat(i, profile.name, STARTING_STACK, agent)
}
table = Table(
seats = seats,
smallBlind = SMALL_BLIND,
bigBlind = BIG_BLIND,
random = deckRandom,
observer = { frames.send(it) },
)
viewModelScope.launch { consumeFrames() }
viewModelScope.launch(Dispatchers.Default) { playContinuously() }
}
private suspend fun consumeFrames() {
for (frame in frames) {
_state.value = _state.value.copy(snapshot = frame.maskedFor(HERO_SEAT))
delay(pacingMillis(frame))
}
}
/**
* How long to hold a frame on screen. Zero when the player is on the clock —
* never make someone wait to act — and longest for cards landing and for the
* showdown, which are the moments worth watching.
*/
private fun pacingMillis(frame: TableSnapshot): Long = when (frame.phase) {
TableSnapshot.Phase.DEALT -> 400
TableSnapshot.Phase.BETTING ->
if (frame.toAct == HERO_SEAT) 0 else if (frame.toAct != null) 250 else 450
TableSnapshot.Phase.STREET_COMPLETE -> 700
TableSnapshot.Phase.SHOWDOWN -> 2200
TableSnapshot.Phase.COMPLETE -> 1200
}
private suspend fun playContinuously() {
while (true) {
// Cash-game convention: top anyone back up who cannot cover a blind.
for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK
table.advanceButton()
runCatching { table.playHand() }
.onSuccess { _state.value = _state.value.copy(handsPlayed = _state.value.handsPlayed + 1) }
.onFailure { return } // scope cancelled: the screen went away
}
}
fun submit(action: Action) {
viewModelScope.launch { human.submit(action) }
}
fun fold() = submit(Action(ActionType.FOLD))
fun checkOrCall() {
val o = offer.value ?: return
submit(if (o.canCheck) Action(ActionType.CHECK) else Action(ActionType.CALL, o.toCall))
}
fun raiseTo(amount: Int) = submit(Action(ActionType.RAISE, amount))
override fun onCleared() {
// Release a hand parked on human input so the coroutine can finish.
viewModelScope.launch { human.cancel() }
frames.close()
super.onCleared()
}
}
@@ -0,0 +1,260 @@
package com.jsjdesigns.poker
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.jsjdesigns.poker.game.SeatSnapshot
import com.jsjdesigns.poker.game.TableSnapshot
private val Felt = Color(0xFF0B3D26)
@Composable
fun TableScreen(vm: PokerViewModel) {
val state by vm.state.collectAsStateWithLifecycle()
val offer by vm.offer.collectAsStateWithLifecycle()
val snap = state.snapshot
Column(
modifier = Modifier
.fillMaxSize()
.background(Felt)
.systemBarsPadding()
.padding(12.dp),
) {
Text(
text = "Hand ${state.handsPlayed + 1} ${snap?.street ?: ""}",
color = Color.White.copy(alpha = 0.6f),
fontSize = 12.sp,
)
Spacer(Modifier.height(8.dp))
// Opponents
// Weighted rather than fixed-width: five opponents must all fit on a
// phone, and a horizontally scrolling table hides players from you.
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
snap?.seats?.filter { it.index != HERO_SEAT }?.forEach { seat ->
Box(Modifier.weight(1f)) {
OpponentSeat(seat, isTurn = snap.toAct == seat.index)
}
}
}
Spacer(Modifier.weight(1f))
// Board and pot
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "POT ${snap?.pot ?: 0}",
color = Color(0xFFE3C179),
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
)
Spacer(Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
val board = snap?.board.orEmpty()
repeat(5) { i ->
if (i < board.size) {
CardImage(board[i], Modifier.size(54.dp, 81.dp))
} else {
Box(
Modifier
.size(54.dp, 81.dp)
.clip(RoundedCornerShape(4.dp))
.background(Color.White.copy(alpha = 0.06f)),
)
}
}
}
}
Spacer(Modifier.weight(1f))
// Hero
val hero = snap?.seats?.firstOrNull { it.index == HERO_SEAT }
HeroSeat(hero, isTurn = snap?.toAct == HERO_SEAT)
Spacer(Modifier.height(12.dp))
ActionBar(
offer = offer,
onFold = vm::fold,
onCheckCall = vm::checkOrCall,
onRaise = vm::raiseTo,
)
}
}
@Composable
private fun OpponentSeat(seat: SeatSnapshot, isTurn: Boolean) {
Card(
colors = CardDefaults.cardColors(
containerColor = if (isTurn) Color(0xFF1D6B45) else Color.White.copy(alpha = 0.07f),
),
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
if (seat.folded) {
Box(Modifier.size(24.dp, 36.dp))
} else {
val hole = seat.hole
repeat(2) { i ->
CardImage(hole?.getOrNull(i), Modifier.size(24.dp, 36.dp))
}
}
}
Spacer(Modifier.height(4.dp))
Text(
seat.name + if (seat.isButton) "" else "",
color = Color.White,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.alpha(if (seat.folded) 0.4f else 1f),
)
Text(
if (seat.allIn) "ALL IN" else "${seat.stack}",
color = if (seat.allIn) Color(0xFFE3C179) else Color.White.copy(alpha = 0.7f),
fontSize = 11.sp,
)
if (seat.committedThisRound > 0) {
Text(
"bet ${seat.committedThisRound}",
color = Color(0xFFE3C179),
fontSize = 10.sp,
)
}
}
}
}
@Composable
private fun HeroSeat(seat: SeatSnapshot?, isTurn: Boolean) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
repeat(2) { i ->
CardImage(seat?.hole?.getOrNull(i), Modifier.size(64.dp, 96.dp))
}
}
Column {
Text(
(seat?.name ?: "You") + if (seat?.isButton == true) "" else "",
color = if (isTurn) Color(0xFFE3C179) else Color.White,
fontWeight = FontWeight.Bold,
)
Text("${seat?.stack ?: 0}", color = Color.White.copy(alpha = 0.75f))
if ((seat?.committedThisRound ?: 0) > 0) {
Text("bet ${seat?.committedThisRound}", color = Color(0xFFE3C179), fontSize = 12.sp)
}
}
}
}
@Composable
private fun ActionBar(
offer: com.jsjdesigns.poker.game.DecisionOffer?,
onFold: () -> Unit,
onCheckCall: () -> Unit,
onRaise: (Int) -> Unit,
) {
if (offer == null) {
Box(Modifier.fillMaxWidth().height(96.dp), contentAlignment = Alignment.Center) {
Text("Waiting…", color = Color.White.copy(alpha = 0.4f))
}
return
}
var raiseTo by remember { mutableIntStateOf(offer.minRaiseTo) }
// Reset the slider whenever a new decision arrives, or it keeps the old hand's value.
LaunchedEffect(offer) { raiseTo = offer.minRaiseTo }
Column(Modifier.fillMaxWidth()) {
if (offer.canRaise && offer.maxRaiseTo > offer.minRaiseTo) {
Text("Raise to $raiseTo", color = Color.White, fontSize = 13.sp)
Slider(
value = raiseTo.toFloat(),
onValueChange = { raiseTo = it.toInt() },
valueRange = offer.minRaiseTo.toFloat()..offer.maxRaiseTo.toFloat(),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onFold,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF8F1218)),
) { Text("Fold") }
Button(
onClick = onCheckCall,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF2C6E49)),
) { Text(if (offer.canCheck) "Check" else "Call ${offer.toCall}") }
if (offer.canRaise && offer.maxRaiseTo > offer.minRaiseTo) {
Button(
onClick = { onRaise(raiseTo) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF14357A)),
) { Text(if (raiseTo >= offer.maxRaiseTo) "All in" else "Raise") }
}
}
}
}
@Composable
private fun CardImage(index: Int?, modifier: Modifier) {
Image(
painter = painterResource(if (index != null) cardFace(index) else cardBack()),
contentDescription = null,
modifier = modifier.clip(RoundedCornerShape(4.dp)),
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Some files were not shown because too many files have changed in this diff Show More