diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..095b7fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ +release/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Log Files +*.log + +# Android Studio / IntelliJ +*.iml +.idea/ +captures/ +.externalNativeBuild/ +.cxx/ + +# Keystore files +*.jks +*.keystore +# ...except the shared debug keystore, committed so every CI build signs +# identically and the in-app updater can install updates over each other. +!app/debug.keystore + +# OS +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..a85fc6a --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Pro Libertate Games + +A collection of card and board games for Android, playable against the computer +or against other people over Wi-Fi (LAN) or Bluetooth. + +## What works today + +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 +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. + +## Playing with other people + +Pick a game, choose **Host a game for others to join**, and the device advertises +itself on every radio it has. Other devices choose **Join a game nearby** and +pick the host from the list. Seats start out filled by the computer and are +handed to people as they join, so any mix of humans and AI works — whatever is +still marked *Computer* when the host starts stays AI. + +- **Wi-Fi (LAN)** — both devices must be on the same network. Discovery uses + mDNS/NSD; play runs over a TCP socket. +- **Bluetooth** — devices must already be **paired** in Android's Bluetooth + settings. Play runs over Classic RFCOMM. Scanning for unpaired devices is not + implemented. + +Both transports can be live at once, so some players can be on Wi-Fi and others +on Bluetooth at the same table. + +### How a match is kept in step + +The host is authoritative. Clients send *intents* ("I would like to play this +card") and the host re-validates every one against the rules before it takes +effect — a client cannot make an illegal move by sending a malformed message. +Each client is then sent a state **redacted for its own seat**, so opponents' +hands and the deck order are never transmitted to devices that should not see +them. + +## Settings + +- **Sound** on/off. +- **Animation speed**, 0.5×–2×. One multiplier drives card movement and how long + the computer appears to think. +- **Updates** — check on launch, or check on demand. Updates are pulled from the + latest GitHub Release of this repository, which CI publishes on every push to + `main`. + +## Screen sizes + +There are no fixed orientations or hard-coded card sizes. Layouts measure the +space they are given: the menu grid reflows by width, the Sequence board is +always square and sized to fit whatever is left after the hand, and card widths +are a fraction of the smaller screen dimension. Rotation is handled inside the +activity, so a hand is not lost when the device turns. + +## Building + +```sh +./gradlew assembleDebug # APK at app/build/outputs/apk/debug/ +./gradlew testDebugUnitTest # rules engines and AI +``` + +Requires JDK 17 and the Android SDK (compileSdk 34). + +## CI + +- `.github/workflows/ci.yml` — builds, tests and lints on `dev` and on pull + requests into `dev` or `main`. +- `.github/workflows/release.yml` — on push to `main`, builds the APK and + publishes it as a GitHub Release tagged `v1.0.`. The in-app updater + reads that tag, which is why the APK's `versionCode` is the same run number. + +The debug keystore is committed on purpose: every build is signed with the same +key, so an OTA update installs over the previous one instead of being rejected +for a signature mismatch. + +## Rules + +The exact rules implemented, and the options that vary them, are documented per +game: + +- [Euchre](docs/RULES-euchre.md) +- [Sequence](docs/RULES-sequence.md) — **note the caveat about the board layout** + +## Layout + +``` +game/ rules engines and AI — pure Kotlin, no Android, unit-tested + cards/ deck, suits, ranks + engine/ GameRules / GameAi contracts, seats, table config + euchre/ Euchre model, rules, AI + sequence/ Sequence model, board, rules, AI +net/ wire protocol, LAN and Bluetooth transports, lobby, match driver +settings/ DataStore-backed preferences +update/ GitHub Releases OTA updater +ui/ Compose screens +``` + +Keeping `game/` free of Android types is deliberate: the same code drives the +AI, the host's authoritative state and the JVM unit tests. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..d97dd7c --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,96 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.serialization") +} + +// On CI, GITHUB_RUN_NUMBER is set automatically and matches the release tag +// (v1.0.), so the shipped APK knows its own version and the +// in-app updater can tell whether a newer release exists. Locally it's a dev build. +val ciRunNumber = (System.getenv("GITHUB_RUN_NUMBER") ?: "").toIntOrNull() ?: 0 + +android { + namespace = "org.prolibertate.games" + compileSdk = 34 + + defaultConfig { + applicationId = "org.prolibertate.games" + minSdk = 24 + targetSdk = 34 + versionCode = if (ciRunNumber > 0) ciRunNumber else 1 + versionName = if (ciRunNumber > 0) "1.0.$ciRunNumber" else "1.0.0-dev" + } + + signingConfigs { + // A fixed, checked-in debug keystore (standard "android" password) so + // every build — local or CI — is signed with the same key. Without this, + // each CI runner would generate a random debug key and OTA updates would + // fail to install with a signature mismatch. + getByName("debug") { + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + buildFeatures { + compose = true + } + + composeOptions { + // Must stay in step with the Kotlin version declared in the root build file. + kotlinCompilerExtensionVersion = "1.5.14" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +dependencies { + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.activity:activity-compose:1.9.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2") + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2") + + val composeBom = platform("androidx.compose:compose-bom:2024.06.00") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + debugImplementation("androidx.compose.ui:ui-tooling") + + // Settings persistence for sound / animation speed / update preferences. + implementation("androidx.datastore:datastore-preferences:1.1.1") + + // Wire format for the LAN and Bluetooth game protocol. + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + testImplementation("junit:junit:4.13.2") +} diff --git a/app/debug.keystore b/app/debug.keystore new file mode 100644 index 0000000..5323065 Binary files /dev/null and b/app/debug.keystore differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..fddcb3a --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Add project specific ProGuard rules here. +# The game has no reflection-based code, so the defaults are sufficient. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d1e9b20 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/org/prolibertate/games/MainActivity.kt b/app/src/main/java/org/prolibertate/games/MainActivity.kt new file mode 100644 index 0000000..eb041c1 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/MainActivity.kt @@ -0,0 +1,77 @@ +package org.prolibertate.games + +import android.Manifest +import android.os.Build +import android.os.Bundle +import android.provider.Settings as AndroidSettings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.lifecycle.lifecycleScope +import org.prolibertate.games.net.LobbyController +import org.prolibertate.games.settings.SettingsRepository +import org.prolibertate.games.ui.AppEnv +import org.prolibertate.games.ui.AppRoot +import org.prolibertate.games.ui.theme.ProLibertateTheme +import org.prolibertate.games.update.Updater +import java.util.UUID + +class MainActivity : ComponentActivity() { + + private lateinit var env: AppEnv + + /** + * Bluetooth needs runtime consent from Android 12 onwards. The result is + * ignored deliberately: refusing simply means the Bluetooth transport + * reports itself unavailable and the lobby falls back to Wi-Fi. + */ + private val bluetoothPermissions = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + env = AppEnv( + settingsRepository = SettingsRepository(applicationContext), + updater = Updater(this), + lobby = LobbyController(applicationContext, lifecycleScope), + peerId = stablePeerId(), + requestBluetoothPermissions = ::requestBluetoothPermissions, + ) + + setContent { + ProLibertateTheme { + AppRoot(env) + } + } + } + + private fun requestBluetoothPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + bluetoothPermissions.launch( + arrayOf( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_ADVERTISE, + ) + ) + } + } + + /** + * Identifies this device to a host for the length of an install. ANDROID_ID + * is per-app-signing-key and survives restarts, which is all a lobby needs + * to tell two phones apart. + */ + private fun stablePeerId(): String { + val androidId = runCatching { + AndroidSettings.Secure.getString(contentResolver, AndroidSettings.Secure.ANDROID_ID) + }.getOrNull() + return androidId ?: UUID.randomUUID().toString() + } + + override fun onDestroy() { + super.onDestroy() + if (::env.isInitialized) env.lobby.stop() + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt new file mode 100644 index 0000000..1667c94 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -0,0 +1,159 @@ +package org.prolibertate.games.game + +/** + * The catalogue the main menu is built from. + * + * Every game the app intends to ship appears here, whether or not its rules + * engine exists yet. [GameDescriptor.available] is what the menu keys off to + * decide between "play" and "coming soon", so adding a game is a matter of + * flipping that flag once its engine and screen land. + */ +enum class GameCategory(val label: String) { + CARD("Playing Card Games"), + BOARD("Board Games"), +} + +data class GameDescriptor( + val id: String, + val title: String, + val category: GameCategory, + val minPlayers: Int, + val maxPlayers: Int, + /** True when players are grouped into partnerships rather than playing solo. */ + val teamBased: Boolean, + val blurb: String, + val available: Boolean, +) + +object GameCatalog { + + const val EUCHRE = "euchre" + const val KAISER = "kaiser" + const val PRESIDENT = "president" + const val GOLF = "golf" + const val WIZARD = "wizard" + const val CRAZY_EIGHTS = "crazy8s" + const val SEQUENCE = "sequence" + const val MASTERMIND = "mastermind" + const val BACKGAMMON = "backgammon" + const val CHESS = "chess" + const val CHECKERS = "checkers" + + val all: List = listOf( + GameDescriptor( + id = EUCHRE, + title = "Euchre", + category = GameCategory.CARD, + minPlayers = 4, + maxPlayers = 4, + teamBased = true, + blurb = "Four players, two partnerships, 24-card deck. Bowers are trump.", + available = true, + ), + GameDescriptor( + id = KAISER, + title = "Kaiser", + category = GameCategory.CARD, + minPlayers = 4, + maxPlayers = 4, + teamBased = true, + blurb = "Prairie trick-taking with bidding. The 5 of hearts and 3 of spades decide it.", + available = false, + ), + GameDescriptor( + id = PRESIDENT, + title = "President", + category = GameCategory.CARD, + minPlayers = 3, + maxPlayers = 7, + teamBased = false, + blurb = "Shed your hand first and rule the table. Last one out serves the drinks.", + available = false, + ), + GameDescriptor( + id = GOLF, + title = "Golf", + category = GameCategory.CARD, + minPlayers = 2, + maxPlayers = 6, + teamBased = false, + blurb = "Lowest score wins over nine holes. Match pairs to cancel them out.", + available = false, + ), + GameDescriptor( + id = WIZARD, + title = "Wizard", + category = GameCategory.CARD, + minPlayers = 3, + maxPlayers = 6, + teamBased = false, + blurb = "Bid your tricks exactly. Wizards always win, jesters always lose.", + available = false, + ), + GameDescriptor( + id = CRAZY_EIGHTS, + title = "Crazy 8s", + category = GameCategory.CARD, + minPlayers = 2, + maxPlayers = 6, + teamBased = false, + blurb = "Match rank or suit, and eights change everything.", + available = false, + ), + GameDescriptor( + id = SEQUENCE, + title = "Sequence", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 12, + teamBased = true, + blurb = "Play a card, claim its square, and build five in a row.", + available = true, + ), + GameDescriptor( + id = MASTERMIND, + title = "Mastermind", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "Break the hidden code from black and white peg clues.", + available = false, + ), + GameDescriptor( + id = BACKGAMMON, + title = "Backgammon", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "Race your checkers home, and hit your opponent on the way.", + available = false, + ), + GameDescriptor( + id = CHESS, + title = "Chess", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "The old game. Full legal moves, castling, en passant, promotion.", + available = false, + ), + GameDescriptor( + id = CHECKERS, + title = "Checkers", + category = GameCategory.BOARD, + minPlayers = 2, + maxPlayers = 2, + teamBased = false, + blurb = "Forced captures, kings, and the long diagonal.", + available = false, + ), + ) + + fun byId(id: String): GameDescriptor? = all.firstOrNull { it.id == id } + + fun byCategory(category: GameCategory): List = + all.filter { it.category == category } +} diff --git a/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt b/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt new file mode 100644 index 0000000..652cc93 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/cards/Cards.kt @@ -0,0 +1,97 @@ +package org.prolibertate.games.game.cards + +import kotlinx.serialization.Serializable +import kotlin.random.Random + +@Serializable +enum class Suit(val symbol: String, val isRed: Boolean) { + CLUBS("♣", false), + DIAMONDS("♦", true), + HEARTS("♥", true), + SPADES("♠", false); + + /** Euchre's left bower lives in the suit of the same colour. */ + fun sameColour(): Suit = when (this) { + CLUBS -> SPADES + SPADES -> CLUBS + DIAMONDS -> HEARTS + HEARTS -> DIAMONDS + } +} + +@Serializable +enum class Rank(val short: String, val order: Int) { + TWO("2", 2), + THREE("3", 3), + FOUR("4", 4), + FIVE("5", 5), + SIX("6", 6), + SEVEN("7", 7), + EIGHT("8", 8), + NINE("9", 9), + TEN("10", 10), + JACK("J", 11), + QUEEN("Q", 12), + KING("K", 13), + ACE("A", 14), +} + +@Serializable +data class Card(val rank: Rank, val suit: Suit) { + /** Compact form used in logs and in the Sequence board layout table. */ + val code: String get() = "${rank.short}${suit.name.first()}" + + val label: String get() = "${rank.short}${suit.symbol}" + + override fun toString(): String = label + + companion object { + /** Parses the [code] form, e.g. "10H", "AS", "9D". */ + fun parse(code: String): Card { + val suitChar = code.last() + val rankPart = code.dropLast(1) + val suit = Suit.entries.first { it.name.first() == suitChar } + val rank = Rank.entries.first { it.short == rankPart } + return Card(rank, suit) + } + } +} + +object Decks { + + /** Standard 52-card deck. */ + fun standard52(): List = + Suit.entries.flatMap { suit -> Rank.entries.map { rank -> Card(rank, suit) } } + + /** + * Euchre decks. 24 cards (9 through ace) is the common game; 32 cards + * (7 through ace) is a widespread variant that lengthens the hand. + */ + fun euchre(size: Int): List { + val lowest = when (size) { + 24 -> Rank.NINE + 32 -> Rank.SEVEN + else -> throw IllegalArgumentException("Unsupported Euchre deck size: $size") + } + return Suit.entries.flatMap { suit -> + Rank.entries.filter { it.order >= lowest.order }.map { rank -> Card(rank, suit) } + } + } + + /** Two full decks shuffled together, as Sequence uses. */ + fun double52(): List = standard52() + standard52() +} + +/** + * Deterministic shuffle. Every shuffle in the app runs through a seeded + * [Random] so a networked hand can be reproduced from its seed and so tests + * are repeatable — the host generates the seed and ships it with the state. + */ +fun List.shuffledWith(random: Random): List = toMutableList().apply { + for (i in indices.reversed()) { + val j = random.nextInt(i + 1) + val tmp = this[i] + this[i] = this[j] + this[j] = tmp + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt new file mode 100644 index 0000000..d44b825 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/engine/Engine.kt @@ -0,0 +1,87 @@ +package org.prolibertate.games.game.engine + +import kotlinx.serialization.Serializable + +@Serializable +enum class PlayerKind { + /** A person on this device. */ + HUMAN_LOCAL, + + /** A person on another device, reached over LAN or Bluetooth. */ + HUMAN_REMOTE, + + /** Driven by the host's AI. */ + AI, +} + +@Serializable +data class PlayerSlot( + val seat: Int, + val name: String, + val kind: PlayerKind, + /** Partnership index. Solo games put every seat on its own team. */ + val team: Int, + /** Identifies the remote device for [PlayerKind.HUMAN_REMOTE] seats. */ + val peerId: String? = null, +) + +@Serializable +data class TableConfig( + val gameId: String, + val seats: List, + /** Game-specific options, serialised by the owning rules implementation. */ + val optionsJson: String, + /** Shared shuffle seed so a hand is reproducible from its state. */ + val seed: Long, +) + +/** + * A game's rules, expressed as a pure state machine. + * + * Implementations must be free of Android dependencies and free of side + * effects: [applyMove] returns a new state rather than mutating. That is what + * lets the same code drive the local AI, the host's authoritative copy, and + * the unit tests. + */ +interface GameRules { + + val gameId: String + + fun initialState(config: TableConfig): S + + /** The seat expected to move, or null when nobody is on the clock. */ + fun currentSeat(state: S): Int? + + fun legalMoves(state: S, seat: Int): List + + /** + * Applies [move] on behalf of [seat]. + * + * @throws IllegalArgumentException if the move is not currently legal — + * the host validates every intent that arrives over the network this way. + */ + fun applyMove(state: S, seat: Int, move: M): S + + fun isFinished(state: S): Boolean + + fun summary(state: S): String + + /** + * Strips information [seat] is not entitled to see. + * + * The host holds the true state and sends each client only its own view, + * so an opponent's hand never crosses the wire in the first place. Any + * hidden-information game must implement this meaningfully. + */ + fun viewFor(state: S, seat: Int): S + + fun encodeState(state: S): String + fun decodeState(json: String): S + fun encodeMove(move: M): String + fun decodeMove(json: String): M +} + +/** Chooses moves for unoccupied seats. Always runs on the host. */ +interface GameAi { + fun chooseMove(state: S, seat: Int, legal: List): M +} diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt new file mode 100644 index 0000000..ea8f6ee --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreAi.kt @@ -0,0 +1,175 @@ +package org.prolibertate.games.game.euchre + +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.GameAi + +/** + * A heuristic Euchre opponent. + * + * It does not look at anybody else's cards and it does not search — it counts + * trump, respects its partner, and plays the cheapest card that does the job. + * That is enough to be a reasonable table opponent without pretending to be a + * solver. + */ +class EuchreAi(private val difficulty: Difficulty = Difficulty.NORMAL) : + GameAi { + + enum class Difficulty(val bidThreshold: Int, val aloneThreshold: Int) { + /** Bids loosely and plays straightforwardly. */ + EASY(bidThreshold = 7, aloneThreshold = 26), + + NORMAL(bidThreshold = 9, aloneThreshold = 24), + + /** Bids tighter and is harder to sneak a trick past. */ + CAUTIOUS(bidThreshold = 11, aloneThreshold = 22), + } + + override fun chooseMove(state: EuchreState, seat: Int, legal: List): EuchreMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + return when (state.phase) { + EuchrePhase.BID_ROUND_1 -> chooseBidRound1(state, seat, legal) + EuchrePhase.BID_ROUND_2 -> chooseBidRound2(state, seat, legal) + EuchrePhase.DEALER_DISCARD -> chooseDiscard(state, legal) + EuchrePhase.PLAYING -> choosePlay(state, seat, legal) + else -> legal.first() + } + } + + // ----------------------------------------------------------------------- + // Bidding + // ----------------------------------------------------------------------- + + /** + * Rates a hand for a candidate trump suit. Bowers and trump length carry + * the hand; off-suit aces are worth a trick more often than not. + */ + private fun handValue(hand: List, trump: Suit): Int { + var value = 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 + card.rank == Rank.ACE -> 3 + card.rank == Rank.KING -> 1 + else -> 0 + } + } + // 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 + } + + private fun chooseBidRound1( + state: EuchreState, + seat: Int, + legal: List, + ): EuchreMove { + val upCard = state.upCard ?: return Pass + val trump = upCard.suit + var hand = state.hands[seat] + + // If our partner is the dealer they gain the turn card, and if we are + // the dealer we gain it ourselves — both make the hand stronger than it + // looks. Ordering up into an opponent dealer instead hands them a card. + if (seat == state.dealer) hand = hand + upCard + var value = handValue(hand, trump) + if (partnerOf(seat) == state.dealer) value += 2 + if (seat != state.dealer && partnerOf(seat) != state.dealer) value -= 2 + + val alone = OrderUp(alone = true) + if (value >= difficulty.aloneThreshold && legal.contains(alone)) return alone + val order = OrderUp(alone = false) + if (value >= difficulty.bidThreshold && legal.contains(order)) return order + return if (legal.contains(Pass)) Pass else legal.first() + } + + private fun chooseBidRound2( + state: EuchreState, + seat: Int, + legal: List, + ): EuchreMove { + val hand = state.hands[seat] + val callable = legal.filterIsInstance() + if (callable.isEmpty()) return legal.first() + + val best = callable + .filter { !it.alone } + .maxByOrNull { handValue(hand, it.suit) } + ?: callable.first() + val bestValue = handValue(hand, best.suit) + + val alone = callable.firstOrNull { it.alone && it.suit == best.suit } + if (alone != null && bestValue >= difficulty.aloneThreshold) return alone + if (bestValue >= difficulty.bidThreshold) return best + // Stuck as dealer with a bad hand: name the least-bad suit anyway. + return if (legal.contains(Pass)) Pass else best + } + + private fun chooseDiscard(state: EuchreState, legal: List): EuchreMove { + val trump = requireNotNull(state.trump) + val discards = legal.filterIsInstance() + // Prefer to void a side suit entirely; otherwise throw the lowest card. + val hand = state.hands[state.dealer] + val suitCounts = hand.groupingBy { effectiveSuit(it, trump) }.eachCount() + return discards.minByOrNull { discard -> + val card = discard.card + val suit = effectiveSuit(card, trump) + val trumpPenalty = if (suit == trump) 1000 else 0 + val singletonBonus = if (suitCounts[suit] == 1 && card.rank != Rank.ACE) -20 else 0 + trumpPenalty + singletonBonus + card.rank.order + } ?: discards.first() + } + + // ----------------------------------------------------------------------- + // Card play + // ----------------------------------------------------------------------- + + private fun choosePlay( + state: EuchreState, + seat: Int, + legal: List, + ): EuchreMove { + val trump = requireNotNull(state.trump) + val cards = legal.filterIsInstance().map { it.card } + if (cards.size == 1) return PlayCard(cards.first()) + + if (state.trick.isEmpty()) return PlayCard(chooseLead(cards, trump)) + + val ledSuit = effectiveSuit(state.trick.first().card, trump) + val bestSoFar = state.trick.maxBy { trickStrength(it.card, trump, ledSuit) } + val partnerWinning = partnerOf(bestSoFar.seat) == seat + val lastToPlay = state.trick.size == state.activeSeatCount - 1 + + // Partner already has it and we are last: never waste a good card. + if (partnerWinning && lastToPlay) return PlayCard(lowest(cards, trump, ledSuit)) + + val bestStrength = trickStrength(bestSoFar.card, trump, ledSuit) + val winners = cards.filter { trickStrength(it, trump, ledSuit) > bestStrength } + return when { + // Take it as cheaply as we can. + winners.isNotEmpty() && (!partnerWinning || !lastToPlay) -> + PlayCard(winners.minBy { trickStrength(it, trump, ledSuit) }) + + else -> PlayCard(lowest(cards, trump, ledSuit)) + } + } + + private fun chooseLead(cards: List, trump: Suit): Card { + // Right bower up front pulls trump and protects the left. + cards.firstOrNull { isRightBower(it, trump) }?.let { return it } + // Off-suit aces are most likely to survive early. + cards.filter { !isTrumpCard(it, trump) && it.rank == Rank.ACE } + .maxByOrNull { it.rank.order } + ?.let { return it } + val offSuit = cards.filter { !isTrumpCard(it, trump) } + if (offSuit.isNotEmpty()) return offSuit.maxBy { it.rank.order } + return cards.maxBy { trumpStrength(it, trump) } + } + + private fun lowest(cards: List, trump: Suit, ledSuit: Suit): Card = + cards.minBy { trickStrength(it, trump, ledSuit).takeIf { s -> s > 0 } ?: it.rank.order } +} diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt new file mode 100644 index 0000000..3087724 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreModel.kt @@ -0,0 +1,154 @@ +package org.prolibertate.games.game.euchre + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit + +/** + * Rule toggles. Euchre is played differently in almost every kitchen, so the + * variations that actually change play are surfaced in the game setup screen + * rather than baked in. Defaults are the common North American game. + */ +@Serializable +data class EuchreOptions( + /** 24 cards (9–A) is standard; 32 (7–A) is a widespread longer variant. */ + val deckSize: Int = 24, + /** Dealer must name trump rather than pass the hand out. Also called screw the dealer. */ + val stickTheDealer: Boolean = true, + /** Allow a maker to play the hand without their partner for a bigger score. */ + val allowGoingAlone: Boolean = true, + /** First team to this many points wins. 10 is standard; 11 and 15 are common. */ + val pointsToWin: Int = 10, +) { + init { + require(deckSize == 24 || deckSize == 32) { "Euchre deck must be 24 or 32 cards" } + require(pointsToWin > 0) { "pointsToWin must be positive" } + } +} + +@Serializable +enum class EuchrePhase { + /** Players may order up the turned card, in turn, starting left of the dealer. */ + BID_ROUND_1, + + /** The turn card is down; players may name any other suit. */ + BID_ROUND_2, + + /** The dealer has picked up the turn card and must discard one. */ + DEALER_DISCARD, + + PLAYING, + + /** Five tricks played and scored; waiting to deal the next hand. */ + HAND_OVER, + + GAME_OVER, +} + +@Serializable +data class PlayedCard(val seat: Int, val card: Card) + +@Serializable +data class EuchreState( + val options: EuchreOptions, + val seed: Long, + val handNumber: Int, + val dealer: Int, + val phase: EuchrePhase, + /** Per-seat hands. Redacted to empty for seats other than the viewer. */ + val hands: List>, + /** Card counts survive redaction so the table can still be drawn. */ + val handCounts: List, + val upCard: Card?, + /** The turned-down card in round two; its suit may not be named. */ + val turnedDown: Card?, + val trump: Suit?, + val maker: Int?, + val aloneSeat: Int?, + val turn: Int, + val passes: Int, + val trick: List, + val leader: Int, + val tricksWon: List, + /** Two entries: team 0 is seats 0 and 2, team 1 is seats 1 and 3. */ + val scores: List, + val lastTrickWinner: Int?, + val log: List, +) { + val sittingOut: Int? get() = aloneSeat?.let { partnerOf(it) } + + fun isActive(seat: Int): Boolean = seat != sittingOut + + val activeSeatCount: Int get() = if (aloneSeat != null) 3 else 4 +} + +@Serializable +sealed interface EuchreMove + +@Serializable +@SerialName("pass") +data object Pass : EuchreMove + +@Serializable +@SerialName("order_up") +data class OrderUp(val alone: Boolean) : EuchreMove + +@Serializable +@SerialName("call_trump") +data class CallTrump(val suit: Suit, val alone: Boolean) : EuchreMove + +@Serializable +@SerialName("discard") +data class Discard(val card: Card) : EuchreMove + +@Serializable +@SerialName("play") +data class PlayCard(val card: Card) : EuchreMove + +// --------------------------------------------------------------------------- +// Seating and card ranking +// --------------------------------------------------------------------------- + +fun partnerOf(seat: Int): Int = (seat + 2) % 4 + +fun teamOf(seat: Int): Int = seat % 2 + +/** The jack of trump: the highest card in the game. */ +fun isRightBower(card: Card, trump: Suit): Boolean = + card.rank == Rank.JACK && card.suit == trump + +/** + * The jack of the same colour as trump. It counts as a trump card and outranks + * every trump except the right bower — the rule that catches every new player. + */ +fun isLeftBower(card: Card, trump: Suit): Boolean = + card.rank == Rank.JACK && card.suit == trump.sameColour() + +/** The suit a card counts as, which is trump for the left bower. */ +fun effectiveSuit(card: Card, trump: Suit?): Suit = + if (trump != null && isLeftBower(card, trump)) trump else card.suit + +fun isTrumpCard(card: Card, trump: Suit?): Boolean = + trump != null && effectiveSuit(card, trump) == trump + +/** + * Ranks a card within a trick. Higher wins. Cards that neither follow the led + * suit nor trump score zero and can never take the trick. + */ +fun trickStrength(card: Card, trump: Suit, ledSuit: Suit): Int = when { + isRightBower(card, trump) -> 1000 + isLeftBower(card, trump) -> 900 + isTrumpCard(card, trump) -> 800 + card.rank.order + effectiveSuit(card, trump) == ledSuit -> 100 + card.rank.order + else -> 0 +} + +/** Ranks a card in isolation, for AI hand evaluation. */ +fun trumpStrength(card: Card, trump: Suit): Int = when { + isRightBower(card, trump) -> 1000 + isLeftBower(card, trump) -> 900 + isTrumpCard(card, trump) -> 800 + card.rank.order + else -> card.rank.order +} diff --git a/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt new file mode 100644 index 0000000..3c7084a --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/euchre/EuchreRules.kt @@ -0,0 +1,377 @@ +package org.prolibertate.games.game.euchre + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Euchre as a pure state machine. See RULES-euchre.md for the ruleset and the + * variants exposed through [EuchreOptions]. + */ +object EuchreRules : GameRules { + + override val gameId: String = GameCatalog.EUCHRE + + private const val CARDS_PER_HAND = 5 + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + // ----------------------------------------------------------------------- + // Setup + // ----------------------------------------------------------------------- + + override fun initialState(config: TableConfig): EuchreState { + require(config.seats.size == 4) { "Euchre is a four-handed game" } + val options = json.decodeFromString(config.optionsJson) + return deal( + options = options, + seed = config.seed, + handNumber = 0, + dealer = 0, + scores = listOf(0, 0), + log = listOf("Hand 1 — dealer is seat 0."), + ) + } + + /** Deals a fresh hand and opens the first round of bidding. */ + private fun deal( + options: EuchreOptions, + seed: Long, + handNumber: Int, + dealer: Int, + scores: List, + log: List, + ): EuchreState { + // Derived per hand so a given (seed, handNumber) always reproduces the + // same deal — handy for tests and for replaying a networked game. + val random = Random(seed + handNumber * 7919L) + val deck = Decks.euchre(options.deckSize).shuffledWith(random) + val hands = (0 until 4).map { seat -> + deck.subList(seat * CARDS_PER_HAND, (seat + 1) * CARDS_PER_HAND).toList() + } + val upCard = deck[4 * CARDS_PER_HAND] + return EuchreState( + options = options, + seed = seed, + handNumber = handNumber, + dealer = dealer, + phase = EuchrePhase.BID_ROUND_1, + hands = hands, + handCounts = hands.map { it.size }, + upCard = upCard, + turnedDown = null, + trump = null, + maker = null, + aloneSeat = null, + turn = (dealer + 1) % 4, + passes = 0, + trick = emptyList(), + leader = (dealer + 1) % 4, + tricksWon = listOf(0, 0, 0, 0), + scores = scores, + lastTrickWinner = null, + log = log, + ) + } + + /** + * Advances from [EuchrePhase.HAND_OVER] into the next deal. Driven by the + * session controller once the finished hand has been on screen long enough + * to read, rather than by a player move. + */ + fun nextHand(state: EuchreState): EuchreState { + check(state.phase == EuchrePhase.HAND_OVER) { "Hand is not over" } + val dealer = (state.dealer + 1) % 4 + return deal( + options = state.options, + seed = state.seed, + handNumber = state.handNumber + 1, + dealer = dealer, + scores = state.scores, + log = state.log + "Hand ${state.handNumber + 2} — dealer is seat $dealer.", + ) + } + + // ----------------------------------------------------------------------- + // Turn order + // ----------------------------------------------------------------------- + + private fun nextActive(state: EuchreState, from: Int): Int { + var seat = (from + 1) % 4 + while (seat == state.sittingOut) seat = (seat + 1) % 4 + return seat + } + + override fun currentSeat(state: EuchreState): Int? = when (state.phase) { + EuchrePhase.BID_ROUND_1, + EuchrePhase.BID_ROUND_2, + EuchrePhase.DEALER_DISCARD, + EuchrePhase.PLAYING, + -> state.turn + + EuchrePhase.HAND_OVER, EuchrePhase.GAME_OVER -> null + } + + // ----------------------------------------------------------------------- + // Legal moves + // ----------------------------------------------------------------------- + + override fun legalMoves(state: EuchreState, seat: Int): List { + if (currentSeat(state) != seat) return emptyList() + return when (state.phase) { + EuchrePhase.BID_ROUND_1 -> buildList { + add(OrderUp(alone = false)) + if (state.options.allowGoingAlone) add(OrderUp(alone = true)) + add(Pass) + } + + EuchrePhase.BID_ROUND_2 -> buildList { + val barred = state.turnedDown?.suit + for (suit in Suit.entries) { + if (suit == barred) continue + add(CallTrump(suit, alone = false)) + if (state.options.allowGoingAlone) add(CallTrump(suit, alone = true)) + } + // Stick the dealer: with three passes already in, the dealer has + // to name something rather than throw the hand in. + val dealerIsStuck = + state.options.stickTheDealer && seat == state.dealer && state.passes == 3 + if (!dealerIsStuck) add(Pass) + } + + EuchrePhase.DEALER_DISCARD -> state.hands[state.dealer].map { Discard(it) } + + EuchrePhase.PLAYING -> playableCards(state, seat).map { PlayCard(it) } + + EuchrePhase.HAND_OVER, EuchrePhase.GAME_OVER -> emptyList() + } + } + + /** Follow the led suit if you can; otherwise anything goes. */ + fun playableCards(state: EuchreState, seat: Int): List { + val hand = state.hands[seat] + val led = state.trick.firstOrNull() ?: return hand + val ledSuit = effectiveSuit(led.card, state.trump) + val following = hand.filter { effectiveSuit(it, state.trump) == ledSuit } + return following.ifEmpty { hand } + } + + // ----------------------------------------------------------------------- + // Move application + // ----------------------------------------------------------------------- + + override fun applyMove(state: EuchreState, seat: Int, move: EuchreMove): EuchreState { + require(currentSeat(state) == seat) { "Not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + return when (move) { + is Pass -> applyPass(state, seat) + is OrderUp -> applyOrderUp(state, seat, move.alone) + is CallTrump -> applyCallTrump(state, seat, move.suit, move.alone) + is Discard -> applyDiscard(state, move.card) + is PlayCard -> applyPlay(state, seat, move.card) + }.normalised() + } + + private fun EuchreState.normalised(): EuchreState = copy(handCounts = hands.map { it.size }) + + private fun applyPass(state: EuchreState, seat: Int): EuchreState { + val passes = state.passes + 1 + val log = state.log + "Seat $seat passes." + return when (state.phase) { + EuchrePhase.BID_ROUND_1 -> if (passes >= 4) { + state.copy( + phase = EuchrePhase.BID_ROUND_2, + turnedDown = state.upCard, + upCard = null, + turn = (state.dealer + 1) % 4, + passes = 0, + log = log + "${state.upCard?.label} turned down.", + ) + } else { + state.copy(turn = (seat + 1) % 4, passes = passes, log = log) + } + + EuchrePhase.BID_ROUND_2 -> if (passes >= 4) { + // Nobody wanted it and the dealer wasn't stuck: throw the hand in. + deal( + options = state.options, + seed = state.seed, + handNumber = state.handNumber + 1, + dealer = (state.dealer + 1) % 4, + scores = state.scores, + log = log + "Hand thrown in; redealing.", + ) + } else { + state.copy(turn = (seat + 1) % 4, passes = passes, log = log) + } + + else -> error("Pass is not legal in ${state.phase}") + } + } + + private fun applyOrderUp(state: EuchreState, seat: Int, alone: Boolean): EuchreState { + val upCard = requireNotNull(state.upCard) { "No turn card to order up" } + // The dealer takes the turn card into hand and will discard one back. + val hands = state.hands.toMutableList() + hands[state.dealer] = hands[state.dealer] + upCard + return state.copy( + phase = EuchrePhase.DEALER_DISCARD, + hands = hands, + trump = upCard.suit, + maker = seat, + aloneSeat = if (alone) seat else null, + upCard = null, + turn = state.dealer, + log = state.log + buildString { + append("Seat $seat orders up ${upCard.suit.symbol}") + if (alone) append(" and goes alone") + append(".") + }, + ) + } + + private fun applyCallTrump( + state: EuchreState, + seat: Int, + suit: Suit, + alone: Boolean, + ): EuchreState { + val withTrump = state.copy( + trump = suit, + maker = seat, + aloneSeat = if (alone) seat else null, + log = state.log + buildString { + append("Seat $seat names ${suit.symbol}") + if (alone) append(" and goes alone") + append(".") + }, + ) + return beginPlay(withTrump) + } + + private fun applyDiscard(state: EuchreState, card: Card): EuchreState { + val hands = state.hands.toMutableList() + hands[state.dealer] = hands[state.dealer] - card + return beginPlay(state.copy(hands = hands, log = state.log + "Dealer discards.")) + } + + /** Trump is settled: clear any lone hand's partner and lead to the first trick. */ + private fun beginPlay(state: EuchreState): EuchreState { + val sittingOut = state.sittingOut + val hands = state.hands.toMutableList() + if (sittingOut != null) hands[sittingOut] = emptyList() + val leader = nextActive(state, state.dealer) + return state.copy( + phase = EuchrePhase.PLAYING, + hands = hands, + leader = leader, + turn = leader, + trick = emptyList(), + passes = 0, + ) + } + + private fun applyPlay(state: EuchreState, seat: Int, card: Card): EuchreState { + 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) + + if (trick.size < state.activeSeatCount) { + return played.copy(turn = nextActive(state, seat)) + } + + // Trick complete: resolve it. + val trump = requireNotNull(state.trump) + val ledSuit = effectiveSuit(trick.first().card, trump) + val winner = trick.maxBy { trickStrength(it.card, trump, ledSuit) }.seat + val tricksWon = state.tricksWon.toMutableList() + tricksWon[winner] = tricksWon[winner] + 1 + + val afterTrick = played.copy( + trick = emptyList(), + tricksWon = tricksWon, + leader = winner, + turn = winner, + lastTrickWinner = winner, + log = state.log + "Seat $winner takes the trick.", + ) + + val handFinished = afterTrick.hands.all { it.isEmpty() } + return if (handFinished) scoreHand(afterTrick) else afterTrick + } + + // ----------------------------------------------------------------------- + // Scoring + // ----------------------------------------------------------------------- + + private fun scoreHand(state: EuchreState): EuchreState { + val maker = requireNotNull(state.maker) + val makerTeam = teamOf(maker) + val makerTricks = state.tricksWon.filterIndexed { seat, _ -> teamOf(seat) == makerTeam }.sum() + val scores = state.scores.toMutableList() + + val note: String + if (makerTricks >= 3) { + val points = when { + makerTricks == 5 && state.aloneSeat != null -> 4 + makerTricks == 5 -> 2 + else -> 1 + } + scores[makerTeam] = scores[makerTeam] + points + note = "Team $makerTeam took $makerTricks tricks for $points point(s)." + } else { + // Euchred: the defenders score two regardless of how it fell apart. + val defenders = 1 - makerTeam + scores[defenders] = scores[defenders] + 2 + note = "Team $makerTeam is euchred — team $defenders scores 2." + } + + val winner = scores.indexOfFirst { it >= state.options.pointsToWin } + return state.copy( + phase = if (winner >= 0) EuchrePhase.GAME_OVER else EuchrePhase.HAND_OVER, + scores = scores, + log = state.log + note + if (winner >= 0) listOf("Team $winner wins.") else emptyList(), + ) + } + + override fun isFinished(state: EuchreState): Boolean = state.phase == EuchrePhase.GAME_OVER + + override fun summary(state: EuchreState): String = when (state.phase) { + EuchrePhase.GAME_OVER -> { + val winner = state.scores.indexOfFirst { it >= state.options.pointsToWin } + "Team $winner wins ${state.scores[0]}–${state.scores[1]}" + } + + else -> "We ${state.scores[0]} — They ${state.scores[1]}" + } + + // ----------------------------------------------------------------------- + // Redaction and wire format + // ----------------------------------------------------------------------- + + override fun viewFor(state: EuchreState, seat: Int): EuchreState = state.copy( + hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() }, + handCounts = state.handCounts, + ) + + override fun encodeState(state: EuchreState): String = json.encodeToString(state) + + override fun decodeState(json: String): EuchreState = + this.json.decodeFromString(json) + + override fun encodeMove(move: EuchreMove): String = json.encodeToString(move) + + override fun decodeMove(json: String): EuchreMove = + this.json.decodeFromString(json) +} diff --git a/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt new file mode 100644 index 0000000..20f1871 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceAi.kt @@ -0,0 +1,122 @@ +package org.prolibertate.games.game.sequence + +import org.prolibertate.games.game.engine.GameAi + +/** + * A heuristic Sequence opponent. + * + * It scores every legal placement by how much it advances one of its own runs + * and how badly it spoils an opponent's, holds its jacks back until they are + * worth spending, and never looks at anyone's hand. + */ +class SequenceAi(private val difficulty: Difficulty = Difficulty.NORMAL) : + GameAi { + + enum class Difficulty(val blockWeight: Int, val jackPatience: Int) { + /** Builds its own runs and mostly ignores yours. */ + EASY(blockWeight = 1, jackPatience = 0), + + NORMAL(blockWeight = 3, jackPatience = 3), + + /** Blocks aggressively and saves jacks for real threats. */ + CAUTIOUS(blockWeight = 5, jackPatience = 4), + } + + override fun chooseMove( + state: SequenceState, + seat: Int, + legal: List, + ): SequenceMove { + require(legal.isNotEmpty()) { "No legal move for seat $seat" } + val team = state.teamOf(seat) + + // Clearing a dead card is free value when nothing better is on offer. + val exchange = legal.filterIsInstance().firstOrNull() + if (exchange != null && legal.none { it is PlaceChip }) return exchange + + val best = legal + .filterIsInstance() + .maxByOrNull { scorePlacement(state, it, team) } + + val removal = legal + .filterIsInstance() + .maxByOrNull { threatAt(state, it.cell, state.chips[it.cell]) } + + // Spend a one-eyed jack only on a genuinely dangerous chip. + if (removal != null) { + val threat = threatAt(state, removal.cell, state.chips[removal.cell]) + if (threat >= difficulty.jackPatience) return removal + } + + return best ?: removal ?: exchange ?: legal.first() + } + + /** + * Rates placing a chip: how long a run it joins for us, plus how much of an + * opponent's run it interrupts. Wild jacks are discounted so they are not + * burned on an ordinary square. + */ + private fun scorePlacement(state: SequenceState, move: PlaceChip, team: Int): Int { + val ours = runPotential(state, move.cell, team) + val theirs = (0 until state.options.teamCount) + .filter { it != team } + .maxOfOrNull { runPotential(state, move.cell, it) } + ?: 0 + + var score = ours * 4 + theirs * difficulty.blockWeight + // Central squares sit on more possible lines than edge squares. + val row = SequenceBoard.rowOf(move.cell) + val col = SequenceBoard.colOf(move.cell) + score += 4 - (kotlin.math.abs(row - 4) + kotlin.math.abs(col - 4)) / 2 + if (isTwoEyedJack(move.card)) score -= 12 + return score + } + + /** + * The best run this team could still build through [cell]: the highest + * count of friendly-or-free squares in any five-window containing it, once + * windows blocked by an opponent are discarded. + */ + private fun runPotential(state: SequenceState, cell: Int, team: Int): Int { + val row = SequenceBoard.rowOf(cell) + val col = SequenceBoard.colOf(cell) + var best = 0 + + for ((dr, dc) in DIRECTIONS) { + for (offset in -(RUN_LENGTH - 1)..0) { + var count = 0 + var blocked = false + for (step in 0 until RUN_LENGTH) { + val r = row + (offset + step) * dr + val c = col + (offset + step) * dc + if (r !in 0 until BOARD_SIZE || c !in 0 until BOARD_SIZE) { + blocked = true + break + } + val target = SequenceBoard.cellAt(r, c) + val occupant = state.chips[target] + when { + SequenceBoard.isCorner(target) -> count++ + target == cell -> count++ + occupant == team -> count++ + occupant == NO_TEAM -> Unit + else -> { + blocked = true + } + } + if (blocked) break + } + if (!blocked && count > best) best = count + } + } + return best + } + + /** How close [team] is to completing a run through [cell]. */ + private fun threatAt(state: SequenceState, cell: Int, team: Int): Int = + if (team == NO_TEAM) 0 else runPotential(state, cell, team) + + private companion object { + val DIRECTIONS = listOf(1 to 0, 0 to 1, 1 to 1, 1 to -1) + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/sequence/SequenceModel.kt b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceModel.kt new file mode 100644 index 0000000..815cd83 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceModel.kt @@ -0,0 +1,144 @@ +package org.prolibertate.games.game.sequence + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit + +const val BOARD_SIZE = 10 +const val BOARD_CELLS = BOARD_SIZE * BOARD_SIZE +const val RUN_LENGTH = 5 + +/** Sentinel stored in [SequenceState.chips] for an unoccupied square. */ +const val NO_TEAM = -1 + +/** + * The printed board. + * + * IMPORTANT: this is a generated layout, not a reproduction of the commercial + * Sequence board. It satisfies every structural property the rules depend on — + * each of the 48 non-jack cards appears exactly twice, the four corners are + * free squares, and no card sits next to its own twin — so play is correct, but + * square-for-square it will not match a physical board. Replace [LAYOUT] with + * the real arrangement to match one; nothing else needs to change. + */ +object SequenceBoard { + + private val LAYOUT: List = listOf( + "FREE 10C 6D 4C 7C 8H 9S 10C 10H FREE", + " 3H 8S 2H 9C AH 10S 4S 5H QH 4H", + " 3S AS KD KC AC KS 3D 10D 6D KH", + " 6H 3D 3C QC 7D 8H 9D 5S 4H 6C", + " AD 5D 8S 9S 4S 2D 10H QD 5D QC", + " 7H 9D QH 2C 4C AS AD 8C 6C QS", + " 2S 6S 8D KH 7C 7S 4D 7S 3H 3S", + " 10S 4D 7H AC 2H 2C 3C 6S 2S 7D", + " 8C 10D KD 5H 9H 5C QS 8D 9H 9C", + "FREE 6H KS 2D AH 5S QD KC 5C FREE", + ) + + /** Card printed on each square, or null for the four free corners. */ + val cells: List = LAYOUT.flatMap { row -> + row.trim().split(Regex("\\s+")).map { token -> + if (token == "FREE") null else Card.parse(token) + } + }.also { parsed -> + require(parsed.size == BOARD_CELLS) { "Board must have $BOARD_CELLS squares" } + } + + /** Squares showing a given card. Every non-jack card occupies exactly two. */ + val squaresByCard: Map> = cells + .withIndex() + .filter { it.value != null } + .groupBy({ it.value!! }, { it.index }) + + fun isCorner(cell: Int): Boolean = cells[cell] == null + + fun rowOf(cell: Int): Int = cell / BOARD_SIZE + fun colOf(cell: Int): Int = cell % BOARD_SIZE + fun cellAt(row: Int, col: Int): Int = row * BOARD_SIZE + col +} + +/** Jacks do not appear on the board; they are the two wild actions instead. */ +fun isTwoEyedJack(card: Card): Boolean = + card.rank == Rank.JACK && (card.suit == Suit.DIAMONDS || card.suit == Suit.CLUBS) + +fun isOneEyedJack(card: Card): Boolean = + card.rank == Rank.JACK && (card.suit == Suit.HEARTS || card.suit == Suit.SPADES) + +@Serializable +data class SequenceOptions( + /** Two teams need two sequences; three teams need one. */ + val teamCount: Int = 2, + val playersPerTeam: Int = 1, + val sequencesToWin: Int = 2, + /** Allow swapping a card whose squares are both taken. */ + val deadCardExchange: Boolean = true, +) { + init { + require(teamCount in 2..3) { "Sequence supports two or three teams" } + require(playersPerTeam >= 1) { "Each team needs at least one player" } + require(sequencesToWin >= 1) { "sequencesToWin must be positive" } + } + + val playerCount: Int get() = teamCount * playersPerTeam +} + +/** + * Cards dealt per player, which shrinks as the table grows. These are the + * standard counts; unusual player counts fall back to the nearest band. + */ +fun handSizeFor(players: Int): Int = when { + players <= 2 -> 7 + players <= 4 -> 6 + players <= 6 -> 5 + players <= 9 -> 4 + else -> 3 +} + +@Serializable +enum class SequencePhase { PLAYING, GAME_OVER } + +@Serializable +data class SequenceState( + val options: SequenceOptions, + val seed: Long, + /** Team index per seat. */ + val teams: List, + /** Occupying team per square, or [NO_TEAM]. */ + val chips: List, + /** Squares belonging to a completed sequence: safe from one-eyed jacks. */ + val locked: List, + val hands: List>, + val handCounts: List, + val drawPile: List, + val discardPile: List, + val turn: Int, + val sequencesByTeam: List, + val winner: Int?, + val phase: SequencePhase, + /** Set once per turn when a dead card has already been exchanged. */ + val exchangedThisTurn: Boolean, + val lastPlacedCell: Int?, + val log: List, +) { + val playerCount: Int get() = teams.size + + fun teamOf(seat: Int): Int = teams[seat] +} + +@Serializable +sealed interface SequenceMove + +@Serializable +@SerialName("place") +data class PlaceChip(val card: Card, val cell: Int) : SequenceMove + +@Serializable +@SerialName("remove") +data class RemoveChip(val card: Card, val cell: Int) : SequenceMove + +@Serializable +@SerialName("exchange") +data class ExchangeDeadCard(val card: Card) : SequenceMove diff --git a/app/src/main/java/org/prolibertate/games/game/sequence/SequenceRules.kt b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceRules.kt new file mode 100644 index 0000000..ef3eaf0 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/sequence/SequenceRules.kt @@ -0,0 +1,302 @@ +package org.prolibertate.games.game.sequence + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.shuffledWith +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +/** + * Sequence as a pure state machine. See RULES-sequence.md for the ruleset and + * the caveat about the board layout. + */ +object SequenceRules : GameRules { + + override val gameId: String = GameCatalog.SEQUENCE + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + /** The four straight lines a run of five can follow. */ + private val DIRECTIONS = listOf( + 1 to 0, // horizontal + 0 to 1, // vertical + 1 to 1, // diagonal down-right + 1 to -1, // diagonal down-left + ) + + override fun initialState(config: TableConfig): SequenceState { + val options = json.decodeFromString(config.optionsJson) + require(config.seats.size == options.playerCount) { + "Seat count ${config.seats.size} does not match ${options.playerCount} players" + } + + val random = Random(config.seed) + var deck = Decks.double52().shuffledWith(random) + val handSize = handSizeFor(options.playerCount) + val hands = mutableListOf>() + for (seat in 0 until options.playerCount) { + hands += deck.take(handSize) + deck = deck.drop(handSize) + } + + // Seats alternate around the table so partners are never adjacent. + val teams = (0 until options.playerCount).map { it % options.teamCount } + + return SequenceState( + options = options, + seed = config.seed, + teams = teams, + chips = List(BOARD_CELLS) { NO_TEAM }, + locked = List(BOARD_CELLS) { false }, + hands = hands, + handCounts = hands.map { it.size }, + drawPile = deck, + discardPile = emptyList(), + turn = 0, + sequencesByTeam = List(options.teamCount) { 0 }, + winner = null, + phase = SequencePhase.PLAYING, + exchangedThisTurn = false, + lastPlacedCell = null, + log = listOf("Sequence — ${options.teamCount} teams, first to ${options.sequencesToWin}."), + ) + } + + override fun currentSeat(state: SequenceState): Int? = + if (state.phase == SequencePhase.PLAYING) state.turn else null + + // ----------------------------------------------------------------------- + // Legal moves + // ----------------------------------------------------------------------- + + override fun legalMoves(state: SequenceState, seat: Int): List { + if (currentSeat(state) != seat) return emptyList() + val team = state.teamOf(seat) + val moves = mutableListOf() + + for (card in state.hands[seat].distinct()) { + when { + isTwoEyedJack(card) -> + // Wild: any empty square on the board. + for (cell in 0 until BOARD_CELLS) { + if (!SequenceBoard.isCorner(cell) && state.chips[cell] == NO_TEAM) { + moves += PlaceChip(card, cell) + } + } + + isOneEyedJack(card) -> + // Removes one opposing chip that is not part of a sequence. + for (cell in 0 until BOARD_CELLS) { + val occupant = state.chips[cell] + if (occupant != NO_TEAM && occupant != team && !state.locked[cell]) { + moves += RemoveChip(card, cell) + } + } + + else -> { + val squares = SequenceBoard.squaresByCard[card].orEmpty() + val open = squares.filter { state.chips[it] == NO_TEAM } + if (open.isEmpty()) { + // Dead card: both printed squares are taken. + if (state.options.deadCardExchange && !state.exchangedThisTurn) { + moves += ExchangeDeadCard(card) + } + } else { + open.forEach { moves += PlaceChip(card, it) } + } + } + } + } + return moves + } + + // ----------------------------------------------------------------------- + // Move application + // ----------------------------------------------------------------------- + + override fun applyMove(state: SequenceState, seat: Int, move: SequenceMove): SequenceState { + require(currentSeat(state) == seat) { "Not seat $seat's turn" } + require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" } + return when (move) { + is PlaceChip -> applyPlace(state, seat, move) + is RemoveChip -> applyRemove(state, seat, move) + is ExchangeDeadCard -> applyExchange(state, seat, move) + } + } + + private fun applyPlace(state: SequenceState, seat: Int, move: PlaceChip): SequenceState { + val team = state.teamOf(seat) + val chips = state.chips.toMutableList() + chips[move.cell] = team + + val locked = state.locked.toMutableList() + val newSequences = claimSequences(chips, locked, move.cell, team) + + val sequences = state.sequencesByTeam.toMutableList() + sequences[team] = sequences[team] + newSequences + + val afterCard = discardAndDraw(state, seat, move.card) + val won = sequences[team] >= state.options.sequencesToWin + + return afterCard.copy( + chips = chips, + locked = locked, + sequencesByTeam = sequences, + lastPlacedCell = move.cell, + winner = if (won) team else null, + phase = if (won) SequencePhase.GAME_OVER else SequencePhase.PLAYING, + log = afterCard.log + buildString { + append("Seat $seat plays ${move.card.label}") + if (newSequences > 0) append(" — sequence!") + append(".") + }, + ).advanceTurn(skip = won) + } + + private fun applyRemove(state: SequenceState, seat: Int, move: RemoveChip): SequenceState { + val chips = state.chips.toMutableList() + chips[move.cell] = NO_TEAM + return discardAndDraw(state, seat, move.card) + .copy( + chips = chips, + log = state.log + "Seat $seat removes a chip with ${move.card.label}.", + ) + .advanceTurn(skip = false) + } + + /** Swapping a dead card costs the card but not the turn. */ + private fun applyExchange( + state: SequenceState, + seat: Int, + move: ExchangeDeadCard, + ): SequenceState = discardAndDraw(state, seat, move.card).copy( + exchangedThisTurn = true, + log = state.log + "Seat $seat swaps dead card ${move.card.label}.", + ) + + /** Moves the played card to the discard pile and replaces it from the deck. */ + private fun discardAndDraw( + state: SequenceState, + seat: Int, + card: Card, + ): SequenceState { + val hands = state.hands.toMutableList() + hands[seat] = hands[seat] - card + + var draw = state.drawPile + var discard = state.discardPile + + if (draw.isEmpty() && discard.isNotEmpty()) { + // Deck exhausted: the discards are shuffled back in and play + // continues. This happens before the card just played joins the + // pile, so a swapped dead card can never come straight back — + // which would otherwise let a player exchange the same card + // forever. + val random = Random(state.seed + state.log.size) + draw = discard.shuffledWith(random) + discard = emptyList() + } + discard = discard + card + + if (draw.isNotEmpty()) { + hands[seat] = hands[seat] + draw.first() + draw = draw.drop(1) + } + + return state.copy( + hands = hands, + handCounts = hands.map { it.size }, + drawPile = draw, + discardPile = discard, + ) + } + + private fun SequenceState.advanceTurn(skip: Boolean): SequenceState = + if (skip) this + else copy(turn = (turn + 1) % playerCount, exchangedThisTurn = false) + + // ----------------------------------------------------------------------- + // Sequence detection + // ----------------------------------------------------------------------- + + /** + * Counts and locks any runs of five completed by the chip just played at + * [cell]. + * + * Two sequences may share at most one chip, so a candidate run is only + * accepted when at most one of its squares is already locked. Corners are + * free squares that count for every team. + */ + private fun claimSequences( + chips: List, + locked: MutableList, + cell: Int, + team: Int, + ): Int { + fun ownedBy(c: Int): Boolean = SequenceBoard.isCorner(c) || chips[c] == team + + val row = SequenceBoard.rowOf(cell) + val col = SequenceBoard.colOf(cell) + var found = 0 + + for ((dr, dc) in DIRECTIONS) { + // Every window of five that includes the new chip. + for (offset in -(RUN_LENGTH - 1)..0) { + val window = (0 until RUN_LENGTH).map { step -> + val r = row + (offset + step) * dr + val c = col + (offset + step) * dc + if (r !in 0 until BOARD_SIZE || c !in 0 until BOARD_SIZE) return@map -1 + SequenceBoard.cellAt(r, c) + } + if (window.any { it < 0 }) continue + if (!window.all { ownedBy(it) }) continue + // Free corners are shared by everyone and never count as the + // already-locked chip for the shared-chip limit. + val lockedInWindow = window.count { locked[it] && !SequenceBoard.isCorner(it) } + if (lockedInWindow > 1) continue + + window.forEach { locked[it] = true } + found++ + break // At most one new sequence per direction. + } + } + return found + } + + // ----------------------------------------------------------------------- + // Results, redaction and wire format + // ----------------------------------------------------------------------- + + override fun isFinished(state: SequenceState): Boolean = + state.phase == SequencePhase.GAME_OVER + + override fun summary(state: SequenceState): String = when (val winner = state.winner) { + null -> state.sequencesByTeam + .mapIndexed { team, count -> "Team $team: $count" } + .joinToString(" ") + + else -> "Team $winner wins" + } + + override fun viewFor(state: SequenceState, seat: Int): SequenceState = state.copy( + hands = state.hands.mapIndexed { index, hand -> if (index == seat) hand else emptyList() }, + handCounts = state.handCounts, + // The deck order is hidden information too. + drawPile = emptyList(), + ) + + override fun encodeState(state: SequenceState): String = json.encodeToString(state) + + override fun decodeState(json: String): SequenceState = this.json.decodeFromString(json) + + override fun encodeMove(move: SequenceMove): String = json.encodeToString(move) + + override fun decodeMove(json: String): SequenceMove = this.json.decodeFromString(json) +} diff --git a/app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt b/app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt new file mode 100644 index 0000000..a39d74e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/BluetoothTransport.kt @@ -0,0 +1,176 @@ +package org.prolibertate.games.net + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothServerSocket +import android.bluetooth.BluetoothSocket +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.UUID + +/** + * Bluetooth play over Classic RFCOMM. + * + * RFCOMM rather than BLE: this is a sustained, ordered, two-way stream of small + * messages, which is exactly what a serial profile is for. BLE would mean + * chunking everything into 20-byte GATT writes for no benefit. + * + * Discovery is limited to *paired* devices. Pair the two devices once in + * Android's Bluetooth settings and they will see each other here; scanning for + * unpaired devices needs a foreground scan plus location permission on older + * releases and is not worth the friction for a game lobby. + */ +class BluetoothTransport(private val context: Context) : Transport { + + override val kind: TransportKind = TransportKind.BLUETOOTH + + private val serviceUuid: UUID = UUID.fromString(RFCOMM_UUID) + + private val adapter: BluetoothAdapter? + get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + + private var serverSocket: BluetoothServerSocket? = null + + override fun isAvailable(): Boolean = adapter?.isEnabled == true && hasConnectPermission() + + /** + * Android 12 replaced the blanket BLUETOOTH permission with BLUETOOTH_CONNECT. + * Below 31 the manifest permission is granted at install time. + */ + fun hasConnectPermission(): Boolean = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.BLUETOOTH_CONNECT, + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + + // ----------------------------------------------------------------------- + // Hosting + // ----------------------------------------------------------------------- + + @SuppressLint("MissingPermission") + override fun host(displayName: String, scope: CoroutineScope): Flow = callbackFlow { + val bluetooth = adapter + if (bluetooth == null || !hasConnectPermission()) { + close() + return@callbackFlow + } + + val server = runCatching { + bluetooth.listenUsingRfcommWithServiceRecord(SERVICE_NAME, serviceUuid) + }.getOrNull() + if (server == null) { + close() + return@callbackFlow + } + serverSocket = server + + val acceptJob = scope.launch(Dispatchers.IO) { + while (isActive) { + val socket: BluetoothSocket = runCatching { server.accept() }.getOrNull() ?: break + val connection = StreamConnection( + peerId = runCatching { socket.remoteDevice?.address }.getOrNull() ?: "bt", + kind = TransportKind.BLUETOOTH, + input = socket.inputStream, + output = socket.outputStream, + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + trySend(connection) + } + } + + awaitClose { + acceptJob.cancel() + runCatching { server.close() } + serverSocket = null + } + } + + // ----------------------------------------------------------------------- + // Discovery + // ----------------------------------------------------------------------- + + /** + * Paired devices are re-published on a slow poll: there is no callback for + * "a device became reachable", and a host that is not listening simply + * refuses the connection when joined. + */ + @SuppressLint("MissingPermission") + override fun discover(scope: CoroutineScope): Flow> = callbackFlow { + if (!hasConnectPermission()) { + trySend(emptyList()) + close() + return@callbackFlow + } + + val pollJob = scope.launch { + while (isActive) { + val bonded = runCatching { adapter?.bondedDevices.orEmpty() }.getOrDefault(emptySet()) + trySend( + bonded.map { device -> + DiscoveredHost( + id = "bt:${device.address}", + name = runCatching { device.name }.getOrNull() ?: device.address, + kind = TransportKind.BLUETOOTH, + address = device.address, + ) + } + ) + delay(POLL_INTERVAL_MS) + } + } + + awaitClose { pollJob.cancel() } + } + + // ----------------------------------------------------------------------- + // Joining + // ----------------------------------------------------------------------- + + @SuppressLint("MissingPermission") + override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection = + withContext(Dispatchers.IO) { + val bluetooth = requireNotNull(adapter) { "No Bluetooth adapter" } + require(hasConnectPermission()) { "Bluetooth permission not granted" } + + val device = bluetooth.getRemoteDevice(host.address) + // Discovery is expensive and slows down a connect attempt sharply. + runCatching { bluetooth.cancelDiscovery() } + + val socket = device.createRfcommSocketToServiceRecord(serviceUuid) + socket.connect() + StreamConnection( + peerId = host.id, + kind = TransportKind.BLUETOOTH, + input = socket.inputStream, + output = socket.outputStream, + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + } + + override fun stop() { + runCatching { serverSocket?.close() } + serverSocket = null + } + + private companion object { + const val POLL_INTERVAL_MS = 4_000L + } +} diff --git a/app/src/main/java/org/prolibertate/games/net/LanTransport.kt b/app/src/main/java/org/prolibertate/games/net/LanTransport.kt new file mode 100644 index 0000000..506b550 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/LanTransport.kt @@ -0,0 +1,215 @@ +package org.prolibertate.games.net + +import android.content.Context +import android.net.nsd.NsdManager +import android.net.nsd.NsdServiceInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.util.Collections + +/** + * LAN play: mDNS service discovery to find the host, then a plain TCP socket + * per client. + * + * NSD is used rather than a UDP broadcast ping because it works across the + * Wi-Fi isolation settings most home routers ship with, and because Android + * gives us the resolver for free. + */ +class LanTransport(private val context: Context) : Transport { + + override val kind: TransportKind = TransportKind.LAN + + private val nsdManager: NsdManager + get() = context.getSystemService(Context.NSD_SERVICE) as NsdManager + + private var serverSocket: ServerSocket? = null + private var registrationListener: NsdManager.RegistrationListener? = null + private var discoveryListener: NsdManager.DiscoveryListener? = null + + override fun isAvailable(): Boolean = true + + // ----------------------------------------------------------------------- + // Hosting + // ----------------------------------------------------------------------- + + override fun host(displayName: String, scope: CoroutineScope): Flow = callbackFlow { + // Port 0 lets the OS pick a free port, which is then advertised. + val server = ServerSocket(0) + serverSocket = server + + val serviceInfo = NsdServiceInfo().apply { + serviceName = "$SERVICE_NAME @ $displayName" + serviceType = SERVICE_TYPE + port = server.localPort + } + val listener = object : NsdManager.RegistrationListener { + override fun onServiceRegistered(info: NsdServiceInfo) = Unit + override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) = Unit + override fun onServiceUnregistered(info: NsdServiceInfo) = Unit + override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) = Unit + } + registrationListener = listener + runCatching { + nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, listener) + } + + val acceptJob = scope.launch(Dispatchers.IO) { + while (isActive && !server.isClosed) { + val socket = runCatching { server.accept() }.getOrNull() ?: break + socket.tcpNoDelay = true + val connection = StreamConnection( + peerId = socket.inetAddress?.hostAddress ?: "unknown", + kind = TransportKind.LAN, + input = socket.getInputStream(), + output = socket.getOutputStream(), + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + trySend(connection) + } + } + + awaitClose { + acceptJob.cancel() + stopAdvertising() + runCatching { server.close() } + } + } + + // ----------------------------------------------------------------------- + // Discovery + // ----------------------------------------------------------------------- + + override fun discover(scope: CoroutineScope): Flow> = callbackFlow { + val found = Collections.synchronizedMap(linkedMapOf()) + + fun publish() { + trySend(synchronized(found) { found.values.toList() }) + } + + // Resolutions are queued one at a time: NsdManager rejects concurrent + // resolve calls with FAILURE_ALREADY_ACTIVE on many devices. + val pending = ArrayDeque() + var resolving = false + + fun resolveNext() { + if (resolving) return + val next = synchronized(pending) { pending.removeFirstOrNull() } ?: return + resolving = true + val resolveListener = object : NsdManager.ResolveListener { + override fun onResolveFailed(info: NsdServiceInfo, errorCode: Int) { + resolving = false + resolveNext() + } + + override fun onServiceResolved(info: NsdServiceInfo) { + val address = info.host?.hostAddress + if (address != null) { + val host = DiscoveredHost( + id = "lan:$address:${info.port}", + name = info.serviceName.substringAfter("@ ").trim() + .ifBlank { info.serviceName }, + kind = TransportKind.LAN, + address = address, + port = info.port, + ) + synchronized(found) { found[host.id] = host } + publish() + } + resolving = false + resolveNext() + } + } + @Suppress("DEPRECATION") + runCatching { nsdManager.resolveService(next, resolveListener) } + .onFailure { + resolving = false + resolveNext() + } + } + + val listener = object : NsdManager.DiscoveryListener { + override fun onDiscoveryStarted(serviceType: String) = Unit + + override fun onServiceFound(info: NsdServiceInfo) { + if (info.serviceType?.contains("plgames") != true) return + synchronized(pending) { pending.addLast(info) } + resolveNext() + } + + override fun onServiceLost(info: NsdServiceInfo) { + synchronized(found) { + val key = found.entries.firstOrNull { it.value.name == info.serviceName }?.key + if (key != null) found.remove(key) + } + publish() + } + + override fun onDiscoveryStopped(serviceType: String) = Unit + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + close() + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) = Unit + } + discoveryListener = listener + + publish() + runCatching { + nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) + }.onFailure { close() } + + awaitClose { stopDiscovery() } + } + + // ----------------------------------------------------------------------- + // Joining + // ----------------------------------------------------------------------- + + override suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection = + withContext(Dispatchers.IO) { + val socket = Socket() + socket.connect(InetSocketAddress(host.address, host.port), CONNECT_TIMEOUT_MS) + socket.tcpNoDelay = true + StreamConnection( + peerId = host.id, + kind = TransportKind.LAN, + input = socket.getInputStream(), + output = socket.getOutputStream(), + scope = scope, + onClosed = { runCatching { socket.close() } }, + ) + } + + // ----------------------------------------------------------------------- + + private fun stopAdvertising() { + registrationListener?.let { runCatching { nsdManager.unregisterService(it) } } + registrationListener = null + } + + private fun stopDiscovery() { + discoveryListener?.let { runCatching { nsdManager.stopServiceDiscovery(it) } } + discoveryListener = null + } + + override fun stop() { + stopAdvertising() + stopDiscovery() + runCatching { serverSocket?.close() } + serverSocket = null + } + + private companion object { + const val CONNECT_TIMEOUT_MS = 8_000 + } +} diff --git a/app/src/main/java/org/prolibertate/games/net/LobbyController.kt b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt new file mode 100644 index 0000000..6728a2f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/LobbyController.kt @@ -0,0 +1,262 @@ +package org.prolibertate.games.net + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * The pre-game lobby. + * + * The host advertises on every available transport at once, so a table can mix + * players arriving over Wi-Fi with players arriving over Bluetooth — from the + * seat list's point of view a peer is a peer. Seats start filled with AI and + * are handed over to people as they join, which is what makes "AI, humans, or + * any combination" fall out for free: whatever is still AI when the host + * starts, stays AI. + */ +class LobbyController( + context: Context, + private val scope: CoroutineScope, +) { + + data class State( + val hosting: Boolean = false, + val joining: Boolean = false, + val connected: Boolean = false, + val gameId: String? = null, + val optionsJson: String = "{}", + val hostName: String = "", + val seats: List = emptyList(), + val discovered: List = emptyList(), + val message: String? = null, + val started: TableConfig? = null, + ) + + private val lan = LanTransport(context) + private val bluetooth = BluetoothTransport(context) + + val transports: List = listOf(lan, bluetooth) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _state.asStateFlow() + + /** Peer id -> live connection, for every seated remote player. */ + private val connections = linkedMapOf() + private var clientLink: Connection? = null + private val jobs = mutableListOf() + + val hostConnections: List get() = connections.values.toList() + val clientConnection: Connection? get() = clientLink + + fun availableTransports(): List = transports.filter { it.isAvailable() } + + // ----------------------------------------------------------------------- + // Hosting + // ----------------------------------------------------------------------- + + fun startHosting( + gameId: String, + optionsJson: String, + hostName: String, + seatCount: Int, + teamOf: (Int) -> Int, + ) { + stop() + val seats = (0 until seatCount).map { seat -> + PlayerSlot( + seat = seat, + name = if (seat == 0) hostName else "Computer $seat", + kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, + team = teamOf(seat), + ) + } + _state.value = State( + hosting = true, + gameId = gameId, + optionsJson = optionsJson, + hostName = hostName, + seats = seats, + message = "Waiting for players…", + ) + + for (transport in availableTransports()) { + jobs += scope.launch { + transport.host(hostName, scope).collect { connection -> + onClientConnected(connection) + } + } + } + } + + private fun onClientConnected(connection: Connection) { + jobs += scope.launch { + connection.incoming.collect { message -> + if (message !is Hello) return@collect + if (message.protocol != PROTOCOL_VERSION) { + connection.send( + Welcome( + hostName = _state.value.hostName, + gameId = _state.value.gameId.orEmpty(), + accepted = false, + reason = "Different app version — update both devices.", + ) + ) + connection.close() + return@collect + } + seatPlayer(connection, message) + } + } + } + + /** Gives an arriving player the first seat still held by the computer. */ + private suspend fun seatPlayer(connection: Connection, hello: Hello) { + val current = _state.value + val seats = current.seats.toMutableList() + val index = seats.indexOfFirst { it.kind == PlayerKind.AI } + if (index < 0) { + connection.send( + Welcome(current.hostName, current.gameId.orEmpty(), accepted = false, reason = "The table is full.") + ) + connection.close() + return + } + + seats[index] = seats[index].copy( + name = hello.displayName, + kind = PlayerKind.HUMAN_REMOTE, + peerId = connection.peerId, + ) + connections[connection.peerId] = connection + _state.value = current.copy( + seats = seats, + message = "${hello.displayName} joined over ${connection.kind.label}.", + ) + + connection.send(Welcome(current.hostName, current.gameId.orEmpty(), accepted = true)) + broadcastLobby() + } + + private suspend fun broadcastLobby() { + val current = _state.value + val update = LobbyUpdate( + gameId = current.gameId.orEmpty(), + seats = current.seats, + optionsJson = current.optionsJson, + hostName = current.hostName, + ) + connections.values.forEach { it.send(update) } + } + + /** Host: lock the table in and tell everyone to build the same match. */ + fun startMatch(seed: Long): TableConfig { + val current = _state.value + val config = TableConfig( + gameId = current.gameId.orEmpty(), + seats = current.seats, + optionsJson = current.optionsJson, + seed = seed, + ) + scope.launch { connections.values.forEach { it.send(StartGame(config)) } } + _state.value = current.copy(started = config) + return config + } + + /** Replaces a seat's occupant — used by the host to swap a person for AI. */ + fun setSeatKind(seat: Int, kind: PlayerKind, name: String? = null) { + val current = _state.value + val seats = current.seats.toMutableList() + if (seat !in seats.indices) return + seats[seat] = seats[seat].copy( + kind = kind, + name = name ?: seats[seat].name, + peerId = if (kind == PlayerKind.AI) null else seats[seat].peerId, + ) + _state.value = current.copy(seats = seats) + scope.launch { broadcastLobby() } + } + + // ----------------------------------------------------------------------- + // Joining + // ----------------------------------------------------------------------- + + fun startDiscovery() { + stop() + _state.value = State(joining = true, message = "Looking for games…") + for (transport in availableTransports()) { + jobs += scope.launch { + transport.discover(scope).collect { hosts -> + val others = _state.value.discovered.filter { it.kind != transport.kind } + _state.value = _state.value.copy(discovered = others + hosts) + } + } + } + } + + fun join(host: DiscoveredHost, displayName: String, peerId: String) { + jobs += scope.launch { + _state.value = _state.value.copy(message = "Connecting to ${host.name}…") + val transport = transports.firstOrNull { it.kind == host.kind } ?: return@launch + val connection = runCatching { transport.join(host, scope) }.getOrElse { error -> + _state.value = _state.value.copy(message = "Couldn't connect: ${error.message}") + return@launch + } + clientLink = connection + + launch { + connection.incoming.collect { message -> + when (message) { + is Welcome -> _state.value = if (message.accepted) { + _state.value.copy( + connected = true, + gameId = message.gameId, + hostName = message.hostName, + message = "Joined ${message.hostName}. Waiting for the host to start…", + ) + } else { + _state.value.copy(message = message.reason ?: "The host declined.") + } + + is LobbyUpdate -> _state.value = _state.value.copy( + seats = message.seats, + gameId = message.gameId, + optionsJson = message.optionsJson, + hostName = message.hostName, + ) + + is StartGame -> _state.value = _state.value.copy(started = message.config) + + is Bye -> _state.value = + _state.value.copy(connected = false, message = "The host left.") + + else -> Unit + } + } + } + + connection.send(Hello(peerId = peerId, displayName = displayName)) + } + } + + fun clearStarted() { + _state.value = _state.value.copy(started = null) + } + + fun stop() { + jobs.forEach { it.cancel() } + jobs.clear() + connections.values.forEach { runCatching { it.close() } } + connections.clear() + runCatching { clientLink?.close() } + clientLink = null + transports.forEach { runCatching { it.stop() } } + _state.value = State() + } +} diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt new file mode 100644 index 0000000..1ed9c2e --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -0,0 +1,264 @@ +package org.prolibertate.games.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.prolibertate.games.game.engine.GameAi +import org.prolibertate.games.game.engine.GameRules +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.TableConfig + +/** + * Drives one match, in either of two roles. + * + * As HOST it owns the authoritative state: it validates every move (local, AI + * or remote), advances the game, and pushes each participant a view redacted + * for their seat. As CLIENT it holds only what the host has sent and turns + * local input into [MoveIntent]s. + * + * A purely offline game against AI is just a host with no connections, so + * there is only one code path to get right. + */ +class MatchController( + private val rules: GameRules, + private val ai: GameAi, + private val config: TableConfig, + private val scope: CoroutineScope, + val role: Role, + /** Seats played by a person on this device. */ + private val localSeats: Set, + /** The local player's primary seat, used to pick the view to render. */ + val primarySeat: Int, + /** + * Advances a state that nobody is waiting on — Euchre's gap between a + * scored hand and the next deal. Returning null means the game is over. + */ + private val advanceIdle: (S) -> S? = { null }, + /** Scaled by the animation-speed setting so the table paces itself. */ + private val aiThinkingMillis: () -> Long = { 700L }, +) { + + enum class Role { HOST, CLIENT } + + private val _state = MutableStateFlow(null) + + /** What the local player should see: redacted on the host, as-sent on a client. */ + val state: StateFlow = _state.asStateFlow() + + private val _legalMoves = MutableStateFlow>(emptyList()) + val legalMoves: StateFlow> = _legalMoves.asStateFlow() + + private val _finished = MutableStateFlow(false) + val finished: StateFlow = _finished.asStateFlow() + + private val _notice = MutableStateFlow(null) + val notice: StateFlow = _notice.asStateFlow() + + /** Host only: the true state. Clients never see this. */ + private var authoritative: S? = null + private val lock = Mutex() + private var sequence = 0 + + private val peers = mutableMapOf() + private var hostLink: Connection? = null + + private val seatKinds: Map = + config.seats.associate { it.seat to it.kind } + private val seatPeers: Map = + config.seats.associate { it.seat to it.peerId } + + // ----------------------------------------------------------------------- + // Start-up + // ----------------------------------------------------------------------- + + /** Host: begin the match and start pushing state to [clients]. */ + fun startAsHost(clients: List) { + check(role == Role.HOST) { "Not hosting" } + clients.forEach { connection -> + peers[connection.peerId] = connection + scope.launch { + connection.incoming.collect { message -> onHostMessage(connection, message) } + } + } + scope.launch { + lock.withLock { + authoritative = rules.initialState(config) + publish() + } + driveIdleSeats() + } + } + + /** Client: render whatever arrives from [link]. */ + fun startAsClient(link: Connection) { + check(role == Role.CLIENT) { "Not a client" } + hostLink = link + scope.launch { + link.incoming.collect { message -> + when (message) { + is StateSync -> { + _state.value = rules.decodeState(message.stateJson) + _legalMoves.value = message.legalMoves.map { rules.decodeMove(it) } + _finished.value = _state.value?.let { rules.isFinished(it) } ?: false + } + + is Rejected -> _notice.value = message.reason + is Bye -> _notice.value = "The host left the game." + else -> Unit + } + } + } + } + + // ----------------------------------------------------------------------- + // Input + // ----------------------------------------------------------------------- + + /** Called by the UI when the local player commits to a move. */ + fun submit(move: M) { + scope.launch { + when (role) { + Role.HOST -> applyIfLegal(primarySeatOnClock(), move) + Role.CLIENT -> hostLink?.send(MoveIntent(rules.encodeMove(move))) + } + } + } + + private fun primarySeatOnClock(): Int { + val current = authoritative?.let { rules.currentSeat(it) } + // With several local seats (pass-and-play or AI fill), the seat on the + // clock is the one submitting, provided it is actually ours. + return if (current != null && current in localSeats) current else primarySeat + } + + private suspend fun onHostMessage(connection: Connection, message: NetMessage) { + if (message !is MoveIntent) return + val seat = seatPeers.entries.firstOrNull { it.value == connection.peerId }?.key + if (seat == null) { + connection.send(Rejected("You are not seated at this table.")) + return + } + val move = runCatching { rules.decodeMove(message.moveJson) }.getOrNull() + if (move == null) { + connection.send(Rejected("Unreadable move.")) + return + } + applyIfLegal(seat, move) + } + + /** + * The single choke point where a move becomes real. Everything — local + * taps, AI picks and anything arriving over the wire — lands here and is + * re-checked against the rules. + */ + private suspend fun applyIfLegal(seat: Int, move: M) { + lock.withLock { + val current = authoritative ?: return + if (rules.currentSeat(current) != seat) { + seatPeers[seat]?.let { peers[it]?.send(Rejected("It is not your turn.")) } + return + } + if (move !in rules.legalMoves(current, seat)) { + seatPeers[seat]?.let { peers[it]?.send(Rejected("That move is not legal.")) } + return + } + authoritative = rules.applyMove(current, seat, move) + publish() + } + driveIdleSeats() + } + + // ----------------------------------------------------------------------- + // Host loop + // ----------------------------------------------------------------------- + + /** + * Plays out every seat the humans are not responsible for, then advances + * through any state nobody is waiting on, until a person is on the clock. + */ + private suspend fun driveIdleSeats() { + while (true) { + val current = lock.withLock { authoritative } ?: return + if (rules.isFinished(current)) { + _finished.value = true + return + } + + val seat = rules.currentSeat(current) + if (seat == null) { + // Nobody on the clock: a hand has been scored, or the game ended. + delay(aiThinkingMillis() * 2) + val advanced = advanceIdle(current) + if (advanced == null) { + _finished.value = true + return + } + lock.withLock { + authoritative = advanced + publish() + } + continue + } + + if (seatKinds[seat] != PlayerKind.AI) return // a person's turn + + delay(aiThinkingMillis()) + val chosen = lock.withLock { + val live = authoritative ?: return + if (rules.currentSeat(live) != seat) return@withLock null + val legal = rules.legalMoves(live, seat) + if (legal.isEmpty()) return@withLock null + val move = ai.chooseMove(live, seat, legal) + authoritative = rules.applyMove(live, seat, move) + publish() + move + } + if (chosen == null) return + } + } + + /** Pushes the local view and every remote seat's view. Call under [lock]. */ + private suspend fun publish() { + val current = authoritative ?: return + sequence++ + + _state.value = rules.viewFor(current, primarySeat) + _legalMoves.value = if (rules.currentSeat(current) in localSeats) { + rules.legalMoves(current, rules.currentSeat(current)!!) + } else { + emptyList() + } + _finished.value = rules.isFinished(current) + + for (slot in config.seats) { + val peerId = slot.peerId ?: continue + val connection = peers[peerId] ?: continue + val seatView = rules.viewFor(current, slot.seat) + val moves = if (rules.currentSeat(current) == slot.seat) { + rules.legalMoves(current, slot.seat).map { rules.encodeMove(it) } + } else { + emptyList() + } + connection.send( + StateSync( + seq = sequence, + yourSeat = slot.seat, + stateJson = rules.encodeState(seatView), + legalMoves = moves, + ) + ) + } + } + + fun close() { + peers.values.forEach { runCatching { it.close() } } + peers.clear() + runCatching { hostLink?.close() } + hostLink = null + } +} diff --git a/app/src/main/java/org/prolibertate/games/net/Protocol.kt b/app/src/main/java/org/prolibertate/games/net/Protocol.kt new file mode 100644 index 0000000..5fef50f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/Protocol.kt @@ -0,0 +1,92 @@ +package org.prolibertate.games.net + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig + +/** + * The wire protocol, shared by the LAN and Bluetooth transports. + * + * The host is authoritative: clients send [MoveIntent] and render whatever + * [StateSync] tells them. Nothing is trusted from a client beyond "I would like + * to make this move", which the host re-validates against the rules before it + * takes effect. Each client is sent a state redacted for its own seat, so an + * opponent's hand is never transmitted in the first place. + */ +const val PROTOCOL_VERSION = 1 + +/** Service type advertised over mDNS, and the RFCOMM service name. */ +const val SERVICE_TYPE = "_plgames._tcp" +const val SERVICE_NAME = "ProLibertateGames" + +/** Fixed RFCOMM UUID. Both ends must agree; this is ours. */ +const val RFCOMM_UUID = "8f1d5a20-3a4e-4b26-9d31-6c0d2f7a91b4" + +val protocolJson: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + classDiscriminator = "t" +} + +@Serializable +sealed interface NetMessage + +/** First message a joining client sends. */ +@Serializable +@SerialName("hello") +data class Hello( + val peerId: String, + val displayName: String, + val protocol: Int = PROTOCOL_VERSION, +) : NetMessage + +/** Host's reply, or a refusal if the lobby is full or the versions differ. */ +@Serializable +@SerialName("welcome") +data class Welcome( + val hostName: String, + val gameId: String, + val accepted: Boolean, + val reason: String? = null, +) : NetMessage + +/** Broadcast whenever the seat list or options change. */ +@Serializable +@SerialName("lobby") +data class LobbyUpdate( + val gameId: String, + val seats: List, + val optionsJson: String, + val hostName: String, +) : NetMessage + +/** Host starts the match; everyone builds the same initial state from this. */ +@Serializable +@SerialName("start") +data class StartGame(val config: TableConfig) : NetMessage + +/** A client's redacted view of the authoritative state. */ +@Serializable +@SerialName("state") +data class StateSync( + val seq: Int, + val yourSeat: Int, + val stateJson: String, + /** Encoded moves this seat may currently make. Empty when it is not its turn. */ + val legalMoves: List, +) : NetMessage + +/** A client asking to play a move. The host validates it. */ +@Serializable +@SerialName("intent") +data class MoveIntent(val moveJson: String) : NetMessage + +@Serializable +@SerialName("rejected") +data class Rejected(val reason: String) : NetMessage + +@Serializable +@SerialName("bye") +data object Bye : NetMessage diff --git a/app/src/main/java/org/prolibertate/games/net/Transport.kt b/app/src/main/java/org/prolibertate/games/net/Transport.kt new file mode 100644 index 0000000..8bf2523 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/net/Transport.kt @@ -0,0 +1,137 @@ +package org.prolibertate.games.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.Closeable +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.atomic.AtomicBoolean + +enum class TransportKind(val label: String) { + LAN("Wi-Fi"), + BLUETOOTH("Bluetooth"), +} + +/** + * A host someone could join, found by one of the transports. [address] is + * transport-specific: an IP for LAN, a MAC for Bluetooth. + */ +data class DiscoveredHost( + val id: String, + val name: String, + val kind: TransportKind, + val address: String, + val port: Int = 0, +) + +/** + * One peer-to-peer link carrying [NetMessage]s. + * + * Both transports end up with a plain pair of streams, so the framing and + * serialisation live here once rather than twice. + */ +interface Connection : Closeable { + val peerId: String + val kind: TransportKind + val incoming: SharedFlow + suspend fun send(message: NetMessage) + val isOpen: Boolean +} + +/** + * Newline-delimited JSON over a stream pair. + * + * Line framing keeps this debuggable — you can watch a session with netcat — + * and JSON payloads never contain a raw newline, so the framing is safe. + */ +class StreamConnection( + override val peerId: String, + override val kind: TransportKind, + private val input: InputStream, + private val output: OutputStream, + private val scope: CoroutineScope, + private val onClosed: (StreamConnection) -> Unit = {}, +) : Connection { + + private val reader: BufferedReader = input.bufferedReader() + private val writer: BufferedWriter = output.bufferedWriter() + private val closed = AtomicBoolean(false) + + private val _incoming = MutableSharedFlow(replay = 0, extraBufferCapacity = 64) + override val incoming: SharedFlow = _incoming + + override val isOpen: Boolean get() = !closed.get() + + init { + scope.launch(Dispatchers.IO) { + try { + while (isActive && !closed.get()) { + val line = reader.readLine() ?: break + if (line.isBlank()) continue + val message = runCatching { + protocolJson.decodeFromString(line) + }.getOrNull() ?: continue // ignore anything we can't parse + _incoming.emit(message) + } + } catch (_: Exception) { + // A dropped link is normal: someone walked out of range or + // closed the app. Fall through to close(). + } finally { + close() + } + } + } + + override suspend fun send(message: NetMessage) { + if (closed.get()) return + withContext(Dispatchers.IO) { + try { + writer.write(protocolJson.encodeToString(message)) + writer.newLine() + writer.flush() + } catch (_: Exception) { + close() + } + } + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + runCatching { reader.close() } + runCatching { writer.close() } + runCatching { input.close() } + runCatching { output.close() } + onClosed(this) + } +} + +/** + * Discovery and connection setup. Everything after the handshake is the same + * for both transports, which is what lets a lobby accept players over Wi-Fi and + * Bluetooth at the same time. + */ +interface Transport { + + val kind: TransportKind + + /** Whether the radio exists and is usable right now. */ + fun isAvailable(): Boolean + + /** Starts advertising and emits each client that connects. */ + fun host(displayName: String, scope: CoroutineScope): Flow + + /** Emits the current list of visible hosts as it changes. */ + fun discover(scope: CoroutineScope): Flow> + + suspend fun join(host: DiscoveredHost, scope: CoroutineScope): Connection + + fun stop() +} diff --git a/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt new file mode 100644 index 0000000..c27af5d --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/settings/SettingsRepository.kt @@ -0,0 +1,74 @@ +package org.prolibertate.games.settings + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +/** + * User settings. + * + * [animationSpeed] is a multiplier rather than a duration so every animated + * thing in the app can scale off one number: 0.5x is slow and deliberate, 2x + * is for people who already know the rules. + */ +data class Settings( + val soundEnabled: Boolean = true, + val animationSpeed: Float = 1.0f, + val checkForUpdatesOnLaunch: Boolean = true, + val playerName: String = "Player", +) { + /** Scales a nominal duration by the chosen speed. */ + fun scaled(millis: Long): Long = + (millis / animationSpeed.coerceIn(MIN_SPEED, MAX_SPEED)).toLong() + + companion object { + const val MIN_SPEED = 0.5f + const val MAX_SPEED = 2.0f + } +} + +class SettingsRepository(private val context: Context) { + + private object Keys { + val SOUND = booleanPreferencesKey("sound_enabled") + val SPEED = floatPreferencesKey("animation_speed") + val UPDATE_ON_LAUNCH = booleanPreferencesKey("check_updates_on_launch") + val NAME = stringPreferencesKey("player_name") + } + + val settings: Flow = context.dataStore.data.map { prefs -> + Settings( + soundEnabled = prefs[Keys.SOUND] ?: true, + animationSpeed = prefs[Keys.SPEED] ?: 1.0f, + checkForUpdatesOnLaunch = prefs[Keys.UPDATE_ON_LAUNCH] ?: true, + playerName = prefs[Keys.NAME] ?: "Player", + ) + } + + suspend fun setSoundEnabled(enabled: Boolean) { + context.dataStore.edit { it[Keys.SOUND] = enabled } + } + + suspend fun setAnimationSpeed(speed: Float) { + context.dataStore.edit { + it[Keys.SPEED] = speed.coerceIn(Settings.MIN_SPEED, Settings.MAX_SPEED) + } + } + + suspend fun setCheckForUpdatesOnLaunch(enabled: Boolean) { + context.dataStore.edit { it[Keys.UPDATE_ON_LAUNCH] = enabled } + } + + suspend fun setPlayerName(name: String) { + context.dataStore.edit { it[Keys.NAME] = name.trim().ifBlank { "Player" } } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt new file mode 100644 index 0000000..4abbe14 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -0,0 +1,275 @@ +package org.prolibertate.games.ui + +import androidx.activity.compose.BackHandler +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.unit.dp +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.GameCategory +import org.prolibertate.games.game.GameDescriptor +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.Updater + +/** Everything the screens need, assembled once by the activity. */ +class AppEnv( + val settingsRepository: SettingsRepository, + val updater: Updater, + val lobby: LobbyController, + val peerId: String, + val requestBluetoothPermissions: () -> Unit, +) + +/** + * Where the app currently is. + * + * A hand-rolled stack rather than the navigation library: there are six + * destinations, two of which carry a live [TableConfig], and a typed sealed + * class is both smaller and harder to get wrong than argument encoding. + */ +sealed interface Route { + data object Menu : Route + data object Settings : Route + data class Setup(val gameId: String) : Route + data class Lobby(val gameId: String, val optionsJson: String, val hosting: Boolean) : Route + data class Play( + val gameId: String, + val config: TableConfig, + val hosting: Boolean, + val localSeat: Int, + ) : Route +} + +@Composable +fun AppRoot(env: AppEnv) { + val settings by env.settingsRepository.settings.collectAsState(initial = Settings()) + var stack by remember { mutableStateOf(listOf(Route.Menu)) } + + fun push(route: Route) { + stack = stack + route + } + + fun pop() { + if (stack.size > 1) { + // Leaving a lobby or a table tears the networking down with it. + when (stack.last()) { + is Route.Lobby, is Route.Play -> env.lobby.stop() + else -> Unit + } + stack = stack.dropLast(1) + } + } + + BackHandler(enabled = stack.size > 1) { pop() } + + when (val route = stack.last()) { + is Route.Menu -> MainMenuScreen( + onPickGame = { push(Route.Setup(it.id)) }, + onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, + onSettings = { push(Route.Settings) }, + ) + + is Route.Settings -> SettingsScreen( + env = env, + settings = settings, + onBack = { pop() }, + ) + + is Route.Setup -> GameSetupScreen( + descriptor = GameCatalog.byId(route.gameId)!!, + playerName = settings.playerName, + onBack = { pop() }, + onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) }, + onHostOnline = { optionsJson -> + push(Route.Lobby(route.gameId, optionsJson, hosting = true)) + }, + ) + + is Route.Lobby -> LobbyScreen( + env = env, + route = route, + playerName = settings.playerName, + onBack = { pop() }, + onStart = { config, hosting, seat -> + push(Route.Play(config.gameId, config, hosting, seat)) + }, + ) + + is Route.Play -> PlayScreen( + env = env, + route = route, + settings = settings, + onExit = { pop() }, + ) + } +} + +// --------------------------------------------------------------------------- +// Main menu +// --------------------------------------------------------------------------- + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainMenuScreen( + onPickGame: (GameDescriptor) -> Unit, + onJoinGame: () -> Unit, + onSettings: () -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Pro Libertate Games") }, + actions = { TextButton(onClick = onSettings) { Text("Settings") } }, + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton(onClick = onJoinGame) { Text("Join a game nearby") } + } + + // The column count follows the available width, so a phone in + // portrait gets two and a tablet in landscape gets four or more. + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 172.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + GameCategory.entries.forEach { category -> + item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { + Text( + text = category.label, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + } + items(GameCatalog.byCategory(category)) { game -> + GameTile(game = game, onClick = { onPickGame(game) }) + } + } + } + } + } +} + +@Composable +private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { + Card( + onClick = onClick, + enabled = game.available, + colors = CardDefaults.cardColors( + containerColor = if (game.available) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + } + ), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = game.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = game.blurb, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp), + ) + Text( + text = if (game.available) { + if (game.minPlayers == game.maxPlayers) { + "${game.minPlayers} players" + } else { + "${game.minPlayers}–${game.maxPlayers} players" + } + } else { + "Coming soon" + }, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(top = 8.dp), + ) + } + } +} + +// --------------------------------------------------------------------------- +// Shared chrome +// --------------------------------------------------------------------------- + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScreenScaffold( + title: String, + onBack: () -> Unit, + actions: @Composable () -> Unit = {}, + content: @Composable (Modifier) -> Unit, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { TextButton(onClick = onBack) { Text("Back") } }, + actions = { actions() }, + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.TopStart, + ) { + content(Modifier.fillMaxSize().padding(12.dp)) + } + } +} + +@Composable +fun PrimaryAction(text: String, enabled: Boolean = true, onClick: () -> Unit) { + Button(onClick = onClick, enabled = enabled, modifier = Modifier.fillMaxWidth()) { + Text(text) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt new file mode 100644 index 0000000..42fd5e3 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -0,0 +1,240 @@ +package org.prolibertate.games.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.unit.dp +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.GameDescriptor +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.game.euchre.EuchreOptions +import org.prolibertate.games.game.sequence.SequenceOptions + +private val setupJson = Json { encodeDefaults = true } + +/** + * Choose who is playing and which house rules apply. + * + * Rule variations are per game and live here rather than in a global settings + * screen, because they change what the engine does rather than how it looks. + */ +@Composable +fun GameSetupScreen( + descriptor: GameDescriptor, + playerName: String, + onBack: () -> Unit, + onPlayOffline: (TableConfig) -> Unit, + onHostOnline: (String) -> Unit, +) { + var euchre by remember { mutableStateOf(EuchreOptions()) } + var sequence by remember { mutableStateOf(SequenceOptions()) } + + val optionsJson = when (descriptor.id) { + GameCatalog.EUCHRE -> setupJson.encodeToString(euchre) + GameCatalog.SEQUENCE -> setupJson.encodeToString(sequence) + else -> "{}" + } + val seatCount = when (descriptor.id) { + GameCatalog.EUCHRE -> 4 + GameCatalog.SEQUENCE -> sequence.playerCount + else -> descriptor.minPlayers + } + + ScreenScaffold(title = descriptor.title, onBack = onBack) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(descriptor.blurb, style = MaterialTheme.typography.bodyMedium) + + Divider() + Text("House rules", fontWeight = FontWeight.Bold) + + when (descriptor.id) { + GameCatalog.EUCHRE -> EuchreOptionsEditor(euchre) { euchre = it } + GameCatalog.SEQUENCE -> SequenceOptionsEditor(sequence) { sequence = it } + else -> Text("No options yet for this game.") + } + + Divider() + Text("Opponents", fontWeight = FontWeight.Bold) + Text( + text = "Play now and every other seat is taken by the computer. Host a game " + + "instead and people can claim those seats as they join — whatever is " + + "still empty when you start stays computer-controlled.", + style = MaterialTheme.typography.bodySmall, + ) + + PrimaryAction(text = "Play against the computer") { + onPlayOffline( + offlineConfig(descriptor.id, optionsJson, seatCount, playerName) + ) + } + PrimaryAction(text = "Host a game for others to join") { + onHostOnline(optionsJson) + } + } + } +} + +/** Seat 0 is the local player; the rest start as AI. */ +private fun offlineConfig( + gameId: String, + optionsJson: String, + seatCount: Int, + playerName: String, +): TableConfig = TableConfig( + gameId = gameId, + seats = (0 until seatCount).map { seat -> + PlayerSlot( + seat = seat, + name = if (seat == 0) playerName else "Computer $seat", + kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI, + team = teamForSeat(gameId, seat), + ) + }, + optionsJson = optionsJson, + // Seeded from the clock so consecutive games are not identical. + seed = System.currentTimeMillis(), +) + +fun teamForSeat(gameId: String, seat: Int): Int = when (gameId) { + // Partners sit opposite each other. + GameCatalog.EUCHRE -> seat % 2 + else -> seat % 2 +} + +@Composable +private fun EuchreOptionsEditor(options: EuchreOptions, onChange: (EuchreOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Deck", + values = listOf(24, 32), + selected = options.deckSize, + display = { "$it cards" }, + onSelect = { onChange(options.copy(deckSize = it)) }, + ) + ChipRow( + label = "Game to", + values = listOf(10, 11, 15), + selected = options.pointsToWin, + display = { "$it points" }, + onSelect = { onChange(options.copy(pointsToWin = it)) }, + ) + ToggleRow( + title = "Stick the dealer", + subtitle = "If everyone passes twice, the dealer must name trump", + checked = options.stickTheDealer, + onChange = { onChange(options.copy(stickTheDealer = it)) }, + ) + ToggleRow( + title = "Allow going alone", + subtitle = "A maker may sit their partner out for a bigger score", + checked = options.allowGoingAlone, + onChange = { onChange(options.copy(allowGoingAlone = it)) }, + ) + } +} + +@Composable +private fun SequenceOptionsEditor(options: SequenceOptions, onChange: (SequenceOptions) -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ChipRow( + label = "Teams", + values = listOf(2, 3), + selected = options.teamCount, + display = { "$it teams" }, + onSelect = { teams -> + // Three teams race to a single sequence; two teams need two. + onChange(options.copy(teamCount = teams, sequencesToWin = if (teams == 3) 1 else 2)) + }, + ) + ChipRow( + label = "Players per team", + values = listOf(1, 2, 3, 4), + selected = options.playersPerTeam, + display = { "$it" }, + onSelect = { onChange(options.copy(playersPerTeam = it)) }, + ) + ChipRow( + label = "Sequences to win", + values = listOf(1, 2, 3), + selected = options.sequencesToWin, + display = { "$it" }, + onSelect = { onChange(options.copy(sequencesToWin = it)) }, + ) + ToggleRow( + title = "Dead card exchange", + subtitle = "Swap a card whose squares are both already taken", + checked = options.deadCardExchange, + onChange = { onChange(options.copy(deadCardExchange = it)) }, + ) + Text( + text = "${options.playerCount} players in total.", + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun ChipRow( + label: String, + values: List, + selected: T, + display: (T) -> String, + onSelect: (T) -> Unit, +) { + Column { + Text(label, style = MaterialTheme.typography.labelLarge) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + values.forEach { value -> + FilterChip( + selected = value == selected, + onClick = { onSelect(value) }, + label = { Text(display(value)) }, + ) + } + } + } +} + +@Composable +private fun ToggleRow( + title: String, + subtitle: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + Text(title) + Text(subtitle, style = MaterialTheme.typography.bodySmall) + } + Switch(checked = checked, onCheckedChange = onChange) + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt new file mode 100644 index 0000000..1d0eb6c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/LobbyScreen.kt @@ -0,0 +1,187 @@ +package org.prolibertate.games.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.TableConfig +import org.prolibertate.games.net.DiscoveredHost +import org.prolibertate.games.net.LobbyController + +/** + * Hosting or joining a table. + * + * A host advertises on Wi-Fi and Bluetooth simultaneously, and the seat list + * does not care which one a given player arrived on. + */ +@Composable +fun LobbyScreen( + env: AppEnv, + route: Route.Lobby, + playerName: String, + onBack: () -> Unit, + onStart: (TableConfig, Boolean, Int) -> Unit, +) { + val state by env.lobby.state.collectAsState() + + LaunchedEffect(route) { + env.requestBluetoothPermissions() + if (route.hosting) { + val descriptor = GameCatalog.byId(route.gameId) + val seatCount = descriptor?.let { + if (it.id == GameCatalog.EUCHRE) 4 else it.minPlayers + } ?: 4 + env.lobby.startHosting( + gameId = route.gameId, + optionsJson = route.optionsJson, + hostName = playerName, + seatCount = seatCount, + teamOf = { seat -> teamForSeat(route.gameId, seat) }, + ) + } else { + env.lobby.startDiscovery() + } + } + + // Both roles leave for the table the moment the host commits. + LaunchedEffect(state.started) { + val config = state.started ?: return@LaunchedEffect + val seat = if (route.hosting) { + 0 + } else { + config.seats.firstOrNull { it.peerId == env.peerId }?.seat + ?: config.seats.firstOrNull { it.kind == PlayerKind.HUMAN_REMOTE }?.seat + ?: 0 + } + env.lobby.clearStarted() + onStart(config, route.hosting, seat) + } + + val title = if (route.hosting) "Hosting" else "Join a game" + + ScreenScaffold(title = title, onBack = onBack) { modifier -> + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) { + val available = env.lobby.availableTransports() + Text( + text = if (available.isEmpty()) { + "No Wi-Fi or Bluetooth available. Turn one on to play with others." + } else { + (if (route.hosting) "Visible over " else "Searching over ") + + available.joinToString(" and ") { it.kind.label } + "." + }, + style = MaterialTheme.typography.bodySmall, + ) + + state.message?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + + Divider() + + if (route.hosting) { + HostingBody(env.lobby, state) + } else { + JoiningBody(env, state.discovered, playerName) + } + } + } +} + +@Composable +private fun HostingBody(lobby: LobbyController, state: LobbyController.State) { + Text("Seats", fontWeight = FontWeight.Bold) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth(), + ) { + items(state.seats) { slot -> + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text("Seat ${slot.seat} — ${slot.name}") + Text( + text = when (slot.kind) { + PlayerKind.HUMAN_LOCAL -> "You" + PlayerKind.HUMAN_REMOTE -> "Joined" + PlayerKind.AI -> "Computer" + } + " · Team ${slot.team}", + style = MaterialTheme.typography.bodySmall, + ) + } + if (slot.kind == PlayerKind.HUMAN_REMOTE) { + TextButton(onClick = { + lobby.setSeatKind(slot.seat, PlayerKind.AI, "Computer ${slot.seat}") + }) { Text("Drop") } + } + } + } + } + } + + PrimaryAction( + text = "Start game", + enabled = state.seats.isNotEmpty(), + ) { + lobby.startMatch(seed = System.currentTimeMillis()) + } + Text( + text = "Any seat still showing Computer will be played by the AI.", + style = MaterialTheme.typography.bodySmall, + ) +} + +@Composable +private fun JoiningBody( + env: AppEnv, + discovered: List, + playerName: String, +) { + if (discovered.isEmpty()) { + Text("No games found yet. Make sure the host has started theirs.") + Text( + text = "Bluetooth only shows devices already paired in Android's " + + "Bluetooth settings.", + style = MaterialTheme.typography.bodySmall, + ) + return + } + + LazyColumn(verticalArrangement = Arrangement.spacedBy(6.dp)) { + items(discovered) { host -> + Card( + modifier = Modifier + .fillMaxWidth() + .clickable { env.lobby.join(host, playerName, env.peerId) } + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(host.name, fontWeight = FontWeight.Bold) + Text( + text = "over ${host.kind.label}", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt new file mode 100644 index 0000000..aae16d7 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -0,0 +1,104 @@ +package org.prolibertate.games.ui + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import org.prolibertate.games.game.GameCatalog +import org.prolibertate.games.game.euchre.EuchreAi +import org.prolibertate.games.game.euchre.EuchreMove +import org.prolibertate.games.game.euchre.EuchrePhase +import org.prolibertate.games.game.euchre.EuchreRules +import org.prolibertate.games.game.euchre.EuchreState +import org.prolibertate.games.game.sequence.SequenceAi +import org.prolibertate.games.game.sequence.SequenceMove +import org.prolibertate.games.game.sequence.SequenceRules +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.SequenceScreen + +/** + * Builds the match for the chosen game and hands it to that game's screen. + * + * Offline play and hosting are the same thing here — an offline game is simply + * a host with no connections — so there is one path through this code whether + * or not anyone else is at the table. + */ +@Composable +fun PlayScreen( + env: AppEnv, + route: Route.Play, + settings: Settings, + onExit: () -> Unit, +) { + val scope = rememberCoroutineScope() + val role = if (route.hosting) MatchController.Role.HOST else MatchController.Role.CLIENT + + when (route.gameId) { + GameCatalog.EUCHRE -> { + val controller = remember(route) { + MatchController( + rules = EuchreRules, + ai = EuchreAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + // A scored hand pauses on screen, then the next one is dealt. + advanceIdle = { state -> + if (state.phase == EuchrePhase.HAND_OVER) { + EuchreRules.nextHand(state) + } else { + null + } + }, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + EuchreScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + GameCatalog.SEQUENCE -> { + val controller = remember(route) { + MatchController( + rules = SequenceRules, + ai = SequenceAi(), + config = route.config, + scope = scope, + role = role, + localSeats = setOf(route.localSeat), + primarySeat = route.localSeat, + aiThinkingMillis = { settings.scaled(700L) }, + ) + } + StartMatch(env, controller, route) + SequenceScreen(controller = controller, localSeat = route.localSeat, onExit = onExit) + } + + else -> Text("That game isn't playable yet.") + } +} + +@Composable +private fun StartMatch( + env: AppEnv, + controller: MatchController, + route: Route.Play, +) { + LaunchedEffect(controller) { + if (route.hosting) { + controller.startAsHost(env.lobby.hostConnections) + } else { + env.lobby.clientConnection?.let { controller.startAsClient(it) } + } + } + DisposableEffect(controller) { + onDispose { controller.close() } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt new file mode 100644 index 0000000..cd7a41c --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/SettingsScreen.kt @@ -0,0 +1,180 @@ +package org.prolibertate.games.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Divider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.unit.dp +import kotlinx.coroutines.launch +import org.prolibertate.games.settings.Settings +import org.prolibertate.games.update.Updater + +@Composable +fun SettingsScreen( + env: AppEnv, + settings: Settings, + onBack: () -> Unit, +) { + val scope = rememberCoroutineScope() + var name by remember(settings.playerName) { mutableStateOf(settings.playerName) } + var updateStatus by remember { mutableStateOf(null) } + var pendingRelease by remember { mutableStateOf(null) } + + ScreenScaffold(title = "Settings", onBack = onBack) { modifier -> + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + SettingRow( + title = "Sound", + subtitle = "Card and chip effects", + ) { + Switch( + checked = settings.soundEnabled, + onCheckedChange = { enabled -> + scope.launch { env.settingsRepository.setSoundEnabled(enabled) } + }, + ) + } + + Divider() + + Column { + Text("Animation speed", fontWeight = FontWeight.Bold) + Text( + text = "${"%.1f".format(settings.animationSpeed)}× — " + + "how quickly cards move and the computer takes its turn", + style = MaterialTheme.typography.bodySmall, + ) + Slider( + value = settings.animationSpeed, + onValueChange = { speed -> + scope.launch { env.settingsRepository.setAnimationSpeed(speed) } + }, + valueRange = Settings.MIN_SPEED..Settings.MAX_SPEED, + // Half-step increments across 0.5x-2.0x. + steps = 5, + ) + } + + Divider() + + Column { + Text("Your name", fontWeight = FontWeight.Bold) + Text( + "Shown to other players in a lobby", + style = MaterialTheme.typography.bodySmall, + ) + OutlinedTextField( + value = name, + onValueChange = { entered -> + name = entered + scope.launch { env.settingsRepository.setPlayerName(entered) } + }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + + Divider() + + Text("Updates", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium) + Text( + text = "Installed: v${env.updater.installedVersionName()}. Updates come from " + + "the latest release published on the project's main branch.", + style = MaterialTheme.typography.bodySmall, + ) + + SettingRow( + title = "Check on launch", + subtitle = "Look for a newer release when the app starts", + ) { + Switch( + checked = settings.checkForUpdatesOnLaunch, + onCheckedChange = { enabled -> + scope.launch { env.settingsRepository.setCheckForUpdatesOnLaunch(enabled) } + }, + ) + } + + PrimaryAction(text = "Check for updates now") { + scope.launch { + updateStatus = "Checking…" + when (val result = env.updater.check()) { + is Updater.Result.UpToDate -> { + updateStatus = "You're on the latest version." + pendingRelease = null + } + + is Updater.Result.Available -> { + pendingRelease = result.release + updateStatus = "Version ${result.release.tag} is available." + } + + is Updater.Result.Failed -> { + updateStatus = result.reason + pendingRelease = null + } + } + } + } + + pendingRelease?.let { release -> + PrimaryAction(text = "Download and install ${release.tag}") { + scope.launch { + if (env.updater.needsInstallPermission()) { + updateStatus = + "Allow installs from this app, then tap install again." + env.updater.requestInstallPermission() + } else { + updateStatus = "Downloading…" + val error = env.updater.downloadAndInstall(release) + updateStatus = error ?: "Handing over to the installer…" + } + } + } + } + + updateStatus?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + } + } +} + +@Composable +private fun SettingRow( + title: String, + subtitle: String, + control: @Composable () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(title, fontWeight = FontWeight.Bold) + Text(subtitle, style = MaterialTheme.typography.bodySmall) + } + control() + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/common/Components.kt b/app/src/main/java/org/prolibertate/games/ui/common/Components.kt new file mode 100644 index 0000000..3808e66 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/common/Components.kt @@ -0,0 +1,106 @@ +package org.prolibertate.games.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.ui.theme.CardBlack +import org.prolibertate.games.ui.theme.CardRed + +/** + * A playing card. + * + * Sized by width with a fixed 5:7 aspect ratio so the same composable works + * from a phone in portrait up to a tablet in landscape — callers pass a width + * derived from the space they actually have rather than a hard-coded dp. + */ +@Composable +fun PlayingCardView( + card: Card, + modifier: Modifier = Modifier, + width: Dp = 64.dp, + selected: Boolean = false, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, +) { + val ink = if (card.suit.isRed) CardRed else CardBlack + Box( + modifier = modifier + .width(width) + .aspectRatio(CARD_ASPECT) + .clip(RoundedCornerShape(width * 0.12f)) + .background(Color.White) + .border( + width = if (selected) 3.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else Color(0x33000000), + shape = RoundedCornerShape(width * 0.12f), + ) + .alpha(if (enabled) 1f else 0.45f) + .then( + if (onClick != null && enabled) Modifier.clickable { onClick() } else Modifier + ), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = card.rank.short, + color = ink, + fontWeight = FontWeight.Bold, + fontSize = (width.value * 0.34f).sp, + ) + Text( + text = card.suit.symbol, + color = ink, + fontSize = (width.value * 0.34f).sp, + ) + } + } +} + +/** The back of a card, for hands you are not allowed to see. */ +@Composable +fun CardBackView( + modifier: Modifier = Modifier, + width: Dp = 64.dp, +) { + Box( + modifier = modifier + .width(width) + .aspectRatio(CARD_ASPECT) + .clip(RoundedCornerShape(width * 0.12f)) + .background(MaterialTheme.colorScheme.primary) + .border(1.dp, Color(0x33000000), RoundedCornerShape(width * 0.12f)), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(width * 0.12f) + .border(1.dp, Color(0x55FFFFFF), RoundedCornerShape(width * 0.06f)), + ) + } +} + +const val CARD_ASPECT = 5f / 7f diff --git a/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt new file mode 100644 index 0000000..489b05b --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/EuchreScreen.kt @@ -0,0 +1,282 @@ +package org.prolibertate.games.ui.game + +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.prolibertate.games.game.euchre.CallTrump +import org.prolibertate.games.game.euchre.Discard +import org.prolibertate.games.game.euchre.EuchreMove +import org.prolibertate.games.game.euchre.EuchrePhase +import org.prolibertate.games.game.euchre.EuchreState +import org.prolibertate.games.game.euchre.OrderUp +import org.prolibertate.games.game.euchre.Pass +import org.prolibertate.games.game.euchre.PlayCard +import org.prolibertate.games.game.euchre.teamOf +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.PrimaryAction +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.common.CardBackView +import org.prolibertate.games.ui.common.PlayingCardView +import org.prolibertate.games.ui.theme.FeltGreenDark + +/** + * The Euchre table. + * + * Layout is driven by the space available rather than by orientation checks: + * card width is a fraction of the narrower dimension, so the same code lays out + * sensibly on a phone held either way and on a tablet. + */ +@Composable +fun EuchreScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + val notice by controller.notice.collectAsState() + + val current = state + if (current == null) { + ScreenScaffold(title = "Euchre", onBack = onExit) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + } + return + } + + ScreenScaffold(title = "Euchre", onBack = onExit) { modifier -> + BoxWithConstraints(modifier = modifier) { + val compact = maxWidth < 600.dp + val cardWidth = (minOf(maxWidth, maxHeight) * if (compact) 0.17f else 0.12f) + .coerceIn(48.dp, 104.dp) + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ScoreBar(current, localSeat) + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .background(FeltGreenDark), + contentAlignment = Alignment.Center, + ) { + TrickArea(current, cardWidth) + } + + notice?.let { + Text(it, style = MaterialTheme.typography.bodySmall) + } + + when (current.phase) { + EuchrePhase.BID_ROUND_1, + EuchrePhase.BID_ROUND_2, + -> BiddingControls(current, legal) { controller.submit(it) } + + EuchrePhase.HAND_OVER -> Text( + text = current.log.lastOrNull().orEmpty(), + style = MaterialTheme.typography.titleSmall, + ) + + EuchrePhase.GAME_OVER -> { + Text("Game over.", fontWeight = FontWeight.Bold) + PrimaryAction(text = "Back to the menu") { onExit() } + } + + else -> Unit + } + + HandRow( + state = current, + seat = localSeat, + legal = legal, + cardWidth = cardWidth, + onPlay = { controller.submit(it) }, + ) + } + } + } +} + +@Composable +private fun ScoreBar(state: EuchreState, localSeat: Int) { + val myTeam = teamOf(localSeat) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text("Us ${state.scores[myTeam]} — Them ${state.scores[1 - myTeam]}", + fontWeight = FontWeight.Bold) + val tricks = state.tricksWon.filterIndexed { seat, _ -> teamOf(seat) == myTeam }.sum() + Text("Tricks this hand: $tricks", style = MaterialTheme.typography.bodySmall) + } + Column(horizontalAlignment = Alignment.End) { + Text( + text = state.trump?.let { "Trump ${it.symbol}" } ?: "Bidding", + fontWeight = FontWeight.Bold, + ) + state.aloneSeat?.let { + Text("Seat $it is alone", style = MaterialTheme.typography.bodySmall) + } + } + } +} + +@Composable +private fun TrickArea(state: EuchreState, cardWidth: androidx.compose.ui.unit.Dp) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + state.upCard?.let { up -> + Text("Turned up", color = MaterialTheme.colorScheme.onPrimary) + PlayingCardView(card = up, width = cardWidth) + } + if (state.trick.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + state.trick.forEach { played -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Seat ${played.seat}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onPrimary, + ) + PlayingCardView(card = played.card, width = cardWidth) + } + } + } + } else if (state.upCard == null && state.phase == EuchrePhase.PLAYING) { + Text("Lead a card", color = MaterialTheme.colorScheme.onPrimary) + } + + // Opponents' hands are only ever card counts on this device. + Row( + modifier = Modifier.padding(top = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + state.handCounts.forEachIndexed { seat, count -> + if (seat != state.turn) return@forEachIndexed + repeat(count.coerceAtMost(5)) { + CardBackView(width = cardWidth * 0.4f) + } + } + } + } +} + +@Composable +private fun BiddingControls( + state: EuchreState, + legal: List, + onMove: (EuchreMove) -> Unit, +) { + if (legal.isEmpty()) { + Text("Waiting for seat ${state.turn}…") + return + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = if (state.phase == EuchrePhase.BID_ROUND_1) { + "Order up ${state.upCard?.label.orEmpty()}?" + } else { + "Name a suit" + }, + fontWeight = FontWeight.Bold, + ) + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + legal.forEach { move -> + val label = when (move) { + is Pass -> "Pass" + is OrderUp -> if (move.alone) "Order up alone" else "Order up" + is CallTrump -> { + (if (move.alone) "Alone " else "") + move.suit.symbol + } + + is Discard -> "Discard ${move.card.label}" + is PlayCard -> move.card.label + } + if (move is Pass) { + OutlinedButton(onClick = { onMove(move) }) { Text(label) } + } else { + Button(onClick = { onMove(move) }) { Text(label) } + } + } + } + } +} + +@Composable +private fun HandRow( + state: EuchreState, + seat: Int, + legal: List, + cardWidth: androidx.compose.ui.unit.Dp, + onPlay: (EuchreMove) -> Unit, +) { + val hand = state.hands.getOrNull(seat).orEmpty() + val playable = legal.filterIsInstance().map { it.card }.toSet() + val discardable = legal.filterIsInstance().map { it.card }.toSet() + + Column { + Text( + text = when { + discardable.isNotEmpty() -> "Discard a card" + playable.isNotEmpty() -> "Your turn" + else -> "Your hand" + }, + style = MaterialTheme.typography.labelLarge, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (hand.isEmpty()) { + Box(modifier = Modifier.size(cardWidth)) { + Text("—", style = MaterialTheme.typography.bodySmall) + } + } + hand.forEach { card -> + val canPlay = card in playable + val canDiscard = card in discardable + PlayingCardView( + card = card, + width = cardWidth, + enabled = canPlay || canDiscard, + onClick = { + when { + canDiscard -> onPlay(Discard(card)) + canPlay -> onPlay(PlayCard(card)) + } + }, + ) + } + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt new file mode 100644 index 0000000..be00e6f --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/SequenceScreen.kt @@ -0,0 +1,296 @@ +package org.prolibertate.games.ui.game + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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.NO_TEAM +import org.prolibertate.games.game.sequence.PlaceChip +import org.prolibertate.games.game.sequence.RemoveChip +import org.prolibertate.games.game.sequence.SequenceBoard +import org.prolibertate.games.game.sequence.SequenceMove +import org.prolibertate.games.game.sequence.SequenceState +import org.prolibertate.games.net.MatchController +import org.prolibertate.games.ui.PrimaryAction +import org.prolibertate.games.ui.ScreenScaffold +import org.prolibertate.games.ui.theme.FeltGreenDark +import org.prolibertate.games.ui.theme.TeamColours + +/** + * The Sequence board. + * + * The board is always square and always fits: its side is the smaller of the + * available width and the height left over after the hand, so rotating the + * device rescales rather than clipping. + */ +@Composable +fun SequenceScreen( + controller: MatchController, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + var selected by remember { mutableStateOf(null) } + + val current = state + if (current == null) { + ScreenScaffold(title = "Sequence", onBack = onExit) { modifier -> + Box(modifier, contentAlignment = Alignment.Center) { Text("Dealing…") } + } + return + } + + // Squares the selected card could legally claim or clear. + val placements = legal.filterIsInstance().filter { it.card == selected } + val removals = legal.filterIsInstance().filter { it.card == selected } + val highlighted = (placements.map { it.cell } + removals.map { it.cell }).toSet() + + ScreenScaffold(title = "Sequence", onBack = onExit) { modifier -> + BoxWithConstraints(modifier = modifier) { + val wide = maxWidth > maxHeight + val boardSide = if (wide) { + minOf(maxWidth * 0.62f, maxHeight) + } else { + minOf(maxWidth, maxHeight * 0.68f) + } + val cell = boardSide / BOARD_SIZE + + val board: @Composable () -> Unit = { + BoardGrid( + state = current, + cellSize = cell, + highlighted = highlighted, + onCellTap = { index -> + val placement = placements.firstOrNull { it.cell == index } + val removal = removals.firstOrNull { it.cell == index } + when { + placement != null -> { + controller.submit(placement) + selected = null + } + + removal != null -> { + controller.submit(removal) + selected = null + } + } + }, + ) + } + + val side: @Composable () -> Unit = { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatusPanel(current, localSeat) + HandStrip( + state = current, + seat = localSeat, + legal = legal, + selected = selected, + cardWidth = (cell * 1.4f).coerceIn(40.dp, 84.dp), + onSelect = { card -> selected = if (selected == card) null else card }, + onExchange = { controller.submit(it); selected = null }, + ) + if (controller.finished.collectAsState().value) { + PrimaryAction("Back to the menu") { onExit() } + } + } + } + + if (wide) { + Row(modifier = Modifier.fillMaxSize()) { + board() + Box(modifier = Modifier.weight(1f)) { side() } + } + } else { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + board() + side() + } + } + } + } +} + +@Composable +private fun BoardGrid( + state: SequenceState, + cellSize: Dp, + highlighted: Set, + onCellTap: (Int) -> Unit, +) { + Column(modifier = Modifier.background(FeltGreenDark)) { + for (row in 0 until BOARD_SIZE) { + Row { + for (col in 0 until BOARD_SIZE) { + val index = SequenceBoard.cellAt(row, col) + BoardCell( + card = SequenceBoard.cells[index], + team = state.chips[index], + locked = state.locked[index], + highlighted = index in highlighted, + size = cellSize, + onClick = { onCellTap(index) }, + ) + } + } + } + } +} + +@Composable +private fun BoardCell( + card: Card?, + team: Int, + locked: Boolean, + highlighted: Boolean, + size: Dp, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .size(size) + .padding(0.5.dp) + .clip(RoundedCornerShape(size * 0.12f)) + .background(if (card == null) MaterialTheme.colorScheme.primary else Color.White) + .border( + width = if (highlighted) 2.dp else 0.5.dp, + color = if (highlighted) MaterialTheme.colorScheme.tertiary else Color(0x33000000), + shape = RoundedCornerShape(size * 0.12f), + ) + .clickable { onClick() }, + contentAlignment = Alignment.Center, + ) { + if (card == null) { + // A free corner, wild for every team. + Text("★", fontSize = (size.value * 0.45f).sp, color = Color.White) + } else { + Text( + text = card.label, + fontSize = (size.value * 0.26f).sp, + color = if (card.suit.isRed) Color(0xFFB3261E) else Color.Black, + ) + } + if (team != NO_TEAM) { + Box( + modifier = Modifier + .size(size * 0.62f) + .clip(CircleShape) + .background(TeamColours.getOrElse(team) { Color.Gray }) + .border( + width = if (locked) 2.dp else 0.dp, + color = if (locked) Color.White else Color.Transparent, + shape = CircleShape, + ) + ) + } + } +} + +@Composable +private fun StatusPanel(state: SequenceState, localSeat: Int) { + val myTeam = state.teamOf(localSeat) + Column { + Text("You are team $myTeam", fontWeight = FontWeight.Bold) + state.sequencesByTeam.forEachIndexed { team, count -> + Text( + text = "Team $team: $count / ${state.options.sequencesToWin} sequences", + style = MaterialTheme.typography.bodySmall, + color = TeamColours.getOrElse(team) { Color.Gray }, + ) + } + state.winner?.let { + Text("Team $it wins!", fontWeight = FontWeight.Bold) + } ?: Text( + text = if (state.turn == localSeat) "Your turn" else "Seat ${state.turn} is playing…", + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun HandStrip( + state: SequenceState, + seat: Int, + legal: List, + selected: Card?, + cardWidth: Dp, + onSelect: (Card) -> Unit, + onExchange: (ExchangeDeadCard) -> Unit, +) { + val hand = state.hands.getOrNull(seat).orEmpty() + val playable = legal.mapNotNull { + when (it) { + is PlaceChip -> it.card + is RemoveChip -> it.card + is ExchangeDeadCard -> it.card + } + }.toSet() + val exchanges = legal.filterIsInstance().associateBy { it.card } + + Column { + Text("Your hand", style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + hand.forEach { card -> + org.prolibertate.games.ui.common.PlayingCardView( + card = card, + width = cardWidth, + selected = card == selected, + enabled = card in playable, + onClick = { + val dead = exchanges[card] + if (dead != null) onExchange(dead) else onSelect(card) + }, + ) + } + } + selected?.let { + Text( + text = "Tap a highlighted square to play ${it.label}.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} diff --git a/app/src/main/java/org/prolibertate/games/ui/theme/Theme.kt b/app/src/main/java/org/prolibertate/games/ui/theme/Theme.kt new file mode 100644 index 0000000..45aa6a7 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/theme/Theme.kt @@ -0,0 +1,50 @@ +package org.prolibertate.games.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** Card-table green, which the board and table surfaces are drawn on. */ +val FeltGreen = Color(0xFF1B5E20) +val FeltGreenDark = Color(0xFF0B3D12) +val Parchment = Color(0xFFF4EFE4) +val CardRed = Color(0xFFB3261E) +val CardBlack = Color(0xFF1C1B1F) + +val TeamColours: List = listOf( + Color(0xFF1565C0), // blue + Color(0xFFC62828), // red + Color(0xFF2E7D32), // green, only used with three teams +) + +private val DarkColours = darkColorScheme( + primary = Color(0xFF7BC67E), + onPrimary = Color(0xFF00390A), + secondary = Color(0xFFB9CCB4), + background = Color(0xFF10140F), + surface = Color(0xFF1A1F18), + surfaceVariant = Color(0xFF2A2F27), +) + +private val LightColours = lightColorScheme( + primary = FeltGreen, + onPrimary = Color.White, + secondary = Color(0xFF52634F), + background = Parchment, + surface = Color(0xFFFBF8F1), + surfaceVariant = Color(0xFFE0E4D8), +) + +@Composable +fun ProLibertateTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = if (darkTheme) DarkColours else LightColours, + content = content, + ) +} diff --git a/app/src/main/java/org/prolibertate/games/update/Updater.kt b/app/src/main/java/org/prolibertate/games/update/Updater.kt new file mode 100644 index 0000000..028fc78 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/update/Updater.kt @@ -0,0 +1,127 @@ +package org.prolibertate.games.update + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +/** + * Over-the-air updates from the repository's GitHub Releases. + * + * Release tags are v1.0. and the APK's versionCode is that same run + * number, so comparing the two is enough to tell whether a newer build exists. + * The repository's releases must be publicly downloadable — no token is + * embedded in the app, and GitHub blocks anonymous access to private assets. + */ +class Updater(private val activity: Activity) { + + data class Release(val tag: String, val versionCode: Int, val apkUrl: String, val apkName: String) + + sealed interface Result { + data class Available(val release: Release) : Result + data object UpToDate : Result + data class Failed(val reason: String) : Result + } + + suspend fun check(): Result = withContext(Dispatchers.IO) { + try { + val latest = fetchLatestRelease() + ?: return@withContext Result.Failed("No published release found.") + if (latest.versionCode <= installedVersionCode()) { + Result.UpToDate + } else { + Result.Available(latest) + } + } catch (e: Exception) { + Result.Failed(e.message ?: "Update check failed.") + } + } + + private fun fetchLatestRelease(): Release? { + val url = URL("https://api.github.com/repos/$OWNER/$REPO/releases/latest") + val connection = (url.openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + setRequestProperty("Accept", "application/vnd.github+json") + setRequestProperty("User-Agent", "ProLibertateGames-Updater") + connectTimeout = 15_000 + readTimeout = 15_000 + } + try { + if (connection.responseCode != 200) return null + val json = JSONObject(connection.inputStream.bufferedReader().use { it.readText() }) + val tag = json.getString("tag_name") + val versionCode = tag.substringAfterLast('.').toIntOrNull() ?: return null + val assets = json.getJSONArray("assets") + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val name = asset.getString("name") + if (name.endsWith(".apk")) { + return Release(tag, versionCode, asset.getString("browser_download_url"), name) + } + } + return null + } finally { + connection.disconnect() + } + } + + /** Downloads the APK and hands it to the system installer. */ + suspend fun downloadAndInstall(release: Release): String? = withContext(Dispatchers.IO) { + try { + val target = File(activity.cacheDir, "update.apk") + URL(release.apkUrl).openStream().use { input -> + target.outputStream().use { output -> input.copyTo(output) } + } + withContext(Dispatchers.Main) { launchInstaller(target) } + null + } catch (e: Exception) { + e.message ?: "Download failed." + } + } + + /** API 26+ requires the user to allow installs from this app first. */ + fun needsInstallPermission(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + !activity.packageManager.canRequestPackageInstalls() + + fun requestInstallPermission() { + runCatching { + activity.startActivity( + Intent( + Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, + Uri.parse("package:${activity.packageName}"), + ) + ) + } + } + + private fun launchInstaller(apk: File) { + if (activity.isFinishing) return + val uri = FileProvider.getUriForFile(activity, "${activity.packageName}.fileprovider", apk) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + runCatching { activity.startActivity(intent) } + } + + @Suppress("DEPRECATION") + fun installedVersionCode(): Int = + activity.packageManager.getPackageInfo(activity.packageName, 0).versionCode + + fun installedVersionName(): String = + activity.packageManager.getPackageInfo(activity.packageName, 0).versionName ?: "unknown" + + private companion object { + const val OWNER = "adrianoftyriel" + const val REPO = "ProLibertateGames" + } +} diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..a1cb143 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..5c8a39a --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6b78462 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher.xml b/app/src/main/res/mipmap/ic_launcher.xml new file mode 100644 index 0000000..8ed1d4d --- /dev/null +++ b/app/src/main/res/mipmap/ic_launcher.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/mipmap/ic_launcher_round.xml b/app/src/main/res/mipmap/ic_launcher_round.xml new file mode 100644 index 0000000..69e9235 --- /dev/null +++ b/app/src/main/res/mipmap/ic_launcher_round.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..48d3808 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #1B5E20 + #FF000000 + #FFFFFFFF + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1eff924 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Pro Libertate Games + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..bf1e235 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..bb2112d --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt new file mode 100644 index 0000000..56b7e50 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/euchre/EuchreRulesTest.kt @@ -0,0 +1,392 @@ +package org.prolibertate.games.game.euchre + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Decks +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +class EuchreRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config(options: EuchreOptions = EuchreOptions(), seed: Long = 42L) = 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, + ) + + // -- Deck --------------------------------------------------------------- + + @Test + fun `24 card deck runs nine to ace`() { + val deck = Decks.euchre(24) + assertEquals(24, deck.size) + assertEquals(24, deck.distinct().size) + assertTrue(deck.none { it.rank.order < Rank.NINE.order }) + } + + @Test + fun `32 card deck runs seven to ace`() { + val deck = Decks.euchre(32) + assertEquals(32, deck.size) + assertTrue(deck.any { it.rank == Rank.SEVEN }) + } + + // -- Bower ranking ------------------------------------------------------ + + @Test + fun `left bower counts as trump not its printed suit`() { + val trump = Suit.SPADES + val leftBower = Card(Rank.JACK, Suit.CLUBS) + assertTrue(isLeftBower(leftBower, trump)) + assertEquals(Suit.SPADES, effectiveSuit(leftBower, trump)) + assertTrue(isTrumpCard(leftBower, trump)) + } + + @Test + fun `right bower outranks left bower which outranks the ace of trump`() { + val trump = Suit.SPADES + val right = trickStrength(Card(Rank.JACK, Suit.SPADES), trump, trump) + val left = trickStrength(Card(Rank.JACK, Suit.CLUBS), trump, trump) + val aceTrump = trickStrength(Card(Rank.ACE, Suit.SPADES), trump, trump) + assertTrue("right > left", right > left) + assertTrue("left > ace of trump", left > aceTrump) + } + + @Test + fun `off suit card cannot win a trick`() { + val trump = Suit.SPADES + val ledSuit = Suit.HEARTS + val offSuit = trickStrength(Card(Rank.ACE, Suit.DIAMONDS), trump, ledSuit) + assertEquals(0, offSuit) + } + + // -- Following suit ----------------------------------------------------- + + private fun playingState( + hands: List>, + trump: Suit, + trick: List = emptyList(), + turn: Int = 0, + ) = EuchreState( + options = EuchreOptions(), + seed = 1L, + handNumber = 0, + dealer = 3, + phase = EuchrePhase.PLAYING, + hands = hands, + handCounts = hands.map { it.size }, + upCard = null, + turnedDown = null, + trump = trump, + maker = 0, + aloneSeat = null, + turn = turn, + passes = 0, + trick = trick, + leader = 0, + tricksWon = listOf(0, 0, 0, 0), + scores = listOf(0, 0), + lastTrickWinner = null, + log = emptyList(), + ) + + @Test + fun `left bower must be played when trump is led`() { + val trump = Suit.SPADES + val hand = listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.KING, Suit.HEARTS)) + val state = playingState( + hands = listOf(emptyList(), hand, emptyList(), emptyList()), + trump = trump, + trick = listOf(PlayedCard(0, Card(Rank.NINE, Suit.SPADES))), + turn = 1, + ) + val playable = EuchreRules.playableCards(state, 1) + assertEquals(listOf(Card(Rank.JACK, Suit.CLUBS)), playable) + } + + @Test + fun `left bower does not satisfy following its printed suit`() { + val trump = Suit.SPADES + // Clubs is led, and the only club held is the left bower — which is a + // spade for this hand, so the player is void in clubs and may play + // anything. + val hand = listOf(Card(Rank.JACK, Suit.CLUBS), Card(Rank.KING, Suit.HEARTS)) + val state = playingState( + hands = listOf(emptyList(), hand, emptyList(), emptyList()), + trump = trump, + trick = listOf(PlayedCard(0, Card(Rank.NINE, Suit.CLUBS))), + turn = 1, + ) + assertEquals(hand.toSet(), EuchreRules.playableCards(state, 1).toSet()) + } + + // -- Bidding ------------------------------------------------------------ + + @Test + fun `ordering up sets trump and sends the dealer to discard`() { + val state = EuchreRules.initialState(config()) + val upSuit = state.upCard!!.suit + val next = EuchreRules.applyMove(state, state.turn, OrderUp(alone = false)) + assertEquals(EuchrePhase.DEALER_DISCARD, next.phase) + assertEquals(upSuit, next.trump) + assertEquals(next.dealer, next.turn) + assertEquals(6, next.hands[next.dealer].size) + } + + @Test + fun `dealer discard returns the hand to five cards and starts play`() { + var state = EuchreRules.initialState(config()) + 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) + assertEquals(EuchrePhase.PLAYING, state.phase) + assertEquals(5, state.hands[state.dealer].size) + assertEquals((state.dealer + 1) % 4, state.turn) + } + + @Test + fun `four passes turn the card down and open the second round`() { + var state = EuchreRules.initialState(config()) + val up = state.upCard!! + repeat(4) { state = EuchreRules.applyMove(state, state.turn, Pass) } + assertEquals(EuchrePhase.BID_ROUND_2, state.phase) + assertEquals(up, state.turnedDown) + assertTrue("turned-down suit cannot be named", + EuchreRules.legalMoves(state, state.turn) + .filterIsInstance() + .none { it.suit == up.suit }) + } + + @Test + fun `stick the dealer removes the dealers option to pass`() { + var state = EuchreRules.initialState(config(EuchreOptions(stickTheDealer = true))) + repeat(4) { state = EuchreRules.applyMove(state, state.turn, Pass) } + repeat(3) { state = EuchreRules.applyMove(state, state.turn, Pass) } + assertEquals(state.dealer, state.turn) + val moves = EuchreRules.legalMoves(state, state.dealer) + assertFalse("dealer must name a suit", moves.contains(Pass)) + assertTrue(moves.all { it is CallTrump }) + } + + @Test + fun `without stick the dealer a passed out hand is redealt`() { + var state = EuchreRules.initialState(config(EuchreOptions(stickTheDealer = false))) + val firstDealer = state.dealer + repeat(8) { state = EuchreRules.applyMove(state, state.turn, Pass) } + assertEquals(EuchrePhase.BID_ROUND_1, state.phase) + assertEquals((firstDealer + 1) % 4, state.dealer) + } + + // -- Going alone -------------------------------------------------------- + + @Test + fun `going alone sits the partner out`() { + var state = EuchreRules.initialState(config()) + val bidder = state.turn + state = EuchreRules.applyMove(state, bidder, OrderUp(alone = true)) + val discard = EuchreRules.legalMoves(state, state.dealer).first() as Discard + state = EuchreRules.applyMove(state, state.dealer, discard) + assertEquals(bidder, state.aloneSeat) + assertEquals(partnerOf(bidder), state.sittingOut) + assertEquals(3, state.activeSeatCount) + assertTrue(state.hands[partnerOf(bidder)].isEmpty()) + assertTrue("the sitting-out seat never gets the turn", state.turn != state.sittingOut) + } + + // -- Scoring ------------------------------------------------------------ + + /** Plays a hand out with a fixed strategy so scoring can be asserted. */ + private fun playOutHand(state: EuchreState, pick: (EuchreState, Int) -> EuchreMove): EuchreState { + var current = state + var guard = 0 + while (current.phase == EuchrePhase.PLAYING && guard++ < 100) { + val seat = EuchreRules.currentSeat(current)!! + current = EuchreRules.applyMove(current, seat, pick(current, seat)) + } + return current + } + + @Test + fun `taking all five tricks scores two, or four when alone`() { + // Team 0 holds every trump; team 1 cannot win a trick. + val trump = Suit.SPADES + val strong = listOf( + Card(Rank.JACK, Suit.SPADES), Card(Rank.JACK, Suit.CLUBS), + Card(Rank.ACE, Suit.SPADES), Card(Rank.KING, Suit.SPADES), + Card(Rank.QUEEN, Suit.SPADES), + ) + val weak0 = listOf( + Card(Rank.NINE, Suit.HEARTS), Card(Rank.TEN, Suit.HEARTS), + Card(Rank.QUEEN, Suit.HEARTS), Card(Rank.KING, Suit.HEARTS), + Card(Rank.ACE, Suit.HEARTS), + ) + val weak1 = listOf( + Card(Rank.NINE, Suit.DIAMONDS), Card(Rank.TEN, Suit.DIAMONDS), + Card(Rank.QUEEN, Suit.DIAMONDS), Card(Rank.KING, Suit.DIAMONDS), + Card(Rank.ACE, Suit.DIAMONDS), + ) + val weak2 = listOf( + Card(Rank.NINE, Suit.CLUBS), Card(Rank.TEN, Suit.CLUBS), + Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS), + Card(Rank.ACE, Suit.CLUBS), + ) + + val state = playingState( + hands = listOf(strong, weak0, weak1, weak2), + trump = trump, + ).copy(maker = 0, leader = 0, turn = 0) + + val finished = playOutHand(state) { s, seat -> + PlayCard(EuchreRules.playableCards(s, seat).first()) + } + assertEquals(5, finished.tricksWon[0]) + assertEquals(2, finished.scores[0]) + assertEquals(0, finished.scores[1]) + } + + @Test + fun `failing to take three tricks hands the defenders two points`() { + val trump = Suit.SPADES + // Seat 0 makes it but holds nothing; seat 1 holds all the trump. + val makerHand = listOf( + Card(Rank.NINE, Suit.HEARTS), Card(Rank.TEN, Suit.HEARTS), + Card(Rank.QUEEN, Suit.HEARTS), Card(Rank.KING, Suit.HEARTS), + Card(Rank.ACE, Suit.HEARTS), + ) + val trumpHand = listOf( + Card(Rank.JACK, Suit.SPADES), Card(Rank.JACK, Suit.CLUBS), + Card(Rank.ACE, Suit.SPADES), Card(Rank.KING, Suit.SPADES), + Card(Rank.QUEEN, Suit.SPADES), + ) + val filler1 = listOf( + Card(Rank.NINE, Suit.DIAMONDS), Card(Rank.TEN, Suit.DIAMONDS), + Card(Rank.QUEEN, Suit.DIAMONDS), Card(Rank.KING, Suit.DIAMONDS), + Card(Rank.ACE, Suit.DIAMONDS), + ) + val filler2 = listOf( + Card(Rank.NINE, Suit.CLUBS), Card(Rank.TEN, Suit.CLUBS), + Card(Rank.QUEEN, Suit.CLUBS), Card(Rank.KING, Suit.CLUBS), + Card(Rank.ACE, Suit.CLUBS), + ) + + val state = playingState( + hands = listOf(makerHand, trumpHand, filler1, filler2), + trump = trump, + ).copy(maker = 0, leader = 0, turn = 0) + + // Seat 1 always trumps in; everyone else follows or throws off. + val finished = playOutHand(state) { s, seat -> + val playable = EuchreRules.playableCards(s, seat) + val card = if (seat == 1) { + playable.maxByOrNull { trumpStrength(it, trump) }!! + } else { + playable.first() + } + PlayCard(card) + } + assertEquals(EuchrePhase.HAND_OVER, finished.phase) + assertTrue("makers took fewer than three", finished.tricksWon[0] + finished.tricksWon[2] < 3) + assertEquals(2, finished.scores[1]) + assertEquals(0, finished.scores[0]) + } + + // -- Full games --------------------------------------------------------- + + @Test + fun `random legal play always reaches a winner`() { + repeat(25) { iteration -> + val random = Random(iteration.toLong()) + var state = EuchreRules.initialState(config(seed = iteration.toLong())) + var guard = 0 + while (!EuchreRules.isFinished(state) && guard++ < 5000) { + val seat = EuchreRules.currentSeat(state) + if (seat == null) { + state = EuchreRules.nextHand(state) + continue + } + val legal = EuchreRules.legalMoves(state, seat) + assertTrue("seat $seat had no legal move in ${state.phase}", legal.isNotEmpty()) + state = EuchreRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertTrue("game $iteration did not finish", EuchreRules.isFinished(state)) + assertTrue(state.scores.max() >= state.options.pointsToWin) + } + } + + @Test + fun `ai play always reaches a winner`() { + val ai = EuchreAi() + repeat(10) { iteration -> + var state = EuchreRules.initialState(config(seed = 500L + iteration)) + var guard = 0 + while (!EuchreRules.isFinished(state) && guard++ < 5000) { + val seat = EuchreRules.currentSeat(state) + if (seat == null) { + state = EuchreRules.nextHand(state) + continue + } + val legal = EuchreRules.legalMoves(state, seat) + val move = ai.chooseMove(state, seat, legal) + assertTrue("ai returned an illegal move $move", legal.contains(move)) + state = EuchreRules.applyMove(state, seat, move) + } + assertTrue("ai game $iteration did not finish", EuchreRules.isFinished(state)) + } + } + + // -- Redaction and wire format ----------------------------------------- + + @Test + fun `a seat view hides every other hand but keeps the counts`() { + val state = EuchreRules.initialState(config()) + val view = EuchreRules.viewFor(state, seat = 2) + assertEquals(state.hands[2], view.hands[2]) + assertTrue(view.hands.filterIndexed { i, _ -> i != 2 }.all { it.isEmpty() }) + assertEquals(listOf(5, 5, 5, 5), view.handCounts) + } + + @Test + fun `state and moves survive a round trip`() { + val state = EuchreRules.initialState(config()) + val decoded = EuchreRules.decodeState(EuchreRules.encodeState(state)) + assertEquals(state, decoded) + + val moves = listOf( + Pass, + OrderUp(alone = true), + CallTrump(Suit.HEARTS, alone = false), + Discard(Card(Rank.NINE, Suit.CLUBS)), + PlayCard(Card(Rank.ACE, Suit.SPADES)), + ) + moves.forEach { move -> + assertEquals(move, EuchreRules.decodeMove(EuchreRules.encodeMove(move))) + } + } + + @Test + fun `illegal moves are rejected`() { + val state = EuchreRules.initialState(config()) + val wrongSeat = (state.turn + 1) % 4 + var threw = false + try { + EuchreRules.applyMove(state, wrongSeat, Pass) + } catch (expected: IllegalArgumentException) { + threw = true + } + assertTrue("playing out of turn must be rejected", threw) + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt new file mode 100644 index 0000000..844e5cb --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/sequence/SequenceRulesTest.kt @@ -0,0 +1,374 @@ +package org.prolibertate.games.game.sequence + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.prolibertate.games.game.cards.Card +import org.prolibertate.games.game.cards.Rank +import org.prolibertate.games.game.cards.Suit +import org.prolibertate.games.game.engine.PlayerKind +import org.prolibertate.games.game.engine.PlayerSlot +import org.prolibertate.games.game.engine.TableConfig +import kotlin.random.Random + +class SequenceRulesTest { + + private val json = Json { encodeDefaults = true } + + private fun config( + options: SequenceOptions = SequenceOptions(), + seed: Long = 7L, + ) = TableConfig( + gameId = "sequence", + seats = (0 until options.playerCount).map { + PlayerSlot( + seat = it, + name = "P$it", + kind = PlayerKind.AI, + team = it % options.teamCount, + ) + }, + optionsJson = json.encodeToString(options), + seed = seed, + ) + + // -- Board -------------------------------------------------------------- + + @Test + fun `board holds every non-jack card twice plus four free corners`() { + assertEquals(BOARD_CELLS, SequenceBoard.cells.size) + val cards = SequenceBoard.cells.filterNotNull() + assertEquals(96, cards.size) + assertEquals(4, SequenceBoard.cells.count { it == null }) + + val counts = cards.groupingBy { it }.eachCount() + assertEquals("48 distinct cards", 48, counts.size) + assertTrue("each appears exactly twice", counts.values.all { it == 2 }) + assertTrue("no jacks on the board", cards.none { it.rank == Rank.JACK }) + } + + @Test + fun `the four corners are the free squares`() { + listOf( + SequenceBoard.cellAt(0, 0), + SequenceBoard.cellAt(0, 9), + SequenceBoard.cellAt(9, 0), + SequenceBoard.cellAt(9, 9), + ).forEach { assertTrue(SequenceBoard.isCorner(it)) } + } + + @Test + fun `every non-jack card maps to exactly two squares`() { + val nonJacks = Suit.entries.flatMap { suit -> + Rank.entries.filter { it != Rank.JACK }.map { Card(it, suit) } + } + nonJacks.forEach { card -> + assertEquals("$card", 2, SequenceBoard.squaresByCard[card]?.size) + } + } + + @Test + fun `jack classification splits one-eyed from two-eyed`() { + assertTrue(isTwoEyedJack(Card(Rank.JACK, Suit.DIAMONDS))) + assertTrue(isTwoEyedJack(Card(Rank.JACK, Suit.CLUBS))) + assertTrue(isOneEyedJack(Card(Rank.JACK, Suit.HEARTS))) + assertTrue(isOneEyedJack(Card(Rank.JACK, Suit.SPADES))) + assertFalse(isOneEyedJack(Card(Rank.ACE, Suit.SPADES))) + } + + // -- Dealing ------------------------------------------------------------ + + @Test + fun `hand sizes follow the player count`() { + assertEquals(7, handSizeFor(2)) + assertEquals(6, handSizeFor(3)) + assertEquals(6, handSizeFor(4)) + assertEquals(5, handSizeFor(6)) + assertEquals(4, handSizeFor(8)) + assertEquals(3, handSizeFor(12)) + } + + @Test + fun `initial deal gives everyone a hand from a 104 card deck`() { + val options = SequenceOptions(teamCount = 2, playersPerTeam = 2) + val state = SequenceRules.initialState(config(options)) + assertEquals(4, state.playerCount) + state.hands.forEach { assertEquals(6, it.size) } + assertEquals(104 - 4 * 6, state.drawPile.size) + assertTrue(state.chips.all { it == NO_TEAM }) + } + + // -- Placement ---------------------------------------------------------- + + @Test + fun `placing a chip claims the square and passes the turn`() { + var state = SequenceRules.initialState(config()) + val move = SequenceRules.legalMoves(state, 0).filterIsInstance().first() + state = SequenceRules.applyMove(state, 0, move) + assertEquals(state.teams[0], state.chips[move.cell]) + assertEquals(1, state.turn) + } + + @Test + fun `a card whose squares are both taken becomes a dead card`() { + val card = Card(Rank.ACE, Suit.HEARTS) + val squares = SequenceBoard.squaresByCard.getValue(card) + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + squares.forEach { chips[it] = 1 } + + val state = baseState(chips = chips, hand0 = listOf(card)) + val moves = SequenceRules.legalMoves(state, 0) + assertTrue("no placement is possible", moves.none { it is PlaceChip }) + assertEquals(listOf(ExchangeDeadCard(card)), moves) + } + + @Test + fun `exchanging a dead card keeps the turn`() { + val card = Card(Rank.ACE, Suit.HEARTS) + val squares = SequenceBoard.squaresByCard.getValue(card) + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + squares.forEach { chips[it] = 1 } + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, ExchangeDeadCard(card)) + assertEquals("turn does not pass", 0, state.turn) + assertTrue(state.exchangedThisTurn) + assertFalse("the dead card is gone", state.hands[0].contains(card)) + } + + // -- Jacks -------------------------------------------------------------- + + @Test + fun `a two-eyed jack can be placed on any empty square`() { + val jack = Card(Rank.JACK, Suit.DIAMONDS) + val state = baseState(hand0 = listOf(jack)) + val placements = SequenceRules.legalMoves(state, 0).filterIsInstance() + // Every square except the four corners is available. + assertEquals(BOARD_CELLS - 4, placements.size) + } + + @Test + fun `a one-eyed jack removes an opposing chip but not a locked one`() { + val jack = Card(Rank.JACK, Suit.SPADES) + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + val target = SequenceBoard.cellAt(5, 5) + val protectedCell = SequenceBoard.cellAt(6, 6) + chips[target] = 1 + chips[protectedCell] = 1 + val locked = MutableList(BOARD_CELLS) { false } + locked[protectedCell] = true + + val state = baseState(chips = chips, locked = locked, hand0 = listOf(jack)) + val removals = SequenceRules.legalMoves(state, 0).filterIsInstance() + assertEquals(listOf(target), removals.map { it.cell }) + } + + @Test + fun `a one-eyed jack cannot remove your own teams chip`() { + val jack = Card(Rank.JACK, Suit.SPADES) + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + chips[SequenceBoard.cellAt(4, 4)] = 0 // seat 0 is on team 0 + + val state = baseState(chips = chips, hand0 = listOf(jack)) + assertTrue(SequenceRules.legalMoves(state, 0).none { it is RemoveChip }) + } + + // -- Sequences ---------------------------------------------------------- + + @Test + fun `five in a row completes a sequence and locks the squares`() { + // Four chips already down in row 4; the card for the fifth is in hand. + val fifth = SequenceBoard.cellAt(4, 4) + val card = SequenceBoard.cells[fifth]!! + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(0, 1, 2, 3).forEach { chips[SequenceBoard.cellAt(4, it)] = 0 } + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + + assertEquals(1, state.sequencesByTeam[0]) + (0..4).forEach { col -> + assertTrue("(4,$col) locked", state.locked[SequenceBoard.cellAt(4, col)]) + } + } + + @Test + fun `a corner counts as a free square for the run`() { + // Corner (0,0) plus (0,1)..(0,3) held, needing only (0,4). + val fifth = SequenceBoard.cellAt(0, 4) + val card = SequenceBoard.cells[fifth]!! + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(1, 2, 3).forEach { chips[SequenceBoard.cellAt(0, it)] = 0 } + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + assertEquals("the free corner completed the five", 1, state.sequencesByTeam[0]) + } + + @Test + fun `a diagonal run counts`() { + val fifth = SequenceBoard.cellAt(4, 4) + val card = SequenceBoard.cells[fifth]!! + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(0, 1, 2, 3).forEach { chips[SequenceBoard.cellAt(it, it)] = 0 } + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + assertEquals(1, state.sequencesByTeam[0]) + } + + @Test + fun `an opposing chip breaks the run`() { + val fifth = SequenceBoard.cellAt(4, 4) + val card = SequenceBoard.cells[fifth]!! + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(0, 1, 3).forEach { chips[SequenceBoard.cellAt(4, it)] = 0 } + chips[SequenceBoard.cellAt(4, 2)] = 1 + + var state = baseState(chips = chips, hand0 = listOf(card)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + assertEquals(0, state.sequencesByTeam[0]) + } + + @Test + fun `two sequences may share at most one chip`() { + // A full row of nine for team 0. Placing the tenth cannot manufacture + // an extra sequence out of heavily overlapping windows. + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + val locked = MutableList(BOARD_CELLS) { false } + (0..4).forEach { + chips[SequenceBoard.cellAt(6, it)] = 0 + locked[SequenceBoard.cellAt(6, it)] = true + } + (5..8).forEach { chips[SequenceBoard.cellAt(6, it)] = 0 } + + val fifth = SequenceBoard.cellAt(6, 9) + val card = SequenceBoard.cells[fifth]!! + var state = baseState(chips = chips, locked = locked, hand0 = listOf(card)) + .copy(sequencesByTeam = listOf(1, 0)) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + + // Squares 5..9 contain no already-locked chip, so this is a clean second. + assertEquals(2, state.sequencesByTeam[0]) + } + + @Test + fun `reaching the sequence target wins the game`() { + val chips = MutableList(BOARD_CELLS) { NO_TEAM } + listOf(0, 1, 2, 3).forEach { chips[SequenceBoard.cellAt(4, it)] = 0 } + val fifth = SequenceBoard.cellAt(4, 4) + val card = SequenceBoard.cells[fifth]!! + + var state = baseState( + chips = chips, + hand0 = listOf(card), + options = SequenceOptions(teamCount = 2, sequencesToWin = 1), + ) + state = SequenceRules.applyMove(state, 0, PlaceChip(card, fifth)) + assertTrue(SequenceRules.isFinished(state)) + assertEquals(0, state.winner) + assertEquals(SequencePhase.GAME_OVER, state.phase) + } + + // -- Full games --------------------------------------------------------- + + @Test + fun `random legal play terminates without an illegal state`() { + repeat(10) { iteration -> + val random = Random(iteration.toLong()) + var state = SequenceRules.initialState(config(seed = iteration.toLong())) + var guard = 0 + while (!SequenceRules.isFinished(state) && guard++ < 4000) { + val seat = SequenceRules.currentSeat(state) ?: break + val legal = SequenceRules.legalMoves(state, seat) + if (legal.isEmpty()) break // a blocked table is a legitimate draw + state = SequenceRules.applyMove(state, seat, legal[random.nextInt(legal.size)]) + } + assertTrue("ran away", guard < 4000) + } + } + + @Test + fun `ai play produces only legal moves and reaches a result`() { + val ai = SequenceAi() + repeat(5) { iteration -> + var state = SequenceRules.initialState( + config(SequenceOptions(teamCount = 2, playersPerTeam = 1), seed = 900L + iteration) + ) + var guard = 0 + while (!SequenceRules.isFinished(state) && guard++ < 4000) { + val seat = SequenceRules.currentSeat(state) ?: break + val legal = SequenceRules.legalMoves(state, seat) + if (legal.isEmpty()) break + val move = ai.chooseMove(state, seat, legal) + assertTrue("ai returned an illegal move $move", legal.contains(move)) + state = SequenceRules.applyMove(state, seat, move) + } + assertTrue("ai game ran away", guard < 4000) + } + } + + // -- Redaction and wire format ----------------------------------------- + + @Test + fun `a seat view hides other hands and the deck order`() { + val state = SequenceRules.initialState(config(SequenceOptions(playersPerTeam = 2))) + val view = SequenceRules.viewFor(state, seat = 1) + assertEquals(state.hands[1], view.hands[1]) + assertTrue(view.hands.filterIndexed { i, _ -> i != 1 }.all { it.isEmpty() }) + assertTrue("deck order is hidden", view.drawPile.isEmpty()) + assertEquals(state.handCounts, view.handCounts) + } + + @Test + fun `state and moves survive a round trip`() { + val state = SequenceRules.initialState(config()) + assertEquals(state, SequenceRules.decodeState(SequenceRules.encodeState(state))) + + val moves = listOf( + PlaceChip(Card(Rank.ACE, Suit.HEARTS), 12), + RemoveChip(Card(Rank.JACK, Suit.SPADES), 40), + ExchangeDeadCard(Card(Rank.TWO, Suit.CLUBS)), + ) + moves.forEach { assertEquals(it, SequenceRules.decodeMove(SequenceRules.encodeMove(it))) } + } + + @Test + fun `summary reports the running sequence count`() { + val state = SequenceRules.initialState(config()) + assertNotNull(SequenceRules.summary(state)) + } + + // -- Helpers ------------------------------------------------------------ + + private fun baseState( + chips: List = List(BOARD_CELLS) { NO_TEAM }, + locked: List = List(BOARD_CELLS) { false }, + hand0: List, + options: SequenceOptions = SequenceOptions(), + ): SequenceState { + val hands = listOf(hand0) + List(options.playerCount - 1) { emptyList() } + return SequenceState( + options = options, + seed = 1L, + teams = (0 until options.playerCount).map { it % options.teamCount }, + chips = chips, + locked = locked, + hands = hands, + handCounts = hands.map { it.size }, + drawPile = emptyList(), + discardPile = emptyList(), + turn = 0, + sequencesByTeam = List(options.teamCount) { 0 }, + winner = null, + phase = SequencePhase.PLAYING, + exchangedThisTurn = false, + lastPlacedCell = null, + log = emptyList(), + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6c2cdd5 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,6 @@ +// Top-level build file. Configuration common to all sub-projects goes here. +plugins { + id("com.android.application") version "8.5.2" apply false + id("org.jetbrains.kotlin.android") version "1.9.24" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false +} diff --git a/docs/RULES-euchre.md b/docs/RULES-euchre.md new file mode 100644 index 0000000..6abdf6e --- /dev/null +++ b/docs/RULES-euchre.md @@ -0,0 +1,81 @@ +# Euchre — implemented rules + +This is the ruleset the engine enforces, so you can check it against how you +actually play. Anything marked **toggle** is exposed in the game setup screen; +anything else is currently fixed and would need a code change. + +## Table + +- Four players, two fixed partnerships. Seats 0 and 2 are one team, seats 1 and + 3 are the other, so partners sit opposite each other. +- The deal rotates one seat clockwise after every hand. + +## Deck — *toggle* + +- **24 cards** (9, 10, J, Q, K, A in each suit). The standard game. +- **32 cards** (7 through A). Five cards are still dealt to each player and one + is still turned up, so the extra cards simply deepen the undealt remainder. + Some 32-card variants deal eight cards and play eight tricks; that is *not* + what this does. + +## Ranking + +With trump named, the order from the top is: + +1. **Right bower** — the jack of trump. +2. **Left bower** — the jack of the *same colour* as trump. It counts as a trump + card, not as a card of its printed suit. If spades are trump, the jack of + clubs is a spade for the whole hand: it must be played when spades are led, + and it does *not* satisfy following clubs. +3. A, K, Q, 10, 9 of trump. + +Non-trump suits rank A, K, Q, J, 10, 9. A card that is neither trump nor of the +led suit cannot win a trick. + +## Bidding + +**Round one.** Starting to the dealer's left, each player may order up the +turned card or pass. Ordering up makes that card's suit trump; the dealer takes +the card into hand and discards one card face down. + +**Round two.** If all four pass, the turn card is turned down and its suit may +not be named. Starting again to the dealer's left, each player may name any +other suit or pass. + +**Stick the dealer — *toggle*, on by default.** If the first three players pass +in round two, the dealer must name a suit. With the toggle off, a fourth pass +throws the hand in and it is redealt by the next dealer. + +**Going alone — *toggle*, on by default.** A maker may declare going alone when +ordering up or naming trump. Their partner sits the hand out entirely and plays +no cards; the hand is played three-handed. + +## Play + +- The player to the dealer's left leads to the first trick; after that the + winner of each trick leads to the next. +- You must follow the led suit if you hold it, remembering that the left bower + belongs to trump. Otherwise you may play anything. +- Five tricks are played. + +## Scoring + +Let the makers be the team that named trump. + +| Outcome | Points | +| --- | --- | +| Makers take 3 or 4 tricks | 1 to the makers | +| Makers take all 5 | 2 to the makers | +| Makers take all 5, playing alone | 4 to the makers | +| Makers take fewer than 3 (euchred) | 2 to the defenders | + +A lone hand that takes 3 or 4 tricks scores 1, the same as an ordinary make. + +**Game — *toggle*.** First team to **10** points (default), or 11, or 15. + +## Known simplifications + +- Cards are dealt five at a time rather than in the traditional 3–2 / 2–3 + packets. With a shuffled deck this changes nothing about the odds, but it does + mean the deal does not *look* like a real one. +- No defending alone, no farmer's hand, no notrump or misère bids. diff --git a/docs/RULES-sequence.md b/docs/RULES-sequence.md new file mode 100644 index 0000000..b09c36d --- /dev/null +++ b/docs/RULES-sequence.md @@ -0,0 +1,77 @@ +# Sequence — implemented rules + +This is the ruleset the engine enforces. Anything marked **toggle** is exposed +in the game setup screen. + +## ⚠️ The board layout is not the commercial one + +`SequenceBoard.LAYOUT` in `SequenceModel.kt` is a **generated** 10×10 layout, not +a reproduction of the printed Sequence board. It was generated because the real +arrangement could not be verified from here, and shipping a guessed one labelled +as authentic would have been worse than shipping an honest substitute. + +It satisfies every property the rules depend on: + +- each of the 48 non-jack cards appears exactly twice, +- the four corners are free squares, +- no card is adjacent to its own twin (including diagonally). + +So the game plays correctly — but square for square it will not match a physical +board, and two players using this app plus one physical board would disagree. +To fix that, replace the ten strings in `LAYOUT` with the real arrangement. +Nothing else needs to change; the parser, the tests and the AI all read from it. + +## Table + +- **Teams — *toggle*.** Two or three. Seats are dealt round-robin into teams, so + team-mates are never seated adjacently. +- **Players per team — *toggle*.** 1 to 4. +- **Sequences to win — *toggle*.** Defaults to 2 for two teams and 1 for three + teams, matching the usual rule, but can be set explicitly. + +## Cards + +Two full 52-card decks, shuffled together — 104 cards. + +Hand size shrinks as the table grows: + +| Players | Cards | +| --- | --- | +| 2 | 7 | +| 3–4 | 6 | +| 5–6 | 5 | +| 7–9 | 4 | +| 10+ | 3 | + +After playing a card you draw a replacement. If the draw pile runs out, the +discards are shuffled to form a new one. + +## Turn + +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. +- **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. + +## Sequences + +A sequence is five chips in a row — horizontally, vertically or diagonally. + +- The **four corners are free squares** and count towards any team's sequence. +- Two sequences may **share at most one chip**. The engine enforces this by + refusing to count a run of five that contains more than one already-locked + square. +- Chips in a completed sequence are locked and cannot be removed by a one-eyed + jack. + +The first team to reach the sequence target wins immediately. + +## Known simplifications + +- A completely blocked table (no legal move for anyone) ends the game without a + winner rather than triggering a formal draw. +- The AI does not count cards or track which squares opponents can still reach. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..23ff864 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +# Project-wide Gradle settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.parallel=true +org.gradle.caching=true + +# AndroidX +android.useAndroidX=true + +# Kotlin code style +kotlin.code.style=official + +# Only build the resources needed for the configuration being built. +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..86d7148 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ProLibertateGames" +include(":app")