Merge dev: Euchre bidding fix, trick sweep, branding, menu filtering

Publishes a production release carrying everything since v1.0.2.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
Claude
2026-08-06 03:06:17 +00:00
27 changed files with 987 additions and 78 deletions
+30 -1
View File
@@ -12,7 +12,11 @@ The app shell is complete and two games are playable end to end:
| **Playable** | Euchre, Sequence |
| **Listed, not yet implemented** | Kaiser, President, Golf, Wizard, Crazy 8s, Mastermind, Backgammon, Chess, Checkers |
Unimplemented games appear in the menu marked *Coming soon*. Adding one means
Unimplemented games appear in the menu marked *Coming soon* **on dev builds
only** — a production release lists just the games that can actually be played,
so nobody installs it and taps into a dead end. The menu decides this from the
installed APK's own version name, so it follows the build rather than the
channel selected for future updates. Adding one means
writing a `GameRules` implementation, a `GameAi`, and a screen, then flipping
`available` in `GameCatalog` — the menu, lobby, networking, settings and OTA
layers are game-agnostic and need no changes.
@@ -53,6 +57,31 @@ them.
- **Production** — stable builds, published from `main`.
- **Dev** — preview builds, published from `dev` on every green CI run.
## Branding
The launcher icon is a single square of the Wallace tartan, and the app opens on
a tartan splash carrying the name in a Celtic uncial hand for two seconds before
fading into the menu.
The tartan is drawn, not an image: `ui/theme/Tartan.kt` lays the sett down as
warp and then as weft at half opacity, which is what produces the blended
squares instead of a flat grid. The icon is the same construction as a vector
drawable, so it stays sharp at every density and needs no PNGs.
Two honest caveats:
- **The sett is an approximation.** `WallaceSett` reproduces what the Wallace
tartan reads as — a bold red and black check divided by a narrow gold
overstripe — but the registered threadcount could not be verified, so the
numbers were chosen to look right rather than to be authoritative. They are
plain data; replacing them with the registered counts rebrands the whole app
and needs no other change.
- **The typeface is bundled under a licence.** Uncial Antiqua, © 2011 Brian J.
Bonislawsky DBA Astigmatic, Reserved Font Name "Uncial Antiqua", used under
the SIL Open Font License 1.1. The full licence is at
[docs/licenses/UncialAntiqua-OFL.txt](docs/licenses/UncialAntiqua-OFL.txt) and
must stay with the font — the OFL requires it to be distributed alongside.
## Screen sizes
There are no fixed orientations or hard-coded card sizes. Layouts measure the
@@ -154,6 +154,22 @@ object GameCatalog {
fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id }
fun byCategory(category: GameCategory): List<GameDescriptor> =
all.filter { it.category == category }
/**
* Games in a category.
*
* [includeComingSoon] is what separates the two update channels: a dev
* build lists everything, including the games that are still only entries
* in this table, so the roadmap is visible while working on it. A
* production build lists only what can actually be played, so nobody
* downloads a release and taps into a dead end.
*/
fun byCategory(
category: GameCategory,
includeComingSoon: Boolean = true,
): List<GameDescriptor> = all.filter {
it.category == category && (includeComingSoon || it.available)
}
/** Everything currently playable, regardless of category. */
val playable: List<GameDescriptor> get() = all.filter { it.available }
}
@@ -16,14 +16,20 @@ import org.prolibertate.games.game.engine.GameAi
class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
GameAi<EuchreState, EuchreMove> {
/**
* Thresholds are calibrated against the score an average five-card hand
* earns from [handValue], which sits around 11. A threshold near that mean
* means every seat bids and the card is never turned down — see
* EuchreBiddingRateTest, which pins the resulting order-up rate.
*/
enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) {
/** Bids loosely and plays straightforwardly. */
EASY(bidThreshold = 7, aloneThreshold = 26),
EASY(bidThreshold = 21, aloneThreshold = 32),
NORMAL(bidThreshold = 9, aloneThreshold = 24),
NORMAL(bidThreshold = 24, aloneThreshold = 32),
/** Bids tighter and is harder to sneak a trick past. */
CAUTIOUS(bidThreshold = 11, aloneThreshold = 22),
CAUTIOUS(bidThreshold = 27, aloneThreshold = 31),
}
override fun chooseMove(state: EuchreState, seat: Int, legal: List<EuchreMove>): EuchreMove {
@@ -47,19 +53,25 @@ class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) :
*/
private fun handValue(hand: List<Card>, trump: Suit): Int {
var value = 0
var trumpCount = 0
for (card in hand) {
value += when {
isRightBower(card, trump) -> 9
isLeftBower(card, trump) -> 7
isTrumpCard(card, trump) -> 3 + (card.rank.order - Rank.NINE.order) / 2
isRightBower(card, trump) -> 10
isLeftBower(card, trump) -> 8
isTrumpCard(card, trump) -> 4 + (card.rank.order - Rank.NINE.order) / 2
card.rank == Rank.ACE -> 3
card.rank == Rank.KING -> 1
else -> 0
}
if (isTrumpCard(card, trump)) trumpCount++
}
// A void is a chance to trump in — but only while there is trump left
// to do it with. Counting voids on a trumpless hand was inflating
// exactly the weak hands that should be passing.
if (trumpCount >= 2) {
val sideSuits = Suit.entries.filter { it != trump }
value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2
}
// A void in a side suit means an early chance to trump in.
val sideSuits = Suit.entries.filter { it != trump }
value += sideSuits.count { suit -> hand.none { effectiveSuit(it, trump) == suit } } * 2
return value
}
@@ -70,6 +70,12 @@ data class EuchreState(
val turn: Int,
val passes: Int,
val trick: List<PlayedCard>,
/**
* The trick that has just been won, kept on the table so it can be read and
* then animated away. Cleared as soon as the next card is played. Winner is
* [lastTrickWinner].
*/
val completedTrick: List<PlayedCard> = emptyList(),
val leader: Int,
val tricksWon: List<Int>,
/** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */
@@ -277,6 +277,7 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
leader = leader,
turn = leader,
trick = emptyList(),
completedTrick = emptyList(),
passes = 0,
)
}
@@ -285,7 +286,8 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
val hands = state.hands.toMutableList()
hands[seat] = hands[seat] - card
val trick = state.trick + PlayedCard(seat, card)
val played = state.copy(hands = hands, trick = trick)
// Playing to a new trick sweeps the previous one off the table.
val played = state.copy(hands = hands, trick = trick, completedTrick = emptyList())
if (trick.size < state.activeSeatCount) {
return played.copy(turn = nextActive(state, seat))
@@ -300,6 +302,9 @@ object EuchreRules : GameRules<EuchreState, EuchreMove> {
val afterTrick = played.copy(
trick = emptyList(),
// Held so the finished trick can be seen before it is cleared away,
// rather than the fourth card vanishing the instant it lands.
completedTrick = trick,
tricksWon = tricksWon,
leader = winner,
turn = winner,
@@ -41,6 +41,12 @@ class MatchController<S : Any, M : Any>(
private val advanceIdle: (S) -> S? = { null },
/** Scaled by the animation-speed setting so the table paces itself. */
private val aiThinkingMillis: () -> Long = { 700L },
/**
* Extra pause before the next move is made, given the current state. Lets a
* game hold a finished trick on the table long enough to be read before the
* next card lands on top of it.
*/
private val holdBeforeNextMove: (S) -> Long = { 0L },
) {
enum class Role { HOST, CLIENT }
@@ -207,7 +213,7 @@ class MatchController<S : Any, M : Any>(
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
delay(aiThinkingMillis())
delay(holdBeforeNextMove(current) + aiThinkingMillis())
val chosen = lock.withLock {
val live = authoritative ?: return
if (rules.currentSeat(live) != seat) return@withLock null
@@ -25,10 +25,23 @@ data class Settings(
val soundEnabled: Boolean = true,
val animationSpeed: Float = 1.0f,
val checkForUpdatesOnLaunch: Boolean = true,
val playerName: String = "Player",
/**
* Exactly what the user has typed, which may be blank while they are
* mid-edit. Nothing substitutes a default into this value: doing so meant
* that clearing the field wrote "Player" straight back and the text
* snapped back before a real name could be typed.
*/
val playerName: String = "",
/** Which builds the updater offers. Production unless deliberately changed. */
val updateChannel: UpdateChannel = UpdateChannel.PRODUCTION,
) {
/**
* The name other players actually see. This is where the default lives, so
* an empty field is a display concern rather than something written back
* into what the user is typing.
*/
val displayName: String get() = playerName.trim().ifBlank { DEFAULT_PLAYER_NAME }
/** Scales a nominal duration by the chosen speed. */
fun scaled(millis: Long): Long =
(millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong()
@@ -36,6 +49,7 @@ data class Settings(
companion object {
const val MIN_SPEED = 0.5f
const val MAX_SPEED = 2.0f
const val DEFAULT_PLAYER_NAME = "Player"
}
}
@@ -54,7 +68,7 @@ class SettingsRepository(private val context: Context) {
soundEnabled = prefs[Keys.SOUND] ?: true,
animationSpeed = prefs[Keys.SPEED] ?: 1.0f,
checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true,
playerName = prefs[Keys.NAME] ?: "Player",
playerName = prefs[Keys.NAME] ?: "",
updateChannel = prefs[Keys.CHANNEL]
?.let { stored -> runCatching { UpdateChannel.valueOf(stored) }.getOrNull() }
?: UpdateChannel.PRODUCTION,
@@ -79,7 +93,8 @@ class SettingsRepository(private val context: Context) {
context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled }
}
/** Stores the name verbatim. The fallback belongs to [Settings.displayName]. */
suspend fun setPlayerName(name: String) {
context.dataStore.edit { it[Keys.NAME] = name.trim().ifBlank { "Player" } }
context.dataStore.edit { it[Keys.NAME] = name }
}
}
@@ -39,6 +39,7 @@ import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.net.LobbyController
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.settings.SettingsRepository
import org.prolibertate.games.update.UpdateChannel
import org.prolibertate.games.update.Updater
/** Everything the screens need, assembled once by the activity. */
@@ -75,11 +76,15 @@ fun AppRoot(env: AppEnv) {
val settings by env.settingsRepository.settings.collectAsState(initial = Settings())
var stack by remember { mutableStateOf(listOf<Route>(Route.Menu)) }
fun push(route: Route) {
stack = stack + route
// Read from the installed APK's own version name, so this follows the build
// rather than the channel the user has selected for future updates.
val showComingSoon = remember {
env.updater.installedChannel() == UpdateChannel.DEV
}
fun pop() {
val push: (Route) -> Unit = { route -> stack = stack + route }
val pop: () -> Unit = {
if (stack.size > 1) {
// Leaving a lobby or a table tears the networking down with it.
when (stack.last()) {
@@ -92,8 +97,40 @@ fun AppRoot(env: AppEnv) {
BackHandler(enabled = stack.size > 1) { pop() }
var splashDone by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize()) {
AppContent(
env = env,
settings = settings,
stack = stack,
showComingSoon = showComingSoon,
push = push,
pop = pop,
)
// Over the top, so the menu behind it is already composed when it fades.
if (!splashDone) {
SplashScreen(onFinished = { splashDone = true })
}
}
}
/** The navigation stack proper, kept separate so the splash can sit over it. */
@Composable
private fun AppContent(
env: AppEnv,
settings: Settings,
stack: List<Route>,
showComingSoon: Boolean,
push: (Route) -> Unit,
pop: () -> Unit,
) {
when (val route = stack.last()) {
is Route.Menu -> MainMenuScreen(
// Dev builds show the games that are not finished yet; production
// releases list only what can actually be played.
showComingSoon = showComingSoon,
onPickGame = { push(Route.Setup(it.id)) },
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
onSettings = { push(Route.Settings) },
@@ -107,7 +144,7 @@ fun AppRoot(env: AppEnv) {
is Route.Setup -> GameSetupScreen(
descriptor = GameCatalog.byId(route.gameId)!!,
playerName = settings.playerName,
playerName = settings.displayName,
onBack = { pop() },
onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) },
onHostOnline = { optionsJson ->
@@ -118,7 +155,7 @@ fun AppRoot(env: AppEnv) {
is Route.Lobby -> LobbyScreen(
env = env,
route = route,
playerName = settings.playerName,
playerName = settings.displayName,
onBack = { pop() },
onStart = { config, hosting, seat ->
push(Route.Play(config.gameId, config, hosting, seat))
@@ -141,6 +178,7 @@ fun AppRoot(env: AppEnv) {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainMenuScreen(
showComingSoon: Boolean,
onPickGame: (GameDescriptor) -> Unit,
onJoinGame: () -> Unit,
onSettings: () -> Unit,
@@ -175,6 +213,10 @@ fun MainMenuScreen(
modifier = Modifier.fillMaxSize(),
) {
GameCategory.entries.forEach { category ->
val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon)
// Don't leave a heading stranded over nothing.
if (games.isEmpty()) return@forEach
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) {
Text(
text = category.label,
@@ -183,7 +225,7 @@ fun MainMenuScreen(
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
}
items(GameCatalog.byCategory(category)) { game ->
items(games) { game ->
GameTile(game = game, onClick = { onPickGame(game) })
}
}
@@ -19,6 +19,7 @@ import org.prolibertate.games.game.sequence.SequenceState
import org.prolibertate.games.net.MatchController
import org.prolibertate.games.settings.Settings
import org.prolibertate.games.ui.game.EuchreScreen
import org.prolibertate.games.ui.game.TRICK_HOLD_MILLIS
import org.prolibertate.games.ui.game.SequenceScreen
/**
@@ -58,10 +59,24 @@ fun PlayScreen(
}
},
aiThinkingMillis = { settings.scaled(700L) },
// Keep the finished trick on the table before the next
// card lands on top of it.
holdBeforeNextMove = { state ->
if (state.completedTrick.isNotEmpty()) {
settings.scaled(TRICK_HOLD_MILLIS)
} else {
0L
}
},
)
}
StartMatch(env, controller, route)
EuchreScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
EuchreScreen(
controller = controller,
localSeat = route.localSeat,
trickHoldMillis = settings.scaled(TRICK_HOLD_MILLIS),
onExit = onExit,
)
}
GameCatalog.SEQUENCE -> {
@@ -36,7 +36,11 @@ fun SettingsScreen(
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var name by remember(settings.playerName) { mutableStateOf(settings.playerName) }
// Null until the user touches the field, at which point their text takes
// over for good. Keying this off the stored name instead meant every
// keystroke re-seeded the field from what had just been written, so the
// caret jumped and a cleared field refilled itself.
var editedName by remember { mutableStateOf<String?>(null) }
var updateStatus by remember { mutableStateOf<String?>(null) }
var pendingRelease by remember { mutableStateOf<Updater.Release?>(null) }
@@ -86,14 +90,22 @@ fun SettingsScreen(
style = MaterialTheme.typography.bodySmall,
)
OutlinedTextField(
value = name,
value = editedName ?: settings.playerName,
onValueChange = { entered ->
name = entered
editedName = entered
scope.launch { env.settingsRepository.setPlayerName(entered) }
},
placeholder = { Text(Settings.DEFAULT_PLAYER_NAME) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
)
if ((editedName ?: settings.playerName).isBlank()) {
Text(
text = "Leave it empty and you'll show up as " +
"${Settings.DEFAULT_PLAYER_NAME}.",
style = MaterialTheme.typography.bodySmall,
)
}
}
Divider()
@@ -0,0 +1,131 @@
package org.prolibertate.games.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import org.prolibertate.games.R
import org.prolibertate.games.ui.theme.TartanBackground
/** How long the splash holds at full opacity before fading. */
private const val SPLASH_HOLD_MILLIS = 2_000L
private const val SPLASH_FADE_MILLIS = 600
/**
* Uncial Antiqua — a Celtic uncial face, bundled under the SIL Open Font
* License. See docs/licenses/UncialAntiqua-OFL.txt.
*/
val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua))
/**
* The launch screen: Wallace tartan under the name of the house.
*
* Drawn as an overlay rather than a separate destination, so the menu is
* already composed and ready behind it and the fade reveals a live screen
* instead of a blank one.
*/
@Composable
fun SplashScreen(
onFinished: () -> Unit,
modifier: Modifier = Modifier,
) {
var fading by remember { mutableStateOf(false) }
val alpha by animateFloatAsState(
targetValue = if (fading) 0f else 1f,
animationSpec = tween(durationMillis = SPLASH_FADE_MILLIS),
label = "splashFade",
)
LaunchedEffect(Unit) {
delay(SPLASH_HOLD_MILLIS)
fading = true
delay(SPLASH_FADE_MILLIS.toLong())
onFinished()
}
BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) {
// Read the constraints here and hold them in locals: inside Column the
// implicit receiver becomes ColumnScope, and maxWidth/maxHeight are no
// longer in scope.
val shortestSide = minOf(maxWidth, maxHeight).value
// Weave scale follows the screen so the cloth reads the same on a phone
// and a tablet rather than turning into a fine grid on a large display.
val threadWidth = (shortestSide * 0.012f).dp
val titleSize = (shortestSide * 0.13f).sp
TartanBackground(
modifier = Modifier.fillMaxSize(),
threadWidth = threadWidth,
)
// A scrim keeps the lettering legible over the busiest part of the sett.
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.45f))
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Pro Libertate",
style = TextStyle(
fontFamily = CelticFontFamily,
fontSize = titleSize,
color = Color(0xFFF7EFD8),
textAlign = TextAlign.Center,
shadow = Shadow(
color = Color.Black,
offset = Offset(0f, 3f),
blurRadius = 10f,
),
),
)
Text(
text = "Games",
style = TextStyle(
fontFamily = CelticFontFamily,
fontSize = titleSize,
color = Color(0xFFF2C200),
textAlign = TextAlign.Center,
shadow = Shadow(
color = Color.Black,
offset = Offset(0f, 3f),
blurRadius = 10f,
),
),
)
}
}
}
@@ -41,6 +41,12 @@ fun PlayingCardView(
width: Dp = 64.dp,
selected: Boolean = false,
enabled: Boolean = true,
/**
* Short note printed on the face, for when the rank and suit alone do not
* say what the card does — a Sequence jack being the obvious case, since
* one-eyed and two-eyed jacks are only distinguishable by their artwork.
*/
caption: String? = null,
onClick: (() -> Unit)? = null,
) {
val ink = if (card.suit.isRed) CardRed else CardBlack
@@ -61,6 +67,8 @@ fun PlayingCardView(
),
contentAlignment = Alignment.Center,
) {
// Shrink the pips slightly when a caption has to share the face.
val pipScale = if (caption == null) 0.34f else 0.28f
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
@@ -69,13 +77,22 @@ fun PlayingCardView(
text = card.rank.short,
color = ink,
fontWeight = FontWeight.Bold,
fontSize = (width.value * 0.34f).sp,
fontSize = (width.value * pipScale).sp,
)
Text(
text = card.suit.symbol,
color = ink,
fontSize = (width.value * 0.34f).sp,
fontSize = (width.value * pipScale).sp,
)
if (caption != null) {
Text(
text = caption,
color = ink,
fontWeight = FontWeight.Bold,
fontSize = (width.value * 0.145f).sp,
maxLines = 1,
)
}
}
}
}
@@ -1,13 +1,18 @@
package org.prolibertate.games.ui.game
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
@@ -18,13 +23,22 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
import org.prolibertate.games.game.euchre.CallTrump
import org.prolibertate.games.game.euchre.Discard
import org.prolibertate.games.game.euchre.EuchreMove
@@ -51,6 +65,7 @@ import org.prolibertate.games.ui.theme.FeltGreenDark
fun EuchreScreen(
controller: MatchController<EuchreState, EuchreMove>,
localSeat: Int,
trickHoldMillis: Long,
onExit: () -> Unit,
) {
val state by controller.state.collectAsState()
@@ -84,7 +99,7 @@ fun EuchreScreen(
.background(FeltGreenDark),
contentAlignment = Alignment.Center,
) {
TrickArea(current, localSeat, cardWidth)
TrickArea(current, localSeat, cardWidth, trickHoldMillis)
}
notice?.let {
@@ -166,8 +181,47 @@ private fun seatLabel(relative: Int): String = when (relative) {
* turned to face them, so you can read a trick at a glance instead of matching
* seat numbers to cards.
*/
/** How long a completed trick rests on the table before being swept away. */
const val TRICK_HOLD_MILLIS = 1_000L
/** Duration of the sweep itself. */
private const val TRICK_SWEEP_MILLIS = 450
@Composable
private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
private fun TrickArea(
state: EuchreState,
localSeat: Int,
cardWidth: Dp,
trickHoldMillis: Long,
) {
// A finished trick stays put for a beat so it can be read, then slides off
// towards whoever won it.
val sweeping = state.trick.isEmpty() && state.completedTrick.isNotEmpty()
val cardsOnTable = if (sweeping) state.completedTrick else state.trick
var sweepStarted by remember(state.completedTrick) { mutableStateOf(false) }
LaunchedEffect(state.completedTrick) {
if (state.completedTrick.isNotEmpty()) {
delay(trickHoldMillis)
sweepStarted = true
}
}
val sweep by animateFloatAsState(
targetValue = if (sweeping && sweepStarted) 1f else 0f,
animationSpec = tween(durationMillis = TRICK_SWEEP_MILLIS),
label = "trickSweep",
)
// Everything travels towards the winner's edge of the table together.
val winnerRelative = state.lastTrickWinner?.let { relativePosition(it, localSeat) } ?: 0
val (dirX, dirY) = when (winnerRelative) {
0 -> 0f to 1f
1 -> -1f to 0f
2 -> 0f to -1f
else -> 1f to 0f
}
val travelPx = with(LocalDensity.current) { (cardWidth * 2.5f).toPx() }
Box(modifier = Modifier.fillMaxSize().padding(4.dp)) {
// The turn card sits in the middle of the table during bidding.
@@ -185,7 +239,9 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
}
}
if (state.upCard == null && state.trick.isEmpty() && state.phase == EuchrePhase.PLAYING) {
if (state.upCard == null && cardsOnTable.isEmpty() &&
state.phase == EuchrePhase.PLAYING
) {
Text(
text = if (state.turn == localSeat) "Lead a card" else "Waiting…",
color = MaterialTheme.colorScheme.onPrimary,
@@ -193,7 +249,7 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
)
}
state.trick.forEach { played ->
cardsOnTable.forEach { played ->
val relative = relativePosition(played.seat, localSeat)
val alignment = when (relative) {
0 -> Alignment.BottomCenter
@@ -211,7 +267,15 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
}
Column(
modifier = Modifier.align(alignment),
modifier = Modifier
.align(alignment)
.offset {
IntOffset(
x = (dirX * travelPx * sweep).roundToInt(),
y = (dirY * travelPx * sweep).roundToInt(),
)
}
.alpha(1f - sweep),
horizontalAlignment = Alignment.CenterHorizontally,
) {
PlayingCardView(
@@ -227,6 +291,16 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
}
}
// Name the winner while their cards are still on the table.
if (sweeping && state.lastTrickWinner != null) {
Text(
text = "${seatLabel(winnerRelative)} takes it",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.align(Alignment.Center).alpha(1f - sweep),
)
}
// Who is on the clock, shown where they sit rather than as a seat number.
if (state.phase == EuchrePhase.PLAYING && state.trick.size < state.activeSeatCount) {
val waitingOn = relativePosition(state.turn, localSeat)
@@ -242,6 +316,7 @@ private fun TrickArea(state: EuchreState, localSeat: Int, cardWidth: Dp) {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BiddingControls(
state: EuchreState,
@@ -260,6 +335,8 @@ private fun BiddingControls(
// the turn card to somebody else or to yourself.
val youAreDealer = state.turn == state.dealer
val upCard = state.upCard
val canPass = Pass in legal
val choices = legal.filter { it != Pass }
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
@@ -282,13 +359,25 @@ private fun BiddingControls(
style = MaterialTheme.typography.bodySmall,
)
}
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
// Stick the dealer is the one case where there is genuinely no way out,
// so say so rather than leaving it looking like a missing button.
if (!canPass) {
Text(
text = "Everyone has passed, so as dealer you must name a suit — " +
"that's stick the dealer. Turn it off in game setup if you'd " +
"rather the hand be thrown in.",
style = MaterialTheme.typography.bodySmall,
)
}
// Wraps rather than scrolls. A scrolling row pushed Pass off the right
// edge of a phone screen, which made bidding look compulsory.
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
legal.forEach { move ->
choices.forEach { move ->
val label = when (move) {
is Pass -> "Pass"
is OrderUp -> when {
youAreDealer && move.alone -> "Pick it up, alone"
youAreDealer -> "Pick it up"
@@ -301,12 +390,20 @@ private fun BiddingControls(
is Discard -> "Discard ${move.card.label}"
is PlayCard -> move.card.label
is Pass -> "Pass"
}
if (move is Pass) {
OutlinedButton(onClick = { onMove(move) }) { Text(label) }
} else {
Button(onClick = { onMove(move) }) { Text(label) }
}
Button(onClick = { onMove(move) }) { Text(label) }
}
}
// Kept out of the wrapping group and full width so it can never be
// clipped, scrolled away or mistaken for one of the bids.
if (canPass) {
OutlinedButton(
onClick = { onMove(Pass) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Pass")
}
}
}
@@ -35,6 +35,8 @@ import androidx.compose.ui.unit.sp
import org.prolibertate.games.game.cards.Card
import org.prolibertate.games.game.sequence.BOARD_SIZE
import org.prolibertate.games.game.sequence.ExchangeDeadCard
import org.prolibertate.games.game.sequence.isOneEyedJack
import org.prolibertate.games.game.sequence.isTwoEyedJack
import org.prolibertate.games.game.sequence.NO_TEAM
import org.prolibertate.games.game.sequence.PlaceChip
import org.prolibertate.games.game.sequence.RemoveChip
@@ -54,6 +56,17 @@ import org.prolibertate.games.ui.theme.TeamColours
*/
private val HighlightAmber = Color(0xFFFFC107)
/**
* What a jack does, printed on its face. On a real deck this is carried by the
* artwork — a jack shown in profile has one eye and removes a chip, one shown
* face-on has two and is wild — which a rank-and-suit card cannot convey.
*/
private fun jackCaption(card: Card): String? = when {
isTwoEyedJack(card) -> "WILD"
isOneEyedJack(card) -> "REMOVE"
else -> null
}
/**
* The Sequence board.
*
@@ -294,6 +307,9 @@ private fun HandStrip(
width = cardWidth,
selected = card == selected,
enabled = card in playable,
// A jack's power comes from its artwork, which these cards
// do not have, so spell it out on the face instead.
caption = jackCaption(card),
onClick = {
val dead = exchanges[card]
if (dead != null) onExchange(dead) else onSelect(card)
@@ -301,9 +317,18 @@ private fun HandStrip(
)
}
}
selected?.let {
selected?.let { card ->
Text(
text = "Tap a highlighted square to play ${it.label}.",
text = when {
isTwoEyedJack(card) ->
"${card.label} is wild — tap any empty square."
isOneEyedJack(card) ->
"${card.label} removes a chip — tap an opponent's, " +
"as long as it isn't part of a finished sequence."
else -> "Tap a highlighted square to play ${card.label}."
},
style = MaterialTheme.typography.bodySmall,
)
}
@@ -0,0 +1,97 @@
package org.prolibertate.games.ui.theme
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
/** One band of a tartan sett, measured in threads. */
data class TartanStripe(val color: Color, val threads: Int)
val WallaceRed = Color(0xFFB3121B)
val WallaceBlack = Color(0xFF111111)
val WallaceGold = Color(0xFFF2C200)
/**
* The Wallace sett.
*
* NOTE: this is an approximation, not a certified threadcount. It reproduces
* what the Wallace tartan reads as — a bold red and black check divided by a
* narrow gold overstripe — but the exact thread counts held by the Scottish
* Register of Tartans could not be verified from here, so the numbers below
* are chosen to look right rather than to be authoritative. They are plain
* data: replacing them with the registered counts changes the whole app's
* branding and needs no other edit.
*/
val WallaceSett: List<TartanStripe> = listOf(
TartanStripe(WallaceRed, 40),
TartanStripe(WallaceBlack, 40),
TartanStripe(WallaceGold, 4),
)
/**
* Draws a tartan by laying the sett down vertically as warp, then across as
* weft at half opacity. The overlap is what produces the blended squares and
* the sense of a woven cloth rather than a printed grid — red warp under black
* weft comes out a darker red, exactly as it does on the loom.
*/
fun DrawScope.drawTartan(
sett: List<TartanStripe> = WallaceSett,
threadSize: Float,
) {
if (sett.isEmpty() || threadSize <= 0f) return
val repeat = sett.sumOf { it.threads } * threadSize
if (repeat <= 0f) return
// Warp: full-height bands.
var x = 0f
while (x < size.width) {
var offset = x
for (stripe in sett) {
val width = stripe.threads * threadSize
drawRect(
color = stripe.color,
topLeft = Offset(offset, 0f),
size = Size(width, size.height),
)
offset += width
}
x += repeat
}
// Weft: full-width bands, half opacity so the warp shows through.
var y = 0f
while (y < size.height) {
var offset = y
for (stripe in sett) {
val height = stripe.threads * threadSize
drawRect(
color = stripe.color.copy(alpha = 0.5f),
topLeft = Offset(0f, offset),
size = Size(size.width, height),
)
offset += height
}
y += repeat
}
}
/**
* A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be
* a fine weave on a phone and a coarse one on a tablet without redrawing it.
*/
@Composable
fun TartanBackground(
modifier: Modifier = Modifier,
sett: List<TartanStripe> = WallaceSett,
threadWidth: Dp = 2.dp,
) {
Canvas(modifier = modifier) {
drawTartan(sett = sett, threadSize = threadWidth.toPx())
}
}
@@ -1,11 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
A single square of the Wallace tartan.
The sett is laid down as warp (opaque, vertical) over a black ground, then as
weft (half opacity, horizontal), which is what produces the blended squares
rather than a flat grid. One full repeat is 72 units wide, so the centre
72x72 safe zone of the adaptive icon shows exactly one repeat.
Thread proportions match WallaceSett in Tartan.kt — see the note there about
these being a visual approximation rather than the registered threadcount.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Solid grass-green field. -->
<!-- Ground -->
<path
android:fillColor="#2E7D32"
android:fillColor="#111111"
android:pathData="M0,0h108v108h-108z" />
<!-- Warp: red -->
<path
android:fillColor="#B3121B"
android:pathData="M0,0h34.29v108h-34.29z" />
<path
android:fillColor="#B3121B"
android:pathData="M72,0h34.29v108h-34.29z" />
<!-- Warp: gold overstripe -->
<path
android:fillColor="#F2C200"
android:pathData="M68.57,0h3.43v108h-3.43z" />
<!-- Weft: red -->
<path
android:fillColor="#B3121B"
android:fillAlpha="0.5"
android:pathData="M0,0h108v34.29h-108z" />
<path
android:fillColor="#B3121B"
android:fillAlpha="0.5"
android:pathData="M0,72h108v34.29h-108z" />
<!-- Weft: black -->
<path
android:fillColor="#111111"
android:fillAlpha="0.5"
android:pathData="M0,34.29h108v34.28h-108z" />
<path
android:fillColor="#111111"
android:fillAlpha="0.5"
android:pathData="M0,106.29h108v1.71h-108z" />
<!-- Weft: gold overstripe -->
<path
android:fillColor="#F2C200"
android:fillAlpha="0.5"
android:pathData="M0,68.57h108v3.43h-108z" />
</vector>
@@ -1,14 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Deliberately empty. The icon is the cloth itself, so the adaptive icon's
foreground layer carries nothing — anything drawn here would sit on top of the
tartan and break the "single square of tartan" idea.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- A black hole in the center of the icon's safe zone. -->
<path
android:fillColor="#111111"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
android:fillColor="#00000000"
android:pathData="M0,0h108v108h-108z" />
</vector>
Binary file not shown.
+16 -12
View File
@@ -1,17 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallback launcher icon for API < 26 (no adaptive-icon support). -->
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
at icon scale so one full sett repeat fills the tile. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#2E7D32"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#111111"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
android:viewportWidth="84"
android:viewportHeight="84">
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
<!-- Warp -->
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
<!-- Weft -->
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
</vector>
+16 -12
View File
@@ -1,17 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Fallback round launcher icon for API < 26. -->
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
at icon scale so one full sett repeat fills the tile. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#2E7D32"
android:pathData="M54,54m-54,0a54,54 0,1 1,108 0a54,54 0,1 1,-108 0" />
<path
android:fillColor="#111111"
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
<path
android:fillColor="#000000"
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
android:viewportWidth="84"
android:viewportHeight="84">
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
<!-- Warp -->
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
<!-- Weft -->
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
</vector>
+4 -1
View File
@@ -1,6 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1B5E20</color>
<color name="ic_launcher_background">#B3121B</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<!-- Shown for the instant before Compose draws, so launch does not flash
white before the tartan splash appears. -->
<color name="splash_background">#4A0B10</color>
</resources>
+1
View File
@@ -5,5 +5,6 @@
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@color/splash_background</item>
</style>
</resources>
@@ -0,0 +1,65 @@
package org.prolibertate.games.game
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CatalogTest {
@Test
fun `every game has a unique id`() {
val ids = GameCatalog.all.map { it.id }
assertEquals("ids must be unique", ids.size, ids.distinct().size)
ids.forEach { assertEquals(it, GameCatalog.byId(it)?.id) }
}
@Test
fun `a production build lists only games that can be played`() {
GameCategory.entries.forEach { category ->
val shown = GameCatalog.byCategory(category, includeComingSoon = false)
assertTrue(
"$category offers an unfinished game in a release build",
shown.all { it.available },
)
}
}
@Test
fun `a dev build lists everything including what is still coming`() {
val shown = GameCategory.entries.flatMap {
GameCatalog.byCategory(it, includeComingSoon = true)
}
assertEquals(GameCatalog.all.size, shown.size)
assertTrue("unfinished games are visible while developing", shown.any { !it.available })
}
@Test
fun `hiding unfinished games still leaves something to play in each category`() {
// A category filtered down to nothing would leave a stranded heading,
// and a menu with no playable game at all would be a broken release.
GameCategory.entries.forEach { category ->
assertFalse(
"$category has nothing playable in a release build",
GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(),
)
}
assertTrue(GameCatalog.playable.isNotEmpty())
}
@Test
fun `playable games are the ones with engines wired up`() {
assertEquals(
setOf(GameCatalog.EUCHRE, GameCatalog.SEQUENCE),
GameCatalog.playable.map { it.id }.toSet(),
)
}
@Test
fun `player counts are sane`() {
GameCatalog.all.forEach { game ->
assertTrue("${game.id} min players", game.minPlayers >= 2)
assertTrue("${game.id} max >= min", game.maxPlayers >= game.minPlayers)
}
}
}
@@ -0,0 +1,121 @@
package org.prolibertate.games.game.euchre
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertTrue
import org.junit.Test
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
/**
* How often the AI takes the bid.
*
* This exists because an over-eager AI does not look like a tuning problem from
* the table — it looks like the game refusing to let you bid. If the three
* computer players order up nearly every deal, a human dealer is dragged into
* picking up the turn card again and again and never gets a say.
*/
class EuchreBiddingRateTest {
private val json = Json { encodeDefaults = true }
private fun config(seed: Long, options: EuchreOptions = EuchreOptions()) = TableConfig(
gameId = "euchre",
seats = (0 until 4).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it % 2)
},
optionsJson = json.encodeToString(options),
seed = seed,
)
private data class Outcome(
val orderedUpInRoundOne: Boolean,
val reachedRoundTwo: Boolean,
val dealerForcedToPickUp: Boolean,
)
/** Runs the bidding for one deal with every seat played by the AI. */
private fun bidOutDeal(seed: Long): Outcome {
val ai = EuchreAi()
var state = EuchreRules.initialState(config(seed))
val dealer = state.dealer
var reachedRoundTwo = false
var guard = 0
while (guard++ < 40) {
when (state.phase) {
EuchrePhase.BID_ROUND_1 -> Unit
EuchrePhase.BID_ROUND_2 -> reachedRoundTwo = true
else -> break
}
val seat = EuchreRules.currentSeat(state) ?: break
val legal = EuchreRules.legalMoves(state, seat)
state = EuchreRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
// Reaching the discard step means somebody ordered it up and the dealer
// now has to take the card whether they like it or not.
val orderedUpInRoundOne = !reachedRoundTwo && state.trump != null
return Outcome(
orderedUpInRoundOne = orderedUpInRoundOne,
reachedRoundTwo = reachedRoundTwo,
dealerForcedToPickUp = orderedUpInRoundOne && state.maker != dealer,
)
}
@Test
fun `the ai does not order up on most deals`() {
val deals = 2000
val outcomes = (0 until deals).map { bidOutDeal(it.toLong()) }
val orderedUp = outcomes.count { it.orderedUpInRoundOne }
val forcedPickUp = outcomes.count { it.dealerForcedToPickUp }
val roundTwo = outcomes.count { it.reachedRoundTwo }
val orderedUpRate = orderedUp * 100.0 / deals
val forcedRate = forcedPickUp * 100.0 / deals
println(
"ordered up in round 1: %.1f%% dealer forced to pick up: %.1f%% reached round 2: %.1f%%"
.format(orderedUpRate, forcedRate, roundTwo * 100.0 / deals)
)
// Ordering up should be a decision, not a formality. Real play turns the
// card down more often than not, so a majority of deals must survive
// round one.
assertTrue(
"AI orders up on %.1f%% of deals — far too eager".format(orderedUpRate),
orderedUpRate <= 55.0,
)
assertTrue(
"AI almost never bids (%.1f%%) — too timid".format(orderedUpRate),
orderedUpRate >= 15.0,
)
// The specific complaint: as dealer you keep being handed the turn card.
assertTrue(
"dealer is dragged into picking up on %.1f%% of deals".format(forcedRate),
forcedRate <= 45.0,
)
}
@Test
fun `going alone stays rare`() {
val ai = EuchreAi()
var alone = 0
val deals = 1000
repeat(deals) { seed ->
var state = EuchreRules.initialState(config(seed.toLong()))
var guard = 0
while (state.trump == null && guard++ < 40) {
val seat = EuchreRules.currentSeat(state) ?: break
val legal = EuchreRules.legalMoves(state, seat)
if (legal.isEmpty()) break
state = EuchreRules.applyMove(state, seat, ai.chooseMove(state, seat, legal))
}
if (state.aloneSeat != null) alone++
}
val rate = alone * 100.0 / deals
println("went alone: %.1f%%".format(rate))
assertTrue("going alone on %.1f%% of deals is too often".format(rate), rate <= 12.0)
}
}
@@ -304,6 +304,45 @@ class EuchreRulesTest {
assertEquals(0, finished.scores[0])
}
// -- Completed tricks ---------------------------------------------------
@Test
fun `a finished trick stays on the table until the next card is played`() {
val trump = Suit.SPADES
val hands = listOf(
listOf(Card(Rank.ACE, Suit.SPADES), Card(Rank.NINE, Suit.HEARTS)),
listOf(Card(Rank.NINE, Suit.SPADES), Card(Rank.TEN, Suit.HEARTS)),
listOf(Card(Rank.TEN, Suit.SPADES), Card(Rank.QUEEN, Suit.HEARTS)),
listOf(Card(Rank.QUEEN, Suit.SPADES), Card(Rank.KING, Suit.HEARTS)),
)
var state = playingState(hands = hands, trump = trump)
// Play a full trick.
repeat(4) {
val seat = EuchreRules.currentSeat(state)!!
state = EuchreRules.applyMove(state, seat, PlayCard(state.hands[seat].first()))
}
assertTrue("the live trick is cleared", state.trick.isEmpty())
assertEquals("all four cards are held for display", 4, state.completedTrick.size)
assertEquals("seat 0 played the ace of trump", 0, state.lastTrickWinner)
// The next card played sweeps it away.
val next = EuchreRules.currentSeat(state)!!
state = EuchreRules.applyMove(state, next, PlayCard(state.hands[next].first()))
assertTrue("held trick is cleared once play resumes", state.completedTrick.isEmpty())
}
@Test
fun `a fresh hand starts with nothing held on the table`() {
var state = EuchreRules.initialState(config())
assertTrue(state.completedTrick.isEmpty())
state = EuchreRules.applyMove(state, state.turn, OrderUp(alone = false))
val discard = EuchreRules.legalMoves(state, state.dealer).first() as Discard
state = EuchreRules.applyMove(state, state.dealer, discard)
assertTrue("play begins with a clear table", state.completedTrick.isEmpty())
}
// -- Full games ---------------------------------------------------------
@Test
+5
View File
@@ -53,6 +53,11 @@ Play one card and place a chip on a matching empty square, then draw.
- **Two-eyed jacks** (♦ and ♣) are wild: place a chip on any empty square.
- **One-eyed jacks** (♥ and ♠) remove one opposing chip — but never a chip that
is part of a completed sequence, and never your own team's chip.
On a physical deck the difference is carried by the artwork: a jack drawn in
profile shows one eye, a jack drawn face-on shows two. These cards are drawn as
rank and suit only, so a jack in hand is captioned **WILD** or **REMOVE**
instead.
- **Dead card — *toggle*, on by default.** If both squares printed with your card
are already occupied, you may discard it and draw a replacement. This does not
cost your turn, and you may do it once per turn.
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2011 by Brian J. Bonislawsky DBA Astigmatic (AOETI) ([email protected]), with Reserved Font Name "Uncial Antiqua"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.